41 changed files with 65 additions and 2426 deletions
@ -1,196 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,6 +0,0 @@
|
||||
import { Event } from 'nostr-tools' |
||||
|
||||
export type TProfileFollowPack = { |
||||
event: Event |
||||
title: string |
||||
} |
||||
@ -1,7 +0,0 @@
@@ -1,7 +0,0 @@
|
||||
export type TProfileZap = { |
||||
pr: string |
||||
pubkey: string |
||||
amount: number |
||||
created_at: number |
||||
comment?: string |
||||
} |
||||
@ -1,94 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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