eleven / messenger Public

home: star + independently-sort saved spaces (#334)

The home page's "Your Eleven spaces" list gains starring and sorting.
Star a space and it jumps into a Starred list; each list (Starred and
the rest) reorders independently with ↑/↓ — reordering one never disturbs
the other. This is the list the native apps read: starred spaces populate
the rail, the rest go behind a "more" affordance.

Blob schema (client-side, additive): servers entries gain an optional
`starred: true`; the array order is the display order. Partitioning
starred-vs-rest preserves each subgroup's relative order, so the two
lists sort independently within one array — no schema-level ordering
duplication, backward compatible with existing blobs.

The partition/star/move logic is a pure, framework-free module
(web/static/serverlist.js) with a Node test (home/test/serverlist.test.mjs,
wired into npm test) — home.js just renders it and persists on change with
inline "Saving…/Saved." feedback. Schema documented in
docs/onboarding-and-home-server.md as the contract for native clients.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a693a210a0e071c10f8e7598a423993f5f8e2863 parent 66242b0
6 files changed, +268 −23
  • docs/onboarding-and-home-server.md +12 −0
  • home/test/serverlist.test.mjs +89 −0
  • home/web/pages/index.html +25 −4
  • home/web/static/home.js +94 −18
  • home/web/static/serverlist.js +47 −0
  • package.json +1 −1
diff --git a/docs/onboarding-and-home-server.md b/docs/onboarding-and-home-server.md
index a7c624e..33f895c 100644
--- a/docs/onboarding-and-home-server.md
+++ b/docs/onboarding-and-home-server.md
@@ -112,6 +112,18 @@ Per user: WebAuthn credential public key(s), one **opaque padded blob**
device link tokens. No email, no username, no display name. The credential ID
*is* the identity.
+**Blob schema (the decrypted plaintext — the contract every client reads).**
+`{ v: 1, servers: [{ url, name?, starred? }] }`. The `servers` **array order is
+the display order**; each entry may carry `starred: true`. Rendering (web and
+native) partitions it into a **Starred** list and the rest, *each keeping its
+array-order* — so the two lists sort independently: reordering one never
+disturbs the other. Native apps map this straight through — starred spaces
+populate the rail, the rest live behind a "more" affordance. The pure helpers
+(`web/static/serverlist.js`, unit-tested) are the canonical partition/star/move
+logic; keep the native implementations behaviourally identical. Merges on a
+version race are last-writer-wins per `url` (the list is tiny), which can drop a
+concurrent reorder — acceptable until per-entry ordering earns a CRDT.
+
### Crypto
- Passkey at RP `home.elevenmessenger.com`, created with the **PRF extension**.
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs
new file mode 100644
index 0000000..497dfd8
--- /dev/null
+++ b/home/test/serverlist.test.mjs
@@ -0,0 +1,89 @@
+// Tests for the saved-spaces list helpers (star + independent sort), run in Node.
+// Same harness style as home/test/crypto.test.mjs.
+// node home/test/serverlist.test.mjs
+import assert from "node:assert/strict";
+import {
+ partitionServers,
+ toggleStar,
+ moveInGroup,
+} from "../web/static/serverlist.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const urls = (list) => list.map((s) => s.url);
+
+await test("partitionServers splits starred from the rest, keeping array order", () => {
+ const servers = [
+ { url: "a", starred: true },
+ { url: "b" },
+ { url: "c", starred: true },
+ { url: "d" },
+ ];
+ const { starred, others } = partitionServers(servers);
+ assert.deepEqual(urls(starred), ["a", "c"]);
+ assert.deepEqual(urls(others), ["b", "d"]);
+});
+
+await test("partitionServers tolerates null/empty", () => {
+ assert.deepEqual(partitionServers(null), { starred: [], others: [] });
+ assert.deepEqual(partitionServers([]), { starred: [], others: [] });
+});
+
+await test("toggleStar adds then removes the flag, and doesn't mutate the input", () => {
+ const servers = [{ url: "a" }, { url: "b" }];
+ const on = toggleStar(servers, "a");
+ assert.equal(on[0].starred, true);
+ assert.equal(servers[0].starred, undefined, "input array/entry must be untouched");
+
+ const off = toggleStar(on, "a");
+ assert.equal("starred" in off[0], false, "unstarred entry carries no flag");
+});
+
+await test("toggleStar leaves unknown urls unchanged", () => {
+ const servers = [{ url: "a", name: "A" }];
+ assert.deepEqual(toggleStar(servers, "zzz"), servers);
+});
+
+await test("moveInGroup reorders within a group and never disturbs the other", () => {
+ // starred: [a, c] others: [b, d] (interleaved in the master array)
+ const servers = [
+ { url: "a", starred: true },
+ { url: "b" },
+ { url: "c", starred: true },
+ { url: "d" },
+ ];
+ // Move starred 'c' up: it swaps with 'a' (its nearest starred neighbour),
+ // jumping over the non-starred 'b'. Others keep their order.
+ const moved = moveInGroup(servers, "c", -1);
+ assert.deepEqual(urls(partitionServers(moved).starred), ["c", "a"]);
+ assert.deepEqual(urls(partitionServers(moved).others), ["b", "d"], "other list unchanged");
+});
+
+await test("moveInGroup down within the 'others' group skips over starred entries", () => {
+ const servers = [
+ { url: "b" },
+ { url: "a", starred: true },
+ { url: "d" },
+ ];
+ const moved = moveInGroup(servers, "b", +1); // b swaps with d, over starred a
+ assert.deepEqual(urls(partitionServers(moved).others), ["d", "b"]);
+ assert.deepEqual(urls(partitionServers(moved).starred), ["a"]);
+});
+
+await test("moveInGroup at a group boundary is a no-op", () => {
+ const servers = [{ url: "a", starred: true }, { url: "b" }];
+ assert.deepEqual(moveInGroup(servers, "a", -1), servers, "top of its group can't move up");
+ assert.deepEqual(moveInGroup(servers, "b", +1), servers, "bottom of its group can't move down");
+});
+
+console.log(failures ? `\n${failures} FAILED` : "\nALL PASSED");
+process.exit(failures ? 1 : 0);
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 3a3c9d0..eedf6d9 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -41,16 +41,37 @@
.vault-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
.vault-head #signout { font-size: .85rem; font-weight: 600; }
#vault h2 { font-size: 15px; margin: 0 0 4px; font-family: var(--font-display); }
+ /* Section headings: "Starred" and "More" — only shown once some are starred. */
+ .spaces-head {
+ font-size: .7rem; text-transform: uppercase; letter-spacing: .05em;
+ color: var(--muted); font-family: var(--font-display); margin: 16px 0 2px;
+ }
+ .spaces-head:first-child { margin-top: 0; }
+ #spaces > p.empty { color: var(--muted); font-size: .9rem; margin: 6px 0 0; }
ul.servers { list-style: none; margin: 0; padding: 0; }
ul.servers li {
- display: flex; flex-direction: column; gap: 2px;
- padding: .55rem 0; border-top: 1px solid var(--border);
+ display: flex; flex-direction: row; align-items: center; gap: 10px;
+ padding: .5rem 0; border-top: 1px solid var(--border);
}
ul.servers li:first-child { border-top: none; }
+ .srv-main { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
ul.servers li a.name { font-weight: 600; color: var(--ink); text-decoration: none; }
ul.servers li a.name:hover { text-decoration: underline; }
ul.servers li .url { color: var(--muted); font-size: .8rem; overflow-wrap: anywhere; }
- ul.servers li.empty { color: var(--muted); font-size: .9rem; }
+ /* Star toggle: a quiet outline star that fills gold when on. */
+ .star {
+ background: none; border: none; padding: 2px; cursor: pointer;
+ font-size: 18px; line-height: 1; color: var(--muted);
+ }
+ .star.on { color: #f2b705; }
+ .star:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
+ /* Up/down reorder within a list. */
+ .moves { display: flex; gap: 3px; }
+ .move {
+ background: var(--quiet); border: none; border-radius: 7px;
+ width: 26px; height: 26px; font-size: 13px; line-height: 1; cursor: pointer; color: var(--ink);
+ }
+ .move:disabled { opacity: .35; cursor: default; }
#space-section { margin-top: 16px; }
#space-section p.lead { color: var(--muted); font-size: .85rem; margin: 0 0 8px; }
#new-space { width: 100%; }
@@ -81,7 +102,7 @@
<h2>Your Eleven spaces</h2>
<button class="linkish" id="signout">Sign out</button>
</div>
- <ul class="servers" id="server-list"></ul>
+ <div id="spaces"></div>
<!-- Create a new space — only where this home server offers provisioning
(revealed by /api/features) and only once you're signed in. -->
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 88c7781..47fcc3c 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -16,6 +16,9 @@ import {
import {
apiJSON, beginLogin, runLogin, fetchBlob, putBlob,
} from "/static/backup-core.js";
+import {
+ partitionServers, toggleStar, moveInGroup,
+} from "/static/serverlist.js";
// ---- session state ----------------------------------------------------------
// Session-only on purpose (the design doc's web default): closing the tab
@@ -138,7 +141,7 @@ function signOut() {
$("vault").classList.add("hidden");
$("space-section").classList.add("hidden");
$("new-space-result").classList.add("hidden");
- $("server-list").textContent = "";
+ $("spaces").textContent = "";
$("signed-out").classList.remove("hidden");
$("reassure").classList.remove("hidden");
$("signin").disabled = false;
@@ -153,26 +156,99 @@ function showVault() {
$("reassure").classList.add("hidden");
$("vault").classList.remove("hidden");
if (spacesEnabled) $("space-section").classList.remove("hidden");
- const list = $("server-list");
- list.textContent = "";
+
+ const box = $("spaces");
+ box.textContent = "";
const servers = vault.obj.servers || [];
if (!servers.length) {
- const li = document.createElement("li");
- li.className = "empty";
- li.textContent = "No spaces saved yet — save one from its chat page and it'll appear here.";
- list.append(li);
+ const p = document.createElement("p");
+ p.className = "empty";
+ p.textContent = "No spaces saved yet — save one from its chat page and it'll appear here.";
+ box.append(p);
+ return;
+ }
+ // Two independently-sorted lists: Starred (→ the app's rail) and the rest
+ // (→ a "more" affordance). Headings appear only once something's starred.
+ const { starred, others } = partitionServers(servers);
+ if (starred.length) {
+ box.append(sectionHeading("Starred"), serverList(starred));
+ if (others.length) box.append(sectionHeading("More"));
}
- for (const s of servers) {
- const li = document.createElement("li");
- const name = document.createElement("a");
- name.className = "name";
- name.href = s.url;
- name.textContent = s.name || new URL(s.url).host;
- const url = document.createElement("span");
- url.className = "url";
- url.textContent = s.url;
- li.append(name, url);
- list.append(li);
+ if (others.length) box.append(serverList(others));
+}
+
+function sectionHeading(text) {
+ const h = document.createElement("div");
+ h.className = "spaces-head";
+ h.textContent = text;
+ return h;
+}
+
+function serverList(group) {
+ const ul = document.createElement("ul");
+ ul.className = "servers";
+ group.forEach((s, i) => ul.append(serverRow(s, i, group.length)));
+ return ul;
+}
+
+function serverRow(s, idxInGroup, groupLen) {
+ const li = document.createElement("li");
+
+ const star = document.createElement("button");
+ star.type = "button";
+ star.className = "star" + (s.starred ? " on" : "");
+ star.textContent = s.starred ? "★" : "☆";
+ star.setAttribute("aria-label", s.starred ? "Unstar " + (s.name || s.url) : "Star " + (s.name || s.url));
+ star.addEventListener("click", () => { applyServers(toggleStar(vault.obj.servers, s.url)); });
+
+ const main = document.createElement("div");
+ main.className = "srv-main";
+ const name = document.createElement("a");
+ name.className = "name";
+ name.href = s.url;
+ name.textContent = s.name || new URL(s.url).host;
+ const url = document.createElement("span");
+ url.className = "url";
+ url.textContent = s.url;
+ main.append(name, url);
+
+ const moves = document.createElement("div");
+ moves.className = "moves";
+ const up = moveButton("↑", "Move up", idxInGroup === 0, () => applyServers(moveInGroup(vault.obj.servers, s.url, -1)));
+ const down = moveButton("↓", "Move down", idxInGroup === groupLen - 1, () => applyServers(moveInGroup(vault.obj.servers, s.url, +1)));
+ moves.append(up, down);
+
+ li.append(star, main, moves);
+ return li;
+}
+
+function moveButton(glyph, label, disabled, onClick) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "move";
+ b.textContent = glyph;
+ b.setAttribute("aria-label", label);
+ b.disabled = disabled;
+ if (!disabled) b.addEventListener("click", onClick);
+ return b;
+}
+
+// applyServers swaps in a new servers array (star/reorder returns a fresh one),
+// re-renders immediately (optimistic), then persists to the backup in the
+// background with inline feedback.
+function applyServers(next) {
+ vault.obj.servers = next;
+ showVault();
+ persistVault();
+}
+
+async function persistVault() {
+ vaultStatus("Saving…", "busy");
+ try {
+ await saveVault();
+ vaultStatus("Saved.", "ok");
+ } catch (e) {
+ vaultStatus(e.message || "Couldn't save your changes.", "error");
}
}
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js
new file mode 100644
index 0000000..b13fe0d
--- /dev/null
+++ b/home/web/static/serverlist.js
@@ -0,0 +1,47 @@
+// Pure helpers for the saved-spaces list — framework-free and side-effect-free,
+// so they're unit-testable in Node (home/test/serverlist.test.mjs) without a DOM.
+//
+// The backup blob's `servers` array is the master order; each entry is
+// { url, name?, starred? }. Rendering partitions it into a **Starred** list and
+// the rest, each keeping its own relative array order — so the two lists sort
+// independently (reordering one never disturbs the other). This is the contract
+// the native apps read too: starred spaces populate the rail, the rest live
+// behind a "more" affordance.
+
+// partitionServers splits into { starred, others }, each in array order.
+export function partitionServers(servers) {
+ const starred = [];
+ const others = [];
+ for (const s of servers || []) (s.starred ? starred : others).push(s);
+ return { starred, others };
+}
+
+// toggleStar returns a NEW servers array with the entry's starred flag flipped
+// (added as `starred: true`, or removed entirely so an unstarred entry carries no
+// flag). Unknown urls pass through unchanged.
+export function toggleStar(servers, url) {
+ return (servers || []).map((s) => {
+ if (s.url !== url) return s;
+ if (s.starred) {
+ const { starred, ...rest } = s;
+ return rest;
+ }
+ return { ...s, starred: true };
+ });
+}
+
+// moveInGroup returns a NEW servers array with the entry at `url` swapped with its
+// nearest **same-group** (same starred-ness) neighbour in direction `dir`
+// (-1 = up, +1 = down), skipping over entries from the other group. Returns the
+// array unchanged if there's no such neighbour (already at an end of its list).
+export function moveInGroup(servers, url, dir) {
+ const arr = (servers || []).slice();
+ const i = arr.findIndex((s) => s.url === url);
+ if (i < 0) return arr;
+ const group = !!arr[i].starred;
+ let j = i + dir;
+ while (j >= 0 && j < arr.length && !!arr[j].starred !== group) j += dir;
+ if (j < 0 || j >= arr.length) return arr;
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ return arr;
+}
diff --git a/package.json b/package.json
index 65e7571..cf4badf 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs"
}
}