Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
e5bae89930
  1. 52
      src/hooks/useProfileInteractionPartners.ts
  2. 10
      src/i18n/locales/de.ts
  3. 10
      src/i18n/locales/en.ts
  4. 17
      src/lib/follow-list-history.ts
  5. 108
      src/lib/profile-interaction-partners.ts
  6. 9
      src/pages/secondary/FollowingListPage/index.tsx
  7. 217
      src/pages/secondary/ProfileInteractionDiagramPage/index.tsx
  8. 4
      src/providers/FollowListProvider.tsx
  9. 4
      vite.config.ts

52
src/hooks/useProfileInteractionPartners.ts

@ -1,52 +0,0 @@
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 }
}

10
src/i18n/locales/de.ts

@ -45,6 +45,16 @@ export default {
"In gecachten Notizen noch keine markierten Personen. Timeline öffnen oder Feeds lesen, damit das Archiv füllt.", "In gecachten Notizen noch keine markierten Personen. Timeline öffnen oder Feeds lesen, damit das Archiv füllt.",
interactionMapRefresh: "Cache erneut scannen", interactionMapRefresh: "Cache erneut scannen",
interactionMapCellTitle: "{{count}} Erwähnungen · zuletzt {{when}}", interactionMapCellTitle: "{{count}} Erwähnungen · zuletzt {{when}}",
interactionMapIncludeFollows: "Alle meine Follows einblenden",
interactionMapIncludeFollowsHint:
"Zeigt deine komplette Follow-Liste zusammen mit Personen aus ihren gecachten Tags. Bei langen Listen scrollen.",
interactionMapIncludeFollowsBreakdown:
"{{total}} angezeigt — {{fromTags}} aus ihren gecachten Tags, {{fromFollowsOnly}} nur aus deiner Follow-Liste",
interactionMapCellTitleFollowOnly: "Nicht in ihren lokalen Tags — nur deine Follow-Liste",
interactionMapFollowingCheckbox: "Folge ich",
interactionMapMentionsShort: "×{{count}}",
interactionMapRecencyUnknown: "—",
interactionMapScore: "Score {{score}}",
followings: "Folgekonten", followings: "Folgekonten",
boosted: "geboostet", boosted: "geboostet",
"Boosted by:": "Geboostet von:", "Boosted by:": "Geboostet von:",

10
src/i18n/locales/en.ts

@ -43,6 +43,16 @@ export default {
"No tagged people found in cached notes yet. Open their timeline or browse feeds so notes land in the archive.", "No tagged people found in cached notes yet. Open their timeline or browse feeds so notes land in the archive.",
interactionMapRefresh: "Rescan cache", interactionMapRefresh: "Rescan cache",
interactionMapCellTitle: "{{count}} mentions · last {{when}}", interactionMapCellTitle: "{{count}} mentions · last {{when}}",
interactionMapIncludeFollows: "Include everyone I follow",
interactionMapIncludeFollowsHint:
"Shows your full follow list merged with people from their cached tags. Scroll when the list is long.",
interactionMapIncludeFollowsBreakdown:
"{{total}} shown — {{fromTags}} from their cached tags, {{fromFollowsOnly}} from your follows only",
interactionMapCellTitleFollowOnly: "Not in their cached tags — your follow list only",
interactionMapFollowingCheckbox: "Following",
interactionMapMentionsShort: "×{{count}}",
interactionMapRecencyUnknown: "—",
interactionMapScore: "Score {{score}}",
followings: "followings", followings: "followings",
boosted: "boosted", boosted: "boosted",
"Boosted by:": "Boosted by:", "Boosted by:": "Boosted by:",

17
src/lib/follow-list-history.ts

@ -0,0 +1,17 @@
import { FOLLOWS_HISTORY_RELAY_URLS } from '@/constants'
import { createFollowListDraftEvent } from '@/lib/draft-event'
import type { Event } from 'nostr-tools'
import type { TDraftEvent, TPublishOptions } from '@/types'
/**
* Append-only snapshot of the current kind-3 contacts list to follows-history relays (same practice as
* {@link FollowingListPage} before destructive edits). Call immediately before publishing a new contacts state.
*/
export async function publishFollowListPreimageToHistory(
publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise<Event>,
tags: string[][],
content: string | undefined
): Promise<void> {
const draft = createFollowListDraftEvent(tags, content ?? '')
await publish(draft, { specifiedRelayUrls: [...FOLLOWS_HISTORY_RELAY_URLS] })
}

108
src/lib/profile-interaction-partners.ts

@ -33,6 +33,88 @@ export type TInteractionPartnerStat = {
lastReferencedAt: number 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 viewers
* 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[] { export function buildInteractionPartnerStats(events: Event[], authorPubkey: string): TInteractionPartnerStat[] {
const author = authorPubkey.trim().toLowerCase() const author = authorPubkey.trim().toLowerCase()
if (!HEX64.test(author)) return [] if (!HEX64.test(author)) return []
@ -68,3 +150,29 @@ export function mergeEventsById(events: Event[]): Event[] {
} }
return [...m.values()] 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)
)
}

9
src/pages/secondary/FollowingListPage/index.tsx

@ -22,8 +22,8 @@ import { useFetchFollowings, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { FOLLOWS_HISTORY_RELAY_URLS } from '@/constants'
import { createFollowListDraftEvent } from '@/lib/draft-event' import { createFollowListDraftEvent } from '@/lib/draft-event'
import { publishFollowListPreimageToHistory } from '@/lib/follow-list-history'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -81,8 +81,11 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
}) })
if (followListEvent) { if (followListEvent) {
const historyDraft = createFollowListDraftEvent(followListEvent.tags ?? [], followListEvent.content ?? '') await publishFollowListPreimageToHistory(
await publish(historyDraft, { specifiedRelayUrls: FOLLOWS_HISTORY_RELAY_URLS }) publish,
followListEvent.tags ?? [],
followListEvent.content ?? undefined
)
} }
const draft = createFollowListDraftEvent([], '') const draft = createFollowListDraftEvent([], '')

