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 | ;; ported-from: src/app/ui.ts (the header's peer roster)
;;
;; THE ROSTER (Phase 5c). What it replaces is `render-header!`'s `mk-row`: for
;; every peer, a `document.createElement`, an `innerHTML` template with a
;; hand-rolled `esc` around the one translated string in it, a
;; `querySelector(".roster-name")` to set the ONE field that could not go
;; through the template safely, an `addEventListener`, and an `appendChild` —
;; after wiping the container with `innerHTML = ""`. Two raw-HTML sinks and one
;; `esc` call, which is what this file takes off Phase 5's budget
;; (test/source-hygiene.test.mjs's seventh check).
;;
;; THIS IS THE SURFACE THAT READS TRUST, and that is the interesting part.
;; The badge beside a peer used to be built from a `verdict` JS object hanging
;; off that peer's roster entry, and ticking "verified" MUTATED it in place.
;; That worked for the roster and for nothing else: every already-rendered
;; message held its own copy of the verdict, so a message's badge stayed stale
;; until the room was reopened. Phase 5b fixed the message half by deriving
;; every badge from app/chat's one `_trust` map; this file fixes the roster half
;; the same way — `:verdicts`, a map from identity pub to {:verified?
;; :key-changed?}, is handed in, and the badge is derived at render. Nothing on
;; this screen mutates a verdict any more, so one update moves BOTH surfaces on
;; the next frame. test/roster-view.test.mjs pins that as a pair.
;;
;; NO `esc`, and no `.-innerHTML`: a peer's display name is attacker-supplied in
;; the only sense that matters (it comes off the wire, and app/chat clamps its
;; length, not its content), and hiccup text becomes a DOM text node.
(ns sueta.app.roster
(:require [sueta.app.msgs :as msgs]
[sueta.i18n :refer (t)]
[ardegazu.rooms.js :as j]))
;; ---- the projection --------------------------------------------------------
;;
;; The JS boundary. A PeerInfo is the mutable `{name?, state, id?, warned?}` bag
;; app/ui keeps in a `Map<peerId, …>`; an `id` is app/chat's PEER-IDENTITY,
;; `{pub, fp, verdict, hue?, glyph?}`. Below this line it is all Clojure data.
(defn- ^boolean live? [state]
(or (identical? "direct" state) (identical? "relayed" state)))
(defn- row-of [id p verdicts]
(let [pid (unchecked-get p "id")
pub (when (some? pid) (unchecked-get pid "pub"))
{:keys [verified? key-changed?]} (get verdicts pub)]
{:peer id
;; a peer who has not said its name yet is shown by the head of its PeerId
:name (j/nn (unchecked-get p "name") (.slice ^string id 0 6))
:state (unchecked-get p "state")
:fp-emoji (when (some? pid) (unchecked-get (unchecked-get pid "fp") "emoji"))
:verified? (true? verified?)
:key-changed? (true? key-changed?)}))
(defn project
"The peer map and this session's context, as the value the header renders.
`peers` is app/ui's `Map<peerId, PeerInfo>` — ITERATION ORDER IS INSERTION
ORDER, which is the order rows appeared in before and the order they appear
in now.
ctx: {:me-name :me-fp :signaling? :verdicts}. `:verdicts` is the map that
replaced the mutated verdict object; see the ns docs.
`:count` and `:status` are answered here rather than in the view because the
two nodes they drive (`#peer-count`, `#status-dot`) live in the header's
still-imperative markup — deriving them purely is what makes them testable
anyway."
[peers ctx]
(let [{:keys [me-name me-fp signaling? verdicts]} ctx
n (.-size ^js peers)
rows (mapv (fn [pair] (row-of (aget pair 0) (aget pair 1) verdicts))
(array-seq (js/Array.from (.entries ^js peers))))]
{:me {:name me-name :fp-emoji me-fp :online? (true? signaling?)}
:count n
;; the dot is green only when there is somebody to be connected TO: signal
;; or a live peer AND a non-empty room, else amber; no transport at all is
;; red regardless
:status (cond
(not (or signaling? (some #(live? (:state %)) rows))) "down"
(pos? n) "ok"
:else "idle")
:rows rows}))
;; ---- derived labels --------------------------------------------------------
(defn peers-label
"The pill beside the room name. Pure, and `:count` is peers OTHER than you —
which is why the plural says n+1. Destructured as `n`, never as `count`: a
binding named after a core var shadows it for the whole body."
[{n :count}]
(if (identical? 0 n) (t "peers.just-you") (t "peers.here" {"n" (inc n)})))
(defn- dot-class [state]
(cond (live? state) "ok" (identical? "connecting" state) "idle" :else "down"))
(defn- state-label [state]
(case state
"direct" (t "peer.state.direct")
"relayed" (t "peer.state.relay")
"connecting" (t "peer.state.connecting")
;; the fourth state is "disconnected"; anything else would have rendered
;; the literal string "undefined" through the template this replaces
(t "peer.state.disconnected")))
;; ---- the view --------------------------------------------------------------
(defn- row-node [rkey cls nm badge label action]
[:div.roster-row.clickable {:replicant/key rkey :on {:click action}}
[:span.dot {:class cls}]
[:span.roster-name nm]
[:span.roster-badge badge]
[:span.roster-state label]])
(defn view
"state -> the children of `#roster`. Pure and total; pinned by
test/vectors/roster-view.json. A SEQ, never a vector — see app/msgs."
[{:keys [me rows]}]
(let [{:keys [online? fp-emoji]} me]
(cons
(row-node "self"
(if online? "ok" "down")
(str (:name me) " " (t "roster.you"))
(if (some? fp-emoji) fp-emoji "")
(if online? (t "roster.online") (t "roster.reconnecting"))
[:identity-sheet])
(map (fn [{:keys [peer name state fp-emoji verified? key-changed?]}]
(row-node peer
(dot-class state)
name
(msgs/mark fp-emoji verified? key-changed?)
(state-label state)
[:peer-sheet peer]))
rows))))
|