Add NIP-84 highlights core functionality

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-06 17:19:28 +00:00
parent 39efec1613
commit a7894c524a
9 changed files with 1394 additions and 2 deletions

104
AGENTS.md
View File

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

421
docs/NOSTR_HIGHLIGHTS.md Normal file
View File

@@ -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 (
<div>
{/* Render article with highlights */}
{highlights?.map(highlight => (
<HighlightIndicator key={highlight.id} highlight={highlight} />
))}
</div>
);
}
```
### Get User's Highlights
```tsx
import { useUserHighlights } from "@/hooks/useUserHighlights";
function ProfilePage({ pubkey }: { pubkey: string }) {
const { data: highlights } = useUserHighlights(pubkey);
return (
<div>
<h2>Highlights</h2>
{highlights?.map(highlight => (
<HighlightCard key={highlight.id} highlight={highlight} />
))}
</div>
);
}
```
## 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 (
<div onMouseUp={handleTextSelection}>
<MarkdownContent content={article.content} />
{showToolbar && (
<div className="fixed bottom-4 right-4">
<Button onClick={handleHighlight}>
Highlight
</Button>
</div>
)}
{/* Render highlight indicators */}
{highlights?.map(highlight => (
<HighlightIndicator key={highlight.id} highlight={highlight} />
))}
</div>
);
}
```
## 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)

View File

@@ -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
* <div onMouseUp={() => checkSelection()}>
* <MarkdownContent content={article.content} />
* <HighlightButton
* onHighlight={handleHighlight}
* onQuoteHighlight={handleQuoteHighlight}
* />
* </div>
* ```
*/
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<HTMLDivElement>(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 (
<>
<div
ref={toolbarRef}
className="fixed z-50 animate-in fade-in-0 zoom-in-95"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div className="bg-background border rounded-lg shadow-lg p-1 flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={handleHighlightClick}
disabled={isPending}
className="gap-2"
>
<Highlighter className="h-4 w-4" />
Highlight
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleQuoteClick}
disabled={isPending}
className="gap-2"
>
<MessageSquare className="h-4 w-4" />
Quote
</Button>
</div>
</div>
{/* Login popover */}
<Popover open={showLogin} onOpenChange={setShowLogin}>
<PopoverTrigger asChild>
<div />
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium text-sm">Sign in to highlight</h4>
<p className="text-sm text-muted-foreground">
You need to be logged in to save highlights
</p>
</div>
<LoginArea className="flex" />
</div>
</PopoverContent>
</Popover>
</>
);
}

View File

@@ -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
* <QuoteHighlightDialog
* open={showDialog}
* onOpenChange={setShowDialog}
* selectedText={selectedText}
* onSubmit={handleQuoteHighlight}
* isPending={isPending}
* />
* ```
*/
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Quote Highlight
</DialogTitle>
<DialogDescription>
Add your thoughts or commentary to this highlight
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Show the selected text */}
<div className="rounded-lg bg-muted p-4 border-l-4 border-primary">
<p className="text-sm text-muted-foreground italic">
"{selectedText}"
</p>
</div>
{/* Comment textarea */}
<div className="space-y-2">
<label htmlFor="comment" className="text-sm font-medium">
Your thoughts
</label>
<Textarea
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="What makes this passage interesting or valuable?"
className="min-h-[120px]"
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
Your comment will be visible to everyone who sees this highlight
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!comment.trim() || isPending}
>
{isPending ? 'Sharing...' : 'Share Highlight'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

187
src/hooks/useHighlights.ts Normal file
View File

@@ -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
});
}

View File

@@ -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] });
},
});
}

View File

@@ -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
});
}

147
src/lib/validators.ts Normal file
View File

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

View File

@@ -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 (
<div className="min-h-screen">
{/* Sticky progress bar */}
@@ -214,9 +276,14 @@ export default function BlogPostPage() {
</div>
)}
{/* Post content */}
<div className="mb-12">
{/* Post content with highlighting */}
<div className="mb-12 relative">
<MarkdownContent content={post.content} />
<HighlightButton
onHighlight={handleHighlight}
onQuoteHighlight={handleQuoteHighlight}
isPending={isPublishingHighlight}
/>
</div>
<Separator className="my-8" />
@@ -268,6 +335,15 @@ export default function BlogPostPage() {
root={post}
/>
</article>
{/* Quote Highlight Dialog */}
<QuoteHighlightDialog
open={showQuoteDialog}
onOpenChange={setShowQuoteDialog}
selectedText={selectedTextForQuote}
onSubmit={handleQuoteSubmit}
isPending={isPublishingHighlight}
/>
</div>
);
}