217
src/pages/secondary/ProfileInteractionDiagramPage/index.tsx

@ -2,21 +2,81 @@ import { RefreshButton } from '@/components/RefreshButton'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { Button } from '@/components/ui/button' 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 { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { useSecondaryPage } from '@/contexts/secondary-page-context' import { useSecondaryPage } from '@/contexts/secondary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useProfileInteractionPartners } from '@/hooks/useProfileInteractionPartners' import {
buildInteractionPartnerStats,
INTERACTION_MAP_RECENCY_MAX_AGE_SEC,
mergeEventsById,
mergeInteractionPartnersWithFollowings,
rankInteractionMapGridRows,
type TInteractionPartnerStat
} from '@/lib/profile-interaction-partners'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { kinds } from 'nostr-tools'
import type { TPageRef } from '@/types' import type { TPageRef } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(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)
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 }
}
const ProfileInteractionDiagramPage = forwardRef< const ProfileInteractionDiagramPage = forwardRef<
TPageRef, TPageRef,
{ id?: string; index?: number; hideTitlebar?: boolean } { id?: string; index?: number; hideTitlebar?: boolean }
@ -24,14 +84,64 @@ const ProfileInteractionDiagramPage = forwardRef<
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const followList = useFollowListOptional()
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
const [refreshNonce, setRefreshNonce] = useState(0) 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 bump = useCallback(() => setRefreshNonce((n) => n + 1), [])
const { partners, loading, rescan, archiveAuthorEvents, sessionEventCount } = useProfileInteractionPartners( const { partners, loading, rescan, archiveAuthorEvents, sessionEventCount } = useProfileInteractionPartners(
profile?.pubkey, profile?.pubkey,
refreshNonce 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) const layoutRef = useRef<TPageRef>(null)
useImperativeHandle( useImperativeHandle(
@ -58,10 +168,6 @@ const ProfileInteractionDiagramPage = forwardRef<
return () => registerPrimaryPanelRefresh(null) return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, rescan, bump]) }, [hideTitlebar, registerPrimaryPanelRefresh, rescan, bump])
const nowSec = dayjs().unix()
const maxCount = partners[0]?.mentionCount ?? 1
const maxAgeSec = Math.max(1, 180 * 86400)
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
ref={layoutRef} ref={layoutRef}
@ -72,7 +178,37 @@ const ProfileInteractionDiagramPage = forwardRef<
displayScrollToTopButton displayScrollToTopButton
> >
<div className="px-4 pb-8 space-y-4"> <div className="px-4 pb-8 space-y-4">
<p className="text-sm text-muted-foreground">{t('interactionMapSubtitle')}</p> <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"> <div className="text-xs text-muted-foreground flex flex-wrap gap-x-3 gap-y-1">
<span>{t('interactionMapSessionNotes', { count: sessionEventCount })}</span> <span>{t('interactionMapSessionNotes', { count: sessionEventCount })}</span>
<span>{t('interactionMapArchiveNotes', { count: archiveAuthorEvents })}</span> <span>{t('interactionMapArchiveNotes', { count: archiveAuthorEvents })}</span>
@ -84,44 +220,81 @@ const ProfileInteractionDiagramPage = forwardRef<
<Skeleton key={i} className="aspect-square rounded-lg" /> <Skeleton key={i} className="aspect-square rounded-lg" />
))} ))}
</div> </div>
) : partners.length === 0 ? ( ) : rankedPartners.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">{t('interactionMapEmpty')}</div> <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"> <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) => { {rankedPartners.map(({ stat: p, score }) => {
const countNorm = Math.min(1, p.mentionCount / maxCount) const heat = score / 100
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 bgAlpha = 0.12 + heat * 0.55
const borderAlpha = 0.25 + heat * 0.65 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 ( return (
<button <div
key={p.pubkey} key={p.pubkey}
type="button" className="relative rounded-lg border border-border min-w-0 transition hover:opacity-95"
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={{ style={{
backgroundColor: `hsl(var(--primary) / ${bgAlpha})`, backgroundColor: `hsl(var(--primary) / ${bgAlpha})`,
borderColor: `hsl(var(--primary) / ${borderAlpha})` borderColor: `hsl(var(--primary) / ${borderAlpha})`
}} }}
>
{showFollowControls && !selfCard ? (
<div className="absolute top-1.5 right-1.5 z-10 flex items-center gap-1 rounded bg-background/80 px-1 py-0.5 border border-border/60 shadow-sm">
<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))
}}
/>
</div>
) : null}
<button
type="button"
className={`w-full min-w-0 flex flex-col items-center gap-1 text-left rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring p-2 ${showFollowControls && !selfCard ? 'pt-7' : ''}`}
title={cellTitle}
onClick={() => push(toProfile(p.pubkey))} 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" /> <UserAvatar userId={p.pubkey} className="h-10 w-10 shrink-0" />
<div className="w-full min-w-0 text-center"> <div className="w-full min-w-0 text-center">
<Username userId={p.pubkey} className="text-xs truncate block" withoutSkeleton /> <Username userId={p.pubkey} className="text-xs truncate block" withoutSkeleton />
</div> </div>
<div className="text-[10px] text-muted-foreground tabular-nums">×{p.mentionCount}</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"> <div className="text-[10px] text-muted-foreground truncate w-full text-center">
{dayjs.unix(p.lastReferencedAt).fromNow()} {p.lastReferencedAt > 0 ? dayjs.unix(p.lastReferencedAt).fromNow() : t('interactionMapRecencyUnknown')}
</div> </div>
</button> </button>
</div>
) )
})} })}
</div> </div>
</div>
)} )}
<div className="flex justify-center pt-2"> <div className="flex justify-center pt-2">

