From a7894c524a959cda6fdb4e4ece2b2735a0a4eb2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:19:28 +0000 Subject: [PATCH] Add NIP-84 highlights core functionality Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- AGENTS.md | 104 +++++ docs/NOSTR_HIGHLIGHTS.md | 421 ++++++++++++++++++ src/components/highlights/HighlightButton.tsx | 174 ++++++++ .../highlights/QuoteHighlightDialog.tsx | 119 +++++ src/hooks/useHighlights.ts | 187 ++++++++ src/hooks/usePublishHighlight.ts | 110 +++++ src/hooks/useUserHighlights.ts | 54 +++ src/lib/validators.ts | 147 ++++++ src/pages/BlogPostPage.tsx | 80 +++- 9 files changed, 1394 insertions(+), 2 deletions(-) create mode 100644 docs/NOSTR_HIGHLIGHTS.md create mode 100644 src/components/highlights/HighlightButton.tsx create mode 100644 src/components/highlights/QuoteHighlightDialog.tsx create mode 100644 src/hooks/useHighlights.ts create mode 100644 src/hooks/usePublishHighlight.ts create mode 100644 src/hooks/useUserHighlights.ts create mode 100644 src/lib/validators.ts diff --git a/AGENTS.md b/AGENTS.md index e3817e4..acf406b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -806,6 +806,110 @@ export function Post(/* ...props */) { } ``` +### Nostr Highlights (NIP-84) + +The project implements NIP-84 highlights (kind:9802) for content curation and discovery. Users can highlight valuable passages from articles and optionally add commentary. + +#### Highlight Event Structure + +**Basic Highlight:** +```typescript +{ + kind: 9802, + content: "The highlighted text passage", + tags: [ + ["a", "30023:author-pubkey:d-tag"], // Reference to source article + ["p", "author-pubkey", "relay-url", "author"], // Article author attribution + ["context", "...surrounding paragraph text..."], // Optional context + ["alt", "Highlight: \"The highlighted text...\""] // NIP-31 alt text + ] +} +``` + +**Quote Highlight (with comment):** +```typescript +{ + kind: 9802, + content: "The highlighted text passage", + tags: [ + ["a", "30023:author-pubkey:d-tag"], + ["p", "author-pubkey", "relay-url", "author"], + ["comment", "User's thoughts about this highlight"], + ["r", "https://example.com/article", "source"], // For URLs + ["p", "mentioned-user-pubkey", "relay-url", "mention"], // Mentions in comment + ["alt", "Quote Highlight: \"The highlighted text...\" - User comment"] + ] +} +``` + +#### Implementation Guidelines + +**Tag Requirements:** +- MUST include one of: `a` tag (for Nostr events), `e` tag (for event IDs), or `r` tag (for URLs) +- SHOULD include `p` tags with `"author"` role for source content creators +- MAY include `context` tag for surrounding text +- Quote highlights use `comment` tag for user commentary +- Use `"mention"` role for p-tags in comments, `"source"` attribute for source URLs + +**Content Field:** +- Contains the highlighted text passage +- May be empty for non-text media highlights (future-proof) +- Maximum recommended length: 500 characters + +**URL Cleaning:** +- Remove tracking parameters (UTM codes, etc.) from `r` tags +- Normalize URLs for consistency + +#### Using Highlights + +**Publishing a Highlight:** +```typescript +import { usePublishHighlight } from "@/hooks/usePublishHighlight"; + +function MyComponent() { + const { mutate: publishHighlight } = usePublishHighlight(); + + const handleHighlight = (selectedText: string, article: NostrEvent) => { + publishHighlight({ + content: selectedText, + article, + context: "Optional surrounding paragraph", + comment: "Optional user commentary" + }); + }; +} +``` + +**Querying Highlights:** +```typescript +import { useHighlights } from "@/hooks/useHighlights"; + +// Get all highlights for a specific article +const { data: highlights } = useHighlights(article.id); + +// Get a user's highlights +import { useUserHighlights } from "@/hooks/useUserHighlights"; +const { data: userHighlights } = useUserHighlights(pubkey); +``` + +**Validation:** +```typescript +import { validateHighlight } from "@/lib/validators"; + +// Validate a highlight event +if (validateHighlight(event)) { + // Event is a valid highlight +} +``` + +#### UI Components + +- `HighlightButton`: Text selection toolbar action +- `QuoteHighlightDialog`: Dialog for adding commentary to highlights +- `HighlightIndicator`: Visual marker on highlighted text +- `HighlightPopover`: Shows highlight details and authors +- `HighlightsPage`: Dedicated page for browsing highlights + ## App Configuration The project includes an `AppProvider` that manages global application state including theme and relay configuration. The default configuration includes: diff --git a/docs/NOSTR_HIGHLIGHTS.md b/docs/NOSTR_HIGHLIGHTS.md new file mode 100644 index 0000000..48d2991 --- /dev/null +++ b/docs/NOSTR_HIGHLIGHTS.md @@ -0,0 +1,421 @@ +# Nostr Highlights (NIP-84) + +This document describes how to implement and use Nostr highlights (kind:9802) in the application. Highlights allow users to curate and share valuable content passages, creating a layer of discovery across the Nostr ecosystem. + +## Overview + +NIP-84 defines highlights as kind:9802 events that signal content a user finds valuable. The `.content` field contains the highlighted text passage, and tags provide attribution, context, and references to the source material. + +## Event Structure + +### Basic Highlight + +A simple highlight without commentary: + +```json +{ + "kind": 9802, + "content": "This is the highlighted text from the article", + "tags": [ + ["a", "30023:author-pubkey:article-identifier"], + ["p", "author-pubkey", "wss://relay.example.com", "author"], + ["context", "This is the full paragraph containing the highlight for context"], + ["alt", "Highlight: \"This is the highlighted text from the article\""] + ] +} +``` + +### Quote Highlight + +A highlight with user commentary (rendered like a quote repost): + +```json +{ + "kind": 9802, + "content": "This is the highlighted text from the article", + "tags": [ + ["a", "30023:author-pubkey:article-identifier"], + ["p", "author-pubkey", "wss://relay.example.com", "author"], + ["comment", "This is why I found this passage interesting and valuable"], + ["p", "mentioned-user-pubkey", "wss://relay.example.com", "mention"], + ["alt", "Quote Highlight: \"This is the highlighted text...\" - User commentary"] + ] +} +``` + +### URL-Based Highlight + +Highlighting content from a web URL (not Nostr-native): + +```json +{ + "kind": 9802, + "content": "Highlighted text from a web article", + "tags": [ + ["r", "https://example.com/article", "source"], + ["p", "author-pubkey", "wss://relay.example.com", "author"], + ["alt", "Highlight from https://example.com/article"] + ] +} +``` + +## Tag Reference + +### Required Tags + +At least one of these must be present: + +- **`a` tag**: Reference to addressable Nostr event (e.g., `30023:pubkey:d-tag` for articles) +- **`e` tag**: Reference to regular Nostr event by ID +- **`r` tag**: Reference to external URL + +### Attribution Tags + +- **`p` tags with `"author"` role**: Credits original content creators + - Format: `["p", "pubkey", "relay-url", "author"]` + - Multiple authors can be tagged +- **`p` tags with `"mention"` role**: Used in comments to mention other users + - Format: `["p", "pubkey", "relay-url", "mention"]` + +### Optional Tags + +- **`context`**: Surrounding paragraph text for better UX +- **`comment`**: User's commentary on the highlight (makes it a quote highlight) +- **`alt`**: NIP-31 alt text for accessibility + +### Tag Attributes + +For quote highlights: +- `r` tags from comments: Use `"mention"` attribute +- `r` tag for source: Use `"source"` attribute + +## Publishing Highlights + +### Using the usePublishHighlight Hook + +```tsx +import { usePublishHighlight } from "@/hooks/usePublishHighlight"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; + +function ArticleComponent({ article }: { article: NostrEvent }) { + const { user } = useCurrentUser(); + const { mutate: publishHighlight, isPending } = usePublishHighlight(); + + const handleHighlight = (selectedText: string) => { + if (!user) { + // Show login prompt + return; + } + + publishHighlight({ + content: selectedText, + article: article, + context: extractContext(selectedText, article.content), + }, { + onSuccess: () => { + // Show success toast + }, + onError: (error) => { + // Show error toast + } + }); + }; + + // ... rest of component +} +``` + +### Publishing Quote Highlights + +```tsx +import { usePublishHighlight } from "@/hooks/usePublishHighlight"; + +function QuoteHighlightDialog({ selectedText, article, onClose }) { + const [comment, setComment] = useState(''); + const { mutate: publishHighlight } = usePublishHighlight(); + + const handleSubmit = () => { + publishHighlight({ + content: selectedText, + article: article, + comment: comment, + }, { + onSuccess: () => { + onClose(); + } + }); + }; + + // ... dialog UI +} +``` + +## Querying Highlights + +### Get Highlights for an Article + +```tsx +import { useHighlights } from "@/hooks/useHighlights"; + +function ArticlePage({ article }: { article: NostrEvent }) { + const { data: highlights, isLoading } = useHighlights(article.id); + + // highlights is an array of validated kind:9802 events + return ( +
+ {/* Render article with highlights */} + {highlights?.map(highlight => ( + + ))} +
+ ); +} +``` + +### Get User's Highlights + +```tsx +import { useUserHighlights } from "@/hooks/useUserHighlights"; + +function ProfilePage({ pubkey }: { pubkey: string }) { + const { data: highlights } = useUserHighlights(pubkey); + + return ( +
+

Highlights

+ {highlights?.map(highlight => ( + + ))} +
+ ); +} +``` + +## Validation + +All highlight events should be validated before use: + +```typescript +import { validateHighlight } from "@/lib/validators"; +import type { NostrEvent } from "@nostrify/nostrify"; + +function processHighlights(events: NostrEvent[]) { + // Filter to only valid highlights + const validHighlights = events.filter(validateHighlight); + + return validHighlights; +} +``` + +The validator checks: +- Event kind is 9802 +- At least one reference tag exists (`a`, `e`, or `r`) +- Tag structure is valid +- Content is present (may be empty for non-text media) + +## UI Components + +### Text Selection and Highlighting + +The application uses native browser text selection with a custom toolbar: + +1. User selects text in an article +2. `HighlightButton` appears near the selection +3. Options: "Highlight" or "Quote Highlight" +4. On "Highlight": Creates event immediately +5. On "Quote Highlight": Opens dialog for commentary + +### Visual Indicators + +Highlighted text is marked with: +- Semi-transparent yellow/amber background +- Hover shows who created highlights +- Click opens `HighlightPopover` with details + +### Highlight Popover + +Shows when clicking highlighted text: +- List of all highlights for that passage +- Author profile information +- Timestamps +- Comments (for quote highlights) +- Link to navigate to full highlight view + +## Best Practices + +### Content Length + +- Recommended maximum: 500 characters +- For longer passages, consider using `context` tag with full paragraph +- Keep highlights focused on the key insight + +### URL Cleaning + +When using `r` tags, clean URLs by removing: +- UTM tracking parameters (`?utm_source=`, etc.) +- Session IDs +- Unnecessary query parameters + +```typescript +function cleanUrl(url: string): string { + const urlObj = new URL(url); + // Remove tracking params + urlObj.searchParams.delete('utm_source'); + urlObj.searchParams.delete('utm_medium'); + urlObj.searchParams.delete('utm_campaign'); + // ... remove other tracking params + return urlObj.toString(); +} +``` + +### Attribution + +Always include `p` tags with `"author"` role for: +- Article authors +- Content creators +- Original sources + +This ensures proper credit and enables discovery. + +### Context + +Include a `context` tag when: +- Highlight is a subset of a larger paragraph +- Surrounding text adds clarity +- Original structure matters + +## Performance Considerations + +### Query Optimization + +When querying highlights: +- Use appropriate time limits +- Consider pagination for large result sets +- Cache results with TanStack Query + +```typescript +const { data: highlights } = useHighlights(articleId, { + // Refetch every 5 minutes + staleTime: 5 * 60 * 1000, + // Cache for 10 minutes + gcTime: 10 * 60 * 1000, +}); +``` + +### Rendering Optimization + +For articles with many highlights: +- Use virtualization for highlight lists +- Lazy-load highlight details +- Debounce hover interactions + +## Accessibility + +### Alt Text + +Always include NIP-31 `alt` tags for screen readers: + +```typescript +const altText = comment + ? `Quote Highlight: "${content.slice(0, 50)}..." - ${comment.slice(0, 50)}` + : `Highlight: "${content.slice(0, 100)}..."`; + +tags.push(["alt", altText]); +``` + +### Keyboard Navigation + +Ensure highlights are keyboard accessible: +- Tab to navigate between highlights +- Enter/Space to open highlight popover +- Escape to close popover + +## Future Enhancements + +The highlight system can be extended with: + +1. **Collections**: Group highlights by theme/topic +2. **Annotations**: Private notes on highlights +3. **Export**: Download highlights as Markdown/PDF +4. **Recommendations**: Suggest highlights based on following graph +5. **Browser Extension**: Highlight any web content +6. **NIP-51 Integration**: Add highlights to bookmark lists +7. **Analytics**: Track highlight engagement for creators + +## Examples + +### Complete Highlight Flow + +```tsx +import { useState } from 'react'; +import { usePublishHighlight } from '@/hooks/usePublishHighlight'; +import { useHighlights } from '@/hooks/useHighlights'; +import { useToast } from '@/hooks/useToast'; +import { Button } from '@/components/ui/button'; + +export function ArticleWithHighlights({ article }) { + const [selectedText, setSelectedText] = useState(''); + const [showToolbar, setShowToolbar] = useState(false); + const { mutate: publishHighlight } = usePublishHighlight(); + const { data: highlights } = useHighlights(article.id); + const { toast } = useToast(); + + const handleTextSelection = () => { + const selection = window.getSelection(); + const text = selection?.toString().trim(); + + if (text && text.length > 0) { + setSelectedText(text); + setShowToolbar(true); + } else { + setShowToolbar(false); + } + }; + + const handleHighlight = () => { + publishHighlight({ + content: selectedText, + article: article, + }, { + onSuccess: () => { + toast({ + title: "Highlight saved!", + description: "Your highlight has been published", + }); + setShowToolbar(false); + } + }); + }; + + return ( +
+ + + {showToolbar && ( +
+ +
+ )} + + {/* Render highlight indicators */} + {highlights?.map(highlight => ( + + ))} +
+ ); +} +``` + +## Related NIPs + +- **NIP-23**: Long-form content (articles being highlighted) +- **NIP-31**: Alt text for events +- **NIP-51**: Lists (potential future integration) +- **NIP-84**: Highlights (this implementation) + +## References + +- [NIP-84 Specification](https://github.com/nostr-protocol/nips/blob/master/84.md) +- [Nostr Protocol Documentation](https://github.com/nostr-protocol/nips) diff --git a/src/components/highlights/HighlightButton.tsx b/src/components/highlights/HighlightButton.tsx new file mode 100644 index 0000000..be9dc3e --- /dev/null +++ b/src/components/highlights/HighlightButton.tsx @@ -0,0 +1,174 @@ +import { useState, useEffect, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Highlighter, MessageSquare } from 'lucide-react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +interface HighlightButtonProps { + /** Callback when user clicks "Highlight" */ + onHighlight: (text: string) => void; + /** Callback when user clicks "Quote Highlight" */ + onQuoteHighlight: (text: string) => void; + /** Whether a highlight operation is in progress */ + isPending?: boolean; +} + +/** + * Text selection toolbar that appears when user selects text + * Provides "Highlight" and "Quote Highlight" actions + * + * @example + * ```tsx + *
checkSelection()}> + * + * + *
+ * ``` + */ +export function HighlightButton({ + onHighlight, + onQuoteHighlight, + isPending = false, +}: HighlightButtonProps) { + const [selectedText, setSelectedText] = useState(''); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const [showLogin, setShowLogin] = useState(false); + const { user } = useCurrentUser(); + const toolbarRef = useRef(null); + + useEffect(() => { + const handleSelection = () => { + const selection = window.getSelection(); + const text = selection?.toString().trim(); + + if (text && text.length > 0 && text.length <= 500) { + // Get selection position + const range = selection?.getRangeAt(0); + const rect = range?.getBoundingClientRect(); + + if (rect) { + // Position toolbar above the selection + setPosition({ + x: rect.left + rect.width / 2, + y: rect.top + window.scrollY - 10, + }); + setSelectedText(text); + } + } else { + // Clear if selection is too long or empty + setPosition(null); + setSelectedText(''); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + // Close toolbar if clicking outside + if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) { + // Don't close if clicking in the selected text + const selection = window.getSelection(); + if (!selection?.toString().trim()) { + setPosition(null); + setSelectedText(''); + } + } + }; + + document.addEventListener('mouseup', handleSelection); + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mouseup', handleSelection); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const handleHighlightClick = () => { + if (!user) { + setShowLogin(true); + return; + } + + onHighlight(selectedText); + // Clear selection + window.getSelection()?.removeAllRanges(); + setPosition(null); + setSelectedText(''); + }; + + const handleQuoteClick = () => { + if (!user) { + setShowLogin(true); + return; + } + + onQuoteHighlight(selectedText); + // Keep selection for the dialog + }; + + if (!position || !selectedText) { + return null; + } + + return ( + <> +
+
+ + +
+
+ + {/* Login popover */} + + +
+ + +
+
+

Sign in to highlight

+

+ You need to be logged in to save highlights +

+
+ +
+
+ + + ); +} diff --git a/src/components/highlights/QuoteHighlightDialog.tsx b/src/components/highlights/QuoteHighlightDialog.tsx new file mode 100644 index 0000000..dcfcb34 --- /dev/null +++ b/src/components/highlights/QuoteHighlightDialog.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { MessageSquare } from 'lucide-react'; + +interface QuoteHighlightDialogProps { + /** Whether the dialog is open */ + open: boolean; + /** Callback when dialog open state changes */ + onOpenChange: (open: boolean) => void; + /** The selected text to highlight */ + selectedText: string; + /** Callback when user submits the quote highlight */ + onSubmit: (comment: string) => void; + /** Whether submission is in progress */ + isPending?: boolean; +} + +/** + * Dialog for creating a quote highlight with user commentary + * + * @example + * ```tsx + * + * ``` + */ +export function QuoteHighlightDialog({ + open, + onOpenChange, + selectedText, + onSubmit, + isPending = false, +}: QuoteHighlightDialogProps) { + const [comment, setComment] = useState(''); + + const handleSubmit = () => { + if (!comment.trim()) return; + onSubmit(comment.trim()); + setComment(''); + }; + + const handleCancel = () => { + setComment(''); + onOpenChange(false); + }; + + return ( + + + + + + Quote Highlight + + + Add your thoughts or commentary to this highlight + + + +
+ {/* Show the selected text */} +
+

+ "{selectedText}" +

+
+ + {/* Comment textarea */} +
+ +