mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
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:
@@ -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`
|
||||
|
||||
142
src/apps/articles/HighlightLayer.tsx
Normal file
142
src/apps/articles/HighlightLayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/hooks/useHighlights.ts
Normal file
61
src/hooks/useHighlights.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user