14 changed files with 443 additions and 8 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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