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 (
+
+ );
+}
diff --git a/src/hooks/useHighlights.ts b/src/hooks/useHighlights.ts
new file mode 100644
index 0000000..46d2e48
--- /dev/null
+++ b/src/hooks/useHighlights.ts
@@ -0,0 +1,187 @@
+import { useQuery } from '@tanstack/react-query';
+import { useNostr } from '@nostrify/react';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { validateHighlight } from '@/lib/validators';
+
+/**
+ * Hook to fetch highlights for a specific article or event
+ *
+ * @param eventId - The ID of the article/event to get highlights for
+ * @param options - Query options
+ * @returns Query result with array of validated highlight events
+ *
+ * @example
+ * ```tsx
+ * const { data: highlights, isLoading } = useHighlights(article.id);
+ * ```
+ */
+export function useHighlights(
+ eventId: string,
+ options?: {
+ enabled?: boolean;
+ staleTime?: number;
+ }
+) {
+ const { nostr } = useNostr();
+
+ return useQuery({
+ queryKey: ['highlights', eventId],
+ queryFn: async (c) => {
+ const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
+
+ // Query for highlights that reference this event
+ const events = await nostr.query(
+ [
+ {
+ kinds: [9802],
+ '#e': [eventId],
+ limit: 150,
+ },
+ ],
+ { signal }
+ );
+
+ // Filter and validate highlights
+ const validHighlights = events.filter(validateHighlight);
+
+ // Sort by created_at (newest first)
+ return validHighlights.sort((a, b) => b.created_at - a.created_at);
+ },
+ enabled: !!eventId && (options?.enabled ?? true),
+ staleTime: options?.staleTime ?? 5 * 60 * 1000, // 5 minutes
+ gcTime: 10 * 60 * 1000, // 10 minutes
+ });
+}
+
+/**
+ * Hook to fetch highlights for an addressable event (like NIP-23 articles)
+ *
+ * @param aTag - The 'a' tag value (e.g., "30023:pubkey:d-tag")
+ * @param options - Query options
+ * @returns Query result with array of validated highlight events
+ *
+ * @example
+ * ```tsx
+ * const aTag = `30023:${article.pubkey}:${dTag}`;
+ * const { data: highlights } = useHighlightsByAddress(aTag);
+ * ```
+ */
+export function useHighlightsByAddress(
+ aTag: string,
+ options?: {
+ enabled?: boolean;
+ staleTime?: number;
+ }
+) {
+ const { nostr } = useNostr();
+
+ return useQuery({
+ queryKey: ['highlights-by-address', aTag],
+ queryFn: async (c) => {
+ const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
+
+ // Query for highlights that reference this addressable event
+ const events = await nostr.query(
+ [
+ {
+ kinds: [9802],
+ '#a': [aTag],
+ limit: 150,
+ },
+ ],
+ { signal }
+ );
+
+ // Filter and validate highlights
+ const validHighlights = events.filter(validateHighlight);
+
+ // Sort by created_at (newest first)
+ return validHighlights.sort((a, b) => b.created_at - a.created_at);
+ },
+ enabled: !!aTag && (options?.enabled ?? true),
+ staleTime: options?.staleTime ?? 5 * 60 * 1000, // 5 minutes
+ gcTime: 10 * 60 * 1000, // 10 minutes
+ });
+}
+
+/**
+ * Combined hook that queries highlights for both event ID and addressable event
+ * Useful for NIP-23 articles which are addressable events
+ *
+ * @param article - The article event to get highlights for
+ * @param options - Query options
+ * @returns Query result with array of validated highlight events
+ *
+ * @example
+ * ```tsx
+ * const { data: highlights } = useArticleHighlights(article);
+ * ```
+ */
+export function useArticleHighlights(
+ article: NostrEvent | null | undefined,
+ options?: {
+ enabled?: boolean;
+ staleTime?: number;
+ }
+) {
+ const { nostr } = useNostr();
+
+ // Build the 'a' tag for addressable events
+ const aTag = article?.kind === 30023
+ ? (() => {
+ const dTag = article.tags.find(([name]) => name === 'd')?.[1];
+ return dTag ? `${article.kind}:${article.pubkey}:${dTag}` : null;
+ })()
+ : null;
+
+ return useQuery({
+ queryKey: ['article-highlights', article?.id, aTag],
+ queryFn: async (c) => {
+ const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
+
+ if (!article) return [];
+
+ const filters: Array<{
+ kinds: number[];
+ '#e'?: string[];
+ '#a'?: string[];
+ limit: number;
+ }> = [];
+
+ // Add filter for event ID
+ if (article.id) {
+ filters.push({
+ kinds: [9802],
+ '#e': [article.id],
+ limit: 150,
+ });
+ }
+
+ // Add filter for addressable event
+ if (aTag) {
+ filters.push({
+ kinds: [9802],
+ '#a': [aTag],
+ limit: 150,
+ });
+ }
+
+ if (filters.length === 0) return [];
+
+ // Query for highlights
+ const events = await nostr.query(filters, { signal });
+
+ // Filter and validate highlights, remove duplicates
+ const validHighlights = events.filter(validateHighlight);
+ const uniqueHighlights = Array.from(
+ new Map(validHighlights.map(h => [h.id, h])).values()
+ );
+
+ // Sort by created_at (newest first)
+ return uniqueHighlights.sort((a, b) => b.created_at - a.created_at);
+ },
+ enabled: !!article && (options?.enabled ?? true),
+ staleTime: options?.staleTime ?? 5 * 60 * 1000, // 5 minutes
+ gcTime: 10 * 60 * 1000, // 10 minutes
+ });
+}
diff --git a/src/hooks/usePublishHighlight.ts b/src/hooks/usePublishHighlight.ts
new file mode 100644
index 0000000..e541d2d
--- /dev/null
+++ b/src/hooks/usePublishHighlight.ts
@@ -0,0 +1,110 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { useNostrPublish } from './useNostrPublish';
+import { cleanUrl } from '@/lib/validators';
+
+export interface PublishHighlightParams {
+ /** The highlighted text content */
+ content: string;
+ /** The source article (Nostr event) */
+ article?: NostrEvent;
+ /** URL source (if not a Nostr event) */
+ url?: string;
+ /** Optional surrounding paragraph for context */
+ context?: string;
+ /** Optional comment for quote highlights */
+ comment?: string;
+ /** Additional author pubkeys to credit */
+ additionalAuthors?: string[];
+}
+
+/**
+ * Hook to publish a highlight (NIP-84 kind:9802) event
+ *
+ * @example
+ * ```tsx
+ * const { mutate: publishHighlight } = usePublishHighlight();
+ *
+ * publishHighlight({
+ * content: selectedText,
+ * article: article,
+ * comment: "This is insightful!"
+ * });
+ * ```
+ */
+export function usePublishHighlight() {
+ const { mutateAsync: publish } = useNostrPublish();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: PublishHighlightParams) => {
+ const { content, article, url, context, comment, additionalAuthors = [] } = params;
+
+ // Validate that we have either an article or URL
+ if (!article && !url) {
+ throw new Error('Must provide either article or url');
+ }
+
+ const tags: string[][] = [];
+
+ // Add reference tags
+ if (article) {
+ // For addressable events (kind 30023 articles), use 'a' tag
+ if (article.kind === 30023) {
+ const dTag = article.tags.find(([name]) => name === 'd')?.[1];
+ if (dTag) {
+ tags.push(['a', `${article.kind}:${article.pubkey}:${dTag}`]);
+ }
+ } else {
+ // For other events, use 'e' tag
+ tags.push(['e', article.id]);
+ }
+
+ // Add author attribution
+ tags.push(['p', article.pubkey, '', 'author']);
+ } else if (url) {
+ // Clean and add URL reference
+ const cleanedUrl = cleanUrl(url);
+ tags.push(['r', cleanedUrl, comment ? 'source' : '']);
+ }
+
+ // Add additional authors
+ additionalAuthors.forEach(pubkey => {
+ tags.push(['p', pubkey, '', 'author']);
+ });
+
+ // Add context if provided
+ if (context) {
+ tags.push(['context', context]);
+ }
+
+ // Add comment if provided (makes it a quote highlight)
+ if (comment) {
+ tags.push(['comment', comment]);
+ }
+
+ // Add alt text for accessibility (NIP-31)
+ const altText = comment
+ ? `Quote Highlight: "${content.slice(0, 50)}${content.length > 50 ? '...' : ''}" - ${comment.slice(0, 50)}${comment.length > 50 ? '...' : ''}`
+ : `Highlight: "${content.slice(0, 100)}${content.length > 100 ? '...' : ''}"`;
+ tags.push(['alt', altText]);
+
+ // Publish the highlight event
+ const event = await publish({
+ kind: 9802,
+ content: content,
+ tags: tags,
+ });
+
+ return event;
+ },
+ onSuccess: (event, variables) => {
+ // Invalidate relevant queries to refresh highlights
+ if (variables.article) {
+ queryClient.invalidateQueries({ queryKey: ['highlights', variables.article.id] });
+ }
+ // Also invalidate user highlights query
+ queryClient.invalidateQueries({ queryKey: ['user-highlights', event.pubkey] });
+ },
+ });
+}
diff --git a/src/hooks/useUserHighlights.ts b/src/hooks/useUserHighlights.ts
new file mode 100644
index 0000000..e48a841
--- /dev/null
+++ b/src/hooks/useUserHighlights.ts
@@ -0,0 +1,54 @@
+import { useQuery } from '@tanstack/react-query';
+import { useNostr } from '@nostrify/react';
+import { validateHighlight } from '@/lib/validators';
+
+/**
+ * Hook to fetch all highlights created by a specific user
+ *
+ * @param pubkey - The public key of the user
+ * @param options - Query options
+ * @returns Query result with array of validated highlight events
+ *
+ * @example
+ * ```tsx
+ * const { data: highlights, isLoading } = useUserHighlights(userPubkey);
+ * ```
+ */
+export function useUserHighlights(
+ pubkey: string,
+ options?: {
+ enabled?: boolean;
+ limit?: number;
+ staleTime?: number;
+ }
+) {
+ const { nostr } = useNostr();
+
+ return useQuery({
+ queryKey: ['user-highlights', pubkey, options?.limit],
+ queryFn: async (c) => {
+ const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
+
+ // Query for highlights by this user
+ const events = await nostr.query(
+ [
+ {
+ kinds: [9802],
+ authors: [pubkey],
+ limit: options?.limit ?? 100,
+ },
+ ],
+ { signal }
+ );
+
+ // Filter and validate highlights
+ const validHighlights = events.filter(validateHighlight);
+
+ // Sort by created_at (newest first)
+ return validHighlights.sort((a, b) => b.created_at - a.created_at);
+ },
+ enabled: !!pubkey && (options?.enabled ?? true),
+ staleTime: options?.staleTime ?? 5 * 60 * 1000, // 5 minutes
+ gcTime: 10 * 60 * 1000, // 10 minutes
+ });
+}
diff --git a/src/lib/validators.ts b/src/lib/validators.ts
new file mode 100644
index 0000000..3d63c45
--- /dev/null
+++ b/src/lib/validators.ts
@@ -0,0 +1,147 @@
+import type { NostrEvent } from '@nostrify/nostrify';
+
+/**
+ * Represents a validated Nostr highlight event (NIP-84)
+ */
+export interface HighlightEvent extends NostrEvent {
+ kind: 9802;
+}
+
+/**
+ * Validates that a Nostr event is a valid NIP-84 highlight (kind:9802)
+ *
+ * Requirements:
+ * - Must be kind 9802
+ * - Must have at least one reference tag (a, e, or r)
+ * - Content may be empty (for non-text media highlights)
+ *
+ * @param event - The Nostr event to validate
+ * @returns true if the event is a valid highlight, false otherwise
+ */
+export function validateHighlight(event: NostrEvent): event is HighlightEvent {
+ // Must be kind 9802
+ if (event.kind !== 9802) return false;
+
+ // Must have at least one reference tag (a, e, or r)
+ const hasReference = event.tags.some(([name]) =>
+ name === 'a' || name === 'e' || name === 'r'
+ );
+
+ if (!hasReference) return false;
+
+ return true;
+}
+
+/**
+ * Extracts the source reference from a highlight event
+ *
+ * @param highlight - The highlight event
+ * @returns The source reference object or null
+ */
+export function getHighlightSource(highlight: HighlightEvent): {
+ type: 'event' | 'address' | 'url';
+ value: string;
+} | null {
+ // Check for 'a' tag (addressable event)
+ const aTag = highlight.tags.find(([name]) => name === 'a');
+ if (aTag && aTag[1]) {
+ return { type: 'address', value: aTag[1] };
+ }
+
+ // Check for 'e' tag (event ID)
+ const eTag = highlight.tags.find(([name]) => name === 'e');
+ if (eTag && eTag[1]) {
+ return { type: 'event', value: eTag[1] };
+ }
+
+ // Check for 'r' tag (URL)
+ const rTag = highlight.tags.find(([name]) => name === 'r');
+ if (rTag && rTag[1]) {
+ return { type: 'url', value: rTag[1] };
+ }
+
+ return null;
+}
+
+/**
+ * Gets the comment from a quote highlight
+ *
+ * @param highlight - The highlight event
+ * @returns The comment text or null if no comment exists
+ */
+export function getHighlightComment(highlight: HighlightEvent): string | null {
+ const commentTag = highlight.tags.find(([name]) => name === 'comment');
+ return commentTag?.[1] || null;
+}
+
+/**
+ * Gets the context text from a highlight
+ *
+ * @param highlight - The highlight event
+ * @returns The context text or null if no context exists
+ */
+export function getHighlightContext(highlight: HighlightEvent): string | null {
+ const contextTag = highlight.tags.find(([name]) => name === 'context');
+ return contextTag?.[1] || null;
+}
+
+/**
+ * Gets all author pubkeys from a highlight event
+ *
+ * @param highlight - The highlight event
+ * @returns Array of author pubkeys
+ */
+export function getHighlightAuthors(highlight: HighlightEvent): string[] {
+ return highlight.tags
+ .filter(([name, , , role]) => name === 'p' && role === 'author')
+ .map(([, pubkey]) => pubkey)
+ .filter(Boolean);
+}
+
+/**
+ * Checks if a highlight is a quote highlight (has a comment)
+ *
+ * @param highlight - The highlight event
+ * @returns true if the highlight has a comment, false otherwise
+ */
+export function isQuoteHighlight(highlight: HighlightEvent): boolean {
+ return highlight.tags.some(([name]) => name === 'comment');
+}
+
+/**
+ * Cleans tracking parameters from a URL
+ *
+ * @param url - The URL to clean
+ * @returns The cleaned URL string
+ */
+export function cleanUrl(url: string): string {
+ try {
+ const urlObj = new URL(url);
+
+ // List of common tracking parameters to remove
+ const trackingParams = [
+ 'utm_source',
+ 'utm_medium',
+ 'utm_campaign',
+ 'utm_term',
+ 'utm_content',
+ 'fbclid',
+ 'gclid',
+ 'ref',
+ 'source',
+ 'campaign',
+ 'mc_cid',
+ 'mc_eid',
+ ];
+
+ // Remove tracking parameters
+ trackingParams.forEach(param => {
+ urlObj.searchParams.delete(param);
+ });
+
+ return urlObj.toString();
+ } catch {
+ // If URL parsing fails, return original
+ return url;
+ }
+}
diff --git a/src/pages/BlogPostPage.tsx b/src/pages/BlogPostPage.tsx
index 4c72806..2dbc21f 100644
--- a/src/pages/BlogPostPage.tsx
+++ b/src/pages/BlogPostPage.tsx
@@ -21,6 +21,9 @@ import { calculateReadingTime } from '@/lib/calculateReadingTime';
import { useToast } from '@/hooks/useToast';
import { useState } from 'react';
import NotFound from '@/pages/NotFound';
+import { HighlightButton } from '@/components/highlights/HighlightButton';
+import { QuoteHighlightDialog } from '@/components/highlights/QuoteHighlightDialog';
+import { usePublishHighlight } from '@/hooks/usePublishHighlight';
export default function BlogPostPage() {
const { nip19: naddr } = useParams<{ nip19: string }>();
@@ -28,6 +31,9 @@ export default function BlogPostPage() {
const { user } = useCurrentUser();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
+ const [showQuoteDialog, setShowQuoteDialog] = useState(false);
+ const [selectedTextForQuote, setSelectedTextForQuote] = useState('');
+ const { mutate: publishHighlight, isPending: isPublishingHighlight } = usePublishHighlight();
// Decode naddr
let pubkey = '';
@@ -124,6 +130,62 @@ export default function BlogPostPage() {
}
};
+ const handleHighlight = (text: string) => {
+ if (!post) return;
+
+ publishHighlight({
+ content: text,
+ article: post,
+ }, {
+ onSuccess: () => {
+ toast({
+ title: "Highlight saved!",
+ description: "Your highlight has been published",
+ });
+ },
+ onError: (error) => {
+ toast({
+ title: "Failed to save highlight",
+ description: error.message || "Could not publish highlight",
+ variant: "destructive",
+ });
+ }
+ });
+ };
+
+ const handleQuoteHighlight = (text: string) => {
+ setSelectedTextForQuote(text);
+ setShowQuoteDialog(true);
+ };
+
+ const handleQuoteSubmit = (comment: string) => {
+ if (!post) return;
+
+ publishHighlight({
+ content: selectedTextForQuote,
+ article: post,
+ comment: comment,
+ }, {
+ onSuccess: () => {
+ toast({
+ title: "Quote highlight shared!",
+ description: "Your highlight with commentary has been published",
+ });
+ setShowQuoteDialog(false);
+ setSelectedTextForQuote('');
+ // Clear selection
+ window.getSelection()?.removeAllRanges();
+ },
+ onError: (error) => {
+ toast({
+ title: "Failed to share highlight",
+ description: error.message || "Could not publish highlight",
+ variant: "destructive",
+ });
+ }
+ });
+ };
+
return (
{/* Sticky progress bar */}
@@ -214,9 +276,14 @@ export default function BlogPostPage() {
)}
- {/* Post content */}
-
+ {/* Post content with highlighting */}
+
+
@@ -268,6 +335,15 @@ export default function BlogPostPage() {
root={post}
/>
+
+ {/* Quote Highlight Dialog */}
+
);
}