// The Bookmarks mini-app: your own place to keep the links worth remembering. // The compelling path is saving a link *someone posted in a chat* — long-press // (or hover) any message that has a link and choose "Save link"; it files into // your bookmarks with a note of who shared it. You can also stash a link by hand // with /save [| note] from anywhere. The app gets its own sidebar thread // with a full-pane view of everything saved, newest first. Every bookmark is a // normal encrypted event in your own self-thread, so the server only ever sees // ciphertext — never a URL, a note, or who you saved it from. // // Relative imports (not "/static/…") so the reducer + link detection are // exercisable in Node tests while still resolving under /static/ in the browser. import { defineApp, namespace, addItem, removeItem } from "../apps.js"; import { ICON_BOOKMARK } from "../icons.js"; import { firstLink } from "../format.js"; // One declaration of the app's event prefix, reused everywhere: N.ev("add") → the // "bookmark.add" wire type (used as the `events` keys and in every emit). Note the // event prefix is the SINGULAR "bookmark" while the app name + CSS classes are the // plural "bookmarks-", so N drives only event types; the classes stay literal below. const N = namespace("bookmark"); // A URL is accepted as-is when it already has an http(s) scheme; a bare domain // (has a dot, no spaces) gets https:// prepended; anything else is rejected. function normalizeUrl(raw) { const s = (raw || "").trim(); if (!s) return null; if (/^https?:\/\//i.test(s)) return s; if (s.includes(".") && !/\s/.test(s)) return "https://" + s; return null; } // Readable link text: hostname plus a truncated path/query, falling back to the // raw url when it doesn't parse (reducer state may hold anything an old client // emitted, so never trust it to be well-formed). function linkText(url) { try { const u = new URL(url); let path = u.pathname === "/" ? "" : u.pathname; if (u.search) path += u.search; if (path.length > 40) path = path.slice(0, 39) + "…"; return u.hostname + path; } catch { return url; } } // A bookmark is { id, url, note, from, ts }. `from` is who shared the link when // it was saved off a chat message ("" when you saved it yourself); `ts` is the // transport-injected send time, so the list sorts newest-first without the emitter // stamping a time. addItem dedupes by id and returns a fresh array, so the sort is // always safe and re-folding the window on reopen never duplicates. export const app = defineApp({ name: "bookmarks", title: "Bookmarks", blurb: "Save a bookmark", // sidebar subtitle before first use (sync w/ native) glyph: "🔖", symbol: "bookmark", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) logo: ICON_BOOKMARK, // The reference places-first declaration (docs/space-apps.md, phase 3): // your reading list or a group's shared link list — the same reducer // serves every copy; only the thread differs. The SPACE place (one library // for the whole space) is parked while the places UI is hidden (§ The // pullback) — restore by adding "space" back to this line. // (The "Save link" message action still files into YOUR bookmarks — // saving somebody's link privately is the point of that gesture.) places: ["you", "chat"], initialState: () => ({ items: [] }), events: { [N.ev("add")]: (s, e) => { if (!e.id || !e.url) return s; const items = addItem(s.items, { id: e.id, url: e.url, note: e.note || "", from: e.from || "", ts: e.ts || 0 }); items.sort((a, b) => b.ts - a.ts); return { items }; }, [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }), }, commands: { // /save [| note] — everything before the first "|" is the url, // everything after is an optional note. Silently ignores invalid urls. "/save": { desc: "Save a bookmark", args: " [| note]", run: (args, ctx) => { const bar = args.indexOf("|"); const rawUrl = bar >= 0 ? args.slice(0, bar) : args; const note = bar >= 0 ? args.slice(bar + 1).trim() : ""; const url = normalizeUrl(rawUrl); if (url) ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note }); }, }, }, // Offer "Save link" on any chat message that contains a link — the whole point // of the app. firstLink() picks the first real http(s) link the reader sees // (it skips code blocks, matching the feed's own linkifier). Saving records who // posted it as the note's attribution, all encrypted into your own thread. messageAction(msg) { const url = firstLink(msg.text || ""); if (!url) return null; return { label: "Save link", glyph: "🔖", run(ctx) { ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note: "", from: msg.from || "" }); }, }; }, // This app's CSS travels with it — the framework injects it once. It reads the // theme's CSS variables (var(--link) etc.) so it follows the chosen theme. styles: ` .bookmarks-pane { max-width: 640px; margin: 0 auto; padding: 16px; } .bookmarks-form { display: flex; gap: 6px; margin-bottom: 14px; } .bookmarks-input { flex: 1 1 auto; min-width: 0; padding: 7px 10px; font: inherit; font-size: 14px; color: var(--ink); background: var(--panel); border: 1px solid var(--border); border-radius: 8px; } .bookmarks-input::placeholder { color: var(--muted); } .bookmarks-input:focus { outline: none; border-color: var(--accent); } .bookmarks-add { flex: 0 0 auto; padding: 7px 14px; font: inherit; font-size: 14px; font-weight: 600; color: var(--accent-ink); background: var(--accent); border: none; border-radius: 8px; cursor: var(--ui-cursor, pointer); } .bookmarks-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } .bookmarks-item { display: flex; align-items: flex-start; gap: 8px; padding: 9px 2px; border-bottom: 1px solid var(--border); } .bookmarks-item:last-child { border-bottom: none; } .bookmarks-main { flex: 1 1 auto; min-width: 0; } .bookmarks-link { color: var(--link); text-decoration: none; font-size: 14px; overflow-wrap: anywhere; word-break: break-all; } .bookmarks-link:hover { text-decoration: underline; } .bookmarks-note { margin-top: 2px; font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere; } .bookmarks-from { margin-top: 2px; font-size: 12px; color: var(--muted); } .bookmarks-remove { flex: 0 0 auto; border: none; background: transparent; color: var(--muted); font-size: 18px; line-height: 1; cursor: var(--ui-cursor, pointer); padding: 0 2px; opacity: 0; transition: opacity 0.12s; } .bookmarks-item:hover .bookmarks-remove, .bookmarks-remove:focus-visible { opacity: 1; } @media (pointer: coarse) { .bookmarks-remove { opacity: 1; } } .bookmarks-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 18px 0; line-height: 1.5; } `, // Personal scope: el is the full thread pane. A compact add form on top, then // the saved links newest-first (the reducer keeps them sorted). render(state, el, ctx) { const items = (state && state.items) || []; const pane = document.createElement("div"); pane.className = "bookmarks-pane"; const form = document.createElement("form"); form.className = "bookmarks-form"; const urlInput = document.createElement("input"); urlInput.className = "bookmarks-input"; urlInput.type = "text"; urlInput.placeholder = "https://…"; urlInput.setAttribute("aria-label", "URL"); const noteInput = document.createElement("input"); noteInput.className = "bookmarks-input"; noteInput.type = "text"; noteInput.placeholder = "Note (optional)"; noteInput.setAttribute("aria-label", "Note"); const add = document.createElement("button"); add.type = "submit"; add.className = "bookmarks-add"; add.textContent = "Add"; form.onsubmit = (ev) => { ev.preventDefault(); const url = normalizeUrl(urlInput.value); if (!url) return; ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note: noteInput.value.trim() }); urlInput.value = ""; noteInput.value = ""; }; form.append(urlInput, noteInput, add); pane.appendChild(form); if (!items.length) { const empty = document.createElement("div"); empty.className = "bookmarks-empty"; // Lead with the hero interaction (save from a chat), then the manual path. empty.textContent = "Nothing saved yet. Long-press a message with a link and choose “Save link”, or add one above."; pane.appendChild(empty); } else { const list = document.createElement("ul"); list.className = "bookmarks-list"; for (const item of items) { const li = document.createElement("li"); li.className = "bookmarks-item"; const main = document.createElement("div"); main.className = "bookmarks-main"; const a = document.createElement("a"); a.className = "bookmarks-link"; a.setAttribute("href", item.url); // url is user content — attribute, never innerHTML a.setAttribute("target", "_blank"); a.setAttribute("rel", "noopener noreferrer"); a.textContent = linkText(item.url); main.appendChild(a); if (item.note) { const note = document.createElement("div"); note.className = "bookmarks-note"; note.textContent = item.note; // textContent — never innerHTML for user content main.appendChild(note); } if (item.from) { const from = document.createElement("div"); from.className = "bookmarks-from"; from.textContent = "Shared by " + item.from; // sender name — textContent, never innerHTML main.appendChild(from); } const rm = document.createElement("button"); rm.type = "button"; rm.className = "bookmarks-remove"; rm.setAttribute("aria-label", "Remove bookmark"); rm.textContent = "×"; rm.onclick = () => ctx.emit({ type: N.ev("remove"), id: item.id }); li.append(main, rm); list.appendChild(li); } pane.appendChild(list); } el.appendChild(pane); }, });