| 1 | // GIF search: `/gif [search]` opens a COMMAND PANEL of Giphy results — tap one |
| 2 | // and it posts into the chat as a normal encrypted image message, from you. |
| 3 | // The app never fetches: all Giphy traffic goes through the host's scoped |
| 4 | // doorway (ctx.host.gif — search, bytes, post), and what lands in the thread |
| 5 | // is an ordinary attachment blob, encrypted client-side to the thread key. The |
| 6 | // server never sees the search, the pick, or the bytes; Giphy sees the query |
| 7 | // from YOUR client (the one deliberate trade-off — see CLAUDE.md, next to |
| 8 | // unfurl's), authenticated with the SPACE's free Giphy key. |
| 9 | // |
| 10 | // The key is asked for ONCE, from whoever runs /gif first, and then belongs to |
| 11 | // the space: it folds into the space thread's log like any space app's data, so |
| 12 | // every other member — and every later joiner — reads it and is never asked. |
| 13 | // That it's readable by everyone here isn't a leak to design around; it's the |
| 14 | // point. A Giphy key is a public client identifier, not a secret (the copy on |
| 15 | // the form says so, because the key does become shared). The lchat server still |
| 16 | // never sees it: the space thread's key rides the instance key, so the blob is |
| 17 | // opaque to it like everything else. |
| 18 | // |
| 19 | // This is the IMPLICITLY SPACE-PLACED shape (apps.js instanceThread): places |
| 20 | // ["space"] + listed:false, so ctx addresses the SPACE (the key), while the |
| 21 | // panel and the posted GIF address the CHAT you typed in — ctx.panel already |
| 22 | // opens there, and the host's `post` sends there. "App data lives space-wide, |
| 23 | // conversation content stays under the conversation's own key" |
| 24 | // (docs/space-apps.md). Posting is direct — tap = send, like Slack's /giphy — |
| 25 | // so the same flow works in the native shells' command-host webview, where |
| 26 | // there is no web composer to stage into. |
| 27 | import { defineApp } from "../apps.js"; |
| 28 | import { ICON_FILM } from "../icons.js"; |
| 29 | |
| 30 | // The largest GIF we'll post. Giphy's "downsized" rendition targets ~2 MB; |
| 31 | // this cap is the backstop when only bigger renditions exist. |
| 32 | export const GIF_MAX_BYTES = 8 * 1024 * 1024; |
| 33 | |
| 34 | // renditionCandidates lists the POSTABLE renditions of a Giphy result, best |
| 35 | // first: downsized → fixed_height → original, skipping metadata-oversize ones |
| 36 | // (sizes arrive as strings, sometimes "0"/absent — the byte fetch re-checks |
| 37 | // the real length, so an unknown size stays in). The panel walks the list on |
| 38 | // post, so one failing URL doesn't kill the send. Pure, unit-testable. |
| 39 | export function renditionCandidates(images, maxBytes = GIF_MAX_BYTES) { |
| 40 | const out = []; |
| 41 | for (const name of ["downsized", "fixed_height", "original"]) { |
| 42 | const r = images && images[name]; |
| 43 | if (!r || !r.url) continue; |
| 44 | const size = parseInt(r.size, 10) || 0; |
| 45 | if (size > maxBytes) continue; |
| 46 | if (out.some((c) => c.url === r.url)) continue; |
| 47 | out.push({ url: r.url, w: parseInt(r.width, 10) || 0, h: parseInt(r.height, 10) || 0 }); |
| 48 | } |
| 49 | return out; |
| 50 | } |
| 51 | |
| 52 | // The best single choice — candidates' head (kept for gifItems' usability gate). |
| 53 | export function pickRendition(images, maxBytes = GIF_MAX_BYTES) { |
| 54 | return renditionCandidates(images, maxBytes)[0] || null; |
| 55 | } |
| 56 | |
| 57 | // gifItems maps a raw Giphy data[] page to what the panel renders: a preview |
| 58 | // (small, fixed-height for an even grid) plus the full images map for |
| 59 | // pickRendition at post time. Results without a usable preview or post |
| 60 | // rendition are dropped. Pure, testable. |
| 61 | export function gifItems(data) { |
| 62 | const out = []; |
| 63 | for (const g of data || []) { |
| 64 | if (!g || !g.images) continue; |
| 65 | const p = g.images.fixed_height_small || g.images.fixed_height; |
| 66 | if (!p || !p.url || !pickRendition(g.images)) continue; |
| 67 | out.push({ |
| 68 | id: g.id || "", |
| 69 | title: g.title || "GIF", |
| 70 | preview: { url: p.url, w: parseInt(p.width, 10) || 0, h: parseInt(p.height, 10) || 0 }, |
| 71 | images: g.images, |
| 72 | }); |
| 73 | } |
| 74 | return out; |
| 75 | } |
| 76 | |
| 77 | export const app = defineApp({ |
| 78 | name: "gif", |
| 79 | title: "GIFs", |
| 80 | glyph: "🎞️", |
| 81 | symbol: "film", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) |
| 82 | logo: ICON_FILM, |
| 83 | // The command acts in the chat you typed it in, but its one piece of data — |
| 84 | // the space's Giphy key — lives in the space. listed:false keeps it out of the |
| 85 | // Apps catalogue (it's a command, not something you add somewhere); with no |
| 86 | // `render` it stays off the widget bar and the sidebar band for free. Together |
| 87 | // that's "implicitly space-placed" — see the header and apps.js instanceThread. |
| 88 | places: ["space"], |
| 89 | listed: false, |
| 90 | initialState: () => ({}), |
| 91 | events: { |
| 92 | // The space's Giphy key. Last writer wins: re-running the form (or fixing a |
| 93 | // rejected key) is just another event, and the fold is a plain overwrite — |
| 94 | // idempotent, and order-stable at the thread's horizon. |
| 95 | "gif.key": (s, e) => ({ ...s, key: String((e && e.key) || "").trim() }), |
| 96 | }, |
| 97 | |
| 98 | commands: { |
| 99 | "/gif": { |
| 100 | desc: "find a GIF — tap one to send it here", |
| 101 | args: "[search]", |
| 102 | run(args, ctx) { |
| 103 | const gifHost = ctx.host && ctx.host.gif; |
| 104 | if (!gifHost) { |
| 105 | ctx.reply({ text: "GIF search isn't available here." }); |
| 106 | return; |
| 107 | } |
| 108 | // No guard for an unreachable space thread: runCommand never calls a |
| 109 | // command it couldn't route (it answers "/gif isn't available here yet" |
| 110 | // itself), so ctx.threadId is always the space's by the time we're here. |
| 111 | const q = (args || "").trim(); |
| 112 | const opened = ctx.panel({ |
| 113 | title: q ? `GIFs matching “${q}”` : "GIFs — tap one to send it", |
| 114 | render(el, panel) { |
| 115 | buildGifPanel(el, panel, ctx, gifHost, q); |
| 116 | }, |
| 117 | }); |
| 118 | if (!opened) ctx.reply({ text: "Open a chat to use /gif." }); |
| 119 | }, |
| 120 | }, |
| 121 | }, |
| 122 | |
| 123 | styles: ` |
| 124 | .gif-search { |
| 125 | width: 100%; box-sizing: border-box; margin: 0 0 8px; |
| 126 | padding: 6px 10px; font: inherit; font-size: 13px; color: var(--ink); |
| 127 | background: var(--bubble-them); border: 1px solid var(--border); border-radius: 8px; |
| 128 | } |
| 129 | .gif-search:focus { outline: none; border-color: var(--accent); } |
| 130 | .gif-grid { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; } |
| 131 | .gif-tile { |
| 132 | border: none; background: var(--bubble-them); padding: 0; cursor: pointer; |
| 133 | border-radius: 8px; overflow: hidden; height: 100px; position: relative; |
| 134 | } |
| 135 | .gif-tile img { display: block; height: 100px; width: auto; } |
| 136 | .gif-tile:hover { outline: 2px solid var(--accent); } |
| 137 | .gif-tile--busy::after { |
| 138 | content: "Sending…"; position: absolute; inset: 0; display: flex; |
| 139 | align-items: center; justify-content: center; font-size: 12px; font-weight: 600; |
| 140 | color: #fff; background: rgba(0, 0, 0, 0.55); |
| 141 | } |
| 142 | .gif-status { font-size: 12px; color: var(--muted); padding: 6px 2px; } |
| 143 | .gif-credit { font-size: 10px; color: var(--muted); text-align: right; padding-top: 6px; } |
| 144 | .gif-keylink { border: none; background: none; padding: 0; font: inherit; color: var(--link); cursor: pointer; } |
| 145 | .gif-keyform { display: flex; flex-direction: column; gap: 8px; max-width: 46ch; padding: 4px 0 8px; } |
| 146 | .gif-keyintro { margin: 0; font-size: 13px; color: var(--ink); line-height: 1.45; } |
| 147 | .gif-keyform a { font-size: 12px; color: var(--link); } |
| 148 | .gif-keyrow { display: flex; gap: 6px; } |
| 149 | .gif-keyrow .gif-search { margin: 0; flex: 1 1 auto; } |
| 150 | .gif-keysave { |
| 151 | flex: 0 0 auto; border: none; border-radius: 8px; padding: 6px 14px; |
| 152 | font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; |
| 153 | background: var(--accent); color: var(--accent-ink, #fff); |
| 154 | } |
| 155 | `, |
| 156 | }); |
| 157 | |
| 158 | // The panel: a refine-search box, the result grid, a status line, and the |
| 159 | // attribution Giphy's terms ask for. All Giphy I/O rides the host doorway. |
| 160 | function buildGifPanel(el, panel, ctx, gifHost, initialQuery) { |
| 161 | let posting = false; |
| 162 | let searchSeq = 0; |
| 163 | |
| 164 | const search = document.createElement("input"); |
| 165 | search.className = "gif-search"; |
| 166 | search.type = "search"; |
| 167 | search.placeholder = "Search GIFs…"; |
| 168 | search.value = initialQuery; |
| 169 | |
| 170 | const grid = document.createElement("div"); |
| 171 | grid.className = "gif-grid"; |
| 172 | const status = document.createElement("div"); |
| 173 | status.className = "gif-status"; |
| 174 | const credit = document.createElement("div"); |
| 175 | credit.className = "gif-credit"; |
| 176 | credit.append("Powered by GIPHY · "); |
| 177 | const keyLink = document.createElement("button"); |
| 178 | keyLink.type = "button"; |
| 179 | keyLink.className = "gif-keylink"; |
| 180 | keyLink.textContent = "API key…"; |
| 181 | keyLink.onclick = () => showKeyForm(""); |
| 182 | credit.appendChild(keyLink); |
| 183 | // Status sits ABOVE the grid: the panel scrolls, and an error appended |
| 184 | // below a tall grid would land out of view exactly when it matters. |
| 185 | el.append(search, status, grid, credit); |
| 186 | |
| 187 | const setStatus = (text) => { |
| 188 | status.textContent = text || ""; |
| 189 | status.style.display = text ? "" : "none"; |
| 190 | }; |
| 191 | |
| 192 | // First-launch setup (and the recovery path for a rejected key): the FIRST |
| 193 | // person to run /gif in this space supplies a free Giphy key, and from then on |
| 194 | // it belongs to the space — nobody else is ever asked. The copy says so |
| 195 | // plainly: the key becomes shared, and everyone's searches go out under it, so |
| 196 | // whoever pastes it is lending their Giphy account to the room. That's fine |
| 197 | // for a public client identifier, but it must not be a surprise. |
| 198 | const showKeyForm = (problem) => { |
| 199 | grid.replaceChildren(); |
| 200 | setStatus(""); |
| 201 | const box = document.createElement("div"); |
| 202 | box.className = "gif-keyform"; |
| 203 | const intro = document.createElement("p"); |
| 204 | intro.className = "gif-keyintro"; |
| 205 | intro.textContent = problem || |
| 206 | "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."; |
| 207 | const how = document.createElement("a"); |
| 208 | how.href = "https://developers.giphy.com/dashboard/"; |
| 209 | how.target = "_blank"; |
| 210 | how.rel = "noopener noreferrer"; |
| 211 | how.textContent = "Get a key at developers.giphy.com"; |
| 212 | const row = document.createElement("div"); |
| 213 | row.className = "gif-keyrow"; |
| 214 | const input = document.createElement("input"); |
| 215 | input.className = "gif-search"; |
| 216 | input.type = "text"; |
| 217 | input.placeholder = "Paste your Giphy API key"; |
| 218 | input.autocomplete = "off"; |
| 219 | input.spellcheck = false; |
| 220 | const save = document.createElement("button"); |
| 221 | save.type = "button"; |
| 222 | save.className = "gif-keysave"; |
| 223 | save.textContent = "Save"; |
| 224 | const submit = async () => { |
| 225 | const key = String(input.value || "").trim(); |
| 226 | if (!key) return; |
| 227 | save.disabled = true; |
| 228 | const sent = await ctx.emit({ type: "gif.key", key }); |
| 229 | save.disabled = false; |
| 230 | if (!sent) { |
| 231 | intro.textContent = "Couldn't save that key — check your connection and try again."; |
| 232 | return; |
| 233 | } |
| 234 | box.remove(); |
| 235 | // Search with the key we just typed, NOT with ctx.getState(): the event |
| 236 | // has to round-trip the server before it folds, so reading state here |
| 237 | // would race it and bounce us straight back to this form. |
| 238 | load(search.value.trim(), key); |
| 239 | }; |
| 240 | save.onclick = submit; |
| 241 | input.onkeydown = (e) => { |
| 242 | if (e.key === "Enter") { |
| 243 | e.preventDefault(); |
| 244 | submit(); |
| 245 | } |
| 246 | }; |
| 247 | row.append(input, save); |
| 248 | box.append(intro, how, row); |
| 249 | grid.appendChild(box); |
| 250 | input.focus(); |
| 251 | }; |
| 252 | |
| 253 | const post = async (item, tile) => { |
| 254 | if (posting) return; |
| 255 | posting = true; |
| 256 | tile.classList.add("gif-tile--busy"); |
| 257 | setStatus(""); |
| 258 | try { |
| 259 | // Walk the acceptable renditions: a single oversize/failed download |
| 260 | // falls back to the next size instead of failing the whole send. |
| 261 | let r = null; |
| 262 | let bytes = null; |
| 263 | for (const cand of renditionCandidates(item.images)) { |
| 264 | bytes = await gifHost.bytes(cand.url, GIF_MAX_BYTES); |
| 265 | if (bytes) { |
| 266 | r = cand; |
| 267 | break; |
| 268 | } |
| 269 | } |
| 270 | if (!bytes) throw new Error("fetch"); |
| 271 | const name = (item.title || "gif").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "gif"; |
| 272 | // NOT ctx.emit: ctx addresses the space (where the key lives), and the GIF |
| 273 | // is conversation content — it belongs to the chat, under the chat's own |
| 274 | // key. The host owns "here", so posting is its doorway's job. |
| 275 | const sent = await gifHost.post(bytes, { name: name + ".gif", type: "image/gif", w: r.w, h: r.h }); |
| 276 | if (!sent) throw new Error("send"); |
| 277 | panel.close(); // posted — the message in the feed is the result |
| 278 | } catch (e) { |
| 279 | console.error("gif post failed at", (e && e.message) || e); |
| 280 | tile.classList.remove("gif-tile--busy"); |
| 281 | posting = false; |
| 282 | setStatus("Couldn't send that one — try another."); |
| 283 | } |
| 284 | }; |
| 285 | |
| 286 | const show = (items) => { |
| 287 | grid.replaceChildren(); |
| 288 | for (const item of items) { |
| 289 | const tile = document.createElement("button"); |
| 290 | tile.type = "button"; |
| 291 | tile.className = "gif-tile"; |
| 292 | tile.setAttribute("aria-label", "Send: " + item.title); |
| 293 | const img = document.createElement("img"); |
| 294 | img.src = item.preview.url; // https-only Giphy CDN — allowed by img-src |
| 295 | img.alt = item.title; |
| 296 | img.loading = "lazy"; |
| 297 | tile.appendChild(img); |
| 298 | tile.onclick = () => post(item, tile); |
| 299 | grid.appendChild(tile); |
| 300 | } |
| 301 | }; |
| 302 | |
| 303 | // keyOverride: the just-typed key, for the one call that can't read it back |
| 304 | // from state yet (see submit). Otherwise the key is the space's, off the fold. |
| 305 | const load = async (q, keyOverride) => { |
| 306 | const seq = ++searchSeq; |
| 307 | setStatus("Searching…"); |
| 308 | const key = keyOverride || ((ctx.getState() || {}).key ?? ""); |
| 309 | const data = await gifHost.search(q, key); |
| 310 | if (seq !== searchSeq) return; // a newer search superseded this one |
| 311 | if (data === "key") { |
| 312 | show([]); |
| 313 | return showKeyForm(""); |
| 314 | } |
| 315 | if (data === "auth") { |
| 316 | show([]); |
| 317 | return showKeyForm("Giphy rejected that key — check it and save again. It's shared with everyone in this space."); |
| 318 | } |
| 319 | if (!data) { |
| 320 | show([]); |
| 321 | setStatus("Couldn't reach Giphy — check your connection and try again."); |
| 322 | return; |
| 323 | } |
| 324 | const items = gifItems(data); |
| 325 | show(items); |
| 326 | setStatus(items.length ? "" : `Nothing for “${q}”. Try another search.`); |
| 327 | }; |
| 328 | |
| 329 | // Refining re-searches (debounced); Enter searches immediately. Escape |
| 330 | // bubbles to the pane's handler, which closes the panel. |
| 331 | let debounce = 0; |
| 332 | search.oninput = () => { |
| 333 | clearTimeout(debounce); |
| 334 | debounce = setTimeout(() => load(search.value.trim()), 350); |
| 335 | }; |
| 336 | search.onkeydown = (e) => { |
| 337 | if (e.key === "Enter") { |
| 338 | e.preventDefault(); |
| 339 | clearTimeout(debounce); |
| 340 | load(search.value.trim()); |
| 341 | } |
| 342 | }; |
| 343 | |
| 344 | load(initialQuery); |
| 345 | } |
| 346 | |