19 changed files with 1020 additions and 55 deletions
@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
|
||||
import UserAvatar from '@/components/UserAvatar' |
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Sheet, |
||||
SheetContent, |
||||
SheetDescription, |
||||
SheetHeader, |
||||
SheetTitle |
||||
} from '@/components/ui/sheet' |
||||
import { getProfileFromEvent } from '@/lib/event-metadata' |
||||
import { toProfile } from '@/lib/link' |
||||
import { |
||||
collectAggregatedNip05sFromKind0, |
||||
truncateAbout |
||||
} from '@/lib/relay-pulse-nip05' |
||||
import { useMuteList } from '@/contexts/mute-list-context' |
||||
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' |
||||
import { SecondaryPageLink } from '@/PageManager' |
||||
import type { Event } from 'nostr-tools' |
||||
import { Users } from 'lucide-react' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
const ABOUT_PREVIEW_LEN = 250 |
||||
|
||||
function CompactProfileCard({ event }: { event: Event }) { |
||||
const profile = getProfileFromEvent(event) |
||||
const nip05s = collectAggregatedNip05sFromKind0(event) |
||||
const about = truncateAbout(profile.about, ABOUT_PREVIEW_LEN) |
||||
|
||||
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={toProfile(event.pubkey)} |
||||
className="font-semibold text-foreground hover:underline" |
||||
> |
||||
{profile.username} |
||||
</SecondaryPageLink> |
||||
{about ? ( |
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground whitespace-pre-wrap break-words"> |
||||
{about} |
||||
</p> |
||||
) : null} |
||||
{nip05s.length > 0 ? ( |
||||
<ul className="mt-2 space-y-0.5 text-xs text-primary"> |
||||
{nip05s.map((id) => ( |
||||
<li key={id} className="truncate font-mono"> |
||||
{id} |
||||
</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 |
||||
|
||||
return ( |
||||
<Button |
||||
type="button" |
||||
variant={variant} |
||||
size={size} |
||||
className={className} |
||||
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="ml-1.5 text-xs font-medium">{t('Relay pulse active npubs')}</span> |
||||
) : null} |
||||
</Button> |
||||
) |
||||
} |
||||
|
||||
/** Mounted once inside {@link FavoriteRelaysActivityProvider}. */ |
||||
export function RelayPulseActiveNpubsSheet() { |
||||
const { t } = useTranslation() |
||||
const { mutePubkeySet } = useMuteList() |
||||
const { |
||||
activeNpubsDrawerOpen, |
||||
setActiveNpubsDrawerOpen, |
||||
followPubkeys, |
||||
otherPubkeys, |
||||
profileKind0ByPubkey, |
||||
profilesLoading |
||||
} = useFavoriteRelaysActivity() |
||||
|
||||
const followWithProfile = useMemo( |
||||
() => |
||||
followPubkeys.filter( |
||||
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) |
||||
), |
||||
[followPubkeys, profileKind0ByPubkey, mutePubkeySet] |
||||
) |
||||
const othersWithProfile = useMemo( |
||||
() => |
||||
otherPubkeys.filter( |
||||
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(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 text-left"> |
||||
<SheetTitle>{t('Relay pulse active npubs')}</SheetTitle> |
||||
<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> |
||||
) |
||||
} |
||||
@ -0,0 +1,378 @@
@@ -0,0 +1,378 @@
|
||||
import UserAvatar from '@/components/UserAvatar' |
||||
import { SimpleUsername } from '@/components/Username' |
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' |
||||
import { cn } from '@/lib/utils' |
||||
import { useMuteList } from '@/contexts/mute-list-context' |
||||
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' |
||||
import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet' |
||||
import type { TFunction } from 'i18next' |
||||
import { useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
const MOBILE_MAX_FOLLOW = 8 |
||||
const MOBILE_MAX_OTHER = 8 |
||||
const SIDEBAR_MAX_FOLLOW = 5 |
||||
const SIDEBAR_MAX_OTHER = 5 |
||||
|
||||
/** Slight overlap so faces stay recognizable */ |
||||
const AVATAR_OVERLAP = '-ml-1' |
||||
|
||||
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 }) |
||||
} |
||||
|
||||
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]) |
||||
} |
||||
|
||||
function OverlappingAvatars({ |
||||
pubkeys, |
||||
max, |
||||
avatarSize, |
||||
rowClassName, |
||||
scrollableRow = false |
||||
}: { |
||||
pubkeys: string[] |
||||
max: number |
||||
avatarSize: 'small' | 'xSmall' | 'tiny' |
||||
rowClassName?: string |
||||
/** Narrow screens: horizontal scroll inside the viewport instead of overflowing the page */ |
||||
scrollableRow?: boolean |
||||
}) { |
||||
const slice = pubkeys.slice(0, max) |
||||
const extra = pubkeys.length - slice.length |
||||
|
||||
const row = ( |
||||
<div |
||||
className={cn( |
||||
'flex flex-row items-center pl-0.5', |
||||
scrollableRow && 'w-max max-w-none' |
||||
)} |
||||
> |
||||
{slice.map((pk, i) => ( |
||||
<HoverCard key={pk} openDelay={180} closeDelay={80}> |
||||
<HoverCardTrigger asChild> |
||||
<div |
||||
className={cn( |
||||
'relative shrink-0 rounded-full ring-2 ring-background transition-[z-index] duration-150', |
||||
i > 0 && AVATAR_OVERLAP |
||||
)} |
||||
style={{ zIndex: i + 1 }} |
||||
> |
||||
<UserAvatar userId={pk} size={avatarSize} /> |
||||
</div> |
||||
</HoverCardTrigger> |
||||
<HoverCardContent side="top" className="w-auto max-w-[min(18rem,calc(100vw-2rem))] py-2 px-3"> |
||||
<SimpleUsername userId={pk} showAt className="text-sm font-medium" /> |
||||
</HoverCardContent> |
||||
</HoverCard> |
||||
))} |
||||
{extra > 0 ? ( |
||||
<div |
||||
className={cn( |
||||
'relative z-[20] flex h-7 min-w-7 shrink-0 items-center justify-center rounded-full bg-muted px-1.5 text-xs font-medium text-muted-foreground ring-2 ring-background', |
||||
slice.length > 0 && AVATAR_OVERLAP |
||||
)} |
||||
title={String(extra)} |
||||
> |
||||
+{extra > 99 ? '99+' : extra} |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
) |
||||
|
||||
if (scrollableRow) { |
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'w-full min-w-0 overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]', |
||||
rowClassName |
||||
)} |
||||
> |
||||
{row} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'flex min-w-0 flex-1 items-center justify-end sm:justify-start', |
||||
rowClassName |
||||
)} |
||||
> |
||||
{row} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
function ActiveAvatarGroups({ |
||||
followPubkeysForAvatars, |
||||
otherPubkeysForAvatars, |
||||
followCount, |
||||
otherCount, |
||||
maxFollow, |
||||
maxOther, |
||||
avatarSize, |
||||
labelClassName, |
||||
stackClassName, |
||||
variant = 'default' |
||||
}: { |
||||
/** Subset with kind 0 only (shown as circles); counts use full totals */ |
||||
followPubkeysForAvatars: string[] |
||||
otherPubkeysForAvatars: string[] |
||||
followCount: number |
||||
otherCount: number |
||||
maxFollow: number |
||||
maxOther: number |
||||
avatarSize: 'small' | 'xSmall' | 'tiny' |
||||
labelClassName: string |
||||
stackClassName?: string |
||||
/** Mobile home: label above avatars + scrollable rows; sidebar/default keeps compact rows on wider mini breakpoints */ |
||||
variant?: 'default' | 'mobileBar' |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const mobileBar = variant === 'mobileBar' |
||||
const groupRowClass = mobileBar |
||||
? 'flex w-full min-w-0 flex-col gap-1.5' |
||||
: 'flex min-w-0 flex-col gap-1 min-[380px]:flex-row min-[380px]:items-center min-[380px]:gap-2' |
||||
|
||||
return ( |
||||
<div className={cn('flex min-w-0 flex-col gap-2', stackClassName)}> |
||||
{followCount > 0 ? ( |
||||
<div className={groupRowClass}> |
||||
<span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}> |
||||
{t('Relay pulse follows', { count: followCount })} |
||||
</span> |
||||
<OverlappingAvatars |
||||
pubkeys={followPubkeysForAvatars} |
||||
max={maxFollow} |
||||
avatarSize={avatarSize} |
||||
scrollableRow={mobileBar} |
||||
rowClassName={mobileBar ? undefined : 'min-[380px]:justify-start'} |
||||
/> |
||||
</div> |
||||
) : null} |
||||
{otherCount > 0 ? ( |
||||
<div className={groupRowClass}> |
||||
<span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}> |
||||
{t('Relay pulse others', { count: otherCount })} |
||||
</span> |
||||
<OverlappingAvatars |
||||
pubkeys={otherPubkeysForAvatars} |
||||
max={maxOther} |
||||
avatarSize={avatarSize} |
||||
scrollableRow={mobileBar} |
||||
rowClassName={mobileBar ? undefined : 'min-[380px]:justify-start'} |
||||
/> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
/** Home feed / mobile: full label above the page title */ |
||||
export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { |
||||
const { t } = useTranslation() |
||||
const { mutePubkeySet } = useMuteList() |
||||
const { |
||||
followPubkeys, |
||||
otherPubkeys, |
||||
followCount, |
||||
otherCount, |
||||
totalCount, |
||||
loading, |
||||
relayActivityReady, |
||||
lastFetchedAtMs, |
||||
profileKind0ByPubkey |
||||
} = useFavoriteRelaysActivity() |
||||
|
||||
const followPubkeysForAvatars = useMemo( |
||||
() => |
||||
followPubkeys.filter( |
||||
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) |
||||
), |
||||
[followPubkeys, profileKind0ByPubkey, mutePubkeySet] |
||||
) |
||||
const otherPubkeysForAvatars = useMemo( |
||||
() => |
||||
otherPubkeys.filter( |
||||
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) |
||||
), |
||||
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet] |
||||
) |
||||
|
||||
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) |
||||
|
||||
if (!relayActivityReady && !loading) { |
||||
return null |
||||
} |
||||
|
||||
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-2 sm:px-4', |
||||
className |
||||
)} |
||||
> |
||||
<p className="text-xs font-medium text-foreground">{t('Relay pulse')}</p> |
||||
{lastFetchedAtMs != null && relativeLabel ? ( |
||||
<p className="mt-0.5 text-[0.65rem] text-muted-foreground"> |
||||
{t('Relay pulse updated', { relative: relativeLabel })} |
||||
</p> |
||||
) : null} |
||||
<p className="mt-1 text-xs text-muted-foreground leading-snug"> |
||||
{t('Relay pulse empty')} |
||||
</p> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/15 px-3 py-2 sm:px-4', |
||||
loading && 'animate-pulse', |
||||
className |
||||
)} |
||||
> |
||||
<div className="flex w-full min-w-0 flex-col gap-3"> |
||||
<div className="flex min-w-0 max-w-full items-center justify-between gap-2"> |
||||
<div className="flex min-w-0 shrink items-center gap-2"> |
||||
<p className="text-xs font-medium leading-tight text-foreground">{t('Relay pulse')}</p> |
||||
<RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0" /> |
||||
</div> |
||||
{lastFetchedAtMs != null && relativeLabel ? ( |
||||
<p className="shrink-0 text-[0.65rem] text-muted-foreground tabular-nums"> |
||||
{t('Relay pulse updated', { relative: relativeLabel })} |
||||
</p> |
||||
) : null} |
||||
</div> |
||||
<ActiveAvatarGroups |
||||
variant="mobileBar" |
||||
followPubkeysForAvatars={followPubkeysForAvatars} |
||||
otherPubkeysForAvatars={otherPubkeysForAvatars} |
||||
followCount={followCount} |
||||
otherCount={otherCount} |
||||
maxFollow={MOBILE_MAX_FOLLOW} |
||||
maxOther={MOBILE_MAX_OTHER} |
||||
avatarSize="small" |
||||
labelClassName="text-[0.7rem] font-medium text-muted-foreground" |
||||
stackClassName="w-full min-w-0 max-w-full" |
||||
/> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
/** Desktop sidebar: compact row under nav */ |
||||
export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) { |
||||
const { t } = useTranslation() |
||||
const { mutePubkeySet } = useMuteList() |
||||
const { |
||||
followPubkeys, |
||||
otherPubkeys, |
||||
followCount, |
||||
otherCount, |
||||
totalCount, |
||||
loading, |
||||
relayActivityReady, |
||||
lastFetchedAtMs, |
||||
profileKind0ByPubkey |
||||
} = useFavoriteRelaysActivity() |
||||
|
||||
const followPubkeysForAvatars = useMemo( |
||||
() => |
||||
followPubkeys.filter( |
||||
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) |
||||
), |
||||
[followPubkeys, profileKind0ByPubkey, mutePubkeySet] |
||||
) |
||||
const otherPubkeysForAvatars = useMemo( |
||||
() => |
||||
otherPubkeys.filter( |
||||
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) |
||||
), |
||||
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet] |
||||
) |
||||
|
||||
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) |
||||
|
||||
if (!relayActivityReady && !loading) { |
||||
return null |
||||
} |
||||
|
||||
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> |
||||
{lastFetchedAtMs != null && relativeLabel ? ( |
||||
<p className="mt-0.5 px-1 text-[0.6rem] text-muted-foreground tabular-nums"> |
||||
{t('Relay pulse updated', { relative: relativeLabel })} |
||||
</p> |
||||
) : null} |
||||
<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> |
||||
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" /> |
||||
</div> |
||||
{lastFetchedAtMs != null && relativeLabel ? ( |
||||
<p className="max-xl:hidden mb-1.5 px-1 text-[0.6rem] text-muted-foreground tabular-nums"> |
||||
{t('Relay pulse updated', { relative: relativeLabel })} |
||||
</p> |
||||
) : null} |
||||
<div className="mb-1 flex justify-center xl:hidden"> |
||||
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" /> |
||||
</div> |
||||
<div className="max-xl:flex max-xl:justify-center"> |
||||
<ActiveAvatarGroups |
||||
followPubkeysForAvatars={followPubkeysForAvatars} |
||||
otherPubkeysForAvatars={otherPubkeysForAvatars} |
||||
followCount={followCount} |
||||
otherCount={otherCount} |
||||
maxFollow={SIDEBAR_MAX_FOLLOW} |
||||
maxOther={SIDEBAR_MAX_OTHER} |
||||
avatarSize="xSmall" |
||||
labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1" |
||||
stackClassName="w-full max-xl:items-center" |
||||
/> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
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] |
||||
} |
||||
|
||||
export function truncateAbout(about: string | undefined, maxLen: number): string { |
||||
if (!about) return '' |
||||
const t = about.trim() |
||||
if (t.length <= maxLen) return t |
||||
return `${t.slice(0, maxLen)}…` |
||||
} |
||||
@ -0,0 +1,260 @@
@@ -0,0 +1,260 @@
|
||||
import logger from '@/lib/logger' |
||||
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' |
||||
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useFollowListOptional } from '@/providers/FollowListProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { queryService, replaceableEventService } from '@/services/client.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 |
||||
/** Wall-clock cadence while the tab is visible */ |
||||
const POLL_INTERVAL_MS = 60 * 60 * 1000 |
||||
/** Enough events to surface many distinct authors without overloading relays */ |
||||
const REQ_LIMIT = 400 |
||||
|
||||
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) => normalizeHexPubkey(p)).filter((p) => p.length === 64) |
||||
) |
||||
const followPubkeys: string[] = [] |
||||
const otherPubkeys: string[] = [] |
||||
for (const pk of orderedPubkeys) { |
||||
const normalized = normalizeHexPubkey(pk) |
||||
if (normalized.length === 64 && followSet.has(normalized)) 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 followList = useFollowListOptional() |
||||
const followings = followList?.followings ?? [] |
||||
const { pubkey: viewerPubkey } = useNostr() |
||||
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 lastCompletedFetchAtRef = useRef(Date.now()) |
||||
const relayKey = useMemo( |
||||
() => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'), |
||||
[favoriteRelays, blockedRelays] |
||||
) |
||||
|
||||
const fetchActive = useCallback(async () => { |
||||
const urls = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) |
||||
if (urls.length === 0) { |
||||
setOrderedPubkeys([]) |
||||
setProfileKind0ByPubkey({}) |
||||
setLoading(false) |
||||
setRelayActivityReady(true) |
||||
const now = Date.now() |
||||
lastCompletedFetchAtRef.current = now |
||||
setLastFetchedAtMs(now) |
||||
return |
||||
} |
||||
setLoading(true) |
||||
const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC |
||||
try { |
||||
const events = await queryService.fetchEvents( |
||||
urls, |
||||
{ since, limit: REQ_LIMIT }, |
||||
{ |
||||
firstRelayResultGraceMs: false, |
||||
eoseTimeout: 1800, |
||||
globalTimeout: 14_000 |
||||
} |
||||
) |
||||
setOrderedPubkeys(aggregatePubkeysByRecency(events)) |
||||
} catch (error) { |
||||
logger.debug('[FavoriteRelaysActivity] fetch failed', { error }) |
||||
setOrderedPubkeys([]) |
||||
setProfileKind0ByPubkey({}) |
||||
} finally { |
||||
setLoading(false) |
||||
setRelayActivityReady(true) |
||||
const now = Date.now() |
||||
lastCompletedFetchAtRef.current = now |
||||
setLastFetchedAtMs(now) |
||||
} |
||||
}, [favoriteRelays, blockedRelays]) |
||||
|
||||
const fetchRef = useRef(fetchActive) |
||||
fetchRef.current = fetchActive |
||||
|
||||
/** Favorite relay set changed after initial hydration — refresh snapshot (not the hourly cadence). */ |
||||
const prevRelayKeyRef = useRef<string | undefined>(undefined) |
||||
useEffect(() => { |
||||
if (prevRelayKeyRef.current === undefined) { |
||||
prevRelayKeyRef.current = relayKey |
||||
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) { |
||||
void fetchRef.current() |
||||
} |
||||
prevViewerRef.current = viewerPubkey ?? undefined |
||||
}, [viewerPubkey]) |
||||
|
||||
/** 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 { followPubkeys, otherPubkeys, followCount, otherCount } = useMemo( |
||||
() => partitionByFollows(displayPubkeys, followings), |
||||
[displayPubkeys, followings] |
||||
) |
||||
|
||||
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> |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
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