eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
12import { defineApp, namespace, addItem, removeItem } from "../apps.js";
13import { ICON_BOOKMARK } from "../icons.js";
14import { 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.
20const 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.
24function 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).
35function 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.
52export 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