12 changed files with 1163 additions and 42 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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