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 | ;; ported-from: src/app/ui.ts (the frame `build!` used to write as one HTML
;; string)
;;
;; THE FRAME (Phase 5d). What it replaces is the single biggest raw-HTML sink
;; this repo had: a 30-line template assigned to `root.innerHTML`, with eleven
;; translated strings pushed through the hand-rolled `esc`, followed by twelve
;; `addEventListener` calls and — for the rest of the file's life — a scatter of
;; `set! .-textContent`, `set! .-className` and `classList.toggle` that were the
;; only way any of it could change.
;;
;; SIX FOREIGN ROOTS LIVE INSIDE THIS TREE, and that is the interesting part.
;; `#msgs`, `#composer`, `#thread-msgs`, `#thread-composer`, `#roster` and
;; `#tiles` are each rendered by a DIFFERENT `replicant.dom/render` call
;; (app/msgs, app/roster, app/tiles), and so are `#sheet` (app/sheets) and the
;; imperative `#toasts`. A frame that re-renders over them would wipe the app.
;; It does not, and the reason is exactly the reconciler property Phase 5c had
;; to learn for the opposite purpose:
;;
;; `replicant.core/reconcile*` bails on its first clause when `unchanged?` —
;; `=` over the whole sexp, children included — so a container node written as
;; a constant literal is never entered, and its real DOM children are never
;; looked at. And when a container's OWN attributes change (the roster's
;; `hidden`, the callbar's, the thread panel's), `update-children` takes its
;; "both empty, we're done" branch, because both the old and the new hiccup
;; declare zero children — the DOM's actual children are not its business.
;;
;; So the rule this file has to keep is one line long: EVERY FOREIGN ROOT IS AN
;; EMPTY NODE, and stays empty in the hiccup no matter what the state says. Put
;; one child in `[:main#msgs]` and the message list is deleted on the next
;; frame. test/chrome-view.test.mjs drives the real reconciler against a model
;; DOM to prove both halves — including a deliberately WRONG variant that puts a
;; child in `#msgs` and must destroy what was there, because a probe that cannot
;; see the bug proves nothing.
;;
;; `#sheet` AND `#toasts` SIT OUTSIDE `.app` ON PURPOSE. `.app` carries
;; `transform: translateY(var(--vvt))` for the iOS keyboard, and a transform
;; makes an element the containing block for `position: fixed` descendants — so
;; an overlay rendered inside `.app` would be laid out against a 760px-wide
;; column instead of the viewport. The imperative code appended overlays to the
;; root for this reason; the hiccup says it out loud.
;;
;; NO `esc`, and no `.-innerHTML`: this is the file that takes the last of both
;; off Phase 5's budget (test/source-hygiene.test.mjs's seventh check).
(ns sueta.app.chrome
(:require [sueta.app.roster :as roster]
[sueta.config :as config]
[sueta.i18n :refer (t)]))
;; ---- the projection --------------------------------------------------------
;;
;; No JS boundary here at all: both inputs are already projections (app/roster's
;; and app/tiles'), which is the point — the pill, the status dot and the six
;; callbar buttons were the three surfaces those two projections drove by hand,
;; and now they are read from the same value the roster and the tiles are.
(defn project
"The frame, as one value.
`roster` is app/roster's projection and `call` is app/tiles'; `ctx` is what
only the shell knows: {:room-label :bell-on? :roster-open? :thread-open?
:viewer-url}.
`:viewer-url` is a blob URL from app/chat's CID registry — content, not a
handle (app/msgs' `:img` carries the same kind of string), so the full-screen
viewer is state like everything else here rather than a `replaceChildren`."
[roster call ctx]
{:room-label (:room-label ctx)
:peers-label (roster/peers-label roster)
:status (:status roster)
:bell-on? (true? (:bell-on? ctx))
:roster-open? (true? (:roster-open? ctx))
:thread-open? (true? (:thread-open? ctx))
:viewer-url (:viewer-url ctx)
:in-call? (true? (:in-call? call))
:any-call? (true? (:any-call? call))
:call-count (:count call)
:mic-on? (true? (:mic-on? call))
:cam-on? (true? (:cam-on? call))})
;; ---- the view --------------------------------------------------------------
(defn- header-node [{:keys [room-label peers-label status bell-on? any-call?]}]
[:header.top
[:button#rooms-btn.icon-btn {:title (t "top.rooms.title") :on {:click [:go-lobby]}} "☰"]
;; the three spans are separated by literal spaces in the markup this
;; replaces, and the gap is what stops the lock, the room name and the
;; version running together
[:div.brand
[:span.lock "🔒"] " "
[:span#room-label room-label] " "
[:span.ver (str "v" config/APP-VERSION)]]
[:button#peers-pill.peers-pill {:on {:click [:toggle-roster]}}
[:span#status-dot.dot {:class status}]
[:span#peer-count peers-label]]
[:button#call-btn.icon-btn {:class (when any-call? "hidden")
:title (t "top.call.title")
:on {:click [:join-call false]}}
"📞"]
[:button#bell-btn.icon-btn {:title (t "top.bell.title") :on {:click [:toggle-bell]}}
(if bell-on? "🔔" "🔕")]
[:button#invite-btn.icon-btn {:title (t "top.invite.title") :on {:click [:invite]}} "🔗"]])
(defn- callbar-node [{:keys [in-call? any-call? call-count mic-on? cam-on?]}]
[:div#callbar.callbar {:class (when-not any-call? "hidden")}
[:span#call-info.call-info (t "call.in-call" {"n" call-count})]
[:button#call-join.cb.join {:class (when in-call? "hidden")
:on {:click [:join-call false]}}
(t "call.join")]
[:button#call-join-video.cb.join {:class (when in-call? "hidden")
:on {:click [:join-call true]}}
(t "call.join-video")]
;; `.off` is only ever asked about while we are IN the call, and the glyph
;; follows it — a button nobody can see saying 🎤 is what the imperative
;; code left behind, and it is preserved rather than tidied
[:button#call-mic.cb {:class (cond-> []
(not in-call?) (conj "hidden")
(and in-call? (not mic-on?)) (conj "off"))
:on {:click [:toggle-mic]}}
(if (and in-call? (not mic-on?)) "🔇" "🎤")]
[:button#call-cam.cb {:class (cond-> []
(not in-call?) (conj "hidden")
(and in-call? (not cam-on?)) (conj "off"))
:on {:click [:toggle-cam]}}
"📷"]
[:button#call-leave.cb.danger {:class (when-not in-call? "hidden")
:on {:click [:leave-call]}}
(t "call.leave")]])
(defn view
"state -> the whole frame. Pure and total; pinned by
test/vectors/chrome-view.json.
A SEQ of three nodes, never a vector — see app/msgs — and the two after
`.app` are hosts rather than content, for the containing-block reason in the
ns docs."
[{:keys [roster-open? thread-open? viewer-url] :as state}]
(list
[:div.app
(header-node state)
;; FOREIGN ROOT (app/roster). Empty in the hiccup, always.
[:div#roster.roster {:class (when-not roster-open? "hidden")}]
(callbar-node state)
;; FOREIGN ROOT (app/tiles)
[:div#tiles.tiles {:class (when-not (:any-call? state) "hidden")}]
;; FOREIGN ROOTS (app/msgs `view` and `composer`)
[:main#msgs.msgs]
[:div#composer.composer]
[:aside#thread.thread {:class (when-not thread-open? "hidden")}
[:header.top
[:button#thread-close.icon-btn {:on {:click [:close-thread]}} "←"]
[:div.brand (t "thread.title")]]
;; FOREIGN ROOTS (app/msgs `thread-view` and `composer`)
[:main#thread-msgs.msgs]
[:div#thread-composer.composer]]
[:div#viewer.viewer {:class (when (nil? viewer-url) "hidden")
:on {:click [:close-viewer]}}
(when (some? viewer-url) [:img {:src viewer-url :alt (t "msg.image.alt")}])]]
;; FOREIGN ROOT (app/sheets) — outside `.app`; see the ns docs
[:div#sheet]
;; and the one container still filled by hand: a toast is three lines of
;; createElement with a timer, it carries no state anyone can act on, and it
;; is neither a raw-HTML sink nor an `esc` call site
[:div#toasts]))
|