| 1 | // The Bookmarks mini-app: your own place to keep the links worth remembering. |
| 2 | // The compelling path is saving a link *someone posted in a chat* — long-press |
| 3 | // (or hover) any message that has a link and choose "Save link"; it files into |
| 4 | // your bookmarks with a note of who shared it. You can also stash a link by hand |
| 5 | // with /save <url> [| note] from anywhere. The app gets its own sidebar thread |
| 6 | // with a full-pane view of everything saved, newest first. Every bookmark is a |
| 7 | // normal encrypted event in your own self-thread, so the server only ever sees |
| 8 | // ciphertext — never a URL, a note, or who you saved it from. |
| 9 | // |
| 10 | // Relative imports (not "/static/…") so the reducer + link detection are |
| 11 | // exercisable in Node tests while still resolving under /static/ in the browser. |
| 12 | import { defineApp, namespace, addItem, removeItem } from "../apps.js"; |
| 13 | import { ICON_BOOKMARK } from "../icons.js"; |
| 14 | import { firstLink } from "../format.js"; |
| 15 | |
| 16 | // One declaration of the app's event prefix, reused everywhere: N.ev("add") → the |
| 17 | // "bookmark.add" wire type (used as the `events` keys and in every emit). Note the |
| 18 | // event prefix is the SINGULAR "bookmark" while the app name + CSS classes are the |
| 19 | // plural "bookmarks-", so N drives only event types; the classes stay literal below. |
| 20 | const N = namespace("bookmark"); |
| 21 | |
| 22 | // A URL is accepted as-is when it already has an http(s) scheme; a bare domain |
| 23 | // (has a dot, no spaces) gets https:// prepended; anything else is rejected. |
| 24 | function normalizeUrl(raw) { |
| 25 | const s = (raw || "").trim(); |
| 26 | if (!s) return null; |
| 27 | if (/^https?:\/\//i.test(s)) return s; |
| 28 | if (s.includes(".") && !/\s/.test(s)) return "https://" + s; |
| 29 | return null; |
| 30 | } |
| 31 | |
| 32 | // Readable link text: hostname plus a truncated path/query, falling back to the |
| 33 | // raw url when it doesn't parse (reducer state may hold anything an old client |
| 34 | // emitted, so never trust it to be well-formed). |
| 35 | function linkText(url) { |
| 36 | try { |
| 37 | const u = new URL(url); |
| 38 | let path = u.pathname === "/" ? "" : u.pathname; |
| 39 | if (u.search) path += u.search; |
| 40 | if (path.length > 40) path = path.slice(0, 39) + "…"; |
| 41 | return u.hostname + path; |
| 42 | } catch { |
| 43 | return url; |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // A bookmark is { id, url, note, from, ts }. `from` is who shared the link when |
| 48 | // it was saved off a chat message ("" when you saved it yourself); `ts` is the |
| 49 | // transport-injected send time, so the list sorts newest-first without the emitter |
| 50 | // stamping a time. addItem dedupes by id and returns a fresh array, so the sort is |
| 51 | // always safe and re-folding the window on reopen never duplicates. |
| 52 | export const app = defineApp({ |
| 53 | name: "bookmarks", |
| 54 | title: "Bookmarks", |
| 55 | blurb: "Save a bookmark", // sidebar subtitle before first use (sync w/ native) |
| 56 | glyph: "🔖", |
| 57 | symbol: "bookmark", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) |
| 58 | logo: ICON_BOOKMARK, |
| 59 | // The reference places-first declaration (docs/space-apps.md, phase 3): |
| 60 | // your reading list or a group's shared link list — the same reducer |
| 61 | // serves every copy; only the thread differs. The SPACE place (one library |
| 62 | // for the whole space) is parked while the places UI is hidden (§ The |
| 63 | // pullback) — restore by adding "space" back to this line. |
| 64 | // (The "Save link" message action still files into YOUR bookmarks — |
| 65 | // saving somebody's link privately is the point of that gesture.) |
| 66 | places: ["you", "chat"], |
| 67 | initialState: () => ({ items: [] }), |
| 68 | events: { |
| 69 | [N.ev("add")]: (s, e) => { |
| 70 | if (!e.id || !e.url) return s; |
| 71 | const items = addItem(s.items, { id: e.id, url: e.url, note: e.note || "", from: e.from || "", ts: e.ts || 0 }); |
| 72 | items.sort((a, b) => b.ts - a.ts); |
| 73 | return { items }; |
| 74 | }, |
| 75 | [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }), |
| 76 | }, |
| 77 | commands: { |
| 78 | // /save <url> [| note] — everything before the first "|" is the url, |
| 79 | // everything after is an optional note. Silently ignores invalid urls. |
| 80 | "/save": { |
| 81 | desc: "Save a bookmark", |
| 82 | args: "<url> [| note]", |
| 83 | run: (args, ctx) => { |
| 84 | const bar = args.indexOf("|"); |
| 85 | const rawUrl = bar >= 0 ? args.slice(0, bar) : args; |
| 86 | const note = bar >= 0 ? args.slice(bar + 1).trim() : ""; |
| 87 | const url = normalizeUrl(rawUrl); |
| 88 | if (url) ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note }); |
| 89 | }, |
| 90 | }, |
| 91 | }, |
| 92 | |
| 93 | // Offer "Save link" on any chat message that contains a link — the whole point |
| 94 | // of the app. firstLink() picks the first real http(s) link the reader sees |
| 95 | // (it skips code blocks, matching the feed's own linkifier). Saving records who |
| 96 | // posted it as the note's attribution, all encrypted into your own thread. |
| 97 | messageAction(msg) { |
| 98 | const url = firstLink(msg.text || ""); |
| 99 | if (!url) return null; |
| 100 | return { |
| 101 | label: "Save link", |
| 102 | glyph: "🔖", |
| 103 | run(ctx) { |
| 104 | ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note: "", from: msg.from || "" }); |
| 105 | }, |
| 106 | }; |
| 107 | }, |
| 108 | |
| 109 | // This app's CSS travels with it — the framework injects it once. It reads the |
| 110 | // theme's CSS variables (var(--link) etc.) so it follows the chosen theme. |
| 111 | styles: ` |
| 112 | .bookmarks-pane { max-width: 640px; margin: 0 auto; padding: 16px; } |
| 113 | .bookmarks-form { display: flex; gap: 6px; margin-bottom: 14px; } |
| 114 | .bookmarks-input { |
| 115 | flex: 1 1 auto; min-width: 0; padding: 7px 10px; font: inherit; font-size: 14px; |
| 116 | color: var(--ink); background: var(--panel); |
| 117 | border: 1px solid var(--border); border-radius: 8px; |
| 118 | } |
| 119 | .bookmarks-input::placeholder { color: var(--muted); } |
| 120 | .bookmarks-input:focus { outline: none; border-color: var(--accent); } |
| 121 | .bookmarks-add { |
| 122 | flex: 0 0 auto; padding: 7px 14px; font: inherit; font-size: 14px; font-weight: 600; |
| 123 | color: var(--accent-ink); background: var(--accent); |
| 124 | border: none; border-radius: 8px; cursor: var(--ui-cursor, pointer); |
| 125 | } |
| 126 | .bookmarks-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } |
| 127 | .bookmarks-item { |
| 128 | display: flex; align-items: flex-start; gap: 8px; |
| 129 | padding: 9px 2px; border-bottom: 1px solid var(--border); |
| 130 | } |
| 131 | .bookmarks-item:last-child { border-bottom: none; } |
| 132 | .bookmarks-main { flex: 1 1 auto; min-width: 0; } |
| 133 | .bookmarks-link { |
| 134 | color: var(--link); text-decoration: none; font-size: 14px; |
| 135 | overflow-wrap: anywhere; word-break: break-all; |
| 136 | } |
| 137 | .bookmarks-link:hover { text-decoration: underline; } |
| 138 | .bookmarks-note { margin-top: 2px; font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere; } |
| 139 | .bookmarks-from { margin-top: 2px; font-size: 12px; color: var(--muted); } |
| 140 | .bookmarks-remove { |
| 141 | flex: 0 0 auto; border: none; background: transparent; color: var(--muted); |
| 142 | font-size: 18px; line-height: 1; cursor: var(--ui-cursor, pointer); padding: 0 2px; |
| 143 | opacity: 0; transition: opacity 0.12s; |
| 144 | } |
| 145 | .bookmarks-item:hover .bookmarks-remove, .bookmarks-remove:focus-visible { opacity: 1; } |
| 146 | @media (pointer: coarse) { .bookmarks-remove { opacity: 1; } } |
| 147 | .bookmarks-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 18px 0; line-height: 1.5; } |
| 148 | `, |
| 149 | |
| 150 | // Personal scope: el is the full thread pane. A compact add form on top, then |
| 151 | // the saved links newest-first (the reducer keeps them sorted). |
| 152 | render(state, el, ctx) { |
| 153 | const items = (state && state.items) || []; |
| 154 | |
| 155 | const pane = document.createElement("div"); |
| 156 | pane.className = "bookmarks-pane"; |
| 157 | |
| 158 | const form = document.createElement("form"); |
| 159 | form.className = "bookmarks-form"; |
| 160 | |
| 161 | const urlInput = document.createElement("input"); |
| 162 | urlInput.className = "bookmarks-input"; |
| 163 | urlInput.type = "text"; |
| 164 | urlInput.placeholder = "https://…"; |
| 165 | urlInput.setAttribute("aria-label", "URL"); |
| 166 | |
| 167 | const noteInput = document.createElement("input"); |
| 168 | noteInput.className = "bookmarks-input"; |
| 169 | noteInput.type = "text"; |
| 170 | noteInput.placeholder = "Note (optional)"; |
| 171 | noteInput.setAttribute("aria-label", "Note"); |
| 172 | |
| 173 | const add = document.createElement("button"); |
| 174 | add.type = "submit"; |
| 175 | add.className = "bookmarks-add"; |
| 176 | add.textContent = "Add"; |
| 177 | |
| 178 | form.onsubmit = (ev) => { |
| 179 | ev.preventDefault(); |
| 180 | const url = normalizeUrl(urlInput.value); |
| 181 | if (!url) return; |
| 182 | ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note: noteInput.value.trim() }); |
| 183 | urlInput.value = ""; |
| 184 | noteInput.value = ""; |
| 185 | }; |
| 186 | |
| 187 | form.append(urlInput, noteInput, add); |
| 188 | pane.appendChild(form); |
| 189 | |
| 190 | if (!items.length) { |
| 191 | const empty = document.createElement("div"); |
| 192 | empty.className = "bookmarks-empty"; |
| 193 | // Lead with the hero interaction (save from a chat), then the manual path. |
| 194 | empty.textContent = "Nothing saved yet. Long-press a message with a link and choose “Save link”, or add one above."; |
| 195 | pane.appendChild(empty); |
| 196 | } else { |
| 197 | const list = document.createElement("ul"); |
| 198 | list.className = "bookmarks-list"; |
| 199 | for (const item of items) { |
| 200 | const li = document.createElement("li"); |
| 201 | li.className = "bookmarks-item"; |
| 202 | |
| 203 | const main = document.createElement("div"); |
| 204 | main.className = "bookmarks-main"; |
| 205 | |
| 206 | const a = document.createElement("a"); |
| 207 | a.className = "bookmarks-link"; |
| 208 | a.setAttribute("href", item.url); // url is user content — attribute, never innerHTML |
| 209 | a.setAttribute("target", "_blank"); |
| 210 | a.setAttribute("rel", "noopener noreferrer"); |
| 211 | a.textContent = linkText(item.url); |
| 212 | main.appendChild(a); |
| 213 | |
| 214 | if (item.note) { |
| 215 | const note = document.createElement("div"); |
| 216 | note.className = "bookmarks-note"; |
| 217 | note.textContent = item.note; // textContent — never innerHTML for user content |
| 218 | main.appendChild(note); |
| 219 | } |
| 220 | |
| 221 | if (item.from) { |
| 222 | const from = document.createElement("div"); |
| 223 | from.className = "bookmarks-from"; |
| 224 | from.textContent = "Shared by " + item.from; // sender name — textContent, never innerHTML |
| 225 | main.appendChild(from); |
| 226 | } |
| 227 | |
| 228 | const rm = document.createElement("button"); |
| 229 | rm.type = "button"; |
| 230 | rm.className = "bookmarks-remove"; |
| 231 | rm.setAttribute("aria-label", "Remove bookmark"); |
| 232 | rm.textContent = "×"; |
| 233 | rm.onclick = () => ctx.emit({ type: N.ev("remove"), id: item.id }); |
| 234 | |
| 235 | li.append(main, rm); |
| 236 | list.appendChild(li); |
| 237 | } |
| 238 | pane.appendChild(list); |
| 239 | } |
| 240 | |
| 241 | el.appendChild(pane); |
| 242 | }, |
| 243 | }); |
| 244 | |