mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 14:14:07 +02:00
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
This commit is contained in:
11
docs/apps.md
11
docs/apps.md
@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
|
||||
}
|
||||
```
|
||||
|
||||
## The seven apps
|
||||
## The eight apps
|
||||
|
||||
| App | `id` | Params | Notes |
|
||||
|---|---|---|---|
|
||||
@@ -97,10 +97,19 @@ export default function ExampleApp({ setTitle }: AppProps) {
|
||||
| Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow |
|
||||
| Note | `notes` | `id`, `relays?` | One note and its replies. **Not** a singleton |
|
||||
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` |
|
||||
| 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.
|
||||
|
||||
### Follow lists are a whole-list replacement
|
||||
|
||||
kind 3 replaces the entire contact list. The follow button therefore reads the current
|
||||
|
||||
205
src/apps/web-bookmarks/index.tsx
Normal file
205
src/apps/web-bookmarks/index.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
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';
|
||||
|
||||
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 = /^https?:\/\/.+/i.test(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') ?? '';
|
||||
const url = sanitizeUrl(bookmarkUrl(dTag)) ?? 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">
|
||||
<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="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>
|
||||
);
|
||||
}
|
||||
23
src/hooks/useWebBookmarks.test.ts
Normal file
23
src/hooks/useWebBookmarks.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bookmarkDTag, bookmarkUrl } from './useWebBookmarks';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
118
src/hooks/useWebBookmarks.ts
Normal file
118
src/hooks/useWebBookmarks.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
*/
|
||||
export function bookmarkDTag(url: string): string {
|
||||
return url.startsWith('https://') ? url.slice('https://'.length) : url;
|
||||
}
|
||||
|
||||
/** Reconstructs a clickable URL from a `d` tag written by `bookmarkDTag`. */
|
||||
export function bookmarkUrl(dTag: string): string {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(dTag) ? dTag : `https://${dTag}`;
|
||||
}
|
||||
|
||||
export interface WebBookmarkInput {
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
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 events
|
||||
.filter((event) => Boolean(tagValue(event, 'd')))
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWebBookmark() {
|
||||
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 eventTags: string[][] = [['d', bookmarkDTag(url)]];
|
||||
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', String(Math.floor(Date.now() / 1000))]);
|
||||
|
||||
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, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react';
|
||||
import { Activity, BookOpen, FileText, Info, Link2, Rss, Settings, User } from 'lucide-react';
|
||||
import type { AppDefinition } from './types';
|
||||
|
||||
/**
|
||||
@@ -49,6 +49,16 @@ export const APPS: AppDefinition[] = [
|
||||
defaultSize: { width: 780, height: 760 },
|
||||
minSize: { width: 360, height: 320 },
|
||||
},
|
||||
{
|
||||
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