diff --git a/docs/apps.md b/docs/apps.md index 6cc09bf..5ede84e 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -96,11 +96,18 @@ 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. **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 | | 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 `Range.toString()` returns, i.e. the plain-text content the reader +actually saw, not markdown syntax. + ### 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..acd3743 --- /dev/null +++ b/src/apps/articles/HighlightLayer.tsx @@ -0,0 +1,121 @@ +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(() => { + function handleSelectionChange() { + const sel = window.getSelection(); + const container = containerRef.current; + if (!sel || sel.isCollapsed || !container || !container.contains(sel.anchorNode)) { + setSelection(null); + return; + } + const text = sel.toString().trim(); + if (!text) { + setSelection(null); + return; + } + const rect = sel.getRangeAt(0).getBoundingClientRect(); + setSelection({ text, top: rect.top, left: rect.left + rect.width / 2 }); + } + + document.addEventListener('selectionchange', handleSelectionChange); + return () => document.removeEventListener('selectionchange', handleSelectionChange); + }, []); + + const handleHighlight = async () => { + if (!selection) 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 && 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..d5a5dc6 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 { Markdown } from './Markdown'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -319,9 +320,14 @@ 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) }); + }, + }); +}