mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
feat: web bookmarks for arbitrary URLs (NIP-B0) (#30)
* feat: web bookmarks for arbitrary URLs (NIP-B0) Adds a Web Bookmarks app backed by NIP-B0 (kind 39701): one addressable event per saved URL, distinct from the NIP-51 bookmark list (#21) since it carries its own title/description/tags per page rather than being an entry in a list. - src/hooks/useWebBookmarks.ts: create/list/delete, plus bookmarkDTag/bookmarkUrl implementing the spec's "strip https://" d-tag rule (round-tripped by a unit test). - Delete publishes a NIP-09 kind 5 request and also drops the item from the local query cache directly, since relays aren't obligated to honor the deletion. - New src/apps/web-bookmarks/index.tsx: inline add form, list with title/description/tags, opens the saved URL in a new tab. Closes #25 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * fix: address review feedback on web bookmarks Per review: - bookmarkDTag() now matches the https scheme case-insensitively, so "HTTPS://…" and "https://…" collapse to the same d tag instead of creating duplicate bookmarks. - The form now accepts every scheme sanitizeUrl() allows (https, http, mailto, nostr) via a dedicated isBookmarkableUrl() check — not sanitizeUrl() itself, which resolves relative URLs against this app's own origin and would have "validated" a bare hostname like "example.com" as a link back into the app. - WebBookmarkRow no longer falls back to the raw unsanitized URL when sanitizeUrl() rejects it (e.g. a malicious "d" tag) — it renders plain text with no link instead of defeating the sanitization. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * fix: round-trip mailto:/nostr: bookmarks and preserve published_at Per review: - bookmarkUrl() required "scheme://" to recognize an already-schemed d tag, so opaque URIs with no "//" — mailto: and nostr: — were incorrectly prefixed with "https://". Fixed by also checking a closed list of the opaque schemes this app supports, alongside the existing "://" check (kept as-is so a hierarchical scheme like gemini:// still round-trips, and so a stripped https URL containing a port, e.g. alice.blog:8080/post, still isn't misread as scheme "alice.blog"). Added regression tests for all three cases. - useCreateWebBookmark now looks up the existing bookmark for the same d tag before publishing and carries its published_at forward, instead of resetting it to now on every edit — per NIP-B0, published_at is "the first time the bookmark was published." Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * fix: dedupe multiple revisions of the same web bookmark Per an earlier "previously missed" finding: useMyWebBookmarks() returned every kind-39701 event a relay handed back, but for an addressable event the pool can return more than one revision of the same d tag (an edit history, or relays disagreeing on what's current), which showed up as duplicate rows for the same URL. Extracted dedupeLatestByDTag() (keeps the newest per d, newest-first) and covered it with regression tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --------- Co-authored-by: highperfocused <highperfocused@pm.me> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
11
docs/apps.md
11
docs/apps.md
@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
|
||||
}
|
||||
```
|
||||
|
||||
## The eight apps
|
||||
## The nine apps
|
||||
|
||||
| App | `id` | Params | Notes |
|
||||
|---|---|---|---|
|
||||
@@ -98,10 +98,19 @@ export default function ExampleApp({ setTitle }: AppProps) {
|
||||
| Note | `notes` | `id?`, `relays?` | One note and its replies, or a blank local draft when `id` is absent. **Not** a singleton |
|
||||
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown`, NIP-84 highlights |
|
||||
| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles |
|
||||
| Web Bookmarks | `web-bookmarks` | — | NIP-B0 kind 39701 — one addressable event per saved URL |
|
||||
| Relays | `relays` | — | Connection state, subscription count, measured latency |
|
||||
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
|
||||
| About | `about` | — | What this is, the app list, the shortcuts |
|
||||
|
||||
### Web bookmarks are one event per URL, not a list
|
||||
|
||||
Unlike a NIP-51 list, each NIP-B0 web bookmark (kind 39701) is its own addressable event —
|
||||
the `d` tag is the URL itself (scheme stripped for `https`, see `bookmarkDTag` in
|
||||
`src/hooks/useWebBookmarks.ts`). Removing one publishes a NIP-09 kind 5 deletion request,
|
||||
which relays are free to ignore, so the client also drops it from its own query cache
|
||||
rather than trusting a refetch to reflect it.
|
||||
|
||||
### Highlighting selects against the DOM, not the markdown source
|
||||
|
||||
`HighlightLayer` (`src/apps/articles/HighlightLayer.tsx`) tracks `window.getSelection()`
|
||||
|
||||
230
src/apps/web-bookmarks/index.tsx
Normal file
230
src/apps/web-bookmarks/index.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { ExternalLink, Loader2, Plus, X } from 'lucide-react';
|
||||
import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome';
|
||||
import { LoginRequired } from '@/components/nostr/LoginRequired';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
bookmarkUrl,
|
||||
useCreateWebBookmark,
|
||||
useDeleteWebBookmark,
|
||||
useMyWebBookmarks,
|
||||
webBookmarkTopics,
|
||||
} from '@/hooks/useWebBookmarks';
|
||||
import { relativeTime, sanitizeUrl, tagValue } from '@/lib/nostrUtils';
|
||||
import type { AppProps } from '@/os/types';
|
||||
|
||||
/**
|
||||
* Same protocol allowlist as sanitizeUrl(), but for an absolute bookmark URL
|
||||
* rather than an href/src that may legitimately be relative to this app's
|
||||
* own origin — sanitizeUrl() would resolve a bare "example.com" against
|
||||
* `window.location.origin` and "validate" it as a link back into this app.
|
||||
*/
|
||||
const BOOKMARKABLE_SCHEMES = new Set(['https:', 'http:', 'mailto:', 'nostr:']);
|
||||
function isBookmarkableUrl(value: string): boolean {
|
||||
try {
|
||||
return BOOKMARKABLE_SCHEMES.has(new URL(value).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function WebBookmarksApp({ setTitle }: AppProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
|
||||
useEffect(() => setTitle('Web Bookmarks'), [setTitle]);
|
||||
|
||||
const bookmarks = useMyWebBookmarks();
|
||||
|
||||
if (!user) {
|
||||
return <LoginRequired action="save web bookmarks" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<AppToolbar>
|
||||
<span className="text-[13px] font-medium">Web Bookmarks</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-7 gap-1.5 px-2 text-xs"
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
>
|
||||
{formOpen ? <X className="size-3.5" aria-hidden /> : <Plus className="size-3.5" aria-hidden />}
|
||||
{formOpen ? 'Cancel' : 'Add bookmark'}
|
||||
</Button>
|
||||
</AppToolbar>
|
||||
|
||||
<AppBody>
|
||||
{formOpen && <NewBookmarkForm onDone={() => setFormOpen(false)} />}
|
||||
|
||||
{bookmarks.isLoading ? (
|
||||
<div className="space-y-3 p-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : bookmarks.data && bookmarks.data.length > 0 ? (
|
||||
bookmarks.data.map((event) => <WebBookmarkRow key={event.id} event={event} />)
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No web bookmarks yet"
|
||||
hint="Save a link and it will show up here."
|
||||
action={
|
||||
!formOpen && (
|
||||
<Button size="sm" onClick={() => setFormOpen(true)}>
|
||||
Add a bookmark
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AppBody>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function NewBookmarkForm({ onDone }: { onDone: () => void }) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const create = useCreateWebBookmark();
|
||||
const { toast } = useToast();
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const isValid = isBookmarkableUrl(trimmedUrl);
|
||||
|
||||
const submit = async () => {
|
||||
if (!isValid) return;
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
url: trimmedUrl,
|
||||
title: title.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
tags: tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
toast({ title: 'Bookmark saved' });
|
||||
onDone();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not save bookmark',
|
||||
description: error instanceof Error ? error.message : 'No relay accepted the update.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5 border-b border-border px-4 py-3">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://…"
|
||||
autoFocus
|
||||
aria-invalid={url.length > 0 && !isValid}
|
||||
/>
|
||||
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Title (optional)" />
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
rows={2}
|
||||
className="min-h-16 resize-none text-[14px]"
|
||||
/>
|
||||
<Input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
placeholder="Tags, comma separated (optional)"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={submit} disabled={!isValid || create.isPending} className="gap-1.5">
|
||||
{create.isPending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
Save bookmark
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebBookmarkRow({ event }: { event: NostrEvent }) {
|
||||
const del = useDeleteWebBookmark();
|
||||
const { toast } = useToast();
|
||||
|
||||
const dTag = tagValue(event, 'd') ?? '';
|
||||
// sanitizeUrl() returning undefined means the reconstructed URL uses a
|
||||
// protocol that could execute script (e.g. a malicious "d" tag) — in that
|
||||
// case there is no safe href to link out to, full stop, not a fallback to
|
||||
// the very value that just failed sanitization.
|
||||
const url = sanitizeUrl(bookmarkUrl(dTag));
|
||||
const title = tagValue(event, 'title') ?? dTag;
|
||||
const topics = webBookmarkTopics(event);
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await del.mutateAsync(event);
|
||||
toast({ title: 'Bookmark removed' });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not remove bookmark',
|
||||
description: error instanceof Error ? error.message : 'No relay accepted the update.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="group border-b border-border px-4 py-3 transition-colors last:border-b-0 hover:bg-muted/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-w-0 items-center gap-1.5 text-[14px] font-medium hover:underline"
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</a>
|
||||
) : (
|
||||
<span className="truncate text-[14px] font-medium text-muted-foreground" title="Not a safe URL to open">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{relativeTime(event.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{dTag}</p>
|
||||
|
||||
{event.content && <p className="mt-1.5 text-[13px] leading-relaxed">{event.content}</p>}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{topics.map((topic) => (
|
||||
<Badge key={topic} variant="secondary" className="text-[11px]">
|
||||
{topic}
|
||||
</Badge>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-6 gap-1 px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
|
||||
onClick={handleDelete}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
{del.isPending ? <Loader2 className="size-3 animate-spin" aria-hidden /> : <X className="size-3" aria-hidden />}
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
75
src/hooks/useWebBookmarks.test.ts
Normal file
75
src/hooks/useWebBookmarks.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { bookmarkDTag, bookmarkUrl, dedupeLatestByDTag } from './useWebBookmarks';
|
||||
|
||||
function bookmarkEvent(dTag: string, createdAt: number, id = `${dTag}-${createdAt}`): NostrEvent {
|
||||
return { id, pubkey: 'author', created_at: createdAt, kind: 39701, tags: [['d', dTag]], content: '', sig: '' };
|
||||
}
|
||||
|
||||
describe('bookmarkDTag / bookmarkUrl', () => {
|
||||
it('strips the https scheme per NIP-B0', () => {
|
||||
expect(bookmarkDTag('https://alice.blog/post')).toBe('alice.blog/post');
|
||||
});
|
||||
|
||||
it('keeps non-https schemes in full', () => {
|
||||
expect(bookmarkDTag('http://alice.blog/post')).toBe('http://alice.blog/post');
|
||||
expect(bookmarkDTag('gemini://example.com/')).toBe('gemini://example.com/');
|
||||
});
|
||||
|
||||
it('round-trips an https URL back through bookmarkUrl', () => {
|
||||
const url = 'https://alice.blog/post';
|
||||
expect(bookmarkUrl(bookmarkDTag(url))).toBe(url);
|
||||
});
|
||||
|
||||
it('round-trips a non-https URL back through bookmarkUrl', () => {
|
||||
const url = 'http://alice.blog/post';
|
||||
expect(bookmarkUrl(bookmarkDTag(url))).toBe(url);
|
||||
});
|
||||
|
||||
it('strips the https scheme case-insensitively, so casing does not create duplicate bookmarks', () => {
|
||||
expect(bookmarkDTag('HTTPS://alice.blog/post')).toBe('alice.blog/post');
|
||||
expect(bookmarkDTag('HtTpS://alice.blog/post')).toBe('alice.blog/post');
|
||||
expect(bookmarkDTag('HTTPS://alice.blog/post')).toBe(bookmarkDTag('https://alice.blog/post'));
|
||||
});
|
||||
|
||||
it('round-trips schemes with no "//", like mailto: and nostr:, instead of prefixing them with https://', () => {
|
||||
expect(bookmarkUrl(bookmarkDTag('mailto:hello@example.com'))).toBe('mailto:hello@example.com');
|
||||
expect(bookmarkUrl(bookmarkDTag('nostr:npub1abc'))).toBe('nostr:npub1abc');
|
||||
});
|
||||
|
||||
it('round-trips a hierarchical non-https scheme like gemini://', () => {
|
||||
const url = 'gemini://example.com/';
|
||||
expect(bookmarkUrl(bookmarkDTag(url))).toBe(url);
|
||||
});
|
||||
|
||||
it('does not mistake a port number in a stripped https URL for a scheme', () => {
|
||||
expect(bookmarkUrl('alice.blog:8080/post')).toBe('https://alice.blog:8080/post');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeLatestByDTag', () => {
|
||||
it('keeps only the newest event for each d tag', () => {
|
||||
const older = bookmarkEvent('alice.blog/post', 100);
|
||||
const newer = bookmarkEvent('alice.blog/post', 200);
|
||||
const result = dedupeLatestByDTag([older, newer]);
|
||||
expect(result).toEqual([newer]);
|
||||
});
|
||||
|
||||
it('is order-independent', () => {
|
||||
const older = bookmarkEvent('alice.blog/post', 100);
|
||||
const newer = bookmarkEvent('alice.blog/post', 200);
|
||||
expect(dedupeLatestByDTag([newer, older])).toEqual([newer]);
|
||||
});
|
||||
|
||||
it('keeps distinct d tags separately, sorted newest first', () => {
|
||||
const a = bookmarkEvent('a.com', 100);
|
||||
const b = bookmarkEvent('b.com', 300);
|
||||
const c = bookmarkEvent('c.com', 200);
|
||||
expect(dedupeLatestByDTag([a, b, c])).toEqual([b, c, a]);
|
||||
});
|
||||
|
||||
it('drops events with no d tag', () => {
|
||||
const noD: NostrEvent = { id: 'x', pubkey: 'author', created_at: 0, kind: 39701, tags: [], content: '', sig: '' };
|
||||
expect(dedupeLatestByDTag([noD])).toEqual([]);
|
||||
});
|
||||
});
|
||||
158
src/hooks/useWebBookmarks.ts
Normal file
158
src/hooks/useWebBookmarks.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { tagValue, tagValues } from '@/lib/nostrUtils';
|
||||
|
||||
/** NIP-B0 "Web Bookmarking": one addressable event per bookmarked URL. */
|
||||
export const WEB_BOOKMARK_KIND = 39701;
|
||||
|
||||
function queryKey(pubkey: string | undefined) {
|
||||
return ['nostr', 'web-bookmarks', pubkey ?? ''] as const;
|
||||
}
|
||||
|
||||
const HTTPS_SCHEME_RE = /^https:\/\//i;
|
||||
|
||||
/**
|
||||
* The `d` tag per NIP-B0: the URI with the `https://` scheme stripped (every
|
||||
* other scheme keeps its full form, so it round-trips through `bookmarkUrl`).
|
||||
* The scheme match is case-insensitive so "HTTPS://" and "https://" collapse
|
||||
* to the same `d` tag instead of creating duplicate bookmarks.
|
||||
*/
|
||||
export function bookmarkDTag(url: string): string {
|
||||
return HTTPS_SCHEME_RE.test(url) ? url.slice('https://'.length) : url;
|
||||
}
|
||||
|
||||
/** Hierarchical URIs, e.g. `http://…` or `gemini://…` — the `//` is what rules out a false match on a stripped https URL that happens to contain a port, e.g. `alice.blog:8080/post`. */
|
||||
const HIERARCHICAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
/** Non-hierarchical URIs with no `//`, e.g. `mailto:` and `nostr:` — not matched by the pattern above. */
|
||||
const OPAQUE_SCHEMES = ['mailto:', 'nostr:'];
|
||||
|
||||
/** Reconstructs a clickable URL from a `d` tag written by `bookmarkDTag`. */
|
||||
export function bookmarkUrl(dTag: string): string {
|
||||
const lower = dTag.toLowerCase();
|
||||
const alreadyHasScheme =
|
||||
HIERARCHICAL_SCHEME_RE.test(dTag) || OPAQUE_SCHEMES.some((scheme) => lower.startsWith(scheme));
|
||||
return alreadyHasScheme ? dTag : `https://${dTag}`;
|
||||
}
|
||||
|
||||
export interface WebBookmarkInput {
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Addressable events: relays across the pool can hand back more than one
|
||||
* revision of the same `d` tag (an edit history, or just multiple relays
|
||||
* disagreeing on what's current). Keeps only the newest per `d`, newest first.
|
||||
*/
|
||||
export function dedupeLatestByDTag(events: NostrEvent[]): NostrEvent[] {
|
||||
const latest = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const dTag = tagValue(event, 'd');
|
||||
if (!dTag) continue;
|
||||
const current = latest.get(dTag);
|
||||
if (!current || event.created_at > current.created_at) latest.set(dTag, event);
|
||||
}
|
||||
return [...latest.values()].sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
export function useMyWebBookmarks() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useQuery<NostrEvent[]>({
|
||||
queryKey: queryKey(user?.pubkey),
|
||||
enabled: Boolean(user),
|
||||
queryFn: async ({ signal }) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [WEB_BOOKMARK_KIND], authors: [user!.pubkey], limit: 200 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
|
||||
);
|
||||
return dedupeLatestByDTag(events);
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWebBookmark() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const publish = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ url, title, description, tags }: WebBookmarkInput) => {
|
||||
if (!user) throw new Error('Sign in to bookmark a page');
|
||||
|
||||
const dTag = bookmarkDTag(url);
|
||||
// Re-bookmarking an already-saved URL is an edit of the same
|
||||
// addressable event, not a new bookmark — published_at per NIP-B0 is
|
||||
// "the first time the bookmark was published", so it must carry over
|
||||
// rather than being reset to now on every edit.
|
||||
const [existing] = await nostr.query(
|
||||
[{ kinds: [WEB_BOOKMARK_KIND], authors: [user.pubkey], '#d': [dTag], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(6000) },
|
||||
);
|
||||
const publishedAt = existing
|
||||
? (tagValue(existing, 'published_at') ?? String(existing.created_at))
|
||||
: String(Math.floor(Date.now() / 1000));
|
||||
|
||||
const eventTags: string[][] = [['d', dTag]];
|
||||
if (title?.trim()) eventTags.push(['title', title.trim()]);
|
||||
for (const tag of tags ?? []) {
|
||||
if (tag.trim()) eventTags.push(['t', tag.trim().toLowerCase()]);
|
||||
}
|
||||
eventTags.push(['published_at', publishedAt]);
|
||||
|
||||
return publish.mutateAsync({
|
||||
kind: WEB_BOOKMARK_KIND,
|
||||
content: description?.trim() ?? '',
|
||||
tags: eventTags,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKey(user?.pubkey) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A NIP-09 deletion request. Relays are free to ignore it — deletion is a
|
||||
* request, not a guarantee — so the removed bookmark is also dropped from
|
||||
* the local cache directly rather than waiting on a relay refetch that may
|
||||
* still hand it back.
|
||||
*/
|
||||
export function useDeleteWebBookmark() {
|
||||
const { user } = useCurrentUser();
|
||||
const publish = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (event: NostrEvent) => {
|
||||
if (!user) throw new Error('Sign in to remove a bookmark');
|
||||
const dTag = tagValue(event, 'd') ?? '';
|
||||
return publish.mutateAsync({
|
||||
kind: 5,
|
||||
content: '',
|
||||
tags: [
|
||||
['a', `${WEB_BOOKMARK_KIND}:${user.pubkey}:${dTag}`],
|
||||
['e', event.id],
|
||||
['k', String(WEB_BOOKMARK_KIND)],
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, removed) => {
|
||||
queryClient.setQueryData<NostrEvent[]>(queryKey(user?.pubkey), (current) =>
|
||||
current?.filter((event) => event.id !== removed.id),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function webBookmarkTopics(event: NostrEvent): string[] {
|
||||
return tagValues(event, 't');
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy } from 'react';
|
||||
import { Activity, Bookmark, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react';
|
||||
import { Activity, Bookmark, BookOpen, FileText, Info, Link2, Rss, Settings, User } from 'lucide-react';
|
||||
import type { AppDefinition } from './types';
|
||||
|
||||
/**
|
||||
@@ -59,6 +59,16 @@ export const APPS: AppDefinition[] = [
|
||||
defaultSize: { width: 600, height: 660 },
|
||||
minSize: { width: 340, height: 300 },
|
||||
},
|
||||
{
|
||||
id: 'web-bookmarks',
|
||||
title: 'Web Bookmarks',
|
||||
description: 'Links you have saved from around the web',
|
||||
icon: Link2,
|
||||
category: 'social',
|
||||
component: lazy(() => import('@/apps/web-bookmarks')),
|
||||
defaultSize: { width: 600, height: 660 },
|
||||
minSize: { width: 340, height: 300 },
|
||||
},
|
||||
{
|
||||
id: 'relays',
|
||||
title: 'Relays',
|
||||
|
||||
Reference in New Issue
Block a user