chat / client / src / sueta / viewlib.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
;; Test-only entry for the VIEW layer, the counterpart of sueta.testlib for the
;; :viewlib build (shadow-cljs.edn explains why the two are separate).
;;
;; Everything here is a thin EDN-string wrapper, so a plain `node --test` file
;; can drive a pure ClojureScript view with no browser, no jsdom and no
;; Playwright — which is the whole claim Phase 5's golden vectors rest on. It is
;; never part of the :app build.
(ns sueta.viewlib
  (:require [cljs.reader :as reader]
            [replicant.dom :as rd]
            [replicant.string :as rstr]
            [sueta.app.chrome :as chrome]
            [sueta.app.lobby :as lobby]
            [sueta.app.msgs :as msgs]
            [sueta.app.roster :as roster]
            [sueta.app.sheets :as sheets]
            [sueta.app.tiles :as tiles]
            [sueta.app.view :as view]
            [ardegazu.rooms.js :as j]))

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

(defn lobby-init
  "`init` over a real JS array (what localStorage hands back), EDN out."
  [js-rooms now]
  (pr-str (lobby/init js-rooms now)))

(defn lobby-step
  "The state machine with no renderer at all: [state' effects] as EDN."
  [state-edn event-edn]
  (pr-str (lobby/step (reader/read-string state-edn) (reader/read-string event-edn))))

(defn lobby-view
  "state -> hiccup, as EDN. This is the snapshot the golden vector pins, and it
   is comparable BY VALUE only because the event handlers in it are data
   (`[:open secret]`) rather than closures — a snapshot full of
   `#object[Function]` cannot tell two states apart, which is what disqualified
   reagent in the spike."
  [state-edn]
  (pr-str (lobby/view (reader/read-string state-edn))))

(defn lobby-html
  "The same view rendered to markup. A weaker assertion than the hiccup — it
   cannot see which event a button is wired to — but it is the one that shows
   escaping, so it is what the XSS case in the vector reads."
  [state-edn]
  (rstr/render (lobby/view (reader/read-string state-edn))))

;; ---- the message list + the composer ---------------------------------------
;;
;; app/msgs's `project` reads JS message objects, because that is what
;; app/chat's projector holds and moving THAT to Clojure data would move the
;; wire fixtures. So the vector's inputs are plain JSON and `js-msg` below
;; rebuilds exactly the shape app/chat builds — including `reactions` as the
;; Clojure map {emoji #{author}} it now is. Test-only, and it is the only place
;; in the repo that constructs a Message outside app/chat: app.test.mjs pins
;; that shape's key set independently, which is what stops the two drifting.

(defn- js-msg [o]
  (let [m (js/Object.assign (js-obj) o)
        rx (j/nn (unchecked-get o "rx") (array))]
    (unchecked-set m "reactions"
                   (into {}
                         (map (fn [pair] [(aget pair 0) (set (array-seq (aget pair 1)))]))
                         (array-seq rx)))
    (js-delete m "rx")
    m))

(defn- stable
  "Every map in `x` as a sorted-map, so `pr-str` prints in KEY order rather than
   in whatever order a CLJS hash map happens to iterate. Snapshot hygiene, not
   semantics — `read-string` hands back an ordinary map — but it is what stops
   the projection fixture from being a bet on ClojureScript's hashing, which a
   version bump could lose for no reason at all."
  [x]
  (cond
    (map? x) (into (sorted-map) (map (fn [pair] [(key pair) (stable (val pair))])) x)
    (set? x) (into (sorted-set) (map stable) x)
    (vector? x) (mapv stable x)
    (seq? x) (map stable x)
    :else x))

(defn msgs-project
  "JSON messages + an EDN context -> the view's whole state, as EDN."
  [json-messages ctx-edn]
  (pr-str (stable (msgs/project (into-array (map js-msg (array-seq json-messages)))
                                (reader/read-string ctx-edn)))))

(defn msgs-view
  "state -> the main list's hiccup, as EDN. The snapshot the golden vector
   pins, comparable BY VALUE only because every handler in it is data."
  [state-edn]
  (pr-str (msgs/view (reader/read-string state-edn))))

(defn msgs-thread-view [state-edn]
  (pr-str (msgs/thread-view (reader/read-string state-edn))))

(defn msgs-composer [thread?]
  (pr-str (msgs/composer thread?)))

(defn msgs-html
  "The same view rendered to markup — weaker than the hiccup (it cannot see
   which event a button is wired to) but it is the one that shows escaping."
  [state-edn]
  (rstr/render (msgs/view (reader/read-string state-edn))))

(defn msgs-thread-html [state-edn]
  (rstr/render (msgs/thread-view (reader/read-string state-edn))))

