feat: highlight text in the Reader (NIP-84)

Adds NIP-84 highlights (kind 9802) to the article reader:
- Selecting text in an article shows a floating "Highlight" button
  (src/apps/articles/HighlightLayer.tsx), publishing the selected
  plain text tagged to the article (`a`) and its author (`p`, role
  "author").
- Existing highlights for the article are listed underneath it, with
  the highlighter's identity and timestamp.

Closes #23

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-06 13:52:19 +02:00
parent 809077d054
commit c56e4fbf02
4 changed files with 199 additions and 4 deletions

View File

@@ -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

View File

@@ -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<HTMLDivElement>(null);
const [selection, setSelection] = useState<SelectionState | null>(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 (
<>
<div ref={containerRef} className="relative">
{children}
{user && selection && (
<button
type="button"
// Selection collapses on mousedown before onClick fires unless
// that default is prevented — the button would otherwise vanish
// the instant it's pressed.
onMouseDown={(event) => event.preventDefault()}
onClick={handleHighlight}
style={{ position: 'fixed', top: selection.top - 40, left: selection.left, transform: 'translateX(-50%)' }}
className="z-50 flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs font-medium text-background shadow-lg"
>
<Highlighter className="size-3.5" aria-hidden />
Highlight
</button>
)}
</div>
<HighlightsList address={address} />
</>
);
}
function HighlightsList({ address }: { address: string }) {
const highlights = useHighlights(address);
if (!highlights.data || highlights.data.length === 0) return null;
return (
<section className="mt-10 border-t border-border pt-6">
<AppSectionTitle>
{highlights.data.length} {highlights.data.length === 1 ? 'Highlight' : 'Highlights'}
</AppSectionTitle>
<ul className="space-y-4">
{highlights.data.map((event) => (
<li key={event.id} className="border-l-2 border-primary/40 pl-3">
<blockquote className="text-[14px] italic leading-relaxed">{event.content}</blockquote>
<AuthorLine pubkey={event.pubkey} createdAt={event.created_at} size="sm" className="mt-1.5" />
</li>
))}
</ul>
</section>
);
}

View File

@@ -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 }) {
)}
</header>
<div className="mt-6 text-[15px]">
<Markdown>{event.content}</Markdown>
</div>
<HighlightLayer
address={`${event.kind}:${event.pubkey}:${tagValue(event, 'd') ?? ''}`}
authorPubkey={event.pubkey}
>
<div className="mt-6 text-[15px]">
<Markdown>{event.content}</Markdown>
</div>
</HighlightLayer>
</article>
);
}

View File

@@ -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<NostrEvent[]>({
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) });
},
});
}