diff --git a/docs/apps.md b/docs/apps.md index 94f117a..f17a0fb 100644 --- a/docs/apps.md +++ b/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()` diff --git a/src/apps/web-bookmarks/index.tsx b/src/apps/web-bookmarks/index.tsx new file mode 100644 index 0000000..96239ca --- /dev/null +++ b/src/apps/web-bookmarks/index.tsx @@ -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 ; + } + + 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 = 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 ( +
+ setUrl(event.target.value)} + placeholder="https://…" + autoFocus + aria-invalid={url.length > 0 && !isValid} + /> + setTitle(event.target.value)} placeholder="Title (optional)" /> +