eleven / messenger Public

home: archive spaces — hide from the list, unarchive, or delete forever (#444)

Adds an archive layer to the "Sign in with Eleven" saved-spaces list:

- Each row's trailing action is now an Archive glyph (was the manage ×).
  One tap sets a space aside (reversible) with an inline "Archived · Undo"
  banner — no floating toast — that fully restores the entry (star + order).
- Archived spaces drop out of the main list; an "Archived (N)" foot button
  opens a dedicated archive screen. Star/search/drag operate on the active
  subset, so archived entries ride along untouched at the tail.
- In the archive: Unarchive (back to the top of your list), or a stern,
  typed-to-arm "Delete your access?" that permanently drops the saved entry —
  strongly worded that this removes your only saved way back, and if you're
  not signed in on another device you'll never get back in.
- The owner-only "destroy the space for everyone" (ledger-checked, fresh
  passkey ceremony) moves into that delete step, one level deeper than before.

Data model: entries gain an optional `archived` flag with two pure,
unit-tested helpers (partitionArchived, setArchived) in serverlist.js. Fully
client-side — the vault blob stays opaque to the server.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell e5ae7d6f90c47d8f1d5eb9ed2f4ea0bd15140079 parent 80020f1
4 files changed, +474 −164
  • home/test/serverlist.test.mjs +62 −0
  • home/web/pages/index.html +75 −32
  • home/web/static/home.js +296 −126
  • home/web/static/serverlist.js +41 −6
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs
index e7d6237..27a47b3 100644
--- a/home/test/serverlist.test.mjs
+++ b/home/test/serverlist.test.mjs
@@ -4,6 +4,8 @@
import assert from "node:assert/strict";
import {
partitionServers,
+ partitionArchived,
+ setArchived,
toggleStar,
moveInGroup,
orderServers,
@@ -134,6 +136,66 @@ await test("orderServers ignores unknown urls and keeps unlisted entries at the
assert.deepEqual(urls(next), ["c", "a", "b"]);
});
+await test("partitionArchived splits archived from active, keeping array order", () => {
+ const servers = [
+ { url: "a", starred: true },
+ { url: "b", archived: true },
+ { url: "c" },
+ { url: "d", archived: true },
+ ];
+ const { active, archived } = partitionArchived(servers);
+ assert.deepEqual(urls(active), ["a", "c"]);
+ assert.deepEqual(urls(archived), ["b", "d"]);
+});
+
+await test("partitionArchived tolerates null/empty", () => {
+ assert.deepEqual(partitionArchived(null), { active: [], archived: [] });
+ assert.deepEqual(partitionArchived([]), { active: [], archived: [] });
+});
+
+await test("setArchived archives: sets the flag, drops starred, doesn't mutate input", () => {
+ const servers = [{ url: "a", starred: true }, { url: "b" }];
+ const next = setArchived(servers, "a", true);
+ const entry = next.find((s) => s.url === "a");
+ assert.equal(entry.archived, true);
+ assert.equal("starred" in entry, false, "an archived space leaves the rail");
+ assert.equal(servers[0].starred, true, "input entry untouched");
+ assert.equal("archived" in servers[0], false, "input entry untouched");
+});
+
+await test("archiving sends the item to the TOP of the archived group, active kept in order", () => {
+ // x active, y already archived; archive 'x' → it lands above y (top of archive).
+ const servers = [
+ { url: "s", starred: true },
+ { url: "x" },
+ { url: "z" },
+ { url: "y", archived: true },
+ ];
+ const { active, archived } = partitionArchived(setArchived(servers, "x", true));
+ assert.deepEqual(urls(active), ["s", "z"], "active keeps order minus x");
+ assert.deepEqual(urls(archived), ["x", "y"], "x is the top of the archive");
+});
+
+await test("unarchiving sends the item to the TOP of the active others, below Starred", () => {
+ const servers = [
+ { url: "s", starred: true },
+ { url: "a" },
+ { url: "z", archived: true },
+ { url: "y", archived: true },
+ ];
+ const next = setArchived(servers, "z", false);
+ const entry = next.find((s) => s.url === "z");
+ assert.equal("archived" in entry, false, "unarchived entry carries no flag");
+ const { active, archived } = partitionArchived(next);
+ assert.deepEqual(urls(active), ["s", "z", "a"], "z is top of others, under Starred s");
+ assert.deepEqual(urls(archived), ["y"], "y stays archived");
+});
+
+await test("setArchived leaves unknown urls unchanged", () => {
+ const servers = [{ url: "a", name: "A" }];
+ assert.deepEqual(setArchived(servers, "zzz", true), servers);
+});
+
await test("fuzzyMatch is a case-insensitive subsequence match", () => {
assert.equal(fuzzyMatch("fam", "family space"), true);
assert.equal(fuzzyMatch("fly", "family"), true, "f-l-y appear in order");
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 88957ff..660653c 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -175,10 +175,11 @@
.led, .num.eleven { transition: none; }
.build-spin { animation: none; opacity: .5; }
}
- /* Manage-a-space screen. Remove is quiet; delete is deliberately loud. */
- #remove { text-align: center; }
- #danger-zone { margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--border); }
+ /* Archive screen. The list is quiet; the permanent delete is deliberately loud. */
+ #archive { text-align: center; }
+ #destroy-zone { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--border); }
.danger-link { color: var(--danger); font-size: .9rem; }
+ .danger-link:disabled { opacity: .4; cursor: default; text-decoration: none; }
.danger-ink { color: var(--danger); }
.stern-copy b { color: var(--ink); overflow-wrap: anywhere; }
#delete-arm {
@@ -194,20 +195,56 @@
}
.btn-danger:disabled { opacity: .4; cursor: default; }
.btn-danger:focus-visible { outline: 2px solid var(--danger); outline-offset: 2px; }
- #delete-stern .wide-quiet { margin-top: 10px; }
- /* The per-row remove: an × that reveals on hover, like the drag grip. */
- .row-remove {
+ #archive-stern .wide-quiet { margin-top: 10px; }
+ /* The per-row archive: a box glyph that reveals on hover, like the drag grip.
+ Reversible, so it stays neutral (not danger-red). */
+ .row-archive {
flex: none; display: grid; place-items: center; width: 22px; height: 30px;
padding: 0; border: none; background: none; color: var(--muted);
border-radius: 7px; cursor: pointer;
opacity: 0; transition: opacity .12s ease, background .12s ease, color .12s ease;
}
- .row-remove svg { display: block; }
- .row-remove:hover { background: var(--quiet); color: var(--danger); }
- .row-remove:focus-visible { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }
- @media (hover: hover) { ul.servers li:hover .row-remove { opacity: .5; } }
- @media (hover: none) { .row-remove { opacity: .4; } }
- @media (prefers-reduced-motion: reduce) { .row-remove { transition: none; } }
+ .row-archive svg { display: block; }
+ .row-archive:hover { background: var(--quiet); color: var(--ink); }
+ .row-archive:focus-visible { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }
+ @media (hover: hover) { ul.servers li:hover .row-archive { opacity: .5; } }
+ @media (hover: none) { .row-archive { opacity: .4; } }
+ @media (prefers-reduced-motion: reduce) { .row-archive { transition: none; } }
+
+ /* The inline "Archived <name> · Undo" note — occupies layout, never a toast. */
+ .undo-note {
+ display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
+ margin: 0 0 10px; padding: 8px 12px; border-radius: 10px;
+ background: var(--quiet); color: var(--muted); font-size: .85rem;
+ }
+ /* The "Archived (N)" foot button: a quiet full-width row that opens the archive. */
+ .archived-open {
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ width: 100%; margin-top: 12px; padding: 9px 12px; border-radius: 10px;
+ font: inherit; font-size: .9rem; font-weight: 600; cursor: pointer; text-align: left;
+ color: var(--muted); background: none; border: 1px solid var(--border);
+ transition: background .12s ease, color .12s ease;
+ }
+ .archived-open > span:first-child { display: inline-flex; align-items: center; gap: 8px; }
+ .archived-open svg { display: block; opacity: .85; }
+ .archived-open .chev { font-size: 1.1rem; line-height: 1; color: var(--muted); }
+ .archived-open:hover { background: var(--quiet); color: var(--ink); }
+ .archived-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+ @media (prefers-reduced-motion: reduce) { .archived-open { transition: none; } }
+
+ /* Archive list rows: name/url + Unarchive + Delete pills. Quiet, non-draggable. */
+ ul.servers.archived li { gap: 6px; }
+ ul.servers.archived li .name { font-weight: 600; line-height: 1.25; color: var(--ink); }
+ ul.servers.archived li .srv-main { text-align: left; }
+ .pill {
+ flex: none; font: inherit; font-size: .8rem; font-weight: 600; cursor: pointer;
+ padding: 5px 10px; border-radius: 999px;
+ color: var(--ink); background: var(--quiet); border: 1px solid transparent;
+ }
+ .pill:hover { filter: brightness(0.97); }
+ .pill.danger-ink { color: var(--danger); }
+ .pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+ #archive-list > p.empty { color: var(--muted); font-size: .9rem; margin: 6px 0 0; }
/* Section headings: "Starred" and "More" — only shown once some are starred. */
.spaces-head {
font-size: .7rem; text-transform: uppercase; letter-spacing: .05em;
@@ -349,30 +386,36 @@
</div>
</div>
- <!-- Manage one saved space: remove it from the list (light — the space keeps
- running), or — only for spaces this account created — delete it forever
- behind the stern typed-confirmation + fresh-passkey step. -->
- <div id="remove" class="hidden screen">
- <button id="remove-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
- <div id="remove-main">
- <h2 class="create-title" id="remove-title"></h2>
- <p class="create-sub" id="remove-host"></p>
- <button id="remove-from-list" type="button" class="wide-quiet">Remove from list</button>
- <div id="danger-zone" class="hidden">
- <button id="open-delete" type="button" class="linkish danger-link">Delete this space permanently…</button>
- </div>
+ <!-- The archive: spaces you've set aside, hidden from the main list. Bring one
+ back (unarchive), or delete it forever behind a stern typed-confirmation —
+ "delete your access" always, and for spaces this account created, the
+ deeper "destroy for everyone" (fresh-passkey ceremony). -->
+ <div id="archive" class="hidden screen">
+ <button id="archive-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <div id="archive-main">
+ <h2 class="create-title">Archived spaces</h2>
+ <p class="create-sub">Hidden from your list. Bring one back anytime, or remove it for good.</p>
+ <div id="archive-list"></div>
</div>
- <div id="delete-stern" class="hidden">
- <h2 class="create-title danger-ink">Delete this space?</h2>
- <p class="create-sub stern-copy">This permanently destroys <b id="stern-host"></b> —
- every message, photo, and member in it. For everyone, not just you.
- <b>It cannot be undone.</b></p>
+ <div id="archive-stern" class="hidden">
+ <h2 class="create-title danger-ink">Delete your access?</h2>
+ <p class="create-sub stern-copy">Deleting <b id="stern-name"></b> removes your only
+ saved way back into <b id="stern-host"></b>. If you're not signed in to it on
+ another device, you'll lose access <b>forever</b> — there will be no way back
+ in. <b>This cannot be undone.</b></p>
<input id="delete-arm" type="text" autocomplete="off" autocapitalize="off"
spellcheck="false" aria-label="Type the space address to confirm">
- <button id="delete-forever" type="button" class="btn-danger" disabled>Delete forever</button>
- <button id="delete-cancel" type="button" class="wide-quiet">Cancel</button>
+ <button id="delete-access" type="button" class="btn-danger" disabled>Delete forever</button>
+ <!-- Owner-only, revealed once ownership is confirmed: also destroy the space
+ itself, for everyone. Gated by the same typed confirmation + a passkey. -->
+ <div id="destroy-zone" class="hidden">
+ <p class="create-sub stern-copy">You created this space, so you can also
+ <b>destroy it for everyone</b> — every message, photo, and member in it.</p>
+ <button id="destroy-forever" type="button" class="linkish danger-link" disabled>Destroy the space for everyone…</button>
+ </div>
+ <button id="archive-cancel" type="button" class="wide-quiet">Cancel</button>
</div>
- <p class="status" id="remove-status"></p>
+ <p class="status" id="archive-status"></p>
</div>
</main>
diff --git a/home/web/static/home.js b/home/web/static/home.js
index dd10f11..4a4ea1e 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -20,7 +20,8 @@ import {
apiJSON, beginLogin, beginRegister, runLogin, runRegister, fetchBlob, putBlob,
} from "/static/backup-core.js";
import {
- partitionServers, toggleStar, moveInGroup, orderServers, filterServers,
+ partitionServers, partitionArchived, setArchived,
+ toggleStar, moveInGroup, orderServers, filterServers,
} from "/static/serverlist.js";
import { mountFader } from "/static/fader.js";
@@ -58,6 +59,15 @@ function spaceStatus(msg, kind) {
el.textContent = msg || "";
el.className = "status" + (kind ? " " + kind : "");
}
+function archiveStatus(msg, kind) {
+ const el = $("archive-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+
+// hostOf is the display host for a saved url, tolerant of a non-URL value.
+function hostOf(url) { try { return new URL(url).host; } catch { return url || ""; } }
+function labelOf(s) { return s.name || hostOf(s.url); }
// Whether this home server offers self-serve space creation (set from
// /api/features). The create-a-space controls only appear when it's both
@@ -188,8 +198,9 @@ function signOut() {
dekKey = null;
$("vault").classList.add("hidden");
$("create").classList.add("hidden");
- $("remove").classList.add("hidden");
- removing = null;
+ $("archive").classList.add("hidden");
+ archiving = null;
+ clearUndo();
$("spaces").textContent = "";
$("signed-out").classList.remove("hidden");
$("newhere").classList.add("hidden"); // fresh signed-out state, no stale panel
@@ -213,39 +224,119 @@ function showVault() {
$("signed-out").classList.add("hidden");
$("reassure").classList.add("hidden");
$("create").classList.add("hidden");
+ $("archive").classList.add("hidden");
$("vault").classList.remove("hidden");
$("open-create").classList.toggle("hidden", !canCreateSpace()); // the + to create
const box = $("spaces");
box.textContent = "";
- const all = vault.obj.servers || [];
- if (!all.length) {
- $("space-search").classList.add("hidden");
- box.append(emptyLine("No spaces saved yet — save one from its chat page and it'll appear here."));
- return;
- }
- // Fuzzy search filters both lists. It only appears once there's enough to sift
- // through; while a query is active, reordering is off (dragging a filtered
- // subset would be ambiguous) so the grips are hidden.
+ // Archived spaces are pulled out first — hidden from the main list, reachable
+ // through the "Archived (N)" affordance at the foot.
+ const { active, archived } = partitionArchived(vault.obj.servers || []);
+
+ // A just-archived space leaves an inline "Archived · Undo" banner (never a
+ // floating toast); it survives re-renders and auto-clears on a timer.
+ if (pendingUndo) box.append(undoBanner());
+
+ // Fuzzy search filters the active lists. It only appears once there's enough
+ // to sift through; while a query is active, reordering is off (dragging a
+ // filtered subset would be ambiguous) so the grips are hidden.
const search = $("space-search");
- search.classList.toggle("hidden", all.length < 4);
+ search.classList.toggle("hidden", active.length < 4);
const query = search.classList.contains("hidden") ? "" : search.value.trim();
- const servers = query ? filterServers(all, query) : all;
- if (!servers.length) {
- box.append(emptyLine("No spaces match “" + query + "”."));
- return;
- }
- const sortable = !query;
-
- // 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, sortable));
- if (others.length) box.append(sectionHeading("More"));
+
+ if (!active.length) {
+ box.append(emptyLine(archived.length
+ ? "All your spaces are archived — open the archive to bring one back."
+ : "No spaces saved yet — save one from its chat page and it'll appear here."));
+ } else {
+ const servers = query ? filterServers(active, query) : active;
+ if (!servers.length) {
+ box.append(emptyLine("No spaces match “" + query + "”."));
+ } else {
+ const sortable = !query;
+ // 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, sortable));
+ if (others.length) box.append(sectionHeading("More"));
+ }
+ if (others.length) box.append(serverList(others, sortable));
+ }
}
- if (others.length) box.append(serverList(others, sortable));
+
+ if (archived.length) box.append(archivedButton(archived.length));
+}
+
+// ---- archive: undo banner + entry point --------------------------------------
+// pendingUndo holds the pre-archive servers snapshot so a single tap fully
+// restores the entry (its star + exact position), not just the archived flag.
+let pendingUndo = null; // { name, prev } | null
+let undoTimer = 0;
+
+function clearUndo() {
+ if (undoTimer) { clearTimeout(undoTimer); undoTimer = 0; }
+ pendingUndo = null;
+}
+
+function undoBanner() {
+ const p = document.createElement("p");
+ p.className = "undo-note";
+ const span = document.createElement("span");
+ span.textContent = "Archived " + pendingUndo.name + ". ";
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "linkish";
+ btn.textContent = "Undo";
+ btn.addEventListener("click", undoArchive);
+ p.append(span, btn);
+ return p;
+}
+
+// A muted archive-box glyph (static, in-code SVG — safe for innerHTML), matching
+// the quiet grip: a lidded box with a handle slot.
+const ARCHIVE_SVG =
+ '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" ' +
+ 'stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
+ '<rect x="2" y="2.5" width="12" height="3" rx="0.8"/>' +
+ '<path d="M3 5.5v7a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-7"/>' +
+ '<path d="M6.3 8.3h3.4"/></svg>';
+
+// The foot-of-list button that opens the archive; shown only when something's in
+// it. The count keeps the archive discoverable without cluttering the main list.
+function archivedButton(n) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "archived-open";
+ btn.addEventListener("click", openArchive);
+ const label = document.createElement("span");
+ label.innerHTML = ARCHIVE_SVG;
+ const text = document.createElement("span");
+ text.textContent = "Archived (" + n + ")";
+ label.append(text);
+ const chev = document.createElement("span");
+ chev.className = "chev";
+ chev.setAttribute("aria-hidden", "true");
+ chev.textContent = "›";
+ btn.append(label, chev);
+ return btn;
+}
+
+function archiveSpace(s) {
+ clearUndo();
+ const prev = vault.obj.servers;
+ pendingUndo = { name: labelOf(s), prev }; // set before applyServers so the banner renders
+ undoTimer = setTimeout(() => { pendingUndo = null; if (vault) showVault(); }, 8000);
+ applyServers(setArchived(prev, s.url, true)); // re-renders (with the banner) + persists
+}
+
+function undoArchive() {
+ if (!pendingUndo) return;
+ const prev = pendingUndo.prev;
+ clearUndo();
+ applyServers(prev); // restore the exact prior array (star + position), re-render, persist
}
function emptyLine(text) {
@@ -271,10 +362,6 @@ function serverList(group, sortable) {
// A quiet 6-dot grip (static, in-code SVG — safe for innerHTML). iOS-style, it
// lives on the right so the star stays the left anchor; it surfaces on hover.
-const X_SVG =
- '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' +
- '<path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
-
const GRIP_SVG =
'<svg viewBox="0 0 10 16" width="10" height="16" aria-hidden="true">' +
'<circle cx="2" cy="3" r="1.25"/><circle cx="8" cy="3" r="1.25"/>' +
@@ -289,15 +376,20 @@ function serverRow(s, sortable) {
star.type = "button";
star.className = "star" + (s.starred ? " on" : "");
star.textContent = s.starred ? "★" : "☆";
- star.setAttribute("aria-label", (s.starred ? "Unstar " : "Star ") + (s.name || s.url));
- star.addEventListener("click", () => { applyServers(toggleStar(vault.obj.servers, s.url)); });
+ star.setAttribute("aria-label", (s.starred ? "Unstar " : "Star ") + labelOf(s));
+ // Star/reorder act on the ACTIVE list only; archived entries ride along at the
+ // tail untouched (they never render a star or grip).
+ star.addEventListener("click", () => {
+ const { active, archived } = partitionArchived(vault.obj.servers);
+ applyServers([...toggleStar(active, s.url), ...archived]);
+ });
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;
+ name.textContent = labelOf(s);
const url = document.createElement("span");
url.className = "url";
url.textContent = s.url;
@@ -305,15 +397,16 @@ function serverRow(s, sortable) {
li.append(star, main);
- // Manage/remove: an × that reveals on hover (like the grip) and opens the
- // manage screen — where "remove from list" and the stern delete live.
- const rm = document.createElement("button");
- rm.type = "button";
- rm.className = "row-remove";
- rm.setAttribute("aria-label", "Remove " + (s.name || s.url) + "…");
- rm.innerHTML = X_SVG;
- rm.addEventListener("click", () => openRemove(s));
- li.append(rm);
+ // Archive: a quiet box glyph that reveals on hover (like the grip). One tap
+ // sets it aside (reversible via the inline Undo); no confirmation — the archive
+ // is where the permanent, strongly-worded delete lives.
+ const arch = document.createElement("button");
+ arch.type = "button";
+ arch.className = "row-archive";
+ arch.setAttribute("aria-label", "Archive " + labelOf(s));
+ arch.innerHTML = ARCHIVE_SVG;
+ arch.addEventListener("click", () => archiveSpace(s));
+ li.append(arch);
// The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) —
// the a11y reorder fallback. Omitted while a search filter is active (you can't
@@ -322,14 +415,15 @@ function serverRow(s, sortable) {
const handle = document.createElement("button");
handle.type = "button";
handle.className = "drag-handle";
- handle.setAttribute("aria-label", "Reorder " + (s.name || s.url));
+ handle.setAttribute("aria-label", "Reorder " + labelOf(s));
handle.innerHTML = GRIP_SVG;
handle.addEventListener("pointerdown", (e) => startDrag(e, li));
handle.addEventListener("keydown", (e) => {
const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0;
if (!dir) return;
e.preventDefault();
- applyServers(moveInGroup(vault.obj.servers, s.url, dir));
+ const { active, archived } = partitionArchived(vault.obj.servers);
+ applyServers([...moveInGroup(active, s.url, dir), ...archived]);
focusHandle(s.url); // re-render dropped focus; put it back on the moved row
});
li.append(handle);
@@ -428,10 +522,10 @@ function commitOrder() {
for (const ul of $("spaces").querySelectorAll("ul.servers")) {
for (const li of ul.querySelectorAll("li")) order.push(li.dataset.url);
}
- const cur = vault.obj.servers || [];
- const next = orderServers(cur, order);
- if (next.length === cur.length && next.every((s, i) => s.url === cur[i].url)) return;
- vault.obj.servers = next;
+ const { active, archived } = partitionArchived(vault.obj.servers || []);
+ const next = orderServers(active, order);
+ if (next.length === active.length && next.every((s, i) => s.url === active[i].url)) return;
+ vault.obj.servers = [...next, ...archived];
persistVault();
}
@@ -447,13 +541,13 @@ function applyServers(next) {
persistVault();
}
-async function persistVault() {
- vaultStatus("Saving…", "busy");
+async function persistVault(status = vaultStatus) {
+ status("Saving…", "busy");
try {
await saveVault();
- vaultStatus("Saved.", "ok");
+ status("Saved.", "ok");
} catch (e) {
- vaultStatus(e.message || "Couldn't save your changes.", "error");
+ status(e.message || "Couldn't save your changes.", "error");
}
}
@@ -573,6 +667,7 @@ function ensureFader() {
}
function openCreate() {
+ clearUndo();
$("new-space-result").classList.add("hidden");
$("create-back").classList.remove("hidden");
spaceStatus("");
@@ -589,94 +684,173 @@ function closeCreate() {
showVault(); // reveals the list again, now including any newly created space
}
-// ---- manage / remove / delete a space -------------------------------------------
+// ---- archive: list, unarchive, permanent delete --------------------------------
-// removing is the entry being managed on the #remove screen.
-let removing = null;
+// archiving is the entry currently in the stern delete step of the archive.
+let archiving = null;
-function removeStatus(msg, kind) {
- const el = $("remove-status");
- el.textContent = msg || "";
- el.className = "status" + (kind ? " " + kind : "");
+// openArchive shows the archive screen (the archived-spaces list).
+function openArchive() {
+ clearUndo();
+ archiving = null;
+ $("vault").classList.add("hidden");
+ $("archive-stern").classList.add("hidden");
+ $("archive-main").classList.remove("hidden");
+ $("archive").classList.remove("hidden");
+ archiveStatus("");
+ renderArchiveList();
}
-// openRemove shows the manage screen for one saved space: "remove from list"
-// always; the stern delete only if this account created the space (checked
-// against the provisioner's ledger via GET /api/spaces — the server enforces
-// the same ownership on the actual delete, this just decides what to offer).
-function openRemove(s) {
- removing = s;
- const host = (() => { try { return new URL(s.url).host; } catch { return s.url; } })();
- $("remove-title").textContent = s.name || host;
- $("remove-host").textContent = s.url;
- $("remove-main").classList.remove("hidden");
- $("delete-stern").classList.add("hidden");
- $("danger-zone").classList.add("hidden");
- removeStatus("");
- $("vault").classList.add("hidden");
- $("remove").classList.remove("hidden");
+function closeArchive() {
+ archiving = null;
+ $("archive").classList.add("hidden");
+ showVault();
+}
+
+// closeArchiveStern steps back from the delete step to the archive list.
+function closeArchiveStern() {
+ archiving = null;
+ $("archive-stern").classList.add("hidden");
+ $("archive-main").classList.remove("hidden");
+ archiveStatus("");
+}
+
+function renderArchiveList() {
+ const box = $("archive-list");
+ box.textContent = "";
+ const { archived } = partitionArchived(vault.obj.servers || []);
+ if (!archived.length) {
+ box.append(emptyLine("Nothing archived."));
+ return;
+ }
+ const ul = document.createElement("ul");
+ ul.className = "servers archived";
+ for (const s of archived) ul.append(archiveRow(s));
+ box.append(ul);
+}
+
+function archiveRow(s) {
+ const li = document.createElement("li");
+ li.dataset.url = s.url;
+
+ const main = document.createElement("div");
+ main.className = "srv-main";
+ const name = document.createElement("span"); // plain text — archived spaces stay quiet
+ name.className = "name";
+ name.textContent = labelOf(s);
+ const url = document.createElement("span");
+ url.className = "url";
+ url.textContent = s.url;
+ main.append(name, url);
+
+ const unarch = document.createElement("button");
+ unarch.type = "button";
+ unarch.className = "pill";
+ unarch.textContent = "Unarchive";
+ unarch.addEventListener("click", () => unarchiveSpace(s));
+
+ const del = document.createElement("button");
+ del.type = "button";
+ del.className = "pill danger-ink";
+ del.textContent = "Delete…";
+ del.addEventListener("click", () => openArchiveDelete(s));
+
+ li.append(main, unarch, del);
+ return li;
+}
+
+// unarchiveSpace moves the entry back to the active list.
+function unarchiveSpace(s) {
+ vault.obj.servers = setArchived(vault.obj.servers, s.url, false);
+ if (!partitionArchived(vault.obj.servers).archived.length) {
+ closeArchive(); // archive is empty now — the space is back on the list
+ persistVault();
+ return;
+ }
+ renderArchiveList();
+ persistVault(archiveStatus);
+}
+
+// openArchiveDelete opens the stern permanent-delete step for one archived space.
+// "Delete your access" (drop the saved entry) is always offered; for a space THIS
+// account created, the deeper "destroy for everyone" is revealed too (ownership
+// checked against the provisioner ledger; the server re-enforces it on delete).
+function openArchiveDelete(s) {
+ archiving = s;
+ const host = hostOf(s.url);
+ $("stern-name").textContent = labelOf(s);
+ $("stern-host").textContent = host;
+ $("delete-arm").value = "";
+ $("delete-arm").placeholder = host;
+ $("delete-access").disabled = true;
+ $("destroy-zone").classList.add("hidden");
+ $("destroy-forever").disabled = true;
+ $("archive-main").classList.add("hidden");
+ $("archive-stern").classList.remove("hidden");
+ archiveStatus("");
if (spacesEnabled) {
apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } })
.then((r) => {
const mine = (r.spaces || []).some((sp) => sp.host === host);
- if (mine && removing === s) $("danger-zone").classList.remove("hidden");
+ if (mine && archiving === s) {
+ $("destroy-zone").classList.remove("hidden");
+ prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule)
+ }
})
- .catch(() => { /* can't confirm ownership — don't offer deletion */ });
+ .catch(() => { /* can't confirm ownership — don't offer the destroy */ });
}
}
-function closeRemove() {
- removing = null;
- $("remove").classList.add("hidden");
- showVault();
+// After a delete/destroy empties the entry, return to wherever makes sense: the
+// archive list if anything's left, else the main list.
+function afterDelete(okMsg) {
+ archiving = null;
+ if (partitionArchived(vault.obj.servers).archived.length) {
+ closeArchiveStern();
+ renderArchiveList();
+ archiveStatus(okMsg, "ok");
+ } else {
+ closeArchive();
+ vaultStatus(okMsg, "ok");
+ }
}
-// removeFromList drops the entry from the vault — the light action. The space
-// itself keeps running; an invite/add-device link is the way back in.
-async function removeFromList() {
- if (!removing) return;
- vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== removing.url);
- removeStatus("Removing…", "busy");
+// deleteAccess drops the saved entry from the vault — permanently. The space
+// keeps running for everyone else; this only removes THIS account's saved way
+// back in (the strongly-worded warning makes that consequence explicit).
+async function deleteAccess() {
+ if (!archiving) return;
+ const s = archiving;
+ vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url);
+ archiveStatus("Deleting…", "busy");
try {
await saveVault();
- closeRemove();
- vaultStatus("Removed from your list.", "ok");
+ afterDelete("Removed from your account.");
} catch (e) {
- removeStatus(e.message || "Couldn't save the change.", "error");
+ archiveStatus(e.message || "Couldn't save the change.", "error");
}
}
-// The stern step: type the space's address to arm the button, then a FRESH
-// passkey ceremony (Face ID) authorises the deletion — a link token alone can
-// never destroy a space. The server re-checks ownership against the ledger.
-function openDelete() {
- const host = (() => { try { return new URL(removing.url).host; } catch { return removing.url; } })();
- $("stern-host").textContent = host;
- $("delete-arm").value = "";
- $("delete-arm").placeholder = host;
- $("delete-forever").disabled = true;
- $("remove-main").classList.add("hidden");
- $("delete-stern").classList.remove("hidden");
- removeStatus("");
- prefetchLogin(); // the ceremony ahead needs fresh options (Safari gesture rule)
-}
-
-async function deleteForever() {
- if (!removing) return;
+// destroyForEveryone is the owner-only deeper action: a FRESH passkey ceremony
+// (a link token alone can never destroy a space) then DELETE against the
+// provisioner, which re-checks ownership against its ledger.
+async function destroyForEveryone() {
+ if (!archiving) return;
+ const s = archiving;
const host = $("stern-host").textContent;
- const btn = $("delete-forever");
+ const btn = $("destroy-forever");
btn.disabled = true;
- removeStatus("Confirm with your passkey…", "busy");
+ archiveStatus("Confirm with your passkey…", "busy");
let proof;
try {
({ proof } = await runLogin(loginBegin));
} catch (e) {
prefetchLogin();
- removeStatus(e.name === "NotAllowedError" ? "Passkey prompt was dismissed." : (e.message || String(e)), "error");
+ archiveStatus(e.name === "NotAllowedError" ? "Passkey prompt was dismissed." : (e.message || String(e)), "error");
btn.disabled = false;
return;
}
- removeStatus("Deleting the space…", "busy");
+ archiveStatus("Destroying the space…", "busy");
try {
const res = await fetch("/api/spaces/" + encodeURIComponent(host), {
method: "DELETE",
@@ -688,12 +862,11 @@ async function deleteForever() {
if (!res.ok && res.status !== 404) {
throw new Error(body.message || body.error || `Couldn't delete (HTTP ${res.status}).`);
}
- vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== removing.url);
+ vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url);
try { await saveVault(); } catch { /* the space is gone; the entry retries next save */ }
- closeRemove();
- vaultStatus("Space deleted.", "ok");
+ afterDelete("Space destroyed.");
} catch (e) {
- removeStatus(e.message || String(e), "error");
+ archiveStatus(e.message || String(e), "error");
btn.disabled = false;
}
}
@@ -735,19 +908,16 @@ $("open-create").addEventListener("click", openCreate);
$("create-back").addEventListener("click", closeCreate);
$("create-done").addEventListener("click", closeCreate);
// (Creating a space is the fader now — mounted in openCreate; see fader.js.)
-$("remove-back").addEventListener("click", closeRemove);
-$("remove-from-list").addEventListener("click", removeFromList);
-$("open-delete").addEventListener("click", openDelete);
-$("delete-cancel").addEventListener("click", () => {
- $("delete-stern").classList.add("hidden");
- $("remove-main").classList.remove("hidden");
- removeStatus("");
-});
-// The typed address arms the delete — exact match only.
+$("archive-back").addEventListener("click", closeArchive);
+$("archive-cancel").addEventListener("click", closeArchiveStern);
+$("delete-access").addEventListener("click", deleteAccess);
+$("destroy-forever").addEventListener("click", destroyForEveryone);
+// The typed address arms both delete actions — exact match only.
$("delete-arm").addEventListener("input", () => {
- $("delete-forever").disabled = $("delete-arm").value.trim() !== $("stern-host").textContent;
+ const armed = $("delete-arm").value.trim() === $("stern-host").textContent;
+ $("delete-access").disabled = !armed;
+ $("destroy-forever").disabled = !armed;
});
-$("delete-forever").addEventListener("click", deleteForever);
// Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input
// lives outside #spaces, so re-rendering the lists never steals its focus.
$("space-search").addEventListener("input", () => { if (vault) showVault(); });
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js
index a638396..77250a8 100644
--- a/home/web/static/serverlist.js
+++ b/home/web/static/serverlist.js
@@ -2,13 +2,16 @@
// 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.
+// { url, name?, starred?, archived? }. Rendering first splits off the **archived**
+// entries (hidden behind an "Archived" affordance), then partitions the rest into
+// a **Starred** list and the remainder — each keeping its own relative array order,
+// so the lists sort independently (reordering one never disturbs another). The
+// master array is kept active-first-then-archived (setArchived maintains this).
+// This is the contract the native apps read too: starred spaces populate the rail,
+// the rest live behind a "more" affordance, and archived ones stay out of view.
-// partitionServers splits into { starred, others }, each in array order.
+// partitionServers splits into { starred, others }, each in array order. Callers
+// pass the ACTIVE subset (archived entries filtered out via partitionArchived).
export function partitionServers(servers) {
const starred = [];
const others = [];
@@ -16,6 +19,38 @@ export function partitionServers(servers) {
return { starred, others };
}
+// partitionArchived splits into { active, archived }, each in array order — the
+// first cut rendering makes, before active is further split into starred/others.
+export function partitionArchived(servers) {
+ const active = [];
+ const archived = [];
+ for (const s of servers || []) (s.archived ? archived : active).push(s);
+ return { active, archived };
+}
+
+// setArchived returns a NEW servers array with the entry's `archived` flag set to
+// `on` AND repositioned to keep the master order active-first-then-archived:
+// archive → drop `starred` (an archived space leaves your rail) and send it to
+// the TOP of the archived group (most-recently-archived first).
+// unarchive → drop `archived` and send it to the TOP of the active "others"
+// group (below Starred), mirroring how unstar returns an entry.
+// Unknown urls pass through unchanged; the input array/entry is never mutated.
+export function setArchived(servers, url, on) {
+ const list = servers || [];
+ const target = list.find((s) => s.url === url);
+ if (!target) return list;
+ const rest = list.filter((s) => s.url !== url);
+ const starred = rest.filter((s) => s.starred && !s.archived);
+ const others = rest.filter((s) => !s.starred && !s.archived);
+ const archived = rest.filter((s) => s.archived);
+ if (on) {
+ const base = (({ starred, ...keep }) => keep)(target); // leaving the rail
+ return [...starred, ...others, { ...base, archived: true }, ...archived];
+ }
+ const base = (({ archived, ...keep }) => keep)(target); // back to the list
+ return [...starred, base, ...others, ...archived];
+}
+
// toggleStar returns a NEW servers array with the entry's starred flag flipped
// AND repositioned: starring sends it to the END of the Starred list, unstarring
// sends it to the TOP of the rest — both of which are the boundary between the