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. 169
      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'
const ISSUES_URL = const ISSUES_URL =
'https://gitrepublic.imwald.eu/repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/jumble-imwald-edition?tab=issues' '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 { interface ErrorBoundaryProps {
children: ReactNode children: ReactNode
} }
@ -28,10 +60,19 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
logger.error('ErrorBoundary caught an error', { error, errorInfo }) logger.error('ErrorBoundary caught an error', { error, errorInfo })
// Recovery reload runs in render() so navigation starts before the error UI paints.
} }
render() { render() {
if (this.state.hasError) { 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 ( return (
<div className="w-screen h-screen flex flex-col items-center justify-center p-4 gap-4"> <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> <h1 className="text-2xl font-bold">Oops, something went wrong.</h1>
@ -67,7 +108,17 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
</pre> </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" /> <RotateCw className="w-4 h-4 mr-2" />
Reload Page Reload Page
</Button> </Button>

54
src/components/NoteList/index.tsx

@ -9,6 +9,8 @@ import {
isReplyNoteEvent isReplyNoteEvent
} from '@/lib/event' } from '@/lib/event'
import { shouldFilterEvent } from '@/lib/event-filtering' 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 { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@ -53,7 +55,14 @@ const NoteList = forwardRef(
areAlgoRelays = false, areAlgoRelays = false,
pinnedEventIds = [], pinnedEventIds = [],
useFilterAsIs = false, 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[] subRequests: TFeedSubRequest[]
showKinds: number[] showKinds: number[]
@ -69,6 +78,8 @@ const NoteList = forwardRef(
useFilterAsIs?: boolean useFilterAsIs?: boolean
/** When provided and returns true, the event is omitted from the feed (in addition to built-in rules). */ /** When provided and returns true, the event is omitted from the feed (in addition to built-in rules). */
extraShouldHideEvent?: (evt: Event) => boolean extraShouldHideEvent?: (evt: Event) => boolean
feedSubscriptionKey?: string
useTimelineCacheBootstrap?: boolean
}, },
ref ref
) => { ) => {
@ -94,12 +105,19 @@ const NoteList = forwardRef(
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render // Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => { const subRequestsKey = useMemo(() => {
return JSON.stringify(subRequests.map(req => ({ return JSON.stringify(
urls: [...req.urls].sort(), // Create a copy before sorting to avoid mutation subRequests.map((req) => ({
filter: req.filter urls: [...req.urls].map((u) => normalizeUrl(u) || u).filter(Boolean).sort(),
}))) filter: stableSpellFeedFilterKey(req.filter)
}))
)
}, [subRequests]) }, [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 // 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 // Use sorted array and JSON.stringify to create a stable key that only changes when content changes
const showKindsKey = useMemo(() => { const showKindsKey = useMemo(() => {
@ -230,7 +248,8 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
useEffect(() => { useEffect(() => {
if (!subRequests.length) { const currentSubRequests = subRequestsRef.current
if (!currentSubRequests.length) {
setLoading(false) setLoading(false)
setEvents([]) setEvents([])
// Return a no-op closer function to satisfy the cleanup function // Return a no-op closer function to satisfy the cleanup function
@ -247,7 +266,7 @@ const NoteList = forwardRef(
setHasMore(true) setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh 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 // CRITICAL: Always ensure filter has kinds - relays require this to return events
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote] const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]
const finalFilter = useFilterAsIs const finalFilter = useFilterAsIs
@ -402,7 +421,8 @@ const NoteList = forwardRef(
{ {
startLogin, startLogin,
needSort: !areAlgoRelays, 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(
promise.then((closer) => closer?.()) promise.then((closer) => closer?.())
} }
}, [ }, [
subRequestsKey, timelineSubscriptionKey,
refreshCount, refreshCount,
showKindsKey, showKindsKey,
showKind1OPs, showKind1OPs,
showKind1Replies, showKind1Replies,
showKind1111, showKind1111,
useFilterAsIs, 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 // Use refs to avoid dependency issues and ensure latest values in async callbacks
const eventsRef = useRef(events) const eventsRef = useRef(events)
const showCountRef = useRef(showCount) const showCountRef = useRef(showCount)

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

@ -1,15 +1,17 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
import type { TPrimaryPageName } from '@/PageManager'
/** /**
* Lives in a dedicated module so lazy chunks (e.g. TooManyRelaysAlertDialog) share the same * 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 * 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"). * 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 = { export type SecondaryPageContextValue = {
push: (url: string) => void push: (url: string) => void
pop: () => void pop: () => void
currentIndex: number currentIndex: number
navigateToPrimaryPage: (page: string, props?: object) => void navigateToPrimaryPage: (page: TPrimaryPageName, props?: object) => void
} }
export const SecondaryPageContext = createContext<SecondaryPageContextValue | undefined>(undefined) export const SecondaryPageContext = createContext<SecondaryPageContextValue | undefined>(undefined)

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

@ -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 {
FAST_WRITE_RELAY_URLS, FAST_WRITE_RELAY_URLS,
PROFILE_FEED_KINDS PROFILE_FEED_KINDS
} from '@/constants' } 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 { normalizeUrl } from '@/lib/url'
import type { TFeedSubRequest, TRelayList } from '@/types' 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 NOTIFICATION_LIMIT = 500
const DISCUSSION_LIMIT = 500 const DISCUSSION_LIMIT = 500
@ -31,6 +36,98 @@ export const MEDIA_SPELL_KINDS = [
ExtendedKind.VOICE ExtendedKind.VOICE
] as const ] 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. */ /** Relays for “global” faux feeds (media, calendar): visible favorites or defaults. */
export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: string[]): string[] { export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: string[]): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
@ -42,8 +139,14 @@ export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: s
return dedupe(base.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) 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[] { function relayUrlsUpToUnblocked(urls: string[], blocked: Set<string>, max: number): string[] {
const seen = new Set<string>() const seen = new Set<string>()
@ -58,6 +161,25 @@ function relayUrlsUpToUnblocked(urls: string[], blocked: Set<string>, max: numbe
return out 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( export function notificationRelayUrls(
relayList: TRelayList | null | undefined, relayList: TRelayList | null | undefined,
favoriteRelays: string[], favoriteRelays: string[],
@ -65,15 +187,21 @@ export function notificationRelayUrls(
): string[] { ): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
const read = relayList?.read ?? [] const read = relayList?.read ?? []
if (read.length > 0) { const readSorted = [...read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b))
const fromRead = relayUrlsUpToUnblocked(read, blocked, NOTIFICATION_FEED_MAX_RELAYS) const favSorted = [...favoriteRelays]
if (fromRead.length > 0) return fromRead .map((u) => normalizeUrl(u) || u)
} .filter(Boolean)
if (favoriteRelays.length > 0) { .sort((a, b) => a.localeCompare(b))
const fromFav = relayUrlsUpToUnblocked(favoriteRelays, blocked, NOTIFICATION_FEED_MAX_RELAYS) const primary =
if (fromFav.length > 0) return fromFav read.length > 0
} ? relayUrlsUpToUnblocked(readSorted, blocked, NOTIFICATION_PRIMARY_MAX)
return relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_FEED_MAX_RELAYS) : 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[] { function dedupe(urls: string[]): string[] {
@ -101,20 +229,31 @@ export function buildMentionsSpellFilter(pubkey: string): Filter {
* Relay set for Spells Discussions (kind 11): same merge order as DiscussionsPage, but capped * Relay set for Spells Discussions (kind 11): same merge order as DiscussionsPage, but capped
* for subscription-based loading (see DISCUSSION_FAUX_SPELL_MAX_RELAYS). * 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( export function discussionRelayUrls(
relayList: TRelayList | null | undefined, relayList: TRelayList | null | undefined,
favoriteRelays: string[], favoriteRelays: string[],
blockedRelays: string[] blockedRelays: string[]
): 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 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 seen = new Set<string>()
const out: string[] = [] const out: string[] = []
for (const u of merged) { for (const k of merged) {
const k = normalizeUrl(u) || u if (seen.has(k)) continue
if (!k || seen.has(k) || blocked.has(k)) continue
seen.add(k) seen.add(k)
out.push(k) out.push(k)
if (out.length >= DISCUSSION_FAUX_SPELL_MAX_RELAYS) break if (out.length >= DISCUSSION_FAUX_SPELL_MAX_RELAYS) break
@ -130,7 +269,7 @@ export function buildDiscussionFilter(): Filter {
} }
export function buildMediaSpellFilter(): Filter { export function buildMediaSpellFilter(): Filter {
return { kinds: [...MEDIA_SPELL_KINDS], limit: 500 } return { kinds: [...MEDIA_SPELL_SHOW_KINDS], limit: 500 }
} }
export function buildCalendarSpellFilter(): Filter { export function buildCalendarSpellFilter(): Filter {

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

@ -35,6 +35,7 @@ import storage from '@/services/local-storage.service'
import { ExtendedKind, FAUX_SPELL_ORDER, PROFILE_FEED_KINDS } from '@/constants' import { ExtendedKind, FAUX_SPELL_ORDER, PROFILE_FEED_KINDS } from '@/constants'
import { isUserInEventMentions } from '@/lib/event' import { isUserInEventMentions } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { import {
buildSpellCatalogAuthors, buildSpellCatalogAuthors,
@ -82,7 +83,8 @@ import {
buildMentionsSpellFilter, buildMentionsSpellFilter,
discussionRelayUrls, discussionRelayUrls,
fauxFavoriteRelayUrls, fauxFavoriteRelayUrls,
MEDIA_SPELL_KINDS, MEDIA_SPELL_SHOW_KINDS,
mediaSpellExtraShouldHideEvent,
notificationRelayUrls notificationRelayUrls
} from './fauxSpellFeeds' } from './fauxSpellFeeds'
import type { TPageRef } from '@/types' import type { TPageRef } from '@/types'
@ -306,30 +308,29 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
setFavoriteIds(new Set(ids)) setFavoriteIds(new Set(ids))
}, []) }, [])
/** Re-sync catalog when inbox / outbox / mailbox entries change (not only `write`). */ /**
const spellCatalogRelayKey = useMemo( * 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
relayList * effect CurrentRelays churn mass useFetchProfile cancellation (e.g. Discussions spell).
? JSON.stringify({ */
r: relayList.read, const normalizedReadSorted = relayList
w: relayList.write, ? [...relayList.read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort()
o: relayList.originalRelays.map((x) => [x.url, x.scope]) : []
}) const normalizedWriteSorted = relayList
: '', ? [...relayList.write].map((u) => normalizeUrl(u) || u).filter(Boolean).sort()
[relayList] : []
)
/** 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. */ /** Read+write only, order-stable. `originalRelays` churns during NIP-66 / discovery but faux spell REQ lists ignore it. */
const relayListWriteKey = useMemo( const relayMailboxStableKey =
() => relayList == null
JSON.stringify( ? ''
[...(relayList?.write ?? [])] : JSON.stringify({ r: normalizedReadSorted, w: normalizedWriteSorted })
.map((u) => normalizeUrl(u) || u)
.filter(Boolean) /** Write URLs only; mailbox key excludes discovery merges on `originalRelays`. */
.sort() const relayListWriteKey = useMemo(() => {
), if (!relayList) return '[]'
[relayList] return JSON.stringify(normalizedWriteSorted)
) }, [relayMailboxStableKey])
useEffect(() => { useEffect(() => {
loadSpells() loadSpells()
@ -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). */ /** Stable key so we re-sync when the follow list changes (not only on array identity). */
const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts]) 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(() => { useEffect(() => {
if (!pubkey) { if (!pubkey) {
setSpellsCatalogSyncing(false) setSpellsCatalogSyncing(false)
@ -346,7 +350,14 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
} }
let cancelled = false let cancelled = false
spellCatalogCloserRef.current = null 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 urls = getRelaysForSpellCatalogSync(relayList ?? undefined)
const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts)
const authorAllowlist = new Set(catalogAuthors) const authorAllowlist = new Set(catalogAuthors)
@ -365,30 +376,41 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
void (async () => { void (async () => {
try { try {
setSpellsCatalogSyncing(true)
const { closer } = await client.subscribeTimeline( const { closer } = await client.subscribeTimeline(
[{ urls, filter }], [{ urls, filter }],
{ {
onEvents: async (events, eosed) => { onEvents: async (events, eosed) => {
if (!eosed || cancelled) return if (cancelled) return
window.clearTimeout(syncTimeout) let wrote = false
for (const ev of events) { for (const ev of events) {
if (cancelled) return if (cancelled) return
if (!verifyEvent(ev) || !isSpellEvent(ev) || !authorAllowlist.has(ev.pubkey)) continue if (!verifyEvent(ev) || !isSpellEvent(ev) || !authorAllowlist.has(ev.pubkey)) continue
try { try {
await indexedDb.putSpellEvent(ev) await indexedDb.putSpellEvent(ev)
wrote = true
} catch (e) { } catch (e) {
logger.warn('[SpellsPage] Failed to cache spell from relay', e) logger.warn('[SpellsPage] Failed to cache spell from relay', e)
} }
} }
if (wrote) scheduleLoadSpells()
if (eosed) {
window.clearTimeout(syncTimeout)
if (loadSpellsDebounce != null) {
clearTimeout(loadSpellsDebounce)
loadSpellsDebounce = null
}
if (!cancelled) await loadSpells() if (!cancelled) await loadSpells()
if (!cancelled) setSpellsCatalogSyncing(false) if (!cancelled) setSpellsCatalogSyncing(false)
closer() closer()
spellCatalogCloserRef.current = null spellCatalogCloserRef.current = null
}
}, },
onNew: () => {} // Not needed onNew: () => {} // Not needed
}, },
{ {
useCache: false // NO CACHING - stream raw from relays useCache: true,
omitDefaultSinceWhenUseCache: true
} }
) )
if (cancelled) { if (cancelled) {
@ -405,12 +427,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return () => { return () => {
cancelled = true cancelled = true
if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce)
window.clearTimeout(syncTimeout) window.clearTimeout(syncTimeout)
spellCatalogCloserRef.current?.() spellCatalogCloserRef.current?.()
spellCatalogCloserRef.current = null spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false) setSpellsCatalogSyncing(false)
} }
}, [pubkey, spellCatalogRelayKey, loadSpells, contactsSyncKey]) }, [pubkey, relayMailboxStableKey, loadSpells, contactsSyncKey])
useEffect(() => { useEffect(() => {
if (!pubkey) { if (!pubkey) {
@ -420,6 +443,37 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([])) client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([]))
}, [pubkey]) }, [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[]>(() => { const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedFauxSpell || selectedFauxSpell === 'following') return [] if (!selectedFauxSpell || selectedFauxSpell === 'following') return []
@ -459,16 +513,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return buildFollowPacksSubRequests() return buildFollowPacksSubRequests()
} }
return [] return []
// spellCatalogRelayKey: stable mailbox fingerprint (not relayList ref) so faux feeds don’t rebuild every NostrProvider tick // relayMailboxStableKey: read/write only — do not tie faux feeds to originalRelays (NIP-66 churn).
}, [ }, [selectedFauxSpell, pubkey, relayMailboxStableKey, fauxFeedRelaysDepsKey])
selectedFauxSpell,
pubkey,
spellCatalogRelayKey,
favoriteRelays,
blockedRelays,
interestListEvent,
bookmarkListEvent
])
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => { const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (selectedFauxSpell === 'following') return followingSubRequests if (selectedFauxSpell === 'following') return followingSubRequests
@ -492,6 +538,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return spellSubRequests return spellSubRequests
}, [selectedFauxSpell, fauxSubRequests, spellSubRequests]) }, [selectedFauxSpell, fauxSubRequests, spellSubRequests])
const spellFeedSubscriptionKey = useMemo(
() => computeSpellSubRequestsIdentityKey(subRequests),
[subRequests]
)
const spellBrowseRelayUrls = useMemo(() => { const spellBrowseRelayUrls = useMemo(() => {
const set = new Set<string>() const set = new Set<string>()
for (const req of subRequests) { for (const req of subRequests) {
@ -500,15 +551,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (n) set.add(n) if (n) set.add(n)
} }
} }
return [...set] return [...set].sort()
}, [subRequests]) }, [subRequests])
const spellBrowseRelayUrlsKey = spellBrowseRelayUrls.join('|')
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
useEffect(() => { useEffect(() => {
if (!spellBrowseRelayUrls.length) return if (!spellBrowseRelayUrlsKey) return
addRelayUrls(spellBrowseRelayUrls) const urls = spellBrowseRelayUrlsKey.split('|')
return () => removeRelayUrls(spellBrowseRelayUrls) addRelayUrls(urls)
}, [spellBrowseRelayUrls, addRelayUrls, removeRelayUrls]) return () => removeRelayUrls(urls)
}, [spellBrowseRelayUrlsKey, addRelayUrls, removeRelayUrls])
const toggleFavorite = useCallback(async (spellId: string) => { const toggleFavorite = useCallback(async (spellId: string) => {
const ids = await indexedDb.getSpellFavoriteIds() const ids = await indexedDb.getSpellFavoriteIds()
@ -585,6 +639,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
.join(',') .join(',')
}, [selectedSpell?.id]) }, [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(() => { const showKinds = useMemo(() => {
if (selectedFauxSpell === 'notifications') { if (selectedFauxSpell === 'notifications') {
return PROFILE_FEED_KINDS return PROFILE_FEED_KINDS
@ -599,7 +657,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [ExtendedKind.FOLLOW_PACK] return [ExtendedKind.FOLLOW_PACK]
} }
if (selectedFauxSpell === 'media') { if (selectedFauxSpell === 'media') {
return [...MEDIA_SPELL_KINDS] return [...MEDIA_SPELL_SHOW_KINDS]
} }
if (selectedFauxSpell === 'calendar') { if (selectedFauxSpell === 'calendar') {
return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME]
@ -616,12 +674,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
.map((tag) => parseInt(tag[1], 10)) .map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
return kinds.length ? kinds : [1] return kinds.length ? kinds : [1]
}, [ }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey])
selectedFauxSpell,
selectedSpell?.id,
showKindsTagKey,
kindFilterShowKinds
])
const spellMenuLabel = useCallback( const spellMenuLabel = useCallback(
(spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)), (spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)),
@ -1043,7 +1096,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="min-h-0 min-w-0 flex-1"> <div className="min-h-0 min-w-0 flex-1">
<NoteList <NoteList
subRequests={subRequests} subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey}
showKinds={showKinds} showKinds={showKinds}
useTimelineCacheBootstrap
useFilterAsIs={fauxNoteListUseFilterAsIs} useFilterAsIs={fauxNoteListUseFilterAsIs}
showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true} showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true}
showKind1Replies={selectedFauxSpell === 'following' ? showKind1Replies : true} showKind1Replies={selectedFauxSpell === 'following' ? showKind1Replies : true}
@ -1052,6 +1107,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
extraShouldHideEvent={ extraShouldHideEvent={
selectedFauxSpell === 'notifications' && pubkey selectedFauxSpell === 'notifications' && pubkey
? notificationsMentionExtraHide ? notificationsMentionExtraHide
: selectedFauxSpell === 'media'
? mediaSpellExtraShouldHideEvent
: undefined : undefined
} }
hideUntrustedNotes={ hideUntrustedNotes={
@ -1062,7 +1119,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</> </>
) : selectedSpell ? ( ) : selectedSpell ? (
subRequests.length > 0 ? ( subRequests.length > 0 ? (
<NoteList subRequests={subRequests} showKinds={showKinds} useFilterAsIs /> <NoteList
subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey}
showKinds={showKinds}
useTimelineCacheBootstrap
useFilterAsIs
/>
) : !pubkey && ) : !pubkey &&
selectedSpell.tags.some( selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts')) (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
const relayUrls = useMemo(() => Object.keys(relayRefCount), [relayRefCount]) const relayUrls = useMemo(() => Object.keys(relayRefCount), [relayRefCount])
const addRelayUrls = useCallback((urls: string[]) => { const addRelayUrls = useCallback((urls: string[]) => {
if (!urls.length) return
setRelayRefCount((prev) => { setRelayRefCount((prev) => {
const newCounts = { ...prev } const newCounts = { ...prev }
urls.forEach((url) => { urls.forEach((url) => {
@ -31,6 +32,7 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode
}, []) }, [])
const removeRelayUrls = useCallback((urls: string[]) => { const removeRelayUrls = useCallback((urls: string[]) => {
if (!urls.length) return
setRelayRefCount((prev) => { setRelayRefCount((prev) => {
const newCounts = { ...prev } const newCounts = { ...prev }
urls.forEach((url) => { urls.forEach((url) => {
@ -45,8 +47,13 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode
}) })
}, []) }, [])
const contextValue = useMemo(
() => ({ relayUrls, addRelayUrls, removeRelayUrls }),
[relayUrls, addRelayUrls, removeRelayUrls]
)
return ( return (
<CurrentRelaysContext.Provider value={{ relayUrls, addRelayUrls, removeRelayUrls }}> <CurrentRelaysContext.Provider value={contextValue}>
{children} {children}
</CurrentRelaysContext.Provider> </CurrentRelaysContext.Provider>
) )

18
src/services/client.service.ts

@ -853,16 +853,22 @@ class ClientService extends EventTarget {
{ {
startLogin, startLogin,
needSort = true, needSort = true,
useCache = false useCache = false,
omitDefaultSinceWhenUseCache = false
}: { }: {
startLogin?: () => void startLogin?: () => void
needSort?: boolean needSort?: boolean
useCache?: 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 newEventIdSet = new Set<string>()
const requestCount = subRequests.length 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 eventIdSet = new Set<string>()
let events: NEvent[] = [] let events: NEvent[] = []
let eosedCount = 0 let eosedCount = 0
@ -901,7 +907,7 @@ class ClientService extends EventTarget {
}, },
onClose onClose
}, },
{ startLogin, needSort, useCache } { startLogin, needSort, useCache, omitDefaultSinceWhenUseCache }
) )
}) })
) )
@ -1191,11 +1197,13 @@ class ClientService extends EventTarget {
{ {
startLogin, startLogin,
needSort = true, needSort = true,
useCache = false useCache = false,
omitDefaultSinceWhenUseCache = false
}: { }: {
startLogin?: () => void startLogin?: () => void
needSort?: boolean needSort?: boolean
useCache?: boolean useCache?: boolean
omitDefaultSinceWhenUseCache?: boolean
} = {} } = {}
) { ) {
const relays = Array.from(new Set(urls)) const relays = Array.from(new Set(urls))
@ -1245,7 +1253,7 @@ class ClientService extends EventTarget {
// CRITICAL FIX: Only set since parameter if caching is enabled // CRITICAL FIX: Only set since parameter if caching is enabled
// When useCache is false, we want to stream raw from relays without time restrictions // 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 // 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) // 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 // This ensures we get recent content even if relays are slow
const oneDayAgo = dayjs().subtract(24, 'hours').unix() const oneDayAgo = dayjs().subtract(24, 'hours').unix()

20
vite.config.ts

@ -1,6 +1,7 @@
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { execSync } from 'child_process' import { execSync } from 'child_process'
import path from 'path' import path from 'path'
import type { Plugin } from 'vite'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import packageJson from './package.json' import packageJson from './package.json'
@ -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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: '/', base: '/',
@ -172,6 +191,7 @@ export default defineConfig({
}, },
plugins: [ plugins: [
react(), react(),
fullReloadOnProvidersAndPages(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
// Use public/manifest.webmanifest and index.html <link> only; avoid duplicate manifest link in build // Use public/manifest.webmanifest and index.html <link> only; avoid duplicate manifest link in build

Loading…
Cancel
Save