nandi/frqpublic Fork 0
6ab08e747b782b7c96acc75ae014a294f6c1ded1
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.

Share the room list and the read markers with the phone f751b7d · on 6ab08e747b782b7c96acc75ae014a294f6c1ded1 · nandi · 8d ago
main.cljd · 1232 lines · 60.4 KBGDScript3 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
(ns frq.main
  "The Flutter entry point: what `frq.cosmic` is to the jolt half.

  Two jobs, and the first one is why `frq.io.dart` cannot install itself the
  way `frq.io.jolt` does: the app's storage directory arrives from
  path_provider as a Future, so `main` is async, awaits it, and installs the
  host before a widget is built. Everything under ../common asks `frq.io` for
  the filesystem, so nothing shared can run until that has happened.

  `ensureInitialized` first, and not as a formality: path_provider is a
  platform channel, and a channel used before the binding exists throws.

  The screens themselves are not written. `frq.app` is 1,834 lines of hiccup
  over glimmer's widget tags, and glimmer is not here — Flutter brings its own
  reconciler, so those screens are a rewrite against cljd.flutter rather than a
  port. What this paints is the proof the shared half is alive under a second
  compiler: the clock and the saved session, read through exactly the
  namespaces the desktop reads them through."
  (:require ["dart:async" :as async]
            ["dart:io" :as dio]
            ["package:flutter/material.dart" :as m]
            ["package:path_provider/path_provider.dart" :as pp]
            [cljd.flutter :as f]
            [frq.hiccup :as h]
            [frq.theme :as t]
            [frq.io.dart :as host]
            ;; The seam, not the implementation above: `open-url!` is asked for
            ;; the same way the shared half asks for it.
            [frq.io :as fio]
            [frq.crypto.dart :as crypto-dart]
            [frq.net.dart :as net]
            [frq.atproto.dart :as atproto]
            [frq.clock :as clock]
            [frq.media.core :as media-core]
            [frq.media.dart :as media]
            [frq.store :as store]
            [frq.actions :as actions]
            [frq.cells :as cells]
            [frq.screens.connect :as connect]
            [frq.screens.chats :as chats]
            [frq.screens.chat :as chat]
            [frq.screens.settings :as settings]
            [frq.screens.app :as screens]
            [frq.rooms :as rooms]
            [frq.members :as members]
            [frq.reactions :as reactions]
            [frq.edits :as edits]
            [frq.profile :as profile]
            [frq.avatars.dart :as avatars]
            ["package:image_picker/image_picker.dart" :as picker]
            [frq.upload.dart :as upload]
            [frq.irc.mutate :as mutate]
            [frq.oauth.core :as oauth]
            [frq.oauth.dart :as oauth-dart]
            [frq.irc.parse :as irc]
            [frq.irc.handshake :as handshake]))

(defonce ^:private lines (atom []))
(defonce ^:private conn (atom nil))

(defonce ^:private last-line (atom nil))

;; Ticked whenever any cell changes, so one `:watch` covers all of them.
;;
;; What this replaced was a list of cells named by hand in the widget below,
;; and everything not on it was a control that flipped its cell and repainted
;; nothing  People, Overview and hide join/part all did.
;;
;; Up here rather than beside `watch-cells!` because it is no longer only
;; cells that tick it: an avatar landing is a change to nothing under
;; `frq.cells`  the URL is in `frq.avatars.dart`  and `room!`, which is the
;; one that asks for it, is defined below this and above that.
(defonce ^:private repaint (atom 0))

(defn- bump!
  "Repaint whatever is on screen. What a background fetch calls when its answer
  has arrived and the rows that read it do not know."
  []
  (swap! repaint inc)
  nil)

(defn- note-window-size!
  "Tell `frq.cells` how big the window is, from the build that knows.

  The shared screens size a picture against it  `preview-height` is four
  fifths of the window and falls back to a fixed 260 while nobody has said 
  so without this a phone drew every screenshot as the same stamp a terminal
  gets. The desktop sets these from libcosmic's resize event; Flutter's
  equivalent is MediaQuery, which is only readable from a build.

  After the frame, and only when it changed. A cell written during a build is
  a setState during a build, which Flutter refuses; and every cell here is
  watched, so writing the same number back on every frame would be an endless
  rebuild rather than a wasted one."
  [^m/Size size]
  (let [w (long (.-width size))
        h (long (.-height size))]
    (when (or (not= w @cells/window-width) (not= h @cells/window-height))
      (.addPostFrameCallback
       (.-instance m/WidgetsBinding)
       (fn [_]
         (reset! cells/window-width w)
         (reset! cells/window-height h)
         nil)))))

(defn- images-in
  "The picture links in a message, fetched as they are noticed.

  Both at once on purpose: the list is what the row draws and the fetch is
  what turns a link into a path, and a line whose images were recorded and
  never fetched is a row that waits for ever. `frq.media.dart` is the once-only
  half  this is called again for every replayed line on every reconnect."
  [text]
  (let [urls (media-core/image-urls text)]
    (doseq [url urls] (media/fetch! url bump!))
    urls))

