diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 2a2f6a27..d7836073 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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() { 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( + + ), + '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 }) { 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( diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index e2787842..396fcdd5 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -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 { 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({ }) { 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({ {t('Follow Packs')} + navigateToProfileInteractions(toProfileInteractionMap(pubkey))} + > + + {t('interactionMapMenu')} + push(toProfileEditor())}> {t('Edit')} diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 0592df0d..6f7cd2b7 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -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({ 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({ )} + navigateToProfileInteractions(toProfileInteractionMap(pubkey))} + > + + {t('interactionMapMenu')} + navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))} > diff --git a/src/contexts/primary-note-view-context.tsx b/src/contexts/primary-note-view-context.tsx index 348b3dbd..2c4b560b 100644 --- a/src/contexts/primary-note-view-context.tsx +++ b/src/contexts/primary-note-view-context.tsx @@ -8,6 +8,7 @@ export type TPrimaryOverlayViewType = | 'hashtag' | 'relay' | 'following' + | 'profile-interactions' | 'mute' | 'bookmarks' | 'pins' diff --git a/src/hooks/useProfileInteractionPartners.ts b/src/hooks/useProfileInteractionPartners.ts new file mode 100644 index 00000000..169b407f --- /dev/null +++ b/src/hooks/useProfileInteractionPartners.ts @@ -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([]) + 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 b85b271d..92bd0e1e 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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:", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5c3a19df..b42825f1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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:", diff --git a/src/lib/link.ts b/src/lib/link.ts index a26d8acc..1dc859cb 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -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() diff --git a/src/lib/profile-interaction-partners.ts b/src/lib/profile-interaction-partners.ts new file mode 100644 index 00000000..4ed4eb60 --- /dev/null +++ b/src/lib/profile-interaction-partners.ts @@ -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() + 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() + + 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() + 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()] +} diff --git a/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx b/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx new file mode 100644 index 00000000..7cba6817 --- /dev/null +++ b/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx @@ -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(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 ( + void rescan()} />} + displayScrollToTopButton + > +
+

{t('interactionMapSubtitle')}

+
+ {t('interactionMapSessionNotes', { count: sessionEventCount })} + {t('interactionMapArchiveNotes', { count: archiveAuthorEvents })} +
+ + {loading && partners.length === 0 ? ( +
+ {Array.from({ length: 15 }).map((_, i) => ( + + ))} +
+ ) : partners.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 + const bgAlpha = 0.12 + heat * 0.55 + const borderAlpha = 0.25 + heat * 0.65 + return ( + + ) + })} +
+ )} + +
+ +
+
+
+ ) +}) + +ProfileInteractionDiagramPage.displayName = 'ProfileInteractionDiagramPage' +export default ProfileInteractionDiagramPage diff --git a/src/routes.tsx b/src/routes.tsx index 86bbd3f6..dd4a510f 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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 = [ { 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) }, diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index d797b628..815dc6cc 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -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 (30–33) matched on title/summary/content and related tags * (not NIP-50 relay semantics). diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index f2151068..c27b3026 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -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 { + 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 { const id = eventId.toLowerCase() await this.initPromise diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts index fb2464a9..c22d4cb4 100644 --- a/src/services/navigation.service.ts +++ b/src/services/navigation.service.ts @@ -41,6 +41,7 @@ export type ViewType = | 'hashtag' | 'relay' | 'following' + | 'profile-interactions' | 'mute' | 'bookmarks' | 'pins' @@ -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') {