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

* 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

* fix: harden selection handling and clamp the highlight button

Per review:
- Guard sel.rangeCount === 0 before calling getRangeAt(0), which
  throws otherwise.
- Scope containment by the range's commonAncestorContainer instead of
  just anchorNode, so a selection that starts inside the article but
  is dragged out past its boundary is correctly rejected.
- Clamp the floating button's top so a selection near the top of the
  viewport doesn't push it off-screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: don't highlight against a malformed address, gate the listener

Per a "needs a closer look" review pass:
- articles/index.tsx now passes an empty string, not a malformed
  "kind:pubkey:" address, when an article has no d tag. HighlightLayer
  treats a falsy address as "highlighting isn't available here."
- The selectionchange listener is only registered when both user and
  address are present (in the effect's deps), instead of always
  running selection tracking regardless of whether a highlight could
  ever be published.
- handleHighlight and the floating button both guard on address too,
  not just selection, so stale selection state from before a prop
  change went missing can't still trigger a publish.
- docs/apps.md corrected: the saved text comes from Selection.toString()
  (window.getSelection()), not Range.toString().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mroxso
2026-09-06 18:47:47 +02:00
committed by GitHub
parent 179d3682f8
commit 50795167f5
4 changed files with 223 additions and 4 deletions

View File

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

View File

@@ -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<HTMLDivElement>(null);
const [selection, setSelection] = useState<SelectionState | null>(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 (
<>
<div ref={containerRef} className="relative">
{children}
{user && address && 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',
// Clamped so a selection near the top of the viewport doesn't
// push the button off-screen and out of reach.
top: Math.max(8, 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 { BookmarkButton } from '@/components/nostr/BookmarkButton';
import { Markdown } from './Markdown';
import { Button } from '@/components/ui/button';
@@ -413,9 +414,17 @@ function ArticleView({ event }: { event: NostrEvent }) {
)}
</header>
<div className="mt-6 text-[15px]">
<Markdown>{event.content}</Markdown>
</div>
<HighlightLayer
// An empty string (rather than a malformed "kind:pubkey:" address)
// when the article has no `d` tag — HighlightLayer treats a falsy
// address as "highlighting isn't available for this article."
address={tagValue(event, 'd') ? `${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) });
},
});
}