diff --git a/docs/apps.md b/docs/apps.md index 3dc65e9..94f117a 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -96,12 +96,19 @@ export default function ExampleApp({ setTitle }: AppProps) { | 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, or a blank local draft when `id` is absent. **Not** a singleton | -| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` | +| 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 | | 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 | +### 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` 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 afe8f14..b6d3cfb 100644 --- a/src/apps/articles/index.tsx +++ b/src/apps/articles/index.tsx @@ -13,6 +13,7 @@ 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'; @@ -413,9 +414,17 @@ function ArticleView({ event }: { event: NostrEvent }) { )} -
- {event.content} -
+ +
+ {event.content} +
+
); } diff --git a/src/hooks/useHighlights.ts b/src/hooks/useHighlights.ts new file mode 100644 index 0000000..24949cf --- /dev/null +++ b/src/hooks/useHighlights.ts @@ -0,0 +1,61 @@ +import { useNostr } from '@nostrify/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { useNostrPublish } from './useNostrPublish'; + +/** NIP-84 "Highlights": a highlighted excerpt of a nostr event or other content. */ +export const HIGHLIGHT_KIND = 9802; + +function queryKey(address: string) { + return ['nostr', 'highlights', address] as const; +} + +/** Highlights tagged to an addressable event, e.g. a NIP-23 article's `kind:pubkey:d`. */ +export function useHighlights(address: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: queryKey(address ?? ''), + enabled: Boolean(address), + queryFn: async ({ signal }) => { + const events = await nostr.query( + [{ kinds: [HIGHLIGHT_KIND], '#a': [address!], limit: 100 }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) }, + ); + return events + .filter((event) => event.content.trim().length > 0) + .sort((a, b) => b.created_at - a.created_at); + }, + staleTime: 30_000, + }); +} + +export interface CreateHighlightInput { + /** The highlighted excerpt itself. */ + text: string; + /** `kind:pubkey:d-identifier` of the article being highlighted. */ + address: string; + /** The article's author, tagged per NIP-84 so the highlight credits them. */ + authorPubkey: string; +} + +export function useCreateHighlight() { + const publish = useNostrPublish(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ text, address, authorPubkey }: CreateHighlightInput) => { + return publish.mutateAsync({ + kind: HIGHLIGHT_KIND, + content: text, + tags: [ + ['a', address], + ['p', authorPubkey, '', 'author'], + ], + }); + }, + onSuccess: (_data, { address }) => { + queryClient.invalidateQueries({ queryKey: queryKey(address) }); + }, + }); +}