dev / templates / app-cljs-rooms / client / src / __NAME__ / app / rooms.cljs
  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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
;; Local room directory + lobby. 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; the store fires onSelfDevice, room.cljs sends the
;; payload), and the social agent's sealed self-channel carries the same list to
;; devices that are never in a room together (social.cljs `appState`).
;;
;; No native prompt()/confirm() anywhere: naming is an inline input, renaming is
;; in-place, and forgetting is a two-tap confirmation.
;;
;; The lobby is built as NODES through __NAME__.app.dom — no `innerHTML`, no
;; `esc` (test/source-hygiene.test.mjs holds that as an absolute): a room label
;; is typed by whoever named the room and synced from other devices, so it is
;; rendered as a text node, never interpolated into markup.
;;
;; A RoomEntry is {s, label, ts, lts?} and a tombstone is {s, ts} — both are
;; persisted AND synced between devices, so they are built with j/ordered
;; (never #js {} — see js.cljs on the nine-key hash-order trap) and are worth
;; pinning with a golden vector once you add tests.
(ns __NAME__.app.rooms
  (:require ["ardegazu-id-kit" :refer (IdBridge)]
            ["ardegazu-social-kit/join" :refer (homeChip)]
            [__NAME__.app.dom :as d]
            [__NAME__.config :as config]
            [__NAME__.i18n :as i18n :refer (t)]
            [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) —
;; social.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.
(defonce ^:private on-rooms-changed (atom nil))

(defn set-on-rooms-changed [f]
  (reset! on-rooms-changed f)
  js/undefined)

;; Wire/storage validators use the JS forms the TypeScript used, not the Clojure
;; predicates: `array?` compiles to `instance? js/Array` for a :browser build,
;; which is realm-sensitive, while TS wrote `Array.isArray`. In a validator that
;; difference IS the wire contract.
(defn- ^boolean arr? [x] (js/Array.isArray x))

(defn- ^boolean s? [x] (identical? "string" (js* "typeof ~{}" x)))
(defn- ^boolean n? [x] (identical? "number" (js* "typeof ~{}" x)))

(defn load-rooms []
  (try
    (let [raw (js/JSON.parse (j/nn (js/localStorage.getItem KEY) "[]"))]
      (if (arr? raw)
        (.filter ^js raw (fn [r] (and (s? (when (j/truthy? r) (unchecked-get r "s")))
                                      (s? (when (j/truthy? r) (unchecked-get r "label"))))))
        (array)))
    (catch :default _ (array))))

(defn- persist [rooms]
  (try (js/localStorage.setItem KEY (js/JSON.stringify (.slice ^js rooms 0 MAX-ROOMS)))
       (catch :default _ nil))
  (when-some [f @on-rooms-changed] (f))
  js/undefined)

(defn- load-gone []
  (try
    (let [raw (js/JSON.parse (j/nn (js/localStorage.getItem GONE-KEY) "[]"))]
      (if (arr? raw)
        (.filter ^js raw (fn [g] (and (s? (when (j/truthy? g) (unchecked-get g "s")))
                                      (n? (when (j/truthy? g) (unchecked-get g "ts"))))))
        (array)))
    (catch :default _ (array))))

(defn- persist-gone [gone]
  (try
    (js/localStorage.setItem
     GONE-KEY
     (js/JSON.stringify (.slice (.sort ^js gone (fn [a b] (- (unchecked-get b "ts") (unchecked-get a "ts"))))
                                0 MAX-GONE)))
    (catch :default _ nil))
  (when-some [f @on-rooms-changed] (f))
  js/undefined)

(declare default-label)

(defn touch-room
  "Record a visit; creates the entry with a default label on first sight."
  [secret]
  (let [rooms (load-rooms)
        gone (load-gone)
        tomb (.find ^js gone (fn [g] (identical? (unchecked-get g "s") secret)))
        existing (.find ^js rooms (fn [x] (identical? (unchecked-get x "s") secret)))
        r (if (identical? existing js/undefined)
            ;; a RoomEntry: {s, label, ts} — three keys, wire + storage order
            (let [fresh (j/ordered "s" secret "label" (default-label) "ts" (js/Date.now))]
              (.push ^js rooms fresh)
              fresh)
            existing)]
    ;; a rejoin must provably outrank the forget it overrides, even across
    ;; skewed device clocks — otherwise the tombstone resurrects on next sync
    (unchecked-set r "ts" (js/Math.max (js/Date.now)
                                       (inc (j/nn (when (j/truthy? tomb) (unchecked-get tomb "ts")) 0))))
    (when (j/truthy? tomb)
      (persist-gone (.filter ^js gone (fn [g] (not (identical? (unchecked-get g "s") secret))))))
    (.sort ^js rooms (fn [a b] (- (unchecked-get b "ts") (unchecked-get a "ts"))))
    (persist rooms)
    r))

(defn rename-room [secret label]
  (let [rooms (load-rooms)
        r (.find ^js rooms (fn [x] (identical? (unchecked-get x "s") secret)))]
    (when (j/truthy? r)
      ;; JS truthiness, so an all-blank label keeps the old one
      (let [cut (.slice ^string label 0 40)]
        (unchecked-set r "label" (if (j/truthy? cut) cut (unchecked-get r "label"))))
      (unchecked-set r "lts" (js/Date.now))
      (persist rooms)))
  js/undefined)

(defn forget-room [secret]
  (persist (.filter ^js (load-rooms) (fn [x] (not (identical? (unchecked-get x "s") secret)))))
  (let [gone (.filter ^js (load-gone) (fn [g] (not (identical? (unchecked-get g "s") secret))))]
    (.push gone (j/ordered "s" secret "ts" (js/Date.now)))
    (persist-gone gone))
  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/store's clamp-ts: bogus remote timestamps must not pin state
   forever."
  [ts]
  (let [now (js/Date.now)]
    (if-not (and (n? ts) (js/Number.isFinite ts))
      now
      (js/Math.min (js/Math.max ts 0) (+ now 120000)))))

(def ^:private CONTROL-RE (js/RegExp. "[\\u0000-\\u001f\\u007f]" "g"))

(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 (when (j/truthy? remote) (unchecked-get remote "rooms"))]
    (if-not (arr? remote-rooms)
      0
      (let [remote-gone (let [g (unchecked-get remote "gone")] (if (arr? g) g (array)))
            by-s (js/Map.)
            gone-by (js/Map.)]
        (doseq [x (array-seq (load-rooms))] (.set by-s (unchecked-get x "s") x))
        (doseq [g (array-seq (load-gone))] (.set gone-by (unchecked-get g "s") g))

        ;; sanitize + dedupe remote entries (newest ts wins within the payload)
        (let [incoming (js/Map.)]
          (doseq [e (array-seq (.slice ^js remote-rooms 0 MAX-ROOMS))]
            (let [sec (when (j/truthy? e) (unchecked-get e "s"))]
              (when (and (s? sec) (.test SECRET-RE sec))
                (let [raw-label (js* "String(~{})" (j/nn (unchecked-get e "label") ""))
                      stripped (.slice (.replace ^string raw-label CONTROL-RE "") 0 40)
                      label (if (j/truthy? stripped) stripped (default-label))
                      ts (clamp-ts (unchecked-get e "ts"))
                      raw-lts (unchecked-get e "lts")
                      lts (when (and (n? raw-lts) (js/Number.isFinite raw-lts)) (clamp-ts raw-lts))
                      prev (.get incoming sec)]
                  (when (or (identical? prev js/undefined) (> ts (unchecked-get prev "ts")))
                    (let [rec (j/ordered "s" sec "label" label "ts" ts)]
                      (when (some? lts) (unchecked-set rec "lts" lts))
                      (.set incoming sec rec)))))))

          (let [added (volatile! 0)]
            (doseq [re (array-seq (js/Array.from (.values incoming)))]
              (let [sec (unchecked-get re "s")
                    tomb (.get gone-by sec)]
                ;; forgotten after their last visit
                (when-not (and (j/truthy? tomb)
                               (not (identical? sec current-secret))
                               (>= (unchecked-get tomb "ts") (unchecked-get re "ts")))
                  ;; the newer entry (or being in the room) beats the tombstone
                  (when (j/truthy? tomb) (.delete gone-by sec))
                  (let [local (.get by-s sec)]
                    (if (identical? local js/undefined)
                      (do (.set by-s sec re) (vswap! added inc))
                      (do
                        (when (> (j/nn (unchecked-get re "lts") 0) (j/nn (unchecked-get local "lts") 0))
                          (unchecked-set local "label" (unchecked-get re "label"))
                          (unchecked-set local "lts" (unchecked-get re "lts")))
                        (unchecked-set local "ts"
                                       (js/Math.max (unchecked-get local "ts") (unchecked-get re "ts")))))))))

            (doseq [g (array-seq (.slice ^js remote-gone 0 MAX-GONE))]
              (let [sec (when (j/truthy? g) (unchecked-get g "s"))]
                (when (and (s? sec) (.test SECRET-RE sec) (not (identical? sec current-secret)))
                  (let [ts (clamp-ts (unchecked-get g "ts"))
                        local (.get by-s sec)]
                    ;; our entry is newer — the forget lost
                    (when-not (and (j/truthy? local) (<= ts (unchecked-get local "ts")))
                      (when (j/truthy? local) (.delete by-s sec))
                      (let [prev (.get gone-by sec)]
                        (when (or (identical? prev js/undefined) (> ts (unchecked-get prev "ts")))
                          (.set gone-by sec (j/ordered "s" sec "ts" ts)))))))))

            (persist (.sort (js/Array.from (.values by-s))
                            (fn [a b] (- (unchecked-get b "ts") (unchecked-get a "ts")))))
            (persist-gone (js/Array.from (.values gone-by)))
            @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"))))

;; ---- the lobby -------------------------------------------------------------

(defn- fmt-ago [ts]
  (let [m (js/Math.round (/ (- (js/Date.now) ts) 60000))]
    (cond
      (< m 1) (t "ago.now")
      (< m 60) (t "ago.m" {"n" m})
      (< m (* 60 24)) (t "ago.h" {"n" (js/Math.round (/ m 60))})
      :else (t "ago.d" {"n" (js/Math.round (/ m 1440))}))))

(defn- rename-row!
  "The in-place rename input for one room row."
  [list-el r sec renaming render]
  (let [row (d/el "div" "room-row")
        in (d/input "rename-in" nil 40)
        commit (fn []
                 (vreset! renaming nil)
                 (when (j/truthy? (.trim (.-value in)))
                   (rename-room sec (.trim (.-value in))))
                 (@render))]
    (.setAttribute in "enterkeyhint" "done")
    (set! (.-value in) (unchecked-get r "label"))
    (.addEventListener in "keydown"
                       (fn [^js e]
                         (when (identical? (.-key e) "Enter") (commit))
                         (when (identical? (.-key e) "Escape")
                           (vreset! renaming nil)
                           (@render))))
    (.addEventListener in "blur" commit)
    (.appendChild list-el (d/add! row in))
    (js/setTimeout (fn [] (.focus in) (.select in)) 30))
  js/undefined)

(defn- room-row!
  "One saved room: open / rename / two-tap forget."
  [list-el r sec renaming render done]
  (let [row (d/el "div" "room-row")
        open-btn (d/el "button" "room-open")
        ren-btn (d/btn "icon-btn small" "✎" (fn [] (vreset! renaming sec) (@render)))
        forget-btn (d/btn "icon-btn small forget" "🗑" nil)]
    (set! (.-type open-btn) "button")
    (d/add! open-btn
            (d/txt "span" "room-label" (unchecked-get r "label"))
            (d/txt "span" "room-ago" (fmt-ago (unchecked-get r "ts"))))
    (.addEventListener open-btn "click" (fn [] (done sec)))
    (set! (.-title ren-btn) (t "lobby.rename.title"))
    (set! (.-title forget-btn) (t "lobby.forget.title"))
    ;; two-tap forget: first tap arms the button, second within 3.5s deletes
    (.addEventListener
     forget-btn "click"
     (fn []
       (if (.contains (.-classList forget-btn) "armed")
         (do (forget-room sec) (@render))
         (do (.add (.-classList forget-btn) "armed")
             (d/set-text! forget-btn (t "lobby.forget.arm"))
             (js/setTimeout (fn []
                              (.remove (.-classList forget-btn) "armed")
                              (d/set-text! forget-btn "🗑"))
                            3500)))))
    (.appendChild list-el (d/add! row open-btn ren-btn forget-btn)))
  js/undefined)

(defn- lobby-box!
  "Build one render of the lobby into `overlay`; returns the box element."
  [overlay rooms]
  (let [box (d/el "div" "modal lobby-box")
        sub (d/el "p" nil)
        list-el (d/el "div" "room-list")
        new-form (d/el "form" "new-room-form")
        new-in (d/input nil (t "lobby.new.ph") 40)
        new-btn (d/txt "button" "primary" (t "lobby.new"))
        join-form (d/el "form" "new-room-form")
        join-in (d/input nil (t "lobby.join.ph") nil)
        join-btn (d/txt "button" "primary ghosty" (t "lobby.join"))]
    ;; `.append` turns strings into TEXT nodes — the <br> is the only element
    (.append sub (t "lobby.sub1") (d/el "br" nil) (t "lobby.sub2"))
    (when (zero? (.-length rooms))
      (d/add! list-el (d/txt "p" "hint" (t "lobby.empty"))))
    (set! (.-id new-form) "new-room-form")
    (set! (.-id new-in) "new-room-name")
    (set! (.-id new-btn) "new-room")
    (set! (.-id join-form) "join-form")
    (set! (.-id join-in) "join-link")
    (set! (.-id join-btn) "join-room")
    (.setAttribute new-in "enterkeyhint" "go")
    (.setAttribute join-in "enterkeyhint" "go")
    (.setAttribute join-in "spellcheck" "false")
    (d/add! box
            (d/txt "h2" nil "__NAME__")
            sub
            list-el
            (d/add! new-form new-in new-btn)
            (d/add! join-form join-in join-btn)
            (d/txt "p" "hint" (t "lobby.hint"))
            (d/txt "p" "hint ver" (t "lobby.version" {"v" config/APP-VERSION})))
    (d/add! overlay box)
    box))

(defn lobby
  "Full-screen lobby; resolves with the chosen or newly created room secret."
  [root make-secret]
  (js/Promise.
   (fn [resolve _reject]
     (let [overlay (d/el "div" "overlay lobby")
           renaming (volatile! nil)] ; secret of the row being renamed

       ;; the lobby is a menu screen: show the kit's "back to the hub" chip
       ;; (renders nothing until body.soc-menu; idempotent across lobby visits)
       (.add (.-classList js/document.body) "soc-menu")
       (try (homeChip (j/ordered "t" i18n/t-kit)) (catch :default _ nil))

       (let [done (fn [secret]
                    (.remove (.-classList js/document.body) "soc-menu")
                    (.remove overlay)
                    (resolve secret))
             render (volatile! nil)]
         (vreset!
          render
          (fn render-fn []
            (let [rooms (load-rooms)]
              (d/clear! overlay)
              (lobby-box! overlay rooms)

              ;; language row: persists locally (set-lang inside the picker),
              ;; mirrors to the suite identity record best-effort, then reloads
              ;; into the new language
              (.appendChild
               (.querySelector overlay ".lobby-box")
               (i18n/lang-picker-el
                {:on-pick
                 (fn [lang]
                   (-> (js/Promise.resolve nil)
                       (.then (fn [_]
                                (let [bridge (IdBridge. (j/ordered "ns" config/NS
                                                                   "bridgeUrl" config/ID-BRIDGE-URL))]
                                  (when (j/truthy? (.mirrorSeed ^js bridge))
                                    (.putProfile ^js bridge (j/ordered "lang" lang))))))
                       (.catch (fn [_] nil))
                       (.then (fn [_] (.reload js/location))))
                   js/undefined)}))

              (let [list-el (.querySelector overlay ".room-list")]
                (doseq [r (array-seq rooms)]
                  (let [sec (unchecked-get r "s")]
                    (if (identical? @renaming sec)
                      (rename-row! list-el r sec renaming render)
                      (room-row! list-el r sec renaming render done)))))

              ;; PWA fix: invite links open the browser, not the installed app —
              ;; so the lobby accepts a pasted link (or bare secret) and joins
              ;; directly.
              (.addEventListener
               (.querySelector overlay "#join-form") "submit"
               (fn [^js e]
                 (.preventDefault e)
                 (let [input ^js (.querySelector overlay "#join-link")
                       ;; anchored: the secret must be the whole input or a
                       ;; #fragment — an unanchored 43-char run would "join" the
                       ;; middle of any pasted JWT/hash
                       m (.match (.trim (.-value input))
                                 (js/RegExp. "(?:#|^)([A-Za-z0-9_-]{43})(?=$|[&?\\s])"))]
                   (if-not (j/truthy? m)
                     (do (set! (.-value input) "")
                         (set! (.-placeholder input) (t "lobby.bad-link")))
                     (do (touch-room (aget m 1)) (done (aget m 1)))))))

              (.addEventListener
               (.querySelector overlay "#new-room-form") "submit"
               (fn [^js e]
                 (.preventDefault e)
                 (let [label (.trim (.-value ^js (.querySelector overlay "#new-room-name")))
                       secret (make-secret)]
                   (touch-room secret)
                   (when (j/truthy? label) (rename-room secret label))
                   (done secret))))
              js/undefined)))
         ((deref render))
         (.appendChild ^js root overlay))))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/dev.git