9 changed files with 130 additions and 471 deletions
@ -1,430 +0,0 @@ |
|||||||
import NoteCard, { NoteCardLoadingSkeleton } from '@/components/NoteCard' |
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' |
|
||||||
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider' |
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider' |
|
||||||
import { queryService } from '@/services/client.service' |
|
||||||
import { NostrEvent } from 'nostr-tools' |
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
||||||
import { useZap } from '@/providers/ZapProvider' |
|
||||||
import noteStatsService from '@/services/note-stats.service' |
|
||||||
import { FAST_READ_RELAY_URLS } from '@/constants' |
|
||||||
import logger from '@/lib/logger' |
|
||||||
import { normalizeUrl } from '@/lib/url' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { ChevronDown } from 'lucide-react' |
|
||||||
|
|
||||||
const SHOW_COUNT = 25 |
|
||||||
const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes
|
|
||||||
|
|
||||||
let cachedCustomEvents: { |
|
||||||
events: Array<{ event: NostrEvent; score: number }> |
|
||||||
timestamp: number |
|
||||||
} | null = null |
|
||||||
|
|
||||||
let isInitializing = false |
|
||||||
|
|
||||||
type SortOrder = 'newest' | 'oldest' | 'most-popular' | 'least-popular' |
|
||||||
|
|
||||||
export type TrendingNotesVariant = 'page' | 'searchAccordion' |
|
||||||
|
|
||||||
export default function TrendingNotes({ variant = 'page' }: { variant?: TrendingNotesVariant }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { isEventDeleted } = useDeletedEvent() |
|
||||||
const { hideUntrustedNotes, isUserTrusted } = useUserTrust() |
|
||||||
const { pubkey, relayList } = useNostr() |
|
||||||
const { favoriteRelays } = useFavoriteRelays() |
|
||||||
const { zapReplyThreshold } = useZap() |
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT) |
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular') |
|
||||||
const [cacheEvents, setCacheEvents] = useState<NostrEvent[]>([]) |
|
||||||
const [cacheLoading, setCacheLoading] = useState(false) |
|
||||||
const [accordionOpen, setAccordionOpen] = useState(false) |
|
||||||
const bottomRef = useRef<HTMLDivElement>(null) |
|
||||||
|
|
||||||
const trendingRelaySource = useMemo<'favorites' | 'default'>(() => { |
|
||||||
if (!pubkey) return 'default' |
|
||||||
const hasFavorites = favoriteRelays.length > 0 |
|
||||||
const hasRead = (relayList?.read?.length ?? 0) > 0 |
|
||||||
if (hasFavorites || hasRead) return 'favorites' |
|
||||||
return 'default' |
|
||||||
}, [pubkey, favoriteRelays, relayList]) |
|
||||||
|
|
||||||
const getRelays = useMemo(() => { |
|
||||||
const relays: string[] = [] |
|
||||||
|
|
||||||
if (pubkey) { |
|
||||||
relays.push(...favoriteRelays) |
|
||||||
if (relayList?.read) { |
|
||||||
relays.push(...relayList.read) |
|
||||||
} |
|
||||||
if (relays.length === 0) { |
|
||||||
relays.push(...FAST_READ_RELAY_URLS) |
|
||||||
} |
|
||||||
} else { |
|
||||||
relays.push(...FAST_READ_RELAY_URLS) |
|
||||||
} |
|
||||||
|
|
||||||
const normalized = relays.map((url) => normalizeUrl(url)).filter((url): url is string => !!url) |
|
||||||
|
|
||||||
return Array.from(new Set(normalized)) |
|
||||||
}, [pubkey, favoriteRelays, relayList]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const initializeCache = async () => { |
|
||||||
if (isInitializing) return |
|
||||||
if (cacheEvents.length > 0) { |
|
||||||
logger.debug('[TrendingNotes] Cache already populated, skipping initialization') |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
const now = Date.now() |
|
||||||
|
|
||||||
if (cachedCustomEvents && now - cachedCustomEvents.timestamp < CACHE_DURATION) { |
|
||||||
const allEvents = cachedCustomEvents.events.map((item) => item.event) |
|
||||||
logger.debug('[TrendingNotes] Using existing cache - loading', allEvents.length, 'events') |
|
||||||
setCacheEvents(allEvents) |
|
||||||
setCacheLoading(false) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
isInitializing = true |
|
||||||
setCacheLoading(true) |
|
||||||
const relays = getRelays |
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => { |
|
||||||
logger.debug('[TrendingNotes] Cache initialization timeout - forcing completion') |
|
||||||
isInitializing = false |
|
||||||
setCacheLoading(false) |
|
||||||
}, 180000) |
|
||||||
|
|
||||||
if (relays.length === 0) { |
|
||||||
clearTimeout(timeoutId) |
|
||||||
isInitializing = false |
|
||||||
setCacheLoading(false) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
const allEvents: NostrEvent[] = [] |
|
||||||
const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - 24 * 60 * 60 |
|
||||||
const batchSize = 3 |
|
||||||
const recentEvents: NostrEvent[] = [] |
|
||||||
|
|
||||||
for (let i = 0; i < relays.length; i += batchSize) { |
|
||||||
const batch = relays.slice(i, i + batchSize) |
|
||||||
const batchPromises = batch.map(async (relay) => { |
|
||||||
try { |
|
||||||
const events = await queryService.fetchEvents([relay], { |
|
||||||
kinds: [1, 11, 30023, 9802, 20, 21, 22], |
|
||||||
since: twentyFourHoursAgo, |
|
||||||
limit: 200 |
|
||||||
}) |
|
||||||
return events |
|
||||||
} catch (error) { |
|
||||||
logger.warn(`[TrendingNotes] Error fetching from relay ${relay}:`, error) |
|
||||||
return [] |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
const batchResults = await Promise.all(batchPromises) |
|
||||||
recentEvents.push(...batchResults.flat()) |
|
||||||
|
|
||||||
if (i + batchSize < relays.length) { |
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200)) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
allEvents.push(...recentEvents) |
|
||||||
|
|
||||||
const topLevelEvents = allEvents.filter((event) => { |
|
||||||
const eTags = event.tags.filter((tag) => tag[0] === 'e') |
|
||||||
return eTags.length === 0 |
|
||||||
}) |
|
||||||
|
|
||||||
const filteredEvents = topLevelEvents.filter((event) => { |
|
||||||
const hasNsfwTag = event.tags.some( |
|
||||||
(tag) => tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'nsfw' |
|
||||||
) |
|
||||||
const hasSensitiveTag = event.tags.some( |
|
||||||
(tag) => tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'sensitive' |
|
||||||
) |
|
||||||
const hasNsfwHashtag = event.content.toLowerCase().includes('#nsfw') |
|
||||||
const hasContentWarning = event.tags.some((tag) => tag[0] === 'content-warning') |
|
||||||
const hasContentWarningL = event.tags.some( |
|
||||||
(tag) => tag[0] === 'L' && tag[1] && tag[1].toLowerCase() === 'content-warning' |
|
||||||
) |
|
||||||
const hasContentWarningl = event.tags.some( |
|
||||||
(tag) => tag[0] === 'l' && tag[1] && tag[1].toLowerCase() === 'content-warning' |
|
||||||
) |
|
||||||
return ( |
|
||||||
!hasNsfwTag && |
|
||||||
!hasSensitiveTag && |
|
||||||
!hasNsfwHashtag && |
|
||||||
!hasContentWarning && |
|
||||||
!hasContentWarningL && |
|
||||||
!hasContentWarningl |
|
||||||
) |
|
||||||
}) |
|
||||||
|
|
||||||
const eventsNeedingStats = filteredEvents.filter((event) => !noteStatsService.getNoteStats(event.id)) |
|
||||||
|
|
||||||
if (eventsNeedingStats.length > 0) { |
|
||||||
const statsBatchSize = 10 |
|
||||||
for (let i = 0; i < eventsNeedingStats.length; i += statsBatchSize) { |
|
||||||
const batch = eventsNeedingStats.slice(i, i + statsBatchSize) |
|
||||||
await Promise.all( |
|
||||||
batch.map((event) => noteStatsService.fetchNoteStats(event, undefined, favoriteRelays).catch(() => {})) |
|
||||||
) |
|
||||||
if (i + statsBatchSize < eventsNeedingStats.length) { |
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200)) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const scoredEvents = filteredEvents.map((event) => { |
|
||||||
const stats = noteStatsService.getNoteStats(event.id) |
|
||||||
let score = 0 |
|
||||||
if (stats?.likes) score += stats.likes.length |
|
||||||
if (stats?.zaps) { |
|
||||||
stats.zaps.forEach((zap) => { |
|
||||||
score += zap.amount >= zapReplyThreshold ? 8 : 1 |
|
||||||
}) |
|
||||||
} |
|
||||||
if (stats?.replies) score += stats.replies.length * 3 |
|
||||||
if (stats?.reposts) score += stats.reposts.length * 5 |
|
||||||
if (stats?.quotes) score += stats.quotes.length * 8 |
|
||||||
if (stats?.highlights) score += stats.highlights.length * 10 |
|
||||||
return { event, score } |
|
||||||
}) |
|
||||||
|
|
||||||
cachedCustomEvents = { |
|
||||||
events: scoredEvents, |
|
||||||
timestamp: now |
|
||||||
} |
|
||||||
|
|
||||||
setCacheEvents(filteredEvents) |
|
||||||
} catch (error) { |
|
||||||
logger.error('[TrendingNotes] Error initializing cache:', error) |
|
||||||
} finally { |
|
||||||
clearTimeout(timeoutId) |
|
||||||
isInitializing = false |
|
||||||
setCacheLoading(false) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
initializeCache() |
|
||||||
}, []) |
|
||||||
|
|
||||||
const relaysFilteredEventsAll = useMemo(() => { |
|
||||||
const idSet = new Set<string>() |
|
||||||
|
|
||||||
const filtered = cacheEvents.filter((evt) => { |
|
||||||
if (isEventDeleted(evt)) return false |
|
||||||
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false |
|
||||||
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id |
|
||||||
if (idSet.has(id)) return false |
|
||||||
idSet.add(id) |
|
||||||
return true |
|
||||||
}) |
|
||||||
|
|
||||||
filtered.sort((a, b) => { |
|
||||||
if (sortOrder === 'newest') return b.created_at - a.created_at |
|
||||||
if (sortOrder === 'oldest') return a.created_at - b.created_at |
|
||||||
if (sortOrder === 'most-popular' || sortOrder === 'least-popular') { |
|
||||||
const statsA = noteStatsService.getNoteStats(a.id) |
|
||||||
const statsB = noteStatsService.getNoteStats(b.id) |
|
||||||
let scoreA = 0 |
|
||||||
let scoreB = 0 |
|
||||||
if (statsA) { |
|
||||||
scoreA += statsA.likes?.length || 0 |
|
||||||
scoreA += (statsA.replies?.length || 0) * 3 |
|
||||||
scoreA += (statsA.reposts?.length || 0) * 5 |
|
||||||
scoreA += (statsA.quotes?.length || 0) * 8 |
|
||||||
scoreA += (statsA.highlights?.length || 0) * 10 |
|
||||||
if (statsA.zaps) { |
|
||||||
statsA.zaps.forEach((zap) => { |
|
||||||
scoreA += zap.amount >= zapReplyThreshold ? 8 : 1 |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
if (statsB) { |
|
||||||
scoreB += statsB.likes?.length || 0 |
|
||||||
scoreB += (statsB.replies?.length || 0) * 3 |
|
||||||
scoreB += (statsB.reposts?.length || 0) * 5 |
|
||||||
scoreB += (statsB.quotes?.length || 0) * 8 |
|
||||||
scoreB += (statsB.highlights?.length || 0) * 10 |
|
||||||
if (statsB.zaps) { |
|
||||||
statsB.zaps.forEach((zap) => { |
|
||||||
scoreB += zap.amount >= zapReplyThreshold ? 8 : 1 |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
return sortOrder === 'most-popular' ? scoreB - scoreA : scoreA - scoreB |
|
||||||
} |
|
||||||
return 0 |
|
||||||
}) |
|
||||||
|
|
||||||
return filtered |
|
||||||
}, [cacheEvents, hideUntrustedNotes, isEventDeleted, isUserTrusted, sortOrder, zapReplyThreshold]) |
|
||||||
|
|
||||||
const relaysFilteredEvents = useMemo( |
|
||||||
() => relaysFilteredEventsAll.slice(0, showCount), |
|
||||||
[relaysFilteredEventsAll, showCount] |
|
||||||
) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const totalLength = relaysFilteredEventsAll.length |
|
||||||
if (showCount >= totalLength) return |
|
||||||
|
|
||||||
const options = { root: null, rootMargin: '10px', threshold: 0.1 } |
|
||||||
const observerInstance = new IntersectionObserver((entries) => { |
|
||||||
if (entries[0].isIntersecting) { |
|
||||||
setShowCount((prev) => prev + SHOW_COUNT) |
|
||||||
} |
|
||||||
}, options) |
|
||||||
|
|
||||||
const currentBottomRef = bottomRef.current |
|
||||||
if (currentBottomRef) observerInstance.observe(currentBottomRef) |
|
||||||
|
|
||||||
return () => { |
|
||||||
if (currentBottomRef) observerInstance.unobserve(currentBottomRef) |
|
||||||
} |
|
||||||
}, [relaysFilteredEventsAll.length, showCount, cacheLoading]) |
|
||||||
|
|
||||||
const headerTitle = |
|
||||||
trendingRelaySource === 'favorites' |
|
||||||
? t('Trending on Your Favorite Relays') |
|
||||||
: t('Trending on the Default Relays') |
|
||||||
|
|
||||||
const sortToolbar = ( |
|
||||||
<div className="flex flex-wrap items-center gap-2 px-4 pb-3"> |
|
||||||
<span className="text-xs text-muted-foreground">{t('Sort')}:</span> |
|
||||||
<div className="flex flex-wrap gap-1"> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
onClick={() => setSortOrder('newest')} |
|
||||||
className={`rounded px-2 py-1 text-xs transition-colors ${ |
|
||||||
sortOrder === 'newest' |
|
||||||
? 'bg-secondary text-secondary-foreground' |
|
||||||
: 'bg-muted/50 text-muted-foreground hover:bg-muted' |
|
||||||
}`}
|
|
||||||
> |
|
||||||
{t('newest')} |
|
||||||
</button> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
onClick={() => setSortOrder('oldest')} |
|
||||||
className={`rounded px-2 py-1 text-xs transition-colors ${ |
|
||||||
sortOrder === 'oldest' |
|
||||||
? 'bg-secondary text-secondary-foreground' |
|
||||||
: 'bg-muted/50 text-muted-foreground hover:bg-muted' |
|
||||||
}`}
|
|
||||||
> |
|
||||||
{t('oldest')} |
|
||||||
</button> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
onClick={() => setSortOrder('most-popular')} |
|
||||||
className={`rounded px-2 py-1 text-xs transition-colors ${ |
|
||||||
sortOrder === 'most-popular' |
|
||||||
? 'bg-secondary text-secondary-foreground' |
|
||||||
: 'bg-muted/50 text-muted-foreground hover:bg-muted' |
|
||||||
}`}
|
|
||||||
> |
|
||||||
{t('most popular')} |
|
||||||
</button> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
onClick={() => setSortOrder('least-popular')} |
|
||||||
className={`rounded px-2 py-1 text-xs transition-colors ${ |
|
||||||
sortOrder === 'least-popular' |
|
||||||
? 'bg-secondary text-secondary-foreground' |
|
||||||
: 'bg-muted/50 text-muted-foreground hover:bg-muted' |
|
||||||
}`}
|
|
||||||
> |
|
||||||
{t('least popular')} |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
|
|
||||||
const notesBody = ( |
|
||||||
<> |
|
||||||
{cacheLoading && cacheEvents.length === 0 ? ( |
|
||||||
<div |
|
||||||
className={ |
|
||||||
variant === 'searchAccordion' |
|
||||||
? 'px-4 py-6 text-center text-sm text-muted-foreground' |
|
||||||
: 'mt-8 text-center text-sm text-muted-foreground' |
|
||||||
} |
|
||||||
> |
|
||||||
{t('Loading trending notes from your relays...')} |
|
||||||
</div> |
|
||||||
) : null} |
|
||||||
|
|
||||||
{relaysFilteredEvents.map((event) => ( |
|
||||||
<NoteCard |
|
||||||
key={ |
|
||||||
isReplaceableEvent((event as NostrEvent).kind) |
|
||||||
? getReplaceableCoordinateFromEvent(event as NostrEvent) |
|
||||||
: (event as NostrEvent).id |
|
||||||
} |
|
||||||
className="w-full" |
|
||||||
event={event} |
|
||||||
/> |
|
||||||
))} |
|
||||||
|
|
||||||
{cacheLoading || showCount < relaysFilteredEventsAll.length ? ( |
|
||||||
<div ref={bottomRef}> |
|
||||||
<NoteCardLoadingSkeleton /> |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<div className="mt-2 text-center text-sm text-muted-foreground">{t('no more notes')}</div> |
|
||||||
)} |
|
||||||
</> |
|
||||||
) |
|
||||||
|
|
||||||
if (variant === 'searchAccordion') { |
|
||||||
return ( |
|
||||||
<Collapsible open={accordionOpen} onOpenChange={setAccordionOpen} className="min-w-0"> |
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/80 bg-muted/15 px-3 py-2.5 text-left hover:bg-muted/25"> |
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-2"> |
|
||||||
<span className="text-base font-semibold leading-tight">{headerTitle}</span> |
|
||||||
{cacheLoading && cacheEvents.length === 0 ? ( |
|
||||||
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> |
|
||||||
) : null} |
|
||||||
</span> |
|
||||||
<ChevronDown |
|
||||||
className={cn( |
|
||||||
'size-5 shrink-0 text-muted-foreground transition-transform', |
|
||||||
accordionOpen && 'rotate-180' |
|
||||||
)} |
|
||||||
/> |
|
||||||
</CollapsibleTrigger> |
|
||||||
<CollapsibleContent className="overflow-hidden"> |
|
||||||
<div className="mt-2 rounded-lg border border-border/60 bg-background"> |
|
||||||
<div className="border-b border-border/60 bg-muted/10">{sortToolbar}</div> |
|
||||||
{notesBody} |
|
||||||
</div> |
|
||||||
</CollapsibleContent> |
|
||||||
</Collapsible> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="min-h-screen"> |
|
||||||
<div className="sticky top-12 z-30 border-b bg-background"> |
|
||||||
<div className="px-4 pb-3 pt-3"> |
|
||||||
<h2 className="text-lg font-bold leading-tight">{headerTitle}</h2> |
|
||||||
</div> |
|
||||||
{sortToolbar} |
|
||||||
</div> |
|
||||||
{notesBody} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,44 @@ |
|||||||
|
import { READ_ONLY_RELAY_URLS } from '@/constants' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' |
||||||
|
import type { TRelayList } from '@/types' |
||||||
|
|
||||||
|
/** First N NIP-65 `write` (outbox) URLs per followed pubkey, follow-list order; locals first per author. */ |
||||||
|
export const FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR = 2 |
||||||
|
|
||||||
|
/** Plain `ws://` relays are almost always someone else's LAN; the client cannot use them for third-party reads. */ |
||||||
|
function isNonPublicWsRelayUrl(normalizedUrl: string): boolean { |
||||||
|
return normalizedUrl.toLowerCase().startsWith('ws://') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Merge each author's outboxes (capped per author) with {@link READ_ONLY_RELAY_URLS}: |
||||||
|
* normalized, blocked-stripped, deduped (first occurrence wins). |
||||||
|
*/ |
||||||
|
export function buildFollowOutboxAggregateReadUrls( |
||||||
|
relayLists: readonly TRelayList[], |
||||||
|
blockedRelays: readonly string[] |
||||||
|
): string[] { |
||||||
|
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b).filter(Boolean)) |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: string[] = [] |
||||||
|
|
||||||
|
for (const rl of relayLists) { |
||||||
|
const writes = relayUrlsLocalsFirst(rl.write ?? []) |
||||||
|
for (const u of writes.slice(0, FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR)) { |
||||||
|
const n = normalizeUrl(u) || u |
||||||
|
if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue |
||||||
|
seen.add(n) |
||||||
|
out.push(n) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (const u of READ_ONLY_RELAY_URLS) { |
||||||
|
const n = normalizeUrl(u) || u |
||||||
|
if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue |
||||||
|
seen.add(n) |
||||||
|
out.push(n) |
||||||
|
} |
||||||
|
|
||||||
|
return out |
||||||
|
} |
||||||
Loading…
Reference in new issue