From 8332a4616d9a0444d60d0b900d2baae1ebe3f535 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 27 Mar 2026 20:28:40 +0100 Subject: [PATCH] bug-fixes --- src/components/KindFilter/index.tsx | 51 +++++++++++-- src/components/MemePicker/index.tsx | 34 +++++++-- src/components/NormalFeed/index.tsx | 6 +- src/components/NoteList/index.tsx | 100 +++++++++++++++++++------- src/constants.ts | 2 + src/i18n/locales/de.ts | 5 ++ src/i18n/locales/en.ts | 7 ++ src/providers/KindFilterProvider.tsx | 28 +++++++- src/services/local-storage.service.ts | 17 +++++ src/services/meme.service.ts | 65 +++++++++++++---- 10 files changed, 262 insertions(+), 53 deletions(-) diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index fa497518..6a761260 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Switch } from '@/components/ui/switch' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer' import { Label } from '@/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -57,13 +58,16 @@ export default function KindFilter({ showKind1OPs: savedShowKind1OPs, showKind1Replies: savedShowKind1Replies, showKind1111: savedShowKind1111, - updateShowKinds + feedKindFilterBypass, + updateShowKinds, + updateFeedKindFilterBypass } = useKindFilter() const [open, setOpen] = useState(false) const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) const [temporaryShowKind1OPs, setTemporaryShowKind1OPs] = useState(savedShowKind1OPs) const [temporaryShowKind1Replies, setTemporaryShowKind1Replies] = useState(savedShowKind1Replies) const [temporaryShowKind1111, setTemporaryShowKind1111] = useState(savedShowKind1111) + const [temporarySeeAllEvents, setTemporarySeeAllEvents] = useState(feedKindFilterBypass) const [isPersistent, setIsPersistent] = useState(true) const isDifferentFromSaved = useMemo( () => !isSameKindFilter(showKinds, savedShowKinds), @@ -74,16 +78,19 @@ export default function KindFilter({ !isSameKindFilter(temporaryShowKinds, savedShowKinds) || temporaryShowKind1OPs !== savedShowKind1OPs || temporaryShowKind1Replies !== savedShowKind1Replies || - temporaryShowKind1111 !== savedShowKind1111, + temporaryShowKind1111 !== savedShowKind1111 || + temporarySeeAllEvents !== feedKindFilterBypass, [ temporaryShowKinds, savedShowKinds, temporaryShowKind1OPs, temporaryShowKind1Replies, temporaryShowKind1111, + temporarySeeAllEvents, savedShowKind1OPs, savedShowKind1Replies, - savedShowKind1111 + savedShowKind1111, + feedKindFilterBypass ] ) @@ -93,9 +100,17 @@ export default function KindFilter({ setTemporaryShowKind1OPs(savedShowKind1OPs) setTemporaryShowKind1Replies(savedShowKind1Replies) setTemporaryShowKind1111(savedShowKind1111) + setTemporarySeeAllEvents(feedKindFilterBypass) setIsPersistent(true) } - }, [open, showKinds, savedShowKind1OPs, savedShowKind1Replies, savedShowKind1111]) + }, [ + open, + showKinds, + savedShowKind1OPs, + savedShowKind1Replies, + savedShowKind1111, + feedKindFilterBypass + ]) const appliedShowKinds = useMemo( () => @@ -107,11 +122,19 @@ export default function KindFilter({ ), [temporaryShowKinds, temporaryShowKind1OPs, temporaryShowKind1Replies, temporaryShowKind1111] ) - const canApply = appliedShowKinds.length > 0 + const canApply = temporarySeeAllEvents || appliedShowKinds.length > 0 const handleApply = () => { if (!canApply) return + updateFeedKindFilterBypass(temporarySeeAllEvents, { persist: isPersistent }) + + if (temporarySeeAllEvents) { + setOpen(false) + onShowKindsChange(showKinds) + return + } + const newShowKinds = appliedShowKinds if (!isSameKindFilter(newShowKinds, showKinds)) { onShowKindsChange(newShowKinds) @@ -131,7 +154,8 @@ export default function KindFilter({ size="titlebar-icon" className={cn( 'relative w-fit px-2 h-8 text-xs focus:text-foreground', - !isDifferentFromSaved && 'text-muted-foreground' + !isDifferentFromSaved && !feedKindFilterBypass && 'text-muted-foreground', + feedKindFilterBypass && 'text-amber-600 dark:text-amber-400' )} onClick={() => { if (isSmallScreen) { @@ -149,7 +173,19 @@ export default function KindFilter({ const content = (
-
+
+ {t('Use filter')} + + {t('See all events')} +
+

+ {temporarySeeAllEvents ? t('See all events hint') : t('Use filter hint')} +

+
{/* Posts (OPs) - kind 1 top-level only */}
diff --git a/src/components/MemePicker/index.tsx b/src/components/MemePicker/index.tsx index d0c2bfa2..05e5468e 100644 --- a/src/components/MemePicker/index.tsx +++ b/src/components/MemePicker/index.tsx @@ -13,11 +13,18 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' -import { fetchMemes, searchMemes, type MemeMetadata } from '@/services/meme.service' +import { + fetchMemes, + mergeMemesIntoIdbCache, + memeMetadataFrom1063Event, + searchMemes, + type MemeMetadata +} from '@/services/meme.service' import mediaUpload from '@/services/media-upload.service' import { ExternalLink, X } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' const MEMEAMIGO_URL = 'https://www.memeamigo.lol/' const MEMEAMIGO_SEARCH_URL = (q: string) => @@ -154,10 +161,18 @@ export default function MemePicker({ seen.add(n) return true }) - await publish(draft, { specifiedRelayUrls }) + const published = await publish(draft, { specifiedRelayUrls }) + const meta = memeMetadataFrom1063Event(published) + if (meta) { + await mergeMemesIntoIdbCache([meta]) + setMemes((prev) => { + const next = [meta, ...prev.filter((m) => m.eventId !== meta.eventId)] + return next.slice(0, 50) + }) + } setPublishDescription('') setQuery('') - await loadMemes('', true) + await loadMemes('', false) } catch (err) { setUploadError(err instanceof Error ? err.message : 'Upload failed') } finally { @@ -221,6 +236,7 @@ export default function MemePicker({ kind: ExtendedKind.FILE_METADATA, content: descriptionForPublish, tags: [ + ['file', url, mime, 'size 0'], ['url', url], ['m', mime], ['t', 'memeamigo'] @@ -235,10 +251,16 @@ export default function MemePicker({ seen.add(n) return true }) - await publish(draft, { specifiedRelayUrls }) + const published = await publish(draft, { specifiedRelayUrls }) + const meta = memeMetadataFrom1063Event(published) + if (meta) { + await mergeMemesIntoIdbCache([meta]) + } setPublishDescription('') - } catch { - // ignore; URL was still inserted + } catch (err) { + toast.error( + err instanceof Error ? err.message : t('Failed to publish meme template for the picker') + ) } finally { setPublishingPaste(false) } diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 9cb5b122..beea684f 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -41,7 +41,8 @@ const NormalFeed = forwardRef(() => { const storedMode = storage.getNoteListMode() if (isMainFeed) { @@ -101,7 +102,7 @@ const NormalFeed = forwardRef setSubHeader(null) - }, [isMainFeed, setSubHeader, listMode, showKindsKey, onSubHeaderRefresh]) + }, [isMainFeed, setSubHeader, listMode, showKindsKey, feedKindFilterBypass, onSubHeaderRefresh]) const renderTabsInFeed = !(isMainFeed && setSubHeader) @@ -115,6 +116,7 @@ const NormalFeed = forwardRef() return events.slice(0, showCount).filter((evt) => { - if (!showKinds.includes(evt.kind)) return false - // Kind 1: show only OPs if showKind1OPs, only replies if showKind1Replies - if (evt.kind === kinds.ShortTextNote) { - const isReply = isReplyNoteEvent(evt) - if (isReply && !showKind1Replies) return false - if (!isReply && !showKind1OPs) return false + if (!seeAllFeedEvents) { + if (!showKinds.includes(evt.kind)) return false + // Kind 1: show only OPs if showKind1OPs, only replies if showKind1Replies + if (evt.kind === kinds.ShortTextNote) { + const isReply = isReplyNoteEvent(evt) + if (isReply && !showKind1Replies) return false + if (!isReply && !showKind1OPs) return false + } + // Kind 1111 (comments): show only if showKind1111 + if (evt.kind === ExtendedKind.COMMENT && !showKind1111) return false } - // Kind 1111 (comments): show only if showKind1111 - if (evt.kind === ExtendedKind.COMMENT && !showKind1111) return false if (shouldHideEvent(evt)) return false const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id @@ -469,7 +484,16 @@ const NoteList = forwardRef( idSet.add(id) return true }) - }, [events, showCount, shouldHideEvent, showKinds, showKind1OPs, showKind1Replies, showKind1111]) + }, [ + events, + showCount, + shouldHideEvent, + showKinds, + showKind1OPs, + showKind1Replies, + showKind1111, + seeAllFeedEvents + ]) useLayoutEffect(() => { if (!feedPaintSessionPendingRef.current && !feedPaintRelayPendingRef.current) return @@ -514,13 +538,15 @@ const NoteList = forwardRef( const idSet = new Set() return newEvents.filter((event: Event) => { - if (!showKinds.includes(event.kind)) return false - if (event.kind === kinds.ShortTextNote) { - const isReply = isReplyNoteEvent(event) - if (isReply && !showKind1Replies) return false - if (!isReply && !showKind1OPs) return false + if (!seeAllFeedEvents) { + if (!showKinds.includes(event.kind)) return false + if (event.kind === kinds.ShortTextNote) { + const isReply = isReplyNoteEvent(event) + if (isReply && !showKind1Replies) return false + if (!isReply && !showKind1OPs) return false + } + if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false } - if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false if (shouldHideEvent(event)) return false const id = isReplaceableEvent(event.kind) @@ -532,7 +558,15 @@ const NoteList = forwardRef( idSet.add(id) return true }) - }, [newEvents, shouldHideEvent, showKinds, showKind1OPs, showKind1Replies, showKind1111]) + }, [ + newEvents, + shouldHideEvent, + showKinds, + showKind1OPs, + showKind1Replies, + showKind1111, + seeAllFeedEvents + ]) useLayoutEffect(() => { if (!onSpellFeedFirstPaint || spellFeedInstrumentToken === undefined) return @@ -753,6 +787,8 @@ const NoteList = forwardRef( const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote] + const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current + const mappedSubRequests = subRequestsRef.current.map(({ urls, filter }) => { const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) if (useFilterAsIs) { @@ -771,6 +807,16 @@ const NoteList = forwardRef( } return { urls, filter: finalFilter } } + if (seeAllNoSpell) { + const { kinds: _omitKinds, ...rest } = filter + return { + urls, + filter: { + ...rest, + limit: areAlgoRelays ? ALGO_LIMIT : LIMIT + } + } + } return { urls, filter: { @@ -783,6 +829,7 @@ const NoteList = forwardRef( const filterMissingKinds = (f: Filter) => !f.kinds || f.kinds.length === 0 const invalidFilters = mappedSubRequests.filter(({ filter: f }) => { + if (seeAllNoSpell) return false if (!filterMissingKinds(f)) return false if (useFilterAsIs && clientSideKindFilter && timelineFilterHasNonKindScope(f)) return false return true @@ -802,6 +849,7 @@ const NoteList = forwardRef( } const narrowLiveBatch = (evs: Event[]) => { + if (seeAllNoSpell) return evs if (!useFilterAsIs || !clientSideKindFilter) return evs return evs.filter((e) => showKinds.includes(e.kind)) } @@ -1036,14 +1084,17 @@ const NoteList = forwardRef( onNew: (event: Event) => { if (!effectActive) return feedRelayReturnedAnyEventRef.current = true - if (!useFilterAsIs && !showKinds.includes(event.kind)) return + const seeAll = seeAllFeedEventsRef.current && !useFilterAsIs + if (!seeAll && !useFilterAsIs && !showKinds.includes(event.kind)) return if (clientSideKindFilter && useFilterAsIs && !showKinds.includes(event.kind)) return - if (event.kind === kinds.ShortTextNote) { - const isReply = isReplyNoteEvent(event) - if (isReply && !showKind1Replies) return - if (!isReply && !showKind1OPs) return + if (!seeAll) { + if (event.kind === kinds.ShortTextNote) { + const isReply = isReplyNoteEvent(event) + if (isReply && !showKind1Replies) return + if (!isReply && !showKind1OPs) return + } + if (event.kind === ExtendedKind.COMMENT && !showKind1111) return } - if (event.kind === ExtendedKind.COMMENT && !showKind1111) return if (shouldHideEventRef.current(event)) return if (pubkey && event.pubkey === pubkey) { // If the new event is from the current user, insert it directly into the feed @@ -1132,6 +1183,7 @@ const NoteList = forwardRef( showKind1OPs, showKind1Replies, showKind1111, + seeAllFeedEvents, useFilterAsIs, areAlgoRelays, relayCapabilityReady, diff --git a/src/constants.ts b/src/constants.ts index 7c6e21d1..6965c477 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -124,6 +124,8 @@ export const StorageKey = { SHOW_KIND_1_OPs: 'showKind1OPs', SHOW_KIND_1_REPLIES: 'showKind1Replies', SHOW_KIND_1111: 'showKind1111', + /** When true, main feed REQs omit `kinds` and the client does not filter by kind (testing). */ + FEED_KIND_FILTER_BYPASS: 'feedKindFilterBypass', /** @deprecated use SHOW_KIND_1_REPLIES + SHOW_KIND_1111 */ SHOW_REPLIES_AND_COMMENTS: 'showRepliesAndComments', HIDE_CONTENT_MENTIONING_MUTED_USERS: 'hideContentMentioningMutedUsers', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index a93b7959..a2be4872 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -727,6 +727,11 @@ export default { 'Select All': 'Alle auswählen', 'Clear All': 'Alle löschen', 'Set as default filter': 'Als Standardfilter festlegen', + 'Use filter': 'Filter nutzen', + 'See all events': 'Alle Ereignisse', + 'See all events hint': + 'Feed-Anfragen ohne Kind-Filter; alle Event-Arten werden angezeigt (Relay-Limits und andere Regeln gelten weiter). Zum Testen neuer Event-Kinds.', + 'Use filter hint': 'Nur unten ausgewählte Kinds werden angefragt und angezeigt.', Apply: 'Anwenden', Reset: 'Zurücksetzen', 'Share something on this Relay': 'Teile etwas auf diesem Relay', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 845b5224..34e5c4cc 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -308,6 +308,8 @@ export default { 'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.', 'No meme templates found. Try searching or open Meme Amigo. The grid only lists kind 1063 (NIP-94) files tagged memeamigo (not random photos from notes).': 'No meme templates found. Try searching or open Meme Amigo. The grid only lists kind 1063 (NIP-94) files tagged memeamigo (not random photos from notes).', + 'Failed to publish meme template for the picker': + 'Failed to publish meme template for the picker', '{{name}} is not a GIF file': '{{name}} is not a GIF file', '{{name}} is not a JPEG, PNG, or WebP file': '{{name}} is not a JPEG, PNG, or WebP file', 'R & W': 'R & W', @@ -766,6 +768,11 @@ export default { 'Select All': 'Select All', 'Clear All': 'Clear All', 'Set as default filter': 'Set as default filter', + 'Use filter': 'Use filter', + 'See all events': 'See all events', + 'See all events hint': + 'Feed requests omit kind filters and every kind is shown (still subject to relay limits and other feed rules). For testing new event kinds.', + 'Use filter hint': 'Only the kinds you select below are requested and shown.', Apply: 'Apply', Reset: 'Reset', 'Share something on this Relay': 'Share something on this Relay', diff --git a/src/providers/KindFilterProvider.tsx b/src/providers/KindFilterProvider.tsx index 36b912eb..4ed12870 100644 --- a/src/providers/KindFilterProvider.tsx +++ b/src/providers/KindFilterProvider.tsx @@ -25,6 +25,8 @@ type TKindFilterContext = { showKind1OPs: boolean showKind1Replies: boolean showKind1111: boolean + /** When true, main feed omits REQ `kinds` and skips client-side kind filtering (testing). */ + feedKindFilterBypass: boolean updateShowKinds: ( kinds: number[], options?: { @@ -38,6 +40,7 @@ type TKindFilterContext = { updateShowKind1OPs: (value: boolean) => void updateShowKind1Replies: (value: boolean) => void updateShowKind1111: (value: boolean) => void + updateFeedKindFilterBypass: (value: boolean, options?: { persist?: boolean }) => void } const KindFilterContext = createContext(undefined) @@ -56,6 +59,7 @@ export function KindFilterProvider({ children }: { children: React.ReactNode }) const storedShowKind1OPs = storage.getShowKind1OPs() const storedShowKind1Replies = storage.getShowKind1Replies() const storedShowKind1111 = storage.getShowKind1111() + const storedFeedKindFilterBypass = storage.getFeedKindFilterBypass() const [showKinds, setShowKindsState] = useState( storedShowKinds.length > 0 ? storedShowKinds : defaultShowKinds @@ -63,6 +67,7 @@ export function KindFilterProvider({ children }: { children: React.ReactNode }) const [showKind1OPs, setShowKind1OPsState] = useState(storedShowKind1OPs) const [showKind1Replies, setShowKind1RepliesState] = useState(storedShowKind1Replies) const [showKind1111, setShowKind1111State] = useState(storedShowKind1111) + const [feedKindFilterBypass, setFeedKindFilterBypassState] = useState(storedFeedKindFilterBypass) const updateShowKinds = useCallback( ( @@ -116,18 +121,37 @@ export function KindFilterProvider({ children }: { children: React.ReactNode }) setShowKindsState(next) }, [showKinds, showKind1OPs, showKind1Replies]) + const updateFeedKindFilterBypass = useCallback((value: boolean, options?: { persist?: boolean }) => { + const persist = options?.persist !== false + if (persist) storage.setFeedKindFilterBypass(value) + setFeedKindFilterBypassState(value) + }, []) + const value = useMemo( () => ({ showKinds, showKind1OPs, showKind1Replies, showKind1111, + feedKindFilterBypass, updateShowKinds, updateShowKind1OPs, updateShowKind1Replies, - updateShowKind1111 + updateShowKind1111, + updateFeedKindFilterBypass }), - [showKinds, showKind1OPs, showKind1Replies, showKind1111, updateShowKinds, updateShowKind1OPs, updateShowKind1Replies, updateShowKind1111] + [ + showKinds, + showKind1OPs, + showKind1Replies, + showKind1111, + feedKindFilterBypass, + updateShowKinds, + updateShowKind1OPs, + updateShowKind1Replies, + updateShowKind1111, + updateFeedKindFilterBypass + ] ) return {children} diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index f94211f4..acca424e 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -51,6 +51,7 @@ const SETTINGS_KEYS = [ StorageKey.SHOW_KIND_1_OPs, StorageKey.SHOW_KIND_1_REPLIES, StorageKey.SHOW_KIND_1111, + StorageKey.FEED_KIND_FILTER_BYPASS, StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS, StorageKey.NOTIFICATION_LIST_STYLE, StorageKey.MEDIA_AUTO_LOAD_POLICY, @@ -96,6 +97,8 @@ class LocalStorageService { private showKind1OPs: boolean = true private showKind1Replies: boolean = true private showKind1111: boolean = true + /** Omit kinds in feed REQ + skip client kind filtering (testing). */ + private feedKindFilterBypass: boolean = false private hideContentMentioningMutedUsers: boolean = false private notificationListStyle: TNotificationStyle = NOTIFICATION_LIST_STYLE.DETAILED private mediaAutoLoadPolicy: TMediaAutoLoadPolicy = MEDIA_AUTO_LOAD_POLICY.ALWAYS @@ -332,6 +335,9 @@ class LocalStorageService { this.showKind1111 = this.showKinds.includes(ExtendedKind.COMMENT) } + const feedKindFilterBypassStr = window.localStorage.getItem(StorageKey.FEED_KIND_FILTER_BYPASS) + this.feedKindFilterBypass = feedKindFilterBypassStr === 'true' + this.hideContentMentioningMutedUsers = window.localStorage.getItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true' @@ -512,6 +518,8 @@ class LocalStorageService { if (showKind1RepliesStr != null) this.showKind1Replies = showKind1RepliesStr === 'true' const showKind1111Str = get(StorageKey.SHOW_KIND_1111) if (showKind1111Str != null) this.showKind1111 = showKind1111Str === 'true' + const feedKindFilterBypassStr = get(StorageKey.FEED_KIND_FILTER_BYPASS) + if (feedKindFilterBypassStr != null) this.feedKindFilterBypass = feedKindFilterBypassStr === 'true' this.hideContentMentioningMutedUsers = get(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true' const notifStyle = get(StorageKey.NOTIFICATION_LIST_STYLE) if (notifStyle != null) this.notificationListStyle = notifStyle === NOTIFICATION_LIST_STYLE.COMPACT ? NOTIFICATION_LIST_STYLE.COMPACT : NOTIFICATION_LIST_STYLE.DETAILED @@ -831,6 +839,15 @@ class LocalStorageService { this.persistSetting(StorageKey.SHOW_KIND_1111, value.toString()) } + getFeedKindFilterBypass(): boolean { + return this.feedKindFilterBypass + } + + setFeedKindFilterBypass(value: boolean) { + this.feedKindFilterBypass = value + this.persistSetting(StorageKey.FEED_KIND_FILTER_BYPASS, value.toString()) + } + getHideContentMentioningMutedUsers() { return this.hideContentMentioningMutedUsers } diff --git a/src/services/meme.service.ts b/src/services/meme.service.ts index 95e2da45..28158858 100644 --- a/src/services/meme.service.ts +++ b/src/services/meme.service.ts @@ -216,13 +216,34 @@ function parseMemeFrom1063(event: NEvent): MemeMetadata | null { } } -function parseMemeFromEvent(event: NEvent): MemeMetadata | null { +export function memeMetadataFrom1063Event(event: NEvent): MemeMetadata | null { if (event.kind !== ExtendedKind.FILE_METADATA) return null return parseMemeFrom1063(event) } -const CACHE_MAX_AGE_MS = 5 * 60 * 1000 -const MIN_MEME_CACHE_ENTRIES = 6 +/** Keep offline / flaky-relay grids usable: cache is valid for days, not minutes. */ +const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +/** Single-item lists (e.g. only your template) still cache and hydrate the picker. */ +const MIN_MEME_CACHE_ENTRIES = 1 +const MEME_CACHE_CAP = 200 + +/** + * Merge new memes into IndexedDB (dedupe by normalized URL, newest wins). Call after publishing 1063. + */ +export async function mergeMemesIntoIdbCache(incoming: MemeMetadata[]): Promise { + if (incoming.length === 0) return + const row = await indexedDb.getMemeCache() + const byKey = new Map() + for (const m of row?.memes ?? []) { + const meta = m as MemeMetadata + if (meta?.url) byKey.set(normalizeMemeUrl(meta.url), meta) + } + for (const m of incoming) { + if (m.url) byKey.set(normalizeMemeUrl(m.url), m) + } + const merged = [...byKey.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, MEME_CACHE_CAP) + await indexedDb.setMemeCache(merged, Date.now()) +} const THECITADEL_FOR_FILE_METADATA = normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com' @@ -245,6 +266,14 @@ export async function fetchMemes( } } + let staleFallback: MemeMetadata[] | null = null + if (!searchQuery) { + const row = await indexedDb.getMemeCache() + if (row?.memes?.length) { + staleFallback = row.memes as MemeMetadata[] + } + } + const readUrls = [ ...GIF_RELAY_URLS, ...FAST_READ_RELAY_URLS, @@ -270,15 +299,23 @@ export async function fetchMemes( ? dedupedUrls : [...dedupedUrls, THECITADEL_FOR_FILE_METADATA] - const events = await queryService.fetchEvents( - relays1063, - { kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 }, - fetchOpts - ) + let events: NEvent[] = [] + try { + events = await queryService.fetchEvents( + relays1063, + { kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 }, + fetchOpts + ) + } catch (err) { + if (!searchQuery && staleFallback?.length) { + return staleFallback.slice(0, limit) as MemeMetadata[] + } + throw err + } const byUrl = new Map() for (const event of events) { - const meme = parseMemeFromEvent(event) + const meme = memeMetadataFrom1063Event(event) if (!meme) continue if (searchQuery) { @@ -299,10 +336,14 @@ export async function fetchMemes( const memes = Array.from(byUrl.values()).map((v) => v.meme) memes.sort((a, b) => b.createdAt - a.createdAt) - const result = memes.slice(0, limit) + let result = memes.slice(0, limit) + + if (!searchQuery && result.length === 0 && staleFallback?.length) { + result = staleFallback.slice(0, limit) + } - if (result.length >= MIN_MEME_CACHE_ENTRIES && !searchQuery) { - await indexedDb.setMemeCache(result, Date.now()) + if (result.length > 0 && !searchQuery) { + await mergeMemesIntoIdbCache(result) } return result