diff --git a/docs/apps.md b/docs/apps.md index 6cc09bf..e98da63 100644 --- a/docs/apps.md +++ b/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 diff --git a/src/apps/web-bookmarks/index.tsx b/src/apps/web-bookmarks/index.tsx new file mode 100644 index 0000000..6e9fcd2 --- /dev/null +++ b/src/apps/web-bookmarks/index.tsx @@ -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 ; + } + + return ( + + + Web Bookmarks + + + + + {formOpen && setFormOpen(false)} />} + + {bookmarks.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ) : bookmarks.data && bookmarks.data.length > 0 ? ( + bookmarks.data.map((event) => ) + ) : ( + setFormOpen(true)}> + Add a bookmark + + ) + } + /> + )} +
+
+ ); +} + +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 ( +
+ setUrl(event.target.value)} + placeholder="https://…" + autoFocus + aria-invalid={url.length > 0 && !isValid} + /> + setTitle(event.target.value)} placeholder="Title (optional)" /> +