mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-05 10:01:22 +02:00
Add NIP-84 highlights core functionality
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
104
AGENTS.md
104
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:
|
||||
|
||||
421
docs/NOSTR_HIGHLIGHTS.md
Normal file
421
docs/NOSTR_HIGHLIGHTS.md
Normal 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)
|
||||
174
src/components/highlights/HighlightButton.tsx
Normal file
174
src/components/highlights/HighlightButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
src/components/highlights/QuoteHighlightDialog.tsx
Normal file
119
src/components/highlights/QuoteHighlightDialog.tsx
Normal 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
187
src/hooks/useHighlights.ts
Normal 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
|
||||
});
|
||||
}
|
||||
110
src/hooks/usePublishHighlight.ts
Normal file
110
src/hooks/usePublishHighlight.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
54
src/hooks/useUserHighlights.ts
Normal file
54
src/hooks/useUserHighlights.ts
Normal 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
147
src/lib/validators.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user