(defn- edit-message!
  "Rewrite a message in place, and say so.

  `frq.edits` is the fold and what it answers; the cell and the picture links
  are this half's. The links are read again off the new text because a
  revision can add one or take one away, and a row drawing the picture its
  line no longer mentions is the shape that gave it away. The desktop's
  `frq.state/edit-message!` is the same function over its own atom."
  [room msgid from text]
  (let [out (edits/apply-edit @cells/channels room msgid from text
                              #(assoc % :images (images-in text)))]
    (reset! cells/channels (:channels out))
    (:result out)))

;; ------------------------------------------------------------------- rooms
;;
;; `rooms.edn` is the authority for what rooms there are, and until now only
;; the desktop kept one: this half started every launch with an empty list,
;; adopted whatever the server announced, and asked for `#test` on top. Which
;; looked like the room list forgetting itself between runs, because it was.
;;
;; The file and its format are `frq.store`, which is already shared  the port
;; is the half that was missing here: read it at startup, write it when the
;; list changes, and ask to be in what it says at 001.

(def ^:private auto-join
  "The room a client with nothing saved lands in, as on the desktop."
  "#test")

;; Whether `rooms.edn` is the authority yet.
;;
;; It is not, the first time this version runs: freeq re-joins an
;; authenticated user's channels at registration, so on connect the server
;; announces every room it has you in  and a client that parted everything
;; not already in its file would walk out of all of them before the file had
;; ever been told they existed. So the first connect adopts what the server
;; says and writes it down, and every connect after that is the strict one.
(defonce ^:private room-list-owned? (atom false))

;; And whether this session is the adopting one, decided at 001.
(defonce ^:private adopting-rooms? (atom false))

(defn- save-prefs!
  "Keep the one flag that has to outlive the run: whether this client has
  taken the room list over from the server. Merged into whatever else
  `prefs.edn` holds  the display toggles are the desktop's to write, and a
  whole-map write from here would drop them."
  []
  (store/save-prefs! (assoc (store/load-prefs)
                            :room-list-owned? @room-list-owned?))
  nil)

(defn- restore-prefs! []
  (reset! room-list-owned? (boolean (:room-list-owned? (store/load-prefs))))
  nil)

(def ^:private remember-rooms!
  "Write the room list out, most recently used first. `frq.rooms` — the same
  records the desktop writes, throttled the same way, so a marker one half
  moved is one the other reads back."
  rooms/remember-rooms!)

(def ^:private restore-rooms!
  "Bring back the rooms of earlier runs, in the order they were last used,
  each with the marker saying how much of it had been read. `frq.rooms` again:
  the buffers this makes are the ones `recount` counts against."
  rooms/restore-channels!)

(defn- ask-to-join!
  "Send the JOIN, once. `:joining?` as well as `:joined?`, because the echo
  takes a round trip: a second JOIN sent in the meantime is what makes the
  server replay nothing, and with the saved list asked for at 001 and the last
  room opened straight after, that window is every launch."
  [name]
  (when-let [c @conn]
    (when (and (.startsWith (str name) "#")
               (let [b (get @cells/channels name)]
                 (and (not (:joined? b)) (not (:joining? b)))))
      (swap! cells/channels assoc-in [name :joining?] true)
      (net/send-line! c (str "JOIN " name))))
  nil)

(defn- open-room!
  "Show a buffer, joining it on the way. A row can outlive the membership
  behind it  a disconnect drops every channel and the buffer stays  so
  opening one is a request to be in it.

  Ensuring the buffer *before* the JOIN is what keeps the strict rule in the
  JOIN handler from throwing the room straight back out: a channel we asked
  for is one the list already holds by the time the echo lands."
  [name]
  (reset! cells/current name)
  ;; An edit belongs to a line in the buffer being left: carried across, the
  ;; next Send would rewrite a message nobody in this room can see. The box
  ;; empties with it, because what is in it is a copy of that line 
  ;; `frq.state/open-channel!` does this too.
  (when (and @cells/editing (not= name (:channel @cells/editing)))
    (reset! cells/editing nil)
    (reset! cells/draft ""))
  ;; Opening a room is reading it: the marker goes to the newest line this
  ;; buffer holds, which is what stops the backlog that follows from arriving
  ;; unread all over again. The tick is `frq.rooms`', so a room opened this
  ;; run sorts above every one `restore-rooms!` counted in.
  (swap! cells/channels
         #(-> (rooms/ensure-channel % name)
              (update name rooms/mark-read)
              (assoc-in [name :accessed] (swap! rooms/access-tick inc))))
  (reset! cells/screen :chat)
  ;; Worth a write of its own: the order the list is read in is the order
  ;; rooms were last opened, and this is the moment it changes.
  (remember-rooms! true)
  (ask-to-join! name)
  nil)

(defn- join-saved-rooms!
  "Ask to be in every channel the file says we are in.

  The server re-joins an authenticated user's channels itself and gets it
  wrong in both directions  it forgets rooms and announces ones that are not
  ours. This is the half that answers the forgetting. A JOIN for a channel the
  server has already put us in is answered with the membership we already
  have, so asking twice costs nothing.

  DMs are not asked for: there is nothing to join in a conversation with a
  person, the buffer is the whole of it."
  []
  (when @conn
    (doseq [[name _] @cells/channels] (ask-to-join! name))
    nil))

(defn- forget-memberships!
  "The buffers survive a lost connection, the memberships do not.

  Leaving `:joined?` set across a drop is what made a reconnect ask for
  nothing: `ask-to-join!` reads it, every room still claimed to be joined, and
  the list came back full of rooms we were no longer in. The users go with it 
  the roll is the server's and it will send a fresh one with the NAMES that
  follow the re-JOIN."
  []
  (swap! cells/channels
         (fn [m]
           (reduce-kv (fn [acc k v]
                        (assoc acc k (assoc v :joined? false :joining? false
                                            :users {})))
                      {} m)))
  nil)

(def ^:private history-limit
  "How many lines of backlog to ask a room for, as `frq.state` asks for them."
  100)

(defn- room!
  "The little of `frq.state/apply-msg!` the conversation list needs: the two
  messages that make a room appear and give it a last line, and everything
  that says who is in it.

  The membership half is `frq.members`, unchanged from what the desktop folds
   the server is the one deciding who is in a room, and it tells both halves
  in the same words."
  [m]
  (let [cmd (str (:command m))
        params (vec (:params m))
        who (irc/nick-of (:prefix m))
        me? (= who (str @cells/form-nick))
        ;; The shared one, so a buffer born here carries the same read marker
        ;; the desktop's does — `frq.rooms/recount` has nothing to count
        ;; against without it.
        ensure rooms/ensure-channel]
    (cond
      (= "JOIN" cmd)
      (let [name (first params)]
        (if me?
          ;; Ours if the file says so  restored at startup, or asked for
          ;; since. Anything else is the server putting us somewhere we did
          ;; not ask to be, which it does: it announces memberships that are
          ;; not real, and adding them is how a list nobody chose fills up.
          ;;
          ;; So we leave again, unless this is the session that is still
          ;; adopting  on the first connect the file has not been told
          ;; anything yet, and parting then would be leaving every room we are
          ;; actually in.
          (if (and (not (contains? @cells/channels name))
                   (not @adopting-rooms?))
            (when-let [c @conn] (net/send-line! c (str "PART " name)))
            (do (swap! cells/channels
                       #(-> (ensure % name)
                            (members/add-user name who)
                            (update name merge {:joined? true
                                                :joining? false})))
                ;; Adopted or asked for, it is ours now and the file should
                ;; say so before the next connect judges it.
                (remember-rooms! true)))
          ;; Somebody else arriving in a room we do not hold is not a reason
          ;; to start holding it: `add-user` builds the buffer it is given,
          ;; which would put a refused room straight back in the list.
          (when (contains? @cells/channels name)
            (swap! cells/channels members/add-user name who))))

      ;; 353 is the roll, in as many lines as it takes; 366 ends it.
      (= "353" cmd)
      (let [name (nth params 2 nil)]
        (when name
          (swap! cells/channels #(members/with-names (ensure % name) name (last params)))))

      (= "366" cmd)
      (let [name (nth params 1 nil)]
        (when name
          (swap! cells/channels members/names-done name)
          ;; End of NAMES, and the only moment this client knows a room is
          ;; fully arrived. freeq re-joins an authenticated user's channels at
          ;; registration and leaves the backlog for the client to ask for, so
          ;; a room that reaches here with an empty buffer has no history
          ;; coming unless we ask  which is why nothing but new lines ever
          ;; appeared here. Same request `frq.state` makes on 366, and the
          ;; replayed lines come back as ordinary PRIVMSGs the branch below
          ;; already folds.
          (when (and @conn (empty? (get-in @cells/channels [name :messages])))
            (net/send-line! @conn
                            (str "CHATHISTORY LATEST " name " * " history-limit)))))

      (= "PART" cmd)
      (swap! cells/channels members/remove-user (first params) who)

      (= "KICK" cmd)
      (swap! cells/channels members/remove-user (first params) (nth params 1 nil))

      (= "QUIT" cmd)
      (swap! cells/channels members/remove-everywhere who)

      (= "NICK" cmd)
      (do (swap! cells/channels members/rename-user who (last params))
          ;; Our own rename is the server settling what we are called  it
          ;; hands a guest a name of its choosing, so this is the usual way
          ;; the nick on screen becomes the real one.
          (when me? (reset! cells/form-nick (last params))))

      (= "MODE" cmd)
      (swap! cells/channels members/with-mode
             (first params) (nth params 1 "") (drop 2 params))

      (= "PRIVMSG" cmd)
      (let [tags (:tags m)
            target (first params)
            text (last params)
            name (if (rooms/dm? target) who target)
            edit-of (or (irc/tag-value tags "+draft/edit")
                        (irc/tag-value tags "+edit"))
            ;; When the server says the line was written, or now when it says
            ;; nothing. The marker is a time as well as an id  a replayed
            ;; backlog is older than what has been read and has to be able to
            ;; say so  and the day separators in `frq.screens.chat` have been
            ;; waiting on this too.
            at (or (clock/parse-time-tag tags) (clock/now-ms))
            ;; Reading a room is marking it read: a line arriving in the room
            ;; on screen moves the marker past itself, and one arriving
            ;; anywhere else is counted against the marker there.
            viewing? (and (= :chat @cells/screen) (= name @cells/current))
            settle (fn [m] (update m name (if viewing?
                                            rooms/mark-read
                                            rooms/recount)))]
        ;; The face, asked for as the line arrives rather than when the row is
        ;; built: a row is built during a Flutter frame, and a fetch started
        ;; there would be state changed mid-build. `frq.avatars.dart` asks the
        ;; directory once per person however many lines they write, and
        ;; `bump!` is what wakes the rows already on screen when it answers.
        (avatars/fetch! (profile/actor (:account m) who) bump!)
        (if edit-of
          ;; A revision is not a new line: it replaces the one it names, under
          ;; that line's own id and never the revision's own wire msgid, which
          ;; nothing else refers to. One older than the backlog we hold has
          ;; nothing here to replace, and is shown as itself rather than lost.
          (when (= :absent (edit-message! name edit-of who text))
            (swap! cells/channels
                   #(-> (rooms/ensure-channel % name)
                          (update-in [name :messages] conj
                                     {:from who
                                      :text text
                                      :images (images-in text)
                                      :did (:account m)
                                      :id edit-of
                                      :at at
                                      :edited? true})
                          settle))))
          (swap! cells/channels
               #(-> (rooms/ensure-channel % name)
                    (update-in [name :messages] conj
                               ;; The msgid is what everything after a message
                               ;; names it by  a reply points at one, a reaction
                               ;; lands on one. Without it a line is on screen and
                               ;; nothing can be said about it.
                               {:from who
                                :text text
                                ;; Any .png link on the line, and the fetch that
                                ;; makes one a picture rather than a link.
                                :images (images-in text)
                                :did (:account m)
                                :at at
                                ;; Who to look a profile up by: the DID the
                                ;; server put on the line, or the nick when that
                                ;; is handle-shaped. A guest has neither and gets
                                ;; no lookup, which is the honest answer.
                                :actor (profile/actor (:account m) who)
                                :id (irc/tag-value tags "msgid")
                                ;; The server canonicalises +draft/reply to
                                ;; +reply; a client that sent the draft name may
                                ;; still reach us before it does.
                                :reply-to (or (irc/tag-value tags "+reply")
                                              (irc/tag-value tags "+draft/reply"))
                                ;; What is already on it, so a reconnect does not
                                ;; start every message empty.
                                ;; What the server says about a line it has
                                ;; already collapsed: replay sends the current
                                ;; text and no `+draft/edit` to hint that it is
                                ;; not the original. This tag is the only trace.
                                :edited? (= "1" (irc/tag-value tags
                                                               "+freeq.at/edited"))
                                :reactions (reactions/parse-tally
                                            (irc/tag-value tags
                                                           "+freeq.at/reactions"))})
                    (assoc-in [name :last-activity] at)
                    settle)))

      ;; A message that is only tags. A reaction is the one this reads:
      ;; `+react` puts an emoji on the message `+reply` names, and the
      ;; server's own `+freeq.at/unreact` takes it off again.
      (= "TAGMSG" cmd)
      (let [tags (:tags m)
            target (first params)
            buffer (if (rooms/dm? target) who target)
            msgid (or (irc/tag-value tags "+reply")
                      (irc/tag-value tags "+draft/reply"))
            add (or (irc/tag-value tags "+react")
                    (irc/tag-value tags "+draft/react"))
            gone (irc/tag-value tags "+freeq.at/unreact")]
        (cond
          add (swap! cells/channels reactions/update-reaction
                     buffer msgid add who true)
          gone (swap! cells/channels reactions/update-reaction
                      buffer msgid gone who false))))
    ;; Whatever that message did to the list, write it down. Throttled, so a
    ;; busy channel does not cost a file write per line  but a DM arriving
    ;; from someone new is a room that exists only here until this runs, and
    ;; the old client lost exactly those between runs. The read marker moves
    ;; on the same path, for the same reason: a line arriving in the room on
    ;; screen is a line read.
    (remember-rooms!)))

(defn- note! [m]
  (reset! last-line (str (:command m) " " (last (:params m))))
  (swap! lines (fn [v] (vec (take-last 8 (conj v m))))))

(def ^:private registration-failed
  "Numerics that mean registration will never complete.

  Only 001 used to clear `connecting?`, so anything the server said instead of
  it spun for ever with no message  and the likeliest of them is the dullest:
  433, the nick is already in use, which is what a second phone or a session
  left open elsewhere gets."
  #{"431"    ; no nickname given
    "432"    ; erroneous nickname
    "433"    ; nickname in use
    "436"    ; nick collision
    "464"    ; password incorrect
    "465"    ; banned
    "ERROR"})