4
src/providers/FollowListProvider.tsx

@ -1,5 +1,6 @@
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { createFollowListDraftEvent } from '@/lib/draft-event' import { createFollowListDraftEvent } from '@/lib/draft-event'
import { publishFollowListPreimageToHistory } from '@/lib/follow-list-history'
import { import {
dedupePTagsAppendPubkey, dedupePTagsAppendPubkey,
fetchLatestReplaceableListEvent, fetchLatestReplaceableListEvent,
@ -51,6 +52,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
if (!accountPubkey) return if (!accountPubkey) return
const base = await mergeLatestFollowTags() const base = await mergeLatestFollowTags()
if (base === null) return if (base === null) return
await publishFollowListPreimageToHistory(publish, base.tags, base.content)
const mergedTags = dedupePTagsAppendPubkey(base.tags, pubkey) const mergedTags = dedupePTagsAppendPubkey(base.tags, pubkey)
const newFollowListDraftEvent = createFollowListDraftEvent(mergedTags, base.content) const newFollowListDraftEvent = createFollowListDraftEvent(mergedTags, base.content)
const newFollowListEvent = await publish(newFollowListDraftEvent) const newFollowListEvent = await publish(newFollowListDraftEvent)
@ -63,6 +65,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
if (unique.length === 0) return if (unique.length === 0) return
const base = await mergeLatestFollowTags() const base = await mergeLatestFollowTags()
if (base === null) return if (base === null) return
await publishFollowListPreimageToHistory(publish, base.tags, base.content)
let mergedTags = base.tags let mergedTags = base.tags
for (const pk of unique) { for (const pk of unique) {
mergedTags = dedupePTagsAppendPubkey(mergedTags, pk) mergedTags = dedupePTagsAppendPubkey(mergedTags, pk)
@ -83,6 +86,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
} }
if (!latest) return if (!latest) return
await publishFollowListPreimageToHistory(publish, latest.tags, latest.content)
const newFollowListDraftEvent = createFollowListDraftEvent( const newFollowListDraftEvent = createFollowListDraftEvent(
removePubkeyFromPTags(latest.tags, pubkey), removePubkeyFromPTags(latest.tags, pubkey),
latest.content latest.content

4
vite.config.ts

@ -127,7 +127,9 @@ export default defineConfig(({ mode }) => {
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')
} },
/** Avoid invalid hook call / `dispatcher is null` when lazy chunks resolve a second `react` copy. */
dedupe: ['react', 'react-dom']
}, },
server: { server: {
// OG/link preview uses `/sites/?url=…`. Without this, Vite serves `index.html` and WebService parses the app shell. // OG/link preview uses `/sites/?url=…`. Without this, Vite serves `index.html` and WebService parses the app shell.

Loading…
Cancel
Save