Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
1c67da27ad
  1. 29
      src/PageManager.tsx
  2. 6
      src/components/Explore/ExploreFavoriteRelays.tsx
  3. 19
      src/components/PostEditor/PostContent.tsx
  4. 196
      src/components/Profile/ProfileBadgeDetailDialog.tsx
  5. 352
      src/components/Profile/ProfileHeaderInteractions.tsx
  6. 12
      src/components/Profile/index.tsx
  7. 14
      src/components/ProfileOptions/index.tsx
  8. 1
      src/constants.ts
  9. 1
      src/contexts/primary-note-view-context.tsx
  10. 156
      src/hooks/useProfileBadges.tsx
  11. 6
      src/hooks/useProfileFollowPacks.tsx
  12. 7
      src/hooks/useProfileInteractions.tsx
  13. 94
      src/hooks/useProfileRelayUrls.tsx
  14. 1
      src/i18n/locales/cs.ts
  15. 21
      src/i18n/locales/de.ts
  16. 21
      src/i18n/locales/en.ts
  17. 1
      src/i18n/locales/es.ts
  18. 1
      src/i18n/locales/fr.ts
  19. 1
      src/i18n/locales/nl.ts
  20. 1
      src/i18n/locales/pl.ts
  21. 1
      src/i18n/locales/ru.ts
  22. 1
      src/i18n/locales/tr.ts
  23. 1
      src/i18n/locales/zh.ts
  24. 5
      src/lib/link.ts
  25. 422
      src/lib/profile-accordion-fetch.ts
  26. 131
      src/lib/profile-accordion-session-cache.ts
  27. 178
      src/lib/profile-interaction-partners.ts
  28. 47
      src/lib/profile-relay-urls.ts
  29. 35
      src/lib/profile-report-relay-urls.ts
  30. 69
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  31. 53
      src/pages/primary/NoteListPage/index.tsx
  32. 30
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  33. 334
      src/pages/secondary/ProfileInteractionDiagramPage/index.tsx
  34. 185
      src/providers/FeedProvider.tsx
  35. 11
      src/providers/feed-context.tsx
  36. 2
      src/routes.tsx
  37. 6
      src/services/client-query.service.ts
  38. 7
      src/services/client.service.ts
  39. 21
      src/services/local-storage.service.ts
  40. 3
      src/services/navigation.service.ts
  41. 3
      src/types/index.d.ts

29
src/PageManager.tsx

