You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

138 lines
5.6 KiB

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