chat / client / src / sueta / app / tiles.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
;; ported-from: src/app/ui.ts (the call tiles)
;;
;; THE CASE THAT BREAKS A NAIVE VDOM (Phase 5c), and the third file to follow
;; the shape app/lobby.cljs established. What it replaces is `update-call`'s
;; ~90-line hand-written reconciler: a `Map<id, {root, video}>` of live element
;; records, a `want` Map computed beside it, a delete pass, a create pass and a
;; mutate-in-place pass. That reconciler exists for exactly one reason — a
;; <video>'s `srcObject` is a live MediaStream, which is DOM IDENTITY rather
;; than content. Rebuild the element and the picture flickers; detach and
;; reattach it and the picture flickers.
;;
;; THE RULE THE RENDERER DECISION CAME WITH (dev/docs/CLJS.md): non-serialisable
;; handles never enter view data. So the streams live in app/view's registry
;; keyed by tile id, `project` never sees one, the view emits only the id, and a
;; life-cycle hook resolves it and assigns `srcObject` ONLY when it differs.
;; Three consequences, and the third is the one that is easy to miss:
;;
;;   * the call screen stays snapshot-testable — it is the one region you would
;;     otherwise have to give up on, and test/vectors/tiles-view.json is what
;;     that buys.
;;   * the vdom never diffs a MediaStream, so it can never decide to "update"
;;     one, and a keyed re-order moves the element rather than rebuilding it.
;;   * a stream ARRIVING is a network event, not a user event, so the view will
;;     not re-run by itself — and even a re-run would not re-fire the hook,
;;     because replicant skips a node whose hiccup is unchanged
;;     (`replicant.core/reconcile*`'s first clause). `:rev` — app/view's
;;     registry revision, ONE integer — is projected into the hook's data for
;;     exactly that reason: it is what makes the node different, which is what
;;     makes the hook fire, which is what puts the picture on the screen.
;;
;; THE HOOK IS DATA, NOT A CLOSURE. `[:tile-stream id rev self?]`, resolved by
;; app/ui's one dispatch through `view/on-handle`. An inline `(fn …)` here would
;; print as `#object[Function]` in the snapshot — the exact opacity replicant
;; was chosen over reagent to avoid, and on THIS screen it would hide the whole
;; stream-attachment mechanism from the vector.
;;
;; NO `esc` and no `.-innerHTML`: this screen never had either (it built nodes
;; and set textContent), so what it contributes to the Phase 5 XSS budget is
;; zero — the roster beside it is what moves those numbers.
(ns sueta.app.tiles
  (:require [sueta.app.msgs :as msgs]
            [ardegazu.rooms.js :as j]))

;; ---- the projection --------------------------------------------------------
;;
;; The JS boundary. Above it CallManager's self-state is a `{micOn, camOn}` JS
;; object and its members are a `Map<peerId, {name, audio, video, stream?}>`;
;; below it everything is Clojure data and `nil` is the only absence.

(defn- tile-of
  "One tile as a value. `stream` is deliberately absent: it is a handle, and
   handles live in app/view's registry."
  [id name muted? video? self? {:keys [accents peer-pubs big]}]
  (let [{:keys [hue glyph]} (get accents (get peer-pubs id))]
    {:id id
     :name name
     :muted? muted?
     :video? video?
     :self? self?
     :big? (contains? big id)
     :hue hue
     :glyph glyph}))

(defn project
  "CallManager's roster as the value the call chrome renders.

   `self-state` is its `{micOn, camOn}` JS object, or nil when we are not in the
   call; `members` its `Map<peerId, {name, audio, video, stream?}>`.

   ctx: {:me-name :accents :peer-pubs :big :rev}. `:peer-pubs` maps a TILE id to
   the identity pub whose chosen accent it wears — and it has no \"self\" entry,
   because the imperative code it replaces looked its own tile up in `_peers`
   too, where nothing ever puts one. Preserved rather than fixed: this step
   moves the renderer, not the behaviour.

   Everything the screen shows is a function of this, which is the whole claim
   test/vectors/tiles-view.json rests on."
  [self-state members ctx]
  (let [in-call? (some? self-state)
        n (.-size ^js members)
        mine (when in-call?
               (tile-of "self" (:me-name ctx)
                        (not (j/truthy? (unchecked-get self-state "micOn")))
                        (j/truthy? (unchecked-get self-state "camOn"))
                        true ctx))
        theirs (mapv (fn [pair]
                       (let [id (aget pair 0)
                             m (aget pair 1)]
                         (tile-of id (unchecked-get m "name")
                                  (not (j/truthy? (unchecked-get m "audio")))
                                  (j/truthy? (unchecked-get m "video"))
                                  false ctx)))
                     (array-seq (js/Array.from (.entries ^js members))))]
    {:in-call? in-call?
     ;; the callbar and the tile strip are shown whenever ANYBODY is in the
     ;; call, which is what makes a call joinable rather than only startable
     :any-call? (or in-call? (pos? n))
     :count (+ n (if in-call? 1 0))
     :mic-on? (and in-call? (j/truthy? (unchecked-get self-state "micOn")))
     :cam-on? (and in-call? (j/truthy? (unchecked-get self-state "camOn")))
     ;; app/view's registry revision. ONE integer, and the reason a stream
     ;; arriving reaches the screen — see the ns docs.
     :rev (:rev ctx)
     :tiles (into (if (some? mine) [mine] []) theirs)}))

;; ---- the view --------------------------------------------------------------

(defn- avatar-node
  "Shown by CSS only while the tile is `.novideo`. Chosen glyph/hue if this
   peer's identity announced one, else the initial and the hash-derived colour
   app/msgs' avatars use — one hash, so a peer's tile and their messages agree."
  [{:keys [id name hue glyph]}]
  [:div.tile-avatar {:style {:background (if (some? hue)
                                           (str "hsl(" hue " 45% 40%)")
                                           (msgs/color-for id))}}
   (if (some? glyph)
     glyph
     (.toUpperCase ^string (if-some [c (first name)] (str c) "?")))])

(defn- tile-node [{:keys [id name muted? video? self? big?] :as tile} rev]
  [:div.tile {:replicant/key id
              :class (cond-> []
                       (not video?) (conj "novideo")
                       big? (conj "big"))
              :on {:click [:tile-zoom id]}}
   ;; `muted` and `mirror` are facts about whose camera this is, and whose it
   ;; is never changes for a given key — but the property is set on the node in
   ;; the hook as well as here, because a self tile that is not muted is an
   ;; audio feedback loop and the attribute alone is not how browsers want to
   ;; be told (`.muted = true` is the autoplay-policy path).
   [:video (cond-> {:autoplay "autoplay"
                    :playsinline "playsinline"
                    :replicant/on-render [:tile-stream id rev self?]}
             self? (assoc :muted "muted" :class "mirror"))]
   (avatar-node tile)
   [:div.tile-label (str (if muted? "🔇 " "") name)]])

(defn view
  "state -> the children of `#tiles`. Pure and total; pinned by
   test/vectors/tiles-view.json.

   A SEQ of nodes, never a vector: `replicant.dom/render` takes a single hiccup
   node or a list of them, and a vector whose head is not a keyword is neither."
  [{:keys [tiles rev]}]
  (map #(tile-node % rev) tiles))

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