41 changed files with 373 additions and 1168 deletions
@ -1,205 +0,0 @@
@@ -1,205 +0,0 @@
|
||||
import UserAvatar from '@/components/UserAvatar' |
||||
import ProfileAbout from '@/components/ProfileAbout' |
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Sheet, |
||||
SheetContent, |
||||
SheetDescription, |
||||
SheetHeader, |
||||
SheetTitle |
||||
} from '@/components/ui/sheet' |
||||
import { getProfileFromEvent } from '@/lib/event-metadata' |
||||
import { cn } from '@/lib/utils' |
||||
import { toProfile } from '@/lib/link' |
||||
import { |
||||
collectAggregatedNip05sFromKind0 |
||||
} from '@/lib/relay-pulse-nip05' |
||||
import { useMuteList } from '@/contexts/mute-list-context' |
||||
import { muteSetHas } from '@/lib/mute-set' |
||||
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' |
||||
import { SecondaryPageLink } from '@/PageManager' |
||||
import { useRelativePastPhrase } from '@/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time' |
||||
import type { Event } from 'nostr-tools' |
||||
import { Users } from 'lucide-react' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
function CompactProfileCard({ event }: { event: Event }) { |
||||
const profile = getProfileFromEvent(event) |
||||
const nip05s = collectAggregatedNip05sFromKind0(event) |
||||
const { setActiveNpubsDrawerOpen } = useFavoriteRelaysActivity() |
||||
const profileUrl = toProfile(event.pubkey) |
||||
const closeDrawer = () => setActiveNpubsDrawerOpen(false) |
||||
|
||||
return ( |
||||
<div className="rounded-lg border border-border/80 bg-muted/20 p-3"> |
||||
<div className="flex gap-3"> |
||||
<UserAvatar userId={event.pubkey} size="semiBig" /> |
||||
<div className="min-w-0 flex-1"> |
||||
<SecondaryPageLink |
||||
to={profileUrl} |
||||
className="font-semibold text-foreground hover:underline" |
||||
onClick={closeDrawer} |
||||
> |
||||
{profile.username} |
||||
</SecondaryPageLink> |
||||
<ProfileAbout |
||||
about={profile.about} |
||||
className="mt-1 line-clamp-4 text-xs leading-snug text-muted-foreground break-words" |
||||
/> |
||||
{nip05s.length > 0 ? ( |
||||
<ul className="mt-2 space-y-0.5 text-xs"> |
||||
{nip05s.map((id) => ( |
||||
<li key={id} className="truncate font-mono"> |
||||
<SecondaryPageLink |
||||
to={profileUrl} |
||||
className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors" |
||||
onClick={closeDrawer} |
||||
> |
||||
{id} |
||||
</SecondaryPageLink> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
) : null} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
export function RelayPulseActiveNpubsOpenButton({ |
||||
className, |
||||
size = 'sm', |
||||
variant = 'outline' |
||||
}: { |
||||
className?: string |
||||
size?: 'sm' | 'icon' |
||||
variant?: 'outline' | 'ghost' |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { setActiveNpubsDrawerOpen, totalCount } = useFavoriteRelaysActivity() |
||||
|
||||
if (totalCount === 0) return null |
||||
|
||||
const countLabel = ( |
||||
<span className="tabular-nums font-medium"> |
||||
{totalCount > 99 ? '99+' : totalCount} |
||||
</span> |
||||
) |
||||
|
||||
return ( |
||||
<Button |
||||
type="button" |
||||
variant={variant} |
||||
size={size} |
||||
className={cn(className, 'relative')} |
||||
aria-label={t('Relay pulse active npubs')} |
||||
title={t('Relay pulse active npubs')} |
||||
onClick={() => setActiveNpubsDrawerOpen(true)} |
||||
> |
||||
<Users className={size === 'icon' ? 'size-4' : 'size-3.5 shrink-0'} /> |
||||
{size === 'icon' ? ( |
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[0.6rem] font-medium text-primary-foreground"> |
||||
{countLabel} |
||||
</span> |
||||
) : ( |
||||
<> |
||||
<span className="ml-1.5 text-xs font-medium">{countLabel}</span> |
||||
<span className="ml-1 text-xs text-muted-foreground"> |
||||
{t('Relay pulse active npubs')} |
||||
</span> |
||||
</> |
||||
)} |
||||
</Button> |
||||
) |
||||
} |
||||
|
||||
/** Mounted once inside {@link FavoriteRelaysActivityProvider}. */ |
||||
export function RelayPulseActiveNpubsSheet() { |
||||
const { t } = useTranslation() |
||||
const { mutePubkeySet } = useMuteList() |
||||
const { |
||||
activeNpubsDrawerOpen, |
||||
setActiveNpubsDrawerOpen, |
||||
followPubkeys, |
||||
otherPubkeys, |
||||
profileKind0ByPubkey, |
||||
profilesLoading, |
||||
lastFetchedAtMs |
||||
} = useFavoriteRelaysActivity() |
||||
|
||||
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) |
||||
|
||||
const followWithProfile = useMemo( |
||||
() => |
||||
followPubkeys.filter( |
||||
(pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk) |
||||
), |
||||
[followPubkeys, profileKind0ByPubkey, mutePubkeySet] |
||||
) |
||||
const othersWithProfile = useMemo( |
||||
() => |
||||
otherPubkeys.filter( |
||||
(pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk) |
||||
), |
||||
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet] |
||||
) |
||||
|
||||
return ( |
||||
<Sheet open={activeNpubsDrawerOpen} onOpenChange={setActiveNpubsDrawerOpen}> |
||||
<SheetContent |
||||
side="right" |
||||
className="flex h-full max-h-[100dvh] w-full flex-col overflow-hidden sm:max-w-md" |
||||
> |
||||
<SheetHeader className="shrink-0 space-y-1 text-left"> |
||||
<SheetTitle>{t('Relay pulse active npubs')}</SheetTitle> |
||||
{lastFetchedAtMs != null && relativeLabel ? ( |
||||
<p className="text-xs text-muted-foreground tabular-nums"> |
||||
{t('Relay pulse updated', { relative: relativeLabel })} |
||||
</p> |
||||
) : null} |
||||
<SheetDescription>{t('Relay pulse active npubs hint')}</SheetDescription> |
||||
</SheetHeader> |
||||
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-3"> |
||||
{profilesLoading ? ( |
||||
<p className="text-sm text-muted-foreground">{t('Loading...')}</p> |
||||
) : null} |
||||
<div className="space-y-6 pb-6"> |
||||
{followWithProfile.length > 0 ? ( |
||||
<section> |
||||
<h3 className="mb-2 text-sm font-semibold text-foreground"> |
||||
{t('Relay pulse drawer following')} |
||||
</h3> |
||||
<div className="space-y-2"> |
||||
{followWithProfile.map((pk) => { |
||||
const ev = profileKind0ByPubkey[pk] |
||||
return ev ? <CompactProfileCard key={pk} event={ev} /> : null |
||||
})} |
||||
</div> |
||||
</section> |
||||
) : null} |
||||
{othersWithProfile.length > 0 ? ( |
||||
<section> |
||||
<h3 className="mb-2 text-sm font-semibold text-foreground"> |
||||
{t('Relay pulse drawer others')} |
||||
</h3> |
||||
<div className="space-y-2"> |
||||
{othersWithProfile.map((pk) => { |
||||
const ev = profileKind0ByPubkey[pk] |
||||
return ev ? <CompactProfileCard key={pk} event={ev} /> : null |
||||
})} |
||||
</div> |
||||
</section> |
||||
) : null} |
||||
{!profilesLoading && |
||||
followWithProfile.length === 0 && |
||||
othersWithProfile.length === 0 ? ( |
||||
<p className="text-sm text-muted-foreground">{t('Relay pulse drawer no profiles')}</p> |
||||
) : null} |
||||
</div> |
||||
</div> |
||||
</SheetContent> |
||||
</Sheet> |
||||
) |
||||
} |
||||
@ -1,96 +0,0 @@
@@ -1,96 +0,0 @@
|
||||
import { cn } from '@/lib/utils' |
||||
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' |
||||
import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export { relativePastPhrase, useRelativePastPhrase } from './relay-pulse-relative-time' |
||||
|
||||
/** Home feed / mobile: compact row above the page title (no section label — opens sheet for detail). */ |
||||
export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { |
||||
const { t } = useTranslation() |
||||
const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity() |
||||
|
||||
if (!relayActivityReady && !loading) { |
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/15 px-3 py-1.5 sm:px-4 animate-pulse', |
||||
className |
||||
)} |
||||
aria-hidden |
||||
> |
||||
<div className="ml-auto h-7 w-28 rounded-md bg-muted/50" /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (relayActivityReady && !loading && totalCount === 0) { |
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/20 px-3 py-1.5 sm:px-4', |
||||
className |
||||
)} |
||||
> |
||||
<p className="text-xs text-muted-foreground leading-snug">{t('Relay pulse empty')}</p> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'flex w-full min-w-0 max-w-full items-center justify-end border-b border-border/60 bg-muted/15 px-3 py-1.5 sm:px-4', |
||||
loading && 'animate-pulse', |
||||
className |
||||
)} |
||||
> |
||||
<RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0 max-w-full" /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
/** Desktop sidebar: compact row under nav */ |
||||
export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) { |
||||
const { t } = useTranslation() |
||||
const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity() |
||||
|
||||
if (!relayActivityReady && !loading) { |
||||
return ( |
||||
<div className={cn('px-1 py-2 xl:px-0 animate-pulse', className)}> |
||||
<p className="text-[0.65rem] font-medium leading-snug text-foreground">{t('Relay pulse')}</p> |
||||
<div className="mt-0.5 h-4 w-16 rounded bg-muted/50" aria-hidden /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (relayActivityReady && !loading && totalCount === 0) { |
||||
return ( |
||||
<div className={cn('hidden px-1 py-2 xl:block xl:px-0', className)}> |
||||
<div className="flex flex-wrap items-center gap-1.5 px-1"> |
||||
<p className="text-[0.65rem] font-medium leading-snug text-foreground">{t('Relay pulse')}</p> |
||||
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" /> |
||||
</div> |
||||
<p className="mt-1 px-1 text-[0.65rem] leading-snug text-muted-foreground"> |
||||
{t('Relay pulse empty')} |
||||
</p> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className={cn('px-1 py-2 xl:px-0', loading && 'animate-pulse', className)}> |
||||
<div className="max-xl:hidden mb-0.5 flex flex-wrap items-center gap-1 px-1"> |
||||
<p className="min-w-0 flex-1 text-[0.65rem] font-medium leading-snug text-foreground"> |
||||
{t('Relay pulse')} |
||||
</p> |
||||
<div className="flex shrink-0 items-center gap-0.5"> |
||||
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" /> |
||||
</div> |
||||
</div> |
||||
<div className="mb-1 flex justify-center gap-0.5 xl:hidden"> |
||||
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" /> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -1,26 +0,0 @@
@@ -1,26 +0,0 @@
|
||||
import type { TFunction } from 'i18next' |
||||
import { useEffect, useMemo, useState } from 'react' |
||||
|
||||
export function relativePastPhrase(timestampMs: number, t: TFunction): string { |
||||
const sec = Math.floor((Date.now() - timestampMs) / 1000) |
||||
if (sec < 45) return t('just now') |
||||
const min = Math.floor(sec / 60) |
||||
if (min < 60) return t('n minutes ago', { n: min }) |
||||
const h = Math.floor(min / 60) |
||||
if (h < 48) return t('n hours ago', { n: h }) |
||||
const d = Math.floor(h / 24) |
||||
return t('n days ago', { n: d }) |
||||
} |
||||
|
||||
export function useRelativePastPhrase(timestampMs: number | null, t: TFunction): string { |
||||
const [tick, setTick] = useState(0) |
||||
useEffect(() => { |
||||
if (timestampMs == null) return |
||||
const id = window.setInterval(() => setTick((x) => x + 1), 30_000) |
||||
return () => clearInterval(id) |
||||
}, [timestampMs]) |
||||
return useMemo(() => { |
||||
if (timestampMs == null) return '' |
||||
return relativePastPhrase(timestampMs, t) |
||||
}, [timestampMs, t, tick]) |
||||
} |
||||
@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' |
||||
import { downloadEventAsMarkdownFile } from '@/lib/download-event-markdown' |
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||
import { parseNostrSpecAffectedKindsFromEvent } from '@/lib/nostr-spec-affected-kinds' |
||||
import { toNote } from '@/lib/link' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSecondaryPageOptional } from '@/PageManager' |
||||
import { Download } from 'lucide-react' |
||||
import type { Event } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
|
||||
/** |
||||
* Compact feed card for Nostr specifications (kind 30817): title, short blurb, affected kinds — no cover images. |
||||
*/ |
||||
export default function NostrSpecCard({ |
||||
event, |
||||
className, |
||||
interactive = true |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
interactive?: boolean |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const secondaryPage = useSecondaryPageOptional() |
||||
const push = secondaryPage?.push ?? ((url: string) => { |
||||
window.location.href = url |
||||
}) |
||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content]) |
||||
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim() |
||||
const affectedKinds = useMemo(() => parseNostrSpecAffectedKindsFromEvent(event), [event]) |
||||
const displayTitle = metadata.title?.trim() || t('Nostr Specification') |
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => { |
||||
if (!interactive) return |
||||
e.stopPropagation() |
||||
push(toNote(event)) |
||||
} |
||||
|
||||
const handleDownload = (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
try { |
||||
downloadEventAsMarkdownFile(event, metadata.title) |
||||
toast.success(t('Article exported as Markdown')) |
||||
} catch { |
||||
toast.error(t('Failed to export article')) |
||||
} |
||||
} |
||||
|
||||
const cardClass = cn( |
||||
'rounded-lg border px-3 py-2.5 transition-colors', |
||||
interactive && 'cursor-pointer hover:bg-muted/50' |
||||
) |
||||
|
||||
return ( |
||||
<div className={cn(className, !interactive && 'pointer-events-none')}> |
||||
<div className={cardClass} onClick={interactive ? handleCardClick : undefined}> |
||||
<div className="flex items-start gap-2"> |
||||
<div className="min-w-0 flex-1 space-y-1"> |
||||
<div className="text-base font-semibold leading-snug break-words line-clamp-2">{displayTitle}</div> |
||||
{summaryText ? ( |
||||
<p className="text-sm leading-snug text-muted-foreground line-clamp-2 break-words">{summaryText}</p> |
||||
) : null} |
||||
{affectedKinds.length > 0 ? ( |
||||
<p className="text-xs tabular-nums text-muted-foreground/90"> |
||||
{t('Nostr spec affected kinds', { |
||||
kinds: affectedKinds.join(', ') |
||||
})} |
||||
</p> |
||||
) : null} |
||||
</div> |
||||
{interactive ? ( |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="size-8 shrink-0 text-muted-foreground hover:text-foreground" |
||||
title={t('Download as Markdown file')} |
||||
aria-label={t('Download as Markdown file')} |
||||
onClick={handleDownload} |
||||
> |
||||
<Download className="size-4" /> |
||||
</Button> |
||||
) : null} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
function markdownFilename(title: string | undefined): string { |
||||
const base = (title?.trim() || 'document') |
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '') |
||||
.replace(/\s+/g, ' ') |
||||
.trim() |
||||
.slice(0, 120) |
||||
return `${base || 'document'}.md` |
||||
} |
||||
|
||||
/** Trigger a browser download of the event body as a `.md` file. */ |
||||
export function downloadEventAsMarkdownFile(event: Event, title?: string): void { |
||||
const filename = markdownFilename(title) |
||||
const blob = new Blob([event.content], { type: 'text/markdown;charset=utf-8' }) |
||||
const url = URL.createObjectURL(blob) |
||||
const anchor = document.createElement('a') |
||||
anchor.href = url |
||||
anchor.download = filename |
||||
document.body.appendChild(anchor) |
||||
anchor.click() |
||||
document.body.removeChild(anchor) |
||||
URL.revokeObjectURL(url) |
||||
} |
||||
@ -1,38 +0,0 @@
@@ -1,38 +0,0 @@
|
||||
import logger from '@/lib/logger' |
||||
|
||||
/** One row per browser; overwritten whenever a new active-npub list is fetched for the same relay + viewer scope. */ |
||||
export type RelayPulseActiveNpubsCacheRow = { |
||||
relayKey: string |
||||
viewerPubkey: string | null |
||||
orderedPubkeys: string[] |
||||
lastFetchedAtMs: number |
||||
} |
||||
|
||||
const STORAGE_KEY = 'jumble.relayPulse.activeNpubs.v1' |
||||
|
||||
export function readRelayPulseActiveNpubsCache( |
||||
relayKey: string, |
||||
viewerPubkey: string | null |
||||
): Pick<RelayPulseActiveNpubsCacheRow, 'orderedPubkeys' | 'lastFetchedAtMs'> | null { |
||||
try { |
||||
const raw = localStorage.getItem(STORAGE_KEY) |
||||
if (!raw) return null |
||||
const data = JSON.parse(raw) as unknown |
||||
if (!data || typeof data !== 'object') return null |
||||
const o = data as Record<string, unknown> |
||||
if (o.relayKey !== relayKey || o.viewerPubkey !== viewerPubkey) return null |
||||
if (!Array.isArray(o.orderedPubkeys) || typeof o.lastFetchedAtMs !== 'number') return null |
||||
const orderedPubkeys = o.orderedPubkeys.filter((x): x is string => typeof x === 'string') |
||||
return { orderedPubkeys, lastFetchedAtMs: o.lastFetchedAtMs } |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
export function writeRelayPulseActiveNpubsCache(row: RelayPulseActiveNpubsCacheRow): void { |
||||
try { |
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(row)) |
||||
} catch (e) { |
||||
logger.debug('[RelayPulseActiveNpubsCache] write failed', { error: e }) |
||||
} |
||||
} |
||||
@ -1,29 +0,0 @@
@@ -1,29 +0,0 @@
|
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
function addNip05(set: Set<string>, raw: unknown) { |
||||
if (typeof raw !== 'string') return |
||||
const t = raw.trim() |
||||
if (t) set.add(t) |
||||
} |
||||
|
||||
/** |
||||
* All NIP-05 identifiers from kind 0: every `nip05` tag plus JSON `nip05` (string or string array). |
||||
* Deduplicated, order not preserved. |
||||
*/ |
||||
export function collectAggregatedNip05sFromKind0(event: Event): string[] { |
||||
const set = new Set<string>() |
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'nip05' && tag[1]) addNip05(set, tag[1]) |
||||
} |
||||
try { |
||||
const obj = JSON.parse(event.content || '{}') as Record<string, unknown> |
||||
const j = obj.nip05 |
||||
if (typeof j === 'string') addNip05(set, j) |
||||
else if (Array.isArray(j)) { |
||||
for (const x of j) addNip05(set, x) |
||||
} |
||||
} catch { |
||||
// ignore invalid JSON
|
||||
} |
||||
return [...set] |
||||
} |
||||
@ -1,447 +0,0 @@
@@ -1,447 +0,0 @@
|
||||
import storage from '@/services/local-storage.service' |
||||
import logger from '@/lib/logger' |
||||
import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' |
||||
import { buildRelayPulseQueryRelayUrls } from '@/lib/home-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 { registerSessionInteractivePrewarmListener } from '@/services/session-interactive-prewarm-bridge' |
||||
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 caps for the sidebar relay pulse. Keep small: each event is Schnorr-verified on the WebSocket |
||||
* thread in nostr-tools; limits of 900+1400 caused main-thread timeouts in verifyEvent when relays returned large batches. |
||||
*/ |
||||
const PULSE_REQ_LIMIT_RECENT = 120 |
||||
const PULSE_REQ_LIMIT_EARLIER = 160 |
||||
/** Hard cap after merging two slices — enough for pubkey diversity without megabytes of verification work. */ |
||||
const PULSE_MERGED_EVENT_CAP = 400 |
||||
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) |
||||
} |
||||
const deduped = mergeRelayPulseEventsById(merged) |
||||
deduped.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id)) |
||||
return deduped.slice(0, PULSE_MERGED_EVENT_CAP) |
||||
} |
||||
|
||||
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, relaySets } = useFavoriteRelays() |
||||
const { pubkey: viewerPubkey, followListEvent, relayList, cacheRelayListEvent, httpRelayListEvent } = |
||||
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 favoriteRelayUrlsForPulse = useMemo( |
||||
() => [...favoriteRelays, ...relaySets.flatMap((rs) => rs.relayUrls)], |
||||
[favoriteRelays, relaySets] |
||||
) |
||||
|
||||
const pulseQueryUrls = useMemo( |
||||
() => |
||||
buildRelayPulseQueryRelayUrls({ |
||||
viewerPubkey, |
||||
favoriteRelayUrls: favoriteRelayUrlsForPulse, |
||||
blockedRelays, |
||||
relayList, |
||||
cacheRelayListEvent, |
||||
httpRelayListEvent |
||||
}), |
||||
[ |
||||
viewerPubkey, |
||||
favoriteRelayUrlsForPulse, |
||||
blockedRelays, |
||||
relayList, |
||||
cacheRelayListEvent, |
||||
httpRelayListEvent |
||||
] |
||||
) |
||||
|
||||
const relayKey = useMemo(() => pulseQueryUrls.join('\n'), [pulseQueryUrls]) |
||||
|
||||
const fetchActive = useCallback(async () => { |
||||
const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null |
||||
const urls = 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 }) |
||||
if (pulseQueryUrls.length > 0) { |
||||
setTimeout(() => void fetchRef.current(), FETCH_RETRY_DELAY_MS) |
||||
} |
||||
} finally { |
||||
setLoading(false) |
||||
setRelayActivityReady(true) |
||||
} |
||||
}, [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]) |
||||
|
||||
/** After session interactive prewarm, relay URLs / follow context are stable — refresh pulse once. */ |
||||
useEffect(() => { |
||||
return registerSessionInteractivePrewarmListener(() => { |
||||
void fetchRef.current() |
||||
}) |
||||
}, []) |
||||
|
||||
/** 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> |
||||
} |
||||
@ -1,37 +0,0 @@
@@ -1,37 +0,0 @@
|
||||
import type { Event } from 'nostr-tools' |
||||
import { createContext, useContext } from 'react' |
||||
|
||||
export type TFavoriteRelaysActivityContext = { |
||||
/** Active pubkeys you follow, most recent global activity first within this group */ |
||||
followPubkeys: string[] |
||||
/** Active pubkeys you do not follow */ |
||||
otherPubkeys: string[] |
||||
followCount: number |
||||
otherCount: number |
||||
/** `followPubkeys` then `otherPubkeys` */ |
||||
pubkeys: string[] |
||||
totalCount: number |
||||
loading: boolean |
||||
/** True after at least one fetch has finished (so empty state is meaningful) */ |
||||
relayActivityReady: boolean |
||||
/** Wall-clock ms when the last sample completed; null before first fetch */ |
||||
lastFetchedAtMs: number | null |
||||
/** Kind 0 events loaded for active pubkeys (viewer excluded); used for avatars + drawer */ |
||||
profileKind0ByPubkey: Record<string, Event> |
||||
profilesLoading: boolean |
||||
activeNpubsDrawerOpen: boolean |
||||
setActiveNpubsDrawerOpen: (open: boolean) => void |
||||
refetch: () => void |
||||
} |
||||
|
||||
export const FavoriteRelaysActivityContext = createContext< |
||||
TFavoriteRelaysActivityContext | undefined |
||||
>(undefined) |
||||
|
||||
export function useFavoriteRelaysActivity(): TFavoriteRelaysActivityContext { |
||||
const ctx = useContext(FavoriteRelaysActivityContext) |
||||
if (!ctx) { |
||||
throw new Error('useFavoriteRelaysActivity must be used within FavoriteRelaysActivityProvider') |
||||
} |
||||
return ctx |
||||
} |
||||
Loading…
Reference in new issue