(defn msgs-composer-html [thread?]
  (rstr/render (msgs/composer thread?)))

(defn msgs-text-runs
  "The link splitter on its own: the one function in the view that parses."
  [text]
  (pr-str (msgs/text-runs text)))

(defn msgs-color-for [author] (msgs/color-for author))

;; ---- the call tiles + the roster (Phase 5c) --------------------------------
;;
;; Both `project`s read JS the way app/msgs' does, so the vectors' inputs are
;; plain JSON and the two builders below rebuild exactly the shapes their
;; callers hand them: CallManager's `Map<peerId, {name, audio, video, stream?}>`
;; and app/ui's `Map<peerId, {name?, state, id?}>`. NEITHER carries a
;; MediaStream — that is the point of the whole design, and a fixture that
;; contained one would be a fixture that could not be written down.

(defn- js-map
  "A js/Map from a JSON array of [k, v] pairs — insertion order is the array's,
   which is what both `project`s iterate in."
  [pairs]
  (let [m (js/Map.)]
    (doseq [pair (array-seq pairs)] (.set m (aget pair 0) (aget pair 1)))
    m))

(defn tiles-project
  "CallManager's self-state (JSON or null), its members (JSON pairs) and an EDN
   context -> the call chrome's whole state, as EDN."
  [json-self json-members ctx-edn]
  (pr-str (stable (tiles/project json-self
                                 (js-map json-members)
                                 (reader/read-string ctx-edn)))))

(defn tiles-view [state-edn]
  (pr-str (tiles/view (reader/read-string state-edn))))

(defn tiles-html [state-edn]
  (rstr/render (tiles/view (reader/read-string state-edn))))

(defn roster-project
  "app/ui's peer map (JSON pairs) and an EDN context -> the header's whole
   state, as EDN."
  [json-peers ctx-edn]
  (pr-str (stable (roster/project (js-map json-peers)
                                  (reader/read-string ctx-edn)))))

(defn roster-view [state-edn]
  (pr-str (roster/view (reader/read-string state-edn))))

(defn roster-html [state-edn]
  (rstr/render (roster/view (reader/read-string state-edn))))

(defn roster-peers-label [state-edn]
  (roster/peers-label (reader/read-string state-edn)))

;; ---- driving the real renderer ---------------------------------------------
;;
;; Everything above renders to a string. THIS pair renders to a DOM, because one
;; question about the call tiles cannot be answered any other way: does a keyed
;; node keep its ELEMENT across a re-render and a re-order? A `<video>` whose
;; `srcObject` is live is the reason to care, and "replicant keys it, so it is
;; fine" is an assumption until something asks.
;;
;; `tiles-hooks` drains the life-cycle dispatches seen since the last call,
;; which is how the other half gets asked: a hook that does not FIRE when a
;; stream arrives is a black tile, and a hook that fires when nothing changed is
;; a re-attached srcObject on every frame.

(defonce ^:private hook-log (atom []))

(defn- unkeyed
  "The same tiles with `:replicant/key` taken off every one of them. Test-only,
   and it exists to be the CONTROL: the identity probe asks the same three
   questions of a keyed and an unkeyed render, and the unkeyed one has to fail
   them. Without that half, a lenient harness would prove nothing."
  [nodes]
  (map (fn [node] (update node 1 dissoc :replicant/key)) nodes))

(defn tiles-render
  "Render app/tiles into `root` with the real `replicant.dom`, through the same
   global dispatch app/ui installs. `keyed?` false strips the keys — see
   `unkeyed`."
  [root state-edn keyed?]
  (rd/set-dispatch! (fn [_event-data handler-data]
                      (swap! hook-log conj handler-data)
                      js/undefined))
  (let [nodes (tiles/view (reader/read-string state-edn))]
    (rd/render root (if keyed? nodes (unkeyed nodes))))
  js/undefined)

(defn tiles-hooks
  "The life-cycle dispatches since the last drain, as EDN strings."
  []
  (let [seen @hook-log]
    (reset! hook-log [])
    (to-array (map pr-str seen))))

;; ---- driving the real renderer: the frame and the overlays (Phase 5d) ------
;;
;; The frame's whole safety argument is a claim about the RECONCILER — that a
;; container written as a constant with no children is skipped, so the six
;; foreign roots inside it (app/msgs ×2, app/roster, app/tiles, both composers)
;; plus `#sheet` and `#toasts` survive every frame repaint. That is not a claim
;; a string renderer can settle, so `chrome-render` drives `replicant.dom` and
;; `wrong?` builds the variant that would break it.

