Browse Source

bug-fixes

restore interactions map
imwald
Silberengel 1 month ago
parent
commit
491f131a55
  1. 10
      src/components/NormalFeed/index.tsx
  2. 10
      src/components/Profile/index.tsx
  3. 8
      src/components/ProfileOptions/index.tsx
  4. 8
      src/i18n/locales/en.ts
  5. 30
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  6. 291
      src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx
  7. 12
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  8. 18
      src/pages/primary/SpellsPage/fauxSpellConfig.ts
  9. 17
      src/pages/primary/SpellsPage/index.tsx

10
src/components/NormalFeed/index.tsx

@ -101,6 +101,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
progressiveDocumentKinds?: readonly number[] progressiveDocumentKinds?: readonly number[]
oneShotAfterMergeComparator?: (a: Event, b: Event) => number oneShotAfterMergeComparator?: (a: Event, b: Event) => number
extraShouldHideEvent?: (ev: Event) => boolean extraShouldHideEvent?: (ev: Event) => boolean
extraShouldHideRepliesEvent?: (ev: Event) => boolean
/** Override default cap for merged one-shot batches (wide d-tag / search merges). */ /** Override default cap for merged one-shot batches (wide d-tag / search merges). */
oneShotMergedCap?: number 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). */ /** 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<TNoteListRef, {
progressiveDocumentKinds, progressiveDocumentKinds,
oneShotAfterMergeComparator, oneShotAfterMergeComparator,
extraShouldHideEvent, extraShouldHideEvent,
extraShouldHideRepliesEvent,
oneShotMergedCap, oneShotMergedCap,
timelinePublicReadFallback = false timelinePublicReadFallback = false
}, },
@ -369,9 +371,13 @@ const NormalFeed = forwardRef<TNoteListRef, {
progressiveWarmupMatch={progressiveWarmupMatch} progressiveWarmupMatch={progressiveWarmupMatch}
progressiveDocumentKinds={progressiveDocumentKinds} progressiveDocumentKinds={progressiveDocumentKinds}
oneShotAfterMergeComparator={oneShotAfterMergeComparator} oneShotAfterMergeComparator={oneShotAfterMergeComparator}
extraShouldHideEvent={extraShouldHideEvent} extraShouldHideEvent={
listMode === 'postsAndReplies'
? extraShouldHideRepliesEvent
: extraShouldHideEvent
}
oneShotMergedCap={oneShotMergedCap} oneShotMergedCap={oneShotMergedCap}
timelinePublicReadFallback={timelinePublicReadFallback} timelinePublicReadFallback={timelinePublicReadFallback && listMode === 'postsAndReplies'}
/> />
</div> </div>
</> </>

10
src/components/Profile/index.tsx

@ -16,6 +16,7 @@ import { createReactionDraftEvent } from '@/lib/draft-event'
import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback' import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback'
import { toProfileEditor } from '@/lib/link' import { toProfileEditor } from '@/lib/link'
import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { isVideo } from '@/lib/url' import { isVideo } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
@ -41,7 +42,8 @@ import {
Gift, Gift,
Link, Link,
MessageCircle, MessageCircle,
ThumbsUp, Network,
ThumbsUp
} from 'lucide-react' } from 'lucide-react'
import { import {
useEffect, useEffect,
@ -545,6 +547,12 @@ export default function Profile({
<Gift /> <Gift />
{t('Follow Packs')} {t('Follow Packs')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigatePrimary('spells', { spell: encodeProfileInteractionsSpellId(pubkey) })}
>
<Network />
{t('Interactions map')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => push(toProfileEditor())}> <DropdownMenuItem onClick={() => push(toProfileEditor())}>
<Pencil /> <Pencil />
{t('Edit')} {t('Edit')}

8
src/components/ProfileOptions/index.tsx

@ -6,6 +6,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
@ -15,6 +16,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' 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 client, { replaceableEventService } from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
import RawEventDialog from '@/components/NoteOptions/RawEventDialog' import RawEventDialog from '@/components/NoteOptions/RawEventDialog'
@ -26,6 +28,7 @@ import {
Ellipsis, Ellipsis,
ThumbsUp, ThumbsUp,
MessageCircle, MessageCircle,
Network,
Send, Send,
SatelliteDish, SatelliteDish,
Video Video
@ -53,6 +56,7 @@ export default function ProfileOptions({
onSendCallInvite?: (url: string) => void onSendCallInvite?: (url: string) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { pubkey: accountPubkey, profile, publish, checkLogin } = useNostr() const { pubkey: accountPubkey, profile, publish, checkLogin } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList() const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
@ -250,6 +254,10 @@ export default function ProfileOptions({
<Copy /> <Copy />
{t('Copy user ID')} {t('Copy user ID')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('spells', { spell: encodeProfileInteractionsSpellId(pubkey) })}>
<Network />
{t('Interactions map')}
</DropdownMenuItem>
{kind0ForRelay && ( {kind0ForRelay && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

8
src/i18n/locales/en.ts

@ -774,6 +774,14 @@ export default {
heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread", heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.", "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", "Topic map": "Topic map",
topicMapDescription: 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.", "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.",

30
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -1,12 +1,21 @@
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList' 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 { checkAlgoRelay } from '@/lib/relay'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { useFeed } from '@/providers/feed-context' import { useFeed } from '@/providers/feed-context'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import client from '@/services/client.service'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { kinds } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
import React, { forwardRef, useEffect, useMemo, useState } from 'react' 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< const RelaysFeed = forwardRef<
TNoteListRef, TNoteListRef,
@ -97,6 +106,19 @@ const RelaysFeed = forwardRef<
} }
] ]
}, [canRenderFeed, replyRelayUrls, relayUrls, defaultKinds]) }, [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) { if (!canRenderFeed) {
return null return null
@ -118,6 +140,8 @@ const RelaysFeed = forwardRef<
feedTimelineScopeKey="all-favorites" feedTimelineScopeKey="all-favorites"
showFeedClientFilter showFeedClientFilter
hostPrimaryPageName="feed" hostPrimaryPageName="feed"
extraShouldHideEvent={hideAggrOnlyMainFeedEvent}
extraShouldHideRepliesEvent={hideAggrOnlyNonReplyEvent}
timelinePublicReadFallback timelinePublicReadFallback
/> />
) )

291
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<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>
)
}

12
src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

@ -22,6 +22,7 @@ import {
} from '@/lib/relay-thread-heat' } from '@/lib/relay-thread-heat'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { encodeProfileInteractionsSpellId } from './fauxSpellConfig'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -464,6 +465,17 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
> >
{t('Topic map')} {t('Topic map')}
</Button> </Button>
{pubkey ? (
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => navigatePrimary('spells', { spell: encodeProfileInteractionsSpellId(pubkey) })}
>
{t('Interactions map')}
</Button>
) : null}
</div> </div>
</div> </div>

18
src/pages/primary/SpellsPage/fauxSpellConfig.ts

@ -22,6 +22,23 @@ import {
export type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number] 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 { export function isBuiltinFauxSpell(s: string): s is FauxSpellName {
return (FAUX_SPELL_ORDER as readonly string[]).includes(s) 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. */ /** URL / picker param: built-in faux name or encoded follow-set spell id. */
export function isFauxSpellPageParam(s: string): boolean { export function isFauxSpellPageParam(s: string): boolean {
if (isBuiltinFauxSpell(s)) return true if (isBuiltinFauxSpell(s)) return true
if (isProfileInteractionsSpellId(s)) return true
if (!isFollowSetSpellId(s)) return false if (!isFollowSetSpellId(s)) return false
return decodeFollowSetSpellId(s) != null return decodeFollowSetSpellId(s) != null
} }

17
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 { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
import ProfileInteractionsMap from './ProfileInteractionsMap'
import RelayThreadHeatMap from './RelayThreadHeatMap' import RelayThreadHeatMap from './RelayThreadHeatMap'
import TopicKeywordHeatMap from './TopicKeywordHeatMap' import TopicKeywordHeatMap from './TopicKeywordHeatMap'
import type { TPageRef } from '@/types' import type { TPageRef } from '@/types'
import { import {
decodeFollowSetSpellId, decodeFollowSetSpellId,
decodeProfileInteractionsSpellId,
fauxSpellLabelKey, fauxSpellLabelKey,
getFollowSetDTag, getFollowSetDTag,
isBuiltinFauxSpell, isBuiltinFauxSpell,
isFollowFeedFauxSpellId, isFollowFeedFauxSpellId,
isFollowSetSpellId, isFollowSetSpellId,
isFauxSpellPageParam, isFauxSpellPageParam,
isProfileInteractionsSpellId,
labelFollowSetEvent labelFollowSetEvent
} from './fauxSpellConfig' } from './fauxSpellConfig'
import { SpellPickerContent } from './SpellPickerContent' import { SpellPickerContent } from './SpellPickerContent'
@ -117,6 +120,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
selectedFauxSpellRefreshRef.current = selectedFauxSpell selectedFauxSpellRefreshRef.current = selectedFauxSpell
const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0) const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0)
const [topicMapRefreshKey, setTopicMapRefreshKey] = useState(0) const [topicMapRefreshKey, setTopicMapRefreshKey] = useState(0)
const [profileInteractionsRefreshKey, setProfileInteractionsRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [spellPickerOpen, setSpellPickerOpen] = useState(false) const [spellPickerOpen, setSpellPickerOpen] = useState(false)
@ -194,6 +198,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpellRefreshRef.current === 'topicMap') { if (selectedFauxSpellRefreshRef.current === 'topicMap') {
setTopicMapRefreshKey((k) => k + 1) setTopicMapRefreshKey((k) => k + 1)
} }
if (isProfileInteractionsSpellId(selectedFauxSpellRefreshRef.current)) {
setProfileInteractionsRefreshKey((k) => k + 1)
}
spellFeedListRef.current?.refresh() spellFeedListRef.current?.refresh()
}, [loadSpells, pubkey]) }, [loadSpells, pubkey])
@ -617,6 +624,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const selectedFauxSpellDisplayLabel = useMemo(() => { const selectedFauxSpellDisplayLabel = useMemo(() => {
if (!selectedFauxSpell) return '' if (!selectedFauxSpell) return ''
if (isProfileInteractionsSpellId(selectedFauxSpell)) {
return t('Interactions map')
}
if (isFollowSetSpellId(selectedFauxSpell)) { if (isFollowSetSpellId(selectedFauxSpell)) {
const d = decodeFollowSetSpellId(selectedFauxSpell) const d = decodeFollowSetSpellId(selectedFauxSpell)
if (!d) return t('Follow set') if (!d) return t('Follow set')
@ -1012,6 +1022,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="min-h-0 min-w-0 flex-1"> <div className="min-h-0 min-w-0 flex-1">
<TopicKeywordHeatMap refreshKey={topicMapRefreshKey} /> <TopicKeywordHeatMap refreshKey={topicMapRefreshKey} />
</div> </div>
) : isProfileInteractionsSpellId(selectedFauxSpell) ? (
<div className="min-h-0 min-w-0 flex-1">
<ProfileInteractionsMap
pubkey={decodeProfileInteractionsSpellId(selectedFauxSpell)!}
refreshKey={profileInteractionsRefreshKey}
/>
</div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div> <div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length > 0 ? (

Loading…
Cancel
Save