From e5bae89930a4465ef9c4593850577176e5006953 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 7 May 2026 00:32:19 +0200 Subject: [PATCH] bug-fixes --- src/hooks/useProfileInteractionPartners.ts | 52 ---- src/i18n/locales/de.ts | 10 + src/i18n/locales/en.ts | 10 + src/lib/follow-list-history.ts | 17 ++ src/lib/profile-interaction-partners.ts | 108 ++++++++ .../secondary/FollowingListPage/index.tsx | 9 +- .../ProfileInteractionDiagramPage/index.tsx | 235 +++++++++++++++--- src/providers/FollowListProvider.tsx | 4 + vite.config.ts | 4 +- 9 files changed, 362 insertions(+), 87 deletions(-) delete mode 100644 src/hooks/useProfileInteractionPartners.ts create mode 100644 src/lib/follow-list-history.ts diff --git a/src/hooks/useProfileInteractionPartners.ts b/src/hooks/useProfileInteractionPartners.ts deleted file mode 100644 index 169b407f..00000000 --- a/src/hooks/useProfileInteractionPartners.ts +++ /dev/null @@ -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([]) - 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 } -} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 92bd0e1e..8158cb5d 100644 --- a/src/i18n/locales/de.ts +++ b/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.", interactionMapRefresh: "Cache erneut scannen", 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", boosted: "geboostet", "Boosted by:": "Geboostet von:", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b42825f1..57dcb575 100644 --- a/src/i18n/locales/en.ts +++ b/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.", interactionMapRefresh: "Rescan cache", 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", boosted: "boosted", "Boosted by:": "Boosted by:", diff --git a/src/lib/follow-list-history.ts b/src/lib/follow-list-history.ts new file mode 100644 index 00000000..24b9a69f --- /dev/null +++ b/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, + tags: string[][], + content: string | undefined +): Promise { + const draft = createFollowListDraftEvent(tags, content ?? '') + await publish(draft, { specifiedRelayUrls: [...FOLLOWS_HISTORY_RELAY_URLS] }) +} diff --git a/src/lib/profile-interaction-partners.ts b/src/lib/profile-interaction-partners.ts index 4ed4eb60..9c122255 100644 --- a/src/lib/profile-interaction-partners.ts +++ b/src/lib/profile-interaction-partners.ts @@ -33,6 +33,88 @@ export type TInteractionPartnerStat = { 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 viewer’s + * 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[] { const author = authorPubkey.trim().toLowerCase() if (!HEX64.test(author)) return [] @@ -68,3 +150,29 @@ export function mergeEventsById(events: Event[]): Event[] { } 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() + 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) + ) +} diff --git a/src/pages/secondary/FollowingListPage/index.tsx b/src/pages/secondary/FollowingListPage/index.tsx index 40eec860..3f97f29a 100644 --- a/src/pages/secondary/FollowingListPage/index.tsx +++ b/src/pages/secondary/FollowingListPage/index.tsx @@ -22,8 +22,8 @@ import { useFetchFollowings, useFetchProfile } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' -import { FOLLOWS_HISTORY_RELAY_URLS } from '@/constants' import { createFollowListDraftEvent } from '@/lib/draft-event' +import { publishFollowListPreimageToHistory } from '@/lib/follow-list-history' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import dayjs from 'dayjs' @@ -81,8 +81,11 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? }) if (followListEvent) { - const historyDraft = createFollowListDraftEvent(followListEvent.tags ?? [], followListEvent.content ?? '') - await publish(historyDraft, { specifiedRelayUrls: FOLLOWS_HISTORY_RELAY_URLS }) + await publishFollowListPreimageToHistory( + publish, + followListEvent.tags ?? [], + followListEvent.content ?? undefined + ) } const draft = createFollowListDraftEvent([], '') diff --git a/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx b/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx index 7cba6817..fe9514cb 100644 --- a/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx +++ b/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx @@ -2,21 +2,81 @@ import { RefreshButton } from '@/components/RefreshButton' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' 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 { Switch } from '@/components/ui/switch' 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 { + buildInteractionPartnerStats, + INTERACTION_MAP_RECENCY_MAX_AGE_SEC, + mergeEventsById, + mergeInteractionPartnersWithFollowings, + rankInteractionMapGridRows, + type TInteractionPartnerStat +} from '@/lib/profile-interaction-partners' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' 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 { toast } from 'sonner' +import { kinds } from 'nostr-tools' import type { TPageRef } from '@/types' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/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([]) + 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< TPageRef, { id?: string; index?: number; hideTitlebar?: boolean } @@ -24,14 +84,64 @@ const ProfileInteractionDiagramPage = forwardRef< const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { push } = useSecondaryPage() + const { pubkey: accountPubkey, checkLogin } = useNostr() + const followList = useFollowListOptional() const { profile } = useFetchProfile(id) const [refreshNonce, setRefreshNonce] = useState(0) + const [includeAllFollows, setIncludeAllFollows] = useState(false) + const [followBusyPubkey, setFollowBusyPubkey] = useState(null) const bump = useCallback(() => setRefreshNonce((n) => n + 1), []) const { partners, loading, rescan, archiveAuthorEvents, sessionEventCount } = useProfileInteractionPartners( profile?.pubkey, 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(null) useImperativeHandle( @@ -58,10 +168,6 @@ const ProfileInteractionDiagramPage = forwardRef< 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 (
-