(defn- with-msgs-child
  "The frame with ONE text child put inside `#msgs`. Test-only, and it exists to
   be the CONTROL: it is precisely the mistake app/chrome's ns docs forbid, and
   the identity probe requires it to DESTROY what another renderer had put
   there. Without that half, a harness that cannot see the bug would report the
   correct version as safe for the wrong reason."
  [nodes]
  (map (fn [node]
         (if (= :div.app (first node))
           (into [] (map (fn [c]
                           (if (and (vector? c) (= :main#msgs.msgs (first c)))
                             (conj c "wrong")
                             c)))
                 node)
           node))
       nodes))

(defn chrome-render [root state-edn wrong?]
  (rd/set-dispatch! (fn [_event-data handler-data]
                      (swap! hook-log conj handler-data)
                      js/undefined))
  (let [nodes (chrome/view (reader/read-string state-edn))]
    (rd/render root (if wrong? (with-msgs-child nodes) nodes)))
  js/undefined)

(defonce ^:private sheet-hook-log (atom []))

(defn sheets-render
  "Render the overlay stack into `root` with the real `replicant.dom`. Its own
   dispatch log, because the question asked of it is the opposite of the tiles':
   there, a hook that does not re-fire is a black tile; here, the two hooks that
   write a SECRET onto a field must fire exactly once per open and never again."
  [root stack-edn]
  (rd/set-dispatch! (fn [_event-data handler-data]
                      (swap! sheet-hook-log conj handler-data)
                      js/undefined))
  (rd/render root (sheets/view (reader/read-string stack-edn)))
  js/undefined)

(defn sheets-hooks []
  (let [seen @sheet-hook-log]
    (reset! sheet-hook-log [])
    (to-array (map pr-str seen))))

;; ---- the frame + the modal overlays (Phase 5d) -----------------------------
;;
;; app/chrome takes two other PROJECTIONS as its inputs (app/roster's and
;; app/tiles'), so its wrappers are EDN in and EDN out with no JS anywhere.
;; app/sheets reads JS the way the three before it do — a PeerInfo bag and
;; room.cljs's SelfIdentityInfo — so its inputs are plain JSON.
;;
;; NOTE what is NOT here: an entry point that hands app/sheets a seed. There
;; isn't one, because `identity-sheet` never reads `me.seed` — the identity key
;; is written onto the field by a mount hook in the runtime, so no fixture in
;; this repo can contain one even by accident.

(defn chrome-project
  "app/roster's projection + app/tiles' + the shell's own context -> the frame's
   whole state, as EDN."
  [roster-edn call-edn ctx-edn]
  (pr-str (stable (chrome/project (reader/read-string roster-edn)
                                  (reader/read-string call-edn)
                                  (reader/read-string ctx-edn)))))

(defn chrome-view [state-edn]
  (pr-str (chrome/view (reader/read-string state-edn))))

(defn chrome-html [state-edn]
  (rstr/render (chrome/view (reader/read-string state-edn))))

(defn sheets-ask-name [current] (pr-str (stable (sheets/ask-name current))))

(defn sheets-peer
  "A peer id, that peer's PeerInfo (JSON) and this session's verdict (EDN or
   \"nil\") -> the peer card's state, as EDN."
  [peer-id json-p verdict-edn]
  (pr-str (stable (sheets/peer-sheet peer-id json-p (reader/read-string verdict-edn)))))

(defn sheets-identity
  "SelfIdentityInfo (JSON or null) + this device's display name -> the identity
   card's state, as EDN."
  [json-me me-name]
  (pr-str (stable (sheets/identity-sheet json-me me-name))))

(defn sheets-copy [] (pr-str (stable (sheets/copy-sheet))))

(defn sheets-step
  "One overlay's pure transition: [sheet', effects] as EDN."
  [state-edn event-edn]
  (pr-str (stable (sheets/step (reader/read-string state-edn)
                               (reader/read-string event-edn)))))

(defn sheets-view
  "The overlay STACK -> hiccup, as EDN."
  [stack-edn]
  (pr-str (sheets/view (reader/read-string stack-edn))))

(defn sheets-html [stack-edn]
  (rstr/render (sheets/view (reader/read-string stack-edn))))

;; ---- the handle registry ---------------------------------------------------

(defn put-handle [id x] (view/put-handle! id x))
(defn get-handle [id] (view/handle id))
(defn forget-handle [id] (view/forget-handle! id))
(defn handle-revision [] (view/revision))

(defn handle-hook
  "Drive `view/on-handle` the way replicant does — build the hook, call it with
   a life-cycle map carrying `node` — and report [node handle] if it fired.
   nil when it did not, which is the case a tile rendered before its stream
   arrived has to survive."
  [id node]
  (let [seen (atom nil)
        hook (view/on-handle id (fn [n h] (reset! seen [n h])))]
    (hook {:replicant/node node})
    (when-some [pair @seen] (to-array pair))))

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