41 changed files with 373 additions and 1168 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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