You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
367 lines
13 KiB
367 lines
13 KiB
import logger from '@/lib/logger' |
|
import { ExtendedKind } from '@/constants' |
|
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' |
|
import { |
|
readRelayPulseActiveNpubsCache, |
|
writeRelayPulseActiveNpubsCache |
|
} from '@/lib/relay-pulse-active-npubs-cache' |
|
import { hexPubkeysEqual, normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey' |
|
import { getPubkeysFromPTags } from '@/lib/tag' |
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import { queryService, replaceableEventService } from '@/services/client.service' |
|
import indexedDb from '@/services/indexed-db.service' |
|
import storage from '@/services/local-storage.service' |
|
import type { Event } from 'nostr-tools' |
|
import { kinds } from 'nostr-tools' |
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
|
import { |
|
FavoriteRelaysActivityContext, |
|
type TFavoriteRelaysActivityContext |
|
} from './favorite-relays-activity-context' |
|
|
|
const ACTIVE_WINDOW_SEC = 3600 |
|
const FETCH_RETRY_DELAY_MS = 2500 |
|
/** Wall-clock cadence while the tab is visible */ |
|
const POLL_INTERVAL_MS = 60 * 60 * 1000 |
|
/** Event cap for relay pulse query. This is event-count (not author-count): keep high enough for >120 active npubs. */ |
|
const REQ_LIMIT = 500 |
|
/** Keep relay pulse focused on note-like activity to avoid expensive all-kind signature verification bursts. */ |
|
const ACTIVE_PULSE_KINDS = [ |
|
kinds.ShortTextNote, |
|
kinds.Repost, |
|
kinds.LongFormArticle, |
|
kinds.Highlights, |
|
ExtendedKind.DISCUSSION, |
|
ExtendedKind.PICTURE, |
|
ExtendedKind.VIDEO, |
|
ExtendedKind.SHORT_VIDEO, |
|
ExtendedKind.COMMENT, |
|
ExtendedKind.GENERIC_REPOST |
|
] as number[] |
|
|
|
function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] { |
|
const lastByPk = new Map<string, number>() |
|
for (const e of events) { |
|
const prev = lastByPk.get(e.pubkey) ?? 0 |
|
if (e.created_at > prev) lastByPk.set(e.pubkey, e.created_at) |
|
} |
|
return [...lastByPk.entries()] |
|
.sort((a, b) => b[1] - a[1]) |
|
.map(([pk]) => pk) |
|
} |
|
|
|
function partitionByFollows(orderedPubkeys: string[], followings: string[]) { |
|
if (followings.length === 0) { |
|
return { |
|
followPubkeys: [] as string[], |
|
otherPubkeys: orderedPubkeys, |
|
followCount: 0, |
|
otherCount: orderedPubkeys.length |
|
} |
|
} |
|
const followSet = new Set( |
|
followings |
|
.map((p) => userIdToPubkey(p)) |
|
.filter((hex): hex is string => !!hex && /^[0-9a-f]{64}$/i.test(hex)) |
|
.map((hex) => hex.toLowerCase()) |
|
) |
|
const followPubkeys: string[] = [] |
|
const otherPubkeys: string[] = [] |
|
for (const pk of orderedPubkeys) { |
|
const hex = normalizeHexPubkey(pk) |
|
if (hex.length === 64 && followSet.has(hex)) followPubkeys.push(pk) |
|
else otherPubkeys.push(pk) |
|
} |
|
return { |
|
followPubkeys, |
|
otherPubkeys, |
|
followCount: followPubkeys.length, |
|
otherCount: otherPubkeys.length |
|
} |
|
} |
|
|
|
export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) { |
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
const { pubkey: viewerPubkey, followListEvent } = useNostr() |
|
const followings = useMemo( |
|
() => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []), |
|
[followListEvent] |
|
) |
|
const [orderedPubkeys, setOrderedPubkeys] = useState<string[]>([]) |
|
const [loading, setLoading] = useState(false) |
|
const [relayActivityReady, setRelayActivityReady] = useState(false) |
|
const [lastFetchedAtMs, setLastFetchedAtMs] = useState<number | null>(null) |
|
const [profileKind0ByPubkey, setProfileKind0ByPubkey] = useState<Record<string, Event>>({}) |
|
const [profilesLoading, setProfilesLoading] = useState(false) |
|
const [activeNpubsDrawerOpen, setActiveNpubsDrawerOpen] = useState(false) |
|
const [fallbackFollowings, setFallbackFollowings] = useState<string[]>([]) |
|
const lastCompletedFetchAtRef = useRef(Date.now()) |
|
/** Nostr pubkey hydrates async after reload; storage already has current account (init before React mount). */ |
|
const viewerForPulseCache = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null |
|
const orderedPubkeysRef = useRef<string[]>([]) |
|
orderedPubkeysRef.current = orderedPubkeys |
|
/** After restoring from disk, ignore the first empty network result (timeouts / slow relays), then behave normally. */ |
|
const skipFirstEmptyNetworkOverwriteRef = useRef(false) |
|
const relayKey = useMemo( |
|
() => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'), |
|
[favoriteRelays, blockedRelays] |
|
) |
|
|
|
const fetchActive = useCallback( |
|
async (useDefaultRelays = false) => { |
|
const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null |
|
const urls = useDefaultRelays |
|
? getFavoritesFeedRelayUrls([], blockedRelays) |
|
: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) |
|
if (urls.length === 0) { |
|
setLoading(false) |
|
setRelayActivityReady(true) |
|
const now = Date.now() |
|
setOrderedPubkeys([]) |
|
lastCompletedFetchAtRef.current = now |
|
setLastFetchedAtMs(now) |
|
writeRelayPulseActiveNpubsCache({ |
|
relayKey, |
|
viewerPubkey: cacheViewer, |
|
orderedPubkeys: [], |
|
lastFetchedAtMs: now |
|
}) |
|
return |
|
} |
|
setLoading(true) |
|
const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC |
|
try { |
|
const events = await queryService.fetchEvents( |
|
urls, |
|
{ since, limit: REQ_LIMIT, kinds: [...ACTIVE_PULSE_KINDS] }, |
|
{ |
|
firstRelayResultGraceMs: false, |
|
eoseTimeout: 1800, |
|
globalTimeout: 14_000 |
|
} |
|
) |
|
const now = Date.now() |
|
const nextPubkeys = aggregatePubkeysByRecency(events) |
|
const prev = orderedPubkeysRef.current |
|
if ( |
|
skipFirstEmptyNetworkOverwriteRef.current && |
|
nextPubkeys.length === 0 && |
|
prev.length > 0 |
|
) { |
|
skipFirstEmptyNetworkOverwriteRef.current = false |
|
logger.debug('[FavoriteRelaysActivity] kept relay pulse from cache; first fetch returned empty') |
|
} else { |
|
skipFirstEmptyNetworkOverwriteRef.current = false |
|
setOrderedPubkeys(nextPubkeys) |
|
lastCompletedFetchAtRef.current = now |
|
setLastFetchedAtMs(now) |
|
writeRelayPulseActiveNpubsCache({ |
|
relayKey, |
|
viewerPubkey: cacheViewer, |
|
orderedPubkeys: nextPubkeys, |
|
lastFetchedAtMs: now |
|
}) |
|
} |
|
} catch (error) { |
|
logger.debug('[FavoriteRelaysActivity] fetch failed', { error, useDefaultRelays }) |
|
if (!useDefaultRelays && favoriteRelays.length > 0) { |
|
setTimeout(() => void fetchRef.current(true), FETCH_RETRY_DELAY_MS) |
|
} |
|
} finally { |
|
setLoading(false) |
|
setRelayActivityReady(true) |
|
} |
|
}, |
|
[favoriteRelays, blockedRelays, relayKey, viewerPubkey] |
|
) |
|
|
|
const fetchRef = useRef(fetchActive) |
|
fetchRef.current = fetchActive |
|
|
|
/** Reset pulse state when account or relay set changes so we show loading until fresh data. */ |
|
const resetForRefetch = useCallback(() => { |
|
skipFirstEmptyNetworkOverwriteRef.current = false |
|
setRelayActivityReady(false) |
|
setOrderedPubkeys([]) |
|
setProfileKind0ByPubkey({}) |
|
}, []) |
|
|
|
/** Initial fetch on mount and when relay set changes. Use stale-while-revalidate: keep previous |
|
* data visible until new fetch completes instead of clearing and showing skeleton. */ |
|
const prevRelayKeyRef = useRef<string | undefined>(undefined) |
|
useEffect(() => { |
|
if (prevRelayKeyRef.current === undefined) { |
|
prevRelayKeyRef.current = relayKey |
|
void fetchRef.current() |
|
return |
|
} |
|
if (prevRelayKeyRef.current === relayKey) return |
|
prevRelayKeyRef.current = relayKey |
|
void fetchRef.current() |
|
}, [relayKey]) |
|
|
|
/** Logged-in user changed — refetch for the new account. Follow list changes update partition via useMemo. */ |
|
const prevViewerRef = useRef<string | undefined>(undefined) |
|
useEffect(() => { |
|
if (prevViewerRef.current !== undefined && prevViewerRef.current !== viewerPubkey) { |
|
resetForRefetch() |
|
setFallbackFollowings([]) |
|
void fetchRef.current() |
|
} |
|
prevViewerRef.current = viewerPubkey ?? undefined |
|
}, [viewerPubkey, resetForRefetch]) |
|
|
|
/** Restore last successful relay-pulse author list from localStorage (same relay set + viewer). */ |
|
useEffect(() => { |
|
const row = readRelayPulseActiveNpubsCache(relayKey, viewerForPulseCache) |
|
if (!row) return |
|
setOrderedPubkeys(row.orderedPubkeys) |
|
setLastFetchedAtMs(row.lastFetchedAtMs) |
|
setRelayActivityReady(true) |
|
lastCompletedFetchAtRef.current = row.lastFetchedAtMs |
|
skipFirstEmptyNetworkOverwriteRef.current = row.orderedPubkeys.length > 0 |
|
}, [relayKey, viewerForPulseCache]) |
|
|
|
/** When follow list from context is empty but we have a logged-in viewer, try IndexedDB cache. |
|
* Fixes race where pulse data arrives before NostrProvider has hydrated follow list from cache. */ |
|
useEffect(() => { |
|
if (!viewerPubkey || followings.length > 0) { |
|
setFallbackFollowings([]) |
|
return |
|
} |
|
let cancelled = false |
|
indexedDb |
|
.getReplaceableEvent(viewerPubkey, kinds.Contacts) |
|
.then((evt) => { |
|
if (cancelled || !evt) return |
|
setFallbackFollowings(getPubkeysFromPTags(evt.tags)) |
|
}) |
|
.catch(() => {}) |
|
return () => { |
|
cancelled = true |
|
} |
|
}, [viewerPubkey, followings.length]) |
|
|
|
/** While the document is visible: poll once per hour; when returning after a long background, catch up if due. */ |
|
useEffect(() => { |
|
let intervalId: ReturnType<typeof setInterval> | undefined |
|
|
|
const runTick = () => { |
|
void fetchRef.current() |
|
} |
|
|
|
const syncPolling = () => { |
|
if (document.visibilityState !== 'visible') { |
|
if (intervalId !== undefined) { |
|
clearInterval(intervalId) |
|
intervalId = undefined |
|
} |
|
return |
|
} |
|
if (intervalId === undefined) { |
|
intervalId = setInterval(runTick, POLL_INTERVAL_MS) |
|
} |
|
if (Date.now() - lastCompletedFetchAtRef.current >= POLL_INTERVAL_MS) { |
|
runTick() |
|
} |
|
} |
|
|
|
syncPolling() |
|
document.addEventListener('visibilitychange', syncPolling) |
|
return () => { |
|
document.removeEventListener('visibilitychange', syncPolling) |
|
if (intervalId !== undefined) clearInterval(intervalId) |
|
} |
|
}, []) |
|
|
|
const profileFetchKeys = useMemo(() => { |
|
if (!viewerPubkey) return orderedPubkeys |
|
return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey)) |
|
}, [orderedPubkeys, viewerPubkey]) |
|
|
|
useEffect(() => { |
|
if (profileFetchKeys.length === 0) { |
|
setProfileKind0ByPubkey({}) |
|
setProfilesLoading(false) |
|
return |
|
} |
|
let cancelled = false |
|
setProfilesLoading(true) |
|
;(async () => { |
|
try { |
|
const events = await replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( |
|
profileFetchKeys, |
|
kinds.Metadata |
|
) |
|
if (cancelled) return |
|
const next: Record<string, Event> = {} |
|
profileFetchKeys.forEach((pk, i) => { |
|
const e = events[i] |
|
if (e) next[pk] = e |
|
}) |
|
setProfileKind0ByPubkey(next) |
|
} catch (err) { |
|
logger.debug('[FavoriteRelaysActivity] profile batch failed', { err }) |
|
if (!cancelled) setProfileKind0ByPubkey({}) |
|
} finally { |
|
if (!cancelled) setProfilesLoading(false) |
|
} |
|
})() |
|
return () => { |
|
cancelled = true |
|
} |
|
}, [profileFetchKeys]) |
|
|
|
const displayPubkeys = useMemo(() => { |
|
if (!viewerPubkey) return orderedPubkeys |
|
return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey)) |
|
}, [orderedPubkeys, viewerPubkey]) |
|
|
|
const effectiveFollowings = followings.length > 0 ? followings : fallbackFollowings |
|
const { followPubkeys, otherPubkeys, followCount, otherCount } = useMemo( |
|
() => partitionByFollows(displayPubkeys, effectiveFollowings), |
|
[displayPubkeys, effectiveFollowings] |
|
) |
|
|
|
const pubkeys = useMemo( |
|
() => [...followPubkeys, ...otherPubkeys], |
|
[followPubkeys, otherPubkeys] |
|
) |
|
|
|
const value: TFavoriteRelaysActivityContext = useMemo( |
|
() => ({ |
|
followPubkeys, |
|
otherPubkeys, |
|
followCount, |
|
otherCount, |
|
pubkeys, |
|
totalCount: displayPubkeys.length, |
|
loading, |
|
relayActivityReady, |
|
lastFetchedAtMs, |
|
profileKind0ByPubkey, |
|
profilesLoading, |
|
activeNpubsDrawerOpen, |
|
setActiveNpubsDrawerOpen, |
|
refetch: fetchActive |
|
}), |
|
[ |
|
followPubkeys, |
|
otherPubkeys, |
|
followCount, |
|
otherCount, |
|
pubkeys, |
|
displayPubkeys.length, |
|
loading, |
|
relayActivityReady, |
|
lastFetchedAtMs, |
|
profileKind0ByPubkey, |
|
profilesLoading, |
|
activeNpubsDrawerOpen, |
|
fetchActive |
|
] |
|
) |
|
|
|
return <FavoriteRelaysActivityContext.Provider value={value}>{children}</FavoriteRelaysActivityContext.Provider> |
|
}
|
|
|