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
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
|
(ns frq.state
"Every cell the UI reads, and the reducers that write them.
glimmer components re-render from ratoms, so the whole app state is a handful
of `atom`s here; the IRC reader thread pushes into the same ones. `apply-msg!`
is the only place a wire message turns into UI state."
(:require [clojure.string :as str]
[frq.rooms :as rooms]
[frq.members :as members]
[frq.reactions :as reactions]
[frq.edits :as edits]
[glimmer.ratom :as r :refer [atom]]
[frq.actions :as actions]
[frq.cells :as cells]
[jolt.host :as host]
[frq.atproto :as atproto]
[frq.av :as av]
[frq.clock :as clock]
[frq.emoji :as emoji]
[frq.irc :as irc]
;; For the side effect: it installs the desktop crypto behind
;; `frq.crypto`, which the shared `frq.msgsig` signs through.
[frq.crypto.openssl]
[frq.msgsig :as msgsig]
[frq.avatars :as avatars]
[frq.media :as media]
[frq.oauth :as oauth]
[frq.platform :as platform]
[frq.store :as store]
[frq.upload :as upload]))
(def default-host cells/default-host)
(def default-port cells/default-port)
;; The cells the connect screen reads live in `frq.cells` now, so that screen
;; could move to common/ and be the same file on the phone. Re-defined here
;; rather than left to the callers: a thousand lines below this say
;; `@form-nick` and `@connecting?`, and none of them care which namespace the
;; atom was made in.
(def screen cells/screen)
;; Not in frq.cells: this holds the live IRC connection, which is jolt's
;; socket and a reader thread. The phone's equivalent is a dart:io Socket and
;; nothing shared could hold either.
(defonce conn (atom nil))
(def status cells/status)
(def error cells/error)
(def connecting? cells/connecting?)
(def form-host cells/form-host)
(def form-port cells/form-port)
(def form-tls? cells/form-tls?)
(def form-nick cells/form-nick)
(def auth-mode cells/auth-mode)
(def form-handle cells/form-handle)
(def form-app-password cells/form-app-password)
(def session cells/session)
(def broker-token cells/broker-token)
(def channels cells/channels)
(def current cells/current)
(def join-input cells/join-input)
(def search cells/search)
(def login-url cells/login-url)
(def popular-channels cells/popular-channels)
(def auto-join "#test")
;; How much backlog to ask for when the server did not volunteer any.
(def history-limit 100)
(def draft cells/draft)
(def replying-to cells/replying-to)
(defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))
(defn cancel-reply! [] (reset! replying-to nil))
(def editing cells/editing)
(def reacting cells/reacting)
(def emoji-search cells/emoji-search)
(def emoji-group cells/emoji-group)
(def lightbox cells/lightbox)
(def show-users? cells/show-users?)
(defn toggle-users! [] (swap! show-users? not))
(def hide-chat-list? cells/hide-chat-list?)
(declare save-prefs!)
(defn toggle-chat-list! []
(swap! hide-chat-list? not)
(save-prefs!))
(def overview? cells/overview?)
(defn toggle-overview! []
(swap! overview? not)
(save-prefs!))
(def overview-return cells/overview-return)
(declare open-channel!)
(defn leaving-for-overview!
"Remember where we are, because a line in the strip is about to take us out
of it. Nothing to remember if there is nowhere to go back to."
[]
(reset! overview-return @current))
(defn overview-back!
"Back to the room the strip took you out of.
The room, and not the place in it: the backlog's scroll is remembered under
one name for every conversation — see `messages-scroll-key` — so what comes
back is the room and whatever that one viewport is currently showing of it.
A place of your own in every room is a bigger change than this button."
[]
(when-let [room @overview-return]
(reset! overview-return nil)
(open-channel! room)))
(def window-width cells/window-width)
(def window-height cells/window-height)
;; Where the second pane starts paying for itself. Below this a 300pt list
;; beside a conversation leaves the messages narrower than the phone layout
;; they were written for.
(def wide-width 900)
(defn wide?
"True while the window has room for the list and a conversation at once."
[]
(>= @window-width wide-width))
(defn chat-visible?
"Whether the conversation in `current` is on screen.
On a narrow window that is the chat screen alone. On a wide one the chats
screen shows it too, in the pane beside the list — so this, and not the
screen, is what decides whether an arriving line counts as unread."
[]
(or (= :chat @screen)
(and (wide?) (= :chats @screen))))
(def at-present? cells/at-present?)
(def jump-tick cells/jump-tick)
;; How a backend answers "did that scroll end at the end?".
;;
;; The window's scroll area says so itself — `:on-change` arrives with "end" —
;; and the terminal's does not: it reports the offset it was asked for and
;; never how far down the bottom is. What it does do is clamp, so a request
;; that came back smaller than it went out is a request that ran into the end.
;; Only a backend that can see that installs this; `scrolled!` is written for
;; both, and asks.
(defonce at-end-probe (atom nil))
(defn scrolled!
"A viewport moved under the reader, to the offset `to`.
For the backends whose scroll areas report a position rather than a place:
where that leaves the reader is what `at-end-probe` is asked, and with
nobody to ask, any scroll at all is a scroll away from the newest line."
[to]
(reset! at-present? (boolean (when-let [probe @at-end-probe] (probe to))))
nil)
(defn jump-to-present!
"Go back to the newest line.
`at-present?` is set here rather than left to the view because not every
backend can tell us: the window's scroll area reports where it ended up and
corrects this on the next frame, and the terminal's does not report the
bottom at all — so what a jump means for the button that asked for it is
said here, once, for both."
[]
(reset! at-present? true)
(swap! jump-tick inc))
;; A counter rather than a clock: the list only needs their order, and a
;; monotonic tick cannot be surprised by the system time moving.
(defonce access-tick (atom 0))
(def hide-join-part? cells/hide-join-part?)
;; 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 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! []
(future (store/save-prefs! (assoc (store/load-prefs)
:hide-join-part? @hide-join-part?
:hide-chat-list? @hide-chat-list?
:overview? @overview?
:room-list-owned? @room-list-owned?))))
(defn toggle-hide-join-part! []
(swap! hide-join-part? not)
(save-prefs!))
(defn restore-prefs!
"Bring back the saved settings at startup."
[]
(let [prefs (store/load-prefs)]
(reset! hide-join-part? (boolean (:hide-join-part? prefs)))
(reset! hide-chat-list? (boolean (:hide-chat-list? prefs)))
(reset! overview? (boolean (:overview? prefs)))
(reset! room-list-owned? (boolean (:room-list-owned? prefs)))
prefs))
(defn connected? [] (some? @conn))
(declare channel-order room-records request-names!)
(defonce ^:private rooms-saved-at (atom 0))
(defn- remember-rooms!
"Write the room records out: what rooms there are, in the order they were
last used, and how much of each has been read.
Off the caller's thread, because opening a room should not wait on a file.
Throttled, because the marker moves on every line that arrives while a room
is on screen and a busy channel would otherwise write the file per message —
`force?` is for the moments worth paying for, which is a room being opened.
A late write costs at most the handful of lines that arrived since the last
one, shown unread again on the next run. That is the right way round: the
marker never claims to have read more than it has."
([] (remember-rooms! false))
([force?]
(let [now (clock/now-ms)]
(when (or force? (> (- now @rooms-saved-at) 5000))
(reset! rooms-saved-at now)
(future (store/save-rooms! (room-records)))))))
(def dm? rooms/dm?)
(defn normalize-channel [s]
(let [s (str/trim (or s ""))]
(cond (str/blank? s) ""
(str/starts-with? s "#") s
:else (str "#" s))))
(defn- after-marker
"The messages in `buffer` the reader has not seen: everything after its read
marker.
By id where the marked message is still held, and by time otherwise. The id
is the exact answer — a msgid survives every revision, so it names the same
line however often the server replays it — and the timestamp is what answers
when the marked line has fallen off the end of the buffer or was never in
this run's copy of it.
Derived rather than counted, because a count cannot survive what the server
does: a JOIN replays the backlog and CHATHISTORY replays it again, and every
line of it would tick a counter a second time. Against a marker a replayed
line is simply older than it and counts for nothing."
[buffer]
(let [id (:last-read-id buffer)
at (:last-read-at buffer 0)
msgs (vec (:messages buffer))]
(if (and id (some #(= id (:id %)) msgs))
(vec (rest (drop-while #(not= id (:id %)) msgs)))
(filterv #(> (:at % 0) at) msgs))))
(defn- mentions-me?
"Whether a line is addressed at the reader by name. Our own lines do not
count — saying your own nick is not being called."
[m]
(let [me (str/trim (or @form-nick ""))]
(and (seq me)
(not= (:from m) me)
(str/includes? (str/lower-case (or (:text m) ""))
(str/lower-case me)))))
(defn- recount
"Answer what the marker says: how many lines are unseen, and whether any of
them names the reader."
[buffer]
;; Joins, parts, quits and "Joined #room" are the room talking about itself,
;; not somebody talking in it. They arrive stamped now — the join notice is
;; written the moment we are in — so counted, every room you are a member of
;; sits at one unread from the moment it opens, saying only that you joined
;; it. The marker still moves past them: they are read, they are just never
;; what made a room worth looking at.
(let [fresh (remove :system? (after-marker buffer))]
(assoc buffer
:unread (count fresh)
:mention? (boolean (some mentions-me? fresh)))))
(defn- mark-read
"Move the marker to the newest line this buffer holds. Both halves: the id
for as long as that line is here, and its time for after it is gone.
The time only ever goes forward. A backlog can arrive after the reader has
already read past it, and taking the last line's time unconditionally would
walk the marker backwards and re-unread what was read."
[buffer]
(let [newest (last (:messages buffer))]
(assoc buffer
:unread 0
:mention? false
:last-read-id (:id newest)
:last-read-at (max (:last-read-at buffer 0) (:at newest 0)))))
;; How far back a room nobody has seen before counts as already read.
;;
;; A room being joined for the first time replays its whole history, and none
;; of that is news — the reader was not away for it, they were not here. So a
;; new buffer starts caught up rather than at the beginning, or joining a busy
;; channel announces a hundred unread posts from before you arrived.
;;
;; Caught up to a minute ago rather than to this instant, because a live line
;; is timestamped by the server and this by our clock: the two disagree by
;; whatever the skew is, and a live message stamped a few seconds behind us
;; would land under the marker and never be counted. A minute is more skew than
;; there will be and far less than the age of any backlog, and what it costs is
;; that a message sent in the minute before you joined counts as unread — which
;; is the harmless direction.
(def ^:private fresh-room-grace-ms 60000)
(defn- ensure-channel [m name]
(if (contains? m name)
m
(assoc m name {:name name :messages [] :unread 0
:joined? false :joining? false :accessed 0
;; What has been seen, and what the count is derived from.
;; `:unread` and `:mention?` are answers, not records — see
;; `recount`.
:last-read-id nil :mention? false
:last-read-at (max 0 (- (clock/now-ms) fresh-room-grace-ms))
:kind (if (dm? name) :dm :channel)
:peer-did nil :last-activity 0
;; nick -> mode prefix, for the people panel
:users {}})))
(defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
chat view re-renders without every message row watching the media cache."}
media-tick (atom 0))
(defn local-id
"A name for a line the server did not name.
freeq tags a message with a `msgid` and that is a line's identity everywhere
it matters — a reply points at one, an edit rewrites one, a reaction lands on
one. But not every line arrives with one: a replayed backlog can come over
with no tags at all, and a line this client has just sent has none until the
server echoes it back.
Those lines are not nameless to the reader, though. They are on the screen,
they are in the overview, and pressing one should go to it. So they get a
name made out of what they are: who said it, what it said, when, and where.
Two lines identical in all four are the same line as far as anything this
client does with one is concerned.
`local-` because it is this client's alone, and it is never sent: the
server knows only the names it gave out."
[channel from text at]
(str "local-" (hash [channel from text at])))
(defn push-message!
"Append a line to a buffer, creating it if needed, and bump the unread count
unless that buffer is the one on screen. Any image it links to is fetched in
the background, as is the sender's avatar.
The extras are what the message carried beyond its text: `:at` when it was
said, from the server's own `time` tag where there is one, and `:did` who
said it, from the `account` tag — an identity that outlasts whatever nick
they are using today. `:id` names this message so a reply can point at it,
and `:reply-to` is the one it answers. `:reactions` is what people have put
on it already, which on a replayed backlog the server hands over in full."
([channel from text] (push-message! channel from text {}))
([channel from text {:keys [at did id reply-to reactions edited?]}]
(let [at (or at (clock/now-ms))
;; A name of our own where the server gave none. See `local-id`.
mine (when-not id (local-id channel from text at))
who (avatars/actor did from)
;; A room reaching the store matters more than the throttle does: a
;; connection joins every channel at once, and the writes for all but
;; the first would be five seconds away — long enough that quitting
;; straight after signing in is how a client forgets the rooms it just
;; joined. Read before the swap, so this is the arrival that made it.
new-room? (not (contains? @channels channel))]
(doseq [url (media/image-urls text)]
(media/fetch! url #(swap! media-tick inc)))
;; The same tick: an avatar arriving is a picture arriving, and the chat
;; view already repaints on it.
(when who (avatars/fetch! who #(swap! media-tick inc)))
(swap! channels
(fn [m]
(let [m (ensure-channel m channel)
viewing? (and (chat-visible?) (= channel @current))
;; The server hands the same message over more than once: a
;; JOIN replays the backlog, the CHATHISTORY we ask for
;; replays it again, and a line can have arrived live before
;; either. The msgid is the message's identity and it
;; survives every revision, so holding the copy we have is
;; what keeps a rejoin from doubling the buffer — and what
;; keeps a replayed *pre-edit* row from landing under a line
;; already showing the current text.
;; And sometimes it replays a line with no tags at all —
;; no msgid to know it by and no time to place it. That
;; line has no identity, so it arrives new on every rejoin:
;; appended again, timestamped `now` because there is
;; nothing else to timestamp it with, and therefore always
;; newer than the read marker. Left alone it is a buffer
;; that grows a copy per reconnect and a room that cannot
;; be finished reading.
;;
;; What it does have is a sender and words, which for an
;; untagged line is identity enough. The cost is that the
;; same person saying the same thing twice — both times
;; untagged — shows once. Ours and the system's are left
;; out of it: those have no msgid either, and a second
;; "ok" from this client, or a second "alice joined", is
;; a real event rather than a replay.
seen? (if id
(some #(= id (:id %)) (get-in m [channel :messages]))
(and (not= "*" from)
(not= from @form-nick)
(some #(and (nil? (:id %))
(= from (:from %))
(= text (:text %)))
(get-in m [channel :messages]))))]
(cond
;; The copy we already hold is the pre-edit one, and this is
;; the server's collapsed row saying so. Same message, later
;; word: take the text rather than the arrival order.
(and seen? edited?)
(assoc-in m [channel :messages]
(mapv (fn [msg]
(if (= id (:id msg))
(assoc msg :text text :edited? true
:images (media/image-urls text))
msg))
(get-in m [channel :messages])))
seen? m
:else
(-> m
(update-in [channel :messages] conj
{:from from :text text :system? (= "*" from)
:actor who
:images (media/image-urls text)
:at at
;; `:id` is what a reply points at, and
;; `:reply-to` is what this one points at.
:id id :local-id mine :reply-to reply-to
;; The sender has since rewritten this line.
;; Replay says so with a tag rather than by
;; sending the revision, so a message can
;; arrive already edited.
:edited? (boolean edited?)
;; emoji -> the nicks who put it there
:reactions (or reactions {})})
(assoc-in [channel :last-activity] at)
;; A DM is a room named after whoever is in it, and a
;; nick is not a name that lasts. The DID is, so the
;; record keeps it the first time the other end says
;; anything — ours would name the wrong side.
(cond-> (and (dm? channel) did (not= from @form-nick))
(assoc-in [channel :peer-did] did))
;; Reading a room *is* marking it read: a line that
;; arrives while it is on screen moves the marker past
;; itself. Everything else re-derives, so a line arriving
;; in a room nobody is looking at costs a recount of that
;; room and nothing more.
(update channel (if viewing? mark-read recount)))))))
(remember-rooms! new-room?))))
(defn open-channel!
"Show a buffer. A channel we are not in is joined on the way — a row can
outlive the membership behind it (a disconnect drops every channel, the
buffer stays), and opening one is a request to be in it."
[name]
(reset! current name)
;; On a wide window the conversation lives in the chats screen's second
;; pane, beside the list; :chat is the narrow window's way of showing it
;; instead of the list, and there is nothing there to trade it for.
(reset! screen (if (wide?) :chats :chat))
;; A picker belongs to the message it was opened on; carrying it into another
;; buffer would offer to react to something that is no longer on screen.
(reset! reacting nil)
;; And 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.
(when (and @editing (not= name (:channel @editing)))
(reset! editing nil)
(reset! draft ""))
(swap! channels #(-> (ensure-channel % name)
(update name mark-read)
(assoc-in [name :accessed] (swap! access-tick inc))))
(remember-rooms! true)
;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a
;; second JOIN sent in the meantime is what makes the server replay nothing.
(let [buffer (get @channels name)]
(when (and @conn
(str/starts-with? name "#")
(not (:joined? buffer))
(not (:joining? buffer)))
(swap! channels #(assoc-in % [name :joining?] true))
(irc/join! @conn name)))
;; Already in it, and nobody listed: the membership survived a restart the
;; NAMES that came with it did not.
(when (:joined? (get @channels name))
(request-names! name)))
(defn join-saved-rooms!
"Ask to be in every channel `rooms.edn` 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: the file says what we are
in, so on arrival we say it too. 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-let [conn @conn]
(doseq [[name buffer] @channels
:when (and (str/starts-with? name "#")
(not (:joined? buffer))
(not (:joining? buffer)))]
(swap! channels #(assoc-in % [name :joining?] true))
(irc/join! conn name))))
(defn leave-channel!
"Leave a room and forget it: PART on the wire, gone from the list, gone from
`rooms.edn`.
The only way a room leaves the file. Everything else adds — the server
announcing one, a message arriving in one — so without this the list is a
thing that only grows, and the strictness above would have nothing to be
strict about."
[name]
(when (and @conn (str/starts-with? name "#"))
(irc/part! @conn name))
(swap! channels dissoc name)
(when (= name @current)
(reset! current nil)
(reset! screen :chats))
(remember-rooms! true))
(def ^:private parse-reactions reactions/parse-tally)
(def ^:private with-reaction reactions/with-reaction)
(defn update-reaction!
"One reaction folded into the buffer it belongs to. `frq.reactions` says what
that means; this is the atom it means it to."
[channel msgid emoji nick on?]
(swap! channels reactions/update-reaction channel msgid emoji nick on?))
(defn edit-message!
"Rewrite a message in place, and say so.
`frq.edits` is the fold and what it answers; the atom and the picture links
are this half's. A message keeps the id it was born with across every
revision, which is what keeps its reactions, replies and pins attached to it."
[channel msgid from text]
(let [out (edits/apply-edit @channels channel msgid from text
#(assoc % :images (media/image-urls text)))]
(reset! channels (:channels out))
(:result out)))
(defn- names-line [channel names]
(swap! channels #(members/with-names (ensure-channel % channel) channel names)))
(defn- names-end! [channel]
(swap! channels members/names-done channel))
(defn- add-user! [channel nick]
(when (and channel nick)
(swap! channels #(members/add-user (ensure-channel % channel) channel nick))))
(defn- remove-user! [channel nick]
(swap! channels members/remove-user channel nick))
(defn- remove-user-everywhere! [nick]
(swap! channels members/remove-everywhere nick))
(defn- rename-user! [old new]
(swap! channels members/rename-user old new))
(defn- apply-mode! [channel modes args]
(swap! channels members/with-mode channel modes args))
(defn member-list [channel]
(members/member-list @channels channel))
(defn member-count [channel]
(members/member-count @channels channel))
(defn request-names!
"Ask who is in a channel we are already in. freeq re-joins an authenticated
user's channels at registration, which happens without a JOIN reaching this
client — and so without the NAMES that follows one."
[channel]
(when (and @conn channel (str/starts-with? channel "#")
(empty? (get-in @channels [channel :users])))
(irc/send-line! @conn (str "NAMES " channel))))
(declare join! join-call!)
;; --- calls -------------------------------------------------------------------
;; Signaling only. The audio and video themselves are `frq.av`'s, and behind it
;; libjoltmoq's; what happens here is that the server's broadcasts become state
;; the screens can read, and a press becomes a TAGMSG.
(defn apply-call-state!
"A `+freeq.at/av-state` broadcast: fold it in, and say so in the buffer.
The system line is worth the space — a call is the one thing that happens in
a channel while nobody types, and without a line saying so the only trace of
someone joining is a number quietly changing in a banner."
[channel st]
(av/apply-state! channel st)
;; And try to dial. The token may already be in hand — from this join, or
;; from the last time we were in this same session — in which case the
;; server's agreement that we are in the call is the last thing we were
;; waiting for. `try-start-media!` refuses if there is nothing to dial with
;; or a call is already up, so calling it on every state change is safe.
(when (av/in-call? channel)
(av/try-start-media! @form-host))
(let [line (av/state-message st)]
(when (seq line)
(push-message! channel "*" line))))
(defn apply-call-error!
"A `+freeq.at/av-error`. Most say the call failed; one says we lost a race.
`start-collision` means our `av-start` and someone else's crossed and theirs
won. The server names the winning session, so the answer is to join that one
rather than to report an error for something the person asked for and can
have — they wanted to be in a call in this room, and there is one."
[tags code]
(let [reason (or (irc/tag-value tags "+freeq.at/av-reason") code)
session-id (irc/tag-value tags "+freeq.at/av-id")
lc @av/local-call
channel (:channel lc)]
(if (and (= "start-collision" code) (seq session-id) channel
(or (:awaiting-start? lc) (str/blank? (:session-id lc))))
(do
(push-message! channel "*" "Call already open — joining it instead")
(av/stop-media!)
(join-call! channel session-id))
(do
(when channel
(push-message! channel "*" (str "Call error: " reason)))
;; Only tear down a call the error is actually about. A `join-failed`
;; naming someone else's session is not ours to act on.
(when (and lc
(or (str/blank? (or session-id ""))
(str/blank? (:session-id lc))
(= session-id (:session-id lc))))
(av/stop-media!))))))
;; Which channel's POLICY reply is outstanding, or nil. The server answers
;; POLICY with a run of NOTICEs addressed to our nick and naming no channel;
;; without this they land in the status banner one at a time, and the rules
;; the reader is being asked to accept are never readable.
;;
;; ponytail: the run ends at the first line that is not a NOTICE, so a PING
;; landing mid-answer truncates the rules. A reply-tag or a POLICY numeric
;; from the server would end it properly.
(defonce ^:private policy-asking (atom nil))
(defn ask-policy!
"Ask the server what this channel's policy says, so the reader can read what
they are being asked to accept.
The answer comes back as plain NOTICEs to our nick, naming no channel — so
the question is remembered here, and the lines that follow it are filed
under the channel that asked."
[ch]
(swap! channels #(assoc-in (ensure-channel % ch) [ch :policy-text] []))
(reset! policy-asking ch)
(when-let [c @conn]
(irc/send-line! c (str "POLICY " ch " RULES"))
(irc/send-line! c (str "POLICY " ch " INFO"))))
(defn accept-policy!
"Accept the channel's policy and go back in. The JOIN follows immediately:
accepting is only ever done in order to be in the room, and the server takes
the two in the order they are sent."
[ch]
(when-let [c @conn]
(irc/send-line! c (str "POLICY " ch " ACCEPT"))
(swap! channels #(-> (ensure-channel % ch)
(assoc-in [ch :policy-required?] false)
(assoc-in [ch :joining?] true)))
(irc/join! c ch)))
(defn apply-msg!
"Fold one parsed IRC message into the state."
[msg]
(let [{:keys [command params prefix]} msg
from (irc/nick-of prefix)]
(when (and @policy-asking (not= command "NOTICE"))
(reset! policy-asking nil))
(case command
"001" (do (reset! status (if @session
(str "Connected as " (:handle @session))
(str "Connected as " @form-nick)))
(reset! connecting? false)
(reset! 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. The server
;; forgets rooms, and a room it has forgotten is one that
;; would otherwise quietly stop existing.
(join-saved-rooms!)
;; Back where the reader left off. The list is still what a
;; connect lands on underneath, so Back from the reopened
;; channel goes to the chats rather than out of the app.
(if-let [last-ch (first (channel-order))]
(open-channel! last-ch)
(join! auto-join)))
"PRIVMSG" (let [[target text] params
;; The server's clock when it offers one: a replayed
;; backlog is hours or weeks old, and stamping it with
;; the moment it arrived would say it all happened now.
at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms))
tags (:tags msg)
;; a DM addressed to us belongs in a buffer named for the
;; sender, not for our own nick — except when the sender
;; is us: `echo-message` sends our own DM back, and the
;; buffer it belongs to is the one we sent it to.
buffer (cond
(str/starts-with? (or target "") "#") target
(= from @form-nick) target
:else from)
;; What this message rewrites, when it is a rewrite. The
;; server canonicalises the name to `+draft/edit`.
edit-of (irc/tag-value tags "+draft/edit")
;; And what the server says about a line it has already
;; collapsed: replay sends one row per message, carrying
;; the current text and no `+draft/edit` to hint that it
;; is not the original. This tag is the only trace.
replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))]
(if edit-of
;; A revision is not a new line: it replaces the one it
;; names, under that line's own id — never the revision's
;; wire msgid, which nothing else refers to.
(when (= :absent (edit-message! buffer edit-of from text))
;; The original is outside the backlog we hold, so show
;; the current text rather than dropping what was said.
(push-message! buffer from text
{:at at
:did (:account msg)
:id edit-of
:edited? true
:reply-to (or (irc/tag-value tags "+reply")
(irc/tag-value tags "+draft/reply"))}))
(push-message! buffer from text
{:at at
:did (:account msg)
:id (irc/tag-value tags "msgid")
:edited? replayed-edit?
;; 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"))
:reactions (parse-reactions
(irc/tag-value tags "+freeq.at/reactions"))})))
;; A message that is only tags. A reaction is the one this client reads:
;; `+react` puts an emoji on the message `+reply` names, and the server's
;; own `+freeq.at/unreact` takes it off again.
"TAGMSG" (let [tags (:tags msg)
target (first params)
buffer (if (str/starts-with? (or target "") "#") target from)
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"))
remove-it (irc/tag-value tags "+freeq.at/unreact")
call-state (av/parse-state tags)
;; The token is directed at our own nick rather than at
;; the channel, so `buffer` is a DM key here and says
;; nothing about which call it is for. The session id in
;; the tag is what does.
token (irc/tag-value tags "+freeq.at/av-token")
call-error (irc/tag-value tags "+freeq.at/av-error")]
(cond
add (update-reaction! buffer msgid add from true)
remove-it (update-reaction! buffer msgid remove-it from false)
call-state (apply-call-state! buffer call-state)
token (av/apply-token! @form-host
(irc/tag-value tags "+freeq.at/av-id")
token)
call-error (apply-call-error! tags call-error)
:else nil))
"JOIN" (let [ch (first params)]
(if (= from @form-nick)
;; Ours if the file says so — restored from rooms.edn, 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 with rooms.
;;
;; 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? @channels ch))
(not @adopting-rooms?))
(when @conn (irc/part! @conn ch))
(let [fresh? (empty? (get-in @channels [ch :messages]))]
(swap! channels #(-> (ensure-channel % ch)
(assoc-in [ch :joined?] true)
(assoc-in [ch :joining?] false)
;; In the room: whatever it was asking
;; for, it is not asking any more.
(assoc-in [ch :policy-required?] false)))
;; Only on the way in to an empty buffer. A reconnect joins
;; every channel again, and saying so on top of the backlog
;; already there is just a second line of noise.
(when fresh? (push-message! ch "*" (str "Joined " ch)))
;; 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!` and `push-message!`
;; both build the buffer they are given, so either one would
;; put the refused room back in the list.
(when (contains? @channels ch)
(add-user! ch from)
(when-not @hide-join-part?
(push-message! ch "*" (str from " joined"))))))
;; NAMES, a line at a time. The channel is the parameter that names one:
;; the reply is `<us> <symbol> <channel> :<names>`, and a server that
;; leaves the symbol out shifts everything before the list along by one.
"353" (let [ch (first (filter #(str/starts-with? (or % "") "#") params))]
(when ch (names-line ch (last params))))
;; End of NAMES. A plain JOIN is replayed history before this arrives, so
;; a channel that reaches here with nothing in it was restored rather
;; than joined — freeq re-joins an authenticated user's channels at
;; registration and leaves the backlog for the client to ask for.
"366" (let [ch (second params)
said (remove :system? (get-in @channels [ch :messages]))]
(names-end! ch)
(when (and ch @conn (empty? said))
(irc/send-line! @conn
(str "CHATHISTORY LATEST " ch " * " history-limit))))
;; `assoc-in` on a channel that is not there does not fail, it invents
;; one — a buffer with a `:joined?` and nothing else, no name and no
;; unread, which the room list then tries to draw. That is not
;; hypothetical now: refusing a room the file does not claim sends PART,
;; and the server echoes it straight back at us. A membership changing in
;; a room we do not hold is nothing to record.
"PART" (let [ch (first params)]
(if (= from @form-nick)
(swap! channels #(if (contains? % ch)
(-> % (assoc-in [ch :joined?] false)
(assoc-in [ch :joining?] false)
(assoc-in [ch :users] {}))
%))
(when (contains? @channels ch)
(remove-user! ch from)
(when-not @hide-join-part?
(push-message! ch "*" (str from " left"))))))
"KICK" (let [[ch who] params]
(if (= who @form-nick)
(swap! channels #(if (contains? % ch)
(-> % (assoc-in [ch :joined?] false)
(assoc-in [ch :joining?] false)
(assoc-in [ch :users] {}))
%))
(remove-user! ch who))
(when (contains? @channels ch)
(push-message! ch "*" (str who " was kicked by " from))))
;; A QUIT and a NICK name no channel, so both are folded into every
;; buffer the person was listed in — and said out loud only where they
;; were, which is what keeps a stranger's rename out of a quiet room.
"QUIT" (let [rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
(when-not @hide-join-part?
(doseq [ch rooms]
(push-message! ch "*" (str from " quit"))))
(remove-user-everywhere! from))
"NICK" (let [new-nick (last params)
rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
(when (= from @form-nick) (reset! form-nick new-nick))
(doseq [ch rooms]
(push-message! ch "*" (str from " is now " new-nick)))
(rename-user! from new-nick))
"MODE" (let [[target modes & args] params]
(when (str/starts-with? (or target "") "#")
(apply-mode! target modes args)))
"NOTICE"
(if-let [ch @policy-asking]
(swap! channels #(update-in (ensure-channel % ch) [ch :policy-text]
(fnil conj []) (str/trimr (or (last params) ""))))
(reset! status (or (last params) @status)))
("372" "375" "376" "002" "003" "004")
(reset! status (or (last params) @status))
;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
;; 471 full, 403 no such channel. The channel is params[1]; clearing its
;; flags is what lets a later attempt send a JOIN at all.
("473" "474" "475" "477" "403" "471")
(let [ch (second params)
why (last params)]
(when ch
(swap! channels #(-> (ensure-channel % ch)
(assoc-in [ch :joined?] false)
(assoc-in [ch :joining?] false)))
(push-message! ch "*" (str "Could not join " ch " — " why))
;; The one refusal the reader can answer themselves: the room is not
;; shut to them, it is waiting on them to say yes to something. The
;; flag is what puts the Accept button in the channel, and the rules
;; are asked for so it is not a yes to an unread page.
(when (str/includes? (str/lower-case (or why "")) "policy")
(swap! channels #(assoc-in % [ch :policy-required?] true))
(ask-policy! ch)))
;; Deliberately not the global banner: it outlives the screen it was
;; about, and the reason is in the channel's own buffer where it
;; belongs. The banner is for what stops the whole app — a failed
;; connection or a refused sign-in.
(when-not ch (reset! error (str "Cannot join: " why))))
;; What the server refused and why, in the reader's words. An edit or a
;; reaction it will not take is otherwise silent: the line on screen
;; simply never changes, which reads as the app having lost it.
"FAIL" (let [[what _code] params]
(reset! error (str (or what "Request") " refused — "
(or (last params) "no reason given"))))
"903" (reset! status (str "Signed in as " (:handle @session)))
("904" "905" "906") (do (reset! session nil)
;; The broker token may still be good — but a
;; refusal is as likely to mean it is not, and a
;; stale one would fail the same way every time,
;; including across restarts if it were kept.
(reset! broker-token nil)
(store/clear-session!)
(reset! error (str "Bluesky sign-in refused: "
(or (last params) "no reason given"))))
;; The signing key belonged to that connection: the server forgets it
;; when the session ends, and signing with it afterwards would be
;; signing with a key nobody can check.
"*DISCONNECTED*" (do (msgsig/forget!)
(reset! conn nil)
(reset! connecting? false)
(swap! channels
#(reduce-kv (fn [m k v]
(assoc m k (assoc v :joined? false :joining? false :users {})))
{} %))
(reset! status "Disconnected"))
"*ERROR*" (do (reset! error (first params))
(reset! connecting? false))
nil)))
(def plain-port 6667)
(defn- describe
"What went wrong, in words. A jolt condition prints as #object[:object], so
the message and the ex-data are what has to be dug out by hand."
[e]
(let [msg (ex-message e)]
(if (seq msg)
msg
;; A raw host condition prints as #object[:object] and says nothing, so
;; its type is the only thing left worth showing.
(str (type e) ": " (str e)))))
(defn- dial! [host port nick tls? sess]
;; stderr is the only console on Android — this line lands in logcat.
(binding [*out* *err*]
(println "frq: dialing" host port (if tls? "tls" "plain")))
(reset! conn (irc/connect! host port nick apply-msg! tls? sess)))
(defn- connect-blocking!
"Sign in if asked to, then dial. Blocking throughout — a browser handoff can
take a minute, and the TLS handshake is not instant either."
[]
(reset! error nil)
(reset! connecting? true)
(reset! status (str "Connecting to " @form-host ":" @form-port "…"))
(let [host @form-host
port (parse-long (str/trim @form-port))
mode @auth-mode
sess (case mode
;; OAuth: the browser does the talking, we wait on loopback. A
;; broker token in hand skips the browser entirely.
:bluesky
(let [handle (str/trim @form-handle)
browser! (fn []
(reset! status "Opening your browser to sign in…")
(oauth/await-callback!
oauth/default-broker handle
(fn [url]
(reset! login-url url)
(oauth/open-browser! url)
(reset! status "Waiting for the browser…"))))
tokens (if-let [bt @broker-token]
;; A saved token that the broker no longer honours
;; is worth exactly one attempt: drop it and go
;; through the browser, rather than failing the
;; same way on every future Connect.
(try (reset! status "Resuming your session…")
(oauth/refresh-session oauth/default-broker bt)
(catch Exception _
(reset! broker-token nil)
(store/clear-session!)
(reset! status "Saved session expired — signing in again…")
(browser!)))
(browser!))
s (assoc tokens :kind :web-token)]
(reset! login-url nil)
(reset! broker-token (:broker-token tokens))
;; Saved on every sign-in, 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)
(reset! session s)
(when (seq (:handle tokens)) (reset! form-handle (:handle tokens)))
s)
:app-password
(do (reset! status (str "Signing in as " (str/trim @form-handle) "…"))
(let [s (assoc (atproto/create-session (str/trim @form-handle)
@form-app-password)
:kind :pds-session)]
(reset! session s)
;; The password did its work at the PDS; do not keep it.
(reset! form-app-password "")
s))
nil)
;; An authenticated connection still needs a nick — the DID is the
;; identity, the nick is only what the channel calls you.
nick (if sess
(or (:nick sess)
(-> (or (:handle sess) "") (str/split #"\\.") first)
(str/trim @form-nick))
(str/trim @form-nick))]
(when sess (reset! form-nick nick))
(try
(dial! host port nick @form-tls? sess)
(catch Exception e
(binding [*out* *err*] (println "frq: dial failed:" (describe e)))
(if @form-tls?
(do (reset! status (str "TLS unavailable — trying " host ":" plain-port "…"))
(try
(dial! host plain-port nick false sess)
(reset! form-tls? false)
(reset! form-port (str plain-port))
(catch Exception e2
(binding [*out* *err*] (println "frq: plain dial failed:" (describe e2)))
(reset! connecting? false)
(reset! conn nil)
(reset! status "Not connected")
(reset! error (str "Could not connect: " (describe e2))))))
(do (reset! connecting? false)
(reset! conn nil)
(reset! status "Not connected")
(reset! error (str "Could not connect: " (describe e)))))))))
(defn connect!
"Start connecting. The work happens on another thread: the OAuth wait sits on
a loopback accept until the browser comes back, and the UI has frames to
paint in the meantime.
A second call while one is in flight is ignored. Dialling twice does not just
waste a socket: the server treats the second session as a reconnect of the
first, and a reconnect is not replayed the channel history a fresh join gets,
so the second connection — the one the UI ends up holding — shows an empty
channel."
[]
(when-not (or @connecting? @conn)
(reset! error nil)
(reset! connecting? true)
(future
(try (connect-blocking!)
(catch Exception e
(reset! connecting? false)
(reset! conn nil)
(reset! status "Not connected")
(reset! error (str "Could not connect: " (describe e))))))))
(defn restore-session!
"Pick up a saved sign-in at startup. Only the durable broker token comes
back; the connection still mints a fresh web-token from it."
[]
(when-let [saved (store/load-session)]
(reset! broker-token (:broker-token saved))
(when (seq (:handle saved)) (reset! form-handle (:handle saved)))
(when (seq (:nick saved)) (reset! form-nick (:nick saved)))
(reset! auth-mode :bluesky)
(reset! status (str "Signed in as " (:handle saved) " — Connect to resume"))
saved))
(defn forget-session!
"Drop the saved sign-in, on disk and in memory."
[]
(store/clear-session!)
(reset! broker-token nil)
(reset! session nil)
(reset! auth-mode :guest)
(reset! status "Not connected"))
(defn disconnect! []
(when-let [c @conn] (irc/close! c))
(reset! conn nil)
(reset! session nil)
;; The buffers survive, the memberships do not — leaving `joined?` set would
;; have the next Open show a channel nobody is in.
(swap! channels #(reduce-kv (fn [m k v]
(assoc m k (assoc v :joined? false :joining? false :users {})))
{} %))
(reset! status "Not connected")
(reset! screen :connect))
(defn open-dm!
"Open a conversation with one person. There is nothing to join — a DM buffer
is a place to type at somebody, and it exists as soon as it is asked for.
Our own nick is not one of them: a buffer talking to yourself would take the
place in the list of one that could answer."
[nick]
(let [nick (str/trim (or nick ""))]
(when (and (seq nick) (not= nick @form-nick))
(open-channel! nick))))
(defn join! [name]
;; Deliberately not clearing `error` here: joining is what follows a
;; successful registration, and a SASL refusal that arrived moments earlier
;; is the one thing the user most needs to still be on screen.
;;
;; `@nick` opens a DM instead. One box for both: what the reader wants is to
;; be somewhere, and the sigil says where — the same way it does on the wire.
(let [name (str/trim (or name ""))]
(if (str/starts-with? name "@")
(open-dm! (subs name 1))
(let [ch (normalize-channel name)]
(when (seq ch)
(open-channel! ch))))))
;; ------------------------------------------------------------------ pasting
(def attachment cells/attachment)
;; Each paste gets a file of its own rather than overwriting the last: the
;; preview is painted from the file, and an upload may still be reading it.
(defonce ^:private paste-count (atom 0))
(defn- paste-path []
(let [n (swap! paste-count inc)]
(str (media/cache-dir) "/outgoing/paste-" n ".png")))
(defn- discard-file!
"Drop a paste's copy on disk. Nothing else keeps it: the picture that matters
after sending is the one the server serves back, which the media cache
fetches like any other."
[path]
(when path (try (host/delete-file! path) (catch Exception _ nil))))
(defn clear-attachment!
"Drop the pasted picture without sending it."
[]
(when-let [a @attachment]
(reset! attachment nil)
(discard-file! (:path a))))
(defn- attach!
"Hold the picture already written to `path` — a copy of ours under
`outgoing/` — against the next line, and start its upload.
The upload runs off the UI thread and 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.
`filename` is what the server files it under; it says which gesture the
picture came in by, and nothing else depends on it."
[path filename]
(let [did (:did @session)
host-name @form-host
channel @current]
(reset! error nil)
(clear-attachment!)
(reset! attachment {:path path :status :uploading})
(future
(try
(let [url (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! attachment #(if (= (:path %) path)
(assoc % :url url :status :ready)
%))
(when-not (= (:path @attachment) path) (discard-file! path)))
(catch Exception e
(swap! attachment #(if (= (:path %) path) nil %))
(discard-file! path)
(reset! error (or (ex-message e) (str e))))))))
(defn paste-image!
"Take the picture on the clipboard and hold it against the next line.
IRC has nowhere to put an image, so a link is the whole of what sending one
means — but that is a fact about the wire, not something the reader should
have to type around. The picture is attached: shown under the draft while
they write whatever they are sending it with, and turned into a link only on
the way out."
[]
(let [path (paste-path)]
(host/mkdirs! (str (media/cache-dir) "/outgoing"))
(if-not (platform/clipboard-image-png! path)
;; Android has no clipboard of pictures to read at all, which is the
;; other half of why the picker below exists.
(reset! error "No picture on the clipboard.")
(attach! path "paste.png"))))
;; ------------------------------------------------------------------ picking
(def image-picker cells/image-picker)
(defn- readable-dir? [path]
(try (and (host/file-exists? path) (host/directory? path))
(catch Exception _ false)))
(defn picker-roots
"The places worth opening the picker on, on whichever platform this is.
Only the ones that are actually there: a phone has no ~/Pictures and a
desktop no /sdcard, and a list of directories that are not there is a list of
dead ends. On Android everything outside the app's own storage is behind a
runtime permission this activity has no code to ask for, so what survives
this filter there is usually the app's own files — which is the honest
answer, not a bug to paper over."
[]
(let [home (or (host/getenv "HOME") "")
under (fn [base] (when (seq base)
(map #(str base "/" %)
["Pictures" "Downloads" "Download" "DCIM"])))]
(vec (distinct (filter readable-dir?
(concat (under home)
(under "/sdcard")
(under "/storage/emulated/0")
[(media/cache-dir) home]))))))
(defn- png? [name]
(str/ends-with? (str/lower-case (str name)) ".png"))
(defn picker-entries
"What `dir` holds, as `{:dirs [...] :files [...]}` of full paths.
PNG only, for the same reason the media cache reads PNG only: it is what the
tree backend paints and what the upload sends. An unreadable directory —
which on Android is most of them — answers empty rather than throwing.
Hidden entries are left out: nothing a reader means to send lives in one, and
a home directory is unusable as a list with them in it."
[dir]
(let [names (try (sort (host/list-dir dir)) (catch Exception _ nil))
keep (remove #(str/starts-with? (str %) ".") names)
path (fn [n] (str dir "/" n))]
{:dirs (vec (filter readable-dir? (map path keep)))
:files (vec (map path (filter png? keep)))}))
(defn parent-dir
"The directory above `dir`, or nil at the top."
[dir]
(let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
(when (and (seq up) (not= up dir) (readable-dir? up)) up)))
;; ------------------------------------------- the platform's own chooser
;; Polling, because a chooser is another app's screen: it takes the reader away
;; and gives nothing back through a handler here. `choosing` is what the poll
;; runs on, and the count is what ends it — a reader who backs out without
;; choosing tells us nothing at all, so the alternative is a poll that outlives
;; the app's interest in the answer.
(defonce ^:private choosing (atom nil))
(def ^:private choose-poll-ms 300)
(def ^:private choose-poll-limit
"Five minutes of asking. Long enough for someone who wandered off mid-choice,
short enough that a cancelled chooser is not still being polled for at
bedtime."
1000)
(defn- take-chosen!
"Attach the picture the chooser has written, if it has written one yet."
[]
(let [path (paste-path)]
(host/mkdirs! (str (media/cache-dir) "/outgoing"))
(when (platform/picked-image! path)
(reset! choosing nil)
(attach! path "picture.png")
true)))
(defn- poll-chosen! []
(when-let [left @choosing]
(when-not (take-chosen!)
(if (pos? left)
(do (reset! choosing (dec left))
(platform/after! choose-poll-ms poll-chosen!))
(reset! choosing nil)))))
(defn choose-image!
"Open the platform's own picture chooser, where there is one; true when it
opened.
Preferred to browsing on a phone, and not only for the taste of it: what the
chooser hands back is a grant for the one picture the reader chose, so the
app needs no permission over their pictures at all — and without such a
permission, browsing finds almost nothing to show. False where there is no
chooser, which is every desktop, and there browsing is the answer."
[]
(when (platform/pick-image!)
(reset! error nil)
(reset! choosing choose-poll-limit)
(platform/after! choose-poll-ms poll-chosen!)
true))
(defn open-image-picker!
"Ask for a picture, whichever way this platform has of choosing one.
The platform's own chooser where there is one — it needs no permission and
knows where the reader's pictures actually are — and otherwise this app's
own browsing screen, which is what a desktop gets."
[]
(when-not (choose-image!)
(reset! error nil)
(reset! image-picker (or (first (picker-roots)) "/"))))
(defn close-image-picker! [] (reset! image-picker nil))
(defn browse! [dir] (when (readable-dir? dir) (reset! image-picker dir)))
(defn- copy-file!
"Copy `from` to `to`, byte for byte."
[from to]
(let [in (java.io.FileInputStream. from)]
(try
(let [out (java.io.FileOutputStream. to)]
(try (.write out (.readAllBytes in))
(finally (.close out))))
(finally (try (.close in) (catch Exception _ nil))))))
(defn pick-image!
"Attach the picture at `path` and close the picker.
Copied into `outgoing/` first 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 pictures folder."
[path]
(let [copy (paste-path)]
(try
(host/mkdirs! (str (media/cache-dir) "/outgoing"))
(copy-file! path copy)
(close-image-picker!)
(attach! copy (or (last (str/split (str path) #"/")) "picture.png"))
(catch Exception e
(discard-file! copy)
(reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
(defn- dm-peer-did
"Who this DM is with. `frq.reactions` reads it out of the buffer."
[channel]
(reactions/peer-did @channels channel @form-nick))
(defn mine?
"Whether we are the one who said this. Nick against nick, which is what the
server itself falls back to for an account with no DID — and an edit it would
refuse is one not worth offering."
[m]
(rooms/mine? m @form-nick))
(defn start-edit!
"Put a message back in the box to be rewritten.
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. Whatever was half-typed is dropped — a draft and an edit are two things
to say, and the box holds one."
[channel m]
(when (and (:id m) (mine? m))
(reset! replying-to nil)
(reset! editing {:channel channel :id (:id m)})
(reset! draft (or (:text m) ""))))
(defn cancel-edit!
"Leave the message as it was said. 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
the reader wrote."
[]
(reset! editing nil)
(reset! draft ""))
(defn send-draft!
"Send the draft, with whatever picture is attached to it.
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.
A picture still on its way up holds the send rather than losing it: the line
is left 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.
A draft with a break in it is several messages. There is no newline on the
wire — a PRIVMSG is one line and a line ends where the protocol says it does
— so the box that lets a reader write a paragraph has to be the thing that
takes it apart again: one message a line, in order, blank lines dropped. Only
the last one carries the picture, and only the first one answers the message
being replied to; the rest are the same thought continuing."
[]
(let [lines (->> (str/split-lines @draft)
(map str/trim)
(remove str/blank?))
;; What the branches below that are about one line read: a command and
;; a rewrite are single-line things whatever the box holds.
text (str/join " " lines)
target @current
reply-to @replying-to
edit @editing
{:keys [url status path] :as att} @attachment]
(cond
(not target) nil
;; A line beginning with "/" is said to the server, not to the room:
;; POLICY, MODE, whatever the server asks for by name. Without it a
;; channel that answers a JOIN with "use POLICY <channel> ACCEPT" is one
;; the reader can see the instructions for and has no way to follow.
;; "//" is how you say a line that really does start with a slash.
(and (str/starts-with? text "/") (not (str/starts-with? text "//")))
(if-let [c @conn]
(let [line (str/trim (subs text 1))]
(when (seq line)
(irc/send-line! c line)
(push-message! target "*" (str "> " line)))
(reset! draft ""))
(reset! error "Not connected."))
(= :uploading status) (reset! error "The picture is still uploading.")
;; 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.
(and edit att) (reset! error "Finish the edit before sending a picture.")
(and edit (str/blank? text)) nil
edit
(do (when-let [c @conn]
(irc/edit! c (:channel edit) (:id edit) text
(dm-peer-did (:channel edit))))
;; Same reason as a new message: the server's echo is the copy that
;; 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 (some-> @conn (irc/cap-acked? "echo-message"))
(edit-message! (:channel edit) (:id edit) @form-nick text))
(reset! editing nil)
(reset! draft ""))
(and (str/blank? text) (not url)) nil
:else
(let [lines (map #(if (str/starts-with? % "//") (subs % 1) %) lines)
;; The picture rides the last line, so a message that is only a
;; picture is the link on its own.
lines (if (seq lines) (vec lines) [""])
last-i (dec (count lines))
lines (map-indexed (fn [i line]
(str/trim (str line (when (and url (= i last-i))
(str " " url)))))
lines)]
;; Saying something is a way of asking to see it.
(jump-to-present!)
(doseq [[i line] (map-indexed vector lines)]
(when-let [c @conn]
(irc/privmsg! c target line (when (zero? i) (:id reply-to))))
;; Only when the server will not send the line back itself. Its copy
;; carries the msgid, and a message with no id is one nobody can react
;; or reply to; echoing locally as well would put the line up twice.
(when-not (some-> @conn (irc/cap-acked? "echo-message"))
(push-message! target @form-nick line
{:reply-to (when (zero? i) (:id reply-to))})))
(reset! replying-to nil)
(reset! draft "")
(when att
(reset! attachment nil)
;; The picture on screen from here on is the one fetched back from
;; the link, like everyone else's.
(discard-file! path))))))
(defn open-picker!
"Choose an emoji for this message. Opening it fresh — no leftover search from
the last time, which would be a screen of somebody else's question."
[channel m]
(when (:id m)
(reset! emoji-search "")
(reset! emoji-group nil)
(reset! reacting {:channel channel :id (:id m)})))
(defn close-picker! [] (reset! reacting nil))
(def picker-emoji
"Moved to `frq.reactions`: it is the cells and the catalog, both of which
are shared, and the phone shows the same picker."
reactions/picker-emoji)
(defn my-reaction?
"Whether this nick is already on that emoji — which is what makes a second
click take it off rather than send the same reaction twice."
[m emoji]
(reactions/mine? m emoji @form-nick))
(def reaction-hover cells/reaction-hover)
(defn hover-reaction!
"The pointer has come to rest on a pill."
[msgid emoji]
(reset! reaction-hover {:id msgid :emoji emoji}))
(defn unhover-reaction!
"The pointer has left that pill. Guarded by which one is being left, so
crossing straight from one pill to the next — both edges in a frame — cannot
take down the card that has just been raised."
[msgid emoji]
(swap! reaction-hover #(when-not (= {:id msgid :emoji emoji} %) %)))
(defn hovering-reaction?
"Whether this is the pill the card belongs to."
[msgid emoji]
(= {:id msgid :emoji emoji} @reaction-hover))
(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 [on? (not (my-reaction? m emoji))
;; Who the DM is with, for the signature: freeq names a DM by both
;; DIDs rather than by a nick, and nothing else in a buffer says
;; which account the other side is. nil in a channel, which is named
;; by itself.
peer (dm-peer-did channel)]
(when-let [c @conn]
(if on?
(irc/react! c channel msgid emoji peer)
(irc/unreact! c channel msgid emoji peer)))
(update-reaction! channel msgid emoji @form-nick on?))))
(defn start-call!
"Open a call on this channel.
Optimistic: the controls appear on the press. What comes back settles it —
an `av-state` says the room has a call, an `av-token` starts the media, and
a `start-collision` means someone beat us to it and we join theirs instead."
[channel]
(when-let [c @conn]
(let [nick (or (:nick @session) @form-nick)
instance (av/begin! {:channel channel
:nick nick
:muted? false
:speaker-muted? false
;; Audio first, always. A call that opened with
;; the camera on would be a call that showed
;; someone's room before they had agreed to.
:camera? false})]
(irc/tagmsg! c channel (av/start-tags instance nil)))))
(defn join-call!
"Join the call already open on this channel."
[channel session-id]
(when-let [c @conn]
(let [nick (or (:nick @session) @form-nick)
instance (av/begin! {:channel channel
:session-id session-id
:nick nick
:muted? false
:speaker-muted? false
:camera? false})]
(irc/tagmsg! c channel (av/join-tags session-id instance)))))
(defn announce-leave!
"Tell the room this device is out of a call it did not choose to leave.
freeq counts a participant until an `av-leave` says otherwise, so a media
plane that fails silently leaves a ghost behind — and the next Join adds
another beside it. Registered with `frq.av` at startup, because that
namespace has no connection to send on."
[{:keys [channel session-id instance]}]
(when (and @conn (seq (or session-id "")))
(irc/tagmsg! @conn channel (av/leave-tags session-id instance))))
(defn leave-call!
"Leave the call, telling the room and the SFU both.
The media plane goes down first and on its own account: the person pressed
leave, so the microphone should be shut whether or not the TAGMSG gets out."
[]
(when-let [{:keys [channel session-id instance]} @av/local-call]
(av/stop-media!)
(when-let [c @conn]
(when (seq session-id)
(irc/tagmsg! c channel (av/leave-tags session-id instance))))))
(def channel-list rooms/channel-list)
(defn channel-order
"The buffer names, most recently opened first — what gets written to disk.
Buffers never opened are left out: a DM that arrived once and was never read
is not a place this client has been, and neither is a channel someone
mentioned. One that was opened is, whether it is a room or a person."
[]
(->> (vals @channels)
(filter #(pos? (:accessed % 0)))
(sort-by #(- (:accessed % 0)))
(mapv :name)))
(defn room-records
"The rooms as they go to disk: what each one is, when it last said anything,
and how far into it the reader has got.
Every room, not only the ones that have been opened. Being in a channel is
what makes it yours; opening it only says which you looked at last, and that
is what `:accessed` orders them by. Writing down the opened ones alone is how
a client in a dozen channels came back knowing one — the rest were left for
the server to remember, which is the thing it does not do.
`:unread` and `:mention?` are not written. They are what the marker adds up
to against the messages in hand, and a count written down is a count that can
be wrong — the marker cannot be. `:mention?` rides along all the same, as the
one thing that cannot be recomputed before the history it was derived from
comes back: a room that had your name in it says so on the next run's first
frame rather than a round trip later, and is corrected by `recount` the
moment the backlog lands."
[]
(->> (vals @channels)
(sort-by #(- (:accessed % 0)))
(mapv #(select-keys % [:name :kind :peer-did :last-activity
:last-read-id :last-read-at :mention?]))))
(defn restore-channels!
"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.
Empty buffers, not memberships: opening one is what joins it, and a list of
rooms is the part worth keeping — the messages in them come from the server.
The tick is seeded so this run's first open still sorts above all of them.
The marker is what makes the returning backlog readable. Without one every
replayed line is new and every room comes back with its whole history
unread; with one, the reader is put back where they were and only what
arrived while they were away is counted. A room migrated from an older frq
has no marker and is caught up as if read, which is the kinder of the two
wrong answers — the alternative announces a hundred unread lines the reader
has already seen."
[]
(when-let [saved (seq (store/load-rooms))]
(let [ordered (reverse saved)] ; oldest first, so ticks ascend
(swap! channels
(fn [m]
(reduce (fn [acc room]
(let [name (:name room)]
(if (contains? acc name)
acc
(assoc acc name
{:name name :messages [] :unread 0
:joined? false :joining? false
:users {}
:kind (or (:kind room)
(if (dm? name) :dm :channel))
:peer-did (:peer-did room)
:last-activity (:last-activity room 0)
:last-read-id (:last-read-id room)
;; No marker at all — an older frq's list,
;; or a record that lost it. Read up to
;; now rather than back to the beginning.
:last-read-at (:last-read-at room
(clock/now-ms))
:mention? (boolean (:mention? room))
:accessed (swap! access-tick inc)})))
)
m
ordered))))
(count saved)))
(defn message-by-id
"The message a reply points at, if this buffer still holds it.
By either name, since a jump may be aiming at a line the server never gave
one to. See `local-id`."
[channel id]
(when id
(first (filter #(= id (rooms/row-id %)) (get-in @channels [channel :messages])))))
(defn react-from-picker!
"Put the chosen emoji on the message the picker was opened for, and close it.
One choice and back to the conversation: a picker left open would be asking a
question that has been answered."
[emoji]
(when-let [{:keys [channel id]} @reacting]
(when-let [m (message-by-id channel id)]
(toggle-reaction! channel m emoji))
(close-picker!)))
(def jump-to cells/jump-to)
(def highlight cells/highlight)
(def overview-limit rooms/overview-limit)
(def recent-everywhere rooms/recent-everywhere)
(def last-preview rooms/last-preview)
;; What the shared screens call. Installed here rather than in an entry point
;; because these are this namespace's own reducers, and the screens that call
;; them are no longer in a position to name them.
(actions/install!
{:connect! connect!
:disconnect! disconnect!
:forget-session! forget-session!
:connected? connected?
:join! join!
:open-channel! open-channel!
:leave-channel! leave-channel!
:toggle-hide-join-part! toggle-hide-join-part!
:browse! browse!
:close-image-picker! close-image-picker!
:pick-image! pick-image!
:parent-dir parent-dir
:picker-entries picker-entries
:picker-roots picker-roots
:media-tick (fn [] @media-tick)
:avatar-ready (fn [actor] (avatars/path-when-ready actor))
:send-draft! send-draft!
:cancel-edit! cancel-edit!
:cancel-reply! cancel-reply!
:clear-attachment! clear-attachment!
:open-image-picker! open-image-picker!
:paste-image! paste-image!
:jump-to-present! jump-to-present!
:scrolled! scrolled!
:toggle-users! toggle-users!
:toggle-chat-list! toggle-chat-list!
:toggle-overview! toggle-overview!
:wide? wide?
:member-count member-count
:start-call! start-call!
:in-call? av/in-call?
:call-in av/call-in
:call-available? av/available?
:desktop? platform/desktop?
:quit! platform/quit!
:avatar-path nil
:image-path nil
:local-call (fn [] @av/local-call)
:local-feed (fn [] av/local-feed)
:media-error (fn [] @av/media-error)
:tiles av/tiles
:tile-rows av/tile-rows
:set-muted! av/set-muted!
:set-speaker-muted! av/set-speaker-muted!
:set-camera! av/set-camera!
:after! platform/after!
:open-url! platform/open-url!
:accept-policy! accept-policy!
:close-picker! close-picker!
:hover-reaction! hover-reaction!
:join-call! join-call!
:leave-call! leave-call!
:leaving-for-overview! leaving-for-overview!
:member-list member-list
:message-by-id message-by-id
:mine? mine?
:my-reaction? my-reaction?
:open-dm! open-dm!
:open-picker! open-picker!
:overview-back! overview-back!
:picker-emoji picker-emoji
:react-from-picker! react-from-picker!
:recent-everywhere recent-everywhere
:reply-to! reply-to!
:start-edit! start-edit!
:toggle-reaction! toggle-reaction!
:unhover-reaction! unhover-reaction!})
|