Browse Source

fix gif picker

imwald
Silberengel 2 weeks ago
parent
commit
db3dc48846
  1. 88
      src/components/GifPicker/index.tsx
  2. 2
      src/constants.ts
  3. 12
      src/lib/relay-auth-feedback.ts
  4. 13
      src/services/client.service.ts
  5. 74
      src/services/gif.service.test.ts
  6. 517
      src/services/gif.service.ts

88
src/components/GifPicker/index.tsx

@ -10,14 +10,14 @@ import { Label } from '@/components/ui/label' @@ -10,14 +10,14 @@ import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMailboxRelayUrls'
import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls'
import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants'
import { ExtendedKind } from '@/constants'
import { cn } from '@/lib/utils'
import { normalizeUrl } from '@/lib/url'
import {
fetchGifs,
getAllCachedGifsForSearch,
getGif1063RelayUrls,
gifMetadataMatchesSearch,
gifShouldOfferNip94Archive,
buildKind1063GifPublishDraft,
@ -28,12 +28,15 @@ import mediaUpload from '@/services/media-upload.service' @@ -28,12 +28,15 @@ import mediaUpload from '@/services/media-upload.service'
import { Download, ExternalLink, X } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useFollowListOptional } from '@/providers/follow-list-context'
/** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */
let _sessionGifs: GifMetadata[] = []
import { useTranslation } from 'react-i18next'
const GIFBUDDY_URL = 'https://www.gifbuddy.lol/'
/** Stable empty follows list — avoids re-running picker fetch every render. */
const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = []
/** Query param gifbuddy may use for pre-filled search (common convention). */
const GIFBUDDY_SEARCH_URL = (q: string) =>
q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL
@ -51,6 +54,12 @@ export default function GifPicker({ @@ -51,6 +54,12 @@ export default function GifPicker({
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { publish, pubkey } = useNostr()
const followList = useFollowListOptional()
const followingPubkeys = useMemo(
() => followList?.followings ?? EMPTY_FOLLOWING_PUBKEYS,
[followList?.followings]
)
const loadGenerationRef = useRef(0)
const [open, setOpen] = useState(false)
const [searchInput, setSearchInput] = useState('')
// Initialise from the module-level session cache so re-opens are instant
@ -71,36 +80,9 @@ export default function GifPicker({ @@ -71,36 +80,9 @@ export default function GifPicker({
const gifbuddyPopupRef = useRef<Window | null>(null)
const userReadRelays = useUserReadInboxUrls()
const userWriteRelays = useUserWriteOutboxUrls()
/** Paste / upload: GIF discovery relays + user writes (unchanged). */
const gifPublishRelayUrls = useMemo(() => {
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
return writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
}, [userWriteRelays])
/** Grid pick / archive: user write relays first, then fast write relays as fallback. */
const gifSelectPublishRelayUrls = useMemo(() => {
const primary =
userWriteRelays.length > 0 ? userWriteRelays : [...FAST_WRITE_RELAY_URLS]
const extra = userWriteRelays.length > 0 ? FAST_WRITE_RELAY_URLS : []
const seen = new Set<string>()
return [...primary, ...extra]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.filter((u) => {
const n = u.toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
}, [userWriteRelays])
/** Kind 1063 publish targets — GIF relays only. */
const gif1063PublishRelayUrls = useMemo(() => getGif1063RelayUrls(), [])
/** Keep gifsRef, session cache, and React state in sync. */
const setGifs = useCallback((newGifs: GifMetadata[], isSearch = false) => {
@ -116,25 +98,27 @@ export default function GifPicker({ @@ -116,25 +98,27 @@ export default function GifPicker({
const pool = gifPoolRef.current
const filtered = trimmed
? pool.filter((g) => gifMetadataMatchesSearch(g, trimmed))
: pool.slice(0, 50)
: pool
setGifs(filtered, trimmed.length > 0)
},
[setGifs]
)
const refreshGifPoolFromIdb = useCallback(async () => {
const pool = await getAllCachedGifsForSearch(pubkey ?? null)
const pool = await getAllCachedGifsForSearch(pubkey ?? null, followingPubkeys)
gifPoolRef.current = pool
return pool
}, [pubkey])
}, [pubkey, followingPubkeys])
const loadGifs = useCallback(
async (forceRefresh = false) => {
const generation = ++loadGenerationRef.current
setError(null)
if (gifPoolRef.current.length === 0) {
try {
const cached = await refreshGifPoolFromIdb()
if (generation !== loadGenerationRef.current) return
if (cached.length > 0) {
applyLocalFilter(searchInputRef.current)
}
@ -145,10 +129,17 @@ export default function GifPicker({ @@ -145,10 +129,17 @@ export default function GifPicker({
}
try {
await fetchGifs(50, forceRefresh, userReadRelays, pubkey ?? null)
await fetchGifs({
forceRefresh: forceRefresh || Boolean(pubkey),
userPubkey: pubkey ?? null,
followingPubkeys,
noteFallbackRelays: userReadRelays
})
if (generation !== loadGenerationRef.current) return
await refreshGifPoolFromIdb()
if (generation !== loadGenerationRef.current) return
applyLocalFilter(searchInputRef.current)
if (gifPoolRef.current.length === 0 && !searchInput.trim()) {
if (gifPoolRef.current.length === 0 && !searchInputRef.current.trim()) {
setError(
t(
'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.'
@ -156,13 +147,14 @@ export default function GifPicker({ @@ -156,13 +147,14 @@ export default function GifPicker({
)
}
} catch (e) {
if (generation !== loadGenerationRef.current) return
setError(e instanceof Error ? e.message : 'Failed to load GIFs')
if (gifPoolRef.current.length === 0) setGifsState([])
} finally {
setLoading(false)
if (generation === loadGenerationRef.current) setLoading(false)
}
},
[t, userReadRelays, pubkey, applyLocalFilter, refreshGifPoolFromIdb]
[t, userReadRelays, pubkey, followingPubkeys, applyLocalFilter, refreshGifPoolFromIdb]
)
useEffect(() => {
@ -185,11 +177,11 @@ export default function GifPicker({ @@ -185,11 +177,11 @@ export default function GifPicker({
if (!pubkey || !/^https?:\/\//i.test(url)) return
// Fire-and-forget: waiting on every relay can freeze the UI when relays are down.
void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gifSelectPublishRelayUrls
specifiedRelayUrls: gif1063PublishRelayUrls
}).catch(() => {})
if (desc) setPublishDescription('')
},
[pubkey, onSelect, publish, gifSelectPublishRelayUrls, publishDescription]
[pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription]
)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -217,7 +209,7 @@ export default function GifPicker({ @@ -217,7 +209,7 @@ export default function GifPicker({
tags,
created_at: Math.floor(Date.now() / 1000)
}
await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls })
await publish(draft, { specifiedRelayUrls: gif1063PublishRelayUrls })
setPublishDescription('')
setSearchInput('')
await loadGifs(true)
@ -273,7 +265,7 @@ export default function GifPicker({ @@ -273,7 +265,7 @@ export default function GifPicker({
setPublishingPaste(true)
try {
await publish(buildKind1063GifPublishDraft(url, descriptionForPublish), {
specifiedRelayUrls: gifPublishRelayUrls
specifiedRelayUrls: gif1063PublishRelayUrls
})
setPublishDescription('')
} catch {
@ -282,7 +274,7 @@ export default function GifPicker({ @@ -282,7 +274,7 @@ export default function GifPicker({
setPublishingPaste(false)
}
}
}, [pasteUrl, pubkey, onSelect, publish, gifPublishRelayUrls, descriptionForPublish])
}, [pasteUrl, pubkey, onSelect, publish, gif1063PublishRelayUrls, descriptionForPublish])
/** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */
const handleArchiveAndInsert = useCallback(
@ -298,7 +290,7 @@ export default function GifPicker({ @@ -298,7 +290,7 @@ export default function GifPicker({
setOpen(false)
void loadGifs(true)
void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gifSelectPublishRelayUrls
specifiedRelayUrls: gif1063PublishRelayUrls
})
.catch(() => {})
.finally(() => {
@ -306,7 +298,7 @@ export default function GifPicker({ @@ -306,7 +298,7 @@ export default function GifPicker({
if (desc) setPublishDescription('')
})
},
[pubkey, publish, gifSelectPublishRelayUrls, onSelect, loadGifs, publishDescription]
[pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription]
)
const gifSourceKindTitle = useCallback(
@ -372,8 +364,8 @@ export default function GifPicker({ @@ -372,8 +364,8 @@ export default function GifPicker({
<ScrollArea
className={
isDrawer
? 'flex-1 min-h-[200px] w-full rounded-md border'
: 'h-[280px] w-full rounded-md border'
? 'flex-1 min-h-[420px] w-full rounded-md border'
: 'h-[520px] w-full rounded-md border'
}
>
{loading ? (

2
src/constants.ts

@ -530,7 +530,7 @@ export const MONERO_NOSTR_RELAY_URLS = [ @@ -530,7 +530,7 @@ export const MONERO_NOSTR_RELAY_URLS = [
* Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */
export const GIF_RELAY_URLS = [
'wss://thecitadel.nostr1.com',
'wss://gifbuddy.lol'
'wss://relay.gifbuddy.lol'
]
export const SEARCHABLE_RELAY_URLS = [

12
src/lib/relay-auth-feedback.ts

@ -20,21 +20,11 @@ function relayLabel(url: string): string { @@ -20,21 +20,11 @@ function relayLabel(url: string): string {
}
}
/** User-visible result after the relay responds to NIP-42 AUTH (`OK` / failure). */
/** Relay responded to NIP-42 AUTH with OK — log only (no success toast; routine on many relays). */
export function notifyRelayNip42Accepted(url: string, okReason?: string): void {
const key = sessionKeyForRelay(url)
if (!key || nip42NotifiedAccept.has(key)) return
nip42NotifiedAccept.add(key)
const relay = relayLabel(url)
const detailSuffix = okReason?.trim() ? ` (${okReason.trim()})` : ''
toast.success(
i18n.t('Relay auth accepted (NIP-42)', {
relay,
detailSuffix,
defaultValue: `The relay accepted authentication (NIP-42): ${relay}${detailSuffix}`
})
)
logger.info('[NIP-42] Auth accepted by relay', { url, okReason })
}

13
src/services/client.service.ts

@ -654,16 +654,15 @@ class ClientService extends EventTarget { @@ -654,16 +654,15 @@ class ClientService extends EventTarget {
/** {@link runSessionPrewarm} — background fetch into GIF IndexedDB cache. */
private async runGifCachePreload(pubkey: string | null): Promise<void> {
const extra: string[] = []
let followings: string[] = []
let noteFallbackRelays: string[] = []
if (pubkey) {
const ev = await this.fetchFollowListEvent(pubkey)
if (ev) followings = getPubkeysFromPTags(ev.tags)
const rl = await this.peekRelayListFromStorage(pubkey)
const [readInboxes, writeOutboxes] = await Promise.all([
collectViewerReadInboxUrls(pubkey, rl),
collectViewerWriteOutboxUrls(pubkey, rl)
])
extra.push(...readInboxes, ...writeOutboxes)
noteFallbackRelays = await collectViewerReadInboxUrls(pubkey, rl)
}
await preloadGifsIntoIdbCache(pubkey, extra)
await preloadGifsIntoIdbCache(pubkey, followings, noteFallbackRelays)
}
/** NIP-66 discovery for Explore / publish hints — call when the user opens Explore, not at boot. */

74
src/services/gif.service.test.ts

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
import { kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
import {
dedupeGifsByUrl,
getGif1063RelayUrls,
sortGifsForPicker,
type GifMetadata
} from './gif.service'
describe('gif.service', () => {
it('getGif1063RelayUrls returns deduped GIF relay constants', () => {
const urls = getGif1063RelayUrls()
expect(urls.length).toBeGreaterThan(0)
expect(urls.some((u) => u.includes('thecitadel'))).toBe(true)
expect(urls.some((u) => u.includes('relay.gifbuddy.lol'))).toBe(true)
})
it('sortGifsForPicker orders own, follows, then others', () => {
const me = 'a'.repeat(64)
const follow = 'b'.repeat(64)
const other = 'c'.repeat(64)
const mk = (pubkey: string, createdAt: number): GifMetadata => ({
url: `https://example.com/${pubkey.slice(0, 4)}.gif`,
sourceKind: ExtendedKind.FILE_METADATA,
eventId: `${pubkey}-${createdAt}`,
pubkey,
createdAt
})
const gifs = [
mk(other, 300),
mk(follow, 200),
mk(me, 100),
mk(me, 400),
mk(follow, 500)
]
const sorted = sortGifsForPicker(gifs, me, [follow])
expect(sorted.map((g) => g.pubkey)).toEqual([
me,
me,
follow,
follow,
other
])
expect(sorted[0]!.createdAt).toBe(400)
expect(sorted[1]!.createdAt).toBe(100)
})
it('dedupeGifsByUrl keeps kind 1063 over kind 1 and 1111 for the same URL', () => {
const url = 'https://cdn.example/animation.gif'
const note = {
url,
sourceKind: kinds.ShortTextNote,
eventId: 'note-1',
pubkey: 'a'.repeat(64),
createdAt: 200
}
const comment = {
url,
sourceKind: ExtendedKind.COMMENT,
eventId: 'comment-1',
pubkey: 'a'.repeat(64),
createdAt: 300
}
const fileMeta = {
url,
sourceKind: ExtendedKind.FILE_METADATA,
eventId: '1063-1',
pubkey: 'a'.repeat(64),
createdAt: 100
}
expect(dedupeGifsByUrl([note, comment, fileMeta])).toEqual([fileMeta])
expect(dedupeGifsByUrl([note, comment])).toEqual([comment])
})
})

517
src/services/gif.service.ts

@ -6,12 +6,13 @@ @@ -6,12 +6,13 @@
import {
ExtendedKind,
FAST_READ_RELAY_URLS,
GIF_RELAY_URLS
GIF_RELAY_URLS,
METADATA_BATCH_AUTHORS_CHUNK
} from '@/constants'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { grantRelayConnectionOperationScope } from '@/lib/read-only-relay-personal'
import { normalizeUrl } from '@/lib/url'
import { kinds } from 'nostr-tools'
import type { Event as NEvent } from 'nostr-tools'
import { kinds, type Event as NEvent, type Filter } from 'nostr-tools'
import { queryService } from './client.service'
import indexedDb from './indexed-db.service'
@ -52,15 +53,24 @@ export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean { @@ -52,15 +53,24 @@ export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean {
return true
}
/** Own GIFs/memes first, then newest first (picker grids). */
export function sortGifsForPicker(gifs: GifMetadata[], userPubkey: string | null): GifMetadata[] {
/** Own GIFs, then follows, then others — newest first within each tier. */
export function sortGifsForPicker(
gifs: GifMetadata[],
userPubkey: string | null,
followingPubkeys: readonly string[] = []
): GifMetadata[] {
const u = userPubkey?.toLowerCase() ?? ''
return [...gifs].sort((a, b) => {
if (u) {
const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0
const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0
if (aOwn !== bOwn) return bOwn - aOwn
const followSet = new Set(followingPubkeys.map((p) => p.toLowerCase()).filter(Boolean))
const tier = (g: GifMetadata): number => {
const pk = g.pubkey.toLowerCase()
if (u && pk === u) return 0
if (followSet.has(pk)) return 1
return 2
}
return [...gifs].sort((a, b) => {
const ta = tier(a)
const tb = tier(b)
if (ta !== tb) return ta - tb
return b.createdAt - a.createdAt
})
}
@ -77,12 +87,35 @@ function normalizeGifUrl(url: string): string { @@ -77,12 +87,35 @@ function normalizeGifUrl(url: string): string {
}
}
/** Priority for deduplication: higher wins. Own event > other's event > non-event. */
const GIF_PRIORITY = {
OWN_EVENT: 2,
OTHER_EVENT: 1,
NON_EVENT: 0
} as const
/** Higher wins when the same GIF URL appears from multiple Nostr events. */
function gifSourceKindPriority(sourceKind: number): number {
if (sourceKind === ExtendedKind.FILE_METADATA) return 3
if (sourceKind === ExtendedKind.COMMENT) return 2
if (sourceKind === kinds.ShortTextNote) return 1
if (sourceKind === ExtendedKind.DISCUSSION) return 1
return 0
}
function shouldPreferGif(candidate: GifMetadata, existing: GifMetadata): boolean {
const cp = gifSourceKindPriority(candidate.sourceKind)
const ep = gifSourceKindPriority(existing.sourceKind)
if (cp !== ep) return cp > ep
return candidate.createdAt > existing.createdAt
}
/** One grid row per GIF URL; kind 1063 beats kind 1 / 1111 / 11 from notes. */
export function dedupeGifsByUrl(gifs: readonly GifMetadata[]): GifMetadata[] {
const byUrl = new Map<string, GifMetadata>()
for (const gif of gifs) {
if (!gif.url?.trim()) continue
const key = normalizeGifUrl(gif.url)
const existing = byUrl.get(key)
if (!existing || shouldPreferGif(gif, existing)) {
byUrl.set(key, gif)
}
}
return [...byUrl.values()]
}
/** `#t` tokens derived from a user description (always includes literal `gif` separately). */
function topicTagsFromGifDescription(description: string): string[] {
@ -279,35 +312,50 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { @@ -279,35 +312,50 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null {
}
}
const CACHE_MAX_AGE_MS = 60 * 60 * 1000 // 1 hour; short enough to stay fresh, long enough to survive browser restarts
const MIN_GIF_CACHE_ENTRIES = 8
const GIF_CACHE_CAP = 80
const CACHE_MAX_AGE_MS = 60 * 60 * 1000 // 1 hour
const MIN_GIF_CACHE_ENTRIES = 1
/** Max kind-1063 rows from authors outside the viewer + follow graph. */
const GIF_OTHERS_1063_MAX = 500
/** Per-REQ page size when paginating author/global 1063 fetches on GIF relays. */
const GIF_1063_PAGE_LIMIT = 500
const GIF_AUTHOR_1063_MAX_PAGES = 40
/** When the 1063 pool is smaller than this, also scrape kind 1 / 1111 for GIF URLs. */
const GIF_NOTES_FALLBACK_THRESHOLD = 50
const GIF_NOTES_RELAY_LIMIT = 500
type GifFetchQueryOpts = {
eoseTimeout: number
globalTimeout: number
firstRelayResultGraceMs: false
foreground?: true
backgroundInterruptImmune?: true
}
/**
* High `limit` values otherwise trigger implicit feed first-relay grace (~2s) and return before
* GIF-heavy relays (e.g. thecitadel) finish. Picker loads must also be foreground so navigation
* does not abort them via {@link QueryService.interruptBackgroundQueries}.
* GIF-heavy relays finish. Picker loads must also be foreground so navigation does not abort them.
*/
const GIF_FETCH_OPTS = {
const GIF_FETCH_OPTS: GifFetchQueryOpts = {
eoseTimeout: 20_000,
globalTimeout: 28_000,
firstRelayResultGraceMs: false as const,
foreground: true as const
firstRelayResultGraceMs: false,
foreground: true
}
/** Session-start cache preload — low priority but must finish (not aborted on navigation). */
const GIF_PRELOAD_FETCH_OPTS = {
const GIF_PRELOAD_FETCH_OPTS: GifFetchQueryOpts = {
eoseTimeout: 18_000,
globalTimeout: 26_000,
firstRelayResultGraceMs: false as const,
backgroundInterruptImmune: true as const
firstRelayResultGraceMs: false,
backgroundInterruptImmune: true
}
let gifOutboxPreloadInFlight: Promise<void> | null = null
/** Ensured on the kind 1063 (NIP-94) relay set even if absent from merged read lists. */
const THECITADEL_FOR_GIF_METADATA =
normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com'
/** Kind 1063 read/write targets — {@link GIF_RELAY_URLS} only. */
export function getGif1063RelayUrls(): string[] {
return dedupeRelayUrls(GIF_RELAY_URLS)
}
function dedupeRelayUrls(urls: readonly string[]): string[] {
const seen = new Set<string>()
@ -322,6 +370,14 @@ function dedupeRelayUrls(urls: readonly string[]): string[] { @@ -322,6 +370,14 @@ function dedupeRelayUrls(urls: readonly string[]): string[] {
})
}
function chunkPubkeys(pubkeys: readonly string[], size: number): string[][] {
const chunks: string[][] = []
for (let i = 0; i < pubkeys.length; i += size) {
chunks.push(pubkeys.slice(i, i + size))
}
return chunks
}
function normalizeCachedGif(g: GifMetadata & { sourceKind?: number }): GifMetadata {
return {
...g,
@ -330,6 +386,139 @@ function normalizeCachedGif(g: GifMetadata & { sourceKind?: number }): GifMetada @@ -330,6 +386,139 @@ function normalizeCachedGif(g: GifMetadata & { sourceKind?: number }): GifMetada
}
}
function mergeGifEventsIntoPool(
events: readonly NEvent[],
pool: Map<string, GifMetadata>,
searchQuery?: string
): void {
for (const event of events) {
const gif = parseGifFromEvent(event)
if (!gif) continue
if (searchQuery && !eventMatchesNip50LocalFullTextQuery(event, searchQuery)) continue
const key = normalizeGifUrl(gif.url)
const existing = pool.get(key)
if (!existing || shouldPreferGif(gif, existing)) {
pool.set(key, gif)
}
}
}
async function fetch1063Paginated(
relays: readonly string[],
opts: GifFetchQueryOpts,
filterBase: Omit<Filter, 'limit' | 'until'>,
maxEvents?: number
): Promise<NEvent[]> {
const out: NEvent[] = []
const seen = new Set<string>()
let until: number | undefined
for (let page = 0; page < GIF_AUTHOR_1063_MAX_PAGES; page++) {
const filter: Filter = {
...filterBase,
kinds: [ExtendedKind.FILE_METADATA],
limit: GIF_1063_PAGE_LIMIT,
...(until != null ? { until } : {})
}
const batch = await queryService.fetchEvents([...relays], filter, opts)
if (batch.length === 0) break
let oldest = until ?? Number.MAX_SAFE_INTEGER
for (const event of batch) {
if (seen.has(event.id)) continue
seen.add(event.id)
out.push(event)
if (event.created_at < oldest) oldest = event.created_at
if (maxEvents != null && out.length >= maxEvents) return out
}
if (batch.length < GIF_1063_PAGE_LIMIT) break
if (oldest <= 1) break
until = oldest - 1
}
return out
}
function gifNoteFallbackRelays(extraReadRelayUrls: readonly string[]): string[] {
return dedupeRelayUrls([...GIF_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...extraReadRelayUrls])
}
export type LoadGifPoolOptions = {
userPubkey: string | null
followingPubkeys?: readonly string[]
/** Viewer read inboxes — used only for kind 1 / 1111 fallback when the 1063 pool is tiny. */
noteFallbackRelays?: readonly string[]
fetchOpts?: GifFetchQueryOpts
}
/** Load picker pool: all viewer 1063 → all follows' 1063 → up to 500 other 1063; optional note scrape. */
export async function loadGifPoolFromRelays(options: LoadGifPoolOptions): Promise<GifMetadata[]> {
const {
userPubkey,
followingPubkeys = [],
noteFallbackRelays = [],
fetchOpts = GIF_FETCH_OPTS
} = options
const relays1063 = getGif1063RelayUrls()
const noteRelays =
noteFallbackRelays.length > 0 ? gifNoteFallbackRelays(noteFallbackRelays) : []
const revokeScope = grantRelayConnectionOperationScope([...relays1063, ...noteRelays])
try {
const pool = new Map<string, GifMetadata>()
const userKey = userPubkey?.toLowerCase() ?? ''
const followAuthors = followingPubkeys.filter((p) => p && p.toLowerCase() !== userKey)
const knownAuthors = new Set<string>()
if (userKey) knownAuthors.add(userKey)
for (const pk of followAuthors) knownAuthors.add(pk.toLowerCase())
if (userPubkey) {
const ownEvents = await fetch1063Paginated(relays1063, fetchOpts, { authors: [userPubkey] })
mergeGifEventsIntoPool(ownEvents, pool)
}
for (const chunk of chunkPubkeys(followAuthors, METADATA_BATCH_AUTHORS_CHUNK)) {
const followEvents = await fetch1063Paginated(relays1063, fetchOpts, { authors: chunk })
mergeGifEventsIntoPool(followEvents, pool)
}
const globalEvents = await fetch1063Paginated(
relays1063,
fetchOpts,
{},
GIF_OTHERS_1063_MAX + pool.size + 200
)
let othersAdded = 0
for (const event of globalEvents) {
if (knownAuthors.has(event.pubkey.toLowerCase())) continue
const gif = parseGifFromEvent(event)
if (!gif) continue
const key = normalizeGifUrl(gif.url)
if (pool.has(key)) continue
pool.set(key, gif)
othersAdded++
if (othersAdded >= GIF_OTHERS_1063_MAX) break
}
if (pool.size < GIF_NOTES_FALLBACK_THRESHOLD) {
const noteEvents = await queryService.fetchEvents(
gifNoteFallbackRelays(noteFallbackRelays),
{
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT],
limit: GIF_NOTES_RELAY_LIMIT
},
fetchOpts
)
mergeGifEventsIntoPool(noteEvents, pool)
}
return sortGifsForPicker(dedupeGifsByUrl([...pool.values()]), userPubkey, followingPubkeys)
} finally {
revokeScope()
}
}
async function persistGifPool(gifs: GifMetadata[]): Promise<void> {
if (gifs.length === 0) return
await indexedDb.setGifCache(gifs, Date.now())
}
export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
@ -347,94 +536,87 @@ export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boole @@ -347,94 +536,87 @@ export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boole
return haystack.includes(q)
}
/** Full IndexedDB GIF pool for local search (up to {@link GIF_CACHE_CAP}). */
/** Full IndexedDB GIF pool for local search and display. */
export async function getAllCachedGifsForSearch(
userPubkey: string | null = null
userPubkey: string | null = null,
followingPubkeys: readonly string[] = []
): Promise<GifMetadata[]> {
try {
const cached = await indexedDb.getGifCache()
if (!cached?.gifs?.length) return []
const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
return sortGifsForPicker(normalized, userPubkey)
return sortGifsForPicker(
dedupeGifsByUrl(normalized),
userPubkey,
followingPubkeys
)
} catch {
return []
}
}
function mergeGifEventsIntoMap(
events: readonly NEvent[],
byUrl: Map<string, { gif: GifMetadata; priority: number }>,
searchQuery: string | undefined,
export type FetchGifsOptions = {
forceRefresh?: boolean
userPubkey: string | null
): void {
for (const event of events) {
const gif = parseGifFromEvent(event)
if (!gif) continue
if (searchQuery && !eventMatchesNip50LocalFullTextQuery(event, searchQuery)) continue
followingPubkeys?: readonly string[]
noteFallbackRelays?: readonly string[]
}
const key = normalizeGifUrl(gif.url)
const priority =
userPubkey && event.pubkey === userPubkey ? GIF_PRIORITY.OWN_EVENT : GIF_PRIORITY.OTHER_EVENT
const existing = byUrl.get(key)
if (!existing || priority > existing.priority) {
byUrl.set(key, { gif, priority })
/**
* Fetch the full GIF picker pool from relays and persist to IndexedDB.
* Order: all viewer kind 1063 all follows' kind 1063 up to 500 other kind 1063;
* if still < 50 entries, adds GIFs parsed from kind 1 / 1111.
*/
export async function fetchGifs(options: FetchGifsOptions): Promise<GifMetadata[]> {
const {
forceRefresh = false,
userPubkey,
followingPubkeys = [],
noteFallbackRelays = []
} = options
if (!forceRefresh) {
const cached = await indexedDb.getGifCache()
if (
cached &&
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) {
const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
return sortGifsForPicker(normalized, userPubkey, followingPubkeys)
}
}
}
export async function mergeGifsIntoIdbCache(incoming: GifMetadata[]): Promise<void> {
if (incoming.length === 0) return
let staleFallback: GifMetadata[] | null = null
const row = await indexedDb.getGifCache()
const byKey = new Map<string, GifMetadata>()
for (const g of row?.gifs ?? []) {
const meta = normalizeCachedGif(g as GifMetadata)
if (meta.url) byKey.set(normalizeGifUrl(meta.url), meta)
}
for (const g of incoming) {
if (g.url) byKey.set(normalizeGifUrl(g.url), g)
}
const merged = [...byKey.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, GIF_CACHE_CAP)
await indexedDb.setGifCache(merged, Date.now())
}
function gifRelayUrlsForFetch(extraReadRelayUrls: readonly string[]): {
relays1063: string[]
relaysNotes: string[]
} {
const dedupedUrls = dedupeRelayUrls([
...GIF_RELAY_URLS,
...FAST_READ_RELAY_URLS,
...extraReadRelayUrls
])
const relays1063 = dedupedUrls.some(
(u) => (normalizeUrl(u) || u).toLowerCase() === THECITADEL_FOR_GIF_METADATA.toLowerCase()
)
? dedupedUrls
: [...dedupedUrls, THECITADEL_FOR_GIF_METADATA]
return { relays1063, relaysNotes: dedupedUrls }
}
if (row?.gifs?.length) {
staleFallback = row.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
}
function gifsFromEvents(
events1063: readonly NEvent[],
eventsNotes: readonly NEvent[],
userPubkey: string | null
): GifMetadata[] {
const byUrl = new Map<string, { gif: GifMetadata; priority: number }>()
mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey)
mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey)
return sortGifsForPicker(
Array.from(byUrl.values()).map((v) => v.gif),
userPubkey
)
try {
const pool = await loadGifPoolFromRelays({
userPubkey,
followingPubkeys,
noteFallbackRelays,
fetchOpts: GIF_FETCH_OPTS
})
if (pool.length > 0) {
await persistGifPool(pool)
}
return pool
} catch (err) {
if (staleFallback?.length) {
return sortGifsForPicker(staleFallback, userPubkey, followingPubkeys)
}
throw err
}
}
/**
* Background session preload into IndexedDB: kind 1063 on GIF relays + thecitadel, kind 1/1111 on the
* broad list, merged with the viewer's inboxes/outboxes when provided.
*/
/** Background session preload into IndexedDB using the tiered 1063 fetch. */
export async function preloadGifsIntoIdbCache(
userPubkey: string | null,
extraReadRelayUrls: readonly string[] = []
followingPubkeys: readonly string[] = [],
noteFallbackRelays: readonly string[] = []
): Promise<void> {
const cached = await indexedDb.getGifCache()
if (
@ -451,27 +633,14 @@ export async function preloadGifsIntoIdbCache( @@ -451,27 +633,14 @@ export async function preloadGifsIntoIdbCache(
}
gifOutboxPreloadInFlight = (async () => {
const { relays1063, relaysNotes } = gifRelayUrlsForFetch(extraReadRelayUrls)
const [events1063, eventsNotes] = await Promise.all([
queryService.fetchEvents(
relays1063,
{ kinds: [ExtendedKind.FILE_METADATA], limit: 400 },
GIF_PRELOAD_FETCH_OPTS
),
queryService.fetchEvents(
relaysNotes,
{
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT],
limit: 500
},
GIF_PRELOAD_FETCH_OPTS
)
])
const gifs = gifsFromEvents(events1063, eventsNotes, userPubkey).slice(0, GIF_CACHE_CAP)
if (gifs.length > 0) {
await mergeGifsIntoIdbCache(gifs)
const pool = await loadGifPoolFromRelays({
userPubkey,
followingPubkeys,
noteFallbackRelays,
fetchOpts: GIF_PRELOAD_FETCH_OPTS
})
if (pool.length > 0) {
await persistGifPool(pool)
}
})()
@ -484,115 +653,37 @@ export async function preloadGifsIntoIdbCache( @@ -484,115 +653,37 @@ export async function preloadGifsIntoIdbCache(
/** @deprecated Use {@link preloadGifsIntoIdbCache}. Kept for call-site compatibility. */
export async function preloadGifsFromUserOutboxes(
outboxRelayUrls: readonly string[],
_outboxRelayUrls: readonly string[],
userPubkey: string | null,
_signal?: AbortSignal
): Promise<void> {
return preloadGifsIntoIdbCache(userPubkey, outboxRelayUrls)
}
/**
* Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays.
* Deduplicates by normalized URL; when the same GIF appears from multiple sources,
* keeps: 1) user's own events, 2) other users' events, 3) non-event sources.
* @param extraReadRelayUrls - Logged-in user's read relays (inboxes) and local relays to include when fetching.
* @param userPubkey - Current user's pubkey; entries from this pubkey get highest priority when deduping.
*/
export async function fetchGifs(
limit: number = 50,
forceRefresh: boolean = false,
extraReadRelayUrls: string[] = [],
userPubkey: string | null = null
): Promise<GifMetadata[]> {
if (!forceRefresh) {
const cached = await indexedDb.getGifCache()
if (
cached &&
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) {
const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
return sortGifsForPicker(normalized, userPubkey).slice(0, limit)
}
}
let staleFallback: GifMetadata[] | null = null
const row = await indexedDb.getGifCache()
if (row?.gifs?.length) {
staleFallback = row.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
}
const limit1063 = Math.max(limit * 15, 400)
const limitNotes = Math.max(limit * 15, 500)
const { relays1063, relaysNotes: dedupedUrls } = gifRelayUrlsForFetch(extraReadRelayUrls)
let events1063: NEvent[] = []
let eventsNotes: NEvent[] = []
try {
;[events1063, eventsNotes] = await Promise.all([
queryService.fetchEvents(
relays1063,
{ kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 },
GIF_FETCH_OPTS
),
queryService.fetchEvents(
dedupedUrls,
{
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT],
limit: limitNotes
},
GIF_FETCH_OPTS
)
])
} catch (err) {
if (staleFallback?.length) {
return sortGifsForPicker(staleFallback, userPubkey).slice(0, limit)
}
throw err
}
const byUrl = new Map<string, { gif: GifMetadata; priority: number }>()
mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey)
mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey)
const allGifs = sortGifsForPicker(
Array.from(byUrl.values()).map((v) => v.gif),
userPubkey
)
let result = allGifs.slice(0, limit)
if (result.length === 0 && staleFallback?.length) {
result = sortGifsForPicker(staleFallback, userPubkey).slice(0, limit)
}
if (allGifs.length > 0) {
await mergeGifsIntoIdbCache(allGifs.slice(0, GIF_CACHE_CAP))
}
return result
return preloadGifsIntoIdbCache(userPubkey, [])
}
/**
* Return whatever is currently in the IndexedDB GIF cache without fetching from relays.
* Used to seed the picker immediately on open; the caller can then trigger a background refresh.
*/
export async function getCachedGifs(userPubkey: string | null = null): Promise<GifMetadata[]> {
const all = await getAllCachedGifsForSearch(userPubkey)
return all.slice(0, 50)
export async function getCachedGifs(
userPubkey: string | null = null,
followingPubkeys: readonly string[] = []
): Promise<GifMetadata[]> {
return getAllCachedGifsForSearch(userPubkey, followingPubkeys)
}
/** Instant local search over the IndexedDB GIF cache (no relay round-trip). */
export async function searchGifs(
query: string,
limit: number = 50,
_forceRefresh: boolean = false,
_extraReadRelayUrls: string[] = [],
userPubkey: string | null = null
userPubkey: string | null = null,
followingPubkeys: readonly string[] = []
): Promise<GifMetadata[]> {
const q = query.trim()
if (!q) return getCachedGifs(userPubkey)
const pool = await getAllCachedGifsForSearch(userPubkey)
return pool.filter((g) => gifMetadataMatchesSearch(g, q)).slice(0, limit)
const pool = await getAllCachedGifsForSearch(userPubkey, followingPubkeys)
if (!q) return pool
return pool.filter((g) => gifMetadataMatchesSearch(g, q))
}
/** @deprecated Merges by URL with a hard cap; prefer {@link persistGifPool}. */
export async function mergeGifsIntoIdbCache(incoming: GifMetadata[]): Promise<void> {
if (incoming.length === 0) return
await indexedDb.setGifCache(incoming, Date.now())
}

Loading…
Cancel
Save