Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
fa4efed6cf
  1. 53
      src/components/ErrorBoundary.tsx
  2. 54
      src/components/NoteList/index.tsx
  3. 4
      src/contexts/secondary-page-context.tsx
  4. 26
      src/lib/spell-feed-request-identity.ts
  5. 179
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  6. 179
      src/pages/primary/SpellsPage/index.tsx
  7. 9
      src/providers/CurrentRelaysProvider.tsx
  8. 18
      src/services/client.service.ts
  9. 20
      vite.config.ts

53
src/components/ErrorBoundary.tsx

@ -7,6 +7,38 @@ import logger from '@/lib/logger' @@ -7,6 +7,38 @@ import logger from '@/lib/logger'
const ISSUES_URL =
'https://gitrepublic.imwald.eu/repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/jumble-imwald-edition?tab=issues'
/** HMR can remount children before parents; context hooks throw. One recovery reload fixes it. */
const CONTEXT_RECOVERY_RELOAD_KEY = 'jumble-context-recovery-reload-at'
const CONTEXT_RECOVERY_COOLDOWN_MS = 20_000
function isLikelyBrokenReactContextFromHmr(message: string): boolean {
return (
/must be used within (a )?[\w]+/i.test(message) ||
message.includes('useNostr must be used within') ||
message.includes('useInterestList must be used within') ||
(message.includes('useContext') && message.includes('null'))
)
}
/** Avoid double `reload()` when React StrictMode runs render twice before navigation. */
let contextRecoveryReloadScheduled = false
function tryContextRecoveryReload(): boolean {
if (typeof window === 'undefined') return false
if (contextRecoveryReloadScheduled) return true
try {
const last = Number(sessionStorage.getItem(CONTEXT_RECOVERY_RELOAD_KEY) || '0')
const now = Date.now()
if (now - last <= CONTEXT_RECOVERY_COOLDOWN_MS) return false
sessionStorage.setItem(CONTEXT_RECOVERY_RELOAD_KEY, String(now))
contextRecoveryReloadScheduled = true
window.location.reload()
return true
} catch {
return false
}
}
interface ErrorBoundaryProps {
children: ReactNode
}
@ -28,10 +60,19 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt @@ -28,10 +60,19 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
logger.error('ErrorBoundary caught an error', { error, errorInfo })
// Recovery reload runs in render() so navigation starts before the error UI paints.
}
render() {
if (this.state.hasError) {
const msg = this.state.error?.message ?? ''
if (isLikelyBrokenReactContextFromHmr(msg) && tryContextRecoveryReload()) {
return (
<div className="flex h-screen w-screen items-center justify-center p-4 text-muted-foreground">
Reloading after a dev hot-reload glitch
</div>
)
}
return (
<div className="w-screen h-screen flex flex-col items-center justify-center p-4 gap-4">
<h1 className="text-2xl font-bold">Oops, something went wrong.</h1>
@ -67,7 +108,17 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt @@ -67,7 +108,17 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
</pre>
</>
)}
<Button onClick={() => window.location.reload()} className="mt-2">
<Button
onClick={() => {
try {
sessionStorage.removeItem(CONTEXT_RECOVERY_RELOAD_KEY)
} catch {
/* ignore */
}
window.location.reload()
}}
className="mt-2"
>
<RotateCw className="w-4 h-4 mr-2" />
Reload Page
</Button>

54
src/components/NoteList/index.tsx

@ -9,6 +9,8 @@ import { @@ -9,6 +9,8 @@ import {
isReplyNoteEvent
} from '@/lib/event'
import { shouldFilterEvent } from '@/lib/event-filtering'
import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity'
import { normalizeUrl } from '@/lib/url'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@ -53,7 +55,14 @@ const NoteList = forwardRef( @@ -53,7 +55,14 @@ const NoteList = forwardRef(
areAlgoRelays = false,
pinnedEventIds = [],
useFilterAsIs = false,
extraShouldHideEvent
extraShouldHideEvent,
/** When set (e.g. Spells page), timeline subscription keys off this string instead of `subRequests` reference churn. */
feedSubscriptionKey,
/**
* When true, hydrate the list from the client timeline cache (IndexedDB-backed) before/at same time as
* live REQ, so feeds feel instant on repeat visits. Spells faux feeds use this; home feed stays false.
*/
useTimelineCacheBootstrap = false
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
@ -69,6 +78,8 @@ const NoteList = forwardRef( @@ -69,6 +78,8 @@ const NoteList = forwardRef(
useFilterAsIs?: boolean
/** When provided and returns true, the event is omitted from the feed (in addition to built-in rules). */
extraShouldHideEvent?: (evt: Event) => boolean
feedSubscriptionKey?: string
useTimelineCacheBootstrap?: boolean
},
ref
) => {
@ -94,12 +105,19 @@ const NoteList = forwardRef( @@ -94,12 +105,19 @@ const NoteList = forwardRef(
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => {
return JSON.stringify(subRequests.map(req => ({
urls: [...req.urls].sort(), // Create a copy before sorting to avoid mutation
filter: req.filter
})))
return JSON.stringify(
subRequests.map((req) => ({
urls: [...req.urls].map((u) => normalizeUrl(u) || u).filter(Boolean).sort(),
filter: stableSpellFeedFilterKey(req.filter)
}))
)
}, [subRequests])
const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey
const subRequestsRef = useRef(subRequests)
subRequestsRef.current = subRequests
// Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds
// Use sorted array and JSON.stringify to create a stable key that only changes when content changes
const showKindsKey = useMemo(() => {
@ -230,7 +248,8 @@ const NoteList = forwardRef( @@ -230,7 +248,8 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
useEffect(() => {
if (!subRequests.length) {
const currentSubRequests = subRequestsRef.current
if (!currentSubRequests.length) {
setLoading(false)
setEvents([])
// Return a no-op closer function to satisfy the cleanup function
@ -247,7 +266,7 @@ const NoteList = forwardRef( @@ -247,7 +266,7 @@ const NoteList = forwardRef(
setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh
const mappedSubRequests = subRequests.map(({ urls, filter }) => {
const mappedSubRequests = subRequestsRef.current.map(({ urls, filter }) => {
// CRITICAL: Always ensure filter has kinds - relays require this to return events
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]
const finalFilter = useFilterAsIs
@ -402,7 +421,8 @@ const NoteList = forwardRef( @@ -402,7 +421,8 @@ const NoteList = forwardRef(
{
startLogin,
needSort: !areAlgoRelays,
useCache: false // Main feeds should always fetch fresh from relays, not use cache
useCache: useTimelineCacheBootstrap,
omitDefaultSinceWhenUseCache: useTimelineCacheBootstrap
}
)
@ -435,16 +455,30 @@ const NoteList = forwardRef( @@ -435,16 +455,30 @@ const NoteList = forwardRef(
promise.then((closer) => closer?.())
}
}, [
subRequestsKey,
timelineSubscriptionKey,
refreshCount,
showKindsKey,
showKind1OPs,
showKind1Replies,
showKind1111,
useFilterAsIs,
areAlgoRelays
areAlgoRelays,
useTimelineCacheBootstrap
])
useEffect(() => {
if (!subRequestsRef.current.length) return
let cancelled = false
const timer = window.setTimeout(() => {
if (cancelled) return
setLoading((prev) => (prev ? false : prev))
}, 15_000)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [timelineSubscriptionKey, refreshCount])
// Use refs to avoid dependency issues and ensure latest values in async callbacks
const eventsRef = useRef(events)
const showCountRef = useRef(showCount)

4
src/contexts/secondary-page-context.tsx

@ -1,15 +1,17 @@ @@ -1,15 +1,17 @@
import { createContext, useContext } from 'react'
import type { TPrimaryPageName } from '@/PageManager'
/**
* Lives in a dedicated module so lazy chunks (e.g. TooManyRelaysAlertDialog) share the same
* context instance as PageManager. Importing from PageManager into those chunks can duplicate
* the module and break Provider matching (useSecondaryPage throws "must be used within Provider").
* Use `import type` only so this file does not create a runtime dependency on PageManager.
*/
export type SecondaryPageContextValue = {
push: (url: string) => void
pop: () => void
currentIndex: number
navigateToPrimaryPage: (page: string, props?: object) => void
navigateToPrimaryPage: (page: TPrimaryPageName, props?: object) => void
}
export const SecondaryPageContext = createContext<SecondaryPageContextValue | undefined>(undefined)

26
src/lib/spell-feed-request-identity.ts

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
import type { TFeedSubRequest } from '@/types'
import { normalizeUrl } from '@/lib/url'
import type { Filter } from 'nostr-tools'
/** Canonical JSON for a REQ filter so subscription identity ignores object identity / key order. */
export function stableSpellFeedFilterKey(filter: Filter): string {
const entries = Object.entries(filter)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
return JSON.stringify(Object.fromEntries(entries))
}
/**
* Single string identity for spell / faux-spell `subRequests`.
* Pass from SpellsPage into NoteList as `feedSubscriptionKey` so timeline subscription does not
* restart when parent passes a new `subRequests` array reference with identical REQ shape.
*/
export function computeSpellSubRequestsIdentityKey(subRequests: TFeedSubRequest[]): string {
if (!subRequests.length) return ''
return JSON.stringify(
subRequests.map((req) => ({
urls: [...req.urls].map((u) => normalizeUrl(u) || u).filter(Boolean).sort(),
filter: stableSpellFeedFilterKey(req.filter)
}))
)
}

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

@ -8,10 +8,15 @@ import { @@ -8,10 +8,15 @@ import {
FAST_WRITE_RELAY_URLS,
PROFILE_FEED_KINDS
} from '@/constants'
import { normalizeTopic } from '@/lib/discussion-topics'
import {
extractHashtagsFromContent,
extractTTagsFromEvent,
normalizeTopic
} from '@/lib/discussion-topics'
import { getImetaInfosFromEvent } from '@/lib/event'
import { normalizeUrl } from '@/lib/url'
import type { TFeedSubRequest, TRelayList } from '@/types'
import { type Event, type Filter } from 'nostr-tools'
import { type Event, type Filter, kinds } from 'nostr-tools'
const NOTIFICATION_LIMIT = 500
const DISCUSSION_LIMIT = 500
@ -31,6 +36,98 @@ export const MEDIA_SPELL_KINDS = [ @@ -31,6 +36,98 @@ export const MEDIA_SPELL_KINDS = [
ExtendedKind.VOICE
] as const
/** Kinds shown in the Media faux spell: native media + kind 1 notes filtered by {@link mediaSpellExtraShouldHideEvent}. */
export const MEDIA_SPELL_SHOW_KINDS = [
kinds.ShortTextNote,
...MEDIA_SPELL_KINDS
] as const
/**
* Topic roots for kind 1 in the Media spell: a note must also match one of these via `t` tag or `#hashtag`
* (after {@link normalizeTopic}), **and** carry media (imeta / media URL / image|video|audio tag).
*/
export const MEDIA_SPELL_TOPIC_SEEDS = [
'vlog',
'video',
'reel',
'gallery',
'podcast',
'photography',
'photo',
'music',
'screencast'
] as const
const MEDIA_SPELL_TOPIC_KEYWORDS = new Set(
MEDIA_SPELL_TOPIC_SEEDS.map((t) => normalizeTopic(t)).filter(Boolean)
)
function hasMediaSpellTopicTag(event: Event): boolean {
for (const topic of extractTTagsFromEvent(event)) {
if (topic && MEDIA_SPELL_TOPIC_KEYWORDS.has(topic)) return true
}
for (const topic of extractHashtagsFromContent(event.content)) {
if (topic && MEDIA_SPELL_TOPIC_KEYWORDS.has(topic)) return true
}
return false
}
function imetaTagsIndicateMedia(event: Event): boolean {
for (const im of getImetaInfosFromEvent(event)) {
const mime = im.m?.toLowerCase() ?? ''
if (mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')) {
return true
}
const u = im.url ?? ''
if (
/\.(jpe?g|png|gif|webp|heic|mp4|webm|m4v|mov|mkv|avi|mp3|m4a|aac|ogg|opus|wav|flac)(\?|#|$)/i.test(
u
)
) {
return true
}
}
return false
}
function hasImageOrStreamTag(event: Event): boolean {
for (const t of event.tags) {
const name = t[0]?.toLowerCase()
if (name === 'image' && t[1]?.startsWith('http')) return true
if ((name === 'video' || name === 'audio' || name === 'stream') && t[1]?.startsWith('http')) {
return true
}
}
return false
}
const CONTENT_MEDIA_FILE_EXT_RE =
/https?:\/\/[^\s<>"')]+\.(?:jpe?g|png|gif|webp|svg|bmp|heic|mp4|webm|m4v|mov|mkv|avi|mp3|m4a|aac|ogg|opus|wav|flac)(?:[\w#./?&=%~+-]*)/i
/** Embed-style hosts (excludes GIF sticker sites like Giphy/Tenor). */
const CONTENT_MEDIA_HOST_RE =
/https?:\/\/(?:(?:[\w-]+\.)*(?:spotify\.com|fountain\.fm)\/|(?:www\.)?(?:youtube\.com\/(?:watch|embed|shorts)|youtu\.be\/|vimeo\.com\/|twitch\.tv\/|instagram\.com\/|(?:i\.)?imgur\.com\/|soundcloud\.com\/|(?:www\.)?tiktok\.com\/|rumble\.com\/|odysee\.com\/))/i
function contentHasMediaUrl(content: string): boolean {
return CONTENT_MEDIA_FILE_EXT_RE.test(content) || CONTENT_MEDIA_HOST_RE.test(content)
}
function hasKind1MediaPayload(event: Event): boolean {
return imetaTagsIndicateMedia(event) || hasImageOrStreamTag(event) || contentHasMediaUrl(event.content)
}
/** Kind 1: require {@link MEDIA_SPELL_TOPIC_SEEDS} match **and** imeta / media URL / image|video|audio tag. */
export function isKind1MediaSpellEligible(event: Event): boolean {
if (event.kind !== kinds.ShortTextNote) return false
return hasMediaSpellTopicTag(event) && hasKind1MediaPayload(event)
}
/** NoteList `extraShouldHideEvent`: hide kind 1 notes that fail the combined topic + media check. */
export function mediaSpellExtraShouldHideEvent(evt: Event): boolean {
if (evt.kind !== kinds.ShortTextNote) return false
return !isKind1MediaSpellEligible(evt)
}
/** Relays for “global” faux feeds (media, calendar): visible favorites or defaults. */
export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: string[]): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
@ -42,8 +139,14 @@ export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: s @@ -42,8 +139,14 @@ export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: s
return dedupe(base.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
}
/** Same cap/priority as the main Notification list: read/inbox relays first, then favorites, then defaults (few relays → faster EOSE, fewer dead sockets). */
const NOTIFICATION_FEED_MAX_RELAYS = 5
/**
* Notifications / bookmarks faux spells: prefer inbox (then favorites), but **always** merge FAST_READ.
* Using only the first N inbox relays meant one dead relay (e.g. offline personal relay) could dominate
* connection/EOSE latency while public relays were never asked skeletons until timeout.
*/
const NOTIFICATION_PRIMARY_MAX = 6
const NOTIFICATION_BLEND_FAST_MAX = 6
const NOTIFICATION_RELAY_CAP = 12
function relayUrlsUpToUnblocked(urls: string[], blocked: Set<string>, max: number): string[] {
const seen = new Set<string>()
@ -58,6 +161,25 @@ function relayUrlsUpToUnblocked(urls: string[], blocked: Set<string>, max: numbe @@ -58,6 +161,25 @@ function relayUrlsUpToUnblocked(urls: string[], blocked: Set<string>, max: numbe
return out
}
function mergeRelayListsUnique(
lists: string[][],
blocked: Set<string>,
cap: number
): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const list of lists) {
for (const u of list) {
const k = normalizeUrl(u) || u
if (!k || blocked.has(k) || seen.has(k)) continue
seen.add(k)
out.push(k)
if (out.length >= cap) return out
}
}
return out
}
export function notificationRelayUrls(
relayList: TRelayList | null | undefined,
favoriteRelays: string[],
@ -65,15 +187,21 @@ export function notificationRelayUrls( @@ -65,15 +187,21 @@ export function notificationRelayUrls(
): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
const read = relayList?.read ?? []
if (read.length > 0) {
const fromRead = relayUrlsUpToUnblocked(read, blocked, NOTIFICATION_FEED_MAX_RELAYS)
if (fromRead.length > 0) return fromRead
}
if (favoriteRelays.length > 0) {
const fromFav = relayUrlsUpToUnblocked(favoriteRelays, blocked, NOTIFICATION_FEED_MAX_RELAYS)
if (fromFav.length > 0) return fromFav
}
return relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_FEED_MAX_RELAYS)
const readSorted = [...read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b))
const favSorted = [...favoriteRelays]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort((a, b) => a.localeCompare(b))
const primary =
read.length > 0
? relayUrlsUpToUnblocked(readSorted, blocked, NOTIFICATION_PRIMARY_MAX)
: favoriteRelays.length > 0
? relayUrlsUpToUnblocked(favSorted, blocked, NOTIFICATION_PRIMARY_MAX)
: []
const fromFast = relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_BLEND_FAST_MAX)
const merged = mergeRelayListsUnique([primary, fromFast], blocked, NOTIFICATION_RELAY_CAP)
if (merged.length > 0) return merged
return relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_RELAY_CAP)
}
function dedupe(urls: string[]): string[] {
@ -101,20 +229,31 @@ export function buildMentionsSpellFilter(pubkey: string): Filter { @@ -101,20 +229,31 @@ export function buildMentionsSpellFilter(pubkey: string): Filter {
* Relay set for Spells Discussions (kind 11): same merge order as DiscussionsPage, but capped
* for subscription-based loading (see DISCUSSION_FAUX_SPELL_MAX_RELAYS).
*/
/**
* Deterministic relay pick: each tier (read / write / fav / fast) is normalized + sorted so NostrProvider
* array order and NIP-66 ref churn do not change which 32 relays we REQ (prevents subscription identity thrash).
*/
export function discussionRelayUrls(
relayList: TRelayList | null | undefined,
favoriteRelays: string[],
blockedRelays: string[]
): string[] {
const read = relayList?.read ?? []
const write = relayList?.write ?? []
const merged = [...read, ...write, ...favoriteRelays, ...FAST_READ_RELAY_URLS, ...FAST_WRITE_RELAY_URLS]
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
const tier = (urls: string[]) =>
[...new Set(urls.map((u) => normalizeUrl(u) || u).filter(Boolean))]
.filter((k) => !blocked.has(k))
.sort((a, b) => a.localeCompare(b))
const read = tier(relayList?.read ?? [])
const write = tier(relayList?.write ?? [])
const fav = tier(favoriteRelays)
const fastR = tier([...FAST_READ_RELAY_URLS])
const fastW = tier([...FAST_WRITE_RELAY_URLS])
const merged = [...read, ...write, ...fav, ...fastR, ...fastW]
const seen = new Set<string>()
const out: string[] = []
for (const u of merged) {
const k = normalizeUrl(u) || u
if (!k || seen.has(k) || blocked.has(k)) continue
for (const k of merged) {
if (seen.has(k)) continue
seen.add(k)
out.push(k)
if (out.length >= DISCUSSION_FAUX_SPELL_MAX_RELAYS) break
@ -130,7 +269,7 @@ export function buildDiscussionFilter(): Filter { @@ -130,7 +269,7 @@ export function buildDiscussionFilter(): Filter {
}
export function buildMediaSpellFilter(): Filter {
return { kinds: [...MEDIA_SPELL_KINDS], limit: 500 }
return { kinds: [...MEDIA_SPELL_SHOW_KINDS], limit: 500 }
}
export function buildCalendarSpellFilter(): Filter {

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

@ -35,6 +35,7 @@ import storage from '@/services/local-storage.service' @@ -35,6 +35,7 @@ import storage from '@/services/local-storage.service'
import { ExtendedKind, FAUX_SPELL_ORDER, PROFILE_FEED_KINDS } from '@/constants'
import { isUserInEventMentions } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey'
import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity'
import { normalizeUrl } from '@/lib/url'
import {
buildSpellCatalogAuthors,
@ -82,7 +83,8 @@ import { @@ -82,7 +83,8 @@ import {
buildMentionsSpellFilter,
discussionRelayUrls,
fauxFavoriteRelayUrls,
MEDIA_SPELL_KINDS,
MEDIA_SPELL_SHOW_KINDS,
mediaSpellExtraShouldHideEvent,
notificationRelayUrls
} from './fauxSpellFeeds'
import type { TPageRef } from '@/types'
@ -306,30 +308,29 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -306,30 +308,29 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
setFavoriteIds(new Set(ids))
}, [])
/** Re-sync catalog when inbox / outbox / mailbox entries change (not only `write`). */
const spellCatalogRelayKey = useMemo(
() =>
relayList
? JSON.stringify({
r: relayList.read,
w: relayList.write,
o: relayList.originalRelays.map((x) => [x.url, x.scope])
})
: '',
[relayList]
)
/**
* Fingerprint by value `relayList` from NostrProvider often gets a new object ref each render.
* Using `[relayList]` in useMemo deps was invalidating every tick new subRequests browse-relay
* effect CurrentRelays churn mass useFetchProfile cancellation (e.g. Discussions spell).
*/
const normalizedReadSorted = relayList
? [...relayList.read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort()
: []
const normalizedWriteSorted = relayList
? [...relayList.write].map((u) => normalizeUrl(u) || u).filter(Boolean).sort()
: []
/** Content key only — `relayList` often gets a new object ref from NostrProvider; recomputing spell filters would re-run `resolveRelativeTime` (Date.now) and churn NoteList subscriptions. */
const relayListWriteKey = useMemo(
() =>
JSON.stringify(
[...(relayList?.write ?? [])]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
),
[relayList]
)
/** Read+write only, order-stable. `originalRelays` churns during NIP-66 / discovery but faux spell REQ lists ignore it. */
const relayMailboxStableKey =
relayList == null
? ''
: JSON.stringify({ r: normalizedReadSorted, w: normalizedWriteSorted })
/** Write URLs only; mailbox key excludes discovery merges on `originalRelays`. */
const relayListWriteKey = useMemo(() => {
if (!relayList) return '[]'
return JSON.stringify(normalizedWriteSorted)
}, [relayMailboxStableKey])
useEffect(() => {
loadSpells()
@ -338,7 +339,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -338,7 +339,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
/** 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 from merged mailbox (10002 + 10432) read/write + fast read. */
/**
* After showing the cache, pull kind 777 from merged mailbox (10002 + 10432) read/write + fast read.
* Deps use `relayMailboxStableKey` only not NIP-66 `originalRelays` so discovery merges dont restart this sub.
*/
useEffect(() => {
if (!pubkey) {
setSpellsCatalogSyncing(false)
@ -346,7 +350,14 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -346,7 +350,14 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}
let cancelled = false
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(true)
let loadSpellsDebounce: ReturnType<typeof setTimeout> | null = null
const scheduleLoadSpells = () => {
if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce)
loadSpellsDebounce = setTimeout(() => {
loadSpellsDebounce = null
if (!cancelled) void loadSpells()
}, 120)
}
const urls = getRelaysForSpellCatalogSync(relayList ?? undefined)
const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts)
const authorAllowlist = new Set(catalogAuthors)
@ -365,30 +376,41 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -365,30 +376,41 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
void (async () => {
try {
setSpellsCatalogSyncing(true)
const { closer } = await client.subscribeTimeline(
[{ urls, filter }],
{
onEvents: async (events, eosed) => {
if (!eosed || cancelled) return
window.clearTimeout(syncTimeout)
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 (!cancelled) await loadSpells()
if (!cancelled) setSpellsCatalogSyncing(false)
closer()
spellCatalogCloserRef.current = null
if (wrote) scheduleLoadSpells()
if (eosed) {
window.clearTimeout(syncTimeout)
if (loadSpellsDebounce != null) {
clearTimeout(loadSpellsDebounce)
loadSpellsDebounce = null
}
if (!cancelled) await loadSpells()
if (!cancelled) setSpellsCatalogSyncing(false)
closer()
spellCatalogCloserRef.current = null
}
},
onNew: () => {} // Not needed
},
{
useCache: false // NO CACHING - stream raw from relays
useCache: true,
omitDefaultSinceWhenUseCache: true
}
)
if (cancelled) {
@ -405,12 +427,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -405,12 +427,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return () => {
cancelled = true
if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce)
window.clearTimeout(syncTimeout)
spellCatalogCloserRef.current?.()
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false)
}
}, [pubkey, spellCatalogRelayKey, loadSpells, contactsSyncKey])
}, [pubkey, relayMailboxStableKey, loadSpells, contactsSyncKey])
useEffect(() => {
if (!pubkey) {
@ -420,6 +443,37 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -420,6 +443,37 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([]))
}, [pubkey])
/** Order-independent favorites/blocked — array order from providers must not rebuild faux subs. */
const sortedFavoriteRelaysKey = JSON.stringify(
[...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b))
)
const sortedBlockedRelaysKey = JSON.stringify(
[...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b))
)
const interestTagsStableKey = interestListEvent
? JSON.stringify(
[...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
)
: ''
const bookmarkTagsStableKey = bookmarkListEvent
? JSON.stringify(
[...bookmarkListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
)
: ''
/** Content-based key so event ref churn does not rebuild faux subs every render. */
const fauxFeedRelaysDepsKey = [
sortedFavoriteRelaysKey,
sortedBlockedRelaysKey,
interestListEvent?.id ?? '',
String(interestListEvent?.created_at ?? ''),
interestTagsStableKey,
bookmarkListEvent?.id ?? '',
String(bookmarkListEvent?.created_at ?? ''),
bookmarkTagsStableKey
].join('\0')
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedFauxSpell || selectedFauxSpell === 'following') return []
@ -459,16 +513,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -459,16 +513,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return buildFollowPacksSubRequests()
}
return []
// spellCatalogRelayKey: stable mailbox fingerprint (not relayList ref) so faux feeds don’t rebuild every NostrProvider tick
}, [
selectedFauxSpell,
pubkey,
spellCatalogRelayKey,
favoriteRelays,
blockedRelays,
interestListEvent,
bookmarkListEvent
])
// relayMailboxStableKey: read/write only — do not tie faux feeds to originalRelays (NIP-66 churn).
}, [selectedFauxSpell, pubkey, relayMailboxStableKey, fauxFeedRelaysDepsKey])
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (selectedFauxSpell === 'following') return followingSubRequests
@ -492,6 +538,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -492,6 +538,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return spellSubRequests
}, [selectedFauxSpell, fauxSubRequests, spellSubRequests])
const spellFeedSubscriptionKey = useMemo(
() => computeSpellSubRequestsIdentityKey(subRequests),
[subRequests]
)
const spellBrowseRelayUrls = useMemo(() => {
const set = new Set<string>()
for (const req of subRequests) {
@ -500,15 +551,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -500,15 +551,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (n) set.add(n)
}
}
return [...set]
return [...set].sort()
}, [subRequests])
const spellBrowseRelayUrlsKey = spellBrowseRelayUrls.join('|')
const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
useEffect(() => {
if (!spellBrowseRelayUrls.length) return
addRelayUrls(spellBrowseRelayUrls)
return () => removeRelayUrls(spellBrowseRelayUrls)
}, [spellBrowseRelayUrls, addRelayUrls, removeRelayUrls])
if (!spellBrowseRelayUrlsKey) return
const urls = spellBrowseRelayUrlsKey.split('|')
addRelayUrls(urls)
return () => removeRelayUrls(urls)
}, [spellBrowseRelayUrlsKey, addRelayUrls, removeRelayUrls])
const toggleFavorite = useCallback(async (spellId: string) => {
const ids = await indexedDb.getSpellFavoriteIds()
@ -585,6 +639,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -585,6 +639,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
.join(',')
}, [selectedSpell?.id])
/** Avoid depending on `kindFilterShowKinds` ref for faux spells that don’t use it (e.g. Discussions). */
const followingShowKindsKey =
selectedFauxSpell === 'following' ? JSON.stringify(kindFilterShowKinds) : ''
const showKinds = useMemo(() => {
if (selectedFauxSpell === 'notifications') {
return PROFILE_FEED_KINDS
@ -599,7 +657,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -599,7 +657,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [ExtendedKind.FOLLOW_PACK]
}
if (selectedFauxSpell === 'media') {
return [...MEDIA_SPELL_KINDS]
return [...MEDIA_SPELL_SHOW_KINDS]
}
if (selectedFauxSpell === 'calendar') {
return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME]
@ -616,12 +674,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -616,12 +674,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
.map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n))
return kinds.length ? kinds : [1]
}, [
selectedFauxSpell,
selectedSpell?.id,
showKindsTagKey,
kindFilterShowKinds
])
}, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey])
const spellMenuLabel = useCallback(
(spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)),
@ -1043,7 +1096,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1043,7 +1096,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="min-h-0 min-w-0 flex-1">
<NoteList
subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey}
showKinds={showKinds}
useTimelineCacheBootstrap
useFilterAsIs={fauxNoteListUseFilterAsIs}
showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true}
showKind1Replies={selectedFauxSpell === 'following' ? showKind1Replies : true}
@ -1052,7 +1107,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1052,7 +1107,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
extraShouldHideEvent={
selectedFauxSpell === 'notifications' && pubkey
? notificationsMentionExtraHide
: undefined
: selectedFauxSpell === 'media'
? mediaSpellExtraShouldHideEvent
: undefined
}
hideUntrustedNotes={
selectedFauxSpell === 'notifications' ? hideUntrustedNotifications : false
@ -1062,7 +1119,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1062,7 +1119,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</>
) : selectedSpell ? (
subRequests.length > 0 ? (
<NoteList subRequests={subRequests} showKinds={showKinds} useFilterAsIs />
<NoteList
subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey}
showKinds={showKinds}
useTimelineCacheBootstrap
useFilterAsIs
/>
) : !pubkey &&
selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))

9
src/providers/CurrentRelaysProvider.tsx

@ -21,6 +21,7 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode @@ -21,6 +21,7 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode
const relayUrls = useMemo(() => Object.keys(relayRefCount), [relayRefCount])
const addRelayUrls = useCallback((urls: string[]) => {
if (!urls.length) return
setRelayRefCount((prev) => {
const newCounts = { ...prev }
urls.forEach((url) => {
@ -31,6 +32,7 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode @@ -31,6 +32,7 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode
}, [])
const removeRelayUrls = useCallback((urls: string[]) => {
if (!urls.length) return
setRelayRefCount((prev) => {
const newCounts = { ...prev }
urls.forEach((url) => {
@ -45,8 +47,13 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode @@ -45,8 +47,13 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode
})
}, [])
const contextValue = useMemo(
() => ({ relayUrls, addRelayUrls, removeRelayUrls }),
[relayUrls, addRelayUrls, removeRelayUrls]
)
return (
<CurrentRelaysContext.Provider value={{ relayUrls, addRelayUrls, removeRelayUrls }}>
<CurrentRelaysContext.Provider value={contextValue}>
{children}
</CurrentRelaysContext.Provider>
)

18
src/services/client.service.ts

@ -853,16 +853,22 @@ class ClientService extends EventTarget { @@ -853,16 +853,22 @@ class ClientService extends EventTarget {
{
startLogin,
needSort = true,
useCache = false
useCache = false,
omitDefaultSinceWhenUseCache = false
}: {
startLogin?: () => void
needSort?: boolean
useCache?: boolean
/** When useCache is true but there are no timeline refs yet, skip the default 24h `since` so REQ stays unbounded (spell feeds / catalog). */
omitDefaultSinceWhenUseCache?: boolean
} = {}
) {
const newEventIdSet = new Set<string>()
const requestCount = subRequests.length
const threshold = Math.floor(requestCount / 2)
// For requestCount===1, floor(1/2)=0 makes eosedCount>=threshold true from the first inner
// callback, so every progressive update forwards to the outer onEvents → setState storms and
// stuck feeds (e.g. Spells Discussions). Require at least one EOSE before opening the gate.
const threshold = requestCount <= 1 ? 1 : Math.floor(requestCount / 2)
let eventIdSet = new Set<string>()
let events: NEvent[] = []
let eosedCount = 0
@ -901,7 +907,7 @@ class ClientService extends EventTarget { @@ -901,7 +907,7 @@ class ClientService extends EventTarget {
},
onClose
},
{ startLogin, needSort, useCache }
{ startLogin, needSort, useCache, omitDefaultSinceWhenUseCache }
)
})
)
@ -1191,11 +1197,13 @@ class ClientService extends EventTarget { @@ -1191,11 +1197,13 @@ class ClientService extends EventTarget {
{
startLogin,
needSort = true,
useCache = false
useCache = false,
omitDefaultSinceWhenUseCache = false
}: {
startLogin?: () => void
needSort?: boolean
useCache?: boolean
omitDefaultSinceWhenUseCache?: boolean
} = {}
) {
const relays = Array.from(new Set(urls))
@ -1245,7 +1253,7 @@ class ClientService extends EventTarget { @@ -1245,7 +1253,7 @@ class ClientService extends EventTarget {
// CRITICAL FIX: Only set since parameter if caching is enabled
// When useCache is false, we want to stream raw from relays without time restrictions
// This allows relay feeds to show all available events, not just recent ones
if (!since && needSort && useCache) {
if (!since && needSort && useCache && !omitDefaultSinceWhenUseCache) {
// Default to last 24 hours if no recent cached events (only when caching is enabled)
// This ensures we get recent content even if relays are slow
const oneDayAgo = dayjs().subtract(24, 'hours').unix()

20
vite.config.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
import path from 'path'
import type { Plugin } from 'vite'
import { defineConfig } from 'vitest/config'
import { VitePWA } from 'vite-plugin-pwa'
import packageJson from './package.json'
@ -24,6 +25,24 @@ const getAppVersion = () => { @@ -24,6 +25,24 @@ const getAppVersion = () => {
}
}
/**
* React Fast Refresh can remount provider children without NostrProvider (e.g. after editing pages),
* causing `useNostr must be used within a NostrProvider`. Full page reload keeps the tree consistent.
*/
function fullReloadOnProvidersAndPages(): Plugin {
return {
name: 'full-reload-providers-pages',
apply: 'serve',
handleHotUpdate({ file, server }) {
const normalized = file.replace(/\\/g, '/')
if (normalized.includes('/src/providers/') || normalized.includes('/src/pages/')) {
server.ws.send({ type: 'full-reload' })
return []
}
}
}
}
// https://vite.dev/config/
export default defineConfig({
base: '/',
@ -172,6 +191,7 @@ export default defineConfig({ @@ -172,6 +191,7 @@ export default defineConfig({
},
plugins: [
react(),
fullReloadOnProvidersAndPages(),
VitePWA({
registerType: 'autoUpdate',
// Use public/manifest.webmanifest and index.html <link> only; avoid duplicate manifest link in build

Loading…
Cancel
Save