From 491f131a55abeb770c2821a536c4e5c054711925 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 11 May 2026 22:12:01 +0200 Subject: [PATCH] bug-fixes restore interactions map --- src/components/NormalFeed/index.tsx | 10 +- src/components/Profile/index.tsx | 10 +- src/components/ProfileOptions/index.tsx | 8 + src/i18n/locales/en.ts | 8 + src/pages/primary/NoteListPage/RelaysFeed.tsx | 30 +- .../SpellsPage/ProfileInteractionsMap.tsx | 291 ++++++++++++++++++ .../primary/SpellsPage/RelayThreadHeatMap.tsx | 12 + .../primary/SpellsPage/fauxSpellConfig.ts | 18 ++ src/pages/primary/SpellsPage/index.tsx | 17 + 9 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 1357cb07..c763b34f 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -101,6 +101,7 @@ const NormalFeed = forwardRef number extraShouldHideEvent?: (ev: Event) => boolean + extraShouldHideRepliesEvent?: (ev: Event) => boolean /** Override default cap for merged one-shot batches (wide d-tag / search merges). */ oneShotMergedCap?: number /** When every relay in the subscribe wave fails before EOSE, merge a one-shot fetch from default read relays (home multi-relay feeds). */ @@ -135,6 +136,7 @@ const NormalFeed = forwardRef diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 05cadc5a..46913f97 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -16,6 +16,7 @@ import { createReactionDraftEvent } from '@/lib/draft-event' import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback' import { toProfileEditor } from '@/lib/link' +import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig' import { generateImageByPubkey } from '@/lib/pubkey' import { isVideo } from '@/lib/url' import { usePrimaryPage } from '@/contexts/primary-page-context' @@ -41,7 +42,8 @@ import { Gift, Link, MessageCircle, - ThumbsUp, + Network, + ThumbsUp } from 'lucide-react' import { useEffect, @@ -545,6 +547,12 @@ export default function Profile({ {t('Follow Packs')} + navigatePrimary('spells', { spell: encodeProfileInteractionsSpellId(pubkey) })} + > + + {t('Interactions map')} + push(toProfileEditor())}> {t('Edit')} diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index a023ae34..f93ade95 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -6,6 +6,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { useMuteList } from '@/contexts/mute-list-context' @@ -15,6 +16,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' +import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig' import client, { replaceableEventService } from '@/services/client.service' import { nip66Service } from '@/services/nip66.service' import RawEventDialog from '@/components/NoteOptions/RawEventDialog' @@ -26,6 +28,7 @@ import { Ellipsis, ThumbsUp, MessageCircle, + Network, Send, SatelliteDish, Video @@ -53,6 +56,7 @@ export default function ProfileOptions({ onSendCallInvite?: (url: string) => void }) { const { t } = useTranslation() + const { navigate } = usePrimaryPage() const { pubkey: accountPubkey, profile, publish, checkLogin } = useNostr() const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() @@ -250,6 +254,10 @@ export default function ProfileOptions({ {t('Copy user ID')} + navigate('spells', { spell: encodeProfileInteractionsSpellId(pubkey) })}> + + {t('Interactions map')} + {kind0ForRelay && ( <> diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3fbf2e01..2f12066e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -774,6 +774,14 @@ export default { heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread", heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", + "Interactions map": "Interactions map", + "Profile interactions map description": + "Profiles ranked by direct interaction count with this profile. Data paints from local cache first, then refreshes from relays.", + "Profile interactions map empty": "No profile interactions found yet. Browse this profile or rescan after syncing.", + "Profile interactions map failed": "Could not build the interactions map", + "n interactions": "{{formattedCount}} interactions", + "outgoing interactions": "{{count}} by this profile", + "incoming interactions": "{{count}} toward this profile", "Topic map": "Topic map", topicMapDescription: "The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.", diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 1524b462..3c0e0faf 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -1,12 +1,21 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' +import { isReplyNoteEvent } from '@/lib/event' +import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { checkAlgoRelay } from '@/lib/relay' -import { normalizeUrl } from '@/lib/url' +import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { useFeed } from '@/providers/feed-context' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' +import client from '@/services/client.service' import relayInfoService from '@/services/relay-info.service' -import { kinds } from 'nostr-tools' -import React, { forwardRef, useEffect, useMemo, useState } from 'react' +import { kinds, type Event } from 'nostr-tools' +import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' + +const AGGR_RELAY_KEY = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase() + +function relaySeenKey(url: string): string { + return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() +} const RelaysFeed = forwardRef< TNoteListRef, @@ -97,6 +106,19 @@ const RelaysFeed = forwardRef< } ] }, [canRenderFeed, replyRelayUrls, relayUrls, defaultKinds]) + const hideAggrOnlyMainFeedEvent = useCallback( + (event: Event) => { + const seenRelays = client.getSeenEventRelayUrls(event.id).map(relaySeenKey) + if (!seenRelays.includes(AGGR_RELAY_KEY)) return false + const allowedRelays = new Set(relayUrls.map(relaySeenKey)) + return !seenRelays.some((relay) => relay !== AGGR_RELAY_KEY && allowedRelays.has(relay)) + }, + [relayUrls] + ) + const hideAggrOnlyNonReplyEvent = useCallback( + (event: Event) => hideAggrOnlyMainFeedEvent(event) && !isReplyNoteEvent(event), + [hideAggrOnlyMainFeedEvent] + ) if (!canRenderFeed) { return null @@ -118,6 +140,8 @@ const RelaysFeed = forwardRef< feedTimelineScopeKey="all-favorites" showFeedClientFilter hostPrimaryPageName="feed" + extraShouldHideEvent={hideAggrOnlyMainFeedEvent} + extraShouldHideRepliesEvent={hideAggrOnlyNonReplyEvent} timelinePublicReadFallback /> ) diff --git a/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx b/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx new file mode 100644 index 00000000..68f21512 --- /dev/null +++ b/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx @@ -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 +} + +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() + 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([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(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 ( +
+
+

{t('Profile interactions map description')}

+
+ +
+ + +
+
+
+ + {loading && cards.length === 0 ? ( +
+ {Array.from({ length: 9 }).map((_, i) => ( + + ))} +
+ ) : error && cards.length === 0 ? ( +
+ {t('Profile interactions map failed')}: {error} +
+ ) : cards.length === 0 ? ( +
+ {t('Profile interactions map empty')} +
+ ) : ( +
+
+ {cards.map((card, index) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index 6f908110..74bb0822 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -22,6 +22,7 @@ import { } from '@/lib/relay-thread-heat' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useSmartNoteNavigation } from '@/PageManager' +import { encodeProfileInteractionsSpellId } from './fauxSpellConfig' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useNostr } from '@/providers/NostrProvider' @@ -464,6 +465,17 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) > {t('Topic map')} + {pubkey ? ( + + ) : null} diff --git a/src/pages/primary/SpellsPage/fauxSpellConfig.ts b/src/pages/primary/SpellsPage/fauxSpellConfig.ts index a073c360..79820cd5 100644 --- a/src/pages/primary/SpellsPage/fauxSpellConfig.ts +++ b/src/pages/primary/SpellsPage/fauxSpellConfig.ts @@ -22,6 +22,23 @@ import { export type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number] +const PROFILE_INTERACTIONS_SPELL_PREFIX = 'profileInteractions:' + +export function encodeProfileInteractionsSpellId(pubkey: string): string { + return `${PROFILE_INTERACTIONS_SPELL_PREFIX}${pubkey.trim().toLowerCase()}` +} + +export function decodeProfileInteractionsSpellId(spellId: string | null | undefined): string | null { + const raw = spellId?.trim() + if (!raw?.startsWith(PROFILE_INTERACTIONS_SPELL_PREFIX)) return null + const pubkey = raw.slice(PROFILE_INTERACTIONS_SPELL_PREFIX.length).trim().toLowerCase() + return /^[0-9a-f]{64}$/.test(pubkey) ? pubkey : null +} + +export function isProfileInteractionsSpellId(spellId: string | null | undefined): boolean { + return decodeProfileInteractionsSpellId(spellId) != null +} + export function isBuiltinFauxSpell(s: string): s is FauxSpellName { return (FAUX_SPELL_ORDER as readonly string[]).includes(s) } @@ -29,6 +46,7 @@ export function isBuiltinFauxSpell(s: string): s is FauxSpellName { /** URL / picker param: built-in faux name or encoded follow-set spell id. */ export function isFauxSpellPageParam(s: string): boolean { if (isBuiltinFauxSpell(s)) return true + if (isProfileInteractionsSpellId(s)) return true if (!isFollowSetSpellId(s)) return false return decodeFollowSetSpellId(s) != null } diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index ba32b87b..eb792828 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -51,17 +51,20 @@ import { verifyEvent } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' +import ProfileInteractionsMap from './ProfileInteractionsMap' import RelayThreadHeatMap from './RelayThreadHeatMap' import TopicKeywordHeatMap from './TopicKeywordHeatMap' import type { TPageRef } from '@/types' import { decodeFollowSetSpellId, + decodeProfileInteractionsSpellId, fauxSpellLabelKey, getFollowSetDTag, isBuiltinFauxSpell, isFollowFeedFauxSpellId, isFollowSetSpellId, isFauxSpellPageParam, + isProfileInteractionsSpellId, labelFollowSetEvent } from './fauxSpellConfig' import { SpellPickerContent } from './SpellPickerContent' @@ -117,6 +120,7 @@ const SpellsPage = forwardRef(function SpellsPage( selectedFauxSpellRefreshRef.current = selectedFauxSpell const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0) const [topicMapRefreshKey, setTopicMapRefreshKey] = useState(0) + const [profileInteractionsRefreshKey, setProfileInteractionsRefreshKey] = useState(0) const layoutRef = useRef(null) const [spellPickerOpen, setSpellPickerOpen] = useState(false) @@ -194,6 +198,9 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpellRefreshRef.current === 'topicMap') { setTopicMapRefreshKey((k) => k + 1) } + if (isProfileInteractionsSpellId(selectedFauxSpellRefreshRef.current)) { + setProfileInteractionsRefreshKey((k) => k + 1) + } spellFeedListRef.current?.refresh() }, [loadSpells, pubkey]) @@ -617,6 +624,9 @@ const SpellsPage = forwardRef(function SpellsPage( const selectedFauxSpellDisplayLabel = useMemo(() => { if (!selectedFauxSpell) return '' + if (isProfileInteractionsSpellId(selectedFauxSpell)) { + return t('Interactions map') + } if (isFollowSetSpellId(selectedFauxSpell)) { const d = decodeFollowSetSpellId(selectedFauxSpell) if (!d) return t('Follow set') @@ -1012,6 +1022,13 @@ const SpellsPage = forwardRef(function SpellsPage(
+ ) : isProfileInteractionsSpellId(selectedFauxSpell) ? ( +
+ +
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
{fauxFeedEmptyMessage}
) : selectedFauxSpell && fauxSubRequests.length > 0 ? (