nandi/frqpublic Fork 0
c945c1fb2a68f453a26a0b00723950bbcc705b66
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Show the overview strip on the Flutter side too 5f32523 · on c945c1fb2a68f453a26a0b00723950bbcc705b66 · nandi · 7d ago
state.clj · 1804 lines · 80.3 KBClojure Blame HistoryRaw
   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!})