19 changed files with 1020 additions and 55 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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