Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
43f171d37b
  1. 141
      src/components/Explore/ExploreRelayReviews.tsx
  2. 85
      src/components/NoteList/index.tsx
  3. 2
      src/hooks/useFetchProfile.tsx
  4. 8
      src/lib/event.ts
  5. 7
      src/lib/notification.ts
  6. 22
      src/lib/pubkey.ts
  7. 6
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  8. 251
      src/pages/primary/SpellsPage/index.tsx
  9. 12
      src/providers/NostrProvider/index.tsx
  10. 22
      src/providers/NotificationProvider.tsx
  11. 6
      src/services/client-replaceable-events.service.ts
  12. 69
      src/services/client.service.ts

141
src/components/Explore/ExploreRelayReviews.tsx

@ -1,50 +1,133 @@ @@ -1,50 +1,133 @@
import NoteList from '@/components/NoteList'
import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata'
import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
import {
getRelayUrlFromRelayReviewEvent,
getStarsFromRelayReviewEvent
} from '@/lib/event-metadata'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { useCallback, useMemo } from 'react'
import client from '@/services/client.service'
import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const REVIEW_QUERY_LIMIT = 100
const SHOW_COUNT = 20
function dedupeRelayReviewsNewestFirst(events: Event[]): Event[] {
const sorted = [...events].sort((a, b) => b.created_at - a.created_at)
const seen = new Set<string>()
const out: Event[] = []
for (const evt of sorted) {
const key = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
if (seen.has(key)) continue
seen.add(key)
out.push(evt)
}
return out
}
export default function ExploreRelayReviews() {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { relayList } = useNostr()
const relayUrls = useMemo(
() =>
getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] }
appendCuratedReadOnlyRelays(
getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] }
),
blockedRelays
),
[favoriteRelays, blockedRelays, relayList]
)
const subRequests = useMemo(() => [{ urls: relayUrls, filter: {} }], [relayUrls])
const relayUrlsKey = useMemo(() => relayUrls.join('|'), [relayUrls])
const [loading, setLoading] = useState(true)
const [events, setEvents] = useState<Event[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setEvents([])
setShowCount(SHOW_COUNT)
void (async () => {
try {
const raw = await client.fetchEvents(
relayUrls,
{ kinds: [ExtendedKind.RELAY_REVIEW], limit: REVIEW_QUERY_LIMIT },
{
firstRelayResultGraceMs: false,
globalTimeout: 12_000,
eoseTimeout: 800,
cache: true
}
)
if (cancelled) return
const withRelay = raw.filter(
(e) => e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)
)
setEvents(dedupeRelayReviewsNewestFirst(withRelay))
} catch {
if (!cancelled) setEvents([])
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [relayUrlsKey])
useEffect(() => {
const options = { root: null, rootMargin: '120px', threshold: 0 }
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && showCount < events.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const el = bottomRef.current
if (el) observer.observe(el)
return () => {
if (el) observer.unobserve(el)
}
}, [showCount, events.length])
const extraShouldHideEvent = useCallback((evt: Event) => {
if (evt.kind !== ExtendedKind.RELAY_REVIEW) return false
if (!getRelayUrlFromRelayReviewEvent(evt)) return true
return !getStarsFromRelayReviewEvent(evt)
}, [])
const visible = events.slice(0, showCount)
return (
<div className="min-w-0 pt-1">
<NoteList
feedSubscriptionKey="explore-relay-reviews"
preserveTimelineOnSubRequestsChange
showKinds={[ExtendedKind.RELAY_REVIEW]}
subRequests={subRequests}
showKind1OPs={false}
showKind1Replies={false}
showKind1111={false}
extraShouldHideEvent={extraShouldHideEvent}
/>
<div className="min-w-0 pt-1 pb-8">
{loading ? (
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-lg border md:border" />
))}
</div>
) : events.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('no relays found')}</p>
) : (
<>
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3">
{visible.map((event) => (
<RelayReviewCard key={event.id} event={event} className="border-b md:border md:border-border" />
))}
</div>
{showCount < events.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null}
{showCount >= events.length ? (
<p className="mt-3 text-center text-sm text-muted-foreground">{t('no more relays')}</p>
) : null}
</>
)}
</div>
)
}

