1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297 | ;; ported-from: src/app/rooms.ts
;;
;; Local room directory. Rooms are capability links (#secret); the list of rooms
;; you know lives in this browser's localStorage — the relay never learns which
;; rooms exist or what they're called. Devices that prove the SAME identity to
;; each other inside a room sync their lists directly (see merge-rooms below and
;; PROTOCOL.md §6).
;;
;; DATA ONLY. The lobby that used to live at the bottom of this file — with its
;; own `esc`, its own render closure and its own innerHTML templates — is
;; app/lobby.cljs now, on replicant (Phase 5a). Everything below is the storage
;; and merge layer, and test/vectors/rooms.json pins it; the eight names
;; shadow-cljs.edn exports are unchanged.
;;
;; SHAPE. A RoomEntry is {s, label, ts, lts?} and a tombstone is {s, ts}. Both
;; are persisted AND synced between devices, so both are DECLARED — see
;; ROOM-ENTRY / TOMBSTONE below — instead of being spelled out at each call
;; site. sueta.wire's `encode` walks the declaration doing the same sequential
;; unchecked-set `j/ordered` did before, so the bytes are unchanged and
;; test/vectors/rooms.json still pins them; what changes is that key order is
;; now a value you can print.
;;
;; LAYERS. Everything above `w/decode` works in Clojure maps with nil as the
;; only absence, so plain predicates are safe. JS semantics — truthiness,
;; `typeof`, `Array.isArray`, `??`, string-coercing comparisons — live in
;; sueta.wire, and nowhere else. (They used to live in the lobby too; the
;; replicant port's one JS boundary is app/lobby.cljs's `normalize`.)
(ns sueta.app.rooms
(:require [sueta.config :as config]
[sueta.i18n :as i18n :refer (t)]
[sueta.wire :as w]
[ardegazu.rooms.js :as j]))
(def ^:private KEY (config/ns-key "rooms"))
(def ^:private GONE-KEY (config/ns-key "rooms-gone"))
(def ^:private SECRET-RE (js/RegExp. "^[A-Za-z0-9_-]{43}$"))
(def ^:private MAX-ROOMS 50)
(def ^:private MAX-GONE 100)
;; Fires after ANY persisted list change (visit/rename/forget/merge) — room.cljs
;; points this at the social agent's debounced self-sync (`syncNow`), so the list
;; also rides the sealed self channel between this identity's devices. A remote
;; merge persists too, so the hook re-fires after applying a sync; that echo is
;; by design — the kit's hash gate keeps it off the wire unless the merge
;; actually changed the list (which other devices should then see).
(defonce ^:private on-rooms-changed (atom nil))
(defn set-on-rooms-changed [f]
(reset! on-rooms-changed f)
js/undefined)
;; ---- the shape registry ----------------------------------------------------
;;
;; Both shapes below are DECLARED, not spelled out at their call sites; the
;; walkers that turn them into JSON and back live in sueta.wire, which is the
;; only namespace here allowed to touch a JS object.
(def ^:private ROOM-ENTRY
"A RoomEntry: {s, label, ts, lts?} — PROTOCOL.md §6."
[[:s "s"] [:label "label"] [:ts "ts"] [:lts "lts"]])
(def ^:private TOMBSTONE
"A forget marker: {s, ts}."
[[:s "s"] [:ts "ts"]])
;; ---- validation ------------------------------------------------------------
;;
;; Storage is trusted for content and re-validated for shape, exactly as before:
;; an entry needs a string `s` and a string `label`, a tombstone needs a string
;; `s` and a numeric `ts`. Everything else rides through untouched — a bogus
;; `ts` is the sorter's problem, not a reason to lose someone's room.
(defn- ^boolean room-entry? [r] (and (w/str? (:s r)) (w/str? (:label r))))
(defn- ^boolean tombstone? [g] (and (w/str? (:s g)) (w/num? (:ts g))))
;; ---- ordered-by-secret list helpers ----------------------------------------
;;
;; The merge keeps its lists in insertion order and looks entries up by secret —
;; what the original did with a js/Map, whose ordering rules these reproduce:
;; replacing keeps the slot, removing closes the gap, a re-add lands at the end.
;; Fifty entries a side, so a scan is the right data structure.
(defn- find-index [entries secret]
(first (keep-indexed (fn [i e] (when (= secret (:s e)) i)) entries)))
(defn- drop-nth [v i]
(into (subvec v 0 i) (subvec v (inc i))))
(defn- by-ts-desc
"Newest first, stable — `garray/stableSort`, like the Array#sort it replaces."
[entries]
(sort #(- (:ts %2) (:ts %1)) entries))
;; ---- storage ---------------------------------------------------------------
(defn- read-list [store-key shape valid?]
(try
(let [raw (js/JSON.parse (j/nn (js/localStorage.getItem store-key) "[]"))]
(if (w/arr? raw)
(into [] (filter valid?) (w/decode-all shape (array-seq raw)))
[]))
(catch :default _ [])))
(defn- write-list! [store-key shape ms]
(try (js/localStorage.setItem store-key (js/JSON.stringify (w/encode-all shape ms)))
(catch :default _ nil))
;; TS `onRoomsChanged?.()` — optional call
(when-some [f @on-rooms-changed] (f))
js/undefined)
(defn- read-rooms [] (read-list KEY ROOM-ENTRY room-entry?))
(defn- read-gone [] (read-list GONE-KEY TOMBSTONE tombstone?))
(defn- persist-rooms! [rooms]
(write-list! KEY ROOM-ENTRY (take MAX-ROOMS rooms)))
(defn- persist-gone! [gone]
(write-list! GONE-KEY TOMBSTONE (take MAX-GONE (by-ts-desc gone))))
(declare default-label)
;; ---- the public directory --------------------------------------------------
(defn load-rooms []
(w/encode-all ROOM-ENTRY (read-rooms)))
(defn- load-gone []
(w/encode-all TOMBSTONE (read-gone)))
(defn touch-room
"Record a visit; creates the entry with a default label on first sight."
[secret]
(let [rooms (read-rooms)
gone (read-gone)
tomb (first (filter #(= secret (:s %)) gone))
i (find-index rooms secret)
;; a rejoin must provably outrank the forget it overrides, even across
;; skewed device clocks — otherwise the tombstone resurrects on next sync
ts (js/Math.max (js/Date.now) (inc (or (:ts tomb) 0)))
;; a RoomEntry: {s, label, ts} — three keys, wire + storage order
entry (assoc (if i (nth rooms i) {:s secret :label (default-label)}) :ts ts)
updated (if i (assoc rooms i entry) (conj rooms entry))]
(when tomb
(persist-gone! (remove #(= secret (:s %)) gone)))
(persist-rooms! (by-ts-desc updated))
(w/encode ROOM-ENTRY entry)))
(defn rename-room [secret label]
(let [rooms (read-rooms)
i (find-index rooms secret)]
(when i
;; TS `label.slice(0,40) || r.label` — an all-blank label keeps the old one
(let [cut (.slice ^string label 0 40)
entry (-> (nth rooms i)
(update :label #(if (seq cut) cut %))
(assoc :lts (js/Date.now)))]
(persist-rooms! (assoc rooms i entry)))))
js/undefined)
(defn forget-room [secret]
(let [mine? #(= secret (:s %))]
(persist-rooms! (remove mine? (read-rooms)))
(persist-gone! (conj (into [] (remove mine?) (read-gone))
{:s secret :ts (js/Date.now)})))
js/undefined)
;; ---- same-identity sync ----------------------------------------------------
(defn build-room-sync
"The roomsync payload for another device holding OUR identity."
[]
(j/ordered "rooms" (load-rooms) "gone" (load-gone)))
(defn- clamp-ts
"Mirror of app/chat's clamp-ts: bogus remote timestamps must not pin state
forever."
[ts now]
(if-not (w/finite-num? ts)
now
(js/Math.min (js/Math.max ts 0) (+ now 120000))))
(def ^:private CONTROL-RE (js/RegExp. "[\\u0000-\\u001f\\u007f]" "g"))
(defn- sanitize
"One remote RoomEntry, re-built from scratch — nothing received is ever
persisted as-is. nil when the secret is not a secret."
[e now default-label-fn]
(let [sec (:s e)]
(when (and (w/str? sec) (.test SECRET-RE sec))
(let [raw (js/String (if (some? (:label e)) (:label e) ""))
stripped (.slice (.replace ^string raw CONTROL-RE "") 0 40)
lts (:lts e)]
(cond-> {:s sec
:label (if (seq stripped) stripped (default-label-fn))
:ts (clamp-ts (:ts e) now)}
(w/finite-num? lts) (assoc :lts (clamp-ts lts now)))))))
(defn- dedupe-by-secret
"Newest ts wins within one payload; the first sighting keeps its position."
[entries]
(reduce (fn [acc e]
(let [i (find-index acc (:s e))]
(cond
(nil? i) (conj acc e)
(> (:ts e) (:ts (nth acc i))) (assoc acc i e)
:else acc)))
[]
entries))
(defn- fold-entry
"Union one sanitized remote entry into {:rooms :gone :added}."
[{:keys [rooms gone] :as state} re current-secret]
(let [sec (:s re)
ti (find-index gone sec)
tomb (when ti (nth gone ti))]
;; forgotten after their last visit — but the room you are standing in is
;; never tombstoned: being here is a rejoin
(if (and tomb (not= sec current-secret) (>= (:ts tomb) (:ts re)))
state
;; the newer entry (or being in the room) beats the tombstone
(let [state (cond-> state ti (assoc :gone (drop-nth gone ti)))
ri (find-index rooms sec)]
(if (nil? ri)
(-> state (assoc :rooms (conj rooms re)) (update :added inc))
(let [local (nth rooms ri)
local (if (> (or (:lts re) 0) (or (:lts local) 0))
(assoc local :label (:label re) :lts (:lts re))
local)
local (assoc local :ts (js/Math.max (:ts local) (:ts re)))]
(assoc state :rooms (assoc rooms ri local))))))))
(defn- fold-tombstone
"Apply one remote forget marker to {:rooms :gone}."
[{:keys [rooms gone] :as state} g current-secret now]
(let [sec (:s g)]
(if-not (and (w/str? sec) (.test SECRET-RE sec) (not= sec current-secret))
state
(let [ts (clamp-ts (:ts g) now)
ri (find-index rooms sec)
local (when ri (nth rooms ri))]
;; our entry is newer — the forget lost
(if (and local (<= ts (:ts local)))
state
(let [state (cond-> state ri (assoc :rooms (drop-nth rooms ri)))
gi (find-index gone sec)]
(if (or (nil? gi) (> ts (:ts (nth gone gi))))
(assoc state :gone (if gi
(assoc gone gi {:s sec :ts ts})
(conj gone {:s sec :ts ts})))
state)))))))
(defn- merge-lists
"The whole merge, as a function of its inputs: local lists + already-decoded
remote lists in, {:rooms :gone :added} out. No storage, no clock."
[rooms gone remote-rooms remote-gone current-secret now default-label-fn]
(let [incoming (->> remote-rooms
(keep #(sanitize % now default-label-fn))
(dedupe-by-secret))
state (reduce #(fold-entry %1 %2 current-secret)
{:rooms rooms :gone gone :added 0}
incoming)]
(reduce #(fold-tombstone %1 %2 current-secret now) state remote-gone)))
(defn merge-rooms
"Fold another device's room list into ours. The payload is remote input from a
peer that proved OUR identity — trusted for content, still strictly
re-validated for shape (fresh objects only; nothing received is persisted
as-is). Returns how many rooms were added.
Merge rules:
- union by secret; visits resolve by newest ts, labels by newest lts
- a tombstone (forget) newer than an entry's last visit removes/suppresses it
- an entry newer than a tombstone deletes the tombstone (both directions)
- the currently-open room is never tombstoned: being here is a rejoin"
[remote current-secret]
(let [remote-rooms (w/oget remote "rooms")]
(if-not (w/arr? remote-rooms)
0
(let [now (js/Date.now)
g (w/oget remote "gone")
remote-gone (if (w/arr? g) g (array))
{:keys [rooms gone added]}
(merge-lists (read-rooms)
(read-gone)
(w/decode-all ROOM-ENTRY (take MAX-ROOMS (array-seq remote-rooms)))
(w/decode-all TOMBSTONE (take MAX-GONE (array-seq remote-gone)))
current-secret
now
default-label)]
(persist-rooms! (by-ts-desc rooms))
(persist-gone! gone)
added))))
;; NOTE: labels are persisted (and synced between devices) — this translates
;; only at generation time for NEW rooms; stored labels stay as they were.
(defn- default-label []
(str (t "room.word") " · " (i18n/fmt-date (js/Date.) (j/ordered "month" "short" "day" "numeric"))))
|