{t('interactionMapSubtitle')}

+
+

{t('interactionMapSubtitle')}

+ {showFollowControls ? ( +
+
+ + +
+ {includeAllFollows ? ( + <> +

{t('interactionMapIncludeFollowsHint')}

+ {includeFollowsBreakdown ? ( +

+ {t('interactionMapIncludeFollowsBreakdown', { + total: includeFollowsBreakdown.total, + fromTags: includeFollowsBreakdown.fromTags, + fromFollowsOnly: includeFollowsBreakdown.fromFollowsOnly + })} +

+ ) : null} + + ) : null} +
+ ) : null} +
{t('interactionMapSessionNotes', { count: sessionEventCount })} {t('interactionMapArchiveNotes', { count: archiveAuthorEvents })} @@ -84,43 +220,80 @@ const ProfileInteractionDiagramPage = forwardRef< ))}
- ) : partners.length === 0 ? ( + ) : rankedPartners.length === 0 ? (
{t('interactionMapEmpty')}
) : ( -
- {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 +
+
+ {rankedPartners.map(({ stat: p, score }) => { + const heat = score / 100 const bgAlpha = 0.12 + heat * 0.55 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 ( - + {showFollowControls && !selfCard ? ( +
+ { + if (v === 'indeterminate') return + handleFollowToggle(p.pubkey, Boolean(v)) + }} + /> +
+ ) : null} + +
) })} +
)} diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx index 35380a7b..b4caab1f 100644 --- a/src/providers/FollowListProvider.tsx +++ b/src/providers/FollowListProvider.tsx @@ -1,5 +1,6 @@ import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { createFollowListDraftEvent } from '@/lib/draft-event' +import { publishFollowListPreimageToHistory } from '@/lib/follow-list-history' import { dedupePTagsAppendPubkey, fetchLatestReplaceableListEvent, @@ -51,6 +52,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) if (!accountPubkey) return const base = await mergeLatestFollowTags() if (base === null) return + await publishFollowListPreimageToHistory(publish, base.tags, base.content) const mergedTags = dedupePTagsAppendPubkey(base.tags, pubkey) const newFollowListDraftEvent = createFollowListDraftEvent(mergedTags, base.content) const newFollowListEvent = await publish(newFollowListDraftEvent) @@ -63,6 +65,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) if (unique.length === 0) return const base = await mergeLatestFollowTags() if (base === null) return + await publishFollowListPreimageToHistory(publish, base.tags, base.content) let mergedTags = base.tags for (const pk of unique) { mergedTags = dedupePTagsAppendPubkey(mergedTags, pk) @@ -83,6 +86,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) } if (!latest) return + await publishFollowListPreimageToHistory(publish, latest.tags, latest.content) const newFollowListDraftEvent = createFollowListDraftEvent( removePubkeyFromPTags(latest.tags, pubkey), latest.content diff --git a/vite.config.ts b/vite.config.ts index 6868e962..89a57e51 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -127,7 +127,9 @@ export default defineConfig(({ mode }) => { resolve: { alias: { '@': path.resolve(__dirname, './src') - } + }, + /** Avoid invalid hook call / `dispatcher is null` when lazy chunks resolve a second `react` copy. */ + dedupe: ['react', 'react-dom'] }, server: { // OG/link preview uses `/sites/?url=…`. Without this, Vite serves `index.html` and WebService parses the app shell.