@ -106,9 +106,6 @@ const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage' @@ -106,9 +106,6 @@ 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) {
@ -924,29 +921,6 @@ export function useSmartOthersRelaySettingsNavigation() { @@ -924,29 +921,6 @@ 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()
@ -1887,8 +1861,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1887,8 +1861,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (
primaryViewType === 'following' ||
primaryViewType === 'others-relay-settings' ||
primaryViewType === 'profile-interactions'
primaryViewType === 'others-relay-settings'
) {
const currentPath = window.location.pathname.split('?')[0].split('#')[0]
const segs = currentPath.split('/').filter(Boolean)

6
src/components/Explore/ExploreFavoriteRelays.tsx

@ -6,7 +6,6 @@ import { toRelay, toRelaySettings } from '@/lib/link' @@ -6,7 +6,6 @@ import { toRelay, toRelaySettings } from '@/lib/link'
import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSecondaryPage, useSmartRelayNavigation } from '@/PageManager'
import { useFeed } from '@/providers/FeedProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { cn } from '@/lib/utils'
import { Newspaper, Settings } from 'lucide-react'
@ -61,7 +60,6 @@ export default function ExploreFavoriteRelays() { @@ -61,7 +60,6 @@ export default function ExploreFavoriteRelays() {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { push } = useSecondaryPage()
const { switchFeed } = useFeed()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const blockedSet = useMemo(
@ -99,9 +97,7 @@ export default function ExploreFavoriteRelays() { @@ -99,9 +97,7 @@ export default function ExploreFavoriteRelays() {
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 font-medium"
onClick={() => {
void switchFeed('all-favorites').then(() => navigate('feed'))
}}
onClick={() => navigate('feed')}
>
<Newspaper className="size-4 shrink-0" strokeWidth={2.5} />
<span>{t('Favorite Relays')}</span>

19
src/components/PostEditor/PostContent.tsx

@ -45,10 +45,9 @@ import { @@ -45,10 +45,9 @@ import {
} from '@/constants'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { cleanUrl, normalizeUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import logger from '@/lib/logger'
import { LoginRequiredError } from '@/lib/nostr-errors'
import postEditorCache from '@/services/post-editor-cache.service'
@ -196,7 +195,6 @@ export default function PostContent({ @@ -196,7 +195,6 @@ export default function PostContent({
const { t, i18n } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const { userGroups } = useGroupList()
const { feedInfo } = useFeed()
const { addReplies } = useReply()
const mergePublishedReplyIntoThread = useCallback(
@ -1363,21 +1361,6 @@ export default function PostContent({ @@ -1363,21 +1361,6 @@ export default function PostContent({
})
// console.log('Published event:', newEvent)
// Check if we need to refresh the current relay view
if (feedInfo.feedType === 'relay' && feedInfo.id) {
const currentRelayUrl = normalizeUrl(feedInfo.id)
const publishedRelays = additionalRelayUrls
// If we published to the current relay being viewed, trigger a refresh after a short delay
if (publishedRelays.some(url => normalizeUrl(url) === currentRelayUrl)) {
setTimeout(() => {
// Trigger a page refresh by dispatching a custom event that the relay view can listen to
window.dispatchEvent(new CustomEvent('relay-refresh-needed', {
detail: { relayUrl: currentRelayUrl }
}))
}, 1000) // 1 second delay to allow the event to propagate
}
}
// Show publishing feedback
if ((newEvent as any).relayStatuses) {

196
src/components/Profile/ProfileBadgeDetailDialog.tsx

@ -1,196 +0,0 @@ @@ -1,196 +0,0 @@
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { TProfileBadge } from '@/hooks/useProfileBadges'
import { fetchBadgeRecipientPubkeys } from '@/lib/fetch-badge-recipient-pubkeys'
import { toNote, toProfile } from '@/lib/link'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { useSecondaryPage } from '@/PageManager'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
function parseIssuerPubkeyFromATag(aTag: string): string | undefined {
const parts = aTag.split(':')
if (parts.length < 2) return undefined
const pk = parts[1]
return /^[0-9a-f]{64}$/i.test(pk) ? pk.toLowerCase() : undefined
}
export default function ProfileBadgeDetailDialog({
open,
onOpenChange,
badge,
profilePubkey,
relayUrls
}: {
open: boolean
onOpenChange: (open: boolean) => void
badge: TProfileBadge | null
profilePubkey: string
relayUrls: string[]
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
/** Secondary panel is below dialog z-index; close modal before navigating. */
const pushSecondaryAndClose = (path: string) => {
onOpenChange(false)
push(path)
}
const [recipientPubkeys, setRecipientPubkeys] = useState<string[]>([])
const [recipientsLoading, setRecipientsLoading] = useState(false)
const [recipientsError, setRecipientsError] = useState(false)
const issuerPubkey = useMemo(() => (badge ? parseIssuerPubkeyFromATag(badge.a) : undefined), [badge])
const displayImage = badge?.image ?? badge?.thumb
const displayThumb = badge?.thumb ?? badge?.image
const label = badge?.name ?? badge?.a.split(':').pop() ?? ''
useEffect(() => {
if (!open || !badge) {
setRecipientPubkeys([])
setRecipientsError(false)
setRecipientsLoading(false)
return
}
if (relayUrls.length === 0) {
setRecipientPubkeys([])
setRecipientsError(true)
return
}
let cancelled = false
setRecipientsLoading(true)
setRecipientsError(false)
fetchBadgeRecipientPubkeys(relayUrls, badge.a)
.then((pubkeys) => {
if (cancelled) return
pubkeys.sort((a, b) => a.localeCompare(b))
setRecipientPubkeys(pubkeys)
})
.catch(() => {
if (!cancelled) {
setRecipientsError(true)
setRecipientPubkeys([])
}
})
.finally(() => {
if (!cancelled) setRecipientsLoading(false)
})
return () => {
cancelled = true
}
}, [open, badge, relayUrls])
const otherRecipients = useMemo(
() => recipientPubkeys.filter((pk) => !hexPubkeysEqual(pk, profilePubkey)),
[recipientPubkeys, profilePubkey]
)
if (!badge) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] flex flex-col gap-3">
<DialogHeader>
<DialogTitle>{t('Badge details')}</DialogTitle>
<DialogDescription className="sr-only">{label}</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-2">
{displayImage || displayThumb ? (
<img
src={displayImage ?? displayThumb}
alt={label}
className="max-h-48 w-auto max-w-full rounded-lg border object-contain bg-muted"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex size-32 items-center justify-center rounded-lg border bg-muted text-sm text-muted-foreground">
{label.slice(0, 3)}
</div>
)}
<div className="text-center text-base font-semibold">{label}</div>
</div>
{badge.description ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{badge.description}
</p>
) : null}
{badge.awardCreatedAt != null ? (
<p className="text-xs text-muted-foreground">
{t('Awarded on', { defaultValue: 'Awarded on' })}{' '}
{new Date(badge.awardCreatedAt * 1000).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})}
</p>
) : null}
{issuerPubkey ? (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">{t('Issued by')}</div>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5 text-left hover:bg-muted/60"
onClick={() => pushSecondaryAndClose(toProfile(issuerPubkey))}
>
<UserAvatar userId={issuerPubkey} size="small" className="shrink-0" />
<Username userId={issuerPubkey} className="truncate text-sm font-medium" skeletonClassName="h-4" />
</button>
</div>
) : null}
<div className="space-y-1 min-h-0 flex-1 flex flex-col">
<div className="text-xs font-medium text-muted-foreground">{t('Other recipients')}</div>
{recipientsLoading ? (
<div className="text-sm text-muted-foreground py-2">{t('Loading...')}</div>
) : recipientsError ? (
<div className="text-sm text-muted-foreground py-2">{t('Recipients could not be loaded')}</div>
) : otherRecipients.length === 0 ? (
<div className="text-sm text-muted-foreground py-2">{t('No other recipients found')}</div>
) : (
<ScrollArea className="h-44 rounded-md border">
<ul className="p-1 space-y-0.5">
{otherRecipients.map((pk) => (
<li key={pk}>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-muted/80"
onClick={() => pushSecondaryAndClose(toProfile(pk))}
>
<UserAvatar userId={pk} size="small" className="shrink-0" />
<Username userId={pk} className="truncate text-sm" skeletonClassName="h-4" />
</button>
</li>
))}
</ul>
</ScrollArea>
)}
</div>
<Button
type="button"
variant="secondary"
className="w-full"
onClick={() => pushSecondaryAndClose(toNote(badge.awardId))}
>
{t('View award')}
</Button>
</DialogContent>
</Dialog>
)
}

352
src/components/Profile/ProfileHeaderInteractions.tsx

@ -1,352 +0,0 @@ @@ -1,352 +0,0 @@
import Content from '@/components/Content'
import ReactionEmojiDisplay from '@/components/Note/ReactionEmojiDisplay'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog'
import { replaceableEventDedupeKey } from '@/lib/event'
import { formatAmount } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { toNote, toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import type { TProfileZap } from '@/hooks/useProfileInteractions'
import type { TProfileBadge } from '@/hooks/useProfileBadges'
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks'
import { Flag, Zap, MessageCircle, ThumbsDown, ThumbsUp, Users } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { Event } from 'nostr-tools'
type Props = {
profilePubkey: string
badgeRelayUrls: string[]
zaps: TProfileZap[]
reactions: Event[]
comments: Event[]
badges: TProfileBadge[]
followPacks: TProfileFollowPack[]
reports: Event[]
loading: boolean
badgesLoading: boolean
followPacksLoading: boolean
reportsLoading: boolean
/** When false (logged out), the Reports section is omitted — reports use the viewer’s relays only. */
reportsEnabled: boolean
}
const ZAPS_PER_ROW = 4
const ZAP_ROWS = 3
const MAX_ZAPS = ZAPS_PER_ROW * ZAP_ROWS
const LIKES_GRID_COLS = 4
const LIKES_GRID_ROWS = 3
const MAX_LIKES = LIKES_GRID_COLS * LIKES_GRID_ROWS
const BADGES_PER_ROW = 6
const BADGE_ROWS = 2
const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS
const BADGE_TILE_PX = 96
const MAX_FOLLOW_PACKS = 8
const MAX_REPORTS = 12
function reportSummaryFromEvent(event: Event): string {
const reportTag = event.tags.find((t) => t[0] === 'report')
const reason = reportTag?.[1]?.trim()
if (reason) return reason
const text = event.content.trim().replace(/\s+/g, ' ')
if (text) return text.length > 48 ? `${text.slice(0, 45)}` : text
return '—'
}
function ZapBadge({ zap }: { zap: TProfileZap }) {
const { push } = useSecondaryPage()
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-yellow-400/40 hover:bg-yellow-400/10 cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toProfile(zap.pubkey))}
>
<UserAvatar userId={zap.pubkey} size="tiny" className="shrink-0" />
<Zap className="size-3 shrink-0 text-yellow-500 fill-yellow-500" strokeWidth={2} aria-hidden />
<span className="font-semibold tabular-nums text-xs text-foreground truncate">{formatAmount(zap.amount)}</span>
</button>
)
}
function ReactionBadge({ event }: { event: Event }) {
const { push } = useSecondaryPage()
const raw = event.content.trim()
const isPlus = raw === '+'
const isMinus = raw === '-'
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer min-w-0 w-full"
onClick={() => push(toProfile(event.pubkey))}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
{isPlus ? (
<ThumbsUp className="size-3 shrink-0 text-primary" aria-hidden />
) : isMinus ? (
<ThumbsDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
) : raw && !raw.startsWith(':') ? (
<span className="text-xs shrink-0">{raw}</span>
) : (
<ReactionEmojiDisplay event={event} variant="compact" maxRawLength={64} className="shrink-0" />
)}
<Username userId={event.pubkey} className="truncate text-xs text-muted-foreground min-w-0" skeletonClassName="h-3" />
</button>
)
}
function CommentBadge({ event }: { event: Event }) {
const { push } = useSecondaryPage()
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(event))}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
<MessageCircle className="size-3 shrink-0 text-primary" aria-hidden />
<span className="truncate text-xs text-muted-foreground min-w-0">
<Content
event={event}
content={event.content}
className="text-xs [&_p]:text-xs [&_p]:m-0 [&_p]:inline"
/>
</span>
</button>
)
}
function ReportBadge({ event }: { event: Event }) {
const { push } = useSecondaryPage()
const summary = reportSummaryFromEvent(event)
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-destructive/25 hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(event))}
title={summary}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
<Flag className="size-3 shrink-0 text-destructive" strokeWidth={2} aria-hidden />
<span className="truncate text-xs text-muted-foreground min-w-0">{summary}</span>
</button>
)
}
function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const authorPk = pack.event.pubkey
return (
<button
type="button"
className="flex flex-col gap-1 px-2 py-1.5 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(pack.event))}
title={pack.title}
>
<div className="flex min-w-0 items-center gap-1.5">
<Users className="size-3 shrink-0 text-primary" aria-hidden />
<span className="truncate text-xs font-medium text-foreground min-w-0">{pack.title}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5 ps-4">
<span className="shrink-0 text-xs text-muted-foreground">{t('Follow pack by')}:</span>
<UserAvatar userId={authorPk} size="xSmall" className="shrink-0" />
<Username
userId={authorPk}
className="min-w-0 truncate text-xs font-medium text-foreground"
skeletonClassName="h-3.5"
/>
</div>
</button>
)
}
function BadgeItem({
badge,
onOpenDetail
}: {
badge: TProfileBadge
onOpenDetail: (b: TProfileBadge) => void
}) {
const { t } = useTranslation()
const imageUrl = badge.thumb ?? badge.image
const label = badge.name ?? badge.a.split(':').pop() ?? ''
return (
<button
type="button"
className="relative shrink-0 rounded-lg border bg-muted p-0 overflow-hidden cursor-pointer transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{ width: BADGE_TILE_PX, height: BADGE_TILE_PX }}
title={label}
aria-label={label ? `${t('Badge details')}: ${label}` : t('Badge details')}
onClick={() => onOpenDetail(badge)}
>
{imageUrl ? (
<>
<img
src={imageUrl}
alt=""
className="size-full rounded-lg object-cover pointer-events-none"
loading="lazy"
onError={(e) => {
e.currentTarget.style.visibility = 'hidden'
const fallback = e.currentTarget.nextElementSibling as HTMLElement | null
fallback?.classList.remove('hidden')
}}
/>
<div className="hidden absolute inset-0 flex items-center justify-center rounded-lg bg-muted p-1 text-center text-xs text-muted-foreground pointer-events-none">
{label.slice(0, 3)}
</div>
</>
) : (
<div className="flex size-full items-center justify-center rounded-lg p-1 text-center text-xs text-muted-foreground">
{label.slice(0, 3)}
</div>
)}
</button>
)
}
export default function ProfileHeaderInteractions({
profilePubkey,
badgeRelayUrls,
zaps,
reactions,
comments,
badges,
followPacks,
reports,
loading,
badgesLoading,
followPacksLoading,
reportsLoading,
reportsEnabled
}: Props) {
const { t } = useTranslation()
const [badgeDialogOpen, setBadgeDialogOpen] = useState(false)
const [selectedBadge, setSelectedBadge] = useState<TProfileBadge | null>(null)
const displayZaps = zaps.slice(0, MAX_ZAPS)
const displayReactions = reactions.slice(0, MAX_LIKES)
const displayBadges = badges.slice(0, MAX_BADGES)
const displayFollowPacks = followPacks.slice(0, MAX_FOLLOW_PACKS)
const displayReports = reports.slice(0, MAX_REPORTS)
const Section = ({
title,
isEmpty,
isLoading,
children,
skeletonCount = 6,
skeletonItemClassName,
skeletonGridClassName
}: {
title: string
isEmpty: boolean
isLoading: boolean
children: React.ReactNode
skeletonCount?: number
skeletonItemClassName?: string
skeletonGridClassName?: string
}) => (
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-1.5">{title}</div>
{isLoading && isEmpty ? (
<div
className={cn(
'grid gap-1.5',
skeletonGridClassName ?? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4'
)}
>
{Array.from({ length: skeletonCount }).map((_, i) => (
<Skeleton key={i} className={cn('h-8 rounded-md min-w-0', skeletonItemClassName)} />
))}
</div>
) : isEmpty ? (
<div className="text-xs text-muted-foreground py-1">{t('None')}</div>
) : (
children
)}
</div>
)
return (
<div className="py-2 space-y-3 w-full min-w-0 overflow-visible">
<Section title={t('Zaps')} isEmpty={displayZaps.length === 0} isLoading={loading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 auto-rows-min">
{displayZaps.map((item) => (
<ZapBadge key={`zap-${item.pr}`} zap={item} />
))}
</div>
</Section>
<Section title={t('Likes')} isEmpty={reactions.length === 0} isLoading={loading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 auto-rows-min">
{displayReactions.map((item) => (
<ReactionBadge key={`reaction-${item.id}`} event={item} />
))}
</div>
</Section>
<Section title={t('Comments')} isEmpty={comments.length === 0} isLoading={loading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{comments.map((item) => (
<CommentBadge key={`comment-${item.id}`} event={item} />
))}
</div>
</Section>
<Section
title={t('Badges')}
isEmpty={displayBadges.length === 0}
isLoading={badgesLoading}
skeletonCount={12}
skeletonGridClassName="grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-1"
skeletonItemClassName="aspect-square h-24 w-full rounded-lg"
>
<div className="flex flex-wrap gap-1">
{displayBadges.map((badge, index) => (
<BadgeItem
key={`${badge.a}-${badge.awardId}-${index}`}
badge={badge}
onOpenDetail={(b) => {
setSelectedBadge(b)
setBadgeDialogOpen(true)
}}
/>
))}
</div>
</Section>
<ProfileBadgeDetailDialog
open={badgeDialogOpen}
onOpenChange={(o) => {
setBadgeDialogOpen(o)
if (!o) setSelectedBadge(null)
}}
badge={selectedBadge}
profilePubkey={profilePubkey}
relayUrls={badgeRelayUrls}
/>
<Section
title={t('In Follow Packs')}
isEmpty={displayFollowPacks.length === 0}
isLoading={followPacksLoading}
skeletonCount={6}
skeletonItemClassName="h-14"
>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{displayFollowPacks.map((pack) => (
<FollowPackBadge key={replaceableEventDedupeKey(pack.event)} pack={pack} />
))}
</div>
</Section>
{reportsEnabled ? (
<Section title={t('Reports')} isEmpty={displayReports.length === 0} isLoading={reportsLoading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{displayReports.map((item) => (
<ReportBadge key={`report-${item.id}`} event={item} />
))}
</div>
</Section>
) : null}
</div>
)
}

12
src/components/Profile/index.tsx

@ -15,11 +15,11 @@ import { kinds, type NostrEvent } from 'nostr-tools' @@ -15,11 +15,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, toProfileInteractionMap } from '@/lib/link'
import { toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSecondaryPage, useSmartProfileInteractionsNavigation } from '@/PageManager'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { replaceableEventService } from '@/services/client.service'
@ -42,7 +42,6 @@ import { @@ -42,7 +42,6 @@ import {
Link,
MessageCircle,
ThumbsUp,
LayoutGrid
} from 'lucide-react'
import {
useEffect,
@ -191,7 +190,6 @@ export default function Profile({ @@ -191,7 +190,6 @@ 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
@ -536,12 +534,6 @@ export default function Profile({ @@ -536,12 +534,6 @@ 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')}

14
src/components/ProfileOptions/index.tsx

@ -28,16 +28,13 @@ import { @@ -28,16 +28,13 @@ import {
MessageCircle,
Send,
SatelliteDish,
Video,
LayoutGrid
Video
} 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, kinds } from 'nostr-tools'
@ -56,7 +53,6 @@ export default function ProfileOptions({ @@ -56,7 +53,6 @@ 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()
@ -82,7 +78,7 @@ export default function ProfileOptions({ @@ -82,7 +78,7 @@ export default function ProfileOptions({
if (event) {
setLocalProfileEvent(event)
}
} catch (error) {
} catch {
// Silently fail: reply/like stay hidden until the event loads
}
}
@ -248,12 +244,6 @@ export default function ProfileOptions({ @@ -248,12 +244,6 @@ export default function ProfileOptions({
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => navigateToProfileInteractions(toProfileInteractionMap(pubkey))}
>
<LayoutGrid />
{t('interactionMapMenu')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))}
>

1
src/constants.ts

@ -278,7 +278,6 @@ export const StorageKey = { @@ -278,7 +278,6 @@ export const StorageKey = {
DEFAULT_ZAP_COMMENT: 'defaultZapComment',
QUICK_ZAP: 'quickZap',
ZAP_REPLY_THRESHOLD: 'zapReplyThreshold',
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
/** Per-pubkey ms timestamps: last full network hydrate (see ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS). */
ACCOUNT_NETWORK_HYDRATE_AT_MAP: 'accountNetworkHydrateAtMap',
AUTOPLAY: 'autoplay',

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

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

156
src/hooks/useProfileBadges.tsx

@ -1,156 +0,0 @@ @@ -1,156 +0,0 @@
import { ExtendedKind } from '@/constants'
import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media'
import {
fetchNip58BadgeAward,
fetchNip58BadgeDefinition,
mergeNip58BadgeRelayPool
} from '@/lib/fetch-badge-nip58'
import indexedDb from '@/services/indexed-db.service'
import { Event } from 'nostr-tools'
import { tagNameEquals } from '@/lib/tag'
export type TProfileBadge = {
/** Badge definition coordinate (e.g. "30009:alice:bravery") */
a: string
/** Badge award event id */
awardId: string
/** Human-readable name from definition */
name?: string
/** High-res image URL */
image?: string
/** Thumbnail URL (prefer thumb over image for grid display) */
thumb?: string
/** From badge definition (NIP-58) */
description?: string
/** Kind 8 award `created_at` when loaded */
awardCreatedAt?: number
}
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */
function parseATag(aTag: string): { kind: number; pubkey: string; d: string } | null {
const parts = aTag.split(':')
if (parts.length < 3) return null
const kind = parseInt(parts[0], 10)
if (isNaN(kind)) return null
const pk = parts[1]
if (!/^[0-9a-fA-F]{64}$/.test(pk)) return null
const d = parts.slice(2).join(':')
if (!d) return null
return { kind, pubkey: pk.toLowerCase(), d }
}
function mergeProfileBadgesByAwardId(seed: TProfileBadge[], fresh: TProfileBadge[]): TProfileBadge[] {
const m = new Map<string, TProfileBadge>()
for (const b of seed) m.set(b.awardId, b)
for (const b of fresh) m.set(b.awardId, b)
return [...m.values()]
}
export async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise<TProfileBadge[]> {
return Promise.all(
badges.map(async (b) => {
if (b.thumb || b.image) return b
const parsed = parseATag(b.a)
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) return b
try {
const def = await indexedDb.getReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d)
if (!def) return b
const name = def.tags.find(tagNameEquals('name'))?.[1]
const description = def.tags.find(tagNameEquals('description'))?.[1]
const media = extractBadgeDefinitionMedia(def)
return {
...b,
name: name ?? b.name ?? parsed.d,
image: media.image,
thumb: media.thumb ?? media.image,
description: description ?? b.description
}
} catch {
return b
}
})
)
}
/**
* Resolves NIP-58 badge definitions/awards for the newest kind-30008 `profile_badges` event.
* Used by profile accordion bundle fetch.
*/
export async function resolveProfileBadgeList(
profileBadgesEvent: Event | undefined,
urls: string[],
blockedRelays: string[],
seedBadges: TProfileBadge[] | null | undefined
): Promise<TProfileBadge[]> {
if (!profileBadgesEvent) {
return seedBadges?.length ? [...seedBadges] : []
}
const tags = profileBadgesEvent.tags
const pairs: { a: string; e: string; eRelayHint?: string }[] = []
for (let i = 0; i < tags.length - 1; i++) {
const ta = tags[i]
const te = tags[i + 1]
if (
ta[0] === 'a' &&
te[0] === 'e' &&
ta[1] &&
te[1] &&
/^[a-f0-9]{64}$/i.test(te[1])
) {
pairs.push({ a: ta[1], e: te[1], eRelayHint: te[2] })
}
}
if (pairs.length === 0) {
return seedBadges?.length ? [...seedBadges] : []
}
const result: TProfileBadge[] = await Promise.all(
pairs.map(async ({ a, e, eRelayHint }) => {
const parsed = parseATag(a)
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
return { a, awardId: e }
}
const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelays)
const [defEvent, awardEvent] = await Promise.all([
fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool),
fetchNip58BadgeAward(e, relayPool)
])
const awardATag = awardEvent?.tags.find(tagNameEquals('a'))?.[1]
const awardMatchesDefinition = !awardEvent || awardATag === a
const awardCreatedAt =
awardMatchesDefinition && awardEvent ? awardEvent.created_at : undefined
if (defEvent) {
try {
await indexedDb.putReplaceableEvent(defEvent)
} catch {
/* ignore */
}
}
if (!defEvent) {
return { a, awardId: e, awardCreatedAt }
}
const name = defEvent.tags.find(tagNameEquals('name'))?.[1]
const description = defEvent.tags.find(tagNameEquals('description'))?.[1]
const media = extractBadgeDefinitionMedia(defEvent)
return {
a,
awardId: e,
name: name ?? parsed.d,
image: media.image,
thumb: media.thumb ?? media.image,
description,
awardCreatedAt
}
})
)
return mergeProfileBadgesByAwardId(seedBadges ?? [], result)
}

6
src/hooks/useProfileFollowPacks.tsx

@ -1,6 +0,0 @@ @@ -1,6 +0,0 @@
import { Event } from 'nostr-tools'
export type TProfileFollowPack = {
event: Event
title: string
}

7
src/hooks/useProfileInteractions.tsx

@ -1,7 +0,0 @@ @@ -1,7 +0,0 @@
export type TProfileZap = {
pr: string
pubkey: string
amount: number
created_at: number
comment?: string
}

94
src/hooks/useProfileRelayUrls.tsx

@ -1,94 +0,0 @@ @@ -1,94 +0,0 @@
import {
profileAccordionGetCachedRelayUrls,
profileAccordionRelayUrlsKey,
profileAccordionSetRelayUrls
} from '@/lib/profile-accordion-session-cache'
import { buildProfileRelayUrls, getProfileRelayUrlsProvisional } from '@/lib/profile-relay-urls'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
/** Returns profile relay URLs (outboxes + PROFILE_FETCH). Use for sharing relays across profile fetches. */
export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean) {
const { blockedRelays } = useFavoriteRelays()
const blockedRelaysRef = useRef(blockedRelays)
blockedRelaysRef.current = blockedRelays
const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays)
const [relayUrls, setRelayUrls] = useState<string[]>([])
const [loading, setLoading] = useState(false)
/** Stale-while-revalidate: avoid accordion skeleton when refreshing relays but URLs already visible */
const relayUrlsRef = useRef<string[]>([])
relayUrlsRef.current = relayUrls
const fetch = useCallback(
async (force = false): Promise<string[]> => {
if (!pubkey) {
setRelayUrls((prev) => (prev.length === 0 ? prev : []))
setLoading(false)
return []
}
if (!force) {
const cached = profileAccordionGetCachedRelayUrls(pubkey)
if (cached?.length) {
setRelayUrls(cached)
setLoading(false)
return cached
}
}
const provisional = getProfileRelayUrlsProvisional(blockedRelaysRef.current)
const revalidateWithVisibleUrls = force && relayUrlsRef.current.length > 0
if (!revalidateWithVisibleUrls) {
if (provisional.length > 0) {
profileAccordionSetRelayUrls(pubkey, provisional)
setRelayUrls(provisional)
setLoading(false)
} else {
setLoading(true)
}
} else {
setLoading(true)
}
try {
const urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
profileAccordionSetRelayUrls(pubkey, urls)
setRelayUrls(urls)
return urls
} catch {
setRelayUrls((prev) => (prev.length === 0 ? prev : []))
return []
} finally {
setLoading(false)
}
},
[pubkey, blockedRelaysKey]
)
const refresh = useCallback(() => {
if (!pubkey) return Promise.resolve([] as string[])
/** Do not invalidate: that wipes interactions/badges/follow-packs cache and forces empty refetches */
return fetch(true)
}, [pubkey, fetch])
useEffect(() => {
if (!pubkey) {
setRelayUrls((prev) => (prev.length === 0 ? prev : []))
setLoading(false)
return
}
if (!enabled) {
const cached = profileAccordionGetCachedRelayUrls(pubkey)
setRelayUrls((prev) => {
if (cached && cached.length > 0) return cached
if (prev.length === 0) return prev
return []
})
setLoading(false)
return
}
void fetch(false)
}, [pubkey, enabled, fetch])
return { relayUrls, loading, refresh }
}

1
src/i18n/locales/cs.ts

@ -611,7 +611,6 @@ export default { @@ -611,7 +611,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

21
src/i18n/locales/de.ts

@ -35,26 +35,6 @@ export default { @@ -35,26 +35,6 @@ 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}}",
interactionMapIncludeFollows: "Alle meine Follows einblenden",
interactionMapIncludeFollowsHint:
"Zeigt deine komplette Follow-Liste zusammen mit Personen aus ihren gecachten Tags. Bei langen Listen scrollen.",
interactionMapIncludeFollowsBreakdown:
"{{total}} angezeigt — {{fromTags}} aus ihren gecachten Tags, {{fromFollowsOnly}} nur aus deiner Follow-Liste",
interactionMapCellTitleFollowOnly: "Nicht in ihren lokalen Tags — nur deine Follow-Liste",
interactionMapFollowingCheckbox: "Folge ich",
interactionMapMentionsShort: "×{{count}}",
interactionMapRecencyUnknown: "—",
interactionMapScore: "Score {{score}}",
followings: "Folgekonten",
boosted: "geboostet",
"Boosted by:": "Geboostet von:",
@ -631,7 +611,6 @@ export default { @@ -631,7 +611,6 @@ export default {
successes: "Erfolge",
None: "Keine",
"Cache & offline storage": "Cache & Offline-Speicher",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "Dieses Relay hat auf eine offene Anfrage (ohne kinds im Filter) keine Events geliefert. Der Feed unten nutzt stattdessen deinen gewohnten Kind-Filter.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

21
src/i18n/locales/en.ts

@ -33,26 +33,6 @@ export default { @@ -33,26 +33,6 @@ 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}}",
interactionMapIncludeFollows: "Include everyone I follow",
interactionMapIncludeFollowsHint:
"Shows your full follow list merged with people from their cached tags. Scroll when the list is long.",
interactionMapIncludeFollowsBreakdown:
"{{total}} shown — {{fromTags}} from their cached tags, {{fromFollowsOnly}} from your follows only",
interactionMapCellTitleFollowOnly: "Not in their cached tags — your follow list only",
interactionMapFollowingCheckbox: "Following",
interactionMapMentionsShort: "×{{count}}",
interactionMapRecencyUnknown: "—",
interactionMapScore: "Score {{score}}",
followings: "followings",
boosted: "boosted",
"Boosted by:": "Boosted by:",
@ -635,7 +615,6 @@ export default { @@ -635,7 +615,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

1
src/i18n/locales/es.ts

@ -611,7 +611,6 @@ export default { @@ -611,7 +611,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

1
src/i18n/locales/fr.ts

@ -611,7 +611,6 @@ export default { @@ -611,7 +611,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

1
src/i18n/locales/nl.ts

@ -611,7 +611,6 @@ export default { @@ -611,7 +611,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

1
src/i18n/locales/pl.ts

@ -611,7 +611,6 @@ export default { @@ -611,7 +611,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

1
src/i18n/locales/ru.ts

@ -611,7 +611,6 @@ export default { @@ -611,7 +611,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

1
src/i18n/locales/tr.ts

@ -611,7 +611,6 @@ export default { @@ -611,7 +611,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

1
src/i18n/locales/zh.ts

@ -611,7 +611,6 @@ export default { @@ -611,7 +611,6 @@ export default {
successes: "successes",
None: "None",
"Cache & offline storage": "Cache & offline storage",
feedStarting: "Starting feeds and relays… This can take a few seconds after login.",
singleRelayKindFallbackNotice: "This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.",
refreshCacheButtonExplainer: "Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.",
"eventArchive.sectionTitle": "Notes & feed archive",

5
src/lib/link.ts

@ -106,11 +106,6 @@ export const toOthersRelaySettings = (pubkey: string) => { @@ -106,11 +106,6 @@ 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()

422
src/lib/profile-accordion-fetch.ts

@ -1,422 +0,0 @@ @@ -1,422 +0,0 @@
/**
* Orchestrated fetch for the profile interactions accordion: phase 1 (zaps, notes, follow packs,
* profile_badges list), then separate batches for comments on notes, comments on profile (#a), and
* profile reactions (#e + #a); badge NIP-58 resolution and reports run after. `onPartial` fires as
* relays return events (coalesced per microtask). Session cache writes stay at completion only.
* Ordering matches the former standalone profile-interactions hook (removed; logic lives here).
*/
import { ExtendedKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls'
import {
profileAccordionGetCachedBadges,
profileAccordionGetCachedFollowPacks,
profileAccordionGetCachedInteractions,
profileAccordionGetCachedReports,
profileAccordionRelayUrlsKey,
profileAccordionSetBadges,
profileAccordionSetFollowPacks,
profileAccordionSetInteractions,
profileAccordionSetReports
} from '@/lib/profile-accordion-session-cache'
import type { TProfileBadge } from '@/hooks/useProfileBadges'
import { enrichBadgesFromIndexedDb, resolveProfileBadgeList } from '@/hooks/useProfileBadges'
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks'
import type { TProfileZap } from '@/hooks/useProfileInteractions'
import { replaceableEventDedupeKey } from '@/lib/event'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { queryService, replaceableEventService } from '@/services/client.service'
import { Event, Filter, kinds } from 'nostr-tools'
const NOTE_IDS_FOR_COMMENTS = 50
const REPORT_LIMIT = 50
const QUERY_OPTS = {
eoseTimeout: 2500,
globalTimeout: 18_000,
firstRelayResultGraceMs: false
} as const
export type ProfileAccordionBundle = {
zaps: TProfileZap[]
reactions: Event[]
comments: Event[]
badges: TProfileBadge[]
followPacks: TProfileFollowPack[]
reports: Event[]
}
function getPackTitle(event: Event): string {
const titleTag = event.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name')
return titleTag?.[1] || 'Follow Pack'
}
function isProfileBadgesListEvent(pubkey: string, e: Event): boolean {
if (e.kind !== ExtendedKind.PROFILE_BADGES) return false
if (!hexPubkeysEqual(e.pubkey, pubkey)) return false
return e.tags.some((t) => t[0] === 'd' && t[1] === 'profile_badges')
}
function cacheHydrated(
pubkey: string,
relayKey: string,
viewerPubkey: string | null | undefined
): ProfileAccordionBundle | null {
const zi = profileAccordionGetCachedInteractions(pubkey, relayKey)
const zb = profileAccordionGetCachedBadges(pubkey, relayKey)
const zf = profileAccordionGetCachedFollowPacks(pubkey, relayKey)
const viewer = viewerPubkey?.trim()
const reportsReady = !viewer || profileAccordionGetCachedReports(pubkey, viewer) !== undefined
if (!zi || zb === undefined || zf === undefined || !reportsReady) return null
const reports =
viewer ? profileAccordionGetCachedReports(pubkey, viewer) ?? [] : []
return {
zaps: zi.zaps,
reactions: zi.reactions,
comments: zi.comments,
badges: zb,
followPacks: zf,
reports
}
}
function bundleSnapshot(args: {
collectedZaps: TProfileZap[]
reactionsByPubkey: Map<string, Event>
collectedComments: Event[]
packByDedupeKey: Map<string, TProfileFollowPack>
badgesForUi: TProfileBadge[]
reports: Event[]
}): ProfileAccordionBundle {
const zaps = [...args.collectedZaps].sort((a, b) => b.amount - a.amount)
const reactions = Array.from(args.reactionsByPubkey.values()).sort(
(a, b) => b.created_at - a.created_at
)
const comments = [...args.collectedComments].sort((a, b) => b.created_at - a.created_at)
const followPacks = [...args.packByDedupeKey.values()].sort(
(a, b) => b.event.created_at - a.event.created_at
)
return {
zaps,
reactions,
comments,
badges: args.badgesForUi,
followPacks,
reports: args.reports
}
}
export async function fetchProfileAccordionBundle(args: {
pubkey: string
urls: string[]
viewerPubkey: string | null | undefined
favoriteRelays: string[]
blockedRelays: string[]
force: boolean
/** Called as relays return events so the UI can render incrementally (not only after full EOSE). */
onPartial?: (bundle: ProfileAccordionBundle) => void
}): Promise<ProfileAccordionBundle> {
const { pubkey, urls, viewerPubkey, favoriteRelays, blockedRelays, force, onPartial } = args
const relayKey = profileAccordionRelayUrlsKey(urls)
const viewer = viewerPubkey?.trim()
if (!force) {
const hit = cacheHydrated(pubkey, relayKey, viewer)
if (hit) return hit
}
const profileReactionATags = new Set([`0:${pubkey}:`, `0:${pubkey}:profile`])
const profileAddrs = [`0:${pubkey}:`, `0:${pubkey}:profile`]
const seedBadges = force ? undefined : profileAccordionGetCachedBadges(pubkey, relayKey)
let resolvedBadges: TProfileBadge[] | null = null
let reportsSoFar: Event[] = []
const collectedZaps: TProfileZap[] = []
const seenZaps = new Set<string>()
const noteIdSet = new Set<string>()
const packByDedupeKey = new Map<string, TProfileFollowPack>()
const reactionsByPubkey = new Map<string, Event>()
const seenProfileReactionEventIds = new Set<string>()
const collectedComments: Event[] = []
const seenCommentIds = new Set<string>()
let profileBadgesEvent: Event | undefined
let profileMetaEvent: Event | undefined
const emit = () => {
if (!onPartial) return
const badgesForUi = resolvedBadges ?? seedBadges ?? []
onPartial(
bundleSnapshot({
collectedZaps,
reactionsByPubkey,
collectedComments,
packByDedupeKey,
badgesForUi,
reports: reportsSoFar
})
)
}
let emitCoalesce = false
const scheduleEmit = () => {
if (!onPartial || emitCoalesce) return
emitCoalesce = true
queueMicrotask(() => {
emitCoalesce = false
emit()
})
}
const reactionTargetsKind0Profile = (evt: Event): boolean => {
if (evt.kind !== kinds.Reaction) return false
const aHit = evt.tags.some((t) => t[0] === 'a' && t[1] && profileReactionATags.has(t[1]))
if (aHit) return true
const pid = profileMetaEvent?.id
if (!pid) return false
return evt.tags.some((t) => t[0] === 'e' && t[1] && hexPubkeysEqual(t[1], pid))
}
const ingestProfileReaction = (evt: Event) => {
if (!reactionTargetsKind0Profile(evt)) return
if (hexPubkeysEqual(evt.pubkey, pubkey)) return
if (seenProfileReactionEventIds.has(evt.id)) return
seenProfileReactionEventIds.add(evt.id)
const existing = reactionsByPubkey.get(evt.pubkey)
if (!existing || evt.created_at > existing.created_at) {
reactionsByPubkey.set(evt.pubkey, evt)
}
}
const ingestComment = (evt: Event) => {
if (evt.kind !== ExtendedKind.COMMENT) return
if (hexPubkeysEqual(evt.pubkey, pubkey)) return
if (seenCommentIds.has(evt.id)) return
seenCommentIds.add(evt.id)
collectedComments.push(evt)
}
const ingestPhase1Event = (evt: Event) => {
if (evt.kind === kinds.Zap) {
const info = getZapInfoFromEvent(evt)
if (!info || !hexPubkeysEqual(info.recipientPubkey ?? '', pubkey) || !info.amount || info.amount <= 0)
return
const sender = info.senderPubkey ?? evt.pubkey
if (hexPubkeysEqual(sender, pubkey)) return
if (seenZaps.has(evt.id)) return
seenZaps.add(evt.id)
collectedZaps.push({
pr: evt.id,
pubkey: sender,
amount: info.amount,
created_at: evt.created_at,
comment: info.comment
})
} else if (evt.kind === kinds.ShortTextNote) {
noteIdSet.add(evt.id)
} else if (evt.kind === ExtendedKind.FOLLOW_PACK) {
const key = replaceableEventDedupeKey(evt)
const next: TProfileFollowPack = { event: evt, title: getPackTitle(evt) }
const prev = packByDedupeKey.get(key)
if (!prev || evt.created_at > prev.event.created_at) {
packByDedupeKey.set(key, next)
}
} else if (isProfileBadgesListEvent(pubkey, evt)) {
if (!profileBadgesEvent || evt.created_at > profileBadgesEvent.created_at) {
profileBadgesEvent = evt
}
}
}
// Keep phase 1 free of #a reaction/comment: many relays handle those poorly when batched with
// zaps/notes/badges. Same ordering as interactions hook — dedicated REQ(s) for profile comments
// and reactions after we have note ids + kind-0 id.
const phase1Filters: Filter[] = [
{ '#p': [pubkey], kinds: [kinds.Zap], limit: 100 },
{ authors: [pubkey], kinds: [kinds.ShortTextNote], limit: NOTE_IDS_FOR_COMMENTS },
{ '#p': [pubkey], kinds: [ExtendedKind.FOLLOW_PACK], limit: 50 },
{
authors: [pubkey],
kinds: [ExtendedKind.PROFILE_BADGES],
'#d': ['profile_badges'],
limit: 5
}
]
const phase1Opts = {
...QUERY_OPTS,
onevent: (evt: Event) => {
ingestPhase1Event(evt)
scheduleEmit()
}
}
const [metaEv, _phase1Events] = await Promise.all([
replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, urls),
queryService.fetchEvents(urls, phase1Filters, phase1Opts)
])
profileMetaEvent = metaEv
emit()
const noteIds = [...noteIdSet].slice(0, NOTE_IDS_FOR_COMMENTS)
if (noteIds.length > 0) {
await queryService.fetchEvents(
urls,
[{ '#e': noteIds, kinds: [ExtendedKind.COMMENT], limit: 50 }],
{
...QUERY_OPTS,
onevent: (evt: Event) => {
if (evt.kind === ExtendedKind.COMMENT) ingestComment(evt)
scheduleEmit()
}
}
)
}
await queryService.fetchEvents(
urls,
[{ '#a': profileAddrs, kinds: [ExtendedKind.COMMENT], limit: 120 }],
{
...QUERY_OPTS,
onevent: (evt: Event) => {
if (evt.kind === ExtendedKind.COMMENT) ingestComment(evt)
scheduleEmit()
}
}
)
const reactionFilters: Filter[] = []
if (profileMetaEvent?.id) {
reactionFilters.push({ '#e': [profileMetaEvent.id], kinds: [kinds.Reaction], limit: 80 })
}
reactionFilters.push({
'#a': [...profileReactionATags],
kinds: [kinds.Reaction],
limit: 80
})
await queryService.fetchEvents(urls, reactionFilters, {
...QUERY_OPTS,
onevent: (evt: Event) => {
if (evt.kind === kinds.Reaction) ingestProfileReaction(evt)
scheduleEmit()
}
})
collectedZaps.sort((a, b) => b.amount - a.amount)
const reactions = Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at)
collectedComments.sort((a, b) => b.created_at - a.created_at)
const followPacks = [...packByDedupeKey.values()].sort((a, b) => b.event.created_at - a.event.created_at)
let badges = await resolveProfileBadgeList(profileBadgesEvent, urls, blockedRelays, seedBadges)
badges = await enrichBadgesFromIndexedDb(badges)
resolvedBadges = badges
emit()
let reports: Event[] = []
if (viewer) {
const reportUrls = await buildProfileReportRelayUrls({
viewerPubkey: viewer,
favoriteRelays,
blockedRelays
})
if (reportUrls.length > 0) {
const seenReportIds = new Set<string>()
reports = await queryService.fetchEvents(
reportUrls,
[{ '#p': [pubkey], kinds: [ExtendedKind.REPORT], limit: REPORT_LIMIT }],
{
...QUERY_OPTS,
onevent: (evt: Event) => {
if (evt.kind !== ExtendedKind.REPORT || seenReportIds.has(evt.id)) return
seenReportIds.add(evt.id)
reportsSoFar.push(evt)
reportsSoFar.sort((a, b) => b.created_at - a.created_at)
scheduleEmit()
}
}
)
}
profileAccordionSetReports(pubkey, viewer, reports)
}
reportsSoFar = reports
profileAccordionSetInteractions(pubkey, relayKey, {
zaps: collectedZaps,
reactions,
comments: collectedComments
})
profileAccordionSetBadges(pubkey, relayKey, badges)
profileAccordionSetFollowPacks(pubkey, relayKey, followPacks)
emit()
return {
zaps: collectedZaps,
reactions,
comments: collectedComments,
badges,
followPacks,
reports
}
}
export function profileAccordionBundleCacheKey(urls: string[]): string {
return profileAccordionRelayUrlsKey(urls)
}
function badgeMergeKey(b: TProfileBadge): string {
return `${b.a}|${b.awardId}`
}
/** Merge two accordion bundles (e.g. provisional relays + delta-only second fetch). */
export function mergeProfileAccordionBundles(
base: ProfileAccordionBundle,
add: ProfileAccordionBundle
): ProfileAccordionBundle {
const zapByPr = new Map(base.zaps.map((z) => [z.pr, z]))
for (const z of add.zaps) {
if (!zapByPr.has(z.pr)) zapByPr.set(z.pr, z)
}
const zaps = [...zapByPr.values()].sort((a, b) => b.amount - a.amount)
const reactionsByPubkey = new Map<string, Event>()
for (const e of base.reactions) {
reactionsByPubkey.set(e.pubkey, e)
}
for (const e of add.reactions) {
const prev = reactionsByPubkey.get(e.pubkey)
if (!prev || e.created_at > prev.created_at) reactionsByPubkey.set(e.pubkey, e)
}
const reactions = [...reactionsByPubkey.values()].sort((a, b) => b.created_at - a.created_at)
const commentById = new Map(base.comments.map((c) => [c.id, c]))
for (const c of add.comments) {
if (!commentById.has(c.id)) commentById.set(c.id, c)
}
const comments = [...commentById.values()].sort((a, b) => b.created_at - a.created_at)
const packByKey = new Map(base.followPacks.map((p) => [replaceableEventDedupeKey(p.event), p]))
for (const p of add.followPacks) {
const k = replaceableEventDedupeKey(p.event)
const prev = packByKey.get(k)
if (!prev || p.event.created_at > prev.event.created_at) packByKey.set(k, p)
}
const followPacks = [...packByKey.values()].sort((a, b) => b.event.created_at - a.event.created_at)
const badgeByKey = new Map(base.badges.map((b) => [badgeMergeKey(b), b]))
for (const b of add.badges) {
const k = badgeMergeKey(b)
if (!badgeByKey.has(k)) badgeByKey.set(k, b)
}
const badges = [...badgeByKey.values()]
const reportById = new Map(base.reports.map((r) => [r.id, r]))
for (const r of add.reports) {
if (!reportById.has(r.id)) reportById.set(r.id, r)
}
const reports = [...reportById.values()].sort((a, b) => b.created_at - a.created_at)
return { zaps, reactions, comments, badges, followPacks, reports }
}

131
src/lib/profile-accordion-session-cache.ts

@ -1,131 +0,0 @@ @@ -1,131 +0,0 @@
/**
* In-memory session cache for profile accordion fetches (per viewed profile pubkey).
* Survives collapsing/reopening the accordion; cleared on full page reload.
*/
import type { TProfileZap } from '@/hooks/useProfileInteractions'
import type { TProfileBadge } from '@/hooks/useProfileBadges'
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks'
import type { Event } from 'nostr-tools'
export type ProfileAccordionInteractionsSnapshot = {
zaps: TProfileZap[]
reactions: Event[]
comments: Event[]
}
type Entry = {
relayUrls?: string[]
/** Fingerprint of profile relay list from {@link profileAccordionSetRelayUrls} (invalidates slices when it changes) */
relayUrlsKey?: string
interactions?: ProfileAccordionInteractionsSnapshot
/** Relay key used for the last interactions fetch (per-slice; avoids races with badges / follow packs) */
interactionsRelayKey?: string
badges?: TProfileBadge[]
badgesRelayKey?: string
followPacks?: TProfileFollowPack[]
followPacksRelayKey?: string
/** viewer hex pubkey → reports */
reportsByViewer?: Record<string, Event[]>
}
const store = new Map<string, Entry>()
export function profileAccordionRelayUrlsKey(urls: string[]): string {
if (urls.length === 0) return ''
return [...urls].sort().join('|')
}
function getEntry(pubkey: string): Entry {
let e = store.get(pubkey)
if (!e) {
e = {}
store.set(pubkey, e)
}
return e
}
export function profileAccordionGetCachedRelayUrls(pubkey: string): string[] | undefined {
const urls = getEntry(pubkey).relayUrls
return urls?.length ? urls : undefined
}
export function profileAccordionSetRelayUrls(pubkey: string, urls: string[]): void {
const e = getEntry(pubkey)
const key = profileAccordionRelayUrlsKey(urls)
if (e.relayUrlsKey && e.relayUrlsKey !== key) {
delete e.interactions
delete e.interactionsRelayKey
delete e.badges
delete e.badgesRelayKey
delete e.followPacks
delete e.followPacksRelayKey
}
e.relayUrls = urls
e.relayUrlsKey = key
}
export function profileAccordionGetCachedInteractions(
pubkey: string,
relayKey: string
): ProfileAccordionInteractionsSnapshot | undefined {
const e = store.get(pubkey)
if (!e?.interactions || e.interactionsRelayKey !== relayKey) return undefined
return e.interactions
}
export function profileAccordionSetInteractions(
pubkey: string,
relayKey: string,
data: ProfileAccordionInteractionsSnapshot
): void {
const e = getEntry(pubkey)
e.interactions = data
e.interactionsRelayKey = relayKey
}
export function profileAccordionGetCachedBadges(pubkey: string, relayKey: string): TProfileBadge[] | undefined {
const e = store.get(pubkey)
if (!e?.badges || e.badgesRelayKey !== relayKey) return undefined
return e.badges
}
export function profileAccordionSetBadges(pubkey: string, relayKey: string, badges: TProfileBadge[]): void {
const e = getEntry(pubkey)
e.badges = badges
e.badgesRelayKey = relayKey
}
export function profileAccordionGetCachedFollowPacks(
pubkey: string,
relayKey: string
): TProfileFollowPack[] | undefined {
const e = store.get(pubkey)
if (!e?.followPacks || e.followPacksRelayKey !== relayKey) return undefined
return e.followPacks
}
export function profileAccordionSetFollowPacks(
pubkey: string,
relayKey: string,
packs: TProfileFollowPack[]
): void {
const e = getEntry(pubkey)
e.followPacks = packs
e.followPacksRelayKey = relayKey
}
export function profileAccordionGetCachedReports(profilePubkey: string, viewerPubkey: string): Event[] | undefined {
return getEntry(profilePubkey).reportsByViewer?.[viewerPubkey]
}
export function profileAccordionSetReports(
profilePubkey: string,
viewerPubkey: string,
reports: Event[]
): void {
const e = getEntry(profilePubkey)
if (!e.reportsByViewer) e.reportsByViewer = {}
e.reportsByViewer[viewerPubkey] = reports
}

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

@ -1,178 +0,0 @@ @@ -1,178 +0,0 @@
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
}
/** Same recency horizon as the interaction map UI (≈ half a year). */
export const INTERACTION_MAP_RECENCY_MAX_AGE_SEC = 180 * 86400
export type TRankedInteractionPartner = {
stat: TInteractionPartnerStat
/** 0–100: more mentions and more recent references rank higher (matches map “heat” weights). */
score: number
}
/**
* Sort by combined frequency + recency. Uses `nowSec` and `maxAgeSec` like the map card shading
* (55% mention density vs max in list, 45% recency within the age window).
*/
export function rankInteractionPartnersByRecencyAndFrequency(
partners: TInteractionPartnerStat[],
nowSec: number,
maxAgeSec: number = INTERACTION_MAP_RECENCY_MAX_AGE_SEC
): TRankedInteractionPartner[] {
if (partners.length === 0) return []
const age = Math.max(1, maxAgeSec)
const maxM = Math.max(1, ...partners.map((p) => p.mentionCount))
const scoreFor = (p: TInteractionPartnerStat): number => {
const countNorm = Math.min(1, p.mentionCount / maxM)
const recencyNorm =
p.lastReferencedAt > 0
? 1 - Math.min(1, Math.max(0, nowSec - p.lastReferencedAt) / age)
: 0
return 100 * (0.55 * countNorm + 0.45 * recencyNorm)
}
return [...partners]
.map((stat) => ({ stat, score: scoreFor(stat) }))
.sort(
(a, b) =>
b.score - a.score ||
b.stat.mentionCount - a.stat.mentionCount ||
b.stat.lastReferencedAt - a.stat.lastReferencedAt ||
a.stat.pubkey.localeCompare(b.stat.pubkey)
)
}
/**
* Rows for the interaction map grid: ranked by frequency/recency, with optional merge of the viewers
* follows. When `includeAllFollows` is true, returns **every** merged row (no row cap): people from cached
* tags first (by score), then everyone who appears only from your follow list (stable pubkey order).
*/
export function rankInteractionMapGridRows(
partners: TInteractionPartnerStat[],
opts: {
includeAllFollows: boolean
followings: string[]
nowSec: number
maxAgeSec?: number
/** Max rows when `includeAllFollows` is false (interaction-only view). Ignored when including follows. */
gridCap?: number
}
): TRankedInteractionPartner[] {
const {
includeAllFollows,
followings,
nowSec,
maxAgeSec = INTERACTION_MAP_RECENCY_MAX_AGE_SEC,
gridCap = 72
} = opts
if (!includeAllFollows) {
return rankInteractionPartnersByRecencyAndFrequency(partners, nowSec, maxAgeSec).slice(0, gridCap)
}
const merged = mergeInteractionPartnersWithFollowings(partners, followings)
if (merged.length === 0) return []
const tagged = merged.filter((p) => p.mentionCount > 0 || p.lastReferencedAt > 0)
const followOnly = merged.filter((p) => p.mentionCount === 0 && p.lastReferencedAt === 0)
const rankedTagged = rankInteractionPartnersByRecencyAndFrequency(tagged, nowSec, maxAgeSec)
const extrasSorted = [...followOnly].sort((a, b) => a.pubkey.localeCompare(b.pubkey))
const extraRows: TRankedInteractionPartner[] = extrasSorted.map((stat) => ({ stat, score: 0 }))
return [...rankedTagged, ...extraRows]
}
export function buildInteractionPartnerStats(events: Event[], authorPubkey: string): TInteractionPartnerStat[] {
const author = authorPubkey.trim().toLowerCase()
if (!HEX64.test(author)) return []
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()]
}
/** Adds follow pubkeys not already present so the viewer can manage follows from the interaction grid. */
export function mergeInteractionPartnersWithFollowings(
partners: TInteractionPartnerStat[],
followedPubkeys: string[]
): TInteractionPartnerStat[] {
const map = new Map<string, TInteractionPartnerStat>()
for (const p of partners) {
const k = p.pubkey.trim().toLowerCase()
if (!HEX64.test(k)) continue
map.set(k, { pubkey: k, mentionCount: p.mentionCount, lastReferencedAt: p.lastReferencedAt })
}
for (const raw of followedPubkeys) {
const k = raw.trim().toLowerCase()
if (!HEX64.test(k)) continue
if (!map.has(k)) {
map.set(k, { pubkey: k, mentionCount: 0, lastReferencedAt: 0 })
}
}
return [...map.values()].sort(
(a, b) =>
b.mentionCount - a.mentionCount ||
b.lastReferencedAt - a.lastReferencedAt ||
a.pubkey.localeCompare(b.pubkey)
)
}

47
src/lib/profile-relay-urls.ts

@ -1,47 +0,0 @@ @@ -1,47 +0,0 @@
/**
* Build relay URLs for profile-related fetches (zaps, likes, comments, badges, follow packs).
* Uses profile owner's outboxes + PROFILE_FETCH_RELAY_URLS.
*/
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import { normalizeUrl } from '@/lib/url'
/**
* Immediate relay stack before NIP-65 outboxes resolve (accordion / fast first paint).
*/
export function getProfileRelayUrlsProvisional(blockedRelays: string[] = []): string[] {
const blocked = new Set(
[...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS].map((u) => (normalizeUrl(u) || u).toLowerCase())
)
const out: string[] = []
const seen = new Set<string>()
for (const u of PROFILE_FETCH_RELAY_URLS) {
const n = normalizeUrl(u) || u
if (!n || blocked.has(n.toLowerCase()) || seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out
}
export async function buildProfileRelayUrls(
pubkey: string,
blockedRelays: string[] = []
): Promise<string[]> {
const blocked = new Set(
[...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS].map((u) => (normalizeUrl(u) || u).toLowerCase())
)
const addRelay = (url: string | undefined, out: Set<string>) => {
if (!url) return
const n = normalizeUrl(url) || url
if (!n || blocked.has(n.toLowerCase())) return
out.add(n)
}
const relayUrlsSet = new Set<string>()
const relayList = await client.fetchRelayList(pubkey).catch(() => ({ write: [] as string[], read: [] as string[] }))
;(relayList?.write ?? []).filter((u): u is string => !!u).forEach((u) => addRelay(u, relayUrlsSet))
PROFILE_FETCH_RELAY_URLS.forEach((u) => addRelay(u, relayUrlsSet))
return Array.from(relayUrlsSet)
}

35
src/lib/profile-report-relay-urls.ts

@ -1,35 +0,0 @@ @@ -1,35 +0,0 @@
/**
* Relays for profile NIP-56 reports (kind 1984): only the viewers favorite tier and read (inbox)
* relays no profile outboxes or global read mirrors, to limit abusive report spam.
*/
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
const MAX_PROFILE_REPORT_RELAYS = 28
export async function buildProfileReportRelayUrls(options: {
viewerPubkey: string
favoriteRelays: string[]
blockedRelays: string[]
}): Promise<string[]> {
const { viewerPubkey, favoriteRelays, blockedRelays } = options
const list = await client.fetchRelayList(viewerPubkey).catch(() => ({ read: [] as string[], write: [] as string[] }))
const inbox = relayUrlsLocalsFirst(list.read ?? [])
.map((u) => normalizeUrl(u) || u)
.filter(Boolean) as string[]
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
return feedRelayPolicyUrls([
{ source: 'favorites', urls: favorites },
{ source: 'viewer-read', urls: inbox }
], {
operation: 'read',
blockedRelays,
maxRelays: MAX_PROFILE_REPORT_RELAYS,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
})
}

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

@ -1,11 +1,7 @@ @@ -1,11 +1,7 @@
import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import { checkAlgoRelay } from '@/lib/relay'
import {
isWispTrendingNotesRelayUrl,
WISP_TRENDING_FEED_KINDS
} from '@/lib/wisp-trending-relay'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { normalizeUrl } from '@/lib/url'
import { useFeed } from '@/providers/FeedProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import relayInfoService from '@/services/relay-info.service'
@ -21,7 +17,7 @@ const RelaysFeed = forwardRef< @@ -21,7 +17,7 @@ const RelaysFeed = forwardRef<
kindsOverride?: number[]
}
>(function RelaysFeed({ setSubHeader, onSubHeaderRefresh, kindsOverride }, ref) {
const { feedInfo, relayUrls } = useFeed()
const { relayUrls } = useFeed()
const { showKinds } = useKindFilterOrDefaults()
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
@ -57,7 +53,7 @@ const RelaysFeed = forwardRef< @@ -57,7 +53,7 @@ const RelaysFeed = forwardRef<
if (cancelled) return
const areAlgo = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))
setAreAlgoRelays(areAlgo)
} catch (_error) {
} catch {
if (!cancelled) setAreAlgoRelays(false)
}
}
@ -76,47 +72,11 @@ const RelaysFeed = forwardRef< @@ -76,47 +72,11 @@ const RelaysFeed = forwardRef<
return fallbackNoteKinds
}, [kindsOverride, showKinds, fallbackNoteKinds])
const canRenderFeed =
(feedInfo.feedType === 'relay' ||
feedInfo.feedType === 'relays' ||
feedInfo.feedType === 'all-favorites') &&
relayUrls.length > 0
/** Distinguishes home relay chips so we do not keep the previous timeline on single→all-favorites (strict superset). */
const feedTimelineScopeKey = useMemo(() => {
if (feedInfo.feedType === 'all-favorites') return 'all-favorites'
if (feedInfo.feedType === 'relays') return `relays:${feedInfo.id ?? ''}`
if (feedInfo.feedType === 'relay') {
/** Same canonical URL identity as {@link NoteList} `subRequestsKey` (not `normalizeUrl` alone — HTTP index relays differ). */
const urlsKey = [...relayUrls]
.map((u) => normalizeAnyRelayUrl(u) || u)
.filter(Boolean)
.sort()
.join('|')
if (urlsKey) return `relay:${urlsKey}`
const id = feedInfo.id ? normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id : ''
return `relay:${id}`
}
return undefined
}, [feedInfo.feedType, feedInfo.id, relayUrls])
const wispTrendingSingleRelay =
feedInfo.feedType === 'relay' &&
relayUrls.length === 1 &&
!!relayUrls[0] &&
isWispTrendingNotesRelayUrl(relayUrls[0])
const canRenderFeed = relayUrls.length > 0
// Hooks must run every render — never place useMemo after conditional returns.
const subRequests = useMemo(() => {
if (!canRenderFeed) return []
if (wispTrendingSingleRelay) {
return [
{
urls: relayUrls,
filter: { kinds: [...WISP_TRENDING_FEED_KINDS], limit: 100 }
}
]
}
return [
{
urls: relayUrls,
@ -125,37 +85,26 @@ const RelaysFeed = forwardRef< @@ -125,37 +85,26 @@ const RelaysFeed = forwardRef<
}
}
]
}, [canRenderFeed, relayUrls, defaultKinds, wispTrendingSingleRelay])
}, [canRenderFeed, relayUrls, defaultKinds])
if (!canRenderFeed) {
return null
}
// preserveTimeline: merge when relay list grows (e.g. all-favorites list fills in). Do not use
// mergeTimelineWhenSubRequestFiltersMatch here — same kinds + different URLs would keep the old
// timeline when switching home feed chips (all-favorites ↔ set ↔ single relay).
// preserveTimeline: merge when relay list grows (e.g. all-favorites list fills in).
return (
<NormalFeed
ref={ref}
subRequests={subRequests}
areAlgoRelays={wispTrendingSingleRelay || areAlgoRelays}
areAlgoRelays={areAlgoRelays}
isMainFeed
setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh}
preserveTimelineOnSubRequestsChange
feedTimelineScopeKey={feedTimelineScopeKey}
feedTimelineScopeKey="all-favorites"
showFeedClientFilter
hostPrimaryPageName="feed"
/**
* {@link timelinePublicReadFallback} uses {@link FAST_READ_RELAY_URLS} with the shard filters kinds only
* there is no this relay URL scope. For a **single chip**, that made every relay show the same global batch.
* Keep fallback for multi-relay surfaces where a broad read matches user intent; single-chip feeds rely on
* that relay + disk/session hydrate only.
*/
timelinePublicReadFallback={
feedInfo.feedType === 'all-favorites' ||
(feedInfo.feedType === 'relays' && relayUrls.length > 1)
}
timelinePublicReadFallback
/>
)
})

53
src/pages/primary/NoteListPage/index.tsx

@ -6,7 +6,6 @@ import { useFeed } from '@/providers/FeedProvider' @@ -6,7 +6,6 @@ import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList'
import { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { TPageRef } from '@/types'
import { Calendar, Compass, Flame } from 'lucide-react'
import React, {
@ -30,15 +29,10 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -30,15 +29,10 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const layoutRef = useRef<TPageRef>(null)
const feedRef = useRef<TNoteListRef>(null)
const { feedInfo, relayUrls, isReady } = useFeed()
const { relayUrls } = useFeed()
const { isSmallScreen } = useScreenSize()
const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null)
const usesSubHeader =
feedInfo.feedType === 'all-favorites' ||
feedInfo.feedType === 'relay' ||
feedInfo.feedType === 'relays'
const runFeedRefresh = useCallback(() => {
feedRef.current?.refresh()
}, [])
@ -56,10 +50,6 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -56,10 +50,6 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
setHomeSubHeader(node)
}, [])
useEffect(() => {
if (!usesSubHeader) setHomeSubHeader(null)
}, [usesSubHeader])
// REMOVED: Scroll-to-top logic - feed should NEVER scroll to top when drawer opens/closes
// The feed stays mounted and maintains scroll position at all times
@ -72,37 +62,6 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -72,37 +62,6 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
}
}, [relayUrls])
let content: React.ReactNode = null
if (!isReady) {
content = (
<div
className="min-h-[40vh] space-y-2 px-1 py-4"
role="status"
aria-live="polite"
aria-busy="true"
>
<p className="px-3 text-sm text-muted-foreground">
{t('feedStarting', {
defaultValue: 'Starting feeds and relays… This can take a few seconds after login.'
})}
</p>
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardLoadingSkeleton key={i} />
))}
</div>
)
} else {
content = (
<>
<RelaysFeed
ref={feedRef}
setSubHeader={setHomeSubHeaderStable}
onSubHeaderRefresh={runFeedRefresh}
/>
</>
)
}
const feedPageTitle = t('Favorite Relays')
const subHeader = (
@ -116,7 +75,7 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -116,7 +75,7 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
)
/** Desktop: nav/logo/account live in titlebar only on small screens; refresh moves to subheader when present. Omit empty h-12 strip. */
const showNoteListTitlebar = isSmallScreen || !usesSubHeader
const showNoteListTitlebar = isSmallScreen
return (
<PrimaryPageLayout
@ -125,14 +84,18 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -125,14 +84,18 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
suppressMobileDefaultActiveRelaysButton
titlebar={
showNoteListTitlebar ? (
<NoteListPageTitlebar onFeedRefresh={runFeedRefresh} showTitlebarRefresh={!usesSubHeader} />
<NoteListPageTitlebar onFeedRefresh={runFeedRefresh} showTitlebarRefresh={false} />
) : null
}
subHeader={subHeader}
displayScrollToTopButton
>
<div className="min-w-0 pt-2">
{content}
<RelaysFeed
ref={feedRef}
setSubHeader={setHomeSubHeaderStable}
onSubHeaderRefresh={runFeedRefresh}
/>
</div>
</PrimaryPageLayout>
)

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

@ -4,9 +4,8 @@ import { ExtendedKind } from '@/constants' @@ -4,9 +4,8 @@ import { ExtendedKind } from '@/constants'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingTombstones } from '@/lib/event'
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { toNote, toProfileInteractionMap } from '@/lib/link'
import { toNote } from '@/lib/link'
import logger from '@/lib/logger'
import { mergeEventsById } from '@/lib/profile-interaction-partners'
import {
parseRelayThreadHeatMapCache,
relayThreadHeatMapSettingKey,
@ -22,14 +21,14 @@ import { @@ -22,14 +21,14 @@ import {
type TRelayThreadHeatEdge
} from '@/lib/relay-thread-heat'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartNoteNavigation, useSmartProfileInteractionsNavigation } from '@/PageManager'
import { useSmartNoteNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider'
import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { cn } from '@/lib/utils'
import { LayoutGrid, Loader2, RefreshCw } from 'lucide-react'
import { Loader2, RefreshCw } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { kinds, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
@ -44,6 +43,18 @@ const SESSION_HEAT_LIMIT = 2500 @@ -44,6 +43,18 @@ const SESSION_HEAT_LIMIT = 2500
const ARCHIVE_HEAT_MAX_SCAN = 30_000
const ARCHIVE_HEAT_MAX_MATCHES = 2000
function mergeEventsById(events: Event[]): Event[] {
const eventsById = new Map<string, Event>()
for (const event of events) {
if (!event?.id) continue
const existing = eventsById.get(event.id)
if (!existing || event.created_at > existing.created_at) {
eventsById.set(event.id, event)
}
}
return Array.from(eventsById.values())
}
const HEAT_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const
const ARCHIVE_SCAN_TIMEOUT_MS = 22_000
@ -87,7 +98,6 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) @@ -87,7 +98,6 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
const { t } = useTranslation()
const { navigate: navigatePrimary } = usePrimaryPage()
const { navigateToNote } = useSmartNoteNavigation()
const { navigateToProfileInteractions } = useSmartProfileInteractionsNavigation()
const { pubkey, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults()
@ -437,16 +447,6 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) @@ -437,16 +447,6 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
)}
{t('heatMapRescan')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => navigateToProfileInteractions(toProfileInteractionMap(pubkey))}
>
<LayoutGrid className="size-4 shrink-0" aria-hidden />
{t('interactionMapMenu')}
</Button>
<Button
type="button"
variant="outline"

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

@ -1,334 +0,0 @@ @@ -1,334 +0,0 @@
import { RefreshButton } from '@/components/RefreshButton'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { useSecondaryPage } from '@/contexts/secondary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useFetchProfile } from '@/hooks/useFetchProfile'
import {
buildInteractionPartnerStats,
INTERACTION_MAP_RECENCY_MAX_AGE_SEC,
mergeEventsById,
mergeInteractionPartnersWithFollowings,
rankInteractionMapGridRows,
type TInteractionPartnerStat
} from '@/lib/profile-interaction-partners'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfile } from '@/lib/link'
import { useFollowListOptional } from '@/providers/follow-list-context'
import { useNostr } from '@/providers/NostrProvider'
import { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { kinds } from 'nostr-tools'
import type { TPageRef } from '@/types'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
const INTERACTION_KINDS = [kinds.ShortTextNote, kinds.Repost, kinds.Reaction] as const
/** Co-located with this lazy page so dev/build chunks share one `react` instance (avoids invalid hook call). */
function useProfileInteractionPartners(authorPubkey: string | undefined, refreshNonce = 0) {
const [partners, setPartners] = useState<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)
setArchiveAuthorEvents(0)
const mergedSession = mergeEventsById([...sessionEv])
setPartners(buildInteractionPartnerStats(mergedSession, pk))
void (async () => {
try {
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))
} catch {
/* best-effort disk */
}
})()
} finally {
setLoading(false)
}
}, [authorPubkey])
useEffect(() => {
void run()
}, [run, refreshNonce])
return { partners, loading, rescan: run, archiveAuthorEvents, sessionEventCount }
}
const ProfileInteractionDiagramPage = forwardRef<
TPageRef,
{ id?: string; index?: number; hideTitlebar?: boolean }
>(({ id, index, hideTitlebar = false }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const followList = useFollowListOptional()
const { profile } = useFetchProfile(id)
const [refreshNonce, setRefreshNonce] = useState(0)
const [includeAllFollows, setIncludeAllFollows] = useState(false)
const [followBusyPubkey, setFollowBusyPubkey] = useState<string | null>(null)
const bump = useCallback(() => setRefreshNonce((n) => n + 1), [])
const { partners, loading, rescan, archiveAuthorEvents, sessionEventCount } = useProfileInteractionPartners(
profile?.pubkey,
refreshNonce
)
const rankedPartners = useMemo(
() =>
rankInteractionMapGridRows(partners, {
includeAllFollows,
followings: followList?.followings ?? [],
nowSec: dayjs().unix(),
maxAgeSec: INTERACTION_MAP_RECENCY_MAX_AGE_SEC,
gridCap: 72
}),
[partners, includeAllFollows, followList?.followings]
)
const includeFollowsBreakdown = useMemo(() => {
if (!includeAllFollows) return null
const merged = mergeInteractionPartnersWithFollowings(partners, followList?.followings ?? [])
const fromTags = merged.filter((p) => p.mentionCount > 0 || p.lastReferencedAt > 0).length
return {
total: merged.length,
fromTags,
fromFollowsOnly: merged.length - fromTags
}
}, [includeAllFollows, partners, followList?.followings])
const showFollowControls = Boolean(followList && accountPubkey)
const handleFollowToggle = useCallback(
(targetPubkey: string, nextChecked: boolean) => {
if (!followList || !accountPubkey) return
if (targetPubkey.toLowerCase() === accountPubkey.toLowerCase()) return
checkLogin(async () => {
setFollowBusyPubkey(targetPubkey)
try {
if (nextChecked) await followList.follow(targetPubkey)
else await followList.unfollow(targetPubkey)
} catch (err) {
toast.error(
(nextChecked ? t('Follow failed') : t('Unfollow failed')) + ': ' + (err as Error).message
)
} finally {
setFollowBusyPubkey(null)
}
})
},
[followList, accountPubkey, checkLogin, t]
)
const layoutRef = useRef<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])
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">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<p className="text-sm text-muted-foreground flex-1 min-w-0">{t('interactionMapSubtitle')}</p>
{showFollowControls ? (
<div className="flex flex-col gap-1.5 shrink-0 sm:max-w-[min(100%,20rem)] sm:text-right">
<div className="flex items-center gap-2 sm:justify-end">
<Label htmlFor="interaction-map-include-follows" className="text-sm font-normal cursor-pointer">
{t('interactionMapIncludeFollows')}
</Label>
<Switch
id="interaction-map-include-follows"
checked={includeAllFollows}
onCheckedChange={setIncludeAllFollows}
/>
</div>
{includeAllFollows ? (
<>
<p className="text-xs text-muted-foreground sm:text-right">{t('interactionMapIncludeFollowsHint')}</p>
{includeFollowsBreakdown ? (
<p className="text-xs text-muted-foreground sm:text-right tabular-nums">
{t('interactionMapIncludeFollowsBreakdown', {
total: includeFollowsBreakdown.total,
fromTags: includeFollowsBreakdown.fromTags,
fromFollowsOnly: includeFollowsBreakdown.fromFollowsOnly
})}
</p>
) : null}
</>
) : null}
</div>
) : null}
</div>
<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>
) : rankedPartners.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">{t('interactionMapEmpty')}</div>
) : (
<div
className={
includeAllFollows
? 'max-h-[min(70vh,720px)] overflow-y-auto overscroll-contain pr-1 -mr-1'
: undefined
}
>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
{rankedPartners.map(({ stat: p, score }) => {
const heat = score / 100
const bgAlpha = 0.12 + heat * 0.55
const borderAlpha = 0.25 + heat * 0.65
const scoreRounded = Math.round(score)
const following = Boolean(
followList?.followings.some((f) => f.toLowerCase() === p.pubkey.toLowerCase())
)
const selfCard = accountPubkey?.toLowerCase() === p.pubkey.toLowerCase()
const cellTitle =
p.mentionCount > 0 && p.lastReferencedAt > 0
? `${t('interactionMapScore', { score: scoreRounded })} · ${t('interactionMapCellTitle', {
count: p.mentionCount,
when: dayjs.unix(p.lastReferencedAt).fromNow()
})}`
: `${t('interactionMapScore', { score: scoreRounded })} · ${t('interactionMapCellTitleFollowOnly')}`
return (
<div
key={p.pubkey}
className="relative isolate rounded-lg border border-border min-w-0 transition hover:opacity-95"
style={{
backgroundColor: `hsl(var(--primary) / ${bgAlpha})`,
borderColor: `hsl(var(--primary) / ${borderAlpha})`
}}
>
{/*
Avoid a native <button> filling the card: it can steal hit-testing over the follow
checkbox (Radix also uses a button), which shows the global disabled cursor and blocks toggles.
*/}
<div
role="button"
tabIndex={0}
className={`w-full min-w-0 flex flex-col items-center gap-1 text-left rounded-lg cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring p-2 ${showFollowControls && !selfCard ? 'pt-7' : ''}`}
title={cellTitle}
onClick={() => push(toProfile(p.pubkey))}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
push(toProfile(p.pubkey))
}
}}
>
<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] font-medium tabular-nums text-primary">
{t('interactionMapScore', { score: scoreRounded })}
</div>
<div className="text-[10px] text-muted-foreground tabular-nums">
{t('interactionMapMentionsShort', { count: p.mentionCount })}
</div>
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{p.lastReferencedAt > 0 ? dayjs.unix(p.lastReferencedAt).fromNow() : t('interactionMapRecencyUnknown')}
</div>
</div>
{showFollowControls && !selfCard ? (
<label
className="absolute top-1.5 right-1.5 z-30 flex cursor-pointer items-center gap-1 rounded border border-border/60 bg-background/95 px-1 py-0.5 shadow-sm backdrop-blur-[2px]"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
id={`interaction-follow-${p.pubkey}`}
checked={following}
disabled={followBusyPubkey === p.pubkey}
aria-label={t('interactionMapFollowingCheckbox')}
onCheckedChange={(v) => {
if (v === 'indeterminate') return
handleFollowToggle(p.pubkey, Boolean(v))
}}
/>
</label>
) : null}
</div>
)
})}
</div>
</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

