41 changed files with 65 additions and 2426 deletions
@ -1,196 +0,0 @@ |
|||||||
import UserAvatar from '@/components/UserAvatar' |
|
||||||
import Username from '@/components/Username' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
Dialog, |
|
||||||
DialogContent, |
|
||||||
DialogDescription, |
|
||||||
DialogHeader, |
|
||||||
DialogTitle |
|
||||||
} from '@/components/ui/dialog' |
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area' |
|
||||||
import type { TProfileBadge } from '@/hooks/useProfileBadges' |
|
||||||
import { fetchBadgeRecipientPubkeys } from '@/lib/fetch-badge-recipient-pubkeys' |
|
||||||
import { toNote, toProfile } from '@/lib/link' |
|
||||||
import { hexPubkeysEqual } from '@/lib/pubkey' |
|
||||||
import { useSecondaryPage } from '@/PageManager' |
|
||||||
import { useEffect, useMemo, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
function parseIssuerPubkeyFromATag(aTag: string): string | undefined { |
|
||||||
const parts = aTag.split(':') |
|
||||||
if (parts.length < 2) return undefined |
|
||||||
const pk = parts[1] |
|
||||||
return /^[0-9a-f]{64}$/i.test(pk) ? pk.toLowerCase() : undefined |
|
||||||
} |
|
||||||
|
|
||||||
export default function ProfileBadgeDetailDialog({ |
|
||||||
open, |
|
||||||
onOpenChange, |
|
||||||
badge, |
|
||||||
profilePubkey, |
|
||||||
relayUrls |
|
||||||
}: { |
|
||||||
open: boolean |
|
||||||
onOpenChange: (open: boolean) => void |
|
||||||
badge: TProfileBadge | null |
|
||||||
profilePubkey: string |
|
||||||
relayUrls: string[] |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
/** Secondary panel is below dialog z-index; close modal before navigating. */ |
|
||||||
const pushSecondaryAndClose = (path: string) => { |
|
||||||
onOpenChange(false) |
|
||||||
push(path) |
|
||||||
} |
|
||||||
const [recipientPubkeys, setRecipientPubkeys] = useState<string[]>([]) |
|
||||||
const [recipientsLoading, setRecipientsLoading] = useState(false) |
|
||||||
const [recipientsError, setRecipientsError] = useState(false) |
|
||||||
|
|
||||||
const issuerPubkey = useMemo(() => (badge ? parseIssuerPubkeyFromATag(badge.a) : undefined), [badge]) |
|
||||||
|
|
||||||
const displayImage = badge?.image ?? badge?.thumb |
|
||||||
const displayThumb = badge?.thumb ?? badge?.image |
|
||||||
const label = badge?.name ?? badge?.a.split(':').pop() ?? '' |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!open || !badge) { |
|
||||||
setRecipientPubkeys([]) |
|
||||||
setRecipientsError(false) |
|
||||||
setRecipientsLoading(false) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if (relayUrls.length === 0) { |
|
||||||
setRecipientPubkeys([]) |
|
||||||
setRecipientsError(true) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
let cancelled = false |
|
||||||
setRecipientsLoading(true) |
|
||||||
setRecipientsError(false) |
|
||||||
fetchBadgeRecipientPubkeys(relayUrls, badge.a) |
|
||||||
.then((pubkeys) => { |
|
||||||
if (cancelled) return |
|
||||||
pubkeys.sort((a, b) => a.localeCompare(b)) |
|
||||||
setRecipientPubkeys(pubkeys) |
|
||||||
}) |
|
||||||
.catch(() => { |
|
||||||
if (!cancelled) { |
|
||||||
setRecipientsError(true) |
|
||||||
setRecipientPubkeys([]) |
|
||||||
} |
|
||||||
}) |
|
||||||
.finally(() => { |
|
||||||
if (!cancelled) setRecipientsLoading(false) |
|
||||||
}) |
|
||||||
|
|
||||||
return () => { |
|
||||||
cancelled = true |
|
||||||
} |
|
||||||
}, [open, badge, relayUrls]) |
|
||||||
|
|
||||||
const otherRecipients = useMemo( |
|
||||||
() => recipientPubkeys.filter((pk) => !hexPubkeysEqual(pk, profilePubkey)), |
|
||||||
[recipientPubkeys, profilePubkey] |
|
||||||
) |
|
||||||
|
|
||||||
if (!badge) return null |
|
||||||
|
|
||||||
return ( |
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}> |
|
||||||
<DialogContent className="max-w-md max-h-[90vh] flex flex-col gap-3"> |
|
||||||
<DialogHeader> |
|
||||||
<DialogTitle>{t('Badge details')}</DialogTitle> |
|
||||||
<DialogDescription className="sr-only">{label}</DialogDescription> |
|
||||||
</DialogHeader> |
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2"> |
|
||||||
{displayImage || displayThumb ? ( |
|
||||||
<img |
|
||||||
src={displayImage ?? displayThumb} |
|
||||||
alt={label} |
|
||||||
className="max-h-48 w-auto max-w-full rounded-lg border object-contain bg-muted" |
|
||||||
loading="lazy" |
|
||||||
referrerPolicy="no-referrer" |
|
||||||
/> |
|
||||||
) : ( |
|
||||||
<div className="flex size-32 items-center justify-center rounded-lg border bg-muted text-sm text-muted-foreground"> |
|
||||||
{label.slice(0, 3)} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
<div className="text-center text-base font-semibold">{label}</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
{badge.description ? ( |
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap break-words max-h-32 overflow-y-auto"> |
|
||||||
{badge.description} |
|
||||||
</p> |
|
||||||
) : null} |
|
||||||
|
|
||||||
{badge.awardCreatedAt != null ? ( |
|
||||||
<p className="text-xs text-muted-foreground"> |
|
||||||
{t('Awarded on', { defaultValue: 'Awarded on' })}{' '} |
|
||||||
{new Date(badge.awardCreatedAt * 1000).toLocaleString(undefined, { |
|
||||||
dateStyle: 'medium', |
|
||||||
timeStyle: 'short' |
|
||||||
})} |
|
||||||
</p> |
|
||||||
) : null} |
|
||||||
|
|
||||||
{issuerPubkey ? ( |
|
||||||
<div className="space-y-1"> |
|
||||||
<div className="text-xs font-medium text-muted-foreground">{t('Issued by')}</div> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex w-full items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5 text-left hover:bg-muted/60" |
|
||||||
onClick={() => pushSecondaryAndClose(toProfile(issuerPubkey))} |
|
||||||
> |
|
||||||
<UserAvatar userId={issuerPubkey} size="small" className="shrink-0" /> |
|
||||||
<Username userId={issuerPubkey} className="truncate text-sm font-medium" skeletonClassName="h-4" /> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
) : null} |
|
||||||
|
|
||||||
<div className="space-y-1 min-h-0 flex-1 flex flex-col"> |
|
||||||
<div className="text-xs font-medium text-muted-foreground">{t('Other recipients')}</div> |
|
||||||
{recipientsLoading ? ( |
|
||||||
<div className="text-sm text-muted-foreground py-2">{t('Loading...')}</div> |
|
||||||
) : recipientsError ? ( |
|
||||||
<div className="text-sm text-muted-foreground py-2">{t('Recipients could not be loaded')}</div> |
|
||||||
) : otherRecipients.length === 0 ? ( |
|
||||||
<div className="text-sm text-muted-foreground py-2">{t('No other recipients found')}</div> |
|
||||||
) : ( |
|
||||||
<ScrollArea className="h-44 rounded-md border"> |
|
||||||
<ul className="p-1 space-y-0.5"> |
|
||||||
{otherRecipients.map((pk) => ( |
|
||||||
<li key={pk}> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-muted/80" |
|
||||||
onClick={() => pushSecondaryAndClose(toProfile(pk))} |
|
||||||
> |
|
||||||
<UserAvatar userId={pk} size="small" className="shrink-0" /> |
|
||||||
<Username userId={pk} className="truncate text-sm" skeletonClassName="h-4" /> |
|
||||||
</button> |
|
||||||
</li> |
|
||||||
))} |
|
||||||
</ul> |
|
||||||
</ScrollArea> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
|
|
||||||
<Button |
|
||||||
type="button" |
|
||||||
variant="secondary" |
|
||||||
className="w-full" |
|
||||||
onClick={() => pushSecondaryAndClose(toNote(badge.awardId))} |
|
||||||
> |
|
||||||
{t('View award')} |
|
||||||
</Button> |
|
||||||
</DialogContent> |
|
||||||
</Dialog> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,352 +0,0 @@ |
|||||||
import Content from '@/components/Content' |
|
||||||
import ReactionEmojiDisplay from '@/components/Note/ReactionEmojiDisplay' |
|
||||||
import UserAvatar from '@/components/UserAvatar' |
|
||||||
import Username from '@/components/Username' |
|
||||||
import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog' |
|
||||||
import { replaceableEventDedupeKey } from '@/lib/event' |
|
||||||
import { formatAmount } from '@/lib/lightning' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { toNote, toProfile } from '@/lib/link' |
|
||||||
import { useSecondaryPage } from '@/PageManager' |
|
||||||
import type { TProfileZap } from '@/hooks/useProfileInteractions' |
|
||||||
import type { TProfileBadge } from '@/hooks/useProfileBadges' |
|
||||||
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' |
|
||||||
import { Flag, Zap, MessageCircle, ThumbsDown, ThumbsUp, Users } from 'lucide-react' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { useState } from 'react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
|
|
||||||
type Props = { |
|
||||||
profilePubkey: string |
|
||||||
badgeRelayUrls: string[] |
|
||||||
zaps: TProfileZap[] |
|
||||||
reactions: Event[] |
|
||||||
comments: Event[] |
|
||||||
badges: TProfileBadge[] |
|
||||||
followPacks: TProfileFollowPack[] |
|
||||||
reports: Event[] |
|
||||||
loading: boolean |
|
||||||
badgesLoading: boolean |
|
||||||
followPacksLoading: boolean |
|
||||||
reportsLoading: boolean |
|
||||||
/** When false (logged out), the Reports section is omitted — reports use the viewer’s relays only. */ |
|
||||||
reportsEnabled: boolean |
|
||||||
} |
|
||||||
|
|
||||||
const ZAPS_PER_ROW = 4 |
|
||||||
const ZAP_ROWS = 3 |
|
||||||
const MAX_ZAPS = ZAPS_PER_ROW * ZAP_ROWS |
|
||||||
const LIKES_GRID_COLS = 4 |
|
||||||
const LIKES_GRID_ROWS = 3 |
|
||||||
const MAX_LIKES = LIKES_GRID_COLS * LIKES_GRID_ROWS |
|
||||||
const BADGES_PER_ROW = 6 |
|
||||||
const BADGE_ROWS = 2 |
|
||||||
const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS |
|
||||||
const BADGE_TILE_PX = 96 |
|
||||||
const MAX_FOLLOW_PACKS = 8 |
|
||||||
const MAX_REPORTS = 12 |
|
||||||
|
|
||||||
function reportSummaryFromEvent(event: Event): string { |
|
||||||
const reportTag = event.tags.find((t) => t[0] === 'report') |
|
||||||
const reason = reportTag?.[1]?.trim() |
|
||||||
if (reason) return reason |
|
||||||
const text = event.content.trim().replace(/\s+/g, ' ') |
|
||||||
if (text) return text.length > 48 ? `${text.slice(0, 45)}…` : text |
|
||||||
return '—' |
|
||||||
} |
|
||||||
|
|
||||||
function ZapBadge({ zap }: { zap: TProfileZap }) { |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
return ( |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-yellow-400/40 hover:bg-yellow-400/10 cursor-pointer text-left min-w-0 w-full" |
|
||||||
onClick={() => push(toProfile(zap.pubkey))} |
|
||||||
> |
|
||||||
<UserAvatar userId={zap.pubkey} size="tiny" className="shrink-0" /> |
|
||||||
<Zap className="size-3 shrink-0 text-yellow-500 fill-yellow-500" strokeWidth={2} aria-hidden /> |
|
||||||
<span className="font-semibold tabular-nums text-xs text-foreground truncate">{formatAmount(zap.amount)}</span> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function ReactionBadge({ event }: { event: Event }) { |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const raw = event.content.trim() |
|
||||||
const isPlus = raw === '+' |
|
||||||
const isMinus = raw === '-' |
|
||||||
return ( |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer min-w-0 w-full" |
|
||||||
onClick={() => push(toProfile(event.pubkey))} |
|
||||||
> |
|
||||||
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" /> |
|
||||||
{isPlus ? ( |
|
||||||
<ThumbsUp className="size-3 shrink-0 text-primary" aria-hidden /> |
|
||||||
) : isMinus ? ( |
|
||||||
<ThumbsDown className="size-3 shrink-0 text-muted-foreground" aria-hidden /> |
|
||||||
) : raw && !raw.startsWith(':') ? ( |
|
||||||
<span className="text-xs shrink-0">{raw}</span> |
|
||||||
) : ( |
|
||||||
<ReactionEmojiDisplay event={event} variant="compact" maxRawLength={64} className="shrink-0" /> |
|
||||||
)} |
|
||||||
<Username userId={event.pubkey} className="truncate text-xs text-muted-foreground min-w-0" skeletonClassName="h-3" /> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function CommentBadge({ event }: { event: Event }) { |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
return ( |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border cursor-pointer text-left min-w-0 w-full" |
|
||||||
onClick={() => push(toNote(event))} |
|
||||||
> |
|
||||||
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" /> |
|
||||||
<MessageCircle className="size-3 shrink-0 text-primary" aria-hidden /> |
|
||||||
<span className="truncate text-xs text-muted-foreground min-w-0"> |
|
||||||
<Content |
|
||||||
event={event} |
|
||||||
content={event.content} |
|
||||||
className="text-xs [&_p]:text-xs [&_p]:m-0 [&_p]:inline" |
|
||||||
/> |
|
||||||
</span> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function ReportBadge({ event }: { event: Event }) { |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const summary = reportSummaryFromEvent(event) |
|
||||||
return ( |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-destructive/25 hover:bg-muted cursor-pointer text-left min-w-0 w-full" |
|
||||||
onClick={() => push(toNote(event))} |
|
||||||
title={summary} |
|
||||||
> |
|
||||||
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" /> |
|
||||||
<Flag className="size-3 shrink-0 text-destructive" strokeWidth={2} aria-hidden /> |
|
||||||
<span className="truncate text-xs text-muted-foreground min-w-0">{summary}</span> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const authorPk = pack.event.pubkey |
|
||||||
return ( |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex flex-col gap-1 px-2 py-1.5 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer text-left min-w-0 w-full" |
|
||||||
onClick={() => push(toNote(pack.event))} |
|
||||||
title={pack.title} |
|
||||||
> |
|
||||||
<div className="flex min-w-0 items-center gap-1.5"> |
|
||||||
<Users className="size-3 shrink-0 text-primary" aria-hidden /> |
|
||||||
<span className="truncate text-xs font-medium text-foreground min-w-0">{pack.title}</span> |
|
||||||
</div> |
|
||||||
<div className="flex min-w-0 items-center gap-1.5 ps-4"> |
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{t('Follow pack by')}:</span> |
|
||||||
<UserAvatar userId={authorPk} size="xSmall" className="shrink-0" /> |
|
||||||
<Username |
|
||||||
userId={authorPk} |
|
||||||
className="min-w-0 truncate text-xs font-medium text-foreground" |
|
||||||
skeletonClassName="h-3.5" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function BadgeItem({ |
|
||||||
badge, |
|
||||||
onOpenDetail |
|
||||||
}: { |
|
||||||
badge: TProfileBadge |
|
||||||
onOpenDetail: (b: TProfileBadge) => void |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const imageUrl = badge.thumb ?? badge.image |
|
||||||
const label = badge.name ?? badge.a.split(':').pop() ?? '' |
|
||||||
return ( |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="relative shrink-0 rounded-lg border bg-muted p-0 overflow-hidden cursor-pointer transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" |
|
||||||
style={{ width: BADGE_TILE_PX, height: BADGE_TILE_PX }} |
|
||||||
title={label} |
|
||||||
aria-label={label ? `${t('Badge details')}: ${label}` : t('Badge details')} |
|
||||||
onClick={() => onOpenDetail(badge)} |
|
||||||
> |
|
||||||
{imageUrl ? ( |
|
||||||
<> |
|
||||||
<img |
|
||||||
src={imageUrl} |
|
||||||
alt="" |
|
||||||
className="size-full rounded-lg object-cover pointer-events-none" |
|
||||||
loading="lazy" |
|
||||||
onError={(e) => { |
|
||||||
e.currentTarget.style.visibility = 'hidden' |
|
||||||
const fallback = e.currentTarget.nextElementSibling as HTMLElement | null |
|
||||||
fallback?.classList.remove('hidden') |
|
||||||
}} |
|
||||||
/> |
|
||||||
<div className="hidden absolute inset-0 flex items-center justify-center rounded-lg bg-muted p-1 text-center text-xs text-muted-foreground pointer-events-none"> |
|
||||||
{label.slice(0, 3)} |
|
||||||
</div> |
|
||||||
</> |
|
||||||
) : ( |
|
||||||
<div className="flex size-full items-center justify-center rounded-lg p-1 text-center text-xs text-muted-foreground"> |
|
||||||
{label.slice(0, 3)} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
export default function ProfileHeaderInteractions({ |
|
||||||
profilePubkey, |
|
||||||
badgeRelayUrls, |
|
||||||
zaps, |
|
||||||
reactions, |
|
||||||
comments, |
|
||||||
badges, |
|
||||||
followPacks, |
|
||||||
reports, |
|
||||||
loading, |
|
||||||
badgesLoading, |
|
||||||
followPacksLoading, |
|
||||||
reportsLoading, |
|
||||||
reportsEnabled |
|
||||||
}: Props) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const [badgeDialogOpen, setBadgeDialogOpen] = useState(false) |
|
||||||
const [selectedBadge, setSelectedBadge] = useState<TProfileBadge | null>(null) |
|
||||||
|
|
||||||
const displayZaps = zaps.slice(0, MAX_ZAPS) |
|
||||||
const displayReactions = reactions.slice(0, MAX_LIKES) |
|
||||||
const displayBadges = badges.slice(0, MAX_BADGES) |
|
||||||
const displayFollowPacks = followPacks.slice(0, MAX_FOLLOW_PACKS) |
|
||||||
const displayReports = reports.slice(0, MAX_REPORTS) |
|
||||||
|
|
||||||
const Section = ({ |
|
||||||
title, |
|
||||||
isEmpty, |
|
||||||
isLoading, |
|
||||||
children, |
|
||||||
skeletonCount = 6, |
|
||||||
skeletonItemClassName, |
|
||||||
skeletonGridClassName |
|
||||||
}: { |
|
||||||
title: string |
|
||||||
isEmpty: boolean |
|
||||||
isLoading: boolean |
|
||||||
children: React.ReactNode |
|
||||||
skeletonCount?: number |
|
||||||
skeletonItemClassName?: string |
|
||||||
skeletonGridClassName?: string |
|
||||||
}) => ( |
|
||||||
<div className="min-w-0"> |
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">{title}</div> |
|
||||||
{isLoading && isEmpty ? ( |
|
||||||
<div |
|
||||||
className={cn( |
|
||||||
'grid gap-1.5', |
|
||||||
skeletonGridClassName ?? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4' |
|
||||||
)} |
|
||||||
> |
|
||||||
{Array.from({ length: skeletonCount }).map((_, i) => ( |
|
||||||
<Skeleton key={i} className={cn('h-8 rounded-md min-w-0', skeletonItemClassName)} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) : isEmpty ? ( |
|
||||||
<div className="text-xs text-muted-foreground py-1">{t('None')}</div> |
|
||||||
) : ( |
|
||||||
children |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="py-2 space-y-3 w-full min-w-0 overflow-visible"> |
|
||||||
<Section title={t('Zaps')} isEmpty={displayZaps.length === 0} isLoading={loading}> |
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 auto-rows-min"> |
|
||||||
{displayZaps.map((item) => ( |
|
||||||
<ZapBadge key={`zap-${item.pr}`} zap={item} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</Section> |
|
||||||
<Section title={t('Likes')} isEmpty={reactions.length === 0} isLoading={loading}> |
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 auto-rows-min"> |
|
||||||
{displayReactions.map((item) => ( |
|
||||||
<ReactionBadge key={`reaction-${item.id}`} event={item} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</Section> |
|
||||||
<Section title={t('Comments')} isEmpty={comments.length === 0} isLoading={loading}> |
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> |
|
||||||
{comments.map((item) => ( |
|
||||||
<CommentBadge key={`comment-${item.id}`} event={item} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</Section> |
|
||||||
<Section |
|
||||||
title={t('Badges')} |
|
||||||
isEmpty={displayBadges.length === 0} |
|
||||||
isLoading={badgesLoading} |
|
||||||
skeletonCount={12} |
|
||||||
skeletonGridClassName="grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-1" |
|
||||||
skeletonItemClassName="aspect-square h-24 w-full rounded-lg" |
|
||||||
> |
|
||||||
<div className="flex flex-wrap gap-1"> |
|
||||||
{displayBadges.map((badge, index) => ( |
|
||||||
<BadgeItem |
|
||||||
key={`${badge.a}-${badge.awardId}-${index}`} |
|
||||||
badge={badge} |
|
||||||
onOpenDetail={(b) => { |
|
||||||
setSelectedBadge(b) |
|
||||||
setBadgeDialogOpen(true) |
|
||||||
}} |
|
||||||
/> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</Section> |
|
||||||
<ProfileBadgeDetailDialog |
|
||||||
open={badgeDialogOpen} |
|
||||||
onOpenChange={(o) => { |
|
||||||
setBadgeDialogOpen(o) |
|
||||||
if (!o) setSelectedBadge(null) |
|
||||||
}} |
|
||||||
badge={selectedBadge} |
|
||||||
profilePubkey={profilePubkey} |
|
||||||
relayUrls={badgeRelayUrls} |
|
||||||
/> |
|
||||||
<Section |
|
||||||
title={t('In Follow Packs')} |
|
||||||
isEmpty={displayFollowPacks.length === 0} |
|
||||||
isLoading={followPacksLoading} |
|
||||||
skeletonCount={6} |
|
||||||
skeletonItemClassName="h-14" |
|
||||||
> |
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> |
|
||||||
{displayFollowPacks.map((pack) => ( |
|
||||||
<FollowPackBadge key={replaceableEventDedupeKey(pack.event)} pack={pack} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</Section> |
|
||||||
{reportsEnabled ? ( |
|
||||||
<Section title={t('Reports')} isEmpty={displayReports.length === 0} isLoading={reportsLoading}> |
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> |
|
||||||
{displayReports.map((item) => ( |
|
||||||
<ReportBadge key={`report-${item.id}`} event={item} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</Section> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,156 +0,0 @@ |
|||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media' |
|
||||||
import { |
|
||||||
fetchNip58BadgeAward, |
|
||||||
fetchNip58BadgeDefinition, |
|
||||||
mergeNip58BadgeRelayPool |
|
||||||
} from '@/lib/fetch-badge-nip58' |
|
||||||
import indexedDb from '@/services/indexed-db.service' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { tagNameEquals } from '@/lib/tag' |
|
||||||
|
|
||||||
export type TProfileBadge = { |
|
||||||
/** Badge definition coordinate (e.g. "30009:alice:bravery") */ |
|
||||||
a: string |
|
||||||
/** Badge award event id */ |
|
||||||
awardId: string |
|
||||||
/** Human-readable name from definition */ |
|
||||||
name?: string |
|
||||||
/** High-res image URL */ |
|
||||||
image?: string |
|
||||||
/** Thumbnail URL (prefer thumb over image for grid display) */ |
|
||||||
thumb?: string |
|
||||||
/** From badge definition (NIP-58) */ |
|
||||||
description?: string |
|
||||||
/** Kind 8 award `created_at` when loaded */ |
|
||||||
awardCreatedAt?: number |
|
||||||
} |
|
||||||
|
|
||||||
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */ |
|
||||||
function parseATag(aTag: string): { kind: number; pubkey: string; d: string } | null { |
|
||||||
const parts = aTag.split(':') |
|
||||||
if (parts.length < 3) return null |
|
||||||
const kind = parseInt(parts[0], 10) |
|
||||||
if (isNaN(kind)) return null |
|
||||||
const pk = parts[1] |
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(pk)) return null |
|
||||||
const d = parts.slice(2).join(':') |
|
||||||
if (!d) return null |
|
||||||
return { kind, pubkey: pk.toLowerCase(), d } |
|
||||||
} |
|
||||||
|
|
||||||
function mergeProfileBadgesByAwardId(seed: TProfileBadge[], fresh: TProfileBadge[]): TProfileBadge[] { |
|
||||||
const m = new Map<string, TProfileBadge>() |
|
||||||
for (const b of seed) m.set(b.awardId, b) |
|
||||||
for (const b of fresh) m.set(b.awardId, b) |
|
||||||
return [...m.values()] |
|
||||||
} |
|
||||||
|
|
||||||
export async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise<TProfileBadge[]> { |
|
||||||
return Promise.all( |
|
||||||
badges.map(async (b) => { |
|
||||||
if (b.thumb || b.image) return b |
|
||||||
const parsed = parseATag(b.a) |
|
||||||
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) return b |
|
||||||
try { |
|
||||||
const def = await indexedDb.getReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d) |
|
||||||
if (!def) return b |
|
||||||
const name = def.tags.find(tagNameEquals('name'))?.[1] |
|
||||||
const description = def.tags.find(tagNameEquals('description'))?.[1] |
|
||||||
const media = extractBadgeDefinitionMedia(def) |
|
||||||
return { |
|
||||||
...b, |
|
||||||
name: name ?? b.name ?? parsed.d, |
|
||||||
image: media.image, |
|
||||||
thumb: media.thumb ?? media.image, |
|
||||||
description: description ?? b.description |
|
||||||
} |
|
||||||
} catch { |
|
||||||
return b |
|
||||||
} |
|
||||||
}) |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Resolves NIP-58 badge definitions/awards for the newest kind-30008 `profile_badges` event. |
|
||||||
* Used by profile accordion bundle fetch. |
|
||||||
*/ |
|
||||||
export async function resolveProfileBadgeList( |
|
||||||
profileBadgesEvent: Event | undefined, |
|
||||||
urls: string[], |
|
||||||
blockedRelays: string[], |
|
||||||
seedBadges: TProfileBadge[] | null | undefined |
|
||||||
): Promise<TProfileBadge[]> { |
|
||||||
if (!profileBadgesEvent) { |
|
||||||
return seedBadges?.length ? [...seedBadges] : [] |
|
||||||
} |
|
||||||
|
|
||||||
const tags = profileBadgesEvent.tags |
|
||||||
const pairs: { a: string; e: string; eRelayHint?: string }[] = [] |
|
||||||
for (let i = 0; i < tags.length - 1; i++) { |
|
||||||
const ta = tags[i] |
|
||||||
const te = tags[i + 1] |
|
||||||
if ( |
|
||||||
ta[0] === 'a' && |
|
||||||
te[0] === 'e' && |
|
||||||
ta[1] && |
|
||||||
te[1] && |
|
||||||
/^[a-f0-9]{64}$/i.test(te[1]) |
|
||||||
) { |
|
||||||
pairs.push({ a: ta[1], e: te[1], eRelayHint: te[2] }) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (pairs.length === 0) { |
|
||||||
return seedBadges?.length ? [...seedBadges] : [] |
|
||||||
} |
|
||||||
|
|
||||||
const result: TProfileBadge[] = await Promise.all( |
|
||||||
pairs.map(async ({ a, e, eRelayHint }) => { |
|
||||||
const parsed = parseATag(a) |
|
||||||
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) { |
|
||||||
return { a, awardId: e } |
|
||||||
} |
|
||||||
|
|
||||||
const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelays) |
|
||||||
const [defEvent, awardEvent] = await Promise.all([ |
|
||||||
fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool), |
|
||||||
fetchNip58BadgeAward(e, relayPool) |
|
||||||
]) |
|
||||||
|
|
||||||
const awardATag = awardEvent?.tags.find(tagNameEquals('a'))?.[1] |
|
||||||
const awardMatchesDefinition = !awardEvent || awardATag === a |
|
||||||
const awardCreatedAt = |
|
||||||
awardMatchesDefinition && awardEvent ? awardEvent.created_at : undefined |
|
||||||
|
|
||||||
if (defEvent) { |
|
||||||
try { |
|
||||||
await indexedDb.putReplaceableEvent(defEvent) |
|
||||||
} catch { |
|
||||||
/* ignore */ |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (!defEvent) { |
|
||||||
return { a, awardId: e, awardCreatedAt } |
|
||||||
} |
|
||||||
|
|
||||||
const name = defEvent.tags.find(tagNameEquals('name'))?.[1] |
|
||||||
const description = defEvent.tags.find(tagNameEquals('description'))?.[1] |
|
||||||
const media = extractBadgeDefinitionMedia(defEvent) |
|
||||||
|
|
||||||
return { |
|
||||||
a, |
|
||||||
awardId: e, |
|
||||||
name: name ?? parsed.d, |
|
||||||
image: media.image, |
|
||||||
thumb: media.thumb ?? media.image, |
|
||||||
description, |
|
||||||
awardCreatedAt |
|
||||||
} |
|
||||||
}) |
|
||||||
) |
|
||||||
|
|
||||||
return mergeProfileBadgesByAwardId(seedBadges ?? [], result) |
|
||||||
} |
|
||||||
@ -1,6 +0,0 @@ |
|||||||
import { Event } from 'nostr-tools' |
|
||||||
|
|
||||||
export type TProfileFollowPack = { |
|
||||||
event: Event |
|
||||||
title: string |
|
||||||
} |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
export type TProfileZap = { |
|
||||||
pr: string |
|
||||||
pubkey: string |
|
||||||
amount: number |
|
||||||
created_at: number |
|
||||||
comment?: string |
|
||||||
} |
|
||||||
@ -1,94 +0,0 @@ |
|||||||
import { |
|
||||||
profileAccordionGetCachedRelayUrls, |
|
||||||
profileAccordionRelayUrlsKey, |
|
||||||
profileAccordionSetRelayUrls |
|
||||||
} from '@/lib/profile-accordion-session-cache' |
|
||||||
import { buildProfileRelayUrls, getProfileRelayUrlsProvisional } from '@/lib/profile-relay-urls' |
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react' |
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
||||||
|
|
||||||
/** Returns profile relay URLs (outboxes + PROFILE_FETCH). Use for sharing relays across profile fetches. */ |
|
||||||
export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean) { |
|
||||||
const { blockedRelays } = useFavoriteRelays() |
|
||||||
const blockedRelaysRef = useRef(blockedRelays) |
|
||||||
blockedRelaysRef.current = blockedRelays |
|
||||||
const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays) |
|
||||||
|
|
||||||
const [relayUrls, setRelayUrls] = useState<string[]>([]) |
|
||||||
const [loading, setLoading] = useState(false) |
|
||||||
/** Stale-while-revalidate: avoid accordion skeleton when refreshing relays but URLs already visible */ |
|
||||||
const relayUrlsRef = useRef<string[]>([]) |
|
||||||
relayUrlsRef.current = relayUrls |
|
||||||
|
|
||||||
const fetch = useCallback( |
|
||||||
async (force = false): Promise<string[]> => { |
|
||||||
if (!pubkey) { |
|
||||||
setRelayUrls((prev) => (prev.length === 0 ? prev : [])) |
|
||||||
setLoading(false) |
|
||||||
return [] |
|
||||||
} |
|
||||||
|
|
||||||
if (!force) { |
|
||||||
const cached = profileAccordionGetCachedRelayUrls(pubkey) |
|
||||||
if (cached?.length) { |
|
||||||
setRelayUrls(cached) |
|
||||||
setLoading(false) |
|
||||||
return cached |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const provisional = getProfileRelayUrlsProvisional(blockedRelaysRef.current) |
|
||||||
const revalidateWithVisibleUrls = force && relayUrlsRef.current.length > 0 |
|
||||||
if (!revalidateWithVisibleUrls) { |
|
||||||
if (provisional.length > 0) { |
|
||||||
profileAccordionSetRelayUrls(pubkey, provisional) |
|
||||||
setRelayUrls(provisional) |
|
||||||
setLoading(false) |
|
||||||
} else { |
|
||||||
setLoading(true) |
|
||||||
} |
|
||||||
} else { |
|
||||||
setLoading(true) |
|
||||||
} |
|
||||||
try { |
|
||||||
const urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current) |
|
||||||
profileAccordionSetRelayUrls(pubkey, urls) |
|
||||||
setRelayUrls(urls) |
|
||||||
return urls |
|
||||||
} catch { |
|
||||||
setRelayUrls((prev) => (prev.length === 0 ? prev : [])) |
|
||||||
return [] |
|
||||||
} finally { |
|
||||||
setLoading(false) |
|
||||||
} |
|
||||||
}, |
|
||||||
[pubkey, blockedRelaysKey] |
|
||||||
) |
|
||||||
|
|
||||||
const refresh = useCallback(() => { |
|
||||||
if (!pubkey) return Promise.resolve([] as string[]) |
|
||||||
/** Do not invalidate: that wipes interactions/badges/follow-packs cache and forces empty refetches */ |
|
||||||
return fetch(true) |
|
||||||
}, [pubkey, fetch]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!pubkey) { |
|
||||||
setRelayUrls((prev) => (prev.length === 0 ? prev : [])) |
|
||||||
setLoading(false) |
|
||||||
return |
|
||||||
} |
|
||||||
if (!enabled) { |
|
||||||
const cached = profileAccordionGetCachedRelayUrls(pubkey) |
|
||||||
setRelayUrls((prev) => { |
|
||||||
if (cached && cached.length > 0) return cached |
|
||||||
if (prev.length === 0) return prev |
|
||||||
return [] |
|
||||||
}) |
|
||||||
setLoading(false) |
|
||||||
return |
|
||||||
} |
|
||||||
void fetch(false) |
|
||||||
}, [pubkey, enabled, fetch]) |
|
||||||
|
|
||||||
return { relayUrls, loading, refresh } |
|
||||||
} |
|
||||||
@ -1,422 +0,0 @@ |
|||||||
/** |
|
||||||
* Orchestrated fetch for the profile interactions accordion: phase 1 (zaps, notes, follow packs, |
|
||||||
* profile_badges list), then separate batches for comments on notes, comments on profile (#a), and |
|
||||||
* profile reactions (#e + #a); badge NIP-58 resolution and reports run after. `onPartial` fires as |
|
||||||
* relays return events (coalesced per microtask). Session cache writes stay at completion only. |
|
||||||
* Ordering matches the former standalone profile-interactions hook (removed; logic lives here). |
|
||||||
*/ |
|
||||||
|
|
||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { getZapInfoFromEvent } from '@/lib/event-metadata' |
|
||||||
import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls' |
|
||||||
import { |
|
||||||
profileAccordionGetCachedBadges, |
|
||||||
profileAccordionGetCachedFollowPacks, |
|
||||||
profileAccordionGetCachedInteractions, |
|
||||||
profileAccordionGetCachedReports, |
|
||||||
profileAccordionRelayUrlsKey, |
|
||||||
profileAccordionSetBadges, |
|
||||||
profileAccordionSetFollowPacks, |
|
||||||
profileAccordionSetInteractions, |
|
||||||
profileAccordionSetReports |
|
||||||
} from '@/lib/profile-accordion-session-cache' |
|
||||||
import type { TProfileBadge } from '@/hooks/useProfileBadges' |
|
||||||
import { enrichBadgesFromIndexedDb, resolveProfileBadgeList } from '@/hooks/useProfileBadges' |
|
||||||
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' |
|
||||||
import type { TProfileZap } from '@/hooks/useProfileInteractions' |
|
||||||
import { replaceableEventDedupeKey } from '@/lib/event' |
|
||||||
import { hexPubkeysEqual } from '@/lib/pubkey' |
|
||||||
import { queryService, replaceableEventService } from '@/services/client.service' |
|
||||||
import { Event, Filter, kinds } from 'nostr-tools' |
|
||||||
|
|
||||||
const NOTE_IDS_FOR_COMMENTS = 50 |
|
||||||
const REPORT_LIMIT = 50 |
|
||||||
|
|
||||||
const QUERY_OPTS = { |
|
||||||
eoseTimeout: 2500, |
|
||||||
globalTimeout: 18_000, |
|
||||||
firstRelayResultGraceMs: false |
|
||||||
} as const |
|
||||||
|
|
||||||
export type ProfileAccordionBundle = { |
|
||||||
zaps: TProfileZap[] |
|
||||||
reactions: Event[] |
|
||||||
comments: Event[] |
|
||||||
badges: TProfileBadge[] |
|
||||||
followPacks: TProfileFollowPack[] |
|
||||||
reports: Event[] |
|
||||||
} |
|
||||||
|
|
||||||
function getPackTitle(event: Event): string { |
|
||||||
const titleTag = event.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name') |
|
||||||
return titleTag?.[1] || 'Follow Pack' |
|
||||||
} |
|
||||||
|
|
||||||
function isProfileBadgesListEvent(pubkey: string, e: Event): boolean { |
|
||||||
if (e.kind !== ExtendedKind.PROFILE_BADGES) return false |
|
||||||
if (!hexPubkeysEqual(e.pubkey, pubkey)) return false |
|
||||||
return e.tags.some((t) => t[0] === 'd' && t[1] === 'profile_badges') |
|
||||||
} |
|
||||||
|
|
||||||
function cacheHydrated( |
|
||||||
pubkey: string, |
|
||||||
relayKey: string, |
|
||||||
viewerPubkey: string | null | undefined |
|
||||||
): ProfileAccordionBundle | null { |
|
||||||
const zi = profileAccordionGetCachedInteractions(pubkey, relayKey) |
|
||||||
const zb = profileAccordionGetCachedBadges(pubkey, relayKey) |
|
||||||
const zf = profileAccordionGetCachedFollowPacks(pubkey, relayKey) |
|
||||||
const viewer = viewerPubkey?.trim() |
|
||||||
const reportsReady = !viewer || profileAccordionGetCachedReports(pubkey, viewer) !== undefined |
|
||||||
if (!zi || zb === undefined || zf === undefined || !reportsReady) return null |
|
||||||
const reports = |
|
||||||
viewer ? profileAccordionGetCachedReports(pubkey, viewer) ?? [] : [] |
|
||||||
return { |
|
||||||
zaps: zi.zaps, |
|
||||||
reactions: zi.reactions, |
|
||||||
comments: zi.comments, |
|
||||||
badges: zb, |
|
||||||
followPacks: zf, |
|
||||||
reports |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
function bundleSnapshot(args: { |
|
||||||
collectedZaps: TProfileZap[] |
|
||||||
reactionsByPubkey: Map<string, Event> |
|
||||||
collectedComments: Event[] |
|
||||||
packByDedupeKey: Map<string, TProfileFollowPack> |
|
||||||
badgesForUi: TProfileBadge[] |
|
||||||
reports: Event[] |
|
||||||
}): ProfileAccordionBundle { |
|
||||||
const zaps = [...args.collectedZaps].sort((a, b) => b.amount - a.amount) |
|
||||||
const reactions = Array.from(args.reactionsByPubkey.values()).sort( |
|
||||||
(a, b) => b.created_at - a.created_at |
|
||||||
) |
|
||||||
const comments = [...args.collectedComments].sort((a, b) => b.created_at - a.created_at) |
|
||||||
const followPacks = [...args.packByDedupeKey.values()].sort( |
|
||||||
(a, b) => b.event.created_at - a.event.created_at |
|
||||||
) |
|
||||||
return { |
|
||||||
zaps, |
|
||||||
reactions, |
|
||||||
comments, |
|
||||||
badges: args.badgesForUi, |
|
||||||
followPacks, |
|
||||||
reports: args.reports |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export async function fetchProfileAccordionBundle(args: { |
|
||||||
pubkey: string |
|
||||||
urls: string[] |
|
||||||
viewerPubkey: string | null | undefined |
|
||||||
favoriteRelays: string[] |
|
||||||
blockedRelays: string[] |
|
||||||
force: boolean |
|
||||||
/** Called as relays return events so the UI can render incrementally (not only after full EOSE). */ |
|
||||||
onPartial?: (bundle: ProfileAccordionBundle) => void |
|
||||||
}): Promise<ProfileAccordionBundle> { |
|
||||||
const { pubkey, urls, viewerPubkey, favoriteRelays, blockedRelays, force, onPartial } = args |
|
||||||
const relayKey = profileAccordionRelayUrlsKey(urls) |
|
||||||
const viewer = viewerPubkey?.trim() |
|
||||||
|
|
||||||
if (!force) { |
|
||||||
const hit = cacheHydrated(pubkey, relayKey, viewer) |
|
||||||
if (hit) return hit |
|
||||||
} |
|
||||||
|
|
||||||
const profileReactionATags = new Set([`0:${pubkey}:`, `0:${pubkey}:profile`]) |
|
||||||
const profileAddrs = [`0:${pubkey}:`, `0:${pubkey}:profile`] |
|
||||||
|
|
||||||
const seedBadges = force ? undefined : profileAccordionGetCachedBadges(pubkey, relayKey) |
|
||||||
let resolvedBadges: TProfileBadge[] | null = null |
|
||||||
let reportsSoFar: Event[] = [] |
|
||||||
|
|
||||||
const collectedZaps: TProfileZap[] = [] |
|
||||||
const seenZaps = new Set<string>() |
|
||||||
const noteIdSet = new Set<string>() |
|
||||||
const packByDedupeKey = new Map<string, TProfileFollowPack>() |
|
||||||
const reactionsByPubkey = new Map<string, Event>() |
|
||||||
const seenProfileReactionEventIds = new Set<string>() |
|
||||||
const collectedComments: Event[] = [] |
|
||||||
const seenCommentIds = new Set<string>() |
|
||||||
let profileBadgesEvent: Event | undefined |
|
||||||
let profileMetaEvent: Event | undefined |
|
||||||
|
|
||||||
const emit = () => { |
|
||||||
if (!onPartial) return |
|
||||||
const badgesForUi = resolvedBadges ?? seedBadges ?? [] |
|
||||||
onPartial( |
|
||||||
bundleSnapshot({ |
|
||||||
collectedZaps, |
|
||||||
reactionsByPubkey, |
|
||||||
collectedComments, |
|
||||||
packByDedupeKey, |
|
||||||
badgesForUi, |
|
||||||
reports: reportsSoFar |
|
||||||
}) |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
let emitCoalesce = false |
|
||||||
const scheduleEmit = () => { |
|
||||||
if (!onPartial || emitCoalesce) return |
|
||||||
emitCoalesce = true |
|
||||||
queueMicrotask(() => { |
|
||||||
emitCoalesce = false |
|
||||||
emit() |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const reactionTargetsKind0Profile = (evt: Event): boolean => { |
|
||||||
if (evt.kind !== kinds.Reaction) return false |
|
||||||
const aHit = evt.tags.some((t) => t[0] === 'a' && t[1] && profileReactionATags.has(t[1])) |
|
||||||
if (aHit) return true |
|
||||||
const pid = profileMetaEvent?.id |
|
||||||
if (!pid) return false |
|
||||||
return evt.tags.some((t) => t[0] === 'e' && t[1] && hexPubkeysEqual(t[1], pid)) |
|
||||||
} |
|
||||||
|
|
||||||
const ingestProfileReaction = (evt: Event) => { |
|
||||||
if (!reactionTargetsKind0Profile(evt)) return |
|
||||||
if (hexPubkeysEqual(evt.pubkey, pubkey)) return |
|
||||||
if (seenProfileReactionEventIds.has(evt.id)) return |
|
||||||
seenProfileReactionEventIds.add(evt.id) |
|
||||||
const existing = reactionsByPubkey.get(evt.pubkey) |
|
||||||
if (!existing || evt.created_at > existing.created_at) { |
|
||||||
reactionsByPubkey.set(evt.pubkey, evt) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const ingestComment = (evt: Event) => { |
|
||||||
if (evt.kind !== ExtendedKind.COMMENT) return |
|
||||||
if (hexPubkeysEqual(evt.pubkey, pubkey)) return |
|
||||||
if (seenCommentIds.has(evt.id)) return |
|
||||||
seenCommentIds.add(evt.id) |
|
||||||
collectedComments.push(evt) |
|
||||||
} |
|
||||||
|
|
||||||
const ingestPhase1Event = (evt: Event) => { |
|
||||||
if (evt.kind === kinds.Zap) { |
|
||||||
const info = getZapInfoFromEvent(evt) |
|
||||||
if (!info || !hexPubkeysEqual(info.recipientPubkey ?? '', pubkey) || !info.amount || info.amount <= 0) |
|
||||||
return |
|
||||||
const sender = info.senderPubkey ?? evt.pubkey |
|
||||||
if (hexPubkeysEqual(sender, pubkey)) return |
|
||||||
if (seenZaps.has(evt.id)) return |
|
||||||
seenZaps.add(evt.id) |
|
||||||
collectedZaps.push({ |
|
||||||
pr: evt.id, |
|
||||||
pubkey: sender, |
|
||||||
amount: info.amount, |
|
||||||
created_at: evt.created_at, |
|
||||||
comment: info.comment |
|
||||||
}) |
|
||||||
} else if (evt.kind === kinds.ShortTextNote) { |
|
||||||
noteIdSet.add(evt.id) |
|
||||||
} else if (evt.kind === ExtendedKind.FOLLOW_PACK) { |
|
||||||
const key = replaceableEventDedupeKey(evt) |
|
||||||
const next: TProfileFollowPack = { event: evt, title: getPackTitle(evt) } |
|
||||||
const prev = packByDedupeKey.get(key) |
|
||||||
if (!prev || evt.created_at > prev.event.created_at) { |
|
||||||
packByDedupeKey.set(key, next) |
|
||||||
} |
|
||||||
} else if (isProfileBadgesListEvent(pubkey, evt)) { |
|
||||||
if (!profileBadgesEvent || evt.created_at > profileBadgesEvent.created_at) { |
|
||||||
profileBadgesEvent = evt |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Keep phase 1 free of #a reaction/comment: many relays handle those poorly when batched with
|
|
||||||
// zaps/notes/badges. Same ordering as interactions hook — dedicated REQ(s) for profile comments
|
|
||||||
// and reactions after we have note ids + kind-0 id.
|
|
||||||
const phase1Filters: Filter[] = [ |
|
||||||
{ '#p': [pubkey], kinds: [kinds.Zap], limit: 100 }, |
|
||||||
{ authors: [pubkey], kinds: [kinds.ShortTextNote], limit: NOTE_IDS_FOR_COMMENTS }, |
|
||||||
{ '#p': [pubkey], kinds: [ExtendedKind.FOLLOW_PACK], limit: 50 }, |
|
||||||
{ |
|
||||||
authors: [pubkey], |
|
||||||
kinds: [ExtendedKind.PROFILE_BADGES], |
|
||||||
'#d': ['profile_badges'], |
|
||||||
limit: 5 |
|
||||||
} |
|
||||||
] |
|
||||||
|
|
||||||
const phase1Opts = { |
|
||||||
...QUERY_OPTS, |
|
||||||
onevent: (evt: Event) => { |
|
||||||
ingestPhase1Event(evt) |
|
||||||
scheduleEmit() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const [metaEv, _phase1Events] = await Promise.all([ |
|
||||||
replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, urls), |
|
||||||
queryService.fetchEvents(urls, phase1Filters, phase1Opts) |
|
||||||
]) |
|
||||||
profileMetaEvent = metaEv |
|
||||||
emit() |
|
||||||
|
|
||||||
const noteIds = [...noteIdSet].slice(0, NOTE_IDS_FOR_COMMENTS) |
|
||||||
|
|
||||||
if (noteIds.length > 0) { |
|
||||||
await queryService.fetchEvents( |
|
||||||
urls, |
|
||||||
[{ '#e': noteIds, kinds: [ExtendedKind.COMMENT], limit: 50 }], |
|
||||||
{ |
|
||||||
...QUERY_OPTS, |
|
||||||
onevent: (evt: Event) => { |
|
||||||
if (evt.kind === ExtendedKind.COMMENT) ingestComment(evt) |
|
||||||
scheduleEmit() |
|
||||||
} |
|
||||||
} |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
await queryService.fetchEvents( |
|
||||||
urls, |
|
||||||
[{ '#a': profileAddrs, kinds: [ExtendedKind.COMMENT], limit: 120 }], |
|
||||||
{ |
|
||||||
...QUERY_OPTS, |
|
||||||
onevent: (evt: Event) => { |
|
||||||
if (evt.kind === ExtendedKind.COMMENT) ingestComment(evt) |
|
||||||
scheduleEmit() |
|
||||||
} |
|
||||||
} |
|
||||||
) |
|
||||||
|
|
||||||
const reactionFilters: Filter[] = [] |
|
||||||
if (profileMetaEvent?.id) { |
|
||||||
reactionFilters.push({ '#e': [profileMetaEvent.id], kinds: [kinds.Reaction], limit: 80 }) |
|
||||||
} |
|
||||||
reactionFilters.push({ |
|
||||||
'#a': [...profileReactionATags], |
|
||||||
kinds: [kinds.Reaction], |
|
||||||
limit: 80 |
|
||||||
}) |
|
||||||
await queryService.fetchEvents(urls, reactionFilters, { |
|
||||||
...QUERY_OPTS, |
|
||||||
onevent: (evt: Event) => { |
|
||||||
if (evt.kind === kinds.Reaction) ingestProfileReaction(evt) |
|
||||||
scheduleEmit() |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
collectedZaps.sort((a, b) => b.amount - a.amount) |
|
||||||
const reactions = Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at) |
|
||||||
collectedComments.sort((a, b) => b.created_at - a.created_at) |
|
||||||
const followPacks = [...packByDedupeKey.values()].sort((a, b) => b.event.created_at - a.event.created_at) |
|
||||||
|
|
||||||
let badges = await resolveProfileBadgeList(profileBadgesEvent, urls, blockedRelays, seedBadges) |
|
||||||
badges = await enrichBadgesFromIndexedDb(badges) |
|
||||||
resolvedBadges = badges |
|
||||||
emit() |
|
||||||
|
|
||||||
let reports: Event[] = [] |
|
||||||
if (viewer) { |
|
||||||
const reportUrls = await buildProfileReportRelayUrls({ |
|
||||||
viewerPubkey: viewer, |
|
||||||
favoriteRelays, |
|
||||||
blockedRelays |
|
||||||
}) |
|
||||||
if (reportUrls.length > 0) { |
|
||||||
const seenReportIds = new Set<string>() |
|
||||||
reports = await queryService.fetchEvents( |
|
||||||
reportUrls, |
|
||||||
[{ '#p': [pubkey], kinds: [ExtendedKind.REPORT], limit: REPORT_LIMIT }], |
|
||||||
{ |
|
||||||
...QUERY_OPTS, |
|
||||||
onevent: (evt: Event) => { |
|
||||||
if (evt.kind !== ExtendedKind.REPORT || seenReportIds.has(evt.id)) return |
|
||||||
seenReportIds.add(evt.id) |
|
||||||
reportsSoFar.push(evt) |
|
||||||
reportsSoFar.sort((a, b) => b.created_at - a.created_at) |
|
||||||
scheduleEmit() |
|
||||||
} |
|
||||||
} |
|
||||||
) |
|
||||||
} |
|
||||||
profileAccordionSetReports(pubkey, viewer, reports) |
|
||||||
} |
|
||||||
reportsSoFar = reports |
|
||||||
|
|
||||||
profileAccordionSetInteractions(pubkey, relayKey, { |
|
||||||
zaps: collectedZaps, |
|
||||||
reactions, |
|
||||||
comments: collectedComments |
|
||||||
}) |
|
||||||
profileAccordionSetBadges(pubkey, relayKey, badges) |
|
||||||
profileAccordionSetFollowPacks(pubkey, relayKey, followPacks) |
|
||||||
|
|
||||||
emit() |
|
||||||
|
|
||||||
return { |
|
||||||
zaps: collectedZaps, |
|
||||||
reactions, |
|
||||||
comments: collectedComments, |
|
||||||
badges, |
|
||||||
followPacks, |
|
||||||
reports |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionBundleCacheKey(urls: string[]): string { |
|
||||||
return profileAccordionRelayUrlsKey(urls) |
|
||||||
} |
|
||||||
|
|
||||||
function badgeMergeKey(b: TProfileBadge): string { |
|
||||||
return `${b.a}|${b.awardId}` |
|
||||||
} |
|
||||||
|
|
||||||
/** Merge two accordion bundles (e.g. provisional relays + delta-only second fetch). */ |
|
||||||
export function mergeProfileAccordionBundles( |
|
||||||
base: ProfileAccordionBundle, |
|
||||||
add: ProfileAccordionBundle |
|
||||||
): ProfileAccordionBundle { |
|
||||||
const zapByPr = new Map(base.zaps.map((z) => [z.pr, z])) |
|
||||||
for (const z of add.zaps) { |
|
||||||
if (!zapByPr.has(z.pr)) zapByPr.set(z.pr, z) |
|
||||||
} |
|
||||||
const zaps = [...zapByPr.values()].sort((a, b) => b.amount - a.amount) |
|
||||||
|
|
||||||
const reactionsByPubkey = new Map<string, Event>() |
|
||||||
for (const e of base.reactions) { |
|
||||||
reactionsByPubkey.set(e.pubkey, e) |
|
||||||
} |
|
||||||
for (const e of add.reactions) { |
|
||||||
const prev = reactionsByPubkey.get(e.pubkey) |
|
||||||
if (!prev || e.created_at > prev.created_at) reactionsByPubkey.set(e.pubkey, e) |
|
||||||
} |
|
||||||
const reactions = [...reactionsByPubkey.values()].sort((a, b) => b.created_at - a.created_at) |
|
||||||
|
|
||||||
const commentById = new Map(base.comments.map((c) => [c.id, c])) |
|
||||||
for (const c of add.comments) { |
|
||||||
if (!commentById.has(c.id)) commentById.set(c.id, c) |
|
||||||
} |
|
||||||
const comments = [...commentById.values()].sort((a, b) => b.created_at - a.created_at) |
|
||||||
|
|
||||||
const packByKey = new Map(base.followPacks.map((p) => [replaceableEventDedupeKey(p.event), p])) |
|
||||||
for (const p of add.followPacks) { |
|
||||||
const k = replaceableEventDedupeKey(p.event) |
|
||||||
const prev = packByKey.get(k) |
|
||||||
if (!prev || p.event.created_at > prev.event.created_at) packByKey.set(k, p) |
|
||||||
} |
|
||||||
const followPacks = [...packByKey.values()].sort((a, b) => b.event.created_at - a.event.created_at) |
|
||||||
|
|
||||||
const badgeByKey = new Map(base.badges.map((b) => [badgeMergeKey(b), b])) |
|
||||||
for (const b of add.badges) { |
|
||||||
const k = badgeMergeKey(b) |
|
||||||
if (!badgeByKey.has(k)) badgeByKey.set(k, b) |
|
||||||
} |
|
||||||
const badges = [...badgeByKey.values()] |
|
||||||
|
|
||||||
const reportById = new Map(base.reports.map((r) => [r.id, r])) |
|
||||||
for (const r of add.reports) { |
|
||||||
if (!reportById.has(r.id)) reportById.set(r.id, r) |
|
||||||
} |
|
||||||
const reports = [...reportById.values()].sort((a, b) => b.created_at - a.created_at) |
|
||||||
|
|
||||||
return { zaps, reactions, comments, badges, followPacks, reports } |
|
||||||
} |
|
||||||
@ -1,131 +0,0 @@ |
|||||||
/** |
|
||||||
* In-memory session cache for profile accordion fetches (per viewed profile pubkey). |
|
||||||
* Survives collapsing/reopening the accordion; cleared on full page reload. |
|
||||||
*/ |
|
||||||
|
|
||||||
import type { TProfileZap } from '@/hooks/useProfileInteractions' |
|
||||||
import type { TProfileBadge } from '@/hooks/useProfileBadges' |
|
||||||
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' |
|
||||||
import type { Event } from 'nostr-tools' |
|
||||||
|
|
||||||
export type ProfileAccordionInteractionsSnapshot = { |
|
||||||
zaps: TProfileZap[] |
|
||||||
reactions: Event[] |
|
||||||
comments: Event[] |
|
||||||
} |
|
||||||
|
|
||||||
type Entry = { |
|
||||||
relayUrls?: string[] |
|
||||||
/** Fingerprint of profile relay list from {@link profileAccordionSetRelayUrls} (invalidates slices when it changes) */ |
|
||||||
relayUrlsKey?: string |
|
||||||
interactions?: ProfileAccordionInteractionsSnapshot |
|
||||||
/** Relay key used for the last interactions fetch (per-slice; avoids races with badges / follow packs) */ |
|
||||||
interactionsRelayKey?: string |
|
||||||
badges?: TProfileBadge[] |
|
||||||
badgesRelayKey?: string |
|
||||||
followPacks?: TProfileFollowPack[] |
|
||||||
followPacksRelayKey?: string |
|
||||||
/** viewer hex pubkey → reports */ |
|
||||||
reportsByViewer?: Record<string, Event[]> |
|
||||||
} |
|
||||||
|
|
||||||
const store = new Map<string, Entry>() |
|
||||||
|
|
||||||
export function profileAccordionRelayUrlsKey(urls: string[]): string { |
|
||||||
if (urls.length === 0) return '' |
|
||||||
return [...urls].sort().join('|') |
|
||||||
} |
|
||||||
|
|
||||||
function getEntry(pubkey: string): Entry { |
|
||||||
let e = store.get(pubkey) |
|
||||||
if (!e) { |
|
||||||
e = {} |
|
||||||
store.set(pubkey, e) |
|
||||||
} |
|
||||||
return e |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionGetCachedRelayUrls(pubkey: string): string[] | undefined { |
|
||||||
const urls = getEntry(pubkey).relayUrls |
|
||||||
return urls?.length ? urls : undefined |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionSetRelayUrls(pubkey: string, urls: string[]): void { |
|
||||||
const e = getEntry(pubkey) |
|
||||||
const key = profileAccordionRelayUrlsKey(urls) |
|
||||||
if (e.relayUrlsKey && e.relayUrlsKey !== key) { |
|
||||||
delete e.interactions |
|
||||||
delete e.interactionsRelayKey |
|
||||||
delete e.badges |
|
||||||
delete e.badgesRelayKey |
|
||||||
delete e.followPacks |
|
||||||
delete e.followPacksRelayKey |
|
||||||
} |
|
||||||
e.relayUrls = urls |
|
||||||
e.relayUrlsKey = key |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionGetCachedInteractions( |
|
||||||
pubkey: string, |
|
||||||
relayKey: string |
|
||||||
): ProfileAccordionInteractionsSnapshot | undefined { |
|
||||||
const e = store.get(pubkey) |
|
||||||
if (!e?.interactions || e.interactionsRelayKey !== relayKey) return undefined |
|
||||||
return e.interactions |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionSetInteractions( |
|
||||||
pubkey: string, |
|
||||||
relayKey: string, |
|
||||||
data: ProfileAccordionInteractionsSnapshot |
|
||||||
): void { |
|
||||||
const e = getEntry(pubkey) |
|
||||||
e.interactions = data |
|
||||||
e.interactionsRelayKey = relayKey |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionGetCachedBadges(pubkey: string, relayKey: string): TProfileBadge[] | undefined { |
|
||||||
const e = store.get(pubkey) |
|
||||||
if (!e?.badges || e.badgesRelayKey !== relayKey) return undefined |
|
||||||
return e.badges |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionSetBadges(pubkey: string, relayKey: string, badges: TProfileBadge[]): void { |
|
||||||
const e = getEntry(pubkey) |
|
||||||
e.badges = badges |
|
||||||
e.badgesRelayKey = relayKey |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionGetCachedFollowPacks( |
|
||||||
pubkey: string, |
|
||||||
relayKey: string |
|
||||||
): TProfileFollowPack[] | undefined { |
|
||||||
const e = store.get(pubkey) |
|
||||||
if (!e?.followPacks || e.followPacksRelayKey !== relayKey) return undefined |
|
||||||
return e.followPacks |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionSetFollowPacks( |
|
||||||
pubkey: string, |
|
||||||
relayKey: string, |
|
||||||
packs: TProfileFollowPack[] |
|
||||||
): void { |
|
||||||
const e = getEntry(pubkey) |
|
||||||
e.followPacks = packs |
|
||||||
e.followPacksRelayKey = relayKey |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionGetCachedReports(profilePubkey: string, viewerPubkey: string): Event[] | undefined { |
|
||||||
return getEntry(profilePubkey).reportsByViewer?.[viewerPubkey] |
|
||||||
} |
|
||||||
|
|
||||||
export function profileAccordionSetReports( |
|
||||||
profilePubkey: string, |
|
||||||
viewerPubkey: string, |
|
||||||
reports: Event[] |
|
||||||
): void { |
|
||||||
const e = getEntry(profilePubkey) |
|
||||||
if (!e.reportsByViewer) e.reportsByViewer = {} |
|
||||||
e.reportsByViewer[viewerPubkey] = reports |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,178 +0,0 @@ |
|||||||
import type { Event } from 'nostr-tools' |
|
||||||
|
|
||||||
const HEX64 = /^[0-9a-f]{64}$/i |
|
||||||
|
|
||||||
/** Pubkeys this author tags with `p` or references via `a` (kind:pubkey:…), excluding self. */ |
|
||||||
export function extractPartnerPubkeysFromEvent(event: Event, authorPubkeyLower: string): string[] { |
|
||||||
const self = authorPubkeyLower.toLowerCase() |
|
||||||
const found = new Set<string>() |
|
||||||
for (const t of event.tags ?? []) { |
|
||||||
const name = t[0] |
|
||||||
if (name === 'p' || name === 'P') { |
|
||||||
const pk = (t[1] ?? '').trim().toLowerCase() |
|
||||||
if (HEX64.test(pk) && pk !== self) found.add(pk) |
|
||||||
continue |
|
||||||
} |
|
||||||
if (name === 'a' || name === 'A') { |
|
||||||
const coord = (t[1] ?? '').trim() |
|
||||||
const parts = coord.split(':') |
|
||||||
if (parts.length >= 2) { |
|
||||||
const pk = parts[1]!.toLowerCase() |
|
||||||
if (HEX64.test(pk) && pk !== self) found.add(pk) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
return [...found] |
|
||||||
} |
|
||||||
|
|
||||||
export type TInteractionPartnerStat = { |
|
||||||
pubkey: string |
|
||||||
/** How often this pubkey appears in p / a references on the author's events */ |
|
||||||
mentionCount: number |
|
||||||
/** Latest event created_at among those references */ |
|
||||||
lastReferencedAt: number |
|
||||||
} |
|
||||||
|
|
||||||
/** Same recency horizon as the interaction map UI (≈ half a year). */ |
|
||||||
export const INTERACTION_MAP_RECENCY_MAX_AGE_SEC = 180 * 86400 |
|
||||||
|
|
||||||
export type TRankedInteractionPartner = { |
|
||||||
stat: TInteractionPartnerStat |
|
||||||
/** 0–100: more mentions and more recent references rank higher (matches map “heat” weights). */ |
|
||||||
score: number |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Sort by combined frequency + recency. Uses `nowSec` and `maxAgeSec` like the map card shading |
|
||||||
* (55% mention density vs max in list, 45% recency within the age window). |
|
||||||
*/ |
|
||||||
export function rankInteractionPartnersByRecencyAndFrequency( |
|
||||||
partners: TInteractionPartnerStat[], |
|
||||||
nowSec: number, |
|
||||||
maxAgeSec: number = INTERACTION_MAP_RECENCY_MAX_AGE_SEC |
|
||||||
): TRankedInteractionPartner[] { |
|
||||||
if (partners.length === 0) return [] |
|
||||||
const age = Math.max(1, maxAgeSec) |
|
||||||
const maxM = Math.max(1, ...partners.map((p) => p.mentionCount)) |
|
||||||
|
|
||||||
const scoreFor = (p: TInteractionPartnerStat): number => { |
|
||||||
const countNorm = Math.min(1, p.mentionCount / maxM) |
|
||||||
const recencyNorm = |
|
||||||
p.lastReferencedAt > 0 |
|
||||||
? 1 - Math.min(1, Math.max(0, nowSec - p.lastReferencedAt) / age) |
|
||||||
: 0 |
|
||||||
return 100 * (0.55 * countNorm + 0.45 * recencyNorm) |
|
||||||
} |
|
||||||
|
|
||||||
return [...partners] |
|
||||||
.map((stat) => ({ stat, score: scoreFor(stat) })) |
|
||||||
.sort( |
|
||||||
(a, b) => |
|
||||||
b.score - a.score || |
|
||||||
b.stat.mentionCount - a.stat.mentionCount || |
|
||||||
b.stat.lastReferencedAt - a.stat.lastReferencedAt || |
|
||||||
a.stat.pubkey.localeCompare(b.stat.pubkey) |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Rows for the interaction map grid: ranked by frequency/recency, with optional merge of the viewer’s |
|
||||||
* follows. When `includeAllFollows` is true, returns **every** merged row (no row cap): people from cached |
|
||||||
* tags first (by score), then everyone who appears only from your follow list (stable pubkey order). |
|
||||||
*/ |
|
||||||
export function rankInteractionMapGridRows( |
|
||||||
partners: TInteractionPartnerStat[], |
|
||||||
opts: { |
|
||||||
includeAllFollows: boolean |
|
||||||
followings: string[] |
|
||||||
nowSec: number |
|
||||||
maxAgeSec?: number |
|
||||||
/** Max rows when `includeAllFollows` is false (interaction-only view). Ignored when including follows. */ |
|
||||||
gridCap?: number |
|
||||||
} |
|
||||||
): TRankedInteractionPartner[] { |
|
||||||
const { |
|
||||||
includeAllFollows, |
|
||||||
followings, |
|
||||||
nowSec, |
|
||||||
maxAgeSec = INTERACTION_MAP_RECENCY_MAX_AGE_SEC, |
|
||||||
gridCap = 72 |
|
||||||
} = opts |
|
||||||
|
|
||||||
if (!includeAllFollows) { |
|
||||||
return rankInteractionPartnersByRecencyAndFrequency(partners, nowSec, maxAgeSec).slice(0, gridCap) |
|
||||||
} |
|
||||||
|
|
||||||
const merged = mergeInteractionPartnersWithFollowings(partners, followings) |
|
||||||
if (merged.length === 0) return [] |
|
||||||
|
|
||||||
const tagged = merged.filter((p) => p.mentionCount > 0 || p.lastReferencedAt > 0) |
|
||||||
const followOnly = merged.filter((p) => p.mentionCount === 0 && p.lastReferencedAt === 0) |
|
||||||
|
|
||||||
const rankedTagged = rankInteractionPartnersByRecencyAndFrequency(tagged, nowSec, maxAgeSec) |
|
||||||
const extrasSorted = [...followOnly].sort((a, b) => a.pubkey.localeCompare(b.pubkey)) |
|
||||||
const extraRows: TRankedInteractionPartner[] = extrasSorted.map((stat) => ({ stat, score: 0 })) |
|
||||||
return [...rankedTagged, ...extraRows] |
|
||||||
} |
|
||||||
|
|
||||||
export function buildInteractionPartnerStats(events: Event[], authorPubkey: string): TInteractionPartnerStat[] { |
|
||||||
const author = authorPubkey.trim().toLowerCase() |
|
||||||
if (!HEX64.test(author)) return [] |
|
||||||
|
|
||||||
const byPk = new Map<string, { count: number; lastAt: number }>() |
|
||||||
|
|
||||||
for (const ev of events) { |
|
||||||
if (!ev?.pubkey || ev.pubkey.toLowerCase() !== author) continue |
|
||||||
const ts = typeof ev.created_at === 'number' ? ev.created_at : 0 |
|
||||||
for (const pk of extractPartnerPubkeysFromEvent(ev, author)) { |
|
||||||
const cur = byPk.get(pk) ?? { count: 0, lastAt: 0 } |
|
||||||
cur.count += 1 |
|
||||||
cur.lastAt = Math.max(cur.lastAt, ts) |
|
||||||
byPk.set(pk, cur) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return [...byPk.entries()] |
|
||||||
.map(([pubkey, v]) => ({ |
|
||||||
pubkey, |
|
||||||
mentionCount: v.count, |
|
||||||
lastReferencedAt: v.lastAt |
|
||||||
})) |
|
||||||
.sort((a, b) => b.mentionCount - a.mentionCount || b.lastReferencedAt - a.lastReferencedAt) |
|
||||||
} |
|
||||||
|
|
||||||
export function mergeEventsById(events: Event[]): Event[] { |
|
||||||
const m = new Map<string, Event>() |
|
||||||
for (const e of events) { |
|
||||||
if (!e?.id) continue |
|
||||||
const prev = m.get(e.id) |
|
||||||
if (!prev || e.created_at > prev.created_at) m.set(e.id, e) |
|
||||||
} |
|
||||||
return [...m.values()] |
|
||||||
} |
|
||||||
|
|
||||||
/** Adds follow pubkeys not already present so the viewer can manage follows from the interaction grid. */ |
|
||||||
export function mergeInteractionPartnersWithFollowings( |
|
||||||
partners: TInteractionPartnerStat[], |
|
||||||
followedPubkeys: string[] |
|
||||||
): TInteractionPartnerStat[] { |
|
||||||
const map = new Map<string, TInteractionPartnerStat>() |
|
||||||
for (const p of partners) { |
|
||||||
const k = p.pubkey.trim().toLowerCase() |
|
||||||
if (!HEX64.test(k)) continue |
|
||||||
map.set(k, { pubkey: k, mentionCount: p.mentionCount, lastReferencedAt: p.lastReferencedAt }) |
|
||||||
} |
|
||||||
for (const raw of followedPubkeys) { |
|
||||||
const k = raw.trim().toLowerCase() |
|
||||||
if (!HEX64.test(k)) continue |
|
||||||
if (!map.has(k)) { |
|
||||||
map.set(k, { pubkey: k, mentionCount: 0, lastReferencedAt: 0 }) |
|
||||||
} |
|
||||||
} |
|
||||||
return [...map.values()].sort( |
|
||||||
(a, b) => |
|
||||||
b.mentionCount - a.mentionCount || |
|
||||||
b.lastReferencedAt - a.lastReferencedAt || |
|
||||||
a.pubkey.localeCompare(b.pubkey) |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,47 +0,0 @@ |
|||||||
/** |
|
||||||
* Build relay URLs for profile-related fetches (zaps, likes, comments, badges, follow packs). |
|
||||||
* Uses profile owner's outboxes + PROFILE_FETCH_RELAY_URLS. |
|
||||||
*/ |
|
||||||
|
|
||||||
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import { normalizeUrl } from '@/lib/url' |
|
||||||
|
|
||||||
/** |
|
||||||
* Immediate relay stack before NIP-65 outboxes resolve (accordion / fast first paint). |
|
||||||
*/ |
|
||||||
export function getProfileRelayUrlsProvisional(blockedRelays: string[] = []): string[] { |
|
||||||
const blocked = new Set( |
|
||||||
[...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS].map((u) => (normalizeUrl(u) || u).toLowerCase()) |
|
||||||
) |
|
||||||
const out: string[] = [] |
|
||||||
const seen = new Set<string>() |
|
||||||
for (const u of PROFILE_FETCH_RELAY_URLS) { |
|
||||||
const n = normalizeUrl(u) || u |
|
||||||
if (!n || blocked.has(n.toLowerCase()) || seen.has(n)) continue |
|
||||||
seen.add(n) |
|
||||||
out.push(n) |
|
||||||
} |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
export async function buildProfileRelayUrls( |
|
||||||
pubkey: string, |
|
||||||
blockedRelays: string[] = [] |
|
||||||
): Promise<string[]> { |
|
||||||
const blocked = new Set( |
|
||||||
[...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS].map((u) => (normalizeUrl(u) || u).toLowerCase()) |
|
||||||
) |
|
||||||
const addRelay = (url: string | undefined, out: Set<string>) => { |
|
||||||
if (!url) return |
|
||||||
const n = normalizeUrl(url) || url |
|
||||||
if (!n || blocked.has(n.toLowerCase())) return |
|
||||||
out.add(n) |
|
||||||
} |
|
||||||
|
|
||||||
const relayUrlsSet = new Set<string>() |
|
||||||
const relayList = await client.fetchRelayList(pubkey).catch(() => ({ write: [] as string[], read: [] as string[] })) |
|
||||||
;(relayList?.write ?? []).filter((u): u is string => !!u).forEach((u) => addRelay(u, relayUrlsSet)) |
|
||||||
PROFILE_FETCH_RELAY_URLS.forEach((u) => addRelay(u, relayUrlsSet)) |
|
||||||
return Array.from(relayUrlsSet) |
|
||||||
} |
|
||||||
@ -1,35 +0,0 @@ |
|||||||
/** |
|
||||||
* Relays for profile NIP-56 reports (kind 1984): only the viewer’s favorite tier and read (inbox) |
|
||||||
* relays — no profile outboxes or global read mirrors, to limit abusive report spam. |
|
||||||
*/ |
|
||||||
|
|
||||||
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' |
|
||||||
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' |
|
||||||
import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' |
|
||||||
import { normalizeUrl } from '@/lib/url' |
|
||||||
import client from '@/services/client.service' |
|
||||||
|
|
||||||
const MAX_PROFILE_REPORT_RELAYS = 28 |
|
||||||
|
|
||||||
export async function buildProfileReportRelayUrls(options: { |
|
||||||
viewerPubkey: string |
|
||||||
favoriteRelays: string[] |
|
||||||
blockedRelays: string[] |
|
||||||
}): Promise<string[]> { |
|
||||||
const { viewerPubkey, favoriteRelays, blockedRelays } = options |
|
||||||
const list = await client.fetchRelayList(viewerPubkey).catch(() => ({ read: [] as string[], write: [] as string[] })) |
|
||||||
const inbox = relayUrlsLocalsFirst(list.read ?? []) |
|
||||||
.map((u) => normalizeUrl(u) || u) |
|
||||||
.filter(Boolean) as string[] |
|
||||||
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) |
|
||||||
return feedRelayPolicyUrls([ |
|
||||||
{ source: 'favorites', urls: favorites }, |
|
||||||
{ source: 'viewer-read', urls: inbox } |
|
||||||
], { |
|
||||||
operation: 'read', |
|
||||||
blockedRelays, |
|
||||||
maxRelays: MAX_PROFILE_REPORT_RELAYS, |
|
||||||
applySocialKindBlockedFilter: false, |
|
||||||
allowThirdPartyLocalRelays: true |
|
||||||
}) |
|
||||||
} |
|
||||||
@ -1,334 +0,0 @@ |
|||||||
import { RefreshButton } from '@/components/RefreshButton' |
|
||||||
import UserAvatar from '@/components/UserAvatar' |
|
||||||
import Username from '@/components/Username' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Checkbox } from '@/components/ui/checkbox' |
|
||||||
import { Label } from '@/components/ui/label' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { Switch } from '@/components/ui/switch' |
|
||||||
import { useSecondaryPage } from '@/contexts/secondary-page-context' |
|
||||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
|
||||||
import { useFetchProfile } from '@/hooks/useFetchProfile' |
|
||||||
import { |
|
||||||
buildInteractionPartnerStats, |
|
||||||
INTERACTION_MAP_RECENCY_MAX_AGE_SEC, |
|
||||||
mergeEventsById, |
|
||||||
mergeInteractionPartnersWithFollowings, |
|
||||||
rankInteractionMapGridRows, |
|
||||||
type TInteractionPartnerStat |
|
||||||
} from '@/lib/profile-interaction-partners' |
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
|
||||||
import { toProfile } from '@/lib/link' |
|
||||||
import { useFollowListOptional } from '@/providers/follow-list-context' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { eventService } from '@/services/client.service' |
|
||||||
import indexedDb from '@/services/indexed-db.service' |
|
||||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { toast } from 'sonner' |
|
||||||
import { kinds } from 'nostr-tools' |
|
||||||
import type { TPageRef } from '@/types' |
|
||||||
import dayjs from 'dayjs' |
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime' |
|
||||||
|
|
||||||
dayjs.extend(relativeTime) |
|
||||||
|
|
||||||
const INTERACTION_KINDS = [kinds.ShortTextNote, kinds.Repost, kinds.Reaction] as const |
|
||||||
|
|
||||||
/** Co-located with this lazy page so dev/build chunks share one `react` instance (avoids invalid hook call). */ |
|
||||||
function useProfileInteractionPartners(authorPubkey: string | undefined, refreshNonce = 0) { |
|
||||||
const [partners, setPartners] = useState<TInteractionPartnerStat[]>([]) |
|
||||||
const [loading, setLoading] = useState(false) |
|
||||||
const [archiveAuthorEvents, setArchiveAuthorEvents] = useState(0) |
|
||||||
const [sessionEventCount, setSessionEventCount] = useState(0) |
|
||||||
|
|
||||||
const run = useCallback(async () => { |
|
||||||
const pk = authorPubkey?.trim().toLowerCase() |
|
||||||
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) { |
|
||||||
setPartners([]) |
|
||||||
setArchiveAuthorEvents(0) |
|
||||||
setSessionEventCount(0) |
|
||||||
return |
|
||||||
} |
|
||||||
setLoading(true) |
|
||||||
try { |
|
||||||
const kindsArr = [...INTERACTION_KINDS] |
|
||||||
const sessionEv = eventService.listSessionEventsAuthoredBy(pk, { kinds: kindsArr, limit: 900 }) |
|
||||||
setSessionEventCount(sessionEv.length) |
|
||||||
setArchiveAuthorEvents(0) |
|
||||||
const mergedSession = mergeEventsById([...sessionEv]) |
|
||||||
setPartners(buildInteractionPartnerStats(mergedSession, pk)) |
|
||||||
|
|
||||||
void (async () => { |
|
||||||
try { |
|
||||||
const idbEv = await indexedDb.scanEventArchiveByAuthorPubkey(pk, { |
|
||||||
kinds: kindsArr, |
|
||||||
maxRowsScanned: 14_000, |
|
||||||
maxMatches: 450 |
|
||||||
}) |
|
||||||
setArchiveAuthorEvents(idbEv.length) |
|
||||||
const merged = mergeEventsById([...sessionEv, ...idbEv]) |
|
||||||
setPartners(buildInteractionPartnerStats(merged, pk)) |
|
||||||
} catch { |
|
||||||
/* best-effort disk */ |
|
||||||
} |
|
||||||
})() |
|
||||||
} finally { |
|
||||||
setLoading(false) |
|
||||||
} |
|
||||||
}, [authorPubkey]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
void run() |
|
||||||
}, [run, refreshNonce]) |
|
||||||
|
|
||||||
return { partners, loading, rescan: run, archiveAuthorEvents, sessionEventCount } |
|
||||||
} |
|
||||||
|
|
||||||
const ProfileInteractionDiagramPage = forwardRef< |
|
||||||
TPageRef, |
|
||||||
{ id?: string; index?: number; hideTitlebar?: boolean } |
|
||||||
>(({ id, index, hideTitlebar = false }, ref) => { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const { pubkey: accountPubkey, checkLogin } = useNostr() |
|
||||||
const followList = useFollowListOptional() |
|
||||||
const { profile } = useFetchProfile(id) |
|
||||||
const [refreshNonce, setRefreshNonce] = useState(0) |
|
||||||
const [includeAllFollows, setIncludeAllFollows] = useState(false) |
|
||||||
const [followBusyPubkey, setFollowBusyPubkey] = useState<string | null>(null) |
|
||||||
const bump = useCallback(() => setRefreshNonce((n) => n + 1), []) |
|
||||||
const { partners, loading, rescan, archiveAuthorEvents, sessionEventCount } = useProfileInteractionPartners( |
|
||||||
profile?.pubkey, |
|
||||||
refreshNonce |
|
||||||
) |
|
||||||
|
|
||||||
const rankedPartners = useMemo( |
|
||||||
() => |
|
||||||
rankInteractionMapGridRows(partners, { |
|
||||||
includeAllFollows, |
|
||||||
followings: followList?.followings ?? [], |
|
||||||
nowSec: dayjs().unix(), |
|
||||||
maxAgeSec: INTERACTION_MAP_RECENCY_MAX_AGE_SEC, |
|
||||||
gridCap: 72 |
|
||||||
}), |
|
||||||
[partners, includeAllFollows, followList?.followings] |
|
||||||
) |
|
||||||
|
|
||||||
const includeFollowsBreakdown = useMemo(() => { |
|
||||||
if (!includeAllFollows) return null |
|
||||||
const merged = mergeInteractionPartnersWithFollowings(partners, followList?.followings ?? []) |
|
||||||
const fromTags = merged.filter((p) => p.mentionCount > 0 || p.lastReferencedAt > 0).length |
|
||||||
return { |
|
||||||
total: merged.length, |
|
||||||
fromTags, |
|
||||||
fromFollowsOnly: merged.length - fromTags |
|
||||||
} |
|
||||||
}, [includeAllFollows, partners, followList?.followings]) |
|
||||||
|
|
||||||
const showFollowControls = Boolean(followList && accountPubkey) |
|
||||||
|
|
||||||
const handleFollowToggle = useCallback( |
|
||||||
(targetPubkey: string, nextChecked: boolean) => { |
|
||||||
if (!followList || !accountPubkey) return |
|
||||||
if (targetPubkey.toLowerCase() === accountPubkey.toLowerCase()) return |
|
||||||
checkLogin(async () => { |
|
||||||
setFollowBusyPubkey(targetPubkey) |
|
||||||
try { |
|
||||||
if (nextChecked) await followList.follow(targetPubkey) |
|
||||||
else await followList.unfollow(targetPubkey) |
|
||||||
} catch (err) { |
|
||||||
toast.error( |
|
||||||
(nextChecked ? t('Follow failed') : t('Unfollow failed')) + ': ' + (err as Error).message |
|
||||||
) |
|
||||||
} finally { |
|
||||||
setFollowBusyPubkey(null) |
|
||||||
} |
|
||||||
}) |
|
||||||
}, |
|
||||||
[followList, accountPubkey, checkLogin, t] |
|
||||||
) |
|
||||||
|
|
||||||
const layoutRef = useRef<TPageRef>(null) |
|
||||||
|
|
||||||
useImperativeHandle( |
|
||||||
ref, |
|
||||||
() => ({ |
|
||||||
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), |
|
||||||
refresh: () => { |
|
||||||
void rescan() |
|
||||||
bump() |
|
||||||
} |
|
||||||
}), |
|
||||||
[rescan, bump] |
|
||||||
) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!hideTitlebar) { |
|
||||||
registerPrimaryPanelRefresh(null) |
|
||||||
return |
|
||||||
} |
|
||||||
registerPrimaryPanelRefresh(() => { |
|
||||||
void rescan() |
|
||||||
bump() |
|
||||||
}) |
|
||||||
return () => registerPrimaryPanelRefresh(null) |
|
||||||
}, [hideTitlebar, registerPrimaryPanelRefresh, rescan, bump]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<SecondaryPageLayout |
|
||||||
ref={layoutRef} |
|
||||||
index={index} |
|
||||||
title={hideTitlebar ? undefined : t('interactionMapTitle')} |
|
||||||
hideBackButton={hideTitlebar} |
|
||||||
controls={hideTitlebar ? undefined : <RefreshButton onClick={() => void rescan()} />} |
|
||||||
displayScrollToTopButton |
|
||||||
> |
|
||||||
<div className="px-4 pb-8 space-y-4"> |
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4"> |
|
||||||
<p className="text-sm text-muted-foreground flex-1 min-w-0">{t('interactionMapSubtitle')}</p> |
|
||||||
{showFollowControls ? ( |
|
||||||
<div className="flex flex-col gap-1.5 shrink-0 sm:max-w-[min(100%,20rem)] sm:text-right"> |
|
||||||
<div className="flex items-center gap-2 sm:justify-end"> |
|
||||||
<Label htmlFor="interaction-map-include-follows" className="text-sm font-normal cursor-pointer"> |
|
||||||
{t('interactionMapIncludeFollows')} |
|
||||||
</Label> |
|
||||||
<Switch |
|
||||||
id="interaction-map-include-follows" |
|
||||||
checked={includeAllFollows} |
|
||||||
onCheckedChange={setIncludeAllFollows} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{includeAllFollows ? ( |
|
||||||
<> |
|
||||||
<p className="text-xs text-muted-foreground sm:text-right">{t('interactionMapIncludeFollowsHint')}</p> |
|
||||||
{includeFollowsBreakdown ? ( |
|
||||||
<p className="text-xs text-muted-foreground sm:text-right tabular-nums"> |
|
||||||
{t('interactionMapIncludeFollowsBreakdown', { |
|
||||||
total: includeFollowsBreakdown.total, |
|
||||||
fromTags: includeFollowsBreakdown.fromTags, |
|
||||||
fromFollowsOnly: includeFollowsBreakdown.fromFollowsOnly |
|
||||||
})} |
|
||||||
</p> |
|
||||||
) : null} |
|
||||||
</> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
<div className="text-xs text-muted-foreground flex flex-wrap gap-x-3 gap-y-1"> |
|
||||||
<span>{t('interactionMapSessionNotes', { count: sessionEventCount })}</span> |
|
||||||
<span>{t('interactionMapArchiveNotes', { count: archiveAuthorEvents })}</span> |
|
||||||
</div> |
|
||||||
|
|
||||||
{loading && partners.length === 0 ? ( |
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2"> |
|
||||||
{Array.from({ length: 15 }).map((_, i) => ( |
|
||||||
<Skeleton key={i} className="aspect-square rounded-lg" /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) : rankedPartners.length === 0 ? ( |
|
||||||
<div className="text-sm text-muted-foreground py-8 text-center">{t('interactionMapEmpty')}</div> |
|
||||||
) : ( |
|
||||||
<div |
|
||||||
className={ |
|
||||||
includeAllFollows |
|
||||||
? 'max-h-[min(70vh,720px)] overflow-y-auto overscroll-contain pr-1 -mr-1' |
|
||||||
: undefined |
|
||||||
} |
|
||||||
> |
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2"> |
|
||||||
{rankedPartners.map(({ stat: p, score }) => { |
|
||||||
const heat = score / 100 |
|
||||||
const bgAlpha = 0.12 + heat * 0.55 |
|
||||||
const borderAlpha = 0.25 + heat * 0.65 |
|
||||||
const scoreRounded = Math.round(score) |
|
||||||
const following = Boolean( |
|
||||||
followList?.followings.some((f) => f.toLowerCase() === p.pubkey.toLowerCase()) |
|
||||||
) |
|
||||||
const selfCard = accountPubkey?.toLowerCase() === p.pubkey.toLowerCase() |
|
||||||
const cellTitle = |
|
||||||
p.mentionCount > 0 && p.lastReferencedAt > 0 |
|
||||||
? `${t('interactionMapScore', { score: scoreRounded })} · ${t('interactionMapCellTitle', { |
|
||||||
count: p.mentionCount, |
|
||||||
when: dayjs.unix(p.lastReferencedAt).fromNow() |
|
||||||
})}` |
|
||||||
: `${t('interactionMapScore', { score: scoreRounded })} · ${t('interactionMapCellTitleFollowOnly')}` |
|
||||||
return ( |
|
||||||
<div |
|
||||||
key={p.pubkey} |
|
||||||
className="relative isolate rounded-lg border border-border min-w-0 transition hover:opacity-95" |
|
||||||
style={{ |
|
||||||
backgroundColor: `hsl(var(--primary) / ${bgAlpha})`, |
|
||||||
borderColor: `hsl(var(--primary) / ${borderAlpha})` |
|
||||||
}} |
|
||||||
> |
|
||||||
{/* |
|
||||||
Avoid a native <button> filling the card: it can steal hit-testing over the follow |
|
||||||
checkbox (Radix also uses a button), which shows the global disabled cursor and blocks toggles. |
|
||||||
*/} |
|
||||||
<div |
|
||||||
role="button" |
|
||||||
tabIndex={0} |
|
||||||
className={`w-full min-w-0 flex flex-col items-center gap-1 text-left rounded-lg cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring p-2 ${showFollowControls && !selfCard ? 'pt-7' : ''}`} |
|
||||||
title={cellTitle} |
|
||||||
onClick={() => push(toProfile(p.pubkey))} |
|
||||||
onKeyDown={(e) => { |
|
||||||
if (e.key === 'Enter' || e.key === ' ') { |
|
||||||
e.preventDefault() |
|
||||||
push(toProfile(p.pubkey)) |
|
||||||
} |
|
||||||
}} |
|
||||||
> |
|
||||||
<UserAvatar userId={p.pubkey} className="h-10 w-10 shrink-0" /> |
|
||||||
<div className="w-full min-w-0 text-center"> |
|
||||||
<Username userId={p.pubkey} className="text-xs truncate block" withoutSkeleton /> |
|
||||||
</div> |
|
||||||
<div className="text-[10px] font-medium tabular-nums text-primary"> |
|
||||||
{t('interactionMapScore', { score: scoreRounded })} |
|
||||||
</div> |
|
||||||
<div className="text-[10px] text-muted-foreground tabular-nums"> |
|
||||||
{t('interactionMapMentionsShort', { count: p.mentionCount })} |
|
||||||
</div> |
|
||||||
<div className="text-[10px] text-muted-foreground truncate w-full text-center"> |
|
||||||
{p.lastReferencedAt > 0 ? dayjs.unix(p.lastReferencedAt).fromNow() : t('interactionMapRecencyUnknown')} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{showFollowControls && !selfCard ? ( |
|
||||||
<label |
|
||||||
className="absolute top-1.5 right-1.5 z-30 flex cursor-pointer items-center gap-1 rounded border border-border/60 bg-background/95 px-1 py-0.5 shadow-sm backdrop-blur-[2px]" |
|
||||||
onClick={(e) => e.stopPropagation()} |
|
||||||
onPointerDown={(e) => e.stopPropagation()} |
|
||||||
> |
|
||||||
<Checkbox |
|
||||||
id={`interaction-follow-${p.pubkey}`} |
|
||||||
checked={following} |
|
||||||
disabled={followBusyPubkey === p.pubkey} |
|
||||||
aria-label={t('interactionMapFollowingCheckbox')} |
|
||||||
onCheckedChange={(v) => { |
|
||||||
if (v === 'indeterminate') return |
|
||||||
handleFollowToggle(p.pubkey, Boolean(v)) |
|
||||||
}} |
|
||||||
/> |
|
||||||
</label> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
) |
|
||||||
})} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
|
|
||||||
<div className="flex justify-center pt-2"> |
|
||||||
<Button variant="outline" size="sm" disabled={loading} onClick={() => void rescan()}> |
|
||||||
{t('interactionMapRefresh')} |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</SecondaryPageLayout> |
|
||||||
) |
|
||||||
}) |
|
||||||
|
|
||||||
ProfileInteractionDiagramPage.displayName = 'ProfileInteractionDiagramPage' |
|
||||||
export default ProfileInteractionDiagramPage |
|
||||||
Loading…
Reference in new issue