9 changed files with 398 additions and 6 deletions
@ -0,0 +1,291 @@
@@ -0,0 +1,291 @@
|
||||
import UserAvatar from '@/components/UserAvatar' |
||||
import Username from '@/components/Username' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Card } from '@/components/ui/card' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
getRelayUrlsWithFavoritesFastReadAndInbox, |
||||
userReadRelaysWithHttp |
||||
} from '@/lib/favorites-feed-relays' |
||||
import { toProfile } from '@/lib/link' |
||||
import { formatPubkey } from '@/lib/pubkey' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import client from '@/services/client.service' |
||||
import type { TSubRequestFilter } from '@/types' |
||||
import { Loader2, RefreshCw, UserRound } from 'lucide-react' |
||||
import type { Event, Filter } from 'nostr-tools' |
||||
import { kinds } from 'nostr-tools' |
||||
import { useCallback, useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
const INTERACTION_KINDS = [ |
||||
kinds.ShortTextNote, |
||||
kinds.Reaction, |
||||
kinds.Repost, |
||||
kinds.Zap, |
||||
kinds.Highlights, |
||||
ExtendedKind.COMMENT, |
||||
ExtendedKind.VOICE_COMMENT, |
||||
ExtendedKind.GENERIC_REPOST, |
||||
ExtendedKind.EXTERNAL_REACTION, |
||||
ExtendedKind.WEB_BOOKMARK |
||||
] |
||||
|
||||
const LOCAL_LIMIT = 1200 |
||||
const RELAY_LIMIT = 700 |
||||
const MAX_CARDS = 80 |
||||
|
||||
type InteractionCard = { |
||||
pubkey: string |
||||
score: number |
||||
authoredByProfile: number |
||||
mentionsProfile: number |
||||
latestCreatedAt: number |
||||
eventIds: Set<string> |
||||
} |
||||
|
||||
type Props = { |
||||
pubkey: string |
||||
refreshKey: number |
||||
} |
||||
|
||||
function interactionFilters(pubkey: string, limit: number): TSubRequestFilter[] { |
||||
return [ |
||||
{ authors: [pubkey], kinds: INTERACTION_KINDS, limit }, |
||||
{ '#p': [pubkey], kinds: INTERACTION_KINDS, limit } as Filter & { limit: number } |
||||
] |
||||
} |
||||
|
||||
function mergeInteractionEvents(targetPubkey: string, events: Event[]): InteractionCard[] { |
||||
const target = targetPubkey.toLowerCase() |
||||
const byPubkey = new Map<string, InteractionCard>() |
||||
const add = (partnerRaw: string | undefined, event: Event, direction: 'out' | 'in') => { |
||||
const partner = partnerRaw?.trim().toLowerCase() |
||||
if (!partner || partner === target || !/^[0-9a-f]{64}$/.test(partner)) return |
||||
let row = byPubkey.get(partner) |
||||
if (!row) { |
||||
row = { |
||||
pubkey: partner, |
||||
score: 0, |
||||
authoredByProfile: 0, |
||||
mentionsProfile: 0, |
||||
latestCreatedAt: 0, |
||||
eventIds: new Set() |
||||
} |
||||
byPubkey.set(partner, row) |
||||
} |
||||
if (row.eventIds.has(event.id)) return |
||||
row.eventIds.add(event.id) |
||||
row.score += 1 |
||||
row.latestCreatedAt = Math.max(row.latestCreatedAt, event.created_at) |
||||
if (direction === 'out') row.authoredByProfile += 1 |
||||
else row.mentionsProfile += 1 |
||||
} |
||||
|
||||
for (const event of events) { |
||||
const pTags = [ |
||||
...new Set( |
||||
event.tags |
||||
.filter((tag) => tag[0] === 'p' && /^[0-9a-f]{64}$/i.test(tag[1] ?? '')) |
||||
.map((tag) => tag[1]!.toLowerCase()) |
||||
) |
||||
] |
||||
if (event.pubkey.toLowerCase() === target) { |
||||
for (const partner of pTags) add(partner, event, 'out') |
||||
} else if (pTags.includes(target)) { |
||||
add(event.pubkey, event, 'in') |
||||
} |
||||
} |
||||
|
||||
return [...byPubkey.values()] |
||||
.sort((a, b) => b.score - a.score || b.latestCreatedAt - a.latestCreatedAt || a.pubkey.localeCompare(b.pubkey)) |
||||
.slice(0, MAX_CARDS) |
||||
} |
||||
|
||||
function compactCount(n: number): string { |
||||
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` |
||||
return String(n) |
||||
} |
||||
|
||||
export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { |
||||
const { t } = useTranslation() |
||||
const { push } = useSecondaryPage() |
||||
const { relayList } = useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const [cards, setCards] = useState<InteractionCard[]>([]) |
||||
const [loading, setLoading] = useState(true) |
||||
const [refreshing, setRefreshing] = useState(false) |
||||
const [error, setError] = useState<string | null>(null) |
||||
|
||||
const relayUrls = useMemo( |
||||
() => |
||||
getRelayUrlsWithFavoritesFastReadAndInbox( |
||||
favoriteRelays, |
||||
blockedRelays, |
||||
userReadRelaysWithHttp(relayList), |
||||
{ |
||||
userWriteRelays: relayList?.write ?? [], |
||||
applySocialKindBlockedFilter: false |
||||
} |
||||
), |
||||
[favoriteRelays, blockedRelays, relayList] |
||||
) |
||||
|
||||
const openProfile = useCallback((partnerPubkey: string) => { |
||||
push(toProfile(partnerPubkey)) |
||||
}, [push]) |
||||
|
||||
const load = useCallback( |
||||
async (includeRelays: boolean) => { |
||||
const filters = interactionFilters(pubkey, includeRelays ? RELAY_LIMIT : LOCAL_LIMIT) |
||||
const local = await client.getLocalFeedEvents( |
||||
filters.map((filter) => ({ urls: relayUrls, filter })), |
||||
{ maxMatches: LOCAL_LIMIT, maxRowsScanned: 28_000 } |
||||
) |
||||
if (!includeRelays || relayUrls.length === 0) return local |
||||
const relayRows = await Promise.all( |
||||
filters.map((filter) => |
||||
client.fetchEvents(relayUrls, filter, { |
||||
cache: true, |
||||
eoseTimeout: 4500, |
||||
globalTimeout: 16_000, |
||||
firstRelayResultGraceMs: false |
||||
}) |
||||
) |
||||
) |
||||
return [...local, ...relayRows.flat()] |
||||
}, |
||||
[pubkey, relayUrls] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
setError(null) |
||||
setLoading(true) |
||||
setRefreshing(true) |
||||
void (async () => { |
||||
try { |
||||
const local = await load(false) |
||||
if (cancelled) return |
||||
setCards(mergeInteractionEvents(pubkey, local)) |
||||
setLoading(false) |
||||
|
||||
const all = await load(true) |
||||
if (cancelled) return |
||||
setCards(mergeInteractionEvents(pubkey, all)) |
||||
} catch (e) { |
||||
if (cancelled) return |
||||
setError(e instanceof Error ? e.message : String(e)) |
||||
} finally { |
||||
if (!cancelled) { |
||||
setLoading(false) |
||||
setRefreshing(false) |
||||
} |
||||
} |
||||
})() |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [pubkey, refreshKey, load]) |
||||
|
||||
return ( |
||||
<div className="flex min-h-0 flex-1 flex-col gap-4"> |
||||
<div className="space-y-2 text-sm text-muted-foreground"> |
||||
<p>{t('Profile interactions map description')}</p> |
||||
<div className="flex flex-wrap items-center gap-2"> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
size="sm" |
||||
className="gap-1.5" |
||||
disabled={refreshing} |
||||
onClick={() => { |
||||
setRefreshing(true) |
||||
void load(true) |
||||
.then((rows) => setCards(mergeInteractionEvents(pubkey, rows))) |
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e))) |
||||
.finally(() => setRefreshing(false)) |
||||
}} |
||||
> |
||||
{refreshing ? <Loader2 className="size-4 animate-spin" aria-hidden /> : <RefreshCw className="size-4" aria-hidden />} |
||||
{t('heatMapRescan')} |
||||
</Button> |
||||
<div className="flex items-center gap-1.5 text-xs"> |
||||
<UserAvatar userId={pubkey} size="small" className="size-5" /> |
||||
<Username userId={pubkey} className="font-medium text-foreground" /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
{loading && cards.length === 0 ? ( |
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"> |
||||
{Array.from({ length: 9 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-24 rounded-xl" /> |
||||
))} |
||||
</div> |
||||
) : error && cards.length === 0 ? ( |
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-8 text-center text-sm text-destructive"> |
||||
{t('Profile interactions map failed')}: {error} |
||||
</div> |
||||
) : cards.length === 0 ? ( |
||||
<div className="rounded-xl border border-dashed border-border/80 px-4 py-12 text-center text-sm text-muted-foreground"> |
||||
{t('Profile interactions map empty')} |
||||
</div> |
||||
) : ( |
||||
<div className="min-h-0 flex-1 overflow-y-auto pb-4"> |
||||
<div className="grid grid-cols-1 gap-2 min-[720px]:grid-cols-2 xl:grid-cols-3"> |
||||
{cards.map((card, index) => ( |
||||
<button |
||||
key={card.pubkey} |
||||
type="button" |
||||
className="min-w-0 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" |
||||
onClick={() => openProfile(card.pubkey)} |
||||
> |
||||
<Card |
||||
className={cn( |
||||
'flex h-full min-w-0 items-center gap-2 p-2 transition-colors hover:bg-accent/70 min-[720px]:gap-3 min-[720px]:p-3', |
||||
index < 3 && 'border-primary/40 bg-primary/5' |
||||
)} |
||||
> |
||||
<div className="relative shrink-0"> |
||||
<UserAvatar userId={card.pubkey} size="semiBig" className="min-[720px]:h-16 min-[720px]:w-16" /> |
||||
<span className="absolute -bottom-1 -right-1 z-10 rounded-full bg-background px-1.5 py-0.5 text-[10px] font-semibold shadow ring-1 ring-border"> |
||||
#{index + 1} |
||||
</span> |
||||
</div> |
||||
<div className="min-w-0 flex-1"> |
||||
<Username userId={card.pubkey} className="block truncate text-sm font-semibold" /> |
||||
<div className="truncate text-xs text-muted-foreground">{formatPubkey(card.pubkey)}</div> |
||||
<div className="mt-1.5 flex min-w-0 flex-wrap gap-1 text-[11px] text-muted-foreground min-[720px]:mt-2 min-[720px]:gap-1.5 min-[720px]:text-xs"> |
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-foreground"> |
||||
<UserRound className="mr-1 inline size-3" aria-hidden /> |
||||
<span className="min-[720px]:hidden">{compactCount(card.score)}</span> |
||||
<span className="hidden min-[720px]:inline"> |
||||
{t('n interactions', { count: card.score, formattedCount: compactCount(card.score) })} |
||||
</span> |
||||
</span> |
||||
{card.authoredByProfile > 0 ? ( |
||||
<span className="hidden rounded-full bg-muted px-2 py-0.5 min-[720px]:inline"> |
||||
{t('outgoing interactions', { count: card.authoredByProfile })} |
||||
</span> |
||||
) : null} |
||||
{card.mentionsProfile > 0 ? ( |
||||
<span className="hidden rounded-full bg-muted px-2 py-0.5 min-[720px]:inline"> |
||||
{t('incoming interactions', { count: card.mentionsProfile })} |
||||
</span> |
||||
) : null} |
||||
</div> |
||||
</div> |
||||
</Card> |
||||
</button> |
||||
))} |
||||
</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue