From 79b09acc3c4c471fb29bb8d6994c26e849b90bb9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 10 Oct 2026 07:35:16 -0700 Subject: GIF search, from nostr.build's GIF index (gifs.nostr.build). A GIF button in the post composer and in every chat composer (DMs, groups, communities) opens a picker: "GIFs from nostr.build" (linked, as their terms require), starter chips (gm, lol, yes, wow, …; the first runs on open), a search field (debounced 250 ms, no one-letter searches, one shared request per normalised query, never aborted, 429/403/503 answered in words), and a masonry grid whose tiles play the small H.264 copy only while near the screen, over the lqip blur, falling back to the animated preview (stills under reduced motion). A pick puts the original URL in a post — published with a NIP-92 imeta (m, dim, size, alt) — or, in a chat, sends it at once after whatever was typed. Media URLs not on gifs.nostr.build are dropped. Browser calls carry no headers: production needs v3.coinos.io registered in the nostr.build dashboard (localhost works as their shared dev client). tools/gif-picker-test.js runs against the live API. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3 --- src/features/messages.js | 37 +++++++-- src/gif-picker.js | 192 +++++++++++++++++++++++++++++++++++++++++++++++ src/i18n.js | 6 ++ src/style.css | 23 +++++- tools/gif-picker-test.js | 89 ++++++++++++++++++++++ 5 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 src/gif-picker.js create mode 100644 tools/gif-picker-test.js diff --git a/src/features/messages.js b/src/features/messages.js index a3452ee..cab7a97 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -43,6 +43,7 @@ import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { sha256 } from '@noble/hashes/sha256'; import { base64urlnopad } from '@scure/base'; import { t, getLang } from '../i18n.js'; +import { createGifPicker, gifMedia } from '../gif-picker.js'; import { inLanguage, isMachinePost, guessLang } from '../lang-guess.js'; import { translatePieces, translatable as transParts } from '../translate-split.js'; import { animateZap, warmZapSound } from '../zap-animation.js'; @@ -82,6 +83,10 @@ const OPEN_VIEW = (() => { export function messagesFeature(ctx) { const { h, ui, render, wallet, toast, hook } = ctx; + const gifs = createGifPicker({ h, t, ui, render }); + // NIP-92: an attachment described, so clients needn't sniff the URL + const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []), + ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])]; // ---- persisted state ---------------------------------------------------- @@ -9796,8 +9801,7 @@ export function messagesFeature(ctx) { render(); try { // NIP-92: what we uploaded, described, so clients needn't sniff the URL - const imeta = media.filter((m) => m && m.url) - .map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]); + const imeta = media.filter((m) => m && m.url).map(mediaImeta); // NIP-18 quote: the reference is already in the text; the tags let a // client render the quoted post inline and tell its author const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8)) @@ -9833,8 +9837,7 @@ export function messagesFeature(ctx) { ])].filter((pk) => pk !== id.pubkey).slice(0, 8); // and whoever the reply mentions by name (NIP-27), beyond the thread's eight for (const pk of mentionedPks(text)) if (pk !== id.pubkey && !pTags.includes(pk)) pTags.push(pk); - const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url)) - .map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]); + const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url)).map(mediaImeta); const root = c.root || (target.id === rootId ? target : null); // the root's scope: from the root itself, or — not loaded yet — copied // off the comment being answered, which carries it @@ -10726,10 +10729,22 @@ export function messagesFeature(ctx) { type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); }, }), + gifs.button('post', 'attach-btn gif-btn'), h('div', { class: 'row gap6 composer-options' }, previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }), h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))), - postCancelWarning()); + postCancelWarning(), + gifs.sheet('post', (g) => { + const cur = composeText().replace(/\s+$/, ''); + const next = (cur ? cur + '\n' : '') + g.url; + ui.profCompose = next; + setDraft(POST_DRAFT, next); + (ui.postMedia ||= []).push(gifMedia(g)); + ui.postPreview = true; + const el = document.querySelector('.post-input'); + if (el) el.value = next; + render(); + })); } // ---- the feed view -------------------------------------------------------- @@ -12431,7 +12446,17 @@ export function messagesFeature(ctx) { // animation, so re-stick once now and once when it has settled. onFocus: () => { stickToBottom(); setTimeout(stickToBottom, 350); }, }), - h('button', { class: 'btn-primary btn-sm', onClick: onSend }, t('msgSend')))); + gifs.button('chat:' + draftKey, 'iconbtn chat-gif'), + h('button', { class: 'btn-primary btn-sm', onClick: onSend }, t('msgSend'))), + // a picked GIF goes out at once, after whatever was typed + gifs.sheet('chat:' + draftKey, (g) => { + const cur = draftFor(draftKey).replace(/\s+$/, ''); + const next = (cur ? cur + ' ' : '') + g.url; + setDraft(draftKey, next); + const el = document.getElementById('msg-draft'); + if (el) el.value = next; + onSend(); + })); }; // ---- home --------------------------------------------------------------- diff --git a/src/gif-picker.js b/src/gif-picker.js new file mode 100644 index 0000000..a2795e2 --- /dev/null +++ b/src/gif-picker.js @@ -0,0 +1,192 @@ +// 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 }; +} diff --git a/src/i18n.js b/src/i18n.js index e80e2f8..5bf7f91 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -517,6 +517,12 @@ const STR = { msgSignerOff: "Your nostr signer isn't connected, so you can't post as yourself yet.", msgAmberDms: 'Signed in with Amber, messages sent to you can’t be opened here, only ones you sent from this device.', msgAmberConnect: 'Connect Amber', + gifBtnTitle: 'Add a GIF', + gifSearchPh: 'Search GIFs', + gifFrom: 'GIFs from', + gifNone: 'No GIFs found.', + gifDown: 'GIF search isn’t available right now.', + gifLimited: 'Too many searches. Try again in a minute.', msgReconnect: 'Reconnect', msgDmsTitle: 'Messages', msgCommunitiesTitle: 'Communities', diff --git a/src/style.css b/src/style.css index dad0520..2ed97a1 100644 --- a/src/style.css +++ b/src/style.css @@ -236,7 +236,7 @@ button:disabled { opacity: .5; cursor: not-allowed; } /* ---- inputs ---- */ label.field { display: block; } .field .lab { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 6px; display: block; } -input[type=text], input[type=password], input[type=number], textarea, select { +input[type=text], input[type=search], input[type=password], input[type=number], textarea, select { width: 100%; font: inherit; color: var(--ink); background: var(--paper); border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px; } @@ -1241,7 +1241,7 @@ button, .btn { transition: filter .12s, background .15s, border-color .15s, box- .chat-avatar.clickable:hover { transform: scale(1.07); } .header-avatar .chat-avatar { transition: transform .15s ease; } .header-avatar:hover .chat-avatar { transform: scale(1.08); } -input[type=text], input[type=password], input[type=number], textarea, select { transition: border-color .15s, box-shadow .15s; } +input[type=text], input[type=search], input[type=password], input[type=number], textarea, select { transition: border-color .15s, box-shadow .15s; } .settings-nav::after { transition: transform .18s ease, color .18s; } .settings-nav:hover::after { transform: translateX(3px); color: var(--ink); } @@ -1632,3 +1632,22 @@ body::after { .mention-ac .item { cursor: pointer; padding: 6px 10px; } /* `code` in a post */ .md-code { font-family: var(--mono, ui-monospace, monospace); font-size: .9em; background: color-mix(in srgb, currentColor 9%, transparent); border-radius: 4px; padding: 0 .3em; } + +/* GIF picker (gifs.nostr.build) */ +.gif-btn { font-weight: 700; font-size: 12px; letter-spacing: .04em; } +.chat-gif { font-weight: 700; font-size: 11px; letter-spacing: .04em; padding: 0 8px; } +.gif-backdrop { align-items: flex-end; padding: 0; } +.gif-sheet { width: 100%; max-width: 560px; height: min(78vh, 640px); gap: 8px; border-radius: 16px 16px 0 0; padding: 12px; } +@media (min-width: 640px) { .gif-backdrop { align-items: center; padding: 24px; } .gif-sheet { border-radius: 16px; } } +.gif-head { align-items: center; } +.gif-head a { color: inherit; text-decoration: underline; } +.gif-search { width: 100%; } +.gif-chips { gap: 6px; overflow-x: auto; flex-wrap: nowrap; scrollbar-width: none; flex-shrink: 0; } +.gif-chips::-webkit-scrollbar { display: none; } +.gif-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } +.gif-grid { columns: 2; column-gap: 6px; } +@media (min-width: 640px) { .gif-grid { columns: 3; } } +.gif-tile { padding: 0; border: 0; border-radius: 8px; overflow: hidden; background: #000 center / cover no-repeat; width: 100%; cursor: pointer; display: block; margin: 0 0 6px; break-inside: avoid; } +.gif-search::-webkit-search-cancel-button { cursor: pointer; } +.gif-tile video, .gif-tile img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } +.gif-note { display: flex; justify-content: center; padding: 32px 0; } diff --git a/tools/gif-picker-test.js b/tools/gif-picker-test.js new file mode 100644 index 0000000..172009b --- /dev/null +++ b/tools/gif-picker-test.js @@ -0,0 +1,89 @@ +// GIF search (gifs.nostr.build): the post composer's GIF button opens a +// picker with the attribution, starter results and a search field; a tap +// puts the original URL in the post, which goes out with a NIP-92 imeta. +// In a DM the picked GIF is sent at once. Uses the live API — localhost is +// admitted as nostr.build's shared development client. +// Run: bun tools/gif-picker-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const PEER = getPublicKey(generateSecretKey()); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5315, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + window.__published = []; + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') { window.__published.push(m[1]); setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); return; } } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}); +const gifReqs = []; +page.on('request', (r) => { if (r.url().startsWith('https://gifs.nostr.build/api/')) gifReqs.push(r.url()); }); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 15000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5315/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk]) => { + const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}'); + st.dms = { [pk]: [{ id: 'a'.repeat(64), from: pk, text: 'send me a gif', t: Math.floor(Date.now() / 1000) - 60 }] }; + localStorage.setItem(k + ':messages', JSON.stringify(st)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Gifpal', t: Date.now() } })); + }, [base, PEER]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + + console.log('\n[in a post]'); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000); + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + check('the post composer has a GIF button', await waitFor(() => !!document.querySelector('.post-actions .gif-btn'), 8000)); + await page.evaluate(() => document.querySelector('.post-actions .gif-btn').click()); + check('the picker opens with the nostr.build attribution, linked', await waitFor(() => !!document.querySelector('.gif-sheet .gif-head a[href="https://nostr.build"]'), 5000)); + check('...and the first starter\'s GIFs', await waitFor(() => document.querySelectorAll('.gif-grid .gif-tile').length >= 6, 20000), String(await page.evaluate(() => document.querySelectorAll('.gif-tile').length))); + check('a tile plays the small mp4, not the original', await waitFor(() => [...document.querySelectorAll('.gif-tile video')].some((v) => /mp4/.test(v.currentSrc || v.getAttribute('src') || '') && v.readyState >= 2), 15000)); + await page.focus('.gif-search'); + await page.keyboard.type('cat', { delay: 40 }); + check('typing searches (debounced: one request for "cat")', await waitFor(() => document.querySelector('.gif-grid')?.getAttribute('data-key') === 'gifs:cat', 15000) + && gifReqs.filter((u) => /[?&]q=c(&|$)|q=ca(&|$)/.test(u)).length === 0, gifReqs.map((u) => new URL(u).searchParams.get('q')).join(',')); + check('no request sends an Authorization header or safe=0', gifReqs.every((u) => !/safe=0/.test(u))); + const picked = await page.evaluate(() => { const b = document.querySelector('.gif-grid .gif-tile'); const title = b.title; b.click(); return title; }); + await sleep(500); + const draft = await page.evaluate(() => document.querySelector('.post-input')?.value || ''); + check('the picker closes and the post holds the GIF\'s original URL', !(await page.evaluate(() => !!document.querySelector('.gif-sheet'))) && /https:\/\/gifs\.nostr\.build\/[0-9a-f]{64}\.(gif|webp)/.test(draft), draft); + await page.evaluate(() => { window.__published = []; [...document.querySelectorAll('.post-actions button')].find((b) => b.textContent.trim() === 'Post').click(); }); + await waitFor(() => window.__published.some((e) => e.kind === 1), 10000); + const ev = await page.evaluate(() => window.__published.find((e) => e.kind === 1)); + const im = ev && ev.tags.find((x) => x[0] === 'imeta'); + check('the note carries a NIP-92 imeta: url, type, size in pixels, alt', !!im && im.some((x) => /^url https:\/\/gifs\.nostr\.build\//.test(x)) && im.some((x) => /^m image\/(gif|webp)$/.test(x)) && im.some((x) => /^dim \d+x\d+$/.test(x)) && im.some((x) => x.startsWith('alt ')), JSON.stringify(im) + ' ' + picked); + + console.log('\n[in a DM]'); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]')?.click()); + await waitFor(() => !!document.querySelector('.chat-thread-row .chat-preview'), 10000); + await page.evaluate(() => document.querySelector('.chat-thread-row .chat-preview').click()); + check('the DM composer has a GIF button', await waitFor(() => !!document.querySelector('.chat-compose .chat-gif'), 8000)); + await page.evaluate(() => { window.__published = []; document.querySelector('.chat-compose .chat-gif').click(); }); + await waitFor(() => document.querySelectorAll('.gif-grid .gif-tile').length > 0, 20000); + await page.evaluate(() => document.querySelector('.gif-grid .gif-tile').click()); + check('a picked GIF is sent straight away', await waitFor(() => window.__published.some((e) => e.kind === 1059), 10000) + && await waitFor(() => [...document.querySelectorAll('.chat-row.mine')].some((r) => r.querySelector('img') || /gifs\.nostr\.build/.test(r.textContent)), 8000)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ GIF search in posts and DMs' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3