85
src/components/NoteList/index.tsx

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
import NewNotesButton from '@/components/NewNotesButton'
import { Button } from '@/components/ui/button'
import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants'
import {
collectEmbeddedEventPrefetchTargets,
@ -13,7 +12,6 @@ import { @@ -13,7 +12,6 @@ import {
isRelayUrlStrictSupersetIdentityKey,
stableSpellFeedFilterKey
} from '@/lib/spell-feed-request-identity'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { normalizeUrl } from '@/lib/url'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils'
@ -45,9 +43,11 @@ import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/prov @@ -45,9 +43,11 @@ import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/prov
import type { TProfile } from '@/types'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 500 // Increased from 200 to load more events per request
const ALGO_LIMIT = 1000 // Increased from 500 for algorithm feeds
const SHOW_COUNT = 50 // Increased from 10 to show more events at once, reducing scroll load frequency
const LIMIT = 100 // Increased from 200 to load more events per request
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds
const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency
/** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */
const ONE_SHOT_MERGED_CAP =100
const FEED_PROFILE_BATCH_DEBOUNCE_MS = 120
const FEED_PROFILE_CHUNK = 36
@ -93,7 +93,13 @@ const NoteList = forwardRef( @@ -93,7 +93,13 @@ const NoteList = forwardRef(
/** Spells page: bumps when user picks a feed; used with {@link onSpellFeedFirstPaint}. */
spellFeedInstrumentToken,
/** Spells page: fired once when the filtered list first has rows after a picker change. */
onSpellFeedFirstPaint
onSpellFeedFirstPaint,
/**
* When true, load events with parallel {@link client.fetchEvents} per subRequest instead of
* {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells
* (except Following). Refresh re-fetches.
*/
oneShotFetch = false
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
@ -115,11 +121,12 @@ const NoteList = forwardRef( @@ -115,11 +121,12 @@ const NoteList = forwardRef(
spellFetchTimeoutMs?: number
spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
oneShotFetch?: boolean
},
ref
) => {
const { t } = useTranslation()
const { startLogin, pubkey, relayList } = useNostr()
const { startLogin, pubkey } = useNostr()
const { isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -185,8 +192,10 @@ const NoteList = forwardRef( @@ -185,8 +192,10 @@ const NoteList = forwardRef(
useLayoutEffect(() => {
const candidates = new Set<string>()
const addPk = (p: string | undefined) => {
if (p && p.length === 64 && /^[0-9a-f]{64}$/.test(p)) {
candidates.add(p)
if (!p) return
const t = p.trim()
if (t.length === 64 && /^[0-9a-f]{64}$/i.test(t)) {
candidates.add(t.toLowerCase())
}
}
for (const e of events) {
@ -431,12 +440,9 @@ const NoteList = forwardRef( @@ -431,12 +440,9 @@ const NoteList = forwardRef(
const refresh = useCallback(() => {
scrollToTop()
setTimeout(() => {
void (async () => {
await syncUserDeletionTombstones(pubkey, relayList)
setRefreshCount((count) => count + 1)
})()
setRefreshCount((count) => count + 1)
}, 500)
}, [pubkey, relayList, scrollToTop])
}, [scrollToTop])
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
@ -515,6 +521,48 @@ const NoteList = forwardRef( @@ -515,6 +521,48 @@ const NoteList = forwardRef(
return () => {}
}
if (oneShotFetch) {
if (!keepExistingTimelineEvents) {
setEvents([])
setNewEvents([])
}
setHasMore(false)
try {
const batches = await Promise.all(
mappedSubRequests.map(({ urls, filter }) =>
client.fetchEvents(urls, filter, {
firstRelayResultGraceMs: false,
globalTimeout: 14_000,
eoseTimeout: 800,
cache: true
})
)
)
if (!effectActive) return undefined
const byId = new Map<string, Event>()
for (const ev of batches.flat()) {
const prev = byId.get(ev.id)
if (!prev || ev.created_at > prev.created_at) {
byId.set(ev.id, ev)
}
}
const merged = [...byId.values()]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, ONE_SHOT_MERGED_CAP)
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
} catch {
if (effectActive) setEvents([])
} finally {
if (effectActive) {
setLoading(false)
setHasMore(false)
setTimelineKey(undefined)
}
}
return undefined
}
const totalRelayUrls = mappedSubRequests.reduce((n, r) => n + r.urls.length, 0)
// Explore-style feeds merge many read relays; subscribeTimeline awaits every ensureRelay — 5s often loses the race.
const subscribeSetupRaceMs = totalRelayUrls > 24 ? 30_000 : 5000
@ -683,7 +731,8 @@ const NoteList = forwardRef( @@ -683,7 +731,8 @@ const NoteList = forwardRef(
showKind1111,
useFilterAsIs,
areAlgoRelays,
spellFetchTimeoutMs
spellFetchTimeoutMs,
oneShotFetch
])
useEffect(() => {
@ -1092,11 +1141,7 @@ const NoteList = forwardRef( @@ -1092,11 +1141,7 @@ const NoteList = forwardRef(
) : events.length > 0 ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
) : (
<div className="flex justify-center w-full mt-2">
<Button size="lg" onClick={() => setRefreshCount((count) => count + 1)}>
{t('reload notes')}
</Button>
</div>
<div ref={bottomRef} className="mt-2 min-h-4" aria-hidden />
)}
</div>
)

2
src/hooks/useFetchProfile.tsx

@ -212,7 +212,7 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -212,7 +212,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
} catch (err) {
const isTimeout = err instanceof Error && err.message.includes('timeout')
if (isTimeout) {
logger.warn('[useFetchProfile] Profile fetch timed out', {
logger.debug('[useFetchProfile] Profile fetch timed out', {
pubkey: pubkey.substring(0, 8),
error: err.message
})

8
src/lib/event.ts

@ -6,6 +6,7 @@ import { TImetaInfo } from '@/types' @@ -6,6 +6,7 @@ import { TImetaInfo } from '@/types'
import { LRUCache } from 'lru-cache'
import { Event, getEventHash, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { getPow } from 'nostr-tools/nip13'
import { hexPubkeysEqual, normalizeHexPubkey } from './pubkey'
import {
generateBech32IdFromATag,
generateBech32IdFromETag,
@ -322,10 +323,11 @@ export function getEmbeddedPubkeys(event: Event) { @@ -322,10 +323,11 @@ export function getEmbeddedPubkeys(event: Event) {
* Events authored by the user are excluded (not treated as incoming mentions).
*/
export function isUserInEventMentions(event: Event, userPubkey: string): boolean {
if (event.pubkey === userPubkey) return false
const inPtags = event.tags.some((t) => t[0] === 'p' && t[1] === userPubkey)
const u = normalizeHexPubkey(userPubkey)
if (hexPubkeysEqual(event.pubkey, u)) return false
const inPtags = event.tags.some((t) => t[0] === 'p' && t[1] && hexPubkeysEqual(t[1], u))
if (inPtags) return true
return getEmbeddedPubkeys(event).includes(userPubkey)
return getEmbeddedPubkeys(event).some((pk) => hexPubkeysEqual(pk, u))
}
export function getLatestEvent(events: Event[]): Event | undefined {

7
src/lib/notification.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { kinds, NostrEvent } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { isMentioningMutedUsers } from './event'
import { tagNameEquals } from './tag'
@ -29,12 +30,14 @@ export function notificationFilter( @@ -29,12 +30,14 @@ export function notificationFilter(
if (pubkey && event.kind === kinds.Reaction) {
const targetPubkey = event.tags.findLast(tagNameEquals('p'))?.[1]
if (targetPubkey !== pubkey) return false
if (!targetPubkey || !hexPubkeysEqual(targetPubkey, pubkey)) return false
}
// For PUBLIC_MESSAGE (kind 24) events, ensure the user is in the 'p' tags
if (pubkey && event.kind === ExtendedKind.PUBLIC_MESSAGE) {
const hasUserInPTags = event.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey)
const hasUserInPTags = event.tags.some(
(tag) => tag[0] === 'p' && tag[1] && hexPubkeysEqual(tag[1], pubkey)
)
if (!hasUserInPTags) return false
}

22
src/lib/pubkey.ts

@ -52,9 +52,31 @@ export function userIdToPubkey(userId: string) { @@ -52,9 +52,31 @@ export function userIdToPubkey(userId: string) {
logger.error('Error decoding userId', { userId, error })
}
}
const trimmed = userId.trim()
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase()
}
return userId
}
/** Lowercase 64-char hex pubkeys for stable Maps, REQ filters, and tag comparison. */
export function normalizeHexPubkey(pubkey: string): string {
const t = pubkey.trim()
return /^[0-9a-f]{64}$/i.test(t) ? t.toLowerCase() : t
}
export function hexPubkeysEqual(a: string, b: string): boolean {
if (a === b) return true
const na = normalizeHexPubkey(a)
const nb = normalizeHexPubkey(b)
return (
na.length === 64 &&
nb.length === 64 &&
/^[0-9a-f]{64}$/.test(na) &&
na === nb
)
}
export function isValidPubkey(pubkey: string) {
return /^[0-9a-f]{64}$/.test(pubkey)
}

6
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
/**
* Built-in faux spells use the same NoteList path as kind-777 REQ spells.
* Built-in faux spells: same NoteList + filters as kind-777 spells; except Following, feeds use one-shot
* `fetchEvents` per subRequest (see NoteList `oneShotFetch`) instead of a live timeline subscription.
*/
import { ExtendedKind, PROFILE_FEED_KINDS, READ_ONLY_RELAY_URLS } from '@/constants'
import {
@ -139,10 +140,11 @@ export function mediaSpellExtraShouldHideEvent(evt: Event): boolean { @@ -139,10 +140,11 @@ export function mediaSpellExtraShouldHideEvent(evt: Event): boolean {
/** Notifications spell: same kind set as profile-style feeds, restricted to `#p` = you on the relay. */
export function buildMentionsSpellFilter(pubkey: string): Filter {
const pk = /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim()
return {
kinds: [...PROFILE_FEED_KINDS],
limit: NOTIFICATION_LIMIT,
'#p': [pubkey]
'#p': [pk]
}
}

251
src/pages/primary/SpellsPage/index.tsx

@ -274,6 +274,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -274,6 +274,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const spellCatalogCloserRef = useRef<(() => void) | null>(null)
/** Bumps spell catalog relay re-sync when the user taps refresh in the titlebar. */
const [spellCatalogManualRefreshKey, setSpellCatalogManualRefreshKey] = useState(0)
/** Last processed {@link spellCatalogManualRefreshKey} so we only treat real bumps as “force sync”. */
const spellCatalogLastManualKeyRef = useRef(0)
const spellFeedListRef = useRef<TNoteListRef>(null)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [spellPickerOpen, setSpellPickerOpen] = useState(false)
@ -386,15 +388,41 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -386,15 +388,41 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
[blockedRelays]
)
/**
* Kind-777 list for the dropdown. When opening with `?spell=…` (faux name, hex id, nevent, etc.), defer
* this IndexedDB read so the feed can subscribe and paint first; the header already reflects the URL.
*/
useEffect(() => {
loadSpells()
}, [loadSpells])
let cancelled = false
const run = () => {
if (!cancelled) void loadSpells()
}
let idleId: number | undefined
let timeoutId: ReturnType<typeof setTimeout> | undefined
if (spellProp?.trim()) {
if (typeof requestIdleCallback !== 'undefined') {
idleId = requestIdleCallback(run, { timeout: 2500 })
} else {
timeoutId = setTimeout(run, 0)
}
} else {
run()
}
return () => {
cancelled = true
if (idleId !== undefined) cancelIdleCallback(idleId)
if (timeoutId !== undefined) clearTimeout(timeoutId)
}
}, [loadSpells, spellProp])
/** Stable key so we re-sync when the follow list changes (not only on array identity). */
const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts])
/**
* After showing the cache, pull kind 777 using the same relay set as the favorites feed.
* Pull kind 777 from relays only when IndexedDB has no spells yet, or when the user requests refresh.
* Otherwise the picker uses {@link loadSpells} from cache only (no extra REQ on each visit / relay churn).
*/
useEffect(() => {
if (!pubkey) {
@ -404,6 +432,16 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -404,6 +432,16 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
let cancelled = false
spellCatalogCloserRef.current = null
let loadSpellsDebounce: ReturnType<typeof setTimeout> | null = null
let delayId: ReturnType<typeof setTimeout> | null = null
let syncTimeout: ReturnType<typeof setTimeout> | null = null
let afterFirstBatchTimer: ReturnType<typeof setTimeout> | null = null
const clearAfterFirstBatchTimer = () => {
if (afterFirstBatchTimer != null) {
clearTimeout(afterFirstBatchTimer)
afterFirstBatchTimer = null
}
}
const scheduleLoadSpells = () => {
if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce)
loadSpellsDebounce = setTimeout(() => {
@ -411,115 +449,125 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -411,115 +449,125 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (!cancelled) void loadSpells()
}, 120)
}
const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, relayList?.read ?? [], {
userWriteRelays: relayList?.write ?? []
})
const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts)
const authorAllowlist = new Set(catalogAuthors)
const filter = {
kinds: [ExtendedKind.SPELL],
authors: catalogAuthors,
limit: contacts.length > 0 ? SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS : SPELL_CATALOG_SYNC_LIMIT
}
const syncTimeout = window.setTimeout(() => {
void (async () => {
const manualBump = spellCatalogManualRefreshKey !== spellCatalogLastManualKeyRef.current
if (manualBump) {
spellCatalogLastManualKeyRef.current = spellCatalogManualRefreshKey
}
const cachedSpells = await indexedDb.getSpellEvents()
if (cancelled) return
logger.warn('[SpellsPage] Spell catalog sync timed out')
spellCatalogCloserRef.current?.()
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false)
}, SPELL_CATALOG_SYNC_TIMEOUT_MS)
let afterFirstBatchTimer: ReturnType<typeof setTimeout> | null = null
let catalogSyncDone = false
const clearAfterFirstBatchTimer = () => {
if (afterFirstBatchTimer != null) {
clearTimeout(afterFirstBatchTimer)
afterFirstBatchTimer = null
const shouldSyncFromRelays = manualBump || cachedSpells.length === 0
if (!shouldSyncFromRelays) {
return
}
}
/** Defer catalog REQ so faux/kind-777 feed opens sockets and paints first. */
const catalogDelayMs = 800
const delayId = window.setTimeout(() => {
const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, relayList?.read ?? [], {
userWriteRelays: relayList?.write ?? []
})
const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts)
const authorAllowlist = new Set(catalogAuthors)
const filter = {
kinds: [ExtendedKind.SPELL],
authors: catalogAuthors,
limit: contacts.length > 0 ? SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS : SPELL_CATALOG_SYNC_LIMIT
}
syncTimeout = setTimeout(() => {
if (cancelled) return
logger.warn('[SpellsPage] Spell catalog sync timed out')
spellCatalogCloserRef.current?.()
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false)
}, SPELL_CATALOG_SYNC_TIMEOUT_MS)
let catalogSyncDone = false
/** Defer catalog REQ so faux/kind-777 feed opens sockets and paints first. */
const catalogDelayMs = 800
if (cancelled) return
void (async () => {
try {
setSpellsCatalogSyncing(true)
const { closer } = await client.subscribeTimeline(
[{ urls, filter }],
{
onEvents: async (events, eosed) => {
if (cancelled) return
let wrote = false
for (const ev of events) {
if (cancelled) return
if (!verifyEvent(ev) || !isSpellEvent(ev) || !authorAllowlist.has(ev.pubkey)) continue
try {
await indexedDb.putSpellEvent(ev)
wrote = true
} catch (e) {
logger.warn('[SpellsPage] Failed to cache spell from relay', e)
}
}
if (wrote) scheduleLoadSpells()
if (wrote && afterFirstBatchTimer == null) {
afterFirstBatchTimer = setTimeout(() => {
afterFirstBatchTimer = null
if (cancelled || catalogSyncDone) return
catalogSyncDone = true
window.clearTimeout(syncTimeout)
if (loadSpellsDebounce != null) {
clearTimeout(loadSpellsDebounce)
loadSpellsDebounce = null
delayId = setTimeout(() => {
if (cancelled) return
void (async () => {
try {
setSpellsCatalogSyncing(true)
const { closer } = await client.subscribeTimeline(
[{ urls, filter }],
{
onEvents: async (events, eosed) => {
if (cancelled) return
let wrote = false
for (const ev of events) {
if (cancelled) return
if (!verifyEvent(ev) || !isSpellEvent(ev) || !authorAllowlist.has(ev.pubkey)) continue
try {
await indexedDb.putSpellEvent(ev)
wrote = true
} catch (e) {
logger.warn('[SpellsPage] Failed to cache spell from relay', e)
}
}
if (wrote) scheduleLoadSpells()
if (wrote && afterFirstBatchTimer == null) {
afterFirstBatchTimer = setTimeout(() => {
afterFirstBatchTimer = null
if (cancelled || catalogSyncDone) return
catalogSyncDone = true
if (syncTimeout != null) clearTimeout(syncTimeout)
if (loadSpellsDebounce != null) {
clearTimeout(loadSpellsDebounce)
loadSpellsDebounce = null
}
void (async () => {
if (!cancelled) await loadSpells()
if (!cancelled) setSpellsCatalogSyncing(false)
})()
closer()
spellCatalogCloserRef.current = null
}, FIRST_RELAY_RESULT_GRACE_MS)
}
void (async () => {
if (eosed) {
clearAfterFirstBatchTimer()
if (cancelled || catalogSyncDone) return
catalogSyncDone = true
if (syncTimeout != null) clearTimeout(syncTimeout)
if (loadSpellsDebounce != null) {
clearTimeout(loadSpellsDebounce)
loadSpellsDebounce = null
}
if (!cancelled) await loadSpells()
if (!cancelled) setSpellsCatalogSyncing(false)
})()
closer()
spellCatalogCloserRef.current = null
}, FIRST_RELAY_RESULT_GRACE_MS)
}
if (eosed) {
clearAfterFirstBatchTimer()
if (cancelled || catalogSyncDone) return
catalogSyncDone = true
window.clearTimeout(syncTimeout)
if (loadSpellsDebounce != null) {
clearTimeout(loadSpellsDebounce)
loadSpellsDebounce = null
}
if (!cancelled) await loadSpells()
if (!cancelled) setSpellsCatalogSyncing(false)
closer()
spellCatalogCloserRef.current = null
closer()
spellCatalogCloserRef.current = null
}
},
onNew: () => {} // Not needed
},
{
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS
}
},
onNew: () => {} // Not needed
},
{
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS
)
if (cancelled) {
closer()
return
}
spellCatalogCloserRef.current = closer
} catch (e) {
if (syncTimeout != null) clearTimeout(syncTimeout)
logger.warn('[SpellsPage] Spell catalog subscribe failed', e)
if (!cancelled) setSpellsCatalogSyncing(false)
}
)
if (cancelled) {
closer()
return
}
spellCatalogCloserRef.current = closer
} catch (e) {
window.clearTimeout(syncTimeout)
logger.warn('[SpellsPage] Spell catalog subscribe failed', e)
if (!cancelled) setSpellsCatalogSyncing(false)
}
})()
}, catalogDelayMs)
})()
}, catalogDelayMs)
})()
return () => {
cancelled = true
window.clearTimeout(delayId)
clearAfterFirstBatchTimer()
if (delayId != null) clearTimeout(delayId)
if (syncTimeout != null) clearTimeout(syncTimeout)
if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce)
window.clearTimeout(syncTimeout)
spellCatalogCloserRef.current?.()
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false)
@ -627,8 +675,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -627,8 +675,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [{ urls: feedUrls, filter: buildMentionsSpellFilter(pubkey) }]
}
if (selectedFauxSpell === 'discussions') {
if (!feedUrls.length) return []
return [{ urls: feedUrls, filter: buildDiscussionFilter() }]
// Same as followPacks: prioritized stack is capped (MAX_REQ_RELAY_URLS); tier-4 FAST_READ
// (incl. aggr) is often dropped when inbox + favorites fill the cap. Append read-only aggr so
// kind-11 discussions still resolve; also recover when feedUrls is empty (all blocked / no list).
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
if (!urls.length) return []
return [{ urls, filter: buildDiscussionFilter() }]
}
if (selectedFauxSpell === 'media') {
if (!feedUrls.length) return []
@ -1279,6 +1331,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1279,6 +1331,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
spellFeedInstrumentToken={spellFeedInstrumentToken}
onSpellFeedFirstPaint={handleSpellFeedFirstPaint}
useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={selectedFauxSpell !== 'following'}
showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true}
showKind1Replies={selectedFauxSpell === 'following' ? showKind1Replies : true}
showKind1111={selectedFauxSpell === 'following' ? showKind1111 : true}

12
src/providers/NostrProvider/index.tsx

@ -377,18 +377,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -377,18 +377,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const mergedRelayList = await client.fetchRelayList(account.pubkey) // Keep using client for relay list merging
setRelayList(mergedRelayList)
const deletionRelayUrls = Array.from(
new Set([
...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url),
...mergedRelayList.read.slice(0, 8).map((url: string) => normalizeUrl(url) || url),
...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url),
])
).slice(0, 20)
client.fetchDeletionEvents(deletionRelayUrls, account.pubkey).catch((err) =>
logger.warn('[NostrProvider] Failed to sync deletion events / tombstones', { error: err })
)
const normalizedRelays = [
...relayList.write.map((url: string) => normalizeUrl(url) || url),
...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url)

22
src/providers/NotificationProvider.tsx

@ -89,6 +89,26 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -89,6 +89,26 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
}
let discussionEosed = false
let initialBufferFlushed = false
const flushBufferedIfReady = () => {
if (
!eosed ||
!discussionEosed ||
!isMountedRef.current ||
initialBufferFlushed
) {
return
}
initialBufferFlushed = true
const buf = notificationBufferRef.current
if (buf.length === 0) return
const sorted = [...buf].sort((a, b) => compareEvents(b, a))
notificationBufferRef.current = sorted.slice(0, 50)
for (const evt of sorted) {
client.emitNewEvent(evt)
}
}
const discussionSubCloser = client.subscribe(
notificationRelays,
[
@ -101,6 +121,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -101,6 +121,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
oneose: (e) => {
if (e) {
discussionEosed = e
flushBufferedIfReady()
}
},
onevent: (evt) => {
@ -160,6 +181,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -160,6 +181,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
...notificationBufferRef.current.sort((a, b) => compareEvents(b, a))
]
}
flushBufferedIfReady()
}
},
onevent: (evt) => {

6
src/services/client-replaceable-events.service.ts

@ -240,7 +240,7 @@ export class ReplaceableEventService { @@ -240,7 +240,7 @@ export class ReplaceableEventService {
// Log when no event is found (helps debug relay failures)
if (kind === kinds.Metadata) {
logger.warn('[ReplaceableEventService] No profile found for pubkey', {
logger.debug('[ReplaceableEventService] No profile found for pubkey', {
pubkey,
cacheKey
})
@ -785,7 +785,7 @@ export class ReplaceableEventService { @@ -785,7 +785,7 @@ export class ReplaceableEventService {
const relayListPromise = client.fetchRelayList(pubkey)
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => {
logger.warn('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey })
logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey })
resolve(null)
}, 2000)
})
@ -896,7 +896,7 @@ export class ReplaceableEventService { @@ -896,7 +896,7 @@ export class ReplaceableEventService {
ReplaceableEventService.releaseProfileFallbackNetworkSlot()
}
logger.warn('[ReplaceableEventService] Profile not found after cache, relay-list fallback, and comprehensive search', {
logger.debug('[ReplaceableEventService] Profile not found after cache, relay-list fallback, and comprehensive search', {
pubkey,
triedRelayHints: relayHints.length > 0
})

69
src/services/client.service.ts

@ -1960,73 +1960,18 @@ class ClientService extends EventTarget { @@ -1960,73 +1960,18 @@ class ClientService extends EventTarget {
/**
* Fetch deletion events (kind 5) and update the tombstone list.
* When `authorPubkey` is set, only that author's deletion requests are queried (typical on login).
* Network sync is intentionally disabled: it queried many relays on every refresh/login and saturated
* the connection pool. Tombstones still update via {@link applyDeletionRequestToLocalCache} when the user deletes from this client.
*/
async fetchDeletionEvents(relayUrls: string[] = [], authorPubkey?: string): Promise<void> {
const relays =
relayUrls.length > 0 ? relayUrls : Array.from(new Set([...PROFILE_FETCH_RELAY_URLS]))
logger.info('[ClientService] Fetching deletion events', {
relayCount: relays.length,
authorPubkey: authorPubkey?.slice(0, 12),
})
try {
const deletionEvents = await this.queryService.query(
relays,
{
kinds: [kinds.EventDeletion],
limit: 100,
...(authorPubkey ? { authors: [authorPubkey] } : {}),
},
undefined,
{
replaceableRace: true,
eoseTimeout: 500,
globalTimeout: 5000,
}
)
logger.debug('[ClientService] Fetched deletion events', { count: deletionEvents.length })
for (const deletionEvent of deletionEvents) {
await this.addTombstoneEntriesFromDeletionEvent(deletionEvent)
}
const removed = await indexedDb.removeTombstonedFromCache()
if (removed > 0) {
logger.info('[ClientService] Removed tombstoned events from cache', { count: removed })
}
dispatchTombstonesUpdated()
} catch (error) {
logger.warn('[ClientService] Failed to fetch deletion events', { error })
}
async fetchDeletionEvents(_relayUrls: string[] = [], _authorPubkey?: string): Promise<void> {
return
}
/**
* Fetch kind-5 events for a profile pubkey (e.g. on profile feed refresh) so their deletes apply to tombstones + UI.
* @deprecated No-op see {@link fetchDeletionEvents}.
*/
async fetchDeletionEventsForPubkey(profilePubkey: string): Promise<void> {
if (!profilePubkey) return
try {
const [relayList, favoriteRelays] = await Promise.all([
this.fetchRelayList(profilePubkey).catch(() => ({ read: [] as string[], write: [] as string[] })),
this.fetchFavoriteRelays(profilePubkey).catch(() => [] as string[])
])
const urls = Array.from(
new Set(
[
...relayList.write.map((url: string) => normalizeUrl(url) || url),
...relayList.read.slice(0, 8).map((url: string) => normalizeUrl(url) || url),
...favoriteRelays.map((url: string) => normalizeUrl(url) || url),
...FAST_READ_RELAY_URLS.map((url: string) => normalizeUrl(url) || url)
].filter(Boolean)
)
).slice(0, 24)
await this.fetchDeletionEvents(urls.length > 0 ? urls : undefined, profilePubkey)
} catch (error) {
logger.warn('[ClientService] fetchDeletionEventsForPubkey failed', { error })
}
async fetchDeletionEventsForPubkey(_profilePubkey: string): Promise<void> {
return
}
async searchNpubsForMention(

Loading…
Cancel
Save