eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Pure helpers for the saved-spaces list — framework-free and side-effect-free,
2// so they're unit-testable in Node (home/test/serverlist.test.mjs) without a DOM.
3//
4// The backup blob's `servers` array is the master order; each entry is
5// { url, name?, starred?, archived? }. Rendering first splits off the **archived**
6// entries (hidden behind an "Archived" affordance), then partitions the rest into
7// a **Starred** list and the remainder — each keeping its own relative array order,
8// so the lists sort independently (reordering one never disturbs another). The
9// master array is kept active-first-then-archived (setArchived maintains this).
10// This is the contract the native apps read too: starred spaces populate the rail,
11// the rest live behind a "more" affordance, and archived ones stay out of view.
12
13// spaceRestoreURL marks a home-list navigation as an automatic sign-in. The
14// instance consumes the marker before creating its ephemeral restore key, then
15// returns through home's encrypted handoff. Existing query parameters survive.
16export function spaceRestoreURL(raw) {
17 try {
18 const url = new URL(raw);
19 url.searchParams.set("restore", "1");
20 return url.toString();
21 } catch {
22 return raw;
23 }
24}
25
26// partitionServers splits into { starred, others }, each in array order. Callers
27// pass the ACTIVE subset (archived entries filtered out via partitionArchived).
28export function partitionServers(servers) {
29 const starred = [];
30 const others = [];
31 for (const s of servers || []) (s.starred ? starred : others).push(s);
32 return { starred, others };
33}
34
35// partitionArchived splits into { active, archived }, each in array order — the
36// first cut rendering makes, before active is further split into starred/others.
37export function partitionArchived(servers) {
38 const active = [];
39 const archived = [];
40 for (const s of servers || []) (s.archived ? archived : active).push(s);
41 return { active, archived };
42}
43
44// setArchived returns a NEW servers array with the entry's `archived` flag set to
45// `on` AND repositioned to keep the master order active-first-then-archived:
46// archive → drop `starred` (an archived space leaves your rail) and send it to
47// the TOP of the archived group (most-recently-archived first).
48// unarchive → drop `archived` and send it to the BOTTOM of the active "others"
49// group (the end of the visible list) — a returning space rejoins
50// at the back, not ahead of the spaces you kept.
51// Unknown urls pass through unchanged; the input array/entry is never mutated.
52export function setArchived(servers, url, on) {
53 const list = servers || [];
54 const target = list.find((s) => s.url === url);
55 if (!target) return list;
56 const rest = list.filter((s) => s.url !== url);
57 const starred = rest.filter((s) => s.starred && !s.archived);
58 const others = rest.filter((s) => !s.starred && !s.archived);
59 const archived = rest.filter((s) => s.archived);
60 if (on) {
61 const base = (({ starred, ...keep }) => keep)(target); // leaving the rail
62 return [...starred, ...others, { ...base, archived: true }, ...archived];
63 }
64 const base = (({ archived, ...keep }) => keep)(target); // back to the list
65 return [...starred, ...others, base, ...archived];
66}
67
68// toggleStar returns a NEW servers array with the entry's starred flag flipped
69// AND repositioned: starring sends it to the END of the Starred list, unstarring
70// sends it to the TOP of the rest — both of which are the boundary between the
71// two groups, so the move is a single insert there. (The array is normalised to
72// starred-first-then-others, which display-partitioning treats identically.)
73// Unknown urls pass through unchanged.
74export function toggleStar(servers, url) {
75 const list = servers || [];
76 const target = list.find((s) => s.url === url);
77 if (!target) return list;
78 const updated = target.starred
79 ? (({ starred, ...rest }) => rest)(target) // unstar: drop the flag
80 : { ...target, starred: true }; // star
81 const starred = list.filter((s) => s.url !== url && s.starred);
82 const others = list.filter((s) => s.url !== url && !s.starred);
83 return [...starred, updated, ...others];
84}
85
86// filterServers returns the subset whose name/host fuzzily matches `query` (a
87// subsequence match, case-insensitive), preserving array order. Empty query →
88// everything. Pure so the fuzzy logic is unit-tested without a DOM.
89export function filterServers(servers, query) {
90 const q = (query || "").trim().toLowerCase();
91 if (!q) return servers || [];
92 return (servers || []).filter((s) => {
93 let host = "";
94 try { host = new URL(s.url).host; } catch { host = s.url || ""; }
95 return fuzzyMatch(q, ((s.name || "") + " " + host).toLowerCase());
96 });
97}
98
99// fuzzyMatch reports whether every character of `q` appears in `text` in order
100// (a subsequence — the familiar command-palette style of fuzzy match). `q` is
101// assumed already lowercased; callers lowercase `text` to match.
102export function fuzzyMatch(q, text) {
103 if (!q) return true;
104 let i = 0;
105 for (const ch of text) {
106 if (ch === q[i] && ++i === q.length) return true;
107 }
108 return false;
109}
110
111// orderServers returns a NEW servers array reordered to match `orderedUrls` (the
112// full top-to-bottom display order across both lists — what a drag leaves behind).
113// Entries whose url isn't listed keep their relative order at the end (defensive);
114// unknown urls are ignored. Entry objects (with their `starred`) are preserved.
115export function orderServers(servers, orderedUrls) {
116 const byUrl = new Map((servers || []).map((s) => [s.url, s]));
117 const out = [];
118 for (const url of orderedUrls) {
119 const s = byUrl.get(url);
120 if (s) {
121 out.push(s);
122 byUrl.delete(url);
123 }
124 }
125 for (const s of servers || []) if (byUrl.has(s.url)) out.push(s);
126 return out;
127}
128
129// moveInGroup returns a NEW servers array with the entry at `url` swapped with its
130// nearest **same-group** (same starred-ness) neighbour in direction `dir`
131// (-1 = up, +1 = down), skipping over entries from the other group. Returns the
132// array unchanged if there's no such neighbour (already at an end of its list).
133// Used by the keyboard reorder fallback (↑/↓ on a focused drag handle).
134export function moveInGroup(servers, url, dir) {
135 const arr = (servers || []).slice();
136 const i = arr.findIndex((s) => s.url === url);
137 if (i < 0) return arr;
138 const group = !!arr[i].starred;
139 let j = i + dir;
140 while (j >= 0 && j < arr.length && !!arr[j].starred !== group) j += dir;
141 if (j < 0 || j >= arr.length) return arr;
142 [arr[i], arr[j]] = [arr[j], arr[i]];
143 return arr;
144}
145
146// ---- removal tombstones ---------------------------------------------------
147//
148// Deleting a space on the home page must STICK. Sync never deletes (by design —
149// see ios HomeSync), so every signed-in device happily re-upserts any server it
150// still holds; a bare removal from `servers` comes back on the next push. A
151// removal therefore also writes a tombstone into the blob's top-level `removed`
152// list — { url, ts } (epoch ms, newest wins per url) — which sync consults:
153// an entry whose device copy predates its tombstone is dropped and never
154// re-pushed, while a space the user deliberately re-added AFTER the removal
155// (device addedAt > ts, or an explicit save/create on the web) clears the
156// tombstone and wins. Old clients spread-merge unknown top-level keys, so
157// tombstones survive their writes even though they don't consult them.
158// HomeVault.swift mirrors these helpers — keep them in lockstep.
159
160const TOMBSTONE_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000;
161const TOMBSTONE_MAX = 200;
162
163// addTombstone returns a NEW removed list with { url, ts } upserted (newest
164// wins per url) and pruned. The input list is never mutated.
165export function addTombstone(removed, url, ts) {
166 return pruneTombstones([
167 ...(removed || []).filter((t) => t && t.url !== url),
168 { url, ts },
169 ], ts);
170}
171
172// clearTombstone returns a NEW removed list without `url` — the deliberate
173// re-add path. Returns undefined when nothing is left, so the key drops out of
174// the blob instead of lingering as [].
175export function clearTombstone(removed, url) {
176 const list = (removed || []).filter((t) => t && t.url !== url);
177 return list.length ? list : undefined;
178}
179
180// tombstonedAt returns when `url` was removed (epoch ms), or 0 if it wasn't.
181export function tombstonedAt(removed, url) {
182 const t = (removed || []).find((t) => t && t.url === url);
183 return t && Number.isFinite(t.ts) ? t.ts : 0;
184}
185
186// mergeRemoved unions two removed lists, newest tombstone per url — the merge
187// rule for version races (two writers, each with its own removals).
188export function mergeRemoved(a, b) {
189 const byUrl = new Map();
190 for (const t of [...(a || []), ...(b || [])]) {
191 if (!t || !t.url) continue;
192 const prev = byUrl.get(t.url);
193 if (!prev || (t.ts || 0) > (prev.ts || 0)) byUrl.set(t.url, t);
194 }
195 return [...byUrl.values()];
196}
197
198// pruneTombstones caps the list so it can't grow unboundedly across years of
199// use: entries older than a year go (any device that hasn't synced in a year
200// re-learns the space at worst), and the newest TOMBSTONE_MAX are kept.
201export function pruneTombstones(removed, now) {
202 return (removed || [])
203 .filter((t) => t && t.url && now - (t.ts || 0) < TOMBSTONE_MAX_AGE_MS)
204 .sort((x, y) => (y.ts || 0) - (x.ts || 0))
205 .slice(0, TOMBSTONE_MAX);
206}
207
208// soleAdminHold decides whether "Delete your access" must be held, given a
209// space's /api/me answer. The hold protects against orphaning: if this key is
210// the space's ONLY admin key, dropping its last saved copy leaves the space
211// running with no admin, forever. Fail-OPEN by design — only an explicit,
212// well-formed "yes, admin, and the only one" engages the hold, so an
213// unreachable space, an older build without the field, or a malformed answer
214// never makes an entry undeletable.
215export function soleAdminHold(me) {
216 return !!me && me.role === "admin" && me.sole_admin === true;
217}
218