eleven / messenger Public

home: fuzzy search, smarter star placement, admin setup link on create (#345)

Three fixes to the saved-spaces UI:

- Fuzzy search filters the list. A search box appears once there are a few
  spaces; typing subsequence-matches name + host across both Starred and
  the rest. While a query is active, reordering is off (the grips hide) —
  you can't meaningfully drag a filtered subset. filterServers/fuzzyMatch
  are pure + unit-tested.

- Star placement follows intent: starring an item sends it to the END of
  the Starred list; unstarring sends it to the TOP of the rest (both land
  at the group boundary). toggleStar now repositions, not just flips.

- The create result links to the ADMIN SETUP flow, not the bare instance.
  The CTA is relabelled "Set up your space →" and points at the one-time
  setup link (claim → keyholder → Eleven setup/administration). The old
  `setup_link || url` fallback is gone: if the setup link can't be minted
  we say so rather than hand over the instance URL (which lands on a
  signed-out card, not the admin flow).

npm test (incl. the new serverlist cases) + go build ./home green.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 739fa0a1bd5ad33c40e821ca29a5704433569fc4 parent c7f1109
4 files changed, +180 −55
  • home/test/serverlist.test.mjs +50 −2
  • home/web/pages/index.html +11 −0
  • home/web/static/home.js +81 −44
  • home/web/static/serverlist.js +38 −9
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs
index 58f4776..e7d6237 100644
--- a/home/test/serverlist.test.mjs
+++ b/home/test/serverlist.test.mjs
@@ -7,6 +7,8 @@ import {
toggleStar,
moveInGroup,
orderServers,
+ filterServers,
+ fuzzyMatch,
} from "../web/static/serverlist.js";
let failures = 0;
@@ -42,11 +44,38 @@ await test("partitionServers tolerates null/empty", () => {
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(on.find((s) => s.url === "a").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");
+ assert.equal("starred" in off.find((s) => s.url === "a"), false, "unstarred entry carries no flag");
+});
+
+await test("starring sends the item to the END of the Starred list", () => {
+ // x, y already starred; star 'b' → it lands after them (end of Starred).
+ const servers = [
+ { url: "x", starred: true },
+ { url: "y", starred: true },
+ { url: "a" },
+ { url: "b" },
+ { url: "c" },
+ ];
+ const { starred, others } = partitionServers(toggleStar(servers, "b"));
+ assert.deepEqual(urls(starred), ["x", "y", "b"], "b appended to Starred");
+ assert.deepEqual(urls(others), ["a", "c"], "others keep order minus b");
+});
+
+await test("unstarring sends the item to the TOP of the rest", () => {
+ // x, y starred; unstar 'x' → it becomes the FIRST of the rest.
+ const servers = [
+ { url: "x", starred: true },
+ { url: "y", starred: true },
+ { url: "a" },
+ { url: "b" },
+ ];
+ const { starred, others } = partitionServers(toggleStar(servers, "x"));
+ assert.deepEqual(urls(starred), ["y"], "x left Starred");
+ assert.deepEqual(urls(others), ["x", "a", "b"], "x is now the top of the rest");
});
await test("toggleStar leaves unknown urls unchanged", () => {
@@ -105,5 +134,24 @@ await test("orderServers ignores unknown urls and keeps unlisted entries at the
assert.deepEqual(urls(next), ["c", "a", "b"]);
});
+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");
+ assert.equal(fuzzyMatch("xyz", "family"), false);
+ assert.equal(fuzzyMatch("", "anything"), true, "empty query matches");
+});
+
+await test("filterServers keeps fuzzy matches over name + host, order preserved", () => {
+ const servers = [
+ { url: "https://family.example.com", name: "Family", starred: true },
+ { url: "https://work.example.com", name: "Work" },
+ { url: "https://a4f.11ch.at" },
+ ];
+ assert.deepEqual(urls(filterServers(servers, "fam")), ["https://family.example.com"]);
+ assert.deepEqual(urls(filterServers(servers, "11ch")), ["https://a4f.11ch.at"], "matches the host");
+ assert.deepEqual(urls(filterServers(servers, "")), urls(servers), "empty query = everything");
+ assert.deepEqual(filterServers(servers, "zzzz"), [], "no matches");
+});
+
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 62d4292..1bcd864 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -48,6 +48,15 @@
}
.spaces-head:first-child { margin-top: 0; }
#spaces > p.empty { color: var(--muted); font-size: .9rem; margin: 6px 0 0; }
+ /* Fuzzy search over the list — appears once there are a few spaces to sift. */
+ #space-search {
+ width: 100%; margin: 4px 0 2px; padding: 8px 12px;
+ font: inherit; font-size: .9rem;
+ color: var(--ink); background: var(--quiet);
+ border: 1px solid transparent; border-radius: 10px;
+ }
+ #space-search::placeholder { color: var(--muted); }
+ #space-search:focus-visible { outline: none; border-color: var(--accent); background: var(--panel); }
ul.servers { list-style: none; margin: 0; padding: 0; }
ul.servers li {
display: flex; align-items: center; gap: 8px;
@@ -122,6 +131,8 @@
<h2>Your Eleven spaces</h2>
<button class="linkish" id="signout">Sign out</button>
</div>
+ <input id="space-search" class="hidden" type="search" inputmode="search"
+ autocomplete="off" spellcheck="false" placeholder="Search spaces" aria-label="Search your spaces">
<div id="spaces"></div>
<!-- Create a new space — only where this home server offers provisioning
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 4be2e68..3cb8a4b 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -17,7 +17,7 @@ import {
apiJSON, beginLogin, runLogin, fetchBlob, putBlob,
} from "/static/backup-core.js";
import {
- partitionServers, toggleStar, moveInGroup, orderServers,
+ partitionServers, toggleStar, moveInGroup, orderServers, filterServers,
} from "/static/serverlist.js";
// ---- session state ----------------------------------------------------------
@@ -159,22 +159,41 @@ function showVault() {
const box = $("spaces");
box.textContent = "";
- const servers = vault.obj.servers || [];
+ 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.
+ const search = $("space-search");
+ search.classList.toggle("hidden", all.length < 4);
+ const query = search.classList.contains("hidden") ? "" : search.value.trim();
+ const servers = query ? filterServers(all, query) : all;
if (!servers.length) {
- 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);
+ 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));
+ box.append(sectionHeading("Starred"), serverList(starred, sortable));
if (others.length) box.append(sectionHeading("More"));
}
- if (others.length) box.append(serverList(others));
+ if (others.length) box.append(serverList(others, sortable));
+}
+
+function emptyLine(text) {
+ const p = document.createElement("p");
+ p.className = "empty";
+ p.textContent = text;
+ return p;
}
function sectionHeading(text) {
@@ -184,10 +203,10 @@ function sectionHeading(text) {
return h;
}
-function serverList(group) {
+function serverList(group, sortable) {
const ul = document.createElement("ul");
ul.className = "servers";
- for (const s of group) ul.append(serverRow(s));
+ for (const s of group) ul.append(serverRow(s, sortable));
return ul;
}
@@ -199,7 +218,7 @@ const GRIP_SVG =
'<circle cx="2" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/>' +
'<circle cx="2" cy="13" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>';
-function serverRow(s) {
+function serverRow(s, sortable) {
const li = document.createElement("li");
li.dataset.url = s.url;
@@ -221,23 +240,28 @@ function serverRow(s) {
url.textContent = s.url;
main.append(name, url);
+ li.append(star, main);
+
// The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) —
- // the a11y fallback that replaces the old visible up/down buttons.
- const handle = document.createElement("button");
- handle.type = "button";
- handle.className = "drag-handle";
- handle.setAttribute("aria-label", "Reorder " + (s.name || s.url));
- 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));
- focusHandle(s.url); // re-render dropped focus; put it back on the moved row
- });
-
- li.append(star, main, handle);
+ // the a11y reorder fallback. Omitted while a search filter is active (you can't
+ // meaningfully reorder a filtered subset).
+ if (sortable) {
+ const handle = document.createElement("button");
+ handle.type = "button";
+ handle.className = "drag-handle";
+ handle.setAttribute("aria-label", "Reorder " + (s.name || s.url));
+ 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));
+ focusHandle(s.url); // re-render dropped focus; put it back on the moved row
+ });
+ li.append(handle);
+ }
+
return li;
}
@@ -400,14 +424,14 @@ async function createSpace() {
saved = false;
}
showVault();
- showNewSpaceResult(host, body.setup_link || url, saved);
+ showNewSpaceResult(host, body.setup_link, saved);
spaceStatus("");
} finally {
btn.disabled = false;
}
}
-function showNewSpaceResult(host, openLink, saved) {
+function showNewSpaceResult(host, setupLink, saved) {
const box = $("new-space-result");
box.classList.remove("hidden");
box.textContent = "";
@@ -415,21 +439,31 @@ function showNewSpaceResult(host, openLink, saved) {
const ready = document.createElement("p");
ready.className = "status ok";
ready.textContent = "Your space " + host + " is ready.";
-
- const a = document.createElement("a");
- a.className = "btn-link";
- a.href = openLink;
- a.target = "_blank";
- a.rel = "noopener";
- a.textContent = "Open your new space →";
-
- const note = document.createElement("p");
- note.className = "sub";
- note.textContent = saved
- ? "This one-time link makes you the keyholder. It's saved to your account below."
- : "This one-time link makes you the keyholder. Couldn't save it just now — add it below to be safe.";
-
- box.append(ready, a, note);
+ box.append(ready);
+
+ if (setupLink) {
+ // The setup link (not the bare instance): claiming it makes you the space's
+ // keyholder and hands you to Eleven to set it up and administer it.
+ const a = document.createElement("a");
+ a.className = "btn-link";
+ a.href = setupLink;
+ a.target = "_blank";
+ a.rel = "noopener";
+ a.textContent = "Set up your space →";
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.textContent = saved
+ ? "This one-time link makes you the keyholder and starts setup with Eleven. It's saved below so you can find it again."
+ : "This one-time link makes you the keyholder and starts setup with Eleven. Couldn't save it just now — add it below to be safe.";
+ box.append(a, note);
+ } else {
+ // No setup link means the mint failed — deliberately don't offer the bare
+ // instance URL (it lands on a signed-out card, not the admin flow).
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.textContent = "Your space is saved below, but the setup link couldn't be created just now — reload and create again, or reach the operator to mint one.";
+ box.append(note);
+ }
}
// ---- add-space form -----------------------------------------------------------
@@ -478,6 +512,9 @@ $("new-space").addEventListener("click", () => createSpace().catch((err) => {
else spaceStatus((err && err.message) || String(err), "error");
}));
$("add-form").addEventListener("submit", (e) => addServer(e).catch((err) => vaultStatus(err.message || String(err), "error")));
+// 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(); });
// Learn whether this home server offers self-serve space creation. If we're
// already signed in (session rehydrate below), reveal the controls too.
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js
index 82f50f4..a638396 100644
--- a/home/web/static/serverlist.js
+++ b/home/web/static/serverlist.js
@@ -17,19 +17,48 @@ export function partitionServers(servers) {
}
// 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.
+// 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
+// two groups, so the move is a single insert there. (The array is normalised to
+// starred-first-then-others, which display-partitioning treats identically.)
+// 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 };
+ const list = servers || [];
+ const target = list.find((s) => s.url === url);
+ if (!target) return list;
+ const updated = target.starred
+ ? (({ starred, ...rest }) => rest)(target) // unstar: drop the flag
+ : { ...target, starred: true }; // star
+ const starred = list.filter((s) => s.url !== url && s.starred);
+ const others = list.filter((s) => s.url !== url && !s.starred);
+ return [...starred, updated, ...others];
+}
+
+// filterServers returns the subset whose name/host fuzzily matches `query` (a
+// subsequence match, case-insensitive), preserving array order. Empty query →
+// everything. Pure so the fuzzy logic is unit-tested without a DOM.
+export function filterServers(servers, query) {
+ const q = (query || "").trim().toLowerCase();
+ if (!q) return servers || [];
+ return (servers || []).filter((s) => {
+ let host = "";
+ try { host = new URL(s.url).host; } catch { host = s.url || ""; }
+ return fuzzyMatch(q, ((s.name || "") + " " + host).toLowerCase());
});
}
+// fuzzyMatch reports whether every character of `q` appears in `text` in order
+// (a subsequence — the familiar command-palette style of fuzzy match). `q` is
+// assumed already lowercased; callers lowercase `text` to match.
+export function fuzzyMatch(q, text) {
+ if (!q) return true;
+ let i = 0;
+ for (const ch of text) {
+ if (ch === q[i] && ++i === q.length) return true;
+ }
+ return false;
+}
+
// orderServers returns a NEW servers array reordered to match `orderedUrls` (the
// full top-to-bottom display order across both lists — what a drag leaves behind).
// Entries whose url isn't listed keep their relative order at the end (defensive);