// GIF search: `/gif [search]` opens a COMMAND PANEL of Giphy results — tap one // and it posts into the chat as a normal encrypted image message, from you. // The app never fetches: all Giphy traffic goes through the host's scoped // doorway (ctx.host.gif — search, bytes, post), and what lands in the thread // is an ordinary attachment blob, encrypted client-side to the thread key. The // server never sees the search, the pick, or the bytes; Giphy sees the query // from YOUR client (the one deliberate trade-off — see CLAUDE.md, next to // unfurl's), authenticated with the SPACE's free Giphy key. // // The key is asked for ONCE, from whoever runs /gif first, and then belongs to // the space: it folds into the space thread's log like any space app's data, so // every other member — and every later joiner — reads it and is never asked. // That it's readable by everyone here isn't a leak to design around; it's the // point. A Giphy key is a public client identifier, not a secret (the copy on // the form says so, because the key does become shared). The lchat server still // never sees it: the space thread's key rides the instance key, so the blob is // opaque to it like everything else. // // This is the IMPLICITLY SPACE-PLACED shape (apps.js instanceThread): places // ["space"] + listed:false, so ctx addresses the SPACE (the key), while the // panel and the posted GIF address the CHAT you typed in — ctx.panel already // opens there, and the host's `post` sends there. "App data lives space-wide, // conversation content stays under the conversation's own key" // (docs/space-apps.md). Posting is direct — tap = send, like Slack's /giphy — // so the same flow works in the native shells' command-host webview, where // there is no web composer to stage into. import { defineApp } from "../apps.js"; import { ICON_FILM } from "../icons.js"; // The largest GIF we'll post. Giphy's "downsized" rendition targets ~2 MB; // this cap is the backstop when only bigger renditions exist. export const GIF_MAX_BYTES = 8 * 1024 * 1024; // renditionCandidates lists the POSTABLE renditions of a Giphy result, best // first: downsized → fixed_height → original, skipping metadata-oversize ones // (sizes arrive as strings, sometimes "0"/absent — the byte fetch re-checks // the real length, so an unknown size stays in). The panel walks the list on // post, so one failing URL doesn't kill the send. Pure, unit-testable. export function renditionCandidates(images, maxBytes = GIF_MAX_BYTES) { const out = []; for (const name of ["downsized", "fixed_height", "original"]) { const r = images && images[name]; if (!r || !r.url) continue; const size = parseInt(r.size, 10) || 0; if (size > maxBytes) continue; if (out.some((c) => c.url === r.url)) continue; out.push({ url: r.url, w: parseInt(r.width, 10) || 0, h: parseInt(r.height, 10) || 0 }); } return out; } // The best single choice — candidates' head (kept for gifItems' usability gate). export function pickRendition(images, maxBytes = GIF_MAX_BYTES) { return renditionCandidates(images, maxBytes)[0] || null; } // gifItems maps a raw Giphy data[] page to what the panel renders: a preview // (small, fixed-height for an even grid) plus the full images map for // pickRendition at post time. Results without a usable preview or post // rendition are dropped. Pure, testable. export function gifItems(data) { const out = []; for (const g of data || []) { if (!g || !g.images) continue; const p = g.images.fixed_height_small || g.images.fixed_height; if (!p || !p.url || !pickRendition(g.images)) continue; out.push({ id: g.id || "", title: g.title || "GIF", preview: { url: p.url, w: parseInt(p.width, 10) || 0, h: parseInt(p.height, 10) || 0 }, images: g.images, }); } return out; } export const app = defineApp({ name: "gif", title: "GIFs", glyph: "🎞️", symbol: "film", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) logo: ICON_FILM, // The command acts in the chat you typed it in, but its one piece of data — // the space's Giphy key — lives in the space. listed:false keeps it out of the // Apps catalogue (it's a command, not something you add somewhere); with no // `render` it stays off the widget bar and the sidebar band for free. Together // that's "implicitly space-placed" — see the header and apps.js instanceThread. places: ["space"], listed: false, initialState: () => ({}), events: { // The space's Giphy key. Last writer wins: re-running the form (or fixing a // rejected key) is just another event, and the fold is a plain overwrite — // idempotent, and order-stable at the thread's horizon. "gif.key": (s, e) => ({ ...s, key: String((e && e.key) || "").trim() }), }, commands: { "/gif": { desc: "find a GIF — tap one to send it here", args: "[search]", run(args, ctx) { const gifHost = ctx.host && ctx.host.gif; if (!gifHost) { ctx.reply({ text: "GIF search isn't available here." }); return; } // No guard for an unreachable space thread: runCommand never calls a // command it couldn't route (it answers "/gif isn't available here yet" // itself), so ctx.threadId is always the space's by the time we're here. const q = (args || "").trim(); const opened = ctx.panel({ title: q ? `GIFs matching “${q}”` : "GIFs — tap one to send it", render(el, panel) { buildGifPanel(el, panel, ctx, gifHost, q); }, }); if (!opened) ctx.reply({ text: "Open a chat to use /gif." }); }, }, }, styles: ` .gif-search { width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px; font: inherit; font-size: 13px; color: var(--ink); background: var(--bubble-them); border: 1px solid var(--border); border-radius: 8px; } .gif-search:focus { outline: none; border-color: var(--accent); } .gif-grid { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; } .gif-tile { border: none; background: var(--bubble-them); padding: 0; cursor: pointer; border-radius: 8px; overflow: hidden; height: 100px; position: relative; } .gif-tile img { display: block; height: 100px; width: auto; } .gif-tile:hover { outline: 2px solid var(--accent); } .gif-tile--busy::after { content: "Sending…"; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: #fff; background: rgba(0, 0, 0, 0.55); } .gif-status { font-size: 12px; color: var(--muted); padding: 6px 2px; } .gif-credit { font-size: 10px; color: var(--muted); text-align: right; padding-top: 6px; } .gif-keylink { border: none; background: none; padding: 0; font: inherit; color: var(--link); cursor: pointer; } .gif-keyform { display: flex; flex-direction: column; gap: 8px; max-width: 46ch; padding: 4px 0 8px; } .gif-keyintro { margin: 0; font-size: 13px; color: var(--ink); line-height: 1.45; } .gif-keyform a { font-size: 12px; color: var(--link); } .gif-keyrow { display: flex; gap: 6px; } .gif-keyrow .gif-search { margin: 0; flex: 1 1 auto; } .gif-keysave { flex: 0 0 auto; border: none; border-radius: 8px; padding: 6px 14px; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; background: var(--accent); color: var(--accent-ink, #fff); } `, }); // The panel: a refine-search box, the result grid, a status line, and the // attribution Giphy's terms ask for. All Giphy I/O rides the host doorway. function buildGifPanel(el, panel, ctx, gifHost, initialQuery) { let posting = false; let searchSeq = 0; const search = document.createElement("input"); search.className = "gif-search"; search.type = "search"; search.placeholder = "Search GIFs…"; search.value = initialQuery; const grid = document.createElement("div"); grid.className = "gif-grid"; const status = document.createElement("div"); status.className = "gif-status"; const credit = document.createElement("div"); credit.className = "gif-credit"; credit.append("Powered by GIPHY · "); const keyLink = document.createElement("button"); keyLink.type = "button"; keyLink.className = "gif-keylink"; keyLink.textContent = "API key…"; keyLink.onclick = () => showKeyForm(""); credit.appendChild(keyLink); // Status sits ABOVE the grid: the panel scrolls, and an error appended // below a tall grid would land out of view exactly when it matters. el.append(search, status, grid, credit); const setStatus = (text) => { status.textContent = text || ""; status.style.display = text ? "" : "none"; }; // First-launch setup (and the recovery path for a rejected key): the FIRST // person to run /gif in this space supplies a free Giphy key, and from then on // it belongs to the space — nobody else is ever asked. The copy says so // plainly: the key becomes shared, and everyone's searches go out under it, so // whoever pastes it is lending their Giphy account to the room. That's fine // for a public client identifier, but it must not be a surprise. const showKeyForm = (problem) => { grid.replaceChildren(); setStatus(""); const box = document.createElement("div"); box.className = "gif-keyform"; const intro = document.createElement("p"); intro.className = "gif-keyintro"; intro.textContent = problem || "GIF search needs a free Giphy API key. Add one and it's shared with everyone in this space — nobody else will be asked, and everyone's searches go to Giphy under this key. The lchat server never sees it."; const how = document.createElement("a"); how.href = "https://developers.giphy.com/dashboard/"; how.target = "_blank"; how.rel = "noopener noreferrer"; how.textContent = "Get a key at developers.giphy.com"; const row = document.createElement("div"); row.className = "gif-keyrow"; const input = document.createElement("input"); input.className = "gif-search"; input.type = "text"; input.placeholder = "Paste your Giphy API key"; input.autocomplete = "off"; input.spellcheck = false; const save = document.createElement("button"); save.type = "button"; save.className = "gif-keysave"; save.textContent = "Save"; const submit = async () => { const key = String(input.value || "").trim(); if (!key) return; save.disabled = true; const sent = await ctx.emit({ type: "gif.key", key }); save.disabled = false; if (!sent) { intro.textContent = "Couldn't save that key — check your connection and try again."; return; } box.remove(); // Search with the key we just typed, NOT with ctx.getState(): the event // has to round-trip the server before it folds, so reading state here // would race it and bounce us straight back to this form. load(search.value.trim(), key); }; save.onclick = submit; input.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); submit(); } }; row.append(input, save); box.append(intro, how, row); grid.appendChild(box); input.focus(); }; const post = async (item, tile) => { if (posting) return; posting = true; tile.classList.add("gif-tile--busy"); setStatus(""); try { // Walk the acceptable renditions: a single oversize/failed download // falls back to the next size instead of failing the whole send. let r = null; let bytes = null; for (const cand of renditionCandidates(item.images)) { bytes = await gifHost.bytes(cand.url, GIF_MAX_BYTES); if (bytes) { r = cand; break; } } if (!bytes) throw new Error("fetch"); const name = (item.title || "gif").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "gif"; // NOT ctx.emit: ctx addresses the space (where the key lives), and the GIF // is conversation content — it belongs to the chat, under the chat's own // key. The host owns "here", so posting is its doorway's job. const sent = await gifHost.post(bytes, { name: name + ".gif", type: "image/gif", w: r.w, h: r.h }); if (!sent) throw new Error("send"); panel.close(); // posted — the message in the feed is the result } catch (e) { console.error("gif post failed at", (e && e.message) || e); tile.classList.remove("gif-tile--busy"); posting = false; setStatus("Couldn't send that one — try another."); } }; const show = (items) => { grid.replaceChildren(); for (const item of items) { const tile = document.createElement("button"); tile.type = "button"; tile.className = "gif-tile"; tile.setAttribute("aria-label", "Send: " + item.title); const img = document.createElement("img"); img.src = item.preview.url; // https-only Giphy CDN — allowed by img-src img.alt = item.title; img.loading = "lazy"; tile.appendChild(img); tile.onclick = () => post(item, tile); grid.appendChild(tile); } }; // keyOverride: the just-typed key, for the one call that can't read it back // from state yet (see submit). Otherwise the key is the space's, off the fold. const load = async (q, keyOverride) => { const seq = ++searchSeq; setStatus("Searching…"); const key = keyOverride || ((ctx.getState() || {}).key ?? ""); const data = await gifHost.search(q, key); if (seq !== searchSeq) return; // a newer search superseded this one if (data === "key") { show([]); return showKeyForm(""); } if (data === "auth") { show([]); return showKeyForm("Giphy rejected that key — check it and save again. It's shared with everyone in this space."); } if (!data) { show([]); setStatus("Couldn't reach Giphy — check your connection and try again."); return; } const items = gifItems(data); show(items); setStatus(items.length ? "" : `Nothing for “${q}”. Try another search.`); }; // Refining re-searches (debounced); Enter searches immediately. Escape // bubbles to the pane's handler, which closes the panel. let debounce = 0; search.oninput = () => { clearTimeout(debounce); debounce = setTimeout(() => load(search.value.trim()), 350); }; search.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); clearTimeout(debounce); load(search.value.trim()); } }; load(initialQuery); }