(defonce ^:private attempt (atom 0))

(defn- watch-cells!
  "Every cell in `frq.cells` ticking `repaint` when it changes.

  Called from `main` rather than run from a top-level `defonce`, and that is
  not a style preference: ClojureDart compiles a `def` to a Dart top-level
  variable, Dart initialises those on first read, and a `defonce` whose value
  nothing ever reads simply never runs. The first attempt at this installed no
  watches at all and printed nothing to say so."
  []
  (doseq [c (cells/all-cells)]
    (add-watch c ::repaint
               (fn [_ _ old new]
                 (when (not= old new) (swap! repaint inc))
                 nil))))

(defonce ^:private caps
  ;; What the server has agreed to on this connection. `frq.irc.handshake`
  ;; holds none of it: it takes the set and hands a new one back, so the
  ;; desktop can keep it in an atom on the connection and the phone here.
  (atom #{}))

(defonce ^:private closing?
  ;; Whether the close about to arrive is one we asked for. Without it a tap
  ;; on Disconnect comes back as "closed by the server", which is both untrue
  ;; and alarming.
  (atom false))

(defn- fail! [why]
  (reset! cells/connecting? false)
  (reset! cells/status "Not connected")
  (reset! cells/screen :connect)
  (reset! cells/error why))

(defn ^:async bluesky-session!
  "A web-token session from freeq's auth broker.

  Only the half that needs no browser. A saved broker token is durable and the
  web-token minted from it is single-use, so a return visit is one POST to
  /session and `frq.oauth.core` already says what to send and what the answer
  means. Getting the *first* broker token is the other half: the browser hands
  it back to a loopback listener on the desktop, and a phone has nowhere for
  that to land  it wants an Android app link, which is a decision about what
  the broker will redirect to and not a porting problem. So that case says so
  rather than quietly connecting as somebody else."
  []
  (if-let [bt @cells/broker-token]
    (do
      (reset! cells/status "Resuming your session…")
      (let [tokens (oauth/refresh-session-parse
                    bt
                    (await (atproto/fetch
                            (oauth/refresh-session-req oauth/default-broker bt))))]
        (reset! cells/broker-token (:broker-token tokens))
        ;; On every sign-in and not only the first: /session can hand back a
        ;; rotated broker token, and the old one may stop working the moment
        ;; it does.
        (store/save-session! tokens)
        (assoc tokens :kind :web-token)))
    ;; No saved token: the browser leg. `frq.oauth.dart` binds the loopback,
    ;; and what comes back through it is the first broker token.
    (let [tokens (await (oauth-dart/await-callback!
                         oauth/default-broker
                         (str @cells/form-handle)
                         (fn [url]
                           (reset! cells/login-url url)
                           (reset! cells/status
                                   (if (fio/open-url! url)
                                     "Waiting for the browser…"
                                     "Open the sign-in link below to continue"))
                           nil)))]
      (reset! cells/login-url nil)
      (reset! cells/broker-token (:broker-token tokens))
      (store/save-session! tokens)
      (assoc tokens :kind :web-token))))

(defn ^:async sign-in!
  "Fill `cells/session` for the mode that was chosen, or say why not.

  True when the connection may go ahead. A guest carries no session, and must
  not carry the last one either: a leftover would have `frq.irc.handshake` ask
  for sasl and authenticate as whoever signed in before."
  []
  (case @cells/auth-mode
    :guest (do (reset! cells/session nil) true)
    (try
      (reset! cells/connecting? true)
      (reset! cells/error nil)
      (reset! cells/session
              (await (if (= :bluesky @cells/auth-mode)
                       (bluesky-session!)
                       (do (reset! cells/status "Signing in…")
                           (atproto/create-session @cells/form-handle
                                                   @cells/form-app-password)))))
      ;; An authenticated connection still needs a nick  the DID is the
      ;; identity, the nick is only what the channel calls you. Same order the
      ;; desktop picks it in: what the broker said, else the first label of the
      ;; handle, else whatever is in the box.
      (let [sess @cells/session
            nick (or (:nick sess)
                     (first (.split (str (or (:handle sess) "")) "."))
                     nil)]
        (when (seq (str (or (:handle sess) "")))
          (reset! cells/form-handle (:handle sess)))
        (when (seq (str (or nick "")))
          (reset! cells/form-nick nick))
        ;; The password did its work at the PDS; do not keep it.
        (reset! cells/form-app-password ""))
      true
      (catch Object e
        (reset! cells/session nil)
        ;; The message, not the exception: `frq.atproto.core` puts the body it
        ;; could not read in the ex-data, and a PDS that answers a resolve with
        ;; an HTML error page puts the whole page there.
        (fail! (str "Sign-in failed: " (or (ex-message e) e)))
        false))))

(defn ^:async connect!
  "What `frq.actions/connect!` is on the phone.

  The desktop's is `frq.state/connect!` — jolt's TLS, SASL, a reader thread.
  This is the dart:io one, and it reads the same cells the screen wrote: the
  host, the port and the TLS tick are `frq.cells`, filled in by the entry and
  the checkbutton on screen.

  Guest and app-password both. The handshake itself is not here: it is
  `frq.irc.handshake`, which answers each line with the lines to send back,
  and all this does is write them."
  []
  ;; Close whatever was open first. Connecting twice left the old socket
  ;; holding the nick, so the second attempt got 433 from its own predecessor.
  (when-let [c @conn]
    (reset! closing? true)
    (net/close! c)
    (reset! conn nil))
  ;; Whatever identity was asked for, settled before the socket opens. Both
  ;; of the signed-in modes are an HTTPS round trip that has nothing to do
  ;; with IRC, and a failure in either must stop here: connecting anyway lands
  ;; us on the server as a guest, which looks like a success and is not the
  ;; one that was asked for. Bluesky did exactly that until it was asked.
  (when (await (sign-in!))
  (let [n (swap! attempt inc)]
      (reset! caps #{})
      (reset! cells/connecting? true)
      (reset! cells/error nil)
      (reset! cells/status (str "Connecting to " @cells/form-host ""))
      (reset! lines [])
      ;; A watchdog, because "no answer at all" is a real outcome: a TLS
      ;; handshake that hangs, or a server that accepts the socket and says
      ;; nothing, leaves every callback below unfired.
      (.then (async/Future.delayed (Duration .seconds 20))
             (fn [_]
               (when (and @cells/connecting? (= n @attempt))
                 (fail! "No answer from the server after 20s"))))
      (try
        (let [sock (await (net/connect!
                           {:host @cells/form-host
                            ;; The cell is a string, because it is what an
                            ;; :entry holds.
                            :port (or (parse-long (str @cells/form-port)) 6697)
                            :tls? (boolean @cells/form-tls?)
                            :on-msg (fn [m]
                                      (note! m)
                                      (room! m)
                                      ;; Capability negotiation and SASL, out of
                                      ;; common/  the same steps the desktop
                                      ;; takes, and all this does is write what
                                      ;; they answer with.
                                      (let [{:keys [send] next-caps :caps}
                                            (handshake/step {:session @cells/session
                                                             :caps @caps}
                                                            m)]
                                        (reset! caps next-caps)
                                        (doseq [line send]
                                          (when-let [c @conn] (net/send-line! c line))))
                                      (let [cmd (str (:command m))
                                            text (str (last (:params m)))]
                                        (cond
                                          (= "001" cmd)
                                          (do (reset! cells/connecting? false)
                                              (reset! cells/error nil)
                                              (reset! cells/status
                                                      (str "Connected as " @cells/form-nick))
                                              ;; Off the connect screen, as
                                              ;; frq.state does on 001. Without
                                              ;; this a successful connect just
                                              ;; puts the Connect button back
                                              ;; and looks exactly like a
                                              ;; failure.
                                              (reset! cells/screen :chats)
                                              ;; This session decides once
                                              ;; whether it is the one that
                                              ;; takes the room list over
                                              ;; from the server. Before the
                                              ;; flag is set the file has
                                              ;; never been told what we are
                                              ;; in, so the server's answer
                                              ;; is adopted rather than
                                              ;; argued with.
                                              (reset! adopting-rooms?
                                                      (not @room-list-owned?))
                                              (when-not @room-list-owned?
                                                (reset! room-list-owned? true)
                                                (save-prefs!))
                                              ;; What the file says we are in,
                                              ;; we ask to be in. A room the
                                              ;; server has forgotten is one
                                              ;; that would otherwise quietly
                                              ;; stop existing.
                                              (join-saved-rooms!)
                                              ;; And back where the reader
                                              ;; left off, rather than into
                                              ;; #test on top of a list they
                                              ;; already had. The chats
                                              ;; screen stays underneath, so
                                              ;; Back goes to the list rather
                                              ;; than out of the app.
                                              (if-let [last-room
                                                       (:name (first (rooms/channel-list)))]
                                                (actions/open-channel! last-room)
                                                (actions/join! auto-join)))

                                          (contains? registration-failed cmd)
                                          (fail! (if (= "433" cmd)
                                                   (str "Nick " @cells/form-nick
                                                        " is already in use — try another")
                                                   (str cmd " " text)))

                                          ;; Something is happening; say so
                                          ;; rather than sit on one message.
                                          @cells/connecting?
                                          (reset! cells/status (str "" cmd)))))
                            :on-close (fn [why]
                                        (reset! conn nil)
                                        (forget-memberships!)
                                        ;; Say what the last thing seen was. A
                                        ;; connection that registers and then
                                        ;; drops is a different bug from one
                                        ;; that never registers, and only the
                                        ;; last line apart tells them.
                                        (if @closing?
                                          (reset! closing? false)
                                          (fail! (str (or why "closed by the server")
                                                      " (after " (count @lines)
                                                      " lines, last: " @last-line ")"))))}))]
          (reset! conn sock)
          ;; CAP first, then registration  the order the server expects, and the
          ;; order `frq.irc` uses.
          (net/send-line! sock "CAP LS 302")
          ;; `form-nick` and nothing else. The handle went on the wire here,
          ;; and the sign-in had already put the *nick* in the cell  so the
          ;; channel called us alice.bsky.social while the client thought it
          ;; was alice, and every test of "is this me?" against a prefix came
          ;; back false. Our own JOIN echo stopped setting `:joined?`, so
          ;; every room wore a "not joined" badge and Open re-sent a JOIN for
          ;; a room we were already in. The DID is the identity; the nick is
          ;; only what the channel calls us, and both halves have to agree on
          ;; it. Same nick the desktop dials with.
          (let [nick (str @cells/form-nick)]
            (net/send-line! sock (str "NICK " nick))
            (net/send-line! sock (str "USER " nick " 0 * :frq"))))
        ;; Object, not Exception. Dart keeps Error and Exception in separate
        ;; hierarchies, so a TypeError is not an Exception and would leave
        ;; `connecting?` true for ever.
        (catch Object e
          (fail! (str e)))))))

(defn- disconnect! []
  (reset! closing? true)
  (when-let [c @conn] (net/close! c))
  (reset! conn nil)
  (forget-memberships!)
  (reset! cells/connecting? false)
  (reset! cells/status "Not connected")
  (reset! cells/screen :connect))

(defonce ^:private handle (atom "nandi-test.bsky.social"))
(defonce ^:private identity-out (atom nil))

(defn ^:async resolve-identity! []
  (reset! identity-out ["resolving…"])
  (try
    (let [h @handle
          did (await (atproto/resolve-handle h))
          pds (await (atproto/pds-endpoint did))]
      (reset! identity-out [(str "did  " did) (str "pds  " pds)]))
    (catch Exception e
      (reset! identity-out [(str "failed: " e)]))))

(defn- connected-screen
  "Where a connect lands, until `frq.app`'s chats screen is portable.

  Not much, and honest about it: the status, what the server said, and the way
  back. The desktop goes to :chats here  that screen reads rooms, messages,
  avatars and the media plane, which is most of what is not ported yet."
  []
  [:page {:max-width 520}
   [:title {:label "frq"}]
   [:status {:live true :label @cells/status}]
   [:card {}
    [:title-2 {:label "What the server said"}]
    (if (empty? @lines)
      [:dim-label {:label "nothing yet"}]
      (for [[i m] (map-indexed vector @lines)]
        [:label {:key i
                 :label (str (:command m)
                             (when-let [p (seq (:params m))]
                               (str "  " (last p))))}]))]
   [:hbox {:spacing 8}
    [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]
   [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]])

(defn- outgoing-dir []
  (let [d (str (fio/config-dir) "/outgoing")]
    (fio/mkdirs! d)
    d))

(defn- discard! [path]
  (try (fio/delete-file! path) (catch Object _ nil)))

(defn- clear-attachment!
  "Drop the picture without sending it, and the copy of it with it."
  []
  (when-let [a @cells/attachment]
    (reset! cells/attachment nil)
    (discard! (:path a))))

(defn ^:async attach!
  "Hold the picture already copied to `path` against the next line, and start
  its upload.

  The upload starts at once rather than at send, so by the time a line is
  written the picture is usually already up. A failure lands in `error` like
  any other and takes the attachment with it  there is nothing to send and
  nothing to show."
  [path filename]
  (let [did (:did @cells/session)
        host-name (str @cells/form-host)
        channel (str @cells/current)]
    (reset! cells/error nil)
    (clear-attachment!)
    (reset! cells/attachment {:path path :status :uploading})
    (try
      (let [url (await (upload/upload! host-name did channel path filename))]
        ;; Only if this is still the picture on screen: a reader who attached
        ;; another, or cleared it, has said what they want, and an upload
        ;; landing afterwards does not get to undo that.
        (swap! cells/attachment #(if (= (:path %) path)
                                   (assoc % :url url :status :ready)
                                   %))
        (when-not (= (:path @cells/attachment) path) (discard! path)))
      (catch Object e
        (swap! cells/attachment #(if (= (:path %) path) nil %))
        (discard! path)
        (reset! cells/error (str (or (ex-message e) e)))))))

(defn ^:async open-image-picker!
  "Ask for a picture, the way a phone asks.

  The system chooser, not this app's browsing screen: what the chooser hands
  back is a grant for the one picture chosen, so the app needs no permission
  over the reader's pictures at all — and without such a permission, browsing
  finds almost nothing to show. `frq.state/open-image-picker!` says the same
  thing from the other side and falls back to browsing where there is no
  chooser, which is every desktop.

  Copied into our own storage rather than attached where it lies: the send
  drops the attachment's file when it is done with it, and what it drops has
  to be ours  not the reader's own picture, sitting in their gallery."
  []
  (try
    (let [chosen (await (.pickImage (picker/ImagePicker)
                                    .source picker/ImageSource.gallery))]
      (when chosen
        (let [copy (str (outgoing-dir) "/" (.-millisecondsSinceEpoch (DateTime/now))
                        ".png")]
          (await (.copy (dio/File. (.-path chosen)) copy))
          (await (attach! copy "picture.png")))))
    (catch Object e
      (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e))))))

(defn- send-draft! []
  (let [text (str @cells/draft)
        room (str @cells/current)
        edit @cells/editing]
    (cond
      (empty? room) nil

      ;; A rewrite replaces what was said, and what was said is a line of
      ;; text: there is no wire form for adding a picture to a message already
      ;; sent, so the attachment is held back rather than silently dropped.
      ;; `frq.state/send-draft!` refuses the same pair for the same reason.
      (and edit @cells/attachment)
      (reset! cells/error "Finish the edit before sending a picture.")

      ;; An edit of nothing is not a way to unsay a line: there is no delete
      ;; on the wire here, and blanking the message is not what emptying the
      ;; box asks for. Send waits for something to say instead.
      (and edit (empty? (.trim text))) nil

      ;; A rewrite replaces what was said. It carries no new msgid of its own
      ;;  the server files it under the original's id — so nothing is added
      ;; here and the echo folds it in where the line already is. The room is
      ;; the edit's own and not whichever one is open: they are the same room
      ;; while the banner is up, because leaving cancels the edit, and naming
      ;; the one being rewritten is what makes that true rather than assumed.
      edit
      (if-let [c @conn]
        (let [room (str (:channel edit))
              text (.trim text)]
          (net/send-line!
           c
           (mutate/edit-line room (:id edit) text
                             (reactions/peer-did @cells/channels room
                                                 (str @cells/form-nick))))
          ;; Same reason as a new message: the server's echo is the copy every
          ;; other client sees, and folding this one in as well would rewrite
          ;; the line twice. Without `echo-message` nothing comes back, so the
          ;; rewrite has to be applied here or it never shows.
          (when-not (handshake/acked? @caps "echo-message")
            (edit-message! room (:id edit) (str @cells/form-nick) text))
          (reset! cells/editing nil)
          (reset! cells/draft ""))
        ;; The draft stays in the box, with the banner still over it: the
        ;; rewrite is still what the reader wants to send once there is
        ;; somewhere to send it.
        (reset! cells/error "Not connected."))

      ;; A picture still on its way up holds the send rather than losing it:
      ;; the line stays in the box, said so, and the reader presses send again
      ;; a moment later. Sending the text without its picture would be the one
      ;; outcome nobody asked for.
      (= :uploading (:status @cells/attachment))
      (reset! cells/error "The picture is still uploading.")

      (and (or (seq text) (:url @cells/attachment)) @conn)
      (let [url (:url @cells/attachment)
            ;; The picture becomes its link, at the end of the line: what goes
            ;; on the wire is the text the reader wrote and a URL after it,
            ;; which is what every other client in the channel knows how to
            ;; show. A line that is only a picture is only the link.
            text (.trim (str text (when url (str " " url))))]
        (do
      (net/send-line! @conn (str "PRIVMSG " room " :" text))
      ;; Echoed locally only when the server will not echo it back. With
      ;; `echo-message` negotiated it does  that is what the cap is for, and
      ;; it is how a client learns the msgid of its own line  so adding one
      ;; here as well put every sent message in the room twice.
      (when-not (handshake/acked? @caps "echo-message")
        (swap! cells/channels update room
               #(update % :messages conj {:from @cells/form-nick :text text})))
      ;; The attachment has done its job the moment the link is on the wire.
      (when-let [a @cells/attachment]
        (reset! cells/attachment nil)
        (discard! (:path a)))
      (reset! cells/draft ""))))))

(defn- message-by-id [room id]
  (when id
    (first (filter #(= id (rooms/row-id %))
                   (get-in @cells/channels [room :messages])))))

(defn- toggle-reaction!
  "Put my emoji on a message, or take it off if it is already mine.

  Applied here as well as sent: the server relays a TAGMSG to everyone in the
  channel *except* the client that sent it, so without this the pill would
  only appear once someone else reacted too."
  [channel m emoji]
  (when-let [msgid (:id m)]
    (let [me (str @cells/form-nick)
          on? (not (reactions/mine? m emoji me))
          peer (reactions/peer-did @cells/channels channel me)]
      (when-let [c @conn]
        (net/send-line! c (if on?
                            (mutate/react-line channel msgid emoji peer)
                            (mutate/unreact-line channel msgid emoji peer))))
      (swap! cells/channels reactions/update-reaction
             channel msgid emoji me on?))))

(defn- close-picker! [] (reset! cells/reacting nil))

(defn ^:async main []
  (m/WidgetsFlutterBinding.ensureInitialized)
  ;; Layout errors do not come back as exceptions  they happen after the
  ;; build, so nothing can catch them, and what they leave is a blank screen
  ;; and an empty log. Flutter routes them here instead.
  (set! (.-onError m/FlutterError)
        (fn [^m/FlutterErrorDetails details]
          (m/debugPrint (str "frq: FLUTTER ERROR " (.-exception details)))
          (m/debugPrint (str "frq: LIBRARY " (.-library details)
                             " CONTEXT " (.-context details)))))
  (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
    (host/install! dir)
    ;; Ed25519 for the reactions freeq will not take on trust. Verified
    ;; against RFC 8032 test 1 on the device: the same public key and the
    ;; same signature OpenSSL gives on the desktop, so a signature minted
    ;; here verifies the same way at the server.
    (crypto-dart/install!)
    ;; How this half asks the directory who someone is. `frq.profile` decides
    ;; when to ask and remembers the answer; only the awaiting is here.
    (profile/install-fetch!
     (fn [actor]
       (.then ^async/Future (atproto/fetch (profile/profile-req actor))
              (fn [body] (profile/deliver-profile! actor body) nil)
              .onError (fn [_ _] (profile/deliver-profile! actor nil) nil))))
    ;; Before any widget is built: a cell that changes before its watch is on
    ;; is a change the screen never hears about.
    (watch-cells!)
    ;; The room list of earlier runs, and the flag that says whether it is the
    ;; authority yet. Before the saved session, because that connects on its
    ;; own the moment it is restored and 001 reads both of these.
    (restore-prefs!)
    (restore-rooms!)
    ;; A sign-in that already happened. Only the durable broker token comes
    ;; back  the connection mints a fresh web-token from it  so this is not
    ;; a session, it is the means to ask for one.
    (when-let [saved (store/load-session)]
      (reset! cells/broker-token (:broker-token saved))
      (when (seq (str (or (:handle saved) "")))
        (reset! cells/form-handle (:handle saved)))
      (when (seq (str (or (:nick saved) "")))
        (reset! cells/form-nick (:nick saved)))
      (reset! cells/auth-mode :bluesky)
      (reset! cells/status (str "Signed in as " (:handle saved) ""))
      ;; And then it connects on its own, as `frq.app/start!` does on the
      ;; desktop: a remembered account has already said what it wants, and
      ;; making it say so again at every launch is a tap that carries no
      ;; information. The web-token is minted fresh from the broker token
      ;; either way, so this is the same round trip Connect would make.
      ;;
      ;; On a timer rather than awaited here, and for the desktop's reason:
      ;; the connect screen with its status is what should be on screen while
      ;; it happens, and if it fails the error lands somewhere visible rather
      ;; than before the first frame.
      (.then (async/Future.delayed (Duration .milliseconds 150))
             (fn [_] (connect!) nil)))
    ;; What the shared screen calls. The desktop installs frq.state's
    ;; reducers here; this installs the phone's.
    (actions/install!
     {:connect! connect!
      :disconnect! disconnect!
      :connected? (fn [] (some? @conn))
      ;; The Join box, and the same one box for both things the desktop's
      ;; `join!` does: `@nick` opens a DM, anything else is a channel and
      ;; gets its `#` if it came without one. Through `open-room!` rather
      ;; than straight onto the wire, because a bare JOIN left the buffer
      ;; unbuilt  and the rule in the JOIN handler would then read our own
      ;; echo as a room the server had invented and part it again.
      :join! (fn [name]
               (let [name (.trim (str name))]
                 (cond
                   (.startsWith name "@")
                   (let [who (.substring name 1)]
                     (when (seq who) (open-room! who)))

                   (seq name)
                   (open-room! (if (.startsWith name "#") name (str "#" name)))

                   :else nil)))
      :leave-channel! (fn [name]
                        ;; Channels only, as on the desktop. There is nothing
                        ;; to part in a conversation with a person  the
                        ;; buffer is the whole of it  and `PART alice` is a
                        ;; line the server answers with an error.
                        (when (and @conn (.startsWith (str name) "#"))
                          (net/send-line! @conn (str "PART " name)))
                        (swap! cells/channels dissoc name)
                        (when (= name @cells/current)
                          (reset! cells/current nil)
                          (reset! cells/screen :chats))
                        ;; The only way a room leaves the file. Everything
                        ;; else adds one, so without this the list is a thing
                        ;; that only grows.
                        (remember-rooms! true))
      :send-draft! send-draft!

      ;; Pictures. The chooser is the platform's, so the browsing screen the
      ;; desktop falls back to is never opened here  and its actions would
      ;; have nothing to list anyway, since everything outside this app's own
      ;; storage is behind a permission it does not ask for.
      :open-image-picker! open-image-picker!
      :clear-attachment! clear-attachment!
      :close-image-picker! (fn [] (reset! cells/image-picker nil))
      :browse! (fn [_] nil)
      :picker-roots (fn [] [])
      :picker-entries (fn [_] {:dirs [] :files []})
      :parent-dir (fn [_] nil)
      :pick-image! (fn [_] nil)
      ;; Android has no clipboard of pictures to read, which is the other half
      ;; of why the chooser above exists.
      :paste-image! (fn []
                      (reset! cells/error "No picture on the clipboard."))

      ;; Answering. Both are a cell and nothing else  what the composer does
      ;; with `replying-to` is the shared screen's business.
      :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text])))
      :cancel-reply! (fn [] (reset! cells/replying-to nil))

      ;; Reacting. The pill itself, then the picker behind it.
      :toggle-reaction! toggle-reaction!
      :my-reaction? (fn [m emoji]
                      (reactions/mine? m emoji (str @cells/form-nick)))
      :open-picker! (fn [channel m]
                      (when (:id m)
                        ;; Fresh: a leftover search from last time is a screen
                        ;; of somebody else's question.
                        (reset! cells/emoji-search "")
                        (reset! cells/emoji-group nil)
                        (reset! cells/reacting {:channel channel :id (:id m)})))
      :close-picker! close-picker!
      :picker-emoji reactions/picker-emoji
      :react-from-picker! (fn [emoji]
                            ;; One choice and back to the conversation: a
                            ;; picker left open is asking a question that has
                            ;; been answered.
                            (when-let [{:keys [channel id]} @cells/reacting]
                              (when-let [m (message-by-id channel id)]
                                (toggle-reaction! channel m emoji))
                              (close-picker!)))
      ;; Hover is a pointer idea. A finger is either on a pill or not on it,
      ;; so there is nothing here to raise a card about.
      :hover-reaction! (fn [_ _] nil)
      :unhover-reaction! (fn [_ _] nil)

      ;; Who someone is, behind the nick on a line. `frq.profile` holds the
      ;; cache and the fields; this is the tapping.
      ;; Opening a profile asks for the picture too: the dialog paints it at 72
      ;; points, and someone whose lines are all above the fold in another room
      ;; may never have had a face fetched for them.
      :profile-open! (fn [nick actor]
                       (avatars/fetch! actor bump!)
                       (profile/open! nick actor))
      :profile-close! profile/close!
      :profile-dismiss! profile/close!
      :profile-entry profile/entry
      :profile-tick (fn [] @profile/tick)
      :profile-stats-line profile/stats-line
      :profile-truncate profile/truncate
      :profile-web-url profile/web-url
      :viewing (fn [] @profile/viewing)
      ;; The pointer half of a profile, which a phone does not have: there is
      ;; no hovering a face, no crossing from the face to the card, and so no
      ;; card to hold open while it happens.
      :hovering (fn [] nil)
      :profile-hover! (fn [_ _] nil)
      :profile-unhover! (fn [_] nil)
      :profile-enter-dialog! (fn [] nil)
      :profile-leave-dialog! (fn [] nil)
      ;; Their profile on the web, handed to the browser the same way the
      ;; sign-in link is.
      :open-url! (fn [url] (fio/open-url! url))

      ;; Where a picture is on disk, once it is. A plain lookup and not the
      ;; desktop's reaction: glimmer wakes the one row that read it, and this
      ;; half repaints the tree  `images-in` ticks `repaint` when a fetch
      ;; lands, and Flutter's own reconciler decides what that costs.
      ;;
      ;; A path and not the URL `:avatar-path` below answers with, and the
      ;; difference is what is behind them: a face is a CDN URL the network
      ;; will serve again, a picture in a message is whatever host a stranger
      ;; put a link to.
      :image-path (fn [url] (media/path-when-ready url))
      :wide? (fn [] false)
      :desktop? (fn [] false)
      :mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))

      ;; Rewriting. The old text is the starting point rather than an empty
      ;; line: an edit is usually a word, and retyping the sentence around it
      ;; is not what was asked for.
      :start-edit! (fn [channel m]
                     (when (and (:id m) (rooms/mine? m (str @cells/form-nick)))
                       (reset! cells/replying-to nil)
                       (reset! cells/editing {:channel channel :id (:id m)})
                       (reset! cells/draft (or (:text m) ""))))
      ;; The box empties with it: what is in it is a copy of the line on
      ;; screen, and leaving that behind would look like a draft.
      :cancel-edit! (fn []
                      (reset! cells/editing nil)
                      (reset! cells/draft ""))
      :message-by-id (fn [room id]
                       (->> (get-in @cells/channels [room :messages])
                            (filter #(= id (:id %)))
                            first))
      ;; Where a face is. A URL and not a path, which is what the desktop
      ;; answers  see `frq.avatars.dart`, and `:avatar` in `frq.hiccup`,
      ;; which is the one place that has to know which it got. Both names
      ;; answer the same thing here: the desktop's `avatar-ready` is
      ;; "downloaded already", and nothing is downloaded here.
      :avatar-path (fn [actor] (avatars/url actor))
      :avatar-ready (fn [actor] (avatars/url actor))
      :member-count (fn [room] (members/member-count @cells/channels room))
      :member-list (fn [room] (members/member-list @cells/channels room))
      :toggle-users! (fn [] (swap! cells/show-users? not))
      :toggle-overview! (fn [] (swap! cells/overview? not))
      ;; What the strip is a list of: every other room's recent lines, a turn
      ;; each. `frq.rooms` reads it off the cells, so both halves ask the same
      ;; question of the same buffers  the desktop's is the same def.
      :recent-everywhere rooms/recent-everywhere
      ;; Where the reader was when a line in the strip took them out of it,
      ;; and the way back to it. The room, not the place in it: one scroll
      ;; position is remembered per conversation either way.
      :leaving-for-overview! (fn [] (reset! cells/overview-return @cells/current))
      :overview-back! (fn []
                        (when-let [room @cells/overview-return]
                          (reset! cells/overview-return nil)
                          (actions/open-channel! room)))
      ;; Timers, for the two the strip's jump needs: aim the scroll, then let
      ;; go of it, and clear the mark a while after that. `Future.delayed` in
      ;; milliseconds, which is the unit `frq.screens.chat` asks in.
      :after! (fn [ms f]
                (.then (async/Future.delayed (Duration .milliseconds ms))
                       (fn [_] (f) nil))
                nil)
      :toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not))
      :toggle-hide-join-part! (fn [] (swap! cells/hide-join-part? not))
      :quit! (fn [] nil)
      :forget-session! (fn []
                         (reset! cells/broker-token nil)
                         (reset! cells/session nil))
      ;; Back to the newest line. Both halves, in the order `frq.state` gives
      ;; them: the flag is what takes the button off the screen, and the tick
      ;; is what `frq.hiccup`'s scroll reads as "someone asked to be taken to
      ;; the end". Setting the flag alone hid the button and left the reader
      ;; where they were  which is the one outcome worse than no button.
      :jump-to-present! (fn []
                          (reset! cells/at-present? true)
                          (swap! cells/jump-tick inc))
      :open-channel! open-room!})
    (f/run
      (m/MaterialApp .title "frq" .theme (t/app-theme))
      .home
      ;; No AppBar. `connect-screen` opens with [:title {:label "frq"}]  the
      ;; desktop has no bar above it either, the window's title is the
      ;; window's — so a bar here says "frq" twice.
      (m/Scaffold)
      .body
      m/SafeArea
      (f/widget
       :context ctx
       ;; One watch, not twenty. `lines` is a local atom and not a cell, so it
       ;; is named beside the tick; everything under `frq.cells` arrives
       ;; through `repaint`.
       :watch [tick repaint ls lines]
       (let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))]
         ;; `frq.screens.app` decides which screen shows, the same way it does
         ;; on the desktop. The phone was switching by hand until this moved.
         (h/render [screens/app]))))))