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 | ;; ported-from: src/app/ui.ts (the four modal overlays)
;;
;; THE OVERLAYS (Phase 5d), and the last raw-HTML in this repo. Four sheets —
;; the first-run name prompt, a peer's fingerprint card, your own identity card
;; and the manual-copy fallback — each built by assigning a string to
;; `overlay.innerHTML`, each interpolating translated text through the
;; hand-rolled `esc`, and each then re-querying its own markup by id to attach
;; listeners and to set the two or three fields the template could not carry
;; safely. `show-identity-sheet!` alone was 166 lines of it.
;;
;; A STACK, NOT A SLOT. `copy!`'s fallback can open ON TOP of the identity sheet
;; (that is the only way to reach it: "copy key" → clipboard write refused), and
;; closing it has to give the identity card back. Appending two elements to the
;; root did that for free; one state slot would not, so the runtime holds a
;; vector and `view` renders all of it. Depth is never more than two.
;;
;; THREE THINGS ARE DELIBERATELY *NOT* IN THE STATE, and the rule they follow is
;; the handle-registry rule from app/view one step further out: the identity
;; SEED, and the manual-copy text (which is the seed half the time). They are
;; content, not handles, so nothing would break — but this state is the thing a
;; snapshot prints, a golden vector commits and a future error report may carry,
;; and a private key has no business in any of them. Each is written onto its
;; input by a mount hook (`[:fill-seed]`, `[:fill-copy]`), which is DATA like
;; every other hook here.
;;
;; The `:rev` hazard Phase 5c found — a hook whose effect depends on something
;; outside the hiccup never re-fires, because `reconcile*` bails on an unchanged
;; node — applies to exactly those two hooks, and the answer here is structural
;; rather than a revision counter: neither sheet can be RE-OPENED while it is
;; still open (opening one requires clicking something the overlay covers), so
;; every open is a mount. test/sheets-view.test.mjs asks the real reconciler to
;; confirm it: the fill hook fires once per open, is silent when the sheet
;; re-renders for `reveal` or `armed`, and fires again after a close-and-reopen.
;;
;; NO `esc`, and no `.-innerHTML`.
(ns sueta.app.sheets
(:require [sueta.i18n :refer (t)]
[ardegazu.rooms.js :as j]))
(def ^:private ARM-MS
"Two-tap confirm for the two destructive identity actions, instead of a native
`confirm()` dialog. The first tap arms, a second within this window commits,
and otherwise it disarms itself."
4000)
(def ^:private SEED-RE
"43 base64url characters — a raw Ed25519 seed. Anchored, and no `g` flag: a
global regex carries `lastIndex` between `.test` calls."
#"^[A-Za-z0-9_-]{43}$")
;; ---- the projections -------------------------------------------------------
;;
;; The JS boundary. `p` is app/ui's PeerInfo bag, `me` is room.cljs's
;; SelfIdentityInfo `{seed, fp, pub, profile, conflict}`; below this line it is
;; all Clojure data and `nil` is the only absence.
(defn ask-name
"The first-run name prompt. `current` prefills the field; it is nil at the one
call site, and kept because the class method takes it."
[current]
{:kind :ask-name :current (j/nn current "")})
(defn peer-sheet
"A remote peer's card: their fingerprint and the verification toggle.
`verdict` is the SESSION's `{:verified? :key-changed?}` for that identity —
app/ui's `_verdicts` map, not a JS object hanging off the peer record. Phase
5c stopped mutating that one, so this sheet reads what the roster and every
message badge read, and ticking the box here moves all three."
[peer-id p verdict]
(let [id (unchecked-get p "id")
fp (when (some? id) (unchecked-get id "fp"))]
{:kind :peer
:peer peer-id
;; resolved HERE rather than in the view, because the toast the verify
;; button raises has to say the same name the card is titled with
:name (j/nn (unchecked-get p "name") (t "peer.unknown"))
:id? (some? id)
:pub (when (some? id) (unchecked-get id "pub"))
:fp-emoji (when (some? fp) (unchecked-get fp "emoji"))
:fp-hex (when (some? fp) (unchecked-get fp "hex"))
:verified? (true? (:verified? verdict))
:key-changed? (true? (:key-changed? verdict))}))
(defn identity-sheet
"Your own card: fingerprint, rename, password-manager save, import, and — when
the suite bridge reports a different key on ardegazu.ro — the conflict
chooser.
`:conf-fp` is nil while `Identity.fromSeed` is still resolving the other
key's fingerprint and `:conf-failed?` says it rejected; the imperative code
set that node's text, then removed the node, from inside two promise
callbacks. It is state now, so the view stays total."
[me me-name]
(let [fp (when (some? me) (unchecked-get me "fp"))]
{:kind :identity
:me? (some? me)
:me-name me-name
:fp-emoji (when (some? fp) (unchecked-get fp "emoji"))
:fp-hex (when (some? fp) (unchecked-get fp "hex"))
:conflict? (and (some? me) (some? (unchecked-get me "conflict")))
:conf-fp nil
:conf-failed? false
;; a SET, because the two armed buttons are independent: arming "use suite
;; identity" must not disarm "use this identity", which is what one
;; `:armed` field would have done
:armed #{}
:reveal? false}))
(defn copy-sheet
"The manual-copy fallback, shown when `navigator.clipboard.writeText` is
refused. The text it exists to show is NOT in here — see the ns docs."
[]
{:kind :copy})
;; ---- the pure core ---------------------------------------------------------
(defn ^boolean seed? [s] (.test SEED-RE s))
(defn step
"One overlay's whole behaviour. Pure: (sheet, event) -> [sheet', effects],
effects are data, and a nil sheet means this overlay is over and the runtime
pops it off the stack."
[state [op a b]]
(case op
:close [nil []]
;; a blank name is not a name: the field is focused again and the prompt
;; stays, which is the one reason this overlay has no click-outside close
:name-ok (let [v (.trim ^string (j/nn a ""))]
(if (seq v)
[nil [[:name v]]]
[state [[:focus-name]]]))
;; the sheet closes FIRST — `[nil …]` — so the runtime's state-then-effects
;; order puts the overlay's removal ahead of the trust write, the two badge
;; refreshes and the toast
:verify [nil [[:verify (:pub state) (:name state) (not (:verified? state))]]]
:adopt (if (contains? (:armed state) :adopt)
[state [[:adopt]]]
[(update state :armed conj :adopt) [[:disarm-after ARM-MS :adopt]]])
;; guarded: a stale timer must not reach into whatever sheet is on top now
:disarm [(cond-> state (contains? state :armed) (update :armed disj a)) []]
:keep [(assoc state :conflict? false)
[[:keep-local] [:toast (t "toast.kept-local")]]]
:conf-fp [(assoc state :conf-fp a) []]
:conf-failed [(assoc state :conf-failed? true) []]
;; `:me-name` MOVES with the rename. The imperative code compared the field
;; against `chat/my-name` live, so renaming to X and back to the original
;; renamed twice; a `:me-name` captured when the sheet opened would have
;; swallowed the second one. It is also what the credential form's username
;; is derived from, so both follow.
:rename (let [v (.trim ^string (j/nn a ""))]
(if (or (empty? v) (identical? v (:me-name state)))
[state []]
[(assoc state :me-name v)
[[:rename v] [:toast (t "toast.renamed" {"name" v})]]]))
:reveal [(update state :reveal? not) []]
:copy-key [state [[:copy-key]]]
:save [state [[:save-credential]]]
;; `b` is "that seed is the one already in use", answered by the runtime so
;; that the seed itself never has to enter this function or its state
:import (cond
(true? b) [state [[:toast (t "toast.already-you")]]]
(not (seed? a)) [state [[:toast (t "toast.bad-key")]]]
(not (contains? (:armed state) :import))
[(update state :armed conj :import) [[:disarm-after ARM-MS :import]]]
:else [state [[:import a]]])
[state []]))
;; ---- the view --------------------------------------------------------------
(defn- ask-name-node [{:keys [current]}]
;; NO click-outside close: there is no way past this one but to say a name
[:div.overlay {:replicant/key "ask-name"}
[:div.modal
[:h2 "sueta"]
[:p (t "join.sub")]
[:input#name-in {:type "text"
:maxlength "32"
;; replicant's `:value` sets the PROPERTY, exactly as the
;; imperative `set! .-value` did, and it is written only
;; when it changes — so the caret is safe (app/lobby's
;; rename row is the precedent)
:value current
:placeholder (t "join.name.ph")
:autocomplete "nickname"
:enterkeyhint "done"
:on {:keydown [:name-key]}
:replicant/on-mount [:focus-soon]}]
[:button#name-ok.primary {:on {:click [:name-ok]}} (t "join.btn")]]])
(defn- peer-node [{:keys [name id? fp-emoji fp-hex verified? key-changed?]}]
[:div.overlay {:replicant/key "peer" :on {:click [:overlay-click]}}
[:div.modal.id-sheet
;; the name comes off the wire and app/chat clamps its LENGTH, not its
;; content. It was the one field the template could not carry, so it was
;; written with textContent afterwards; hiccup text IS a text node.
[:h2 name]
(when id? [:div.fp-emoji fp-emoji])
(when (and id? key-changed?) [:p.warn (t "peer.key-changed")])
(when id? [:p.hint (t "peer.verify-hint")])
(when id? [:p.fp-hex fp-hex])
(when id?
[:button#verify-btn.primary {:on {:click [:verify]}}
(if verified? (t "peer.verified-btn") (t "peer.verify-btn"))])
(when-not id? [:p.hint (t "peer.no-identity")])
[:button#sheet-close.ghost {:on {:click [:close]}} (t "sheet.close")]]])
(defn- conflict-node [{:keys [conf-fp conf-failed? armed]}]
(let [armed? (contains? armed :adopt)]
[:div.id-conflict
[:p.warn (t "id.conflict.warn")]
(when-not conf-failed? [:div#conf-fp.fp-emoji (if (some? conf-fp) conf-fp "…")])
[:p.hint (t "id.conflict.hint")]
[:div.id-row
[:button#id-adopt.primary {:class (when armed? "armed") :on {:click [:adopt]}}
(if armed? (t "id.adopt.arm") (t "id.adopt"))]
[:button#id-keep.ghost {:on {:click [:keep]}} (t "id.keep")]]]))
(defn- identity-node [{:keys [me? me-name fp-emoji fp-hex conflict? armed reveal?] :as state}]
[:div.overlay {:replicant/key "identity" :on {:click [:overlay-click]}}
[:div.modal.id-sheet
[:h2 (t "id.title")]
(when conflict? (conflict-node state))
[:form#id-rename.new-room-form {:on {:submit [:rename]}}
[:input {:type "text"
:maxlength "32"
:autocomplete "nickname"
:enterkeyhint "done"
:placeholder (t "id.rename.ph")
:value me-name}]
[:button.ghost {:type "submit"} (t "id.rename")]]
(when me? [:div.fp-emoji fp-emoji])
(when me? [:p.hint (t "id.hint")])
(when me? [:p.fp-hex fp-hex])
(when me?
;; `method="dialog"` and a preventDefault in the runtime: a form the
;; browser could submit would put an Ed25519 seed on the network
[:form#id-save {:method "dialog" :on {:submit [:save]}}
[:input {:type "text"
:name "username"
:autocomplete "username"
:spellcheck "false"
:value (str "sueta · " me-name)}]
[:input {:type (if reveal? "text" "password")
:name "password"
:autocomplete "new-password"
;; the seed is filled onto the node, never through the state
:replicant/on-mount [:fill-seed]}]
[:button.primary {:type "submit"} (t "id.save")]])
(when me?
[:div.id-row
[:button#id-copy.ghost {:on {:click [:copy-key]}} (t "id.copy")]
[:button#id-reveal.ghost {:on {:click [:reveal]}} (t "id.reveal")]])
(when me?
[:form#id-import {:method "dialog" :on {:submit [:import]}}
[:input {:type "password"
:name "password"
:autocomplete "current-password"
:placeholder (t "id.import.ph")}]
[:button.ghost {:class (when (contains? armed :import) "armed") :type "submit"}
(if (contains? armed :import) (t "id.import.arm") (t "id.import"))]])
(when-not me? [:p.hint (t "id.no-ed25519")])
[:button#sheet-close.ghost {:on {:click [:close]}} (t "sheet.close")]]])
(defn- copy-node [_]
[:div.overlay {:replicant/key "copy" :on {:click [:overlay-click]}}
[:div.modal.id-sheet
[:p.hint (t "copy.manual")]
[:input.copy-out {:type "text"
:readonly "readonly"
:on {:focus [:select-copy]}
;; ONE mount hook for both jobs: fill the field and focus
;; it a beat later. A node carries one `:replicant/on-mount`.
:replicant/on-mount [:fill-copy]}]
[:button#sheet-close.ghost {:on {:click [:close]}} (t "sheet.close")]]])
(defn view
"The overlay STACK (outermost first) -> the children of `#sheet`. Pure and
total; pinned by test/vectors/sheets-view.json.
A SEQ, never a vector — see app/msgs — and an empty one when nothing is
open, which is what makes a dismissed overlay GONE rather than hidden: there
is no node left for a stray focus or a click to find."
[sheets]
(map (fn [{:keys [kind] :as sheet}]
(case kind
:ask-name (ask-name-node sheet)
:peer (peer-node sheet)
:identity (identity-node sheet)
:copy (copy-node sheet)))
sheets))
|