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
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
|
(ns frq.main
"The Flutter entry point: what `frq.cosmic` is to the jolt half.
Two jobs, and the first one is why `frq.io.dart` cannot install itself the
way `frq.io.jolt` does: the app's storage directory arrives from
path_provider as a Future, so `main` is async, awaits it, and installs the
host before a widget is built. Everything under ../common asks `frq.io` for
the filesystem, so nothing shared can run until that has happened.
`ensureInitialized` first, and not as a formality: path_provider is a
platform channel, and a channel used before the binding exists throws.
The screens themselves are not written. `frq.app` is 1,834 lines of hiccup
over glimmer's widget tags, and glimmer is not here — Flutter brings its own
reconciler, so those screens are a rewrite against cljd.flutter rather than a
port. What this paints is the proof the shared half is alive under a second
compiler: the clock and the saved session, read through exactly the
namespaces the desktop reads them through."
(:require ["dart:async" :as async]
["dart:io" :as dio]
["package:flutter/material.dart" :as m]
["package:path_provider/path_provider.dart" :as pp]
[cljd.flutter :as f]
[frq.hiccup :as h]
[frq.theme :as t]
[frq.io.dart :as host]
;; The seam, not the implementation above: `open-url!` is asked for
;; the same way the shared half asks for it.
[frq.io :as fio]
[frq.crypto.dart :as crypto-dart]
[frq.net.dart :as net]
[frq.atproto.dart :as atproto]
[frq.clock :as clock]
[frq.media.core :as media-core]
[frq.media.dart :as media]
[frq.store :as store]
[frq.actions :as actions]
[frq.cells :as cells]
[frq.screens.connect :as connect]
[frq.screens.chats :as chats]
[frq.screens.chat :as chat]
[frq.screens.settings :as settings]
[frq.screens.app :as screens]
[frq.rooms :as rooms]
[frq.members :as members]
[frq.reactions :as reactions]
[frq.edits :as edits]
[frq.profile :as profile]
[frq.avatars.dart :as avatars]
["package:image_picker/image_picker.dart" :as picker]
[frq.upload.dart :as upload]
[frq.irc.mutate :as mutate]
[frq.oauth.core :as oauth]
[frq.oauth.dart :as oauth-dart]
[frq.irc.parse :as irc]
[frq.irc.handshake :as handshake]))
(defonce ^:private lines (atom []))
(defonce ^:private conn (atom nil))
(defonce ^:private last-line (atom nil))
;; Ticked whenever any cell changes, so one `:watch` covers all of them.
;;
;; What this replaced was a list of cells named by hand in the widget below,
;; and everything not on it was a control that flipped its cell and repainted
;; nothing — People, Overview and hide join/part all did.
;;
;; Up here rather than beside `watch-cells!` because it is no longer only
;; cells that tick it: an avatar landing is a change to nothing under
;; `frq.cells` — the URL is in `frq.avatars.dart` — and `room!`, which is the
;; one that asks for it, is defined below this and above that.
(defonce ^:private repaint (atom 0))
(defn- bump!
"Repaint whatever is on screen. What a background fetch calls when its answer
has arrived and the rows that read it do not know."
[]
(swap! repaint inc)
nil)
(defn- note-window-size!
"Tell `frq.cells` how big the window is, from the build that knows.
The shared screens size a picture against it — `preview-height` is four
fifths of the window and falls back to a fixed 260 while nobody has said —
so without this a phone drew every screenshot as the same stamp a terminal
gets. The desktop sets these from libcosmic's resize event; Flutter's
equivalent is MediaQuery, which is only readable from a build.
After the frame, and only when it changed. A cell written during a build is
a setState during a build, which Flutter refuses; and every cell here is
watched, so writing the same number back on every frame would be an endless
rebuild rather than a wasted one."
[^m/Size size]
(let [w (long (.-width size))
h (long (.-height size))]
(when (or (not= w @cells/window-width) (not= h @cells/window-height))
(.addPostFrameCallback
(.-instance m/WidgetsBinding)
(fn [_]
(reset! cells/window-width w)
(reset! cells/window-height h)
nil)))))
(defn- images-in
"The picture links in a message, fetched as they are noticed.
Both at once on purpose: the list is what the row draws and the fetch is
what turns a link into a path, and a line whose images were recorded and
never fetched is a row that waits for ever. `frq.media.dart` is the once-only
half — this is called again for every replayed line on every reconnect."
[text]
(let [urls (media-core/image-urls text)]
(doseq [url urls] (media/fetch! url bump!))
urls))
(defn- edit-message!
"Rewrite a message in place, and say so.
`frq.edits` is the fold and what it answers; the cell and the picture links
are this half's. The links are read again off the new text because a
revision can add one or take one away, and a row drawing the picture its
line no longer mentions is the shape that gave it away. The desktop's
`frq.state/edit-message!` is the same function over its own atom."
[room msgid from text]
(let [out (edits/apply-edit @cells/channels room msgid from text
#(assoc % :images (images-in text)))]
(reset! cells/channels (:channels out))
(:result out)))
;; ------------------------------------------------------------------- rooms
;;
;; `rooms.edn` is the authority for what rooms there are, and until now only
;; the desktop kept one: this half started every launch with an empty list,
;; adopted whatever the server announced, and asked for `#test` on top. Which
;; looked like the room list forgetting itself between runs, because it was.
;;
;; The file and its format are `frq.store`, which is already shared — the port
;; is the half that was missing here: read it at startup, write it when the
;; list changes, and ask to be in what it says at 001.
(def ^:private auto-join
"The room a client with nothing saved lands in, as on the desktop."
"#test")
;; Whether `rooms.edn` is the authority yet.
;;
;; It is not, the first time this version runs: freeq re-joins an
;; authenticated user's channels at registration, so on connect the server
;; announces every room it has you in — and a client that parted everything
;; not already in its file would walk out of all of them before the file had
;; ever been told they existed. So the first connect adopts what the server
;; says and writes it down, and every connect after that is the strict one.
(defonce ^:private room-list-owned? (atom false))
;; And whether this session is the adopting one, decided at 001.
(defonce ^:private adopting-rooms? (atom false))
(defn- save-prefs!
"Keep the one flag that has to outlive the run: whether this client has
taken the room list over from the server. Merged into whatever else
`prefs.edn` holds — the display toggles are the desktop's to write, and a
whole-map write from here would drop them."
[]
(store/save-prefs! (assoc (store/load-prefs)
:room-list-owned? @room-list-owned?))
nil)
(defn- restore-prefs! []
(reset! room-list-owned? (boolean (:room-list-owned? (store/load-prefs))))
nil)
(def ^:private remember-rooms!
"Write the room list out, most recently used first. `frq.rooms` — the same
records the desktop writes, throttled the same way, so a marker one half
moved is one the other reads back."
rooms/remember-rooms!)
(def ^:private restore-rooms!
"Bring back the rooms of earlier runs, in the order they were last used,
each with the marker saying how much of it had been read. `frq.rooms` again:
the buffers this makes are the ones `recount` counts against."
rooms/restore-channels!)
(defn- ask-to-join!
"Send the JOIN, once. `:joining?` as well as `:joined?`, because the echo
takes a round trip: a second JOIN sent in the meantime is what makes the
server replay nothing, and with the saved list asked for at 001 and the last
room opened straight after, that window is every launch."
[name]
(when-let [c @conn]
(when (and (.startsWith (str name) "#")
(let [b (get @cells/channels name)]
(and (not (:joined? b)) (not (:joining? b)))))
(swap! cells/channels assoc-in [name :joining?] true)
(net/send-line! c (str "JOIN " name))))
nil)
(defn- open-room!
"Show a buffer, joining it on the way. A row can outlive the membership
behind it — a disconnect drops every channel and the buffer stays — so
opening one is a request to be in it.
Ensuring the buffer *before* the JOIN is what keeps the strict rule in the
JOIN handler from throwing the room straight back out: a channel we asked
for is one the list already holds by the time the echo lands."
[name]
(reset! cells/current name)
;; An edit belongs to a line in the buffer being left: carried across, the
;; next Send would rewrite a message nobody in this room can see. The box
;; empties with it, because what is in it is a copy of that line —
;; `frq.state/open-channel!` does this too.
(when (and @cells/editing (not= name (:channel @cells/editing)))
(reset! cells/editing nil)
(reset! cells/draft ""))
;; Opening a room is reading it: the marker goes to the newest line this
;; buffer holds, which is what stops the backlog that follows from arriving
;; unread all over again. The tick is `frq.rooms`', so a room opened this
;; run sorts above every one `restore-rooms!` counted in.
(swap! cells/channels
#(-> (rooms/ensure-channel % name)
(update name rooms/mark-read)
(assoc-in [name :accessed] (swap! rooms/access-tick inc))))
(reset! cells/screen :chat)
;; Worth a write of its own: the order the list is read in is the order
;; rooms were last opened, and this is the moment it changes.
(remember-rooms! true)
(ask-to-join! name)
nil)
(defn- join-saved-rooms!
"Ask to be in every channel the file says we are in.
The server re-joins an authenticated user's channels itself and gets it
wrong in both directions — it forgets rooms and announces ones that are not
ours. This is the half that answers the forgetting. A JOIN for a channel the
server has already put us in is answered with the membership we already
have, so asking twice costs nothing.
DMs are not asked for: there is nothing to join in a conversation with a
person, the buffer is the whole of it."
[]
(when @conn
(doseq [[name _] @cells/channels] (ask-to-join! name))
nil))
(defn- forget-memberships!
"The buffers survive a lost connection, the memberships do not.
Leaving `:joined?` set across a drop is what made a reconnect ask for
nothing: `ask-to-join!` reads it, every room still claimed to be joined, and
the list came back full of rooms we were no longer in. The users go with it —
the roll is the server's and it will send a fresh one with the NAMES that
follow the re-JOIN."
[]
(swap! cells/channels
(fn [m]
(reduce-kv (fn [acc k v]
(assoc acc k (assoc v :joined? false :joining? false
:users {})))
{} m)))
nil)
(def ^:private history-limit
"How many lines of backlog to ask a room for, as `frq.state` asks for them."
100)
(defn- room!
"The little of `frq.state/apply-msg!` the conversation list needs: the two
messages that make a room appear and give it a last line, and everything
that says who is in it.
The membership half is `frq.members`, unchanged from what the desktop folds
— the server is the one deciding who is in a room, and it tells both halves
in the same words."
[m]
(let [cmd (str (:command m))
params (vec (:params m))
who (irc/nick-of (:prefix m))
me? (= who (str @cells/form-nick))
;; The shared one, so a buffer born here carries the same read marker
;; the desktop's does — `frq.rooms/recount` has nothing to count
;; against without it.
ensure rooms/ensure-channel]
(cond
(= "JOIN" cmd)
(let [name (first params)]
(if me?
;; Ours if the file says so — restored at startup, or asked for
;; since. Anything else is the server putting us somewhere we did
;; not ask to be, which it does: it announces memberships that are
;; not real, and adding them is how a list nobody chose fills up.
;;
;; So we leave again, unless this is the session that is still
;; adopting — on the first connect the file has not been told
;; anything yet, and parting then would be leaving every room we are
;; actually in.
(if (and (not (contains? @cells/channels name))
(not @adopting-rooms?))
(when-let [c @conn] (net/send-line! c (str "PART " name)))
(do (swap! cells/channels
#(-> (ensure % name)
(members/add-user name who)
(update name merge {:joined? true
:joining? false})))
;; Adopted or asked for, it is ours now and the file should
;; say so before the next connect judges it.
(remember-rooms! true)))
;; Somebody else arriving in a room we do not hold is not a reason
;; to start holding it: `add-user` builds the buffer it is given,
;; which would put a refused room straight back in the list.
(when (contains? @cells/channels name)
(swap! cells/channels members/add-user name who))))
;; 353 is the roll, in as many lines as it takes; 366 ends it.
(= "353" cmd)
(let [name (nth params 2 nil)]
(when name
(swap! cells/channels #(members/with-names (ensure % name) name (last params)))))
(= "366" cmd)
(let [name (nth params 1 nil)]
(when name
(swap! cells/channels members/names-done name)
;; End of NAMES, and the only moment this client knows a room is
;; fully arrived. freeq re-joins an authenticated user's channels at
;; registration and leaves the backlog for the client to ask for, so
;; a room that reaches here with an empty buffer has no history
;; coming unless we ask — which is why nothing but new lines ever
;; appeared here. Same request `frq.state` makes on 366, and the
;; replayed lines come back as ordinary PRIVMSGs the branch below
;; already folds.
(when (and @conn (empty? (get-in @cells/channels [name :messages])))
(net/send-line! @conn
(str "CHATHISTORY LATEST " name " * " history-limit)))))
(= "PART" cmd)
(swap! cells/channels members/remove-user (first params) who)
(= "KICK" cmd)
(swap! cells/channels members/remove-user (first params) (nth params 1 nil))
(= "QUIT" cmd)
(swap! cells/channels members/remove-everywhere who)
(= "NICK" cmd)
(do (swap! cells/channels members/rename-user who (last params))
;; Our own rename is the server settling what we are called — it
;; hands a guest a name of its choosing, so this is the usual way
;; the nick on screen becomes the real one.
(when me? (reset! cells/form-nick (last params))))
(= "MODE" cmd)
(swap! cells/channels members/with-mode
(first params) (nth params 1 "") (drop 2 params))
(= "PRIVMSG" cmd)
(let [tags (:tags m)
target (first params)
text (last params)
name (if (rooms/dm? target) who target)
edit-of (or (irc/tag-value tags "+draft/edit")
(irc/tag-value tags "+edit"))
;; When the server says the line was written, or now when it says
;; nothing. The marker is a time as well as an id — a replayed
;; backlog is older than what has been read and has to be able to
;; say so — and the day separators in `frq.screens.chat` have been
;; waiting on this too.
at (or (clock/parse-time-tag tags) (clock/now-ms))
;; Reading a room is marking it read: a line arriving in the room
;; on screen moves the marker past itself, and one arriving
;; anywhere else is counted against the marker there.
viewing? (and (= :chat @cells/screen) (= name @cells/current))
settle (fn [m] (update m name (if viewing?
rooms/mark-read
rooms/recount)))]
;; The face, asked for as the line arrives rather than when the row is
;; built: a row is built during a Flutter frame, and a fetch started
;; there would be state changed mid-build. `frq.avatars.dart` asks the
;; directory once per person however many lines they write, and
;; `bump!` is what wakes the rows already on screen when it answers.
(avatars/fetch! (profile/actor (:account m) who) bump!)
(if edit-of
;; A revision is not a new line: it replaces the one it names, under
;; that line's own id and never the revision's own wire msgid, which
;; nothing else refers to. One older than the backlog we hold has
;; nothing here to replace, and is shown as itself rather than lost.
(when (= :absent (edit-message! name edit-of who text))
(swap! cells/channels
#(-> (rooms/ensure-channel % name)
(update-in [name :messages] conj
{:from who
:text text
:images (images-in text)
:did (:account m)
:id edit-of
:at at
:edited? true})
settle))))
(swap! cells/channels
#(-> (rooms/ensure-channel % name)
(update-in [name :messages] conj
;; The msgid is what everything after a message
;; names it by — a reply points at one, a reaction
;; lands on one. Without it a line is on screen and
;; nothing can be said about it.
{:from who
:text text
;; Any .png link on the line, and the fetch that
;; makes one a picture rather than a link.
:images (images-in text)
:did (:account m)
:at at
;; Who to look a profile up by: the DID the
;; server put on the line, or the nick when that
;; is handle-shaped. A guest has neither and gets
;; no lookup, which is the honest answer.
:actor (profile/actor (:account m) who)
:id (irc/tag-value tags "msgid")
;; The server canonicalises +draft/reply to
;; +reply; a client that sent the draft name may
;; still reach us before it does.
:reply-to (or (irc/tag-value tags "+reply")
(irc/tag-value tags "+draft/reply"))
;; What is already on it, so a reconnect does not
;; start every message empty.
;; What the server says about a line it has
;; already collapsed: replay sends the current
;; text and no `+draft/edit` to hint that it is
;; not the original. This tag is the only trace.
:edited? (= "1" (irc/tag-value tags
"+freeq.at/edited"))
:reactions (reactions/parse-tally
(irc/tag-value tags
"+freeq.at/reactions"))})
(assoc-in [name :last-activity] at)
settle)))
;; A message that is only tags. A reaction is the one this reads:
;; `+react` puts an emoji on the message `+reply` names, and the
;; server's own `+freeq.at/unreact` takes it off again.
(= "TAGMSG" cmd)
(let [tags (:tags m)
target (first params)
buffer (if (rooms/dm? target) who target)
msgid (or (irc/tag-value tags "+reply")
(irc/tag-value tags "+draft/reply"))
add (or (irc/tag-value tags "+react")
(irc/tag-value tags "+draft/react"))
gone (irc/tag-value tags "+freeq.at/unreact")]
(cond
add (swap! cells/channels reactions/update-reaction
buffer msgid add who true)
gone (swap! cells/channels reactions/update-reaction
buffer msgid gone who false))))
;; Whatever that message did to the list, write it down. Throttled, so a
;; busy channel does not cost a file write per line — but a DM arriving
;; from someone new is a room that exists only here until this runs, and
;; the old client lost exactly those between runs. The read marker moves
;; on the same path, for the same reason: a line arriving in the room on
;; screen is a line read.
(remember-rooms!)))
(defn- note! [m]
(reset! last-line (str (:command m) " " (last (:params m))))
(swap! lines (fn [v] (vec (take-last 8 (conj v m))))))
(def ^:private registration-failed
"Numerics that mean registration will never complete.
Only 001 used to clear `connecting?`, so anything the server said instead of
it spun for ever with no message — and the likeliest of them is the dullest:
433, the nick is already in use, which is what a second phone or a session
left open elsewhere gets."
#{"431" ; no nickname given
"432" ; erroneous nickname
"433" ; nickname in use
"436" ; nick collision
"464" ; password incorrect
"465" ; banned
"ERROR"})
(defonce ^:private attempt (atom 0))
(defn- watch-cells!
"Every cell in `frq.cells` ticking `repaint` when it changes.
Called from `main` rather than run from a top-level `defonce`, and that is
not a style preference: ClojureDart compiles a `def` to a Dart top-level
variable, Dart initialises those on first read, and a `defonce` whose value
nothing ever reads simply never runs. The first attempt at this installed no
watches at all and printed nothing to say so."
[]
(doseq [c (cells/all-cells)]
(add-watch c ::repaint
(fn [_ _ old new]
(when (not= old new) (swap! repaint inc))
nil))))
(defonce ^:private caps
;; What the server has agreed to on this connection. `frq.irc.handshake`
;; holds none of it: it takes the set and hands a new one back, so the
;; desktop can keep it in an atom on the connection and the phone here.
(atom #{}))
(defonce ^:private closing?
;; Whether the close about to arrive is one we asked for. Without it a tap
;; on Disconnect comes back as "closed by the server", which is both untrue
;; and alarming.
(atom false))
(defn- fail! [why]
(reset! cells/connecting? false)
(reset! cells/status "Not connected")
(reset! cells/screen :connect)
(reset! cells/error why))
(defn ^:async bluesky-session!
"A web-token session from freeq's auth broker.
Only the half that needs no browser. A saved broker token is durable and the
web-token minted from it is single-use, so a return visit is one POST to
/session and `frq.oauth.core` already says what to send and what the answer
means. Getting the *first* broker token is the other half: the browser hands
it back to a loopback listener on the desktop, and a phone has nowhere for
that to land — it wants an Android app link, which is a decision about what
the broker will redirect to and not a porting problem. So that case says so
rather than quietly connecting as somebody else."
[]
(if-let [bt @cells/broker-token]
(do
(reset! cells/status "Resuming your session…")
(let [tokens (oauth/refresh-session-parse
bt
(await (atproto/fetch
(oauth/refresh-session-req oauth/default-broker bt))))]
(reset! cells/broker-token (:broker-token tokens))
;; On every sign-in and not only the first: /session can hand back a
;; rotated broker token, and the old one may stop working the moment
;; it does.
(store/save-session! tokens)
(assoc tokens :kind :web-token)))
;; No saved token: the browser leg. `frq.oauth.dart` binds the loopback,
;; and what comes back through it is the first broker token.
(let [tokens (await (oauth-dart/await-callback!
oauth/default-broker
(str @cells/form-handle)
(fn [url]
(reset! cells/login-url url)
(reset! cells/status
(if (fio/open-url! url)
"Waiting for the browser…"
"Open the sign-in link below to continue"))
nil)))]
(reset! cells/login-url nil)
(reset! cells/broker-token (:broker-token tokens))
(store/save-session! tokens)
(assoc tokens :kind :web-token))))
(defn ^:async sign-in!
"Fill `cells/session` for the mode that was chosen, or say why not.
True when the connection may go ahead. A guest carries no session, and must
not carry the last one either: a leftover would have `frq.irc.handshake` ask
for sasl and authenticate as whoever signed in before."
[]
(case @cells/auth-mode
:guest (do (reset! cells/session nil) true)
(try
(reset! cells/connecting? true)
(reset! cells/error nil)
(reset! cells/session
(await (if (= :bluesky @cells/auth-mode)
(bluesky-session!)
(do (reset! cells/status "Signing in…")
(atproto/create-session @cells/form-handle
@cells/form-app-password)))))
;; An authenticated connection still needs a nick — the DID is the
;; identity, the nick is only what the channel calls you. Same order the
;; desktop picks it in: what the broker said, else the first label of the
;; handle, else whatever is in the box.
(let [sess @cells/session
nick (or (:nick sess)
(first (.split (str (or (:handle sess) "")) "."))
nil)]
(when (seq (str (or (:handle sess) "")))
(reset! cells/form-handle (:handle sess)))
(when (seq (str (or nick "")))
(reset! cells/form-nick nick))
;; The password did its work at the PDS; do not keep it.
(reset! cells/form-app-password ""))
true
(catch Object e
(reset! cells/session nil)
;; The message, not the exception: `frq.atproto.core` puts the body it
;; could not read in the ex-data, and a PDS that answers a resolve with
;; an HTML error page puts the whole page there.
(fail! (str "Sign-in failed: " (or (ex-message e) e)))
false))))
(defn ^:async connect!
"What `frq.actions/connect!` is on the phone.
The desktop's is `frq.state/connect!` — jolt's TLS, SASL, a reader thread.
This is the dart:io one, and it reads the same cells the screen wrote: the
host, the port and the TLS tick are `frq.cells`, filled in by the entry and
the checkbutton on screen.
Guest and app-password both. The handshake itself is not here: it is
`frq.irc.handshake`, which answers each line with the lines to send back,
and all this does is write them."
[]
;; Close whatever was open first. Connecting twice left the old socket
;; holding the nick, so the second attempt got 433 from its own predecessor.
(when-let [c @conn]
(reset! closing? true)
(net/close! c)
(reset! conn nil))
;; Whatever identity was asked for, settled before the socket opens. Both
;; of the signed-in modes are an HTTPS round trip that has nothing to do
;; with IRC, and a failure in either must stop here: connecting anyway lands
;; us on the server as a guest, which looks like a success and is not the
;; one that was asked for. Bluesky did exactly that until it was asked.
(when (await (sign-in!))
(let [n (swap! attempt inc)]
(reset! caps #{})
(reset! cells/connecting? true)
(reset! cells/error nil)
(reset! cells/status (str "Connecting to " @cells/form-host "…"))
(reset! lines [])
;; A watchdog, because "no answer at all" is a real outcome: a TLS
;; handshake that hangs, or a server that accepts the socket and says
;; nothing, leaves every callback below unfired.
(.then (async/Future.delayed (Duration .seconds 20))
(fn [_]
(when (and @cells/connecting? (= n @attempt))
(fail! "No answer from the server after 20s"))))
(try
(let [sock (await (net/connect!
{:host @cells/form-host
;; The cell is a string, because it is what an
;; :entry holds.
:port (or (parse-long (str @cells/form-port)) 6697)
:tls? (boolean @cells/form-tls?)
:on-msg (fn [m]
(note! m)
(room! m)
;; Capability negotiation and SASL, out of
;; common/ — the same steps the desktop
;; takes, and all this does is write what
;; they answer with.
(let [{:keys [send] next-caps :caps}
(handshake/step {:session @cells/session
:caps @caps}
m)]
(reset! caps next-caps)
(doseq [line send]
(when-let [c @conn] (net/send-line! c line))))
(let [cmd (str (:command m))
text (str (last (:params m)))]
(cond
(= "001" cmd)
(do (reset! cells/connecting? false)
(reset! cells/error nil)
(reset! cells/status
(str "Connected as " @cells/form-nick))
;; Off the connect screen, as
;; frq.state does on 001. Without
;; this a successful connect just
;; puts the Connect button back
;; and looks exactly like a
;; failure.
(reset! cells/screen :chats)
;; This session decides once
;; whether it is the one that
;; takes the room list over
;; from the server. Before the
;; flag is set the file has
;; never been told what we are
;; in, so the server's answer
;; is adopted rather than
;; argued with.
(reset! adopting-rooms?
(not @room-list-owned?))
(when-not @room-list-owned?
(reset! room-list-owned? true)
(save-prefs!))
;; What the file says we are in,
;; we ask to be in. A room the
;; server has forgotten is one
;; that would otherwise quietly
;; stop existing.
(join-saved-rooms!)
;; And back where the reader
;; left off, rather than into
;; #test on top of a list they
;; already had. The chats
;; screen stays underneath, so
;; Back goes to the list rather
;; than out of the app.
(if-let [last-room
(:name (first (rooms/channel-list)))]
(actions/open-channel! last-room)
(actions/join! auto-join)))
(contains? registration-failed cmd)
(fail! (if (= "433" cmd)
(str "Nick " @cells/form-nick
" is already in use — try another")
(str cmd " " text)))
;; Something is happening; say so
;; rather than sit on one message.
@cells/connecting?
(reset! cells/status (str "… " cmd)))))
:on-close (fn [why]
(reset! conn nil)
(forget-memberships!)
;; Say what the last thing seen was. A
;; connection that registers and then
;; drops is a different bug from one
;; that never registers, and only the
;; last line apart tells them.
(if @closing?
(reset! closing? false)
(fail! (str (or why "closed by the server")
" (after " (count @lines)
" lines, last: " @last-line ")"))))}))]
(reset! conn sock)
;; CAP first, then registration — the order the server expects, and the
;; order `frq.irc` uses.
(net/send-line! sock "CAP LS 302")
;; `form-nick` and nothing else. The handle went on the wire here,
;; and the sign-in had already put the *nick* in the cell — so the
;; channel called us alice.bsky.social while the client thought it
;; was alice, and every test of "is this me?" against a prefix came
;; back false. Our own JOIN echo stopped setting `:joined?`, so
;; every room wore a "not joined" badge and Open re-sent a JOIN for
;; a room we were already in. The DID is the identity; the nick is
;; only what the channel calls us, and both halves have to agree on
;; it. Same nick the desktop dials with.
(let [nick (str @cells/form-nick)]
(net/send-line! sock (str "NICK " nick))
(net/send-line! sock (str "USER " nick " 0 * :frq"))))
;; Object, not Exception. Dart keeps Error and Exception in separate
;; hierarchies, so a TypeError is not an Exception and would leave
;; `connecting?` true for ever.
(catch Object e
(fail! (str e)))))))
(defn- disconnect! []
(reset! closing? true)
(when-let [c @conn] (net/close! c))
(reset! conn nil)
(forget-memberships!)
(reset! cells/connecting? false)
(reset! cells/status "Not connected")
(reset! cells/screen :connect))
(defonce ^:private handle (atom "nandi-test.bsky.social"))
(defonce ^:private identity-out (atom nil))
(defn ^:async resolve-identity! []
(reset! identity-out ["resolving…"])
(try
(let [h @handle
did (await (atproto/resolve-handle h))
pds (await (atproto/pds-endpoint did))]
(reset! identity-out [(str "did " did) (str "pds " pds)]))
(catch Exception e
(reset! identity-out [(str "failed: " e)]))))
(defn- connected-screen
"Where a connect lands, until `frq.app`'s chats screen is portable.
Not much, and honest about it: the status, what the server said, and the way
back. The desktop goes to :chats here — that screen reads rooms, messages,
avatars and the media plane, which is most of what is not ported yet."
[]
[:page {:max-width 520}
[:title {:label "frq"}]
[:status {:live true :label @cells/status}]
[:card {}
[:title-2 {:label "What the server said"}]
(if (empty? @lines)
[:dim-label {:label "nothing yet"}]
(for [[i m] (map-indexed vector @lines)]
[:label {:key i
:label (str (:command m)
(when-let [p (seq (:params m))]
(str " " (last p))))}]))]
[:hbox {:spacing 8}
[:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]
[:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]])
(defn- outgoing-dir []
(let [d (str (fio/config-dir) "/outgoing")]
(fio/mkdirs! d)
d))
(defn- discard! [path]
(try (fio/delete-file! path) (catch Object _ nil)))
(defn- clear-attachment!
"Drop the picture without sending it, and the copy of it with it."
[]
(when-let [a @cells/attachment]
(reset! cells/attachment nil)
(discard! (:path a))))
(defn ^:async attach!
"Hold the picture already copied to `path` against the next line, and start
its upload.
The upload starts at once rather than at send, so by the time a line is
written the picture is usually already up. A failure lands in `error` like
any other and takes the attachment with it — there is nothing to send and
nothing to show."
[path filename]
(let [did (:did @cells/session)
host-name (str @cells/form-host)
channel (str @cells/current)]
(reset! cells/error nil)
(clear-attachment!)
(reset! cells/attachment {:path path :status :uploading})
(try
(let [url (await (upload/upload! host-name did channel path filename))]
;; Only if this is still the picture on screen: a reader who attached
;; another, or cleared it, has said what they want, and an upload
;; landing afterwards does not get to undo that.
(swap! cells/attachment #(if (= (:path %) path)
(assoc % :url url :status :ready)
%))
(when-not (= (:path @cells/attachment) path) (discard! path)))
(catch Object e
(swap! cells/attachment #(if (= (:path %) path) nil %))
(discard! path)
(reset! cells/error (str (or (ex-message e) e)))))))
(defn ^:async open-image-picker!
"Ask for a picture, the way a phone asks.
The system chooser, not this app's browsing screen: what the chooser hands
back is a grant for the one picture chosen, so the app needs no permission
over the reader's pictures at all — and without such a permission, browsing
finds almost nothing to show. `frq.state/open-image-picker!` says the same
thing from the other side and falls back to browsing where there is no
chooser, which is every desktop.
Copied into our own storage rather than attached where it lies: the send
drops the attachment's file when it is done with it, and what it drops has
to be ours — not the reader's own picture, sitting in their gallery."
[]
(try
(let [chosen (await (.pickImage (picker/ImagePicker)
.source picker/ImageSource.gallery))]
(when chosen
(let [copy (str (outgoing-dir) "/" (.-millisecondsSinceEpoch (DateTime/now))
".png")]
(await (.copy (dio/File. (.-path chosen)) copy))
(await (attach! copy "picture.png")))))
(catch Object e
(reset! cells/error (str "Could not read that picture: " (or (ex-message e) e))))))
(defn- send-draft! []
(let [text (str @cells/draft)
room (str @cells/current)
edit @cells/editing]
(cond
(empty? room) nil
;; A rewrite replaces what was said, and what was said is a line of
;; text: there is no wire form for adding a picture to a message already
;; sent, so the attachment is held back rather than silently dropped.
;; `frq.state/send-draft!` refuses the same pair for the same reason.
(and edit @cells/attachment)
(reset! cells/error "Finish the edit before sending a picture.")
;; An edit of nothing is not a way to unsay a line: there is no delete
;; on the wire here, and blanking the message is not what emptying the
;; box asks for. Send waits for something to say instead.
(and edit (empty? (.trim text))) nil
;; A rewrite replaces what was said. It carries no new msgid of its own
;; — the server files it under the original's id — so nothing is added
;; here and the echo folds it in where the line already is. The room is
;; the edit's own and not whichever one is open: they are the same room
;; while the banner is up, because leaving cancels the edit, and naming
;; the one being rewritten is what makes that true rather than assumed.
edit
(if-let [c @conn]
(let [room (str (:channel edit))
text (.trim text)]
(net/send-line!
c
(mutate/edit-line room (:id edit) text
(reactions/peer-did @cells/channels room
(str @cells/form-nick))))
;; Same reason as a new message: the server's echo is the copy every
;; other client sees, and folding this one in as well would rewrite
;; the line twice. Without `echo-message` nothing comes back, so the
;; rewrite has to be applied here or it never shows.
(when-not (handshake/acked? @caps "echo-message")
(edit-message! room (:id edit) (str @cells/form-nick) text))
(reset! cells/editing nil)
(reset! cells/draft ""))
;; The draft stays in the box, with the banner still over it: the
;; rewrite is still what the reader wants to send once there is
;; somewhere to send it.
(reset! cells/error "Not connected."))
;; A picture still on its way up holds the send rather than losing it:
;; the line stays in the box, said so, and the reader presses send again
;; a moment later. Sending the text without its picture would be the one
;; outcome nobody asked for.
(= :uploading (:status @cells/attachment))
(reset! cells/error "The picture is still uploading.")
(and (or (seq text) (:url @cells/attachment)) @conn)
(let [url (:url @cells/attachment)
;; The picture becomes its link, at the end of the line: what goes
;; on the wire is the text the reader wrote and a URL after it,
;; which is what every other client in the channel knows how to
;; show. A line that is only a picture is only the link.
text (.trim (str text (when url (str " " url))))]
(do
(net/send-line! @conn (str "PRIVMSG " room " :" text))
;; Echoed locally only when the server will not echo it back. With
;; `echo-message` negotiated it does — that is what the cap is for, and
;; it is how a client learns the msgid of its own line — so adding one
;; here as well put every sent message in the room twice.
(when-not (handshake/acked? @caps "echo-message")
(swap! cells/channels update room
#(update % :messages conj {:from @cells/form-nick :text text})))
;; The attachment has done its job the moment the link is on the wire.
(when-let [a @cells/attachment]
(reset! cells/attachment nil)
(discard! (:path a)))
(reset! cells/draft ""))))))
(defn- message-by-id [room id]
(when id
(first (filter #(= id (rooms/row-id %))
(get-in @cells/channels [room :messages])))))
(defn- toggle-reaction!
"Put my emoji on a message, or take it off if it is already mine.
Applied here as well as sent: the server relays a TAGMSG to everyone in the
channel *except* the client that sent it, so without this the pill would
only appear once someone else reacted too."
[channel m emoji]
(when-let [msgid (:id m)]
(let [me (str @cells/form-nick)
on? (not (reactions/mine? m emoji me))
peer (reactions/peer-did @cells/channels channel me)]
(when-let [c @conn]
(net/send-line! c (if on?
(mutate/react-line channel msgid emoji peer)
(mutate/unreact-line channel msgid emoji peer))))
(swap! cells/channels reactions/update-reaction
channel msgid emoji me on?))))
(defn- close-picker! [] (reset! cells/reacting nil))
(defn ^:async main []
(m/WidgetsFlutterBinding.ensureInitialized)
;; Layout errors do not come back as exceptions — they happen after the
;; build, so nothing can catch them, and what they leave is a blank screen
;; and an empty log. Flutter routes them here instead.
(set! (.-onError m/FlutterError)
(fn [^m/FlutterErrorDetails details]
(m/debugPrint (str "frq: FLUTTER ERROR " (.-exception details)))
(m/debugPrint (str "frq: LIBRARY " (.-library details)
" CONTEXT " (.-context details)))))
(let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
(host/install! dir)
;; Ed25519 for the reactions freeq will not take on trust. Verified
;; against RFC 8032 test 1 on the device: the same public key and the
;; same signature OpenSSL gives on the desktop, so a signature minted
;; here verifies the same way at the server.
(crypto-dart/install!)
;; How this half asks the directory who someone is. `frq.profile` decides
;; when to ask and remembers the answer; only the awaiting is here.
(profile/install-fetch!
(fn [actor]
(.then ^async/Future (atproto/fetch (profile/profile-req actor))
(fn [body] (profile/deliver-profile! actor body) nil)
.onError (fn [_ _] (profile/deliver-profile! actor nil) nil))))
;; Before any widget is built: a cell that changes before its watch is on
;; is a change the screen never hears about.
(watch-cells!)
;; The room list of earlier runs, and the flag that says whether it is the
;; authority yet. Before the saved session, because that connects on its
;; own the moment it is restored and 001 reads both of these.
(restore-prefs!)
(restore-rooms!)
;; A sign-in that already happened. Only the durable broker token comes
;; back — the connection mints a fresh web-token from it — so this is not
;; a session, it is the means to ask for one.
(when-let [saved (store/load-session)]
(reset! cells/broker-token (:broker-token saved))
(when (seq (str (or (:handle saved) "")))
(reset! cells/form-handle (:handle saved)))
(when (seq (str (or (:nick saved) "")))
(reset! cells/form-nick (:nick saved)))
(reset! cells/auth-mode :bluesky)
(reset! cells/status (str "Signed in as " (:handle saved) "…"))
;; And then it connects on its own, as `frq.app/start!` does on the
;; desktop: a remembered account has already said what it wants, and
;; making it say so again at every launch is a tap that carries no
;; information. The web-token is minted fresh from the broker token
;; either way, so this is the same round trip Connect would make.
;;
;; On a timer rather than awaited here, and for the desktop's reason:
;; the connect screen with its status is what should be on screen while
;; it happens, and if it fails the error lands somewhere visible rather
;; than before the first frame.
(.then (async/Future.delayed (Duration .milliseconds 150))
(fn [_] (connect!) nil)))
;; What the shared screen calls. The desktop installs frq.state's
;; reducers here; this installs the phone's.
(actions/install!
{:connect! connect!
:disconnect! disconnect!
:connected? (fn [] (some? @conn))
;; The Join box, and the same one box for both things the desktop's
;; `join!` does: `@nick` opens a DM, anything else is a channel and
;; gets its `#` if it came without one. Through `open-room!` rather
;; than straight onto the wire, because a bare JOIN left the buffer
;; unbuilt — and the rule in the JOIN handler would then read our own
;; echo as a room the server had invented and part it again.
:join! (fn [name]
(let [name (.trim (str name))]
(cond
(.startsWith name "@")
(let [who (.substring name 1)]
(when (seq who) (open-room! who)))
(seq name)
(open-room! (if (.startsWith name "#") name (str "#" name)))
:else nil)))
:leave-channel! (fn [name]
;; Channels only, as on the desktop. There is nothing
;; to part in a conversation with a person — the
;; buffer is the whole of it — and `PART alice` is a
;; line the server answers with an error.
(when (and @conn (.startsWith (str name) "#"))
(net/send-line! @conn (str "PART " name)))
(swap! cells/channels dissoc name)
(when (= name @cells/current)
(reset! cells/current nil)
(reset! cells/screen :chats))
;; The only way a room leaves the file. Everything
;; else adds one, so without this the list is a thing
;; that only grows.
(remember-rooms! true))
:send-draft! send-draft!
;; Pictures. The chooser is the platform's, so the browsing screen the
;; desktop falls back to is never opened here — and its actions would
;; have nothing to list anyway, since everything outside this app's own
;; storage is behind a permission it does not ask for.
:open-image-picker! open-image-picker!
:clear-attachment! clear-attachment!
:close-image-picker! (fn [] (reset! cells/image-picker nil))
:browse! (fn [_] nil)
:picker-roots (fn [] [])
:picker-entries (fn [_] {:dirs [] :files []})
:parent-dir (fn [_] nil)
:pick-image! (fn [_] nil)
;; Android has no clipboard of pictures to read, which is the other half
;; of why the chooser above exists.
:paste-image! (fn []
(reset! cells/error "No picture on the clipboard."))
;; Answering. Both are a cell and nothing else — what the composer does
;; with `replying-to` is the shared screen's business.
:reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text])))
:cancel-reply! (fn [] (reset! cells/replying-to nil))
;; Reacting. The pill itself, then the picker behind it.
:toggle-reaction! toggle-reaction!
:my-reaction? (fn [m emoji]
(reactions/mine? m emoji (str @cells/form-nick)))
:open-picker! (fn [channel m]
(when (:id m)
;; Fresh: a leftover search from last time is a screen
;; of somebody else's question.
(reset! cells/emoji-search "")
(reset! cells/emoji-group nil)
(reset! cells/reacting {:channel channel :id (:id m)})))
:close-picker! close-picker!
:picker-emoji reactions/picker-emoji
:react-from-picker! (fn [emoji]
;; One choice and back to the conversation: a
;; picker left open is asking a question that has
;; been answered.
(when-let [{:keys [channel id]} @cells/reacting]
(when-let [m (message-by-id channel id)]
(toggle-reaction! channel m emoji))
(close-picker!)))
;; Hover is a pointer idea. A finger is either on a pill or not on it,
;; so there is nothing here to raise a card about.
:hover-reaction! (fn [_ _] nil)
:unhover-reaction! (fn [_ _] nil)
;; Who someone is, behind the nick on a line. `frq.profile` holds the
;; cache and the fields; this is the tapping.
;; Opening a profile asks for the picture too: the dialog paints it at 72
;; points, and someone whose lines are all above the fold in another room
;; may never have had a face fetched for them.
:profile-open! (fn [nick actor]
(avatars/fetch! actor bump!)
(profile/open! nick actor))
:profile-close! profile/close!
:profile-dismiss! profile/close!
:profile-entry profile/entry
:profile-tick (fn [] @profile/tick)
:profile-stats-line profile/stats-line
:profile-truncate profile/truncate
:profile-web-url profile/web-url
:viewing (fn [] @profile/viewing)
;; The pointer half of a profile, which a phone does not have: there is
;; no hovering a face, no crossing from the face to the card, and so no
;; card to hold open while it happens.
:hovering (fn [] nil)
:profile-hover! (fn [_ _] nil)
:profile-unhover! (fn [_] nil)
:profile-enter-dialog! (fn [] nil)
:profile-leave-dialog! (fn [] nil)
;; Their profile on the web, handed to the browser the same way the
;; sign-in link is.
:open-url! (fn [url] (fio/open-url! url))
;; Where a picture is on disk, once it is. A plain lookup and not the
;; desktop's reaction: glimmer wakes the one row that read it, and this
;; half repaints the tree — `images-in` ticks `repaint` when a fetch
;; lands, and Flutter's own reconciler decides what that costs.
;;
;; A path and not the URL `:avatar-path` below answers with, and the
;; difference is what is behind them: a face is a CDN URL the network
;; will serve again, a picture in a message is whatever host a stranger
;; put a link to.
:image-path (fn [url] (media/path-when-ready url))
:wide? (fn [] false)
:desktop? (fn [] false)
:mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))
;; Rewriting. The old text is the starting point rather than an empty
;; line: an edit is usually a word, and retyping the sentence around it
;; is not what was asked for.
:start-edit! (fn [channel m]
(when (and (:id m) (rooms/mine? m (str @cells/form-nick)))
(reset! cells/replying-to nil)
(reset! cells/editing {:channel channel :id (:id m)})
(reset! cells/draft (or (:text m) ""))))
;; The box empties with it: what is in it is a copy of the line on
;; screen, and leaving that behind would look like a draft.
:cancel-edit! (fn []
(reset! cells/editing nil)
(reset! cells/draft ""))
:message-by-id (fn [room id]
(->> (get-in @cells/channels [room :messages])
(filter #(= id (:id %)))
first))
;; Where a face is. A URL and not a path, which is what the desktop
;; answers — see `frq.avatars.dart`, and `:avatar` in `frq.hiccup`,
;; which is the one place that has to know which it got. Both names
;; answer the same thing here: the desktop's `avatar-ready` is
;; "downloaded already", and nothing is downloaded here.
:avatar-path (fn [actor] (avatars/url actor))
:avatar-ready (fn [actor] (avatars/url actor))
:member-count (fn [room] (members/member-count @cells/channels room))
:member-list (fn [room] (members/member-list @cells/channels room))
:toggle-users! (fn [] (swap! cells/show-users? not))
:toggle-overview! (fn [] (swap! cells/overview? not))
;; What the strip is a list of: every other room's recent lines, a turn
;; each. `frq.rooms` reads it off the cells, so both halves ask the same
;; question of the same buffers — the desktop's is the same def.
:recent-everywhere rooms/recent-everywhere
;; Where the reader was when a line in the strip took them out of it,
;; and the way back to it. The room, not the place in it: one scroll
;; position is remembered per conversation either way.
:leaving-for-overview! (fn [] (reset! cells/overview-return @cells/current))
:overview-back! (fn []
(when-let [room @cells/overview-return]
(reset! cells/overview-return nil)
(actions/open-channel! room)))
;; Timers, for the two the strip's jump needs: aim the scroll, then let
;; go of it, and clear the mark a while after that. `Future.delayed` in
;; milliseconds, which is the unit `frq.screens.chat` asks in.
:after! (fn [ms f]
(.then (async/Future.delayed (Duration .milliseconds ms))
(fn [_] (f) nil))
nil)
:toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not))
:toggle-hide-join-part! (fn [] (swap! cells/hide-join-part? not))
:quit! (fn [] nil)
:forget-session! (fn []
(reset! cells/broker-token nil)
(reset! cells/session nil))
;; Back to the newest line. Both halves, in the order `frq.state` gives
;; them: the flag is what takes the button off the screen, and the tick
;; is what `frq.hiccup`'s scroll reads as "someone asked to be taken to
;; the end". Setting the flag alone hid the button and left the reader
;; where they were — which is the one outcome worse than no button.
:jump-to-present! (fn []
(reset! cells/at-present? true)
(swap! cells/jump-tick inc))
:open-channel! open-room!})
(f/run
(m/MaterialApp .title "frq" .theme (t/app-theme))
.home
;; No AppBar. `connect-screen` opens with [:title {:label "frq"}] — the
;; desktop has no bar above it either, the window's title is the
;; window's — so a bar here says "frq" twice.
(m/Scaffold)
.body
m/SafeArea
(f/widget
:context ctx
;; One watch, not twenty. `lines` is a local atom and not a cell, so it
;; is named beside the tick; everything under `frq.cells` arrives
;; through `repaint`.
:watch [tick repaint ls lines]
(let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))]
;; `frq.screens.app` decides which screen shows, the same way it does
;; on the desktop. The phone was switching by hand until this moved.
(h/render [screens/app]))))))
|