185
src/providers/FeedProvider.tsx

@ -1,14 +1,10 @@ @@ -1,14 +1,10 @@
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { getRelaySetFromEvent, getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service'
import { TFeedInfo, TFeedType } from '@/types'
import { kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { FeedContext } from './feed-context'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { useNostr } from './NostrProvider'
@ -42,8 +38,8 @@ function buildAllFavoritesFeedRelayUrls( @@ -42,8 +38,8 @@ function buildAllFavoritesFeedRelayUrls(
}
export function FeedProvider({ children }: { children: React.ReactNode }) {
const { pubkey, isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr()
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const { isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
/**
* Extra relay URLs always merged into the all-favorites feed:
@ -67,11 +63,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -67,11 +63,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
const [relayUrls, setRelayUrls] = useState<string[]>(() =>
buildAllFavoritesFeedRelayUrls([], [], [buildWispTrendingNotesRelayUrl()])
)
const [isReady, setIsReady] = useState(true)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
feedType: 'all-favorites'
})
const feedInfoRef = useRef<TFeedInfo>(feedInfo)
/** Same logical relay policy result — reuse array ref so NoteList does not re-subscribe. */
const setRelayUrlsIfChanged = useCallback((next: string[]) => {
setRelayUrls((prev) => {
@ -80,98 +71,11 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -80,98 +71,11 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
})
}, [])
const switchFeed = useCallback(async (
feedType: TFeedType,
options: {
activeRelaySetId?: string | null
pubkey?: string | null
relay?: string | null
} = {}
) => {
logger.debug('switchFeed called:', { feedType, options })
if (feedType === 'relay') {
const normalizedUrl = normalizeAnyRelayUrl(options.relay ?? '')
const isRelayFeedUrl =
!!normalizedUrl && (isHttpRelayUrl(normalizedUrl) || isWebsocketUrl(normalizedUrl))
logger.debug('Relay switchFeed:', { normalizedUrl, isRelayFeedUrl, blockedRelays })
if (!isRelayFeedUrl) {
logger.debug('Invalid relay URL, setting isReady to true')
setIsReady(true)
return
}
// Don't allow selecting a blocked relay as feed
if (blockedRelays.includes(normalizedUrl)) {
logger.warn('Cannot select blocked relay as feed:', normalizedUrl)
setIsReady(true)
return
}
const newFeedInfo = { feedType, id: normalizedUrl }
logger.component('FeedProvider', 'Setting relay feed info', newFeedInfo)
setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo
setRelayUrlsIfChanged([normalizedUrl])
logger.component('FeedProvider', 'Set relayUrls', { relayUrls: [normalizedUrl] })
storage.setFeedInfo(newFeedInfo, pubkey)
// Reset note list mode to 'posts' when switching to relay feed to ensure main content is shown
storage.setNoteListMode('posts')
setIsReady(true)
logger.component('FeedProvider', 'Relay feed setup complete, isReady set to true')
return
}
if (feedType === 'relays') {
const relaySetId = options.activeRelaySetId ?? (relaySets.length > 0 ? relaySets[0].id : null)
if (!relaySetId || !pubkey) {
setIsReady(true)
return
}
let relaySet =
relaySets.find((set) => set.id === relaySetId) ??
(relaySets.length > 0 ? relaySets[0] : null)
if (!relaySet) {
const storedRelaySetEvent = await indexedDb.getReplaceableEvent(
pubkey,
kinds.Relaysets,
relaySetId
)
if (storedRelaySetEvent) {
relaySet = getRelaySetFromEvent(storedRelaySetEvent, blockedRelays)
}
}
if (relaySet) {
const newFeedInfo = { feedType, id: relaySet.id }
setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo
setRelayUrlsIfChanged(relaySet.relayUrls)
storage.setFeedInfo(newFeedInfo, pubkey)
// Reset note list mode to 'posts' when switching to relay set to ensure main content is shown
storage.setNoteListMode('posts')
setIsReady(true)
}
setIsReady(true)
return
}
if (feedType === 'all-favorites') {
const updateFeedRelayUrls = useCallback(() => {
const finalRelays = buildAllFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, extraFeedRelayUrls)
logger.debug('Switching to all-favorites, finalRelays:', finalRelays)
const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo
logger.debug('Updating all-favorites relay URLs:', finalRelays)
setRelayUrlsIfChanged(finalRelays)
storage.setFeedInfo(newFeedInfo, pubkey)
// Reset note list mode to 'posts' when switching to all-favorites to ensure main content is shown
storage.setNoteListMode('posts')
setIsReady(true)
return
}
setIsReady(true)
}, [pubkey, favoriteRelays, blockedRelays, relaySets, extraFeedRelayUrls, setRelayUrlsIfChanged])
const switchFeedRef = useRef(switchFeed)
switchFeedRef.current = switchFeed
}, [favoriteRelays, blockedRelays, extraFeedRelayUrls, setRelayUrlsIfChanged])
const favoriteRelaysIdentity = useMemo(
() =>
@ -191,79 +95,24 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -191,79 +95,24 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
.join('|'),
[blockedRelays]
)
const relaySetsIdentity = useMemo(
() =>
relaySets
.map((s) => {
const urls = [...s.relayUrls]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join(',')
return `${s.id}:${urls}`
})
.sort()
.join('\n'),
[relaySets]
)
useEffect(() => {
const init = async () => {
logger.debug('FeedProvider init:', { isInitialized, pubkey, favoriteRelays: favoriteRelays.length, blockedRelays: blockedRelays.length })
// Wait for favoriteRelays to be initialized (should have at least default relays)
// If favoriteRelays is empty, it might not be initialized yet, so wait
if (favoriteRelays.length === 0 && !pubkey) {
// For anonymous users, favoriteRelays should be initialized from FAST_READ_RELAY_URLS
// If it's still empty, something is wrong, but we'll use defaults
logger.debug('FeedProvider: favoriteRelays is empty, using defaults')
}
let stored: TFeedInfo | null = null
if (pubkey) {
const fromStorage = storage.getFeedInfo(pubkey)
logger.debug('Stored feed info:', fromStorage)
if (fromStorage) stored = fromStorage
}
const storedFeedType = (stored as { feedType?: string } | null)?.feedType
const migrateHomeToCombo =
storedFeedType === 'following' ||
storedFeedType === 'bookmarks' ||
storedFeedType === 'relay' ||
storedFeedType === 'relays'
if (migrateHomeToCombo && pubkey) {
const migrated: TFeedInfo = { feedType: 'all-favorites' }
storage.setFeedInfo(migrated, pubkey)
logger.info('[FeedProvider] Home feed uses combo (all-favorites); migrated stored selection', {
previous: storedFeedType
logger.debug('FeedProvider relay init:', {
isInitialized,
favoriteRelays: favoriteRelays.length,
blockedRelays: blockedRelays.length
})
}
return await switchFeedRef.current('all-favorites')
if (favoriteRelays.length === 0) {
logger.debug('FeedProvider: favoriteRelays is empty, using defaults')
}
void init()
}, [pubkey, isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, relaySetsIdentity])
// Update relay URLs when favoriteRelays, blocked, or extra relay lists change while in all-favorites mode
useEffect(() => {
if (feedInfo.feedType !== 'all-favorites') return
const finalRelays = buildAllFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, extraFeedRelayUrls)
logger.debug('Updating relay URLs for all-favorites:', finalRelays)
// Same logical list can be merged into a new array each run; keep the previous reference so
// feed consumers (RelaysFeed → NoteList relay subscription) do not re-enter effects in a tight loop.
setRelayUrlsIfChanged(finalRelays)
}, [feedInfo.feedType, favoriteRelays, blockedRelays, extraFeedRelayUrls, setRelayUrlsIfChanged])
updateFeedRelayUrls()
}, [isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, updateFeedRelayUrls])
return (
<FeedContext.Provider
value={{
feedInfo,
relayUrls,
isReady,
switchFeed
relayUrls
}}
>
{children}

11
src/providers/feed-context.tsx

@ -2,21 +2,10 @@ @@ -2,21 +2,10 @@
* Standalone React context for feed state so HMR on `FeedProvider.tsx` does not recreate
* `createContext()` (which breaks `useFeed` after Fast Refresh).
*/
import { TFeedInfo, TFeedType } from '@/types'
import { createContext, useContext } from 'react'
export type TFeedContext = {
feedInfo: TFeedInfo
relayUrls: string[]
isReady: boolean
switchFeed: (
feedType: TFeedType,
options?: {
activeRelaySetId?: string | null
pubkey?: string | null
relay?: string | null
}
) => Promise<void>
}
export const FeedContext = createContext<TFeedContext | undefined>(undefined)

2
src/routes.tsx

@ -22,7 +22,6 @@ const PostSettingsPageLazy = lazy(() => import('./pages/secondary/PostSettingsPa @@ -22,7 +22,6 @@ 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'))
@ -73,7 +72,6 @@ const ROUTES = [ @@ -73,7 +72,6 @@ const ROUTES = [
{ path: '/users', element: SR(ProfileListPageLazy) },
{ 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) },

6
src/services/client-query.service.ts

@ -38,7 +38,8 @@ import type { ISigner, TSignerType } from '@/types' @@ -38,7 +38,8 @@ import type { ISigner, TSignerType } from '@/types'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
if (relaySupportsSearch) return f
const { search: _search, ...rest } = f
const rest = { ...f }
delete rest.search
return rest as Filter
}
@ -357,7 +358,6 @@ export class QueryService { @@ -357,7 +358,6 @@ export class QueryService {
let feedFirstResultGraceTimeoutId: ReturnType<typeof setTimeout> | null = null
let replaceableRaceTimeoutId: ReturnType<typeof setTimeout> | null = null
let allEosed = false
let eventCount = 0
let resolved = false
let firstResultTime: number | null = null
let globalTimeoutId: ReturnType<typeof setTimeout> | null = null
@ -372,7 +372,6 @@ export class QueryService { @@ -372,7 +372,6 @@ export class QueryService {
const evts = await queryIndexRelay(base, effectiveFilter, { signal: abortHttp.signal })
for (const evt of evts) {
if (resolved) return
eventCount++
onevent?.(evt)
events.push(evt)
this.trackEventSeenOnByUrl(evt.id, base)
@ -461,7 +460,6 @@ export class QueryService { @@ -461,7 +460,6 @@ export class QueryService {
effectiveFilter,
{
onevent: (evt) => {
eventCount++
onevent?.(evt)
events.push(evt)
// Session cache: ingest as events arrive (reactions/replies/zaps from note-stats, etc.),

7
src/services/client.service.ts

@ -37,7 +37,8 @@ import { @@ -37,7 +37,8 @@ import {
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
if (relaySupportsSearch) return f
const { search: _search, ...rest } = f
const rest = { ...f }
delete rest.search
return rest as Filter
}
@ -1004,7 +1005,7 @@ class ClientService extends EventTarget { @@ -1004,7 +1005,7 @@ class ClientService extends EventTarget {
}
const bootstrapExtras: string[] = [...(additionalRelayUrls ?? [])]
let authorInboxFromContext: string[] = []
const authorInboxFromContext: string[] = []
const shouldMergeContextInboxes =
!specifiedRelayUrls?.length &&
![kinds.Contacts, kinds.Mutelist, ExtendedKind.FOLLOW_SET].includes(event.kind)
@ -3140,7 +3141,7 @@ class ClientService extends EventTarget { @@ -3140,7 +3141,7 @@ class ClientService extends EventTarget {
return
}
const chunk = followings.slice(i * chunkSize, (i + 1) * chunkSize)
const [relayListEvents, contactsEvents, _profiles] = await Promise.all([
const [relayListEvents, contactsEvents] = await Promise.all([
this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(chunk, kinds.RelayList),
this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(chunk, kinds.Contacts),
Promise.all(chunk.map((pk) => this.fetchProfileEvent(pk)))

21
src/services/local-storage.service.ts

@ -12,7 +12,6 @@ import { randomString } from '@/lib/random' @@ -12,7 +12,6 @@ import { randomString } from '@/lib/random'
import {
TAccount,
TAccountPointer,
TFeedInfo,
TFontSize,
TMediaAutoLoadPolicy,
TMediaUploadServiceConfig,
@ -38,7 +37,6 @@ const SETTINGS_KEYS = [ @@ -38,7 +37,6 @@ const SETTINGS_KEYS = [
StorageKey.DEFAULT_ZAP_COMMENT,
StorageKey.QUICK_ZAP,
StorageKey.ZAP_REPLY_THRESHOLD,
StorageKey.ACCOUNT_FEED_INFO_MAP,
StorageKey.AUTOPLAY,
StorageKey.HIDE_UNTRUSTED_INTERACTIONS,
StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS,
@ -85,7 +83,6 @@ class LocalStorageService { @@ -85,7 +83,6 @@ class LocalStorageService {
private defaultZapComment: string = 'Zap!'
private quickZap: boolean = false
private zapReplyThreshold: number = 1
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true
private hideUntrustedInteractions: boolean = false
@ -191,10 +188,6 @@ class LocalStorageService { @@ -191,10 +188,6 @@ class LocalStorageService {
}
}
const accountFeedInfoMapStr =
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
// deprecated
this.mediaUploadService =
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
@ -574,8 +567,6 @@ class LocalStorageService { @@ -574,8 +567,6 @@ class LocalStorageService {
const num = parseInt(zapReplyStr)
if (!isNaN(num)) this.zapReplyThreshold = num
}
const accountFeedInfoStr = get(StorageKey.ACCOUNT_FEED_INFO_MAP)
if (accountFeedInfoStr != null) this.accountFeedInfoMap = JSON.parse(accountFeedInfoStr) as Record<string, TFeedInfo | undefined>
this.autoplay = get(StorageKey.AUTOPLAY) !== 'false'
const hideInteractions = get(StorageKey.HIDE_UNTRUSTED_INTERACTIONS)
if (hideInteractions != null) this.hideUntrustedInteractions = hideInteractions === 'true'
@ -792,18 +783,6 @@ class LocalStorageService { @@ -792,18 +783,6 @@ class LocalStorageService {
this.persistSetting(StorageKey.ZAP_REPLY_THRESHOLD, sats.toString())
}
getFeedInfo(pubkey: string) {
return this.accountFeedInfoMap[pubkey]
}
setFeedInfo(info: TFeedInfo, pubkey?: string | null) {
this.accountFeedInfoMap[pubkey ?? 'default'] = info
this.persistSetting(
StorageKey.ACCOUNT_FEED_INFO_MAP,
JSON.stringify(this.accountFeedInfoMap)
)
}
getAutoplay() {
return this.autoplay
}

3
src/services/navigation.service.ts

@ -41,7 +41,6 @@ export type ViewType = @@ -41,7 +41,6 @@ export type ViewType =
| 'hashtag'
| 'relay'
| 'following'
| 'profile-interactions'
| 'mute'
| 'bookmarks'
| 'pins'
@ -277,10 +276,8 @@ export class NavigationService { @@ -277,10 +276,8 @@ 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') {

3
src/types/index.d.ts vendored

@ -168,9 +168,6 @@ export type TAccount = { @@ -168,9 +168,6 @@ export type TAccount = {
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
export type TFeedType = 'relays' | 'relay' | 'all-favorites'
export type TFeedInfo = { feedType: TFeedType; id?: string }
export type TImetaInfo = {
url: string
blurHash?: string

Loading…
Cancel
Save