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
|
(ns frq.cells
"The cells the connect screen reads, and the constants beside them.
Moved out of `frq.state` so a screen can be shared: `frq.state` is 1,930
lines that reach `frq.irc` and `frq.av` and will not compile under
ClojureDart for a long time yet, but the cells themselves are atoms and
atoms are portable. `frq.state` re-defs every name here, so its own thousand
lines did not move and neither did anything reading `s/form-handle`.
The reader conditional is the whole trick. On jolt these are glimmer ratoms
— a component that derefs one re-renders when it changes, which is what the
desktop's reconciler is built on. Under ClojureDart they are ordinary atoms,
and `cljd.flutter`'s `:watch` does the same job from the other end. Neither
compiler sees the other's require.
jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has
`:clj` always on, which is why neither branch is spelled that way."
(:require #?@(:cljd []
:jolt [[glimmer.ratom :refer [atom reaction]]])))
(def default-host "irc.freeq.at")
(def default-port "6697")
;; screen: :connect | :chats | :chat | :discover | :settings
(defonce screen (atom :connect))
(defonce status (atom "Not connected"))
(defonce error (atom nil))
(defonce connecting? (atom false))
(defonce form-host (atom default-host))
(defonce form-port (atom default-port))
;; TLS is the default; untick it for a server's plain :6667 listener
(defonce form-tls? (atom true))
(defonce form-nick (atom "frq-guest"))
;; Bluesky sign-in. The app password reaches the user's own PDS and nothing
;; else: freeq is handed the session token it mints, and verifies that token by
;; asking the same PDS. It is never written to disk.
(defonce auth-mode (atom :guest)) ; :guest | :bluesky | :app-password
(defonce form-handle (atom ""))
(defonce form-app-password (atom ""))
(defonce session (atom nil)) ; a pds-session or a web-token one
;; The durable half of an OAuth sign-in. The web-token beside it is single-use,
;; so a reconnect mints a fresh one from this rather than replaying the old.
; ------------------------------------------------------------------ rooms
;; name -> {:name :messages [{:from :text}] :unread n :joined? bool}
(defonce channels (atom {}))
(defonce current (atom nil))
(defonce join-input (atom ""))
(defonce search (atom ""))
(defonce broker-token (atom nil))
(defonce login-url (atom nil)) ; shown while the browser is open
;; ------------------------------------------------------------------- chat
;; What the conversation screen reads. The compose bar's three companions —
;; a reply, an edit and an attachment — are apart from the draft rather than
;; written into it: a link pasted into the box is a line of unreadable text in
;; the middle of whatever the reader was typing.
(defonce draft (atom ""))
;; The message the draft is a rewrite of, as `{:channel :id}`, or nil when the
;; box is being used for something new. Only the id is kept: what is being
;; rewritten is in the box, and the line on screen is the thing it will replace.
(defonce editing (atom nil))
;; The message the draft is answering, as {:id :from :text}, or nil. Held whole
;; rather than as an id alone so the compose bar can say who is being answered
;; without going looking for them.
(defonce replying-to (atom nil))
;; The picture waiting to go out with the next line, or nil:
;;
;; {:path the copy on disk, which is what the preview paints
;; :url where freeq serves it, once the upload has landed
;; :status :uploading | :ready}
;;
;; Held apart from the draft rather than written into it. A link pasted into
;; the entry is a line of unreadable text in the middle of whatever the reader
;; was typing, and it puts their cursor somewhere they did not put it. The
;; picture is a picture until it is sent; the draft stays theirs.
;;
;; One at a time — a second paste replaces the first, which is what a reader
;; who pasted the wrong thing means by pasting the right one.
(defonce attachment (atom nil))
(defonce jump-tick (atom 0))
;; Whether the chat screen is showing who is in the channel. Off by default:
;; the panel costs the conversation a column, and the reader is here for the
;; conversation.
(defonce show-users? (atom false))
;; Whether the wide window is holding the chats list back, leaving the whole
;; row to the conversation. Only a wide window has anything to hide: below
;; `wide-width` the list and the conversation already take turns, and hiding
;; the list there would be hiding the only way to another room.
;;
;; Saved with the other settings rather than reset per launch: it is a choice
;; about how this screen is read, and a reader who wants the conversation
;; whole wants it whole again tomorrow.
(defonce hide-chat-list? (atom false))
;; Whether the conversation is sharing its room with the overview strip —
;; every channel's last lines in one list, under the one you are reading.
;; Saved for the same reason as the fold above it: it is a choice about how
;; this screen is laid out, and a layout a reader chose should be the one they
;; come back to.
(defonce overview? (atom false))
;; Whether the chat view is showing the newest line, and a counter the view
;; watches to be told to go back to it. A counter rather than a flag: a flag
;; would need clearing, and there is no frame in which to clear it.
(defonce at-present? (atom true))
;; --------------------------------------------------- picker, jump, window
(defonce emoji-group (atom nil))
;; What the picker is showing: the search box, and which group is on screen
;; when nothing has been typed. `nil` is the popular row it opens on.
(defonce emoji-search (atom ""))
;; The message a jump has just landed on. It outlives the scroll: arriving at a
;; screenful of messages says nothing about which one was asked for, so the one
;; that was answers for itself until the reader has had time to see it.
(defonce highlight (atom nil))
;; The message a "go to" is currently aiming at. Set for the frame that scrolls
;; to it and taken off again — a scroll target that stays set would pin the
;; view there and take scrolling away from the reader.
(defonce jump-to (atom nil))
;; The picture being looked at full size, or nil. Vidya's tree has no overlay,
;; so this is a screen of its own rather than a layer over the chat.
(defonce lightbox (atom nil)) ; {:path :url}
;; The room the reader was in when a line in the overview took them somewhere
;; else, or nil. The strip is the one place in the app that moves you without
;; you having asked to leave where you were — everything else is a room you
;; chose — so it is the one place that owes you the way back.
(defonce overview-return (atom nil))
;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
;; when it is closed. The picker is a panel over the compose bar rather than a
;; screen: what is being reacted to has to stay in sight.
(defonce reacting (atom nil))
;; The window's content height, polled beside the width and for the same
;; reason. What it is for is the pictures in the conversation: a preview sized
;; against the window is a picture on a laptop and a thumbnail on a phone,
;; where one fixed height is only ever right on one of them.
(defonce window-height (atom 0))
;; The window's content width in points, polled from the backend a few times a
;; second. The app is laid out for a phone-width window, and this is what lets
;; a wide one be more than a phone with margins: past `wide-width` the channel
;; list and the conversation are both on screen instead of taking turns.
(defonce window-width (atom 0))
;; ------------------------------------------------------------- derivation
(defonce ^:private derived-cells
;; One cell per question, kept for the session: a cell made afresh on every
;; render would add a watch to its source each time and never take it off.
(atom {}))
(defn derived-value
"The answer to one question about shared state, under `k`.
A message row that read `highlight` itself was re-rendered whenever the
highlight moved anywhere — every row in the backlog, for one jump. A
reaction is recomputed on each such change, which is a comparison, but it
wakes the rows that read it only when its answer changes.
None of which applies under ClojureDart, where Flutter rebuilds the screen
and diffs its own element tree: there is no subtree to wake, so the question
is simply asked. That is the whole of the difference, and it is why this is
a value rather than a cell — `@(derived k f)` could not be written once."
[k f]
#?(:cljd (f)
:jolt (deref (or (get @derived-cells k)
(let [cell (reaction (f))]
(swap! derived-cells assoc k cell)
cell)))))
;; The pill the pointer is resting on, or nil — `{:id msgid :emoji glyph}`.
;; One at a time, and named by the message as well as the glyph: the same emoji
;; is a pill under many messages, and only the one under the pointer carries a
;; card.
(defonce reaction-hover (atom nil))
;; Comings and goings, hidden or not. A quiet room reads better with them —
;; they are how you notice someone arriving — and a busy one drowns in them,
;; so it is the reader's call. Only other people's: your own "Joined #chan" is
;; the answer to something you just did.
(defonce hide-join-part? (atom false))
(def popular-channels
[["#general" "General discussion"]
["#test" "Test channel"]
["#freeq" "freeq development & support"]
["#dev" "Programming & technology"]
["#music" "Music recommendations"]
["#random" "Off-topic chat"]])
;; joined as soon as the server sends 001
;; Where the picker is looking, or nil when it is closed. A path, so the
;; browsing is just this cell moving.
(defonce image-picker (atom nil))
;; ------------------------------------------------------------------ profiles
;; Who is being looked at, or nil — `{:nick :actor}`, where `actor` is the DID
;; or handle, and nil for a guest.
(defonce profile-viewing (atom nil))
;; The face the pointer is resting on, the same shape. A card is painted for
;; this one alone rather than hung under every avatar in the column.
(defonce profile-hovering (atom nil))
;; Bumped when a profile fetch lands, so the screen re-renders without
;; watching the cache map itself.
(defonce profile-tick (atom 0))
;; ------------------------------------------------------------- enumeration
(defn all-cells
"Every cell above, for a backend that has to be told what to watch.
glimmer needs no such list: a component that derefs a ratom is subscribed to
it by the act of dereferencing, so the desktop hears about a change it never
declared an interest in. `cljd.flutter` works from the other end — `:watch`
names what a widget rebuilds for — and the phone kept that list in the
widget, where it drifted: People, Overview and hide join/part each flipped
the cell they were meant to flip and repainted nothing, because the cell was
not named there.
A function and not a `def`, which is the part ClojureDart makes you care
about: a `def` becomes a Dart top-level variable, those initialise on first
read, and a list nothing has read yet is still empty at the moment the
watches are installed. Calling it also forces every cell it names.
`tools/check-common.py` fails the build if this falls behind the
definitions, because a list kept by hand is only as good as what checks it."
[]
[screen status error connecting? form-host form-port form-tls? form-nick
auth-mode form-handle form-app-password session channels current
join-input search broker-token login-url draft editing replying-to
attachment jump-tick show-users? hide-chat-list? overview? at-present?
emoji-group emoji-search highlight jump-to lightbox overview-return
reacting window-height window-width reaction-hover hide-join-part?
image-picker profile-viewing profile-hovering profile-tick])
|