diff --git a/docs/apps.md b/docs/apps.md index e98da63..f17a0fb 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -89,14 +89,15 @@ export default function ExampleApp({ setTitle }: AppProps) { } ``` -## The eight apps +## The nine apps | App | `id` | Params | Notes | |---|---|---|---| | Feed | `feed` | — | kind 1 timeline, Following/Global, composer (⌘↵ publishes) | | 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` | +| 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 | @@ -110,6 +111,19 @@ the `d` tag is the URL itself (scheme stripped for `https`, see `bookmarkDTag` i 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()` +against the rendered article, not the raw markdown — the highlighted text saved to a kind +9802 event is whatever that `Selection`'s `.toString()` returns, i.e. the plain-text content +the reader actually saw, not markdown syntax. + +### Bookmarks are one whole-list replacement, like follow lists + +kind 10003 is a replaceable event: publishing it replaces the entire list. `useToggleBookmark` +(`src/hooks/useBookmarks.ts`) therefore reads the current list back before publishing an +update, the same trap [follow lists](#follow-lists-are-a-whole-list-replacement) have. + ### 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/articles/HighlightLayer.tsx b/src/apps/articles/HighlightLayer.tsx new file mode 100644 index 0000000..1aad1b4 --- /dev/null +++ b/src/apps/articles/HighlightLayer.tsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { Highlighter } from 'lucide-react'; +import { AppSectionTitle } from '@/components/os/AppChrome'; +import { AuthorLine } from '@/components/nostr/AuthorLine'; +import { useCreateHighlight, useHighlights } from '@/hooks/useHighlights'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; + +interface SelectionState { + text: string; + top: number; + left: number; +} + +/** + * Wraps article content with NIP-84 highlighting: selecting text shows a + * floating "Highlight" button (à la Medium/Kindle), and existing highlights + * from the network are listed underneath the article. + */ +export function HighlightLayer({ + address, + authorPubkey, + children, +}: { + /** `kind:pubkey:d-identifier` of the article being read. */ + address: string; + authorPubkey: string; + children: ReactNode; +}) { + const { user } = useCurrentUser(); + const containerRef = useRef(null); + const [selection, setSelection] = useState(null); + const create = useCreateHighlight(); + const { toast } = useToast(); + + useEffect(() => { + // No point tracking selection at all when highlighting can't happen — + // signed out, or the article has no usable address to attach one to. + // (Stale selection state from before either went missing is harmless: + // the button below also requires `user && address` to render.) + if (!user || !address) return; + + function handleSelectionChange() { + const sel = window.getSelection(); + const container = containerRef.current; + if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !container) { + setSelection(null); + return; + } + const range = sel.getRangeAt(0); + // commonAncestorContainer, not anchorNode: anchorNode is only where the + // selection *started*, so a selection that starts inside the article + // and is dragged out past its boundary would otherwise still pass. + if (!container.contains(range.commonAncestorContainer)) { + setSelection(null); + return; + } + const text = sel.toString().trim(); + if (!text) { + setSelection(null); + return; + } + const rect = range.getBoundingClientRect(); + setSelection({ text, top: rect.top, left: rect.left + rect.width / 2 }); + } + + document.addEventListener('selectionchange', handleSelectionChange); + return () => document.removeEventListener('selectionchange', handleSelectionChange); + }, [user, address]); + + const handleHighlight = async () => { + if (!selection || !address) return; + const { text } = selection; + window.getSelection()?.removeAllRanges(); + setSelection(null); + try { + await create.mutateAsync({ text, address, authorPubkey }); + toast({ title: 'Highlighted' }); + } catch (error) { + toast({ + title: 'Could not save highlight', + description: error instanceof Error ? error.message : 'No relay accepted it.', + variant: 'destructive', + }); + } + }; + + return ( + <> +
+ {children} + + {user && address && selection && ( + + )} +
+ + + + ); +} + +function HighlightsList({ address }: { address: string }) { + const highlights = useHighlights(address); + + if (!highlights.data || highlights.data.length === 0) return null; + + return ( +
+ + {highlights.data.length} {highlights.data.length === 1 ? 'Highlight' : 'Highlights'} + +
    + {highlights.data.map((event) => ( +
  • +
    “{event.content}”
    + +
  • + ))} +
+
+ ); +} diff --git a/src/apps/articles/index.tsx b/src/apps/articles/index.tsx index 831472b..b6d3cfb 100644 --- a/src/apps/articles/index.tsx +++ b/src/apps/articles/index.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; -import { ChevronLeft, Link2 } from 'lucide-react'; +import { Bookmark, ChevronLeft, Link2, Rss } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { AppBody, @@ -13,11 +13,15 @@ import { EmptyState, } from '@/components/os/AppChrome'; import { AuthorLine } from '@/components/nostr/AuthorLine'; +import { HighlightLayer } from './HighlightLayer'; +import { BookmarkButton } from '@/components/nostr/BookmarkButton'; import { Markdown } from './Markdown'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { nip19 } from 'nostr-tools'; import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useMyBookmarkedArticles } from '@/hooks/useBookmarks'; import { absoluteTime, decodeRelayHints, @@ -32,6 +36,8 @@ import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import type { AppParams, AppProps } from '@/os/types'; +type ListScope = 'recent' | 'bookmarked'; + const ARTICLE_KIND = 30023; /** A long-form event is only useful with a body and a `d` identifier (NIP-23). */ @@ -84,6 +90,13 @@ function useArticle( export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { const isMobile = useIsMobile(); + const { user } = useCurrentUser(); + + // Signing out mid-session must not strand the reader on a "Bookmarked" tab + // it can no longer see anything in, so the effective scope is derived + // rather than corrected after render — same reasoning as the Feed. + const [requestedScope, setRequestedScope] = useState('recent'); + const scope: ListScope = user ? requestedScope : 'recent'; // The selection lives in the window's params rather than local state, so the // URL, a reload and the switch between the desktop and mobile shells all @@ -102,7 +115,9 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { [setParams], ); - const list = useRecentArticles(); + const recent = useRecentArticles(); + const bookmarked = useMyBookmarkedArticles(); + const list = scope === 'recent' ? recent : bookmarked; const article = useArticle( selected?.pubkey, selected?.identifier, @@ -116,13 +131,19 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { }, [title, setTitle]); const listPane = ( - - select({ pubkey: event.pubkey, identifier, kind: String(event.kind) }) - } - /> +
+ +
+ + select({ pubkey: event.pubkey, identifier, kind: String(event.kind) }) + } + /> +
+
); const readerPane = !selected ? ( @@ -173,7 +194,19 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) { {title ?? 'Long-form articles'} - {article.data && } + {article.data && ( +
+ {tagValue(article.data, 'd') && ( + + )} + +
+ )} @@ -192,7 +225,7 @@ function CopyArticleLink({ event }: { event: NostrEvent }) { + ); +} + function ArticleList({ query, + scope, selected, onSelect, }: { query: ReturnType; + scope: ListScope; selected: { pubkey: string; identifier: string } | null; onSelect: (event: NostrEvent, identifier: string) => void; }) { @@ -236,14 +331,14 @@ function ArticleList({ if (!query.data || query.data.length === 0) { return (

- No articles on your relays. + {scope === 'bookmarked' ? 'You haven’t bookmarked any articles yet.' : 'No articles on your relays.'}

); } return ( <> - Recent + {scope === 'bookmarked' ? 'Bookmarked' : 'Recent'}
    {query.data.map((event) => { const identifier = tagValue(event, 'd')!; @@ -319,9 +414,17 @@ function ArticleView({ event }: { event: NostrEvent }) { )} -
    - {event.content} -
    + +
    + {event.content} +
    +
    ); } diff --git a/src/apps/bookmarks/index.tsx b/src/apps/bookmarks/index.tsx new file mode 100644 index 0000000..dbe4dea --- /dev/null +++ b/src/apps/bookmarks/index.tsx @@ -0,0 +1,142 @@ +import { useEffect } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { AppBody, AppLayout, AppSectionTitle, AppToolbar, EmptyState } from '@/components/os/AppChrome'; +import { LoginRequired } from '@/components/nostr/LoginRequired'; +import { NoteCard } from '@/components/nostr/NoteCard'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useBookmarkedNoteIds, useMyBookmarkedArticles } from '@/hooks/useBookmarks'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useWindowManager } from '@/os/useWindowManager'; +import { displayName, relativeTime, tagValue } from '@/lib/nostrUtils'; +import type { AppProps } from '@/os/types'; + +function useBookmarkedNotes(ids: string[]) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['nostr', 'bookmarked-notes', ids.join(',')], + enabled: ids.length > 0, + queryFn: async ({ signal }) => { + const events = await nostr.query([{ ids, limit: ids.length }], { + signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]), + }); + const order = new Map(ids.map((id, index) => [id, index])); + return [...events].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); + }, + staleTime: 60_000, + }); +} + +export default function BookmarksApp({ setTitle }: AppProps) { + const { user } = useCurrentUser(); + const { openApp } = useWindowManager(); + + useEffect(() => setTitle('Bookmarks'), [setTitle]); + + const noteIds = useBookmarkedNoteIds(); + const notes = useBookmarkedNotes(noteIds); + const articles = useMyBookmarkedArticles(); + + if (!user) { + return ; + } + + const isLoading = (noteIds.length > 0 && notes.isLoading) || articles.isLoading; + // React Query leaves `data` undefined on a failed query too, so an error + // must be checked before treating "no data" as "no bookmarks" — otherwise + // a relay/network failure reads as an empty list. + const isError = notes.isError || articles.isError; + const isEmpty = + !isLoading && !isError && (notes.data?.length ?? 0) === 0 && (articles.data?.length ?? 0) === 0; + + return ( + + + Bookmarks + + + + {isLoading ? ( +
    + {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
    + ) : isError ? ( + { + notes.refetch(); + articles.refetch(); + }} + > + Try again + + } + /> + ) : isEmpty ? ( + + ) : ( + <> + {notes.data && notes.data.length > 0 && ( + <> + Notes + {notes.data.map((event) => ( + + ))} + + )} + {articles.data && articles.data.length > 0 && ( + <> + Articles + {articles.data.map((event) => ( + + openApp('articles', { + pubkey: event.pubkey, + identifier: tagValue(event, 'd') ?? '', + kind: String(event.kind), + }) + } + /> + ))} + + )} + + )} +
    +
    + ); +} + +function ArticleRow({ event, onOpen }: { event: NostrEvent; onOpen: () => void }) { + const author = useAuthor(event.pubkey); + const title = tagValue(event, 'title') ?? tagValue(event, 'd') ?? 'Untitled'; + + return ( + + ); +} diff --git a/src/apps/feed/index.tsx b/src/apps/feed/index.tsx index dc25322..63e676e 100644 --- a/src/apps/feed/index.tsx +++ b/src/apps/feed/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; -import { Globe, Loader2, Users } from 'lucide-react'; +import { FileText, Globe, Loader2, Users } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome'; import { NoteCard } from '@/components/nostr/NoteCard'; @@ -11,6 +11,8 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMyFollows } from '@/hooks/useFollows'; import { cn } from '@/lib/utils'; +import { useWindowManager } from '@/os/useWindowManager'; +import { isReply } from '@/lib/nostrUtils'; import type { AppProps } from '@/os/types'; type Scope = 'following' | 'global'; @@ -20,9 +22,17 @@ const PAGE_SIZE = 50; /** * A kind 1 event is only worth rendering if it has something to render. Relays * happily return blanks and oddities, so the feed validates before it draws. + * Replies (NIP-10 `e` tags) are excluded too: without their parent for + * context they read as indistinguishable, orphaned root posts — open the + * thread from the Note app instead. */ function isRenderableNote(event: NostrEvent): boolean { - return event.kind === 1 && typeof event.content === 'string' && event.content.trim().length > 0; + return ( + event.kind === 1 && + typeof event.content === 'string' && + event.content.trim().length > 0 && + !isReply(event) + ); } function useFeed(scope: Scope, authors: string[] | undefined) { @@ -51,6 +61,7 @@ function useFeed(scope: Scope, authors: string[] | undefined) { export default function FeedApp({ setTitle }: AppProps) { const { user } = useCurrentUser(); + const { openApp } = useWindowManager(); const { data: follows } = useMyFollows(); const [requestedScope, setScope] = useState('following'); @@ -85,6 +96,15 @@ export default function FeedApp({ setTitle }: AppProps) { />
    {query.isFetching && } + + )} + {user && ( + + )} +
    + + + +