Browse Source

profile interaction heat map

imwald
Silberengel 1 month ago
parent
commit
64dc9afaad
  1. 37
      src/PageManager.tsx
  2. 26
      src/components/Profile/index.tsx
  3. 23
      src/components/ProfileOptions/index.tsx
  4. 1
      src/contexts/primary-note-view-context.tsx
  5. 52
      src/hooks/useProfileInteractionPartners.ts
  6. 10
      src/i18n/locales/de.ts
  7. 10
      src/i18n/locales/en.ts
  8. 5
      src/lib/link.ts
  9. 70
      src/lib/profile-interaction-partners.ts
  10. 138
      src/pages/secondary/ProfileInteractionDiagramPage/index.tsx
  11. 4
      src/routes.tsx
  12. 23
      src/services/client-events.service.ts
  13. 49
      src/services/indexed-db.service.ts
  14. 3
      src/services/navigation.service.ts

37
src/PageManager.tsx

@ -107,6 +107,9 @@ const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage' @@ -107,6 +107,9 @@ const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage'
const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage'))
const PrimaryUserEmojiListPageLazy = lazy(() => import('@/pages/secondary/UserEmojiListPage'))
const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage'))
const PrimaryProfileInteractionDiagramPageLazy = lazy(
() => import('@/pages/secondary/ProfileInteractionDiagramPage')
)
const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage'))
function suspensePrimaryPage(page: ReactElement) {
@ -922,6 +925,29 @@ export function useSmartOthersRelaySettingsNavigation() { @@ -922,6 +925,29 @@ export function useSmartOthersRelaySettingsNavigation() {
return { navigateToOthersRelaySettings }
}
export function useSmartProfileInteractionsNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToProfileInteractions = (url: string) => {
if (isSmallScreen) {
const profileId = url.replace('/users/', '').replace('/interactions', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(
<PrimaryProfileInteractionDiagramPageLazy id={profileId} index={0} hideTitlebar={true} />
),
'profile-interactions'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToProfileInteractions }
}
/** Settings index is a normal primary page; sub-routes open on the secondary stack (panel / drawer). */
export function useSmartSettingsNavigation() {
const { navigate: navigatePrimary } = usePrimaryPage()
@ -1847,9 +1873,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1847,9 +1873,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setPrimaryNoteView(null)
return
}
if (primaryViewType === 'following' || primaryViewType === 'others-relay-settings') {
const currentPath = window.location.pathname
const profileId = currentPath.replace('/users/', '').replace('/following', '').replace('/muted', '').replace('/relays', '')
if (
primaryViewType === 'following' ||
primaryViewType === 'others-relay-settings' ||
primaryViewType === 'profile-interactions'
) {
const currentPath = window.location.pathname.split('?')[0].split('#')[0]
const segs = currentPath.split('/').filter(Boolean)
const profileId = segs[0] === 'users' && segs[1] ? segs[1] : ''
const profileUrl = `/users/${profileId}`
window.history.pushState(null, '', profileUrl)
setPrimaryNoteView(

26
src/components/Profile/index.tsx

@ -16,11 +16,11 @@ import { kinds, type NostrEvent } from 'nostr-tools' @@ -16,11 +16,11 @@ import { kinds, type NostrEvent } from 'nostr-tools'
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 { toProfileEditor, toProfileInteractionMap } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSecondaryPage } from '@/PageManager'
import { useSecondaryPage, useSmartProfileInteractionsNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { replaceableEventService } from '@/services/client.service'
@ -31,7 +31,20 @@ import { @@ -31,7 +31,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link, MessageCircle, ThumbsUp } from 'lucide-react'
import {
Copy,
Ellipsis,
Calendar,
MapPin,
Pencil,
SatelliteDish,
Code,
Gift,
Link,
MessageCircle,
ThumbsUp,
LayoutGrid
} from 'lucide-react'
import {
useEffect,
useLayoutEffect,
@ -180,6 +193,7 @@ export default function Profile({ @@ -180,6 +193,7 @@ export default function Profile({
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { navigateToProfileInteractions } = useSmartProfileInteractionsNavigation()
const { navigate: navigatePrimary } = usePrimaryPage()
const internalFeedRef = useRef<{ refresh: () => void }>(null)
const profileFeedRef = feedRef ?? internalFeedRef
@ -501,6 +515,12 @@ export default function Profile({ @@ -501,6 +515,12 @@ export default function Profile({
<Gift />
{t('Follow Packs')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigateToProfileInteractions(toProfileInteractionMap(pubkey))}
>
<LayoutGrid />
{t('interactionMapMenu')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => push(toProfileEditor())}>
<Pencil />
{t('Edit')}

23
src/components/ProfileOptions/index.tsx

@ -19,12 +19,26 @@ import client from '@/services/client.service' @@ -19,12 +19,26 @@ import client from '@/services/client.service'
import { replaceableEventService } from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service'
import RawEventDialog from '@/components/NoteOptions/RawEventDialog'
import { Bell, BellOff, Copy, Ellipsis, ThumbsUp, MessageCircle, Send, Video, SatelliteDish, Code } from 'lucide-react'
import {
Bell,
BellOff,
Copy,
Ellipsis,
ThumbsUp,
MessageCircle,
Send,
Video,
SatelliteDish,
Code,
LayoutGrid
} from 'lucide-react'
import { useMemo, useState, useEffect } from 'react'
import { createReactionDraftEvent } from '@/lib/draft-event'
import PostEditor from '@/components/PostEditor'
import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback'
import { useTranslation } from 'react-i18next'
import { useSmartProfileInteractionsNavigation } from '@/PageManager'
import { toProfileInteractionMap } from '@/lib/link'
import { toast } from 'sonner'
import { Event } from 'nostr-tools'
@ -43,6 +57,7 @@ export default function ProfileOptions({ @@ -43,6 +57,7 @@ export default function ProfileOptions({
onSendCallInvite?: (url: string) => void
}) {
const { t } = useTranslation()
const { navigateToProfileInteractions } = useSmartProfileInteractionsNavigation()
const { pubkey: accountPubkey, profile, publish, checkLogin } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
@ -231,6 +246,12 @@ export default function ProfileOptions({ @@ -231,6 +246,12 @@ export default function ProfileOptions({
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => navigateToProfileInteractions(toProfileInteractionMap(pubkey))}
>
<LayoutGrid />
{t('interactionMapMenu')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))}
>

1
src/contexts/primary-note-view-context.tsx

@ -8,6 +8,7 @@ export type TPrimaryOverlayViewType = @@ -8,6 +8,7 @@ export type TPrimaryOverlayViewType =
| 'hashtag'
| 'relay'
| 'following'
| 'profile-interactions'
| 'mute'
| 'bookmarks'
| 'pins'

52
src/hooks/useProfileInteractionPartners.ts

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

@ -35,6 +35,16 @@ export default { @@ -35,6 +35,16 @@ export default {
Profile: "Profil",
Logout: "Abmelden",
Following: "Folgende",
interactionMapMenu: "Interaktionskarte",
interactionMapTitle: "Interaktionskarte",
interactionMapSubtitle:
"Personen, die dieser Nutzer in Notizen markiert, die lokal schon vorliegen (Sitzungs‑LRU + IndexedDB‑Archiv). Kräftigere Farbe ≈ häufiger erwähnt; hellerer Rand ≈ zuletzt. Nicht vollständig.",
interactionMapSessionNotes: "Sitzungscache: {{count}} ihrer Notizen",
interactionMapArchiveNotes: "Archiv‑Scan: {{count}} ihrer Notizen (begrenzt)",
interactionMapEmpty:
"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}}",
followings: "Folgekonten",
boosted: "geboostet",
"Boosted by:": "Geboostet von:",

10
src/i18n/locales/en.ts

@ -33,6 +33,16 @@ export default { @@ -33,6 +33,16 @@ export default {
Profile: "Profile",
Logout: "Logout",
Following: "Following",
interactionMapMenu: "Interaction map",
interactionMapTitle: "Interaction map",
interactionMapSubtitle:
"People this user tags in notes and replies we already have locally (in-memory session + IndexedDB archive). Stronger color ≈ more mentions; brighter border ≈ more recent. Not exhaustive.",
interactionMapSessionNotes: "Session cache: {{count}} of their notes",
interactionMapArchiveNotes: "Archive scan: {{count}} of their notes (capped)",
interactionMapEmpty:
"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}}",
followings: "followings",
boosted: "boosted",
"Boosted by:": "Boosted by:",

5
src/lib/link.ts

@ -64,6 +64,11 @@ export const toOthersRelaySettings = (pubkey: string) => { @@ -64,6 +64,11 @@ export const toOthersRelaySettings = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
return `/users/${npub}/relays`
}
/** Cached note mentions / tags — session + IndexedDB archive scan (see profile interaction map page). */
export const toProfileInteractionMap = (pubkeyHex: string) => {
const npub = nip19.npubEncode(pubkeyHex)
return `/users/${npub}/interactions`
}
export const toSearch = (params?: TSearchParams) => {
if (!params) return '/search'
const query = new URLSearchParams()

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

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
import type { Event } from 'nostr-tools'
const HEX64 = /^[0-9a-f]{64}$/i
/** Pubkeys this author tags with `p` or references via `a` (kind:pubkey:…), excluding self. */
export function extractPartnerPubkeysFromEvent(event: Event, authorPubkeyLower: string): string[] {
const self = authorPubkeyLower.toLowerCase()
const found = new Set<string>()
for (const t of event.tags ?? []) {
const name = t[0]
if (name === 'p' || name === 'P') {
const pk = (t[1] ?? '').trim().toLowerCase()
if (HEX64.test(pk) && pk !== self) found.add(pk)
continue
}
if (name === 'a' || name === 'A') {
const coord = (t[1] ?? '').trim()
const parts = coord.split(':')
if (parts.length >= 2) {
const pk = parts[1]!.toLowerCase()
if (HEX64.test(pk) && pk !== self) found.add(pk)
}
}
}
return [...found]
}
export type TInteractionPartnerStat = {
pubkey: string
/** How often this pubkey appears in p / a references on the author's events */
mentionCount: number
/** Latest event created_at among those references */
lastReferencedAt: number
}
export function buildInteractionPartnerStats(events: Event[], authorPubkey: string): TInteractionPartnerStat[] {
const author = authorPubkey.trim().toLowerCase()
if (!HEX64.test(author)) return []
const byPk = new Map<string, { count: number; lastAt: number }>()
for (const ev of events) {
if (!ev?.pubkey || ev.pubkey.toLowerCase() !== author) continue
const ts = typeof ev.created_at === 'number' ? ev.created_at : 0
for (const pk of extractPartnerPubkeysFromEvent(ev, author)) {
const cur = byPk.get(pk) ?? { count: 0, lastAt: 0 }
cur.count += 1
cur.lastAt = Math.max(cur.lastAt, ts)
byPk.set(pk, cur)
}
}
return [...byPk.entries()]
.map(([pubkey, v]) => ({
pubkey,
mentionCount: v.count,
lastReferencedAt: v.lastAt
}))
.sort((a, b) => b.mentionCount - a.mentionCount || b.lastReferencedAt - a.lastReferencedAt)
}
export function mergeEventsById(events: Event[]): Event[] {
const m = new Map<string, Event>()
for (const e of events) {
if (!e?.id) continue
const prev = m.get(e.id)
if (!prev || e.created_at > prev.created_at) m.set(e.id, e)
}
return [...m.values()]
}

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

@ -0,0 +1,138 @@ @@ -0,0 +1,138 @@
import { RefreshButton } from '@/components/RefreshButton'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
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 SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfile } from '@/lib/link'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { TPageRef } from '@/types'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
const ProfileInteractionDiagramPage = forwardRef<
TPageRef,
{ id?: string; index?: number; hideTitlebar?: boolean }
>(({ id, index, hideTitlebar = false }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage()
const { profile } = useFetchProfile(id)
const [refreshNonce, setRefreshNonce] = useState(0)
const bump = useCallback(() => setRefreshNonce((n) => n + 1), [])
const { partners, loading, rescan, archiveAuthorEvents, sessionEventCount } = useProfileInteractionPartners(
profile?.pubkey,
refreshNonce
)
const layoutRef = useRef<TPageRef>(null)
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: () => {
void rescan()
bump()
}
}),
[rescan, bump]
)
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(() => {
void rescan()
bump()
})
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 (
<SecondaryPageLayout
ref={layoutRef}
index={index}
title={hideTitlebar ? undefined : t('interactionMapTitle')}
hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={() => void rescan()} />}
displayScrollToTopButton
>
<div className="px-4 pb-8 space-y-4">
<p className="text-sm text-muted-foreground">{t('interactionMapSubtitle')}</p>
<div className="text-xs text-muted-foreground flex flex-wrap gap-x-3 gap-y-1">
<span>{t('interactionMapSessionNotes', { count: sessionEventCount })}</span>
<span>{t('interactionMapArchiveNotes', { count: archiveAuthorEvents })}</span>
</div>
{loading && partners.length === 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
{Array.from({ length: 15 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
) : partners.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">{t('interactionMapEmpty')}</div>
) : (
<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) => {
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
const bgAlpha = 0.12 + heat * 0.55
const borderAlpha = 0.25 + heat * 0.65
return (
<button
key={p.pubkey}
type="button"
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={{
backgroundColor: `hsl(var(--primary) / ${bgAlpha})`,
borderColor: `hsl(var(--primary) / ${borderAlpha})`
}}
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" />
<div className="w-full min-w-0 text-center">
<Username userId={p.pubkey} className="text-xs truncate block" withoutSkeleton />
</div>
<div className="text-[10px] text-muted-foreground tabular-nums">×{p.mentionCount}</div>
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{dayjs.unix(p.lastReferencedAt).fromNow()}
</div>
</button>
)
})}
</div>
)}
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" disabled={loading} onClick={() => void rescan()}>
{t('interactionMapRefresh')}
</Button>
</div>
</div>
</SecondaryPageLayout>
)
})
ProfileInteractionDiagramPage.displayName = 'ProfileInteractionDiagramPage'
export default ProfileInteractionDiagramPage

4
src/routes.tsx

@ -22,6 +22,7 @@ const PostSettingsPageLazy = lazy(() => import('./pages/secondary/PostSettingsPa @@ -22,6 +22,7 @@ const PostSettingsPageLazy = lazy(() => import('./pages/secondary/PostSettingsPa
const ProfileEditorPageLazy = lazy(() => import('./pages/secondary/ProfileEditorPage'))
const ProfileListPageLazy = lazy(() => import('./pages/secondary/ProfileListPage'))
const ProfilePageLazy = lazy(() => import('./pages/secondary/ProfilePage'))
const ProfileInteractionDiagramPageLazy = lazy(() => import('./pages/secondary/ProfileInteractionDiagramPage'))
const RelayPageLazy = lazy(() => import('./pages/secondary/RelayPage'))
const RelayReviewsPageLazy = lazy(() => import('./pages/secondary/RelayReviewsPage'))
const RelaySettingsPageLazy = lazy(() => import('./pages/secondary/RelaySettingsPage'))
@ -72,9 +73,10 @@ const ROUTES = [ @@ -72,9 +73,10 @@ const ROUTES = [
{ path: '/explore/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/home/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/users', element: SR(ProfileListPageLazy) },
{ path: '/users/:id', element: SR(ProfilePageLazy) },
{ path: '/users/:id/following', element: SR(FollowingListPageLazy) },
{ path: '/users/:id/relays', element: SR(OthersRelaySettingsPageLazy) },
{ path: '/users/:id/interactions', element: SR(ProfileInteractionDiagramPageLazy) },
{ path: '/users/:id', element: SR(ProfilePageLazy) },
{ path: '/relays/:url/reviews', element: SR(RelayReviewsPageLazy) },
{ path: '/relays/:url', element: SR(RelayPageLazy) },
{ path: '/home/relays/:url', element: SR(RelayPageLazy) },

23
src/services/client-events.service.ts

@ -656,6 +656,29 @@ export class EventService { @@ -656,6 +656,29 @@ export class EventService {
return results
}
/**
* Session LRU: events authored by `authorPubkey` (e.g. notes, reposts, reactions) for local aggregates.
*/
listSessionEventsAuthoredBy(
authorPubkey: string,
opts?: { kinds?: readonly number[]; limit?: number }
): NEvent[] {
const pk = authorPubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return []
const kindSet = opts?.kinds?.length ? new Set(opts.kinds) : null
const limit = Math.min(Math.max(opts?.limit ?? 800, 1), 4000)
const out: NEvent[] = []
for (const [, event] of this.sessionEventCache.entries()) {
if (shouldDropEventOnIngest(event)) continue
if (event.pubkey.toLowerCase() !== pk) continue
if (kindSet && !kindSet.has(event.kind)) continue
out.push(event)
if (out.length >= limit) break
}
out.sort((a, b) => b.created_at - a.created_at)
return out
}
/**
* Session cache: NIP-32 citation kinds (3033) matched on title/summary/content and related tags
* (not NIP-50 relay semantics).

49
src/services/indexed-db.service.ts

@ -2937,6 +2937,55 @@ class IndexedDbService { @@ -2937,6 +2937,55 @@ class IndexedDbService {
})
}
/**
* Scan {@link StoreNames.EVENT_ARCHIVE} for events authored by `pubkey` (bounded scan).
* Used for client-side aggregates (e.g. interaction map) from disk cache without a new relay REQ.
*/
async scanEventArchiveByAuthorPubkey(
authorPubkey: string,
options: { kinds?: readonly number[]; maxRowsScanned: number; maxMatches: number }
): Promise<Event[]> {
const pk = authorPubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return []
const kindSet = options.kinds?.length ? new Set(options.kinds) : null
const maxRows = Math.min(Math.max(options.maxRowsScanned, 1), 50_000)
const maxMatches = Math.min(Math.max(options.maxMatches, 1), 2000)
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return []
return new Promise((resolve, reject) => {
const out: Event[] = []
let scanned = 0
const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly')
const store = tx.objectStore(StoreNames.EVENT_ARCHIVE)
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor || scanned >= maxRows || out.length >= maxMatches) {
tx.commit()
resolve(out)
return
}
scanned += 1
const row = cursor.value as TArchivedEventRow
const ev = row?.value
if (
ev &&
isLikelyCachedNostrEvent(ev) &&
ev.pubkey?.toLowerCase() === pk &&
(!kindSet || kindSet.has(ev.kind))
) {
out.push(ev)
}
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
async deleteArchivedEvent(eventId: string): Promise<void> {
const id = eventId.toLowerCase()
await this.initPromise

3
src/services/navigation.service.ts

@ -41,6 +41,7 @@ export type ViewType = @@ -41,6 +41,7 @@ export type ViewType =
| 'hashtag'
| 'relay'
| 'following'
| 'profile-interactions'
| 'mute'
| 'bookmarks'
| 'pins'
@ -276,8 +277,10 @@ export class NavigationService { @@ -276,8 +277,10 @@ export class NavigationService {
if (viewType === 'profile') {
if (pathname.includes('/following')) return 'Following'
if (pathname.includes('/relays')) return 'Relays and Storage Settings'
if (pathname.includes('/interactions')) return 'Interaction map'
return 'Profile'
}
if (viewType === 'profile-interactions') return 'Interaction map'
if (viewType === 'hashtag') return 'Hashtag'
if (viewType === 'relay') return 'Relay'
if (viewType === 'note') {

Loading…
Cancel
Save