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.
 
 
 
 

430 lines
15 KiB

import logger from '@/lib/logger'
import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { buildLiveActivitiesRelayUrls } from '@/lib/live-activities'
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
/** Recent slice (seconds): newest notes dominate global REQ limits; a shorter window improves author diversity. */
const PULSE_RECENT_TAIL_SEC = 1200
/** Per-REQ event cap; two time slices run in parallel and merge (see {@link fetchRelayPulseNoteEvents}). */
const PULSE_REQ_LIMIT_RECENT = 900
const PULSE_REQ_LIMIT_EARLIER = 1400
const FETCH_RETRY_DELAY_MS = 2500
/** Wall-clock cadence while the tab is visible */
const POLL_INTERVAL_MS = 60 * 60 * 1000
/** 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,
...NIP71_VIDEO_KINDS,
ExtendedKind.COMMENT,
ExtendedKind.GENERIC_REPOST
] as number[]
const PULSE_QUERY_OPTS = {
firstRelayResultGraceMs: false as const,
eoseTimeout: 1800,
globalTimeout: 14_000
}
function mergeRelayPulseEventsById(events: { id: string; pubkey: string; created_at: number }[]) {
const byId = new Map<string, (typeof events)[0]>()
for (const e of events) {
const id = e.id?.trim().toLowerCase()
if (!id || !/^[0-9a-f]{64}$/i.test(id)) continue
const prev = byId.get(id)
if (!prev || e.created_at > prev.created_at) byId.set(id, e)
}
return [...byId.values()]
}
/**
* One REQ with a high `limit` over a full hour mostly returns the newest notes, so a few threads can
* exhaust the cap and hide many active npubs. Two slices (recent tail + earlier in the same hour)
* merge by id, then we dedupe by pubkey for the widget.
*/
async function fetchRelayPulseNoteEvents(
urls: string[],
anchorSec: number
): Promise<{ pubkey: string; created_at: number; id: string }[]> {
const sinceFull = anchorSec - ACTIVE_WINDOW_SEC
const recentSince = anchorSec - PULSE_RECENT_TAIL_SEC
const kinds = [...ACTIVE_PULSE_KINDS]
const settled = await Promise.allSettled([
queryService.fetchEvents(
urls,
{ since: recentSince, limit: PULSE_REQ_LIMIT_RECENT, kinds },
PULSE_QUERY_OPTS
),
queryService.fetchEvents(
urls,
{
since: sinceFull,
until: recentSince,
limit: PULSE_REQ_LIMIT_EARLIER,
kinds
},
PULSE_QUERY_OPTS
)
])
const merged: { id: string; pubkey: string; created_at: number }[] = []
for (const r of settled) {
if (r.status === 'fulfilled') merged.push(...r.value)
}
return mergeRelayPulseEventsById(merged)
}
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, relayList } = 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 pulseQueryUrls = useMemo(
() =>
buildLiveActivitiesRelayUrls({
loggedIn: !!viewerPubkey,
favoriteRelays,
blockedRelays,
relayListRead: userReadRelaysWithHttp(relayList),
relayListWrite: relayList?.write ?? []
}),
[viewerPubkey, favoriteRelays, blockedRelays, relayList]
)
const relayKey = useMemo(() => pulseQueryUrls.join('\n'), [pulseQueryUrls])
const fetchActive = useCallback(
async (useDefaultRelays = false) => {
const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null
const urls = useDefaultRelays
? buildLiveActivitiesRelayUrls({
loggedIn: false,
favoriteRelays: [],
blockedRelays,
relayListRead: [],
relayListWrite: []
})
: pulseQueryUrls
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 anchorSec = Math.floor(Date.now() / 1000)
try {
const events = await fetchRelayPulseNoteEvents(urls, anchorSec)
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, pulseQueryUrls]
)
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((prev) => (prev.length ? [] : prev))
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>
}