14 changed files with 443 additions and 8 deletions
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
import { |
||||
buildInteractionPartnerStats, |
||||
mergeEventsById, |
||||
type TInteractionPartnerStat |
||||
} from '@/lib/profile-interaction-partners' |
||||
import { eventService } from '@/services/client.service' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import { useCallback, useEffect, useState } from 'react' |
||||
import { kinds } from 'nostr-tools' |
||||
|
||||
const INTERACTION_KINDS = [kinds.ShortTextNote, kinds.Repost, kinds.Reaction] as const |
||||
|
||||
export 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) |
||||
|
||||
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)) |
||||
} finally { |
||||
setLoading(false) |
||||
} |
||||
}, [authorPubkey]) |
||||
|
||||
useEffect(() => { |
||||
void run() |
||||
}, [run, refreshNonce]) |
||||
|
||||
return { partners, loading, rescan: run, archiveAuthorEvents, sessionEventCount } |
||||
} |
||||
@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
|
||||
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 |
||||
} |
||||
|
||||
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()] |
||||
} |
||||
@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
|
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import UserAvatar from '@/components/UserAvatar' |
||||
import Username from '@/components/Username' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { useSecondaryPage } from '@/contexts/secondary-page-context' |
||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||
import { useFetchProfile } from '@/hooks/useFetchProfile' |
||||
import { useProfileInteractionPartners } from '@/hooks/useProfileInteractionPartners' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { toProfile } from '@/lib/link' |
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import type { TPageRef } from '@/types' |
||||
import dayjs from 'dayjs' |
||||
import relativeTime from 'dayjs/plugin/relativeTime' |
||||
|
||||
dayjs.extend(relativeTime) |
||||
|
||||
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 { profile } = useFetchProfile(id) |
||||
const [refreshNonce, setRefreshNonce] = useState(0) |
||||
const bump = useCallback(() => setRefreshNonce((n) => n + 1), []) |
||||
const { partners, loading, rescan, archiveAuthorEvents, sessionEventCount } = useProfileInteractionPartners( |
||||
profile?.pubkey, |
||||
refreshNonce |
||||
) |
||||
|
||||
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]) |
||||
|
||||
const nowSec = dayjs().unix() |
||||
const maxCount = partners[0]?.mentionCount ?? 1 |
||||
const maxAgeSec = Math.max(1, 180 * 86400) |
||||
|
||||
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"> |
||||
<p className="text-sm text-muted-foreground">{t('interactionMapSubtitle')}</p> |
||||
<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> |
||||
) : partners.length === 0 ? ( |
||||
<div className="text-sm text-muted-foreground py-8 text-center">{t('interactionMapEmpty')}</div> |
||||
) : ( |
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2"> |
||||
{partners.slice(0, 72).map((p) => { |
||||
const countNorm = Math.min(1, p.mentionCount / maxCount) |
||||
const age = Math.max(0, nowSec - p.lastReferencedAt) |
||||
const recencyNorm = 1 - Math.min(1, age / maxAgeSec) |
||||
const heat = 0.55 * countNorm + 0.45 * recencyNorm |
||||
const bgAlpha = 0.12 + heat * 0.55 |
||||
const borderAlpha = 0.25 + heat * 0.65 |
||||
return ( |
||||
<button |
||||
key={p.pubkey} |
||||
type="button" |
||||
className="rounded-lg border border-border p-2 flex flex-col items-center gap-1 min-w-0 text-left transition hover:opacity-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" |
||||
style={{ |
||||
backgroundColor: `hsl(var(--primary) / ${bgAlpha})`, |
||||
borderColor: `hsl(var(--primary) / ${borderAlpha})` |
||||
}} |
||||
onClick={() => push(toProfile(p.pubkey))} |
||||
title={t('interactionMapCellTitle', { |
||||
count: p.mentionCount, |
||||
when: dayjs.unix(p.lastReferencedAt).fromNow() |
||||
})} |
||||
> |
||||
<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] text-muted-foreground tabular-nums">×{p.mentionCount}</div> |
||||
<div className="text-[10px] text-muted-foreground truncate w-full text-center"> |
||||
{dayjs.unix(p.lastReferencedAt).fromNow()} |
||||
</div> |
||||
</button> |
||||
) |
||||
})} |
||||
</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