12 changed files with 1163 additions and 42 deletions
@ -0,0 +1,386 @@
@@ -0,0 +1,386 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { Event } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { getTagValue } from '@/lib/tag' |
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
||||
import { ExternalLink, Book, FileText, Bot } from 'lucide-react' |
||||
|
||||
interface CitationCardProps { |
||||
event: Event |
||||
className?: string |
||||
displayType?: 'end' | 'foot' | 'foot-end' | 'inline' | 'quote' | 'prompt-end' | 'prompt-inline' |
||||
} |
||||
|
||||
export default function CitationCard({ event, className, displayType = 'end' }: CitationCardProps) { |
||||
const { t } = useTranslation() |
||||
|
||||
const citationData = useMemo(() => { |
||||
const title = getTagValue(event, 'title') || '' |
||||
const author = getTagValue(event, 'author') || '' |
||||
const publishedOn = getTagValue(event, 'published_on') || '' |
||||
const accessedOn = getTagValue(event, 'accessed_on') || '' |
||||
const summary = getTagValue(event, 'summary') || '' |
||||
const location = getTagValue(event, 'location') || '' |
||||
const publishedBy = getTagValue(event, 'published_by') || '' |
||||
const version = getTagValue(event, 'version') || '' |
||||
|
||||
if (event.kind === ExtendedKind.CITATION_INTERNAL) { |
||||
const cTag = event.tags.find(tag => tag[0] === 'c')?.[1] || '' |
||||
const relayHint = event.tags.find(tag => tag[0] === 'c')?.[2] || '' |
||||
const geohash = getTagValue(event, 'g') || '' |
||||
|
||||
return { |
||||
type: 'internal', |
||||
title, |
||||
author, |
||||
publishedOn, |
||||
accessedOn, |
||||
summary, |
||||
location, |
||||
geohash, |
||||
cTag, |
||||
relayHint |
||||
} |
||||
} else if (event.kind === ExtendedKind.CITATION_EXTERNAL) { |
||||
const url = getTagValue(event, 'u') || '' |
||||
const openTimestamp = getTagValue(event, 'open_timestamp') || '' |
||||
const geohash = getTagValue(event, 'g') || '' |
||||
|
||||
return { |
||||
type: 'external', |
||||
title, |
||||
author, |
||||
url, |
||||
publishedOn, |
||||
publishedBy, |
||||
version, |
||||
accessedOn, |
||||
summary, |
||||
location, |
||||
geohash, |
||||
openTimestamp |
||||
} |
||||
} else if (event.kind === ExtendedKind.CITATION_HARDCOPY) { |
||||
const pageRange = getTagValue(event, 'page_range') || '' |
||||
const chapterTitle = getTagValue(event, 'chapter_title') || '' |
||||
const editor = getTagValue(event, 'editor') || '' |
||||
const publishedIn = event.tags.find(tag => tag[0] === 'published_in')?.[1] || '' |
||||
const volume = event.tags.find(tag => tag[0] === 'published_in')?.[2] || '' |
||||
const doi = getTagValue(event, 'doi') || '' |
||||
const geohash = getTagValue(event, 'g') || '' |
||||
|
||||
return { |
||||
type: 'hardcopy', |
||||
title, |
||||
author, |
||||
pageRange, |
||||
chapterTitle, |
||||
editor, |
||||
publishedOn, |
||||
publishedBy, |
||||
publishedIn, |
||||
volume, |
||||
doi, |
||||
version, |
||||
accessedOn, |
||||
summary, |
||||
location, |
||||
geohash |
||||
} |
||||
} else if (event.kind === ExtendedKind.CITATION_PROMPT) { |
||||
const llm = getTagValue(event, 'llm') || '' |
||||
const url = getTagValue(event, 'u') || '' |
||||
|
||||
return { |
||||
type: 'prompt', |
||||
llm, |
||||
accessedOn, |
||||
version, |
||||
summary, |
||||
url |
||||
} |
||||
} |
||||
|
||||
return null |
||||
}, [event]) |
||||
|
||||
if (!citationData) { |
||||
return null |
||||
} |
||||
|
||||
const formatDate = (dateStr: string) => { |
||||
if (!dateStr) return '' |
||||
try { |
||||
const date = new Date(dateStr) |
||||
return date.toLocaleDateString() |
||||
} catch { |
||||
return dateStr |
||||
} |
||||
} |
||||
|
||||
const renderCitationContent = () => { |
||||
if (citationData.type === 'internal') { |
||||
return ( |
||||
<div className="space-y-1 text-sm"> |
||||
{citationData.author && ( |
||||
<div className="font-semibold">{citationData.author}</div> |
||||
)} |
||||
{citationData.title && ( |
||||
<div className="italic">"{citationData.title}"</div> |
||||
)} |
||||
{citationData.publishedOn && ( |
||||
<div className="text-muted-foreground">{formatDate(citationData.publishedOn)}</div> |
||||
)} |
||||
{citationData.cTag && ( |
||||
<div className="text-xs text-muted-foreground font-mono break-all"> |
||||
nostr:{citationData.cTag} |
||||
</div> |
||||
)} |
||||
{citationData.summary && ( |
||||
<div className="text-muted-foreground mt-2">{citationData.summary}</div> |
||||
)} |
||||
{event.content && ( |
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-sm border-l-2 border-primary"> |
||||
{event.content} |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} else if (citationData.type === 'external') { |
||||
return ( |
||||
<div className="space-y-1 text-sm"> |
||||
{citationData.author && ( |
||||
<div className="font-semibold">{citationData.author}</div> |
||||
)} |
||||
{citationData.title && ( |
||||
<div className="italic">"{citationData.title}"</div> |
||||
)} |
||||
{citationData.publishedBy && ( |
||||
<div>{citationData.publishedBy}</div> |
||||
)} |
||||
{citationData.publishedOn && ( |
||||
<div className="text-muted-foreground">{formatDate(citationData.publishedOn)}</div> |
||||
)} |
||||
{citationData.url && ( |
||||
<div className="flex items-center gap-1 text-primary hover:underline"> |
||||
<ExternalLink className="w-3 h-3" /> |
||||
<a href={citationData.url} target="_blank" rel="noreferrer noopener" className="break-all"> |
||||
{citationData.url} |
||||
</a> |
||||
</div> |
||||
)} |
||||
{citationData.accessedOn && ( |
||||
<div className="text-xs text-muted-foreground"> |
||||
{t('Accessed on')} {formatDate(citationData.accessedOn)} |
||||
</div> |
||||
)} |
||||
{citationData.version && ( |
||||
<div className="text-xs text-muted-foreground">{t('Version')}: {citationData.version}</div> |
||||
)} |
||||
{citationData.summary && ( |
||||
<div className="text-muted-foreground mt-2">{citationData.summary}</div> |
||||
)} |
||||
{event.content && ( |
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-sm border-l-2 border-primary"> |
||||
{event.content} |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} else if (citationData.type === 'hardcopy') { |
||||
return ( |
||||
<div className="space-y-1 text-sm"> |
||||
{citationData.author && ( |
||||
<div className="font-semibold">{citationData.author}</div> |
||||
)} |
||||
{citationData.title && ( |
||||
<div className="italic">"{citationData.title}"</div> |
||||
)} |
||||
{citationData.chapterTitle && ( |
||||
<div className="text-muted-foreground">{t('Chapter')}: {citationData.chapterTitle}</div> |
||||
)} |
||||
{citationData.editor && ( |
||||
<div>{t('Edited by')} {citationData.editor}</div> |
||||
)} |
||||
{citationData.publishedIn && ( |
||||
<div> |
||||
{t('Published in')} {citationData.publishedIn} |
||||
{citationData.volume && `, ${t('Volume')} ${citationData.volume}`} |
||||
</div> |
||||
)} |
||||
{citationData.publishedBy && ( |
||||
<div>{citationData.publishedBy}</div> |
||||
)} |
||||
{citationData.publishedOn && ( |
||||
<div className="text-muted-foreground">{formatDate(citationData.publishedOn)}</div> |
||||
)} |
||||
{citationData.pageRange && ( |
||||
<div className="text-muted-foreground">{t('Pages')}: {citationData.pageRange}</div> |
||||
)} |
||||
{citationData.doi && ( |
||||
<div className="text-xs text-muted-foreground">DOI: {citationData.doi}</div> |
||||
)} |
||||
{citationData.accessedOn && ( |
||||
<div className="text-xs text-muted-foreground"> |
||||
{t('Accessed on')} {formatDate(citationData.accessedOn)} |
||||
</div> |
||||
)} |
||||
{citationData.version && ( |
||||
<div className="text-xs text-muted-foreground">{t('Version')}: {citationData.version}</div> |
||||
)} |
||||
{citationData.summary && ( |
||||
<div className="text-muted-foreground mt-2">{citationData.summary}</div> |
||||
)} |
||||
{event.content && ( |
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-sm border-l-2 border-primary"> |
||||
{event.content} |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} else if (citationData.type === 'prompt') { |
||||
return ( |
||||
<div className="space-y-1 text-sm"> |
||||
{citationData.llm && ( |
||||
<div className="font-semibold">{citationData.llm}</div> |
||||
)} |
||||
{citationData.accessedOn && ( |
||||
<div className="text-muted-foreground">{t('Accessed on')} {formatDate(citationData.accessedOn)}</div> |
||||
)} |
||||
{citationData.version && ( |
||||
<div className="text-xs text-muted-foreground">{t('Version')}: {citationData.version}</div> |
||||
)} |
||||
{citationData.url && ( |
||||
<div className="flex items-center gap-1 text-primary hover:underline"> |
||||
<ExternalLink className="w-3 h-3" /> |
||||
<a href={citationData.url} target="_blank" rel="noreferrer noopener" className="break-all"> |
||||
{citationData.url} |
||||
</a> |
||||
</div> |
||||
)} |
||||
{citationData.summary && ( |
||||
<div className="text-muted-foreground mt-2">{citationData.summary}</div> |
||||
)} |
||||
{event.content && ( |
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-sm border-l-2 border-primary"> |
||||
{event.content} |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return null |
||||
} |
||||
|
||||
const getIcon = () => { |
||||
switch (citationData.type) { |
||||
case 'internal': |
||||
return <FileText className="w-4 h-4" /> |
||||
case 'external': |
||||
return <ExternalLink className="w-4 h-4" /> |
||||
case 'hardcopy': |
||||
return <Book className="w-4 h-4" /> |
||||
case 'prompt': |
||||
return <Bot className="w-4 h-4" /> |
||||
default: |
||||
return <FileText className="w-4 h-4" /> |
||||
} |
||||
} |
||||
|
||||
const getTitle = () => { |
||||
switch (citationData.type) { |
||||
case 'internal': |
||||
return t('Internal Citation') |
||||
case 'external': |
||||
return t('External Citation') |
||||
case 'hardcopy': |
||||
return t('Hardcopy Citation') |
||||
case 'prompt': |
||||
return t('Prompt Citation') |
||||
default: |
||||
return t('Citation') |
||||
} |
||||
} |
||||
|
||||
// For inline citations, render a compact version
|
||||
if (displayType === 'inline' || displayType === 'prompt-inline') { |
||||
const inlineText = citationData.type === 'internal' && citationData.author && citationData.publishedOn |
||||
? `(${citationData.author}, ${formatDate(citationData.publishedOn)})` |
||||
: citationData.type === 'prompt' && citationData.llm |
||||
? `(${citationData.llm})` |
||||
: `[${t('Citation')}]` |
||||
|
||||
return ( |
||||
<span className={className}> |
||||
<a |
||||
href={`/notes/${event.id}`} |
||||
className="text-primary hover:underline" |
||||
onClick={(e) => { |
||||
e.preventDefault() |
||||
// Scroll to full citation in references section
|
||||
const refSection = document.getElementById('references-section') |
||||
if (refSection) { |
||||
refSection.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
||||
} |
||||
}} |
||||
> |
||||
{inlineText} |
||||
</a> |
||||
</span> |
||||
) |
||||
} |
||||
|
||||
// For footnotes (foot-end), render a brief reference
|
||||
if (displayType === 'foot-end') { |
||||
return ( |
||||
<div className={className}> |
||||
<div className="text-sm text-muted-foreground"> |
||||
{citationData.type === 'internal' && citationData.author && citationData.publishedOn |
||||
? `${citationData.author}, ${formatDate(citationData.publishedOn)}` |
||||
: citationData.type === 'external' && citationData.author |
||||
? `${citationData.author}` |
||||
: citationData.type === 'hardcopy' && citationData.author |
||||
? `${citationData.author}` |
||||
: citationData.type === 'prompt' && citationData.llm |
||||
? `${citationData.llm}` |
||||
: t('See reference')} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
// For quotes, render with quote styling
|
||||
if (displayType === 'quote') { |
||||
return ( |
||||
<Card className={className}> |
||||
<CardHeader className="pb-2"> |
||||
<CardTitle className="text-sm flex items-center gap-2"> |
||||
{getIcon()} |
||||
{getTitle()} |
||||
</CardTitle> |
||||
</CardHeader> |
||||
<CardContent> |
||||
{renderCitationContent()} |
||||
</CardContent> |
||||
</Card> |
||||
) |
||||
} |
||||
|
||||
// For endnotes, footnotes, and prompt-end, render full citation
|
||||
return ( |
||||
<Card className={className}> |
||||
<CardHeader className="pb-2"> |
||||
<CardTitle className="text-sm flex items-center gap-2"> |
||||
{getIcon()} |
||||
{getTitle()} |
||||
</CardTitle> |
||||
</CardHeader> |
||||
<CardContent> |
||||
{renderCitationContent()} |
||||
</CardContent> |
||||
</Card> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
import { useFetchEvent } from '@/hooks' |
||||
import CitationCard from '@/components/CitationCard' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { nip19 } from 'nostr-tools' |
||||
|
||||
interface EmbeddedCitationProps { |
||||
citationId: string // nevent or note ID
|
||||
displayType?: 'end' | 'foot' | 'foot-end' | 'inline' | 'quote' | 'prompt-end' | 'prompt-inline' |
||||
className?: string |
||||
} |
||||
|
||||
export default function EmbeddedCitation({ citationId, displayType = 'end', className }: EmbeddedCitationProps) { |
||||
// Try to decode as bech32 first
|
||||
let eventId: string | null = null |
||||
|
||||
try { |
||||
const decoded = nip19.decode(citationId) |
||||
if (decoded.type === 'nevent') { |
||||
const data = decoded.data as any |
||||
eventId = data.id || citationId |
||||
} else if (decoded.type === 'note') { |
||||
eventId = decoded.data as string |
||||
} else { |
||||
// If it's not a note/nevent, use the original ID
|
||||
eventId = citationId |
||||
} |
||||
} catch { |
||||
// If decoding fails, assume it's already a hex ID
|
||||
eventId = citationId |
||||
} |
||||
|
||||
const { event, isLoading } = useFetchEvent(eventId || '') |
||||
|
||||
if (isLoading) { |
||||
return ( |
||||
<div className={className}> |
||||
<Skeleton className="h-24 w-full" /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!event) { |
||||
return ( |
||||
<div className={className}> |
||||
<div className="text-sm text-muted-foreground p-2 border rounded"> |
||||
Citation not found: {citationId.slice(0, 20)}... |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return <CitationCard event={event} displayType={displayType} className={className} /> |
||||
} |
||||
|
||||
@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { Event } from 'nostr-tools' |
||||
import { forwardRef, useMemo, useEffect, useImperativeHandle, useState, useRef } from 'react' |
||||
import { useProfileNotesTimeline } from '@/hooks/useProfileNotesTimeline' |
||||
import NoteCard from '@/components/NoteCard' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
||||
const INITIAL_SHOW_COUNT = 25 |
||||
const LOAD_MORE_COUNT = 25 |
||||
|
||||
const NOTES_KIND_LIST = [ |
||||
ExtendedKind.PUBLICATION_CONTENT, // 30041
|
||||
ExtendedKind.CITATION_INTERNAL, // 30
|
||||
ExtendedKind.CITATION_EXTERNAL, // 31
|
||||
ExtendedKind.CITATION_HARDCOPY, // 32
|
||||
ExtendedKind.CITATION_PROMPT // 33
|
||||
] |
||||
|
||||
interface ProfileNotesProps { |
||||
pubkey: string |
||||
topSpace?: number |
||||
searchQuery?: string |
||||
kindFilter?: string |
||||
onEventsChange?: (events: Event[]) => void |
||||
} |
||||
|
||||
const ProfileNotes = forwardRef<{ refresh: () => void; getEvents?: () => Event[] }, ProfileNotesProps>( |
||||
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { |
||||
const cacheKey = useMemo(() => `${pubkey}-notes`, [pubkey]) |
||||
const [isRefreshing, setIsRefreshing] = useState(false) |
||||
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) |
||||
const bottomRef = useRef<HTMLDivElement>(null) |
||||
|
||||
const { events: timelineEvents, isLoading, refresh } = useProfileNotesTimeline({ |
||||
pubkey, |
||||
cacheKey, |
||||
kinds: NOTES_KIND_LIST, |
||||
limit: 200, |
||||
filterPredicate: undefined |
||||
}) |
||||
|
||||
useEffect(() => { |
||||
onEventsChange?.(timelineEvents) |
||||
}, [timelineEvents, onEventsChange]) |
||||
|
||||
useEffect(() => { |
||||
if (!isLoading) { |
||||
setIsRefreshing(false) |
||||
} |
||||
}, [isLoading]) |
||||
|
||||
useImperativeHandle( |
||||
ref, |
||||
() => ({ |
||||
refresh: () => { |
||||
setIsRefreshing(true) |
||||
refresh() |
||||
}, |
||||
getEvents: () => timelineEvents |
||||
}), |
||||
[refresh, timelineEvents] |
||||
) |
||||
|
||||
const getKindLabel = (kindValue: string) => { |
||||
if (!kindValue || kindValue === 'all') return 'notes' |
||||
const kindNum = parseInt(kindValue, 10) |
||||
if (kindNum === ExtendedKind.PUBLICATION_CONTENT) return 'notes' |
||||
if (kindNum === ExtendedKind.CITATION_INTERNAL) return 'internal citations' |
||||
if (kindNum === ExtendedKind.CITATION_EXTERNAL) return 'external citations' |
||||
if (kindNum === ExtendedKind.CITATION_HARDCOPY) return 'hardcopy citations' |
||||
if (kindNum === ExtendedKind.CITATION_PROMPT) return 'prompt citations' |
||||
return 'notes' |
||||
} |
||||
|
||||
const eventsFilteredByKind = useMemo(() => { |
||||
if (kindFilter === 'all') { |
||||
return timelineEvents |
||||
} |
||||
const kindNumber = parseInt(kindFilter, 10) |
||||
if (Number.isNaN(kindNumber)) { |
||||
return timelineEvents |
||||
} |
||||
return timelineEvents.filter((event) => event.kind === kindNumber) |
||||
}, [timelineEvents, kindFilter]) |
||||
|
||||
const filteredEvents = useMemo(() => { |
||||
if (!searchQuery.trim()) { |
||||
return eventsFilteredByKind |
||||
} |
||||
const query = searchQuery.toLowerCase().trim() |
||||
return eventsFilteredByKind.filter((event) => { |
||||
const contentLower = event.content.toLowerCase() |
||||
if (contentLower.includes(query)) return true |
||||
return event.tags.some((tag) => { |
||||
if (tag.length <= 1) return false |
||||
const tagValue = tag[1] |
||||
return tagValue && tagValue.toLowerCase().includes(query) |
||||
}) |
||||
}) |
||||
}, [eventsFilteredByKind, searchQuery]) |
||||
|
||||
// Reset showCount when filters change
|
||||
useEffect(() => { |
||||
setShowCount(INITIAL_SHOW_COUNT) |
||||
}, [searchQuery, kindFilter, pubkey]) |
||||
|
||||
// Pagination: slice to showCount for display
|
||||
const displayedEvents = useMemo(() => { |
||||
return filteredEvents.slice(0, showCount) |
||||
}, [filteredEvents, showCount]) |
||||
|
||||
// IntersectionObserver for infinite scroll
|
||||
useEffect(() => { |
||||
if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return |
||||
|
||||
const observer = new IntersectionObserver( |
||||
(entries) => { |
||||
if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { |
||||
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) |
||||
} |
||||
}, |
||||
{ threshold: 0.1 } |
||||
) |
||||
|
||||
observer.observe(bottomRef.current) |
||||
|
||||
return () => { |
||||
observer.disconnect() |
||||
} |
||||
}, [displayedEvents.length, filteredEvents.length]) |
||||
|
||||
if (!pubkey) { |
||||
return ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground">No profile selected</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (isLoading && timelineEvents.length === 0) { |
||||
return ( |
||||
<div className="space-y-2"> |
||||
{Array.from({ length: 3 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-32 w-full" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!filteredEvents.length && !isLoading) { |
||||
return ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground"> |
||||
{searchQuery.trim() ? 'No notes match your search' : 'No notes found'} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div style={{ marginTop: topSpace || 0 }}> |
||||
{isRefreshing && ( |
||||
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing notes...</div> |
||||
)} |
||||
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && ( |
||||
<div className="px-4 py-2 text-sm text-muted-foreground"> |
||||
Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)} |
||||
</div> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{displayedEvents.map((event) => ( |
||||
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} /> |
||||
))} |
||||
</div> |
||||
{displayedEvents.length < filteredEvents.length && ( |
||||
<div ref={bottomRef} className="h-10 flex items-center justify-center"> |
||||
<div className="text-sm text-muted-foreground">Loading more...</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
) |
||||
|
||||
ProfileNotes.displayName = 'ProfileNotes' |
||||
|
||||
export default ProfileNotes |
||||
|
||||
@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react' |
||||
import { Event } from 'nostr-tools' |
||||
import client from '@/services/client.service' |
||||
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { getPrivateRelayUrls } from '@/lib/private-relays' |
||||
|
||||
type ProfileNotesTimelineCacheEntry = { |
||||
events: Event[] |
||||
lastUpdated: number |
||||
} |
||||
|
||||
const timelineCache = new Map<string, ProfileNotesTimelineCacheEntry>() |
||||
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - cache is considered fresh for this long
|
||||
|
||||
type UseProfileNotesTimelineOptions = { |
||||
pubkey: string |
||||
cacheKey: string |
||||
kinds: number[] |
||||
limit?: number |
||||
filterPredicate?: (event: Event) => boolean |
||||
} |
||||
|
||||
type UseProfileNotesTimelineResult = { |
||||
events: Event[] |
||||
isLoading: boolean |
||||
refresh: () => void |
||||
} |
||||
|
||||
function postProcessEvents( |
||||
rawEvents: Event[], |
||||
filterPredicate: ((event: Event) => boolean) | undefined, |
||||
limit: number |
||||
) { |
||||
const dedupMap = new Map<string, Event>() |
||||
rawEvents.forEach((evt) => { |
||||
if (!dedupMap.has(evt.id)) { |
||||
dedupMap.set(evt.id, evt) |
||||
} |
||||
}) |
||||
|
||||
let events = Array.from(dedupMap.values()) |
||||
if (filterPredicate) { |
||||
events = events.filter(filterPredicate) |
||||
} |
||||
events.sort((a, b) => b.created_at - a.created_at) |
||||
return events.slice(0, limit) |
||||
} |
||||
|
||||
export function useProfileNotesTimeline({ |
||||
pubkey, |
||||
cacheKey, |
||||
kinds, |
||||
limit = 200, |
||||
filterPredicate |
||||
}: UseProfileNotesTimelineOptions): UseProfileNotesTimelineResult { |
||||
const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) |
||||
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? []) |
||||
const [isLoading, setIsLoading] = useState(!cachedEntry) |
||||
const [refreshToken, setRefreshToken] = useState(0) |
||||
const subscriptionRef = useRef<() => void>(() => {}) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
|
||||
const subscribe = async () => { |
||||
// Check if we have fresh cached data
|
||||
const cachedEntry = timelineCache.get(cacheKey) |
||||
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity |
||||
const isCacheFresh = cacheAge < CACHE_DURATION |
||||
|
||||
// If cache is fresh, show it immediately and skip subscribing
|
||||
if (isCacheFresh && cachedEntry) { |
||||
setEvents(cachedEntry.events) |
||||
setIsLoading(false) |
||||
// Still subscribe in background to get updates, but don't show loading
|
||||
} else { |
||||
// Cache is stale or missing - show loading and fetch
|
||||
setIsLoading(!cachedEntry) |
||||
} |
||||
|
||||
try { |
||||
// Get private relays (outbox + cache relays) for private notes
|
||||
const privateRelayUrls = await getPrivateRelayUrls(pubkey) |
||||
const normalizedPrivateRelays = Array.from( |
||||
new Set( |
||||
privateRelayUrls |
||||
.map((url) => normalizeUrl(url)) |
||||
.filter((value): value is string => !!value) |
||||
) |
||||
) |
||||
|
||||
// Also include fast read relays as fallback
|
||||
const fastReadRelays = Array.from( |
||||
new Set( |
||||
FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) |
||||
) |
||||
) |
||||
|
||||
// Build relay groups: private relays first, then fast read relays
|
||||
const relayGroups: string[][] = [] |
||||
if (normalizedPrivateRelays.length > 0) { |
||||
relayGroups.push(normalizedPrivateRelays) |
||||
} |
||||
if (fastReadRelays.length > 0) { |
||||
relayGroups.push(fastReadRelays) |
||||
} |
||||
|
||||
if (cancelled) { |
||||
return |
||||
} |
||||
|
||||
const subRequests = relayGroups |
||||
.map((urls) => ({ |
||||
urls, |
||||
filter: { |
||||
authors: [pubkey], |
||||
kinds, |
||||
limit |
||||
} as any |
||||
})) |
||||
.filter((request) => request.urls.length) |
||||
|
||||
if (!subRequests.length) { |
||||
timelineCache.set(cacheKey, { |
||||
events: [], |
||||
lastUpdated: Date.now() |
||||
}) |
||||
setEvents([]) |
||||
setIsLoading(false) |
||||
return |
||||
} |
||||
|
||||
const { closer } = await client.subscribeTimeline( |
||||
subRequests, |
||||
{ |
||||
onEvents: (fetchedEvents) => { |
||||
if (cancelled) return |
||||
const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit) |
||||
timelineCache.set(cacheKey, { |
||||
events: processed, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
setEvents(processed) |
||||
setIsLoading(false) |
||||
}, |
||||
onNew: (evt) => { |
||||
if (cancelled) return |
||||
setEvents((prevEvents) => { |
||||
const combined = [evt as Event, ...prevEvents] |
||||
const processed = postProcessEvents(combined, filterPredicate, limit) |
||||
timelineCache.set(cacheKey, { |
||||
events: processed, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
return processed |
||||
}) |
||||
} |
||||
}, |
||||
{ needSort: true } |
||||
) |
||||
|
||||
subscriptionRef.current = () => closer() |
||||
} catch (error) { |
||||
if (!cancelled) { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
} |
||||
|
||||
subscribe() |
||||
|
||||
return () => { |
||||
cancelled = true |
||||
subscriptionRef.current() |
||||
subscriptionRef.current = () => {} |
||||
} |
||||
}, [pubkey, cacheKey, JSON.stringify(kinds), limit, filterPredicate, refreshToken]) |
||||
|
||||
const refresh = useCallback(() => { |
||||
subscriptionRef.current() |
||||
subscriptionRef.current = () => {} |
||||
timelineCache.delete(cacheKey) |
||||
setIsLoading(true) |
||||
setRefreshToken((token) => token + 1) |
||||
}, [cacheKey]) |
||||
|
||||
return { |
||||
events, |
||||
isLoading, |
||||
refresh |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
import { Label } from '@/components/ui/label' |
||||
import { Switch } from '@/components/ui/switch' |
||||
import { StorageKey } from '@/constants' |
||||
import { hasCacheRelays } from '@/lib/private-relays' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function CacheRelayOnlySetting() { |
||||
const { t } = useTranslation() |
||||
const { pubkey } = useNostr() |
||||
const [enabled, setEnabled] = useState(true) // Default ON
|
||||
const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false) |
||||
|
||||
useEffect(() => { |
||||
// Load from localStorage
|
||||
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES) |
||||
setEnabled(stored === null ? true : stored === 'true') // Default to true if not set
|
||||
|
||||
// Check if user has cache relays
|
||||
if (pubkey) { |
||||
hasCacheRelays(pubkey) |
||||
.then(setHasCacheRelaysAvailable) |
||||
.catch(() => setHasCacheRelaysAvailable(false)) |
||||
} else { |
||||
setHasCacheRelaysAvailable(false) |
||||
} |
||||
}, [pubkey]) |
||||
|
||||
const handleEnabledChange = (checked: boolean) => { |
||||
setEnabled(checked) |
||||
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, checked.toString()) |
||||
} |
||||
|
||||
if (!hasCacheRelaysAvailable) { |
||||
return null // Don't show if user doesn't have cache relays
|
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-4"> |
||||
<h3 className="text-lg font-medium">{t('Private Notes')}</h3> |
||||
<div className="space-y-2"> |
||||
<div className="flex items-center space-x-2"> |
||||
<Label htmlFor="cache-relay-only">{t('Use cache relay only for citations and publication content')}</Label> |
||||
<Switch |
||||
id="cache-relay-only" |
||||
checked={enabled} |
||||
onCheckedChange={handleEnabledChange} |
||||
/> |
||||
</div> |
||||
<div className="text-muted-foreground text-xs"> |
||||
{t('When enabled, citations and publication content (kind 30041) will only be published to your cache relay, not to outbox relays')} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
Loading…
Reference in new issue