// GIF search, from nostr.build's free GIF index (gifs.nostr.build). A web // client is admitted by its Origin, registered once in their dashboard; // localhost is admitted as a shared development client. The rules this // follows are theirs (https://gifs.nostr.build/developers/llms-full.txt): // no headers from the browser, "GIFs from nostr.build" shown wherever the // GIFs are, one request per query (shared, never aborted), debounced typing, // no one-letter searches, grid tiles play the small H.264 copy (`mp4`) and // fall back to the animated preview, and the note gets the original `url` // with a NIP-92 imeta. Media URLs other than gifs.nostr.build are dropped. export const GIF_API = 'https://gifs.nostr.build/api/v1'; export const GIF_STARTERS = ['gm', 'lol', 'yes', 'wow', 'applause', 'facepalm', 'love', 'fire']; const MEDIA_RE = /^https:\/\/gifs\.nostr\.build\//; const LIMIT = 60; // The server's own normalisation, near enough to key the cache by. export const normGifQuery = (q) => String(q || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 100); // A single Latin letter is noise; one emoji, one CJK/Hangul character, or // any two characters are worth a search. No letter, digit or emoji: nothing. export function gifQueryOk(q) { const s = normGifQuery(q); if (!/[\p{L}\p{N}\p{Extended_Pictographic}]/u.test(s)) return false; if ([...s].length === 1 && /[a-z]/i.test(s)) return false; return true; } const safeMedia = (u) => (typeof u === 'string' && MEDIA_RE.test(u) ? u : null); // Only what the picker and the note need, every URL checked. export function cleanGif(g) { if (!g || !safeMedia(g.url) || !(g.width > 0) || !(g.height > 0)) return null; const pv = (p) => (p && { width: p.width, height: p.height, animated: safeMedia(p.animated), still: safeMedia(p.still) }) || null; return { id: String(g.id || g.url), url: g.url, width: g.width, height: g.height, title: typeof g.title === 'string' ? g.title : '', bytes: g.bytes || null, format: g.format === 'webp' ? 'webp' : 'gif', lqip: typeof g.lqip === 'string' && /^data:image\/[a-z]+;base64,[A-Za-z0-9+/=]+$/.test(g.lqip) ? g.lqip : null, mp4: safeMedia(g.mp4), w240: pv(g.previews && g.previews.w240), w480: pv(g.previews && g.previews.w480), }; } // NIP-92 imeta for a picked GIF, so other clients needn't sniff the URL. export const gifMedia = (g) => ({ url: g.url, m: g.format === 'webp' ? 'image/webp' : 'image/gif', dim: g.width + 'x' + g.height, alt: g.title || 'GIF', size: g.bytes || undefined, }); const answers = new Map(); // normalised query -> Promise<{ items } | { error }> export function searchGifs(q, fetchImpl = (u) => fetch(u)) { const key = normGifQuery(q); if (answers.has(key)) return answers.get(key); const p = (async () => { let res; try { res = await Promise.race([ fetchImpl(GIF_API + '/search?' + new URLSearchParams({ q: key, limit: String(LIMIT) })), new Promise((_, no) => setTimeout(() => no(new Error('timeout')), 15000)), ]); } catch { return { error: 'down' }; } // unregistered origins and 503s arrive as network errors if (res.status === 429) return { error: 'limited' }; if (!res.ok) return { error: 'down' }; try { const body = await res.json(); return { items: (body.items || []).map(cleanGif).filter(Boolean) }; } catch { return { error: 'down' }; } })(); answers.set(key, p); // a failure is not an answer to keep: the next try asks again (a rate // limit after a minute) p.then((r) => { if (r.error) setTimeout(() => { if (answers.get(key) === p) answers.delete(key); }, r.error === 'limited' ? 60_000 : 5_000); }); return p; } // The picker: a sheet with a search field, starter chips, a grid of tiles // and the attribution. `ui.gifPick` = { for, q, shown, status, items }; the // caller draws sheet() where its composer is and handles the pick. export function createGifPicker({ h, t, ui, render }) { let timer = 0, seq = 0; const reduce = () => typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; const wide = () => typeof window !== 'undefined' && window.innerWidth >= 640; function run(q) { const st = ui.gifPick; if (!st) return; const key = normGifQuery(q); if (!gifQueryOk(key)) return; const my = ++seq; st.shown = key; st.status = 'loading'; render(); searchGifs(key).then((r) => { if (my !== seq || ui.gifPick !== st) return; // a later query owns the grid if (r.error) { st.status = r.error; st.items = []; } else { st.status = 'ready'; st.items = r.items; } render(); }); } function open(forWhat) { ui.gifPick = { for: forWhat, q: '', shown: '', status: 'idle', items: [] }; run(GIF_STARTERS[0]); } function close() { ui.gifPick = null; clearTimeout(timer); render(); } // One observer for every tile: a clip is attached only near the screen // and plays only on it; far away it lets go of its media. let io = null; const watch = (tile) => { if (typeof IntersectionObserver === 'undefined') return; io ||= new IntersectionObserver((entries) => { for (const en of entries) { const v = en.target.querySelector('video'); if (!v) continue; if (en.isIntersecting) { if (!v.getAttribute('src')) v.setAttribute('src', v.dataset.src + '#t=0.001'); v.play().catch(() => {}); } else if (v.getAttribute('src')) { v.pause(); if (!en.target.isConnected) io.unobserve(en.target); } } }, { rootMargin: '200px 0px' }); io.observe(tile); }; function tile(g, onPick) { const pv = (wide() ? g.w480 : g.w240) || g.w240 || g.w480; const ratio = Math.min(2, Math.max(0.75, g.width / g.height)); const still = pv && pv.still; const moving = !reduce(); const fallback = (moving && pv && pv.animated) || still || null; const media = moving && g.mp4 ? h('video', { muted: true, loop: true, playsinline: true, preload: 'none', 'data-src': g.mp4, onError: (e) => { // no copy after all: the animated preview const box = e.target.parentElement; if (box && fallback) e.target.replaceWith(h('img', { src: fallback, alt: '', loading: 'lazy' })); } }) : fallback ? h('img', { src: fallback, alt: '', loading: 'lazy', onLoad: (e) => { e.target.parentElement.style.backgroundImage = 'none'; } }) : null; if (media && media.tagName === 'VIDEO') { media.muted = true; media.addEventListener('playing', () => { media.parentElement && (media.parentElement.style.backgroundImage = 'none'); }, { once: true }); } const el = h('button', { type: 'button', class: 'gif-tile', title: g.title || 'GIF', 'aria-label': g.title || 'GIF', 'data-key': 'gif:' + g.id, style: 'aspect-ratio:' + ratio.toFixed(3) + (g.lqip ? ';background-image:url("' + g.lqip + '")' : ''), onClick: () => { const p = ui.gifPick; close(); onPick(g, p); }, }, media); el._skipMorph = true; // its clip is attached and played by the observer, not by renders if (media && media.tagName === 'VIDEO') watch(el); return el; } function sheet(forWhat, onPick) { const st = ui.gifPick; if (!st || st.for !== forWhat) return null; const body = st.status === 'ready' ? (st.items.length ? h('div', { class: 'gif-grid', 'data-key': 'gifs:' + st.shown }, ...st.items.map((g) => tile(g, onPick))) : h('div', { class: 'small muted gif-note' }, t('gifNone'))) : st.status === 'loading' ? h('div', { class: 'gif-note' }, h('span', { class: 'spinner' })) : st.status === 'limited' ? h('div', { class: 'small muted gif-note' }, t('gifLimited')) : st.status === 'down' ? h('div', { class: 'small muted gif-note' }, t('gifDown')) : null; return h('div', { class: 'confirm-pop-backdrop gif-backdrop', onClick: (e) => { if (e.target === e.currentTarget) close(); } }, h('div', { class: 'card col gif-sheet' }, h('div', { class: 'row between gif-head' }, h('span', { class: 'small muted', html: t('gifFrom') + ' nostr.build' }), h('button', { type: 'button', class: 'btn-ghost btn-sm', onClick: close }, t('cancel'))), h('input', { type: 'search', class: 'gif-search', placeholder: t('gifSearchPh'), value: st.q, enterkeyhint: 'search', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', onInput: (e) => { st.q = e.target.value; clearTimeout(timer); timer = setTimeout(() => run(st.q.trim() || GIF_STARTERS[0]), 250); }, onKeydown: (e) => { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(timer); run(st.q.trim() || GIF_STARTERS[0]); } }, }), h('div', { class: 'row gif-chips' }, ...GIF_STARTERS.map((s) => h('button', { type: 'button', class: 'feed-chip' + (st.shown === s ? ' on' : ''), onClick: () => { st.q = s; const f = document.querySelector('.gif-search'); if (f) f.value = s; run(s); }, }, s))), h('div', { class: 'gif-body' }, body))); } function button(forWhat, cls = 'gif-btn') { return h('button', { type: 'button', class: cls, title: t('gifBtnTitle'), 'aria-label': t('gifBtnTitle'), onClick: (e) => { e.preventDefault(); open(forWhat); }, }, 'GIF'); } return { open, close, sheet, button }; }