eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
27import { defineApp } from "../apps.js";
28import { 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.
32export 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.
39export 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).
53export 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.
61export 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
77export 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.
160function 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