chat / client / src / sueta / app / ui.cljs
   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
;; ported-from: src/app/ui.ts
;;
;; DOM UI: dark, mobile-first chat with threads, reactions, image sharing.
;;
;; SelfIdentityInfo is {seed, fp, pub, profile, conflict} and the hook bag is a
;; plain JS object of callbacks (room.cljs builds it). Nothing here is on the
;; wire, so nothing here needs key-order care.
;;
;; FULLY CONVERTED AS OF PHASE 5d, and what is left here is a SHELL in the
;; app/lobby sense: it owns the clock, the DOM state nobody may re-render over,
;; the JS objects the projections read, and the one dispatch every data handler
;; in every view arrives at. Eight replicant roots hang off it —
;;
;;   app/chrome   the frame (`#app` itself), and the host nodes the other seven
;;                sit in. Painted SYNCHRONOUSLY: it carries the thread panel's
;;                visibility, and the thread list measures its own scroll in the
;;                same tick it is painted in.
;;   app/msgs     `#msgs`, `#thread-msgs`, and both composers
;;   app/roster   `#roster`         app/tiles   `#tiles`
;;   app/sheets   `#sheet` — the four modal overlays, as a stack
;;
;; NO `esc` AND NO `.-innerHTML` ANYWHERE IN THIS TREE any more. Phase 5's XSS
;; claim is now structural in full: every string this app shows becomes a DOM
;; text node or a `.value`, so there is no escape function left to forget to
;; call. test/source-hygiene.test.mjs's seventh check went from an allowlist
;; with a budget to an empty allowlist — nothing may assign `.-innerHTML`,
;; `.-outerHTML` or `insertAdjacentHTML`, and nothing may define an `esc`.
;;
;; THE HANDLE REGISTRY earns its keep here (app/view's third rule). A tile's
;; MediaStream is DOM identity, not content: `update-call` parks it under the
;; tile id, app/tiles emits only the id and app/view's revision, and the
;; `:tile-stream` case below resolves the pair in a life-cycle hook. That is why
;; the call screen has a golden vector at all, and why a keyed re-order can
;; never detach a live `srcObject` — the vdom never held one.
;;
;; TWO UNCONTROLLED ZONES live here, and they are why `mount!` hands back a
;; SYNCHRONOUS `paint!`:
;;   the scroll container  `near-bottom?` is read BEFORE the render and the pin
;;                         written AFTER it, in one tick. A renderer that
;;                         scheduled its own frame would split the measurement
;;                         from the fix-up — which is what reagent and React 18
;;                         were rejected for (dev/docs/CLJS.md).
;;   the composer textarea its `.value` is the user's. app/msgs never emits a
;;                         `:value` attribute, so replicant never writes one,
;;                         and the field is cleared by hand after a send.
(ns sueta.app.ui
  (:require [ardegazu.id.identity :refer (Identity)]
            [sueta.app.chat :as chat]
            [sueta.app.chrome :as chrome]
            [sueta.app.msgs :as msgs]
            [sueta.app.roster :as roster]
            [sueta.app.sheets :as sheets]
            [sueta.app.tiles :as tiles]
            [sueta.app.trust :as trust]
            [sueta.app.view :as view]
            [sueta.config :as config]
            [sueta.i18n :refer (t)]
            [ardegazu.rooms.js :as j]
            [shadow.cljs.modern :refer (defclass js-await)]))

;; ---- small DOM helpers -----------------------------------------------------

(defn- el
  ([tag] (js/document.createElement tag))
  ([tag cls]
   (let [e (js/document.createElement tag)]
     (when (j/truthy? cls) (set! (.-className e) cls))
     e)))

(defn- ^boolean near-bottom? [elm]
  (< (- (.-scrollHeight ^js elm) (.-scrollTop ^js elm) (.-clientHeight ^js elm)) 120))

(defn- autosize [ta]
  (set! (.-height (.-style ^js ta)) "auto")
  (set! (.-height (.-style ^js ta)) (str (js/Math.min (.-scrollHeight ^js ta) 120) "px"))
  js/undefined)

(defn- ^boolean touch? []
  (.-matches (js/matchMedia "(pointer: coarse)")))

(declare toast build! send-image! render-header! render-list! view-state accents
         show-actions! hide-actions! show-thread! close-thread! on-event do-send!
         thread-arg take-file! start-press! cancel-press!
         view-image! show-peer-sheet! show-identity-sheet! render-bell! notify-pref?
         toggle-notify! copy! track-viewport! q schedule-render audio-unlock!
         roster-state call-state verdict-of set-verdict! peer-pubs attach-stream!
         render-tiles! toggle-big! render-chrome! chrome-state bell-on?
         push-sheet! close-sheet! paint-sheets! on-sheet run-sheet! save-credential!
         resolve-conflict-fp! sheet-value sheet-index)

;; A PeerInfo is {name?, state, id?, warned?}.

(defclass UI
  (constructor [this root store hooks]
    (unchecked-set this "_root" root)
    (unchecked-set this "_store" store)
    (unchecked-set this "_hooks" hooks)
    (unchecked-set this "_openThread" nil)
    (unchecked-set this "_peers" (js/Map.))
    ;; chosen profile accents by identity pub (fed by set-peer-identity + self)
    (unchecked-set this "_hueByPub" (js/Map.))
    (unchecked-set this "_glyphByPub" (js/Map.))
    ;; {identity-pub {:verified? :key-changed?}}. THE fix Phase 5c owed the
    ;; roster: the verdict is a value in one map now, not a JS object mutated in
    ;; place on a peer record that only the roster ever pointed at.
    (unchecked-set this "_verdicts" {})
    (unchecked-set this "_signalingUp" false)
    (unchecked-set this "_renderQueued" false)
    ;; the modal overlays, outermost first. A STACK because the manual-copy
    ;; fallback opens on top of the identity sheet and closing it has to give
    ;; that sheet back (app/sheets); nil-free, never deeper than two.
    (unchecked-set this "_sheets" [])
    ;; what a resolved `askName` promise owes its caller, and the two things
    ;; app/sheets deliberately keeps out of view data: the manual-copy text and
    ;; (through `_hooks.identity`) the seed
    (unchecked-set this "_askResolve" nil)
    (unchecked-set this "_copyText" "")
    ;; frame state that belongs to nobody else: the roster drawer, the
    ;; full-screen image viewer's URL, and the value app/chrome last rendered
    (unchecked-set this "_rosterOpen" false)
    (unchecked-set this "_viewerUrl" nil)
    (unchecked-set this "_chrome" nil)
    ;; [message-id in-thread?] of the open long-press sheet, or nil. VIEW state
    ;; now: the sheet is rendered from it rather than appended to a row.
    (unchecked-set this "_actionsFor" nil)
    (unchecked-set this "_pressTimer" 0)
    ;; the value app/msgs last rendered, and the painters (two synchronous, for
    ;; the lists whose scroll pin has to land in the same tick; two coalescing)
    (unchecked-set this "_state" nil)
    (unchecked-set this "_paint" nil)
    ;; the values app/roster and app/tiles last rendered
    (unchecked-set this "_rosterState" nil)
    (unchecked-set this "_callState" nil)
    ;; NOTE: the durable AUTHOR id (log.myAuthorId), not the session PeerId —
    ;; reactions fold by author id, so the "mine" chip check must too.
    (unchecked-set this "_selfAuthorId" "")
    ;; calls — CallManager's last roster, kept so the paint can re-project it.
    ;; The STREAMS are not here: they are handles, and handles live in
    ;; app/view's registry keyed by tile id.
    (unchecked-set this "_callSelf" nil)
    (unchecked-set this "_callMembers" (js/Map.))
    ;; the tile ids currently holding a handle, so a member who leaves has
    ;; theirs forgotten rather than leaked for the life of the tab
    (unchecked-set this "_parked" #{})
    ;; tile ids the user has tapped to enlarge. VIEW state, not a classList
    ;; toggle: a re-render owns that node's classes now.
    (unchecked-set this "_bigTiles" #{})
    (unchecked-set this "_unread" 0)
    (unchecked-set this "_baseTitle" (.-title js/document))
    (build! this)
    (unchecked-set store "onChange" (fn [] (schedule-render this) js/undefined))
    (track-viewport! this)))

(defn- q [self sel] (.querySelector ^js (unchecked-get self "_root") sel))

;; ---- public state feeds ----------------------------------------------------

(defn- peer-rec [self peer default-state]
  (let [peers ^js (unchecked-get self "_peers")
        existing (.get peers peer)]
    (if (identical? existing js/undefined) (j/ordered "state" default-state) existing)))

(defn set-peer [self peer state name]
  (let [p (peer-rec self peer state)]
    (unchecked-set p "state" state)
    (when (j/truthy? name) (unchecked-set p "name" name))
    (.set ^js (unchecked-get self "_peers") peer p)
    (render-header! self)
    ;; clears the empty-room hint when someone joins
    (schedule-render self))
  js/undefined)

(defn set-peer-name [self peer name]
  (let [p (peer-rec self peer "connecting")]
    (unchecked-set p "name" name)
    (.set ^js (unchecked-get self "_peers") peer p)
    (render-header! self)
    (schedule-render self))
  js/undefined)

(defn- verdict-of
  "This session's verdict for an identity pub: {:verified? :key-changed?}."
  [self pub]
  (get (unchecked-get self "_verdicts") pub))

(defn- set-verdict!
  "Replace one identity's verdict. A new map, never a mutation — the roster
   derives its badge from this, and so (through app/chat's own `_trust`) does
   every message."
  [self pub v]
  (unchecked-set self "_verdicts" (assoc (unchecked-get self "_verdicts") pub v))
  js/undefined)

(defn set-peer-identity [self peer id]
  (let [p (peer-rec self peer "connecting")
        hue (unchecked-get id "hue")
        glyph (unchecked-get id "glyph")
        pub (unchecked-get id "pub")
        verdict (unchecked-get id "verdict")]
    (unchecked-set p "id" id)
    (.set ^js (unchecked-get self "_peers") peer p)
    ;; the announcement's verdict is a FRESH `trust/observe`, so it is also the
    ;; right answer after a rename re-verifies an identity we ticked earlier
    (set-verdict! self pub {:verified? (j/truthy? (unchecked-get verdict "verified"))
                            :key-changed? (j/truthy? (unchecked-get verdict "keyChanged"))})
    ;; remember chosen accents by identity pub — message authors are keyed by pub
    ;; too, so history rendered later picks them up
    (if-not (identical? hue js/undefined)
      (.set ^js (unchecked-get self "_hueByPub") pub hue)
      (.delete ^js (unchecked-get self "_hueByPub") pub))
    (if (j/truthy? glyph)
      (.set ^js (unchecked-get self "_glyphByPub") pub glyph)
      (.delete ^js (unchecked-get self "_glyphByPub") pub))
    (when (and (j/truthy? (unchecked-get (unchecked-get id "verdict") "keyChanged"))
               (not (j/truthy? (unchecked-get p "warned"))))
      (unchecked-set p "warned" true)
      (toast self (t "toast.key-changed" {"name" (j/nn (unchecked-get p "name") (t "peer.unknown"))})))
    (render-header! self)
    (schedule-render self))
  js/undefined)

(defn remove-peer [self peer]
  (.delete ^js (unchecked-get self "_peers") peer)
  (render-header! self)
  js/undefined)

(defn set-signaling [self up]
  (unchecked-set self "_signalingUp" up)
  (render-header! self)
  js/undefined)

(defn toast
  "The one node in this file still built by hand, and it stays that way: three
   lines of createElement with a timer, no state anyone can act on, and neither
   a raw-HTML sink nor an `esc` call site. It goes into app/chrome's `#toasts`
   host rather than into `#app` itself, so that no replicant root ever holds a
   child it does not know about."
  [self text]
  (let [box (el "div" "toast")]
    (set! (.-textContent box) text)
    (.appendChild ^js (q self "#toasts") box)
    (js/setTimeout (fn [] (.remove box)) 3500))
  js/undefined)

(defn ask-name
  "The first-run name prompt, as a promise the room boot awaits.

   THE OVERLAY IS GONE, NOT HIDDEN, BEFORE THIS RESOLVES. `on-sheet` paints the
   stack before it runs the effects, so by the time `[:name v]` calls `resolve`
   the modal has left the DOM — and the composer's `#input`, which has been
   sitting behind it since `build!`, is reachable again in the same tick."
  [self current]
  (js/Promise.
   (fn [resolve _reject]
     (unchecked-set self "_askResolve" resolve)
     (push-sheet! self (sheets/ask-name current)))))

(defn schedule-render [self]
  (when-not (j/truthy? (unchecked-get self "_renderQueued"))
    (unchecked-set self "_renderQueued" true)
    (js/requestAnimationFrame (fn []
                                (unchecked-set self "_renderQueued" false)
                                (render-list! self)
                                js/undefined)))
  js/undefined)

;; ---- construction ----------------------------------------------------------

(defn- build! [self]
  (let [root ^js (unchecked-get self "_root")]
    ;; the lobby removes its own host and the update banner lives on
    ;; document.body, so this is empty already — but replicant's first render
    ;; APPENDS, and it addresses its children by index from then on, so
    ;; anything left behind here would silently shift every lookup by one.
    ;; The `innerHTML = "<div class=…>"` this replaces cleared the root as a
    ;; side effect of writing it.
    (.replaceChildren root)

    ;; ---- the view layer ------------------------------------------------
    ;;
    ;; EIGHT replicant roots, ONE dispatch. app/view's dispatch is global (that
    ;; is replicant's design: `set-dispatch!` binds one var), so every mount
    ;; here is handed the SAME handler and the last `mount!` wins harmlessly.
    ;; Nothing is ever unmounted: the room screen lives as long as the tab, and
    ;; `unmount!` would take the shared dispatch down with it.
    ;;
    ;; THE FRAME GOES FIRST, because the other seven mount into nodes its first
    ;; paint creates. It keeps `paint!` — SYNCHRONOUS — for the same reason the
    ;; two lists do: `show-thread!` unhides the panel and then the thread list
    ;; measures its own scroll, and a frame boundary between those two is a
    ;; thread that opens scrolled to the top.
    ;;
    ;; The two lists get their `paint!` kept, because the scroll pin has to
    ;; happen in the same tick as the render. The roster and the tiles get
    ;; `schedule!` instead: nothing measures them, and their feeds are network
    ;; callbacks that arrive in bursts (a peer connecting walks four states, a
    ;; call roster re-fires per track), so the rAF dirty flag is exactly right.
    ;; The sheets get `paint!`: an overlay that is dismissed has to be GONE
    ;; before the effect behind it runs (app/ui's `ask-name`).
    ;; The two composers keep neither: their hiccup is a function of nothing, so
    ;; the mount paint is the only one they will ever need — and a composer that
    ;; never re-renders cannot move a caret, which is the property this file
    ;; exists to protect.
    (let [handle (fn [rd hd] (on-event self rd hd))
          current (fn [] (unchecked-get self "_state"))]
      (unchecked-set self "_state" (view-state self))
      (unchecked-set self "_rosterState" (roster-state self))
      (unchecked-set self "_callState" (call-state self))
      (unchecked-set self "_chrome" (chrome-state self))
      (unchecked-set self "_chromePaint"
                     (:paint! (view/mount! root
                                           (fn [] (chrome/view (unchecked-get self "_chrome")))
                                           handle)))
      (unchecked-set self "_msgsEl" (q self "#msgs"))
      (unchecked-set self "_threadMsgsEl" (q self "#thread-msgs"))
      (unchecked-set
       self "_paint"
       {:msgs (:paint! (view/mount! (q self "#msgs")
                                    (fn [] (msgs/view (current))) handle))
        :thread (:paint! (view/mount! (q self "#thread-msgs")
                                      (fn [] (msgs/thread-view (current))) handle))
        :roster (:schedule! (view/mount! (q self "#roster")
                                         (fn [] (roster/view (unchecked-get self "_rosterState")))
                                         handle))
        :tiles (:schedule! (view/mount! (q self "#tiles")
                                        (fn [] (tiles/view (unchecked-get self "_callState")))
                                        handle))
        :sheets (:paint! (view/mount! (q self "#sheet")
                                      (fn [] (sheets/view (unchecked-get self "_sheets")))
                                      handle))})
      (view/mount! (q self "#composer") (fn [] (msgs/composer false)) handle)
      (view/mount! (q self "#thread-composer") (fn [] (msgs/composer true)) handle))

    (let [clear-unread (fn []
                         (when (identical? "visible" (.-visibilityState js/document))
                           (unchecked-set self "_unread" 0)
                           (set! (.-title js/document) (unchecked-get self "_baseTitle"))
                           (let [f (unchecked-get js/navigator "clearAppBadge")]
                             (when (j/truthy? f) (.catch (.call f js/navigator) (fn [_] nil)))))
                         js/undefined)]
      (.addEventListener js/document "visibilitychange" clear-unread)
      (.addEventListener js/window "focus" clear-unread))

    ;; paste & drop anywhere
    (.addEventListener js/document "paste"
                       (fn [^js e]
                         (let [cd (.-clipboardData e)
                               items (if (j/truthy? cd) (js/Array.from (.-items cd)) (array))
                               item (.find items (fn [i] (.startsWith ^string (.-type ^js i) "image/")))
                               file (when-not (identical? item js/undefined) (.getAsFile ^js item))]
                           (when (j/truthy? file)
                             (.preventDefault e)
                             (send-image! self file (let [o (unchecked-get self "_openThread")]
                                                      (if (nil? o) js/undefined o)))))
                         js/undefined))
    (.addEventListener js/document "dragover" (fn [^js e] (.preventDefault e) js/undefined))
    (.addEventListener js/document "drop"
                       (fn [^js e]
                         (.preventDefault e)
                         (let [dt (.-dataTransfer e)
                               files (if (j/truthy? dt) (js/Array.from (.-files dt)) (array))
                               file (.find files (fn [f] (.startsWith ^string (.-type ^js f) "image/")))]
                           (when-not (identical? file js/undefined)
                             (send-image! self file (let [o (unchecked-get self "_openThread")]
                                                      (if (nil? o) js/undefined o)))))
                         js/undefined))
    (.addEventListener js/document "click"
                       (fn [^js e]
                         (when-not (j/truthy? (.closest ^js (.-target e) ".actions, .msg-bubble"))
                           (hide-actions! self))
                         js/undefined)))
  js/undefined)

(defn- thread-arg
  "What the `send`/`sendImage` hooks want as their thread argument: undefined
   from the room composer, the open thread root from the panel's."
  [self thread?]
  (if-not thread?
    js/undefined
    (let [o (unchecked-get self "_openThread")] (if (nil? o) js/undefined o))))

(defn- composer-el [self thread?]
  ^js (q self (if thread? "#thread-input" "#input")))

(defn- do-send!
  "The textarea is UNCONTROLLED: its value is read from the node, and cleared
   on the node. Nothing re-renders it, so nothing can move the caret."
  [self thread?]
  (let [input (composer-el self thread?)
        text (.-value input)]
    (when (j/truthy? (.trim ^string text))
      ((unchecked-get (unchecked-get self "_hooks") "send") text (thread-arg self thread?))
      (set! (.-value input) "")
      (autosize input)
      (.focus input)))
  js/undefined)

(defn- take-file! [self e thread?]
  (let [input ^js (.-target ^js e)
        files (.-files input)
        f (when (j/truthy? files) (aget files 0))]
    (set! (.-value input) "")
    (when (j/truthy? f) (send-image! self f (thread-arg self thread?))))
  js/undefined)

(defn- start-press! [self id in-thread?]
  (js/clearTimeout (unchecked-get self "_pressTimer"))
  (unchecked-set self "_pressTimer"
                 (js/setTimeout (fn [] (show-actions! self id in-thread?)) 420))
  js/undefined)

(defn- cancel-press! [self]
  (js/clearTimeout (unchecked-get self "_pressTimer"))
  js/undefined)

(defn- on-event
  "THE dispatch. Every `:on` handler and every action in app/msgs is DATA — a
   vector like `[:react id emoji]` — and this is the one place it becomes an
   effect. That is what keeps the view comparable by value: a closure in the
   hiccup would print as `#object[Function]` and the golden vector could no
   longer tell two states apart."
  [self replicant-data handler-data]
  (let [e (:replicant/dom-event replicant-data)
        [op a b c] handler-data
        hooks (unchecked-get self "_hooks")]
    (case op
      :react ((unchecked-get hooks "react") a b)
      :react-close (do (hide-actions! self) ((unchecked-get hooks "react") a b))
      :open-thread (show-thread! self a)
      :reply-close (do (hide-actions! self) (show-thread! self a))
      :actions (do (.preventDefault ^js e) (show-actions! self a b))
      :press (start-press! self a b)
      :press-cancel (cancel-press! self)
      :view-image (view-image! self (get (chat/image-urls (unchecked-get self "_store")) a))
      :invite (copy! self ((unchecked-get hooks "inviteURL")) (t "toast.invite-copied"))
      :send (do-send! self a)
      :attach (.click ^js (q self (if a "#thread-file-in" "#file-in")))
      :file (take-file! self e a)
      :composer-key (when (and (identical? "Enter" (.-key ^js e))
                               (not (j/truthy? (.-shiftKey ^js e)))
                               (not (touch?)))
                      (.preventDefault ^js e)
                      (do-send! self a))
      :composer-input (autosize (.-target ^js e))
      :identity-sheet (show-identity-sheet! self)
      :peer-sheet (show-peer-sheet! self a)
      :tile-zoom (toggle-big! self a)

      ;; ---- app/chrome: the frame ---------------------------------------
      :go-lobby ((unchecked-get hooks "goLobby"))
      :toggle-roster (do (unchecked-set self "_rosterOpen"
                                        (not (j/truthy? (unchecked-get self "_rosterOpen"))))
                         (render-chrome! self))
      :toggle-bell (toggle-notify! self)
      :join-call ((unchecked-get hooks "joinCall") a)
      :toggle-mic ((unchecked-get hooks "toggleMic"))
      :toggle-cam ((unchecked-get hooks "toggleCam"))
      :leave-call ((unchecked-get hooks "leaveCall"))
      :close-thread (close-thread! self)
      :close-viewer (do (unchecked-set self "_viewerUrl" nil) (render-chrome! self))

      ;; ---- app/sheets: the four overlays -------------------------------
      ;;
      ;; Every one of these is a plain event for the pure `sheets/step`, except
      ;; the three that carry what only the DOM knows (a field's value, whether
      ;; the pasted seed is the one already in use) and the three life-cycle
      ;; hooks below them.
      :close (on-sheet self nil [:close])
      ;; only a click on the backdrop ITSELF closes; a click inside the modal
      ;; reaches the overlay by bubbling and must not
      :overlay-click (when (identical? (.-target ^js e) (.-currentTarget ^js e))
                       (on-sheet self nil [:close]))
      :name-ok (on-sheet self nil [:name-ok (sheet-value self "#name-in")])
      :name-key (when (identical? "Enter" (.-key ^js e))
                  (on-sheet self nil [:name-ok (sheet-value self "#name-in")]))
      :verify (on-sheet self nil [:verify])
      :adopt (on-sheet self nil [:adopt])
      :keep (on-sheet self nil [:keep])
      :reveal (on-sheet self nil [:reveal])
      :copy-key (on-sheet self nil [:copy-key])
      :rename (do (.preventDefault ^js e)
                  (on-sheet self nil [:rename (sheet-value self "#id-rename input")]))
      :save (do (.preventDefault ^js e) (on-sheet self nil [:save]))
      :import (let [me ((unchecked-get hooks "identity"))
                    seed (.trim ^string (sheet-value self "#id-import input"))]
                (.preventDefault ^js e)
                (on-sheet self nil [:import seed
                                    (and (some? me)
                                         (identical? seed (unchecked-get me "seed")))]))
      :select-copy (.select ^js (.-target ^js e))
      ;; the three data LIFE-CYCLE hooks. Two of them write a string onto a
      ;; node that app/sheets deliberately keeps out of view data — the seed
      ;; and the manual-copy text — and both fire exactly once per open,
      ;; because an overlay that is already open cannot be re-opened.
      :focus-soon (let [node (:replicant/node replicant-data)]
                    (when (some? node) (js/setTimeout (fn [] (.focus ^js node)) 50)))
      :fill-seed (let [node (:replicant/node replicant-data)
                       me ((unchecked-get hooks "identity"))]
                   (when (and (some? node) (some? me))
                     (set! (.-value ^js node) (unchecked-get me "seed"))))
      :fill-copy (let [node (:replicant/node replicant-data)]
                   (when (some? node)
                     (set! (.-value ^js node) (unchecked-get self "_copyText"))
                     (js/setTimeout (fn [] (.focus ^js node)) 50)))
      ;; THE LIFE-CYCLE HOOK, and the only place a MediaStream is touched.
      ;; `[:tile-stream id rev self?]` is DATA in the hiccup; app/view's
      ;; `on-handle` resolves the id against the registry and calls back with
      ;; (node, stream), silently doing nothing when the tile is on screen
      ;; before its stream has arrived — the next revision brings the hook back.
      :tile-stream (let [node (:replicant/node replicant-data)]
                     (when (some? node)
                       ;; properties, not the attributes the hiccup also
                       ;; carries: `.muted = true` is the autoplay-policy path,
                       ;; and an unmuted self tile is an audio feedback loop
                       (set! (.-autoplay ^js node) true)
                       (set! (.-playsInline ^js node) true)
                       (set! (.-muted ^js node) (true? c))
                       ((view/on-handle a (fn [n stream] (attach-stream! self n stream)))
                        replicant-data)))
      nil))
  js/undefined)

(defn- send-image! [self file thread]
  (-> (js/Promise.resolve nil)
      (.then (fn [_] ((unchecked-get (unchecked-get self "_hooks") "sendImage") file thread)))
      (.catch (fn [err]
                (toast self (if (instance? js/Error err) (.-message ^js err) (t "toast.image-failed")))
                nil))))

;; ---- rendering -------------------------------------------------------------

(defn- roster-state
  "Everything the header shows, as one value: the peer map plus this session's
   context. The verdicts are OURS, not the peer records' — see app/roster."
  [self]
  (let [me ((unchecked-get (unchecked-get self "_hooks") "identity"))]
    (roster/project
     (unchecked-get self "_peers")
     {:me-name (chat/my-name (unchecked-get self "_store"))
      :me-fp (when (j/truthy? me) (unchecked-get (unchecked-get me "fp") "emoji"))
      :signaling? (j/truthy? (unchecked-get self "_signalingUp"))
      :verdicts (unchecked-get self "_verdicts")})))

(defn- ^boolean bell-on?
  "Notifications are on only when the browser has them, the user has granted
   permission AND this app's own preference says so — three facts, and the
   glyph in the header is a function of all three."
  []
  (and (exists? js/Notification)
       (identical? "granted" (.-permission js/Notification))
       (notify-pref?)))

(defn- chrome-state
  "Everything the frame shows, as one value. Two of its three inputs are other
   projections — app/roster answers the pill and the status dot, app/tiles the
   six callbar buttons — which is what stops those surfaces from being a second
   opinion about the same peers."
  [self]
  (chrome/project
   (unchecked-get self "_rosterState")
   (unchecked-get self "_callState")
   {:room-label ((unchecked-get (unchecked-get self "_hooks") "roomLabel"))
    :bell-on? (bell-on?)
    :roster-open? (j/truthy? (unchecked-get self "_rosterOpen"))
    :thread-open? (some? (unchecked-get self "_openThread"))
    :viewer-url (unchecked-get self "_viewerUrl")}))

(defn- render-chrome!
  "Re-project the frame and paint it NOW. Synchronous on purpose — see `build!`:
   the thread panel's visibility has to land before the thread list measures its
   own scroll, and both happen in one tick.

   THE INVARIANT THIS PAINT RESTS ON: every one of app/chrome's eight host nodes
   is written as a constant with no children, so `reconcile*` either skips it
   outright (`unchanged?`) or takes `update-children`'s both-empty branch. A
   child added to one of them in the hiccup would delete the screen it hosts."
  [self]
  (unchecked-set self "_chrome" (chrome-state self))
  ((unchecked-get self "_chromePaint"))
  js/undefined)

(defn- render-header!
  "Project the peers once, then feed the two surfaces the projection drives: the
   roster (a replicant root of its own) and the frame, whose pill and status dot
   are now read out of the same value rather than written onto two nodes."
  [self]
  (unchecked-set self "_rosterState" (roster-state self))
  (render-chrome! self)
  ((:roster (unchecked-get self "_paint")))
  js/undefined)

;; ---- the modal overlays ----------------------------------------------------
;;
;; app/sheets is pure — `project`, `step`, `view` — and this is its runtime. The
;; stack lives in `_sheets`, the top of it is the one `step` sees, and the order
;; below is the invariant the whole rewrite is built on: STATE FIRST, PAINT,
;; EFFECTS LAST. That is what makes `ask-name`'s promise resolve into a DOM the
;; overlay has already left.

(defn- sheet-value
  "The current value of a field inside the open overlay, or \"\" — the DOM-only
   half of three events, exactly as app/lobby's `value-of` is."
  [self sel]
  (let [node (q self sel)]
    (if (some? node) (.-value ^js node) "")))

(defn- paint-sheets! [self]
  ((:sheets (unchecked-get self "_paint")))
  js/undefined)

(defn- push-sheet! [self sheet]
  (unchecked-set self "_sheets" (conj (unchecked-get self "_sheets") sheet))
  (paint-sheets! self)
  js/undefined)

(defn- sheet-index
  "Where the topmost overlay of `kind` sits in the stack, or nil. `kind` nil
   means \"whatever is on top\".

   IT IS NOT ALWAYS THE TOP, and that is the point: three things reach a sheet
   ASYNCHRONOUSLY — the conflict fingerprint `Identity.fromSeed` resolves, the
   two-tap disarm timer, and the password-manager save — and by the time they
   land the manual-copy fallback may have been pushed over the card that asked.
   The imperative code held a direct node reference and never had the question;
   a stack has to answer it."
  [self kind]
  (let [sheets (unchecked-get self "_sheets")]
    (if (nil? kind)
      (when (seq sheets) (dec (count sheets)))
      (last (keep-indexed (fn [i sheet] (when (= kind (:kind sheet)) i)) sheets)))))

(defn- close-sheet!
  "Drop the topmost overlay of `kind`. Used by the one path that closes
   asynchronously — the password-manager save, which cannot know whether it
   succeeded until its promise settles."
  [self kind]
  (when-some [i (sheet-index self kind)]
    (let [sheets (unchecked-get self "_sheets")]
      (unchecked-set self "_sheets" (into (subvec sheets 0 i) (subvec sheets (inc i))))
      (paint-sheets! self)))
  js/undefined)

(defn- save-credential!
  "\"Save in password app\". NEVER lets the form submit — the seed must not hit
   the network — and reads the seed off `_hooks.identity` rather than out of the
   view state, which is why app/sheets never carries it."
  [self]
  (let [me ((unchecked-get (unchecked-get self "_hooks") "identity"))
        user ^js (q self "#id-save [name=\"username\"]")]
    (-> (js/Promise.resolve nil)
        (.then
         (fn [_]
           ;; Chrome/Edge/Android: ask the password manager directly.
           (let [PC (unchecked-get js/window "PasswordCredential")
                 creds (unchecked-get js/navigator "credentials")]
             (js-await [stored (if (and (j/truthy? PC) (j/truthy? creds) (j/truthy? (.-store ^js creds))
                                        (some? me) (some? user))
                                 (-> (.store ^js creds
                                             (new PC (j/ordered "id" (.-value user)
                                                                "password" (unchecked-get me "seed")
                                                                "name" "sueta identity")))
                                     (.then (fn [_] true))
                                     (.catch (fn [_] false)))
                                 (js/Promise.resolve false))]
               (do
                 ;; Safari/iCloud Keychain has no PasswordCredential: its
                 ;; heuristic is a filled username+new-password form that then
                 ;; disappears — so close the sheet and let AutoFill offer to
                 ;; save.
                 (close-sheet! self :identity)
                 (toast self (if stored (t "toast.saved-pw") (t "toast.save-manual")))
                 js/undefined)))))
        (.catch (fn [_] nil))))
  js/undefined)

(defn- run-sheet!
  "One effect from `sheets/step`."
  [self [fx a b c]]
  (let [hooks (unchecked-get self "_hooks")]
    (case fx
      ;; the awaited half of `ask-name`, and the overlay is already gone
      :name (let [done (unchecked-get self "_askResolve")]
              (unchecked-set self "_askResolve" nil)
              (when (j/truthy? done) (done a)))
      :focus-name (let [node (q self "#name-in")] (when (some? node) (.focus ^js node)))

      ;; ONE update, EVERY surface. The roster derives its badge from
      ;; `_verdicts` and every message derives its own from app/chat's `_trust`,
      ;; which `refresh-trust!` re-reads from the same store — so ticking the
      ;; box moves every badge on the next frame. Before Phase 5c this mutated a
      ;; verdict object that only the roster pointed at, and already-rendered
      ;; messages kept a stale badge until the room was reopened.
      :verify (do
                (trust/set-verified a b c)
                (set-verdict! self a {:verified? c :key-changed? false})
                (chat/refresh-trust! (unchecked-get self "_store"))
                (render-header! self)
                (schedule-render self)
                (toast self (if c
                              (t "toast.verified" {"name" b})
                              (t "toast.unverified" {"name" b}))))

      :adopt ((unchecked-get hooks "adoptSuiteIdentity")) ; reloads
      :keep-local ((unchecked-get hooks "keepLocalIdentity"))
      :rename ((unchecked-get hooks "rename") a)
      :copy-key (let [me ((unchecked-get hooks "identity"))]
                  (when (some? me)
                    (copy! self (unchecked-get me "seed") (t "toast.key-copied"))))
      :save-credential (save-credential! self)
      :import (when-not (j/truthy? ((unchecked-get hooks "importIdentity") a))
                (toast self (t "toast.import-failed")))
      :disarm-after (js/setTimeout (fn [] (on-sheet self :identity [:disarm b])) a)
      :toast (toast self a)
      nil))
  js/undefined)

(defn- on-sheet
  "THE overlay dispatch: deliver `event` to the topmost sheet of `kind` (nil =
   whatever is on top). State first, paint second, effects last — so a sheet
   that closed itself is off the screen before anything it asked for runs."
  [self kind event]
  (when-some [i (sheet-index self kind)]
    (let [sheets (unchecked-get self "_sheets")
          [sheet' effects] (sheets/step (nth sheets i) event)]
      (unchecked-set self "_sheets"
                     (if (nil? sheet')
                       (into (subvec sheets 0 i) (subvec sheets (inc i)))
                       (assoc sheets i sheet')))
      (paint-sheets! self)
      (doseq [effect effects] (run-sheet! self effect))))
  js/undefined)

(defn- show-peer-sheet!
  "Sheet for a remote peer: fingerprint + out-of-band verification toggle. The
   verdict is read from THIS SESSION's map, not from a JS object hanging off the
   peer record — Phase 5c stopped mutating that one."
  [self peer-id]
  (let [p (.get ^js (unchecked-get self "_peers") peer-id)]
    (when-not (identical? p js/undefined)
      (unchecked-set self "_rosterOpen" false)
      (render-chrome! self)
      (let [id (unchecked-get p "id")]
        (push-sheet! self (sheets/peer-sheet
                           peer-id p
                           (when (some? id) (verdict-of self (unchecked-get id "pub"))))))))
  js/undefined)

(defn- resolve-conflict-fp!
  "The suite identity's fingerprint, which only `Identity.fromSeed` can answer.
   It lands as an EVENT rather than as a `textContent` write, so the sheet's
   view stays total and the node it used to remove on failure is simply not
   rendered."
  [self conflict]
  (-> (.fromSeed Identity (unchecked-get conflict "seed"))
      (.then (fn [cid]
               (on-sheet self :identity [:conf-fp
                                         (str (unchecked-get (unchecked-get cid "fingerprint") "emoji")
                                              (if (j/truthy? (unchecked-get conflict "name"))
                                                (str "  “" (unchecked-get conflict "name") "”") ""))])
               js/undefined))
      (.catch (fn [_] (on-sheet self :identity [:conf-failed]) nil)))
  js/undefined)

(defn- show-identity-sheet!
  "Your identity card: fingerprint, rename, password-manager save, import, and
   the suite-identity chooser when the bridge reports a different key."
  [self]
  (unchecked-set self "_rosterOpen" false)
  (render-chrome! self)
  (let [me ((unchecked-get (unchecked-get self "_hooks") "identity"))]
    (push-sheet! self (sheets/identity-sheet me (chat/my-name (unchecked-get self "_store"))))
    (when-some [conflict (when (some? me) (unchecked-get me "conflict"))]
      (resolve-conflict-fp! self conflict)))
  js/undefined)

(defn- accents
  "Chosen hue/glyph by identity pub, self included — the ONE map app/msgs and
   app/tiles both resolve an avatar against. Self's suite profile REPLACES
   rather than merges, exactly as the per-pub lookup it replaced did (Phase 5c
   deleted that lookup: two readers of two maps became two readers of one)."
  [self]
  (let [out (volatile! {})
        me ((unchecked-get (unchecked-get self "_hooks") "identity"))]
    (.forEach ^js (unchecked-get self "_hueByPub")
              (fn [hue pub] (vswap! out assoc-in [pub :hue] hue) js/undefined))
    (.forEach ^js (unchecked-get self "_glyphByPub")
              (fn [glyph pub] (vswap! out assoc-in [pub :glyph] glyph) js/undefined))
    (when (j/truthy? me)
      (let [prof (unchecked-get me "profile")
            hue (unchecked-get prof "hue")
            glyph (unchecked-get prof "glyph")]
        (vswap! out assoc (unchecked-get me "pub")
                (cond-> {}
                  (some? hue) (assoc :hue hue)
                  (some? glyph) (assoc :glyph glyph)))))
    @out))

(defn- view-state
  "Everything the message list shows, as one value. The store is asked for its
   messages, its authorship map and its blob registry; this file supplies the
   session context; app/msgs turns the pair into Clojure data."
  [self]
  (let [store (unchecked-get self "_store")
        me ((unchecked-get (unchecked-get self "_hooks") "identity"))]
    (msgs/project
     (unchecked-get store "messages")
     {:trust (chat/author-trust store)
      :accents (accents self)
      :me-fp (when (j/truthy? me) (unchecked-get (unchecked-get me "fp") "emoji"))
      :self-author (unchecked-get self "_selfAuthorId")
      :blobs (chat/image-urls store)
      :host (.-host js/location)
      :thread (unchecked-get self "_openThread")
      :actions-for (unchecked-get self "_actionsFor")
      :peers (.-size ^js (unchecked-get self "_peers"))})))

(defn- render-list!
  "Measure, render, pin — in ONE tick, because `replicant.dom/render`
   reconciles inline. Splitting those three across frames is what makes a chat
   list jump away from the bottom as a message lands."
  [self]
  (let [msgs-el ^js (unchecked-get self "_msgsEl")
        paint (unchecked-get self "_paint")
        state (view-state self)
        pinned (near-bottom? msgs-el)]
    (unchecked-set self "_state" state)
    ((:msgs paint))
    (when pinned (set! (.-scrollTop msgs-el) (.-scrollHeight msgs-el)))
    (when (some? (:thread state))
      (let [thread-el ^js (unchecked-get self "_threadMsgsEl")
            t-pinned (near-bottom? thread-el)]
        ((:thread paint))
        (when t-pinned (set! (.-scrollTop thread-el) (.-scrollHeight thread-el))))))
  js/undefined)

(defn- show-actions!
  "The long-press sheet is a fact about the screen, so it is STATE: which
   message, in which of the two lists. It used to be a DOM node appended to a
   row, torn down by a `querySelectorAll(\".actions\")` sweep across the whole
   document — which is the shape that leaves one behind the moment a re-render
   replaces the row underneath it."
  [self id in-thread?]
  (unchecked-set self "_actionsFor" [id in-thread?])
  (schedule-render self)
  js/undefined)

(defn- hide-actions! [self]
  (when (some? (unchecked-get self "_actionsFor"))
    (unchecked-set self "_actionsFor" nil)
    (schedule-render self))
  js/undefined)

(defn- show-thread!
  "The panel's visibility is app/chrome's state now, and the chrome paint is
   SYNCHRONOUS for exactly this line: `render-list!` measures `#thread-msgs`'s
   scroll, and a hidden element measures 0/0/0 — which reads as \"pinned to the
   bottom\" and lands the thread at scrollTop 0. Unhide, then measure, in one
   tick."
  [self root-id]
  (unchecked-set self "_openThread" root-id)
  (render-chrome! self)
  (schedule-render self)
  js/undefined)

(defn- close-thread! [self]
  (unchecked-set self "_openThread" nil)
  (render-chrome! self)
  (schedule-render self)
  js/undefined)

(defn- view-image!
  "The full-screen viewer. A blob URL is CONTENT, not a handle (app/msgs already
   carries the same string in every image row), so this is one field of
   app/chrome's state rather than a `replaceChildren`. `url` comes from the CID
   registry, so a tap on an image whose blob was already revoked does nothing
   rather than showing a broken frame."
  [self url]
  (when (some? url)
    (unchecked-set self "_viewerUrl" url)
    (render-chrome! self))
  js/undefined)

(defn bind-self [self author-id]
  (unchecked-set self "_selfAuthorId" author-id)
  js/undefined)

;; ---- calls -----------------------------------------------------------------

(defn- peer-pubs
  "Tile id -> the identity pub whose chosen accent that tile wears.

   There is deliberately NO \"self\" entry: `_peers` holds remote peers only, and
   the imperative code this replaces looked its own tile up in that same map,
   so a self tile has never worn its owner's chosen hue/glyph. Preserved rather
   than fixed — this step moves the renderer, not the behaviour."
  [self]
  (let [out (volatile! {})]
    (.forEach ^js (unchecked-get self "_peers")
              (fn [p id]
                (let [pid (unchecked-get p "id")]
                  (when (some? pid) (vswap! out assoc id (unchecked-get pid "pub"))))
                js/undefined))
    @out))

(defn- call-state
  "Everything the call chrome shows, as one value. The STREAMS are not in it —
   they are in app/view's registry, and `:rev` is the one integer that says so."
  [self]
  (tiles/project
   (unchecked-get self "_callSelf")
   (unchecked-get self "_callMembers")
   {:me-name (str (chat/my-name (unchecked-get self "_store")) " " (t "roster.you"))
    :accents (accents self)
    :peer-pubs (peer-pubs self)
    :big (unchecked-get self "_bigTiles")
    :rev (view/revision)}))

(defn- render-tiles!
  "Re-project the call and schedule the tile paint. Returns the projection, so
   the callbar's still-imperative classes can be driven from the same value."
  [self]
  (let [state (call-state self)]
    (unchecked-set self "_callState" state)
    ((:tiles (unchecked-get self "_paint")))
    state))

(defn- toggle-big!
  "Tapping a tile enlarges it. VIEW STATE, not a `classList.toggle` on the node:
   the node's classes belong to the renderer now, and the next paint would
   otherwise take the class straight back off."
  [self id]
  (let [big (unchecked-get self "_bigTiles")]
    (unchecked-set self "_bigTiles" (if (contains? big id) (disj big id) (conj big id))))
  (render-tiles! self)
  js/undefined)

(defn- attach-stream!
  "The one imperative thing left about a tile, and it is imperative because a
   live `srcObject` is DOM IDENTITY rather than content. Assigned ONLY when it
   differs: writing the identical MediaStream back re-attaches the source and
   the picture flickers — which is the entire reason `update-call` used to be a
   hand-written reconciler."
  [self node stream]
  (when-not (identical? (.-srcObject ^js node) stream)
    (set! (.-srcObject ^js node) stream)
    (.catch (.play ^js node) (fn [_] (audio-unlock! self) nil)))
  js/undefined)

(defn update-call
  "CallManager's roster, applied. STATE FIRST — the roster is stored and every
   live stream parked in app/view's registry under its tile id — then EFFECTS:
   ONE scheduled tile paint and ONE frame paint. The callbar's six buttons used
   to be eleven `classList.toggle` calls and two `textContent` writes here;
   app/chrome derives all thirteen from the same projection the tiles are drawn
   from, so the bar can no longer disagree with the tile strip beneath it.

   Nothing here touches a <video>. `put-handle!` is idempotent, so a roster
   re-fire carrying no new stream does not bump the revision, which means not
   one tile's hiccup changes and not one hook re-runs."
  [self self-state members local-stream]
  (let [st (if (identical? self-state js/undefined) nil self-state)
        streams (volatile! {})
        wanted (volatile! (if (some? st) #{"self"} #{}))]
    (when (and (some? st) (j/truthy? local-stream))
      (vswap! streams assoc "self" local-stream))
    (.forEach ^js members
              (fn [m id]
                (vswap! wanted conj id)
                (let [s (unchecked-get m "stream")]
                  (when (j/truthy? s) (vswap! streams assoc id s)))
                js/undefined))
    (unchecked-set self "_callSelf" st)
    (unchecked-set self "_callMembers" members)
    ;; forget first, then park: a member who left must not keep a MediaStream
    ;; alive in the registry for the life of the tab
    (doseq [id (unchecked-get self "_parked")]
      (when-not (contains? @streams id) (view/forget-handle! id)))
    (doseq [pair @streams] (view/put-handle! (key pair) (val pair)))
    (unchecked-set self "_parked" (set (keys @streams)))
    ;; an enlarged tile whose member has gone forgets it was enlarged. The
    ;; element records this replaced were deleted on leave, taking `.big` with
    ;; them; a set that only ever grew would bring it back on a rejoin, and grow
    ;; for the life of the tab.
    (unchecked-set self "_bigTiles"
                   (into #{} (filter @wanted) (unchecked-get self "_bigTiles")))

    (render-tiles! self)
    (render-chrome! self))
  js/undefined)

(defn- audio-unlock!
  "iOS autoplay fallback: one tap replays every tile. The videos are found in
   the DOM rather than in a map of element records, because there is no map of
   element records any more — replicant owns those nodes."
  [self]
  (when-not (j/truthy? (js/document.getElementById "audio-unlock"))
    (let [b (el "button" "update-banner")]
      (set! (.-id b) "audio-unlock")
      (set! (.-textContent b) (t "call.audio-unlock"))
      (.addEventListener b "click"
                         (fn []
                           (.forEach ^js (.querySelectorAll ^js (q self "#tiles") "video")
                                     (fn [v]
                                       (.catch (.play ^js v) (fn [_] nil))
                                       js/undefined))
                           (.remove b)
                           js/undefined))
      (.appendChild js/document.body b)))
  js/undefined)

;; ---- notifications (fully local — no push services) ------------------------

(defn- ^boolean notify-pref? []
  (try (identical? "1" (js/localStorage.getItem (config/ns-key "notify")))
       (catch :default _ false)))

(defn- render-bell!
  "The bell glyph is a function of `bell-on?`, which `chrome-state` reads on
   every frame paint — so this is a repaint, not a write to a node."
  [self]
  (render-chrome! self))

(defn- toggle-notify! [self]
  (if-not (exists? js/Notification)
    (do (toast self (t "notif.unsupported")) (js/Promise.resolve nil))
    (-> (js/Promise.resolve nil)
        (.then
         (fn [_]
           (if (and (notify-pref?) (identical? "granted" (.-permission js/Notification)))
             (do
               (try (js/localStorage.setItem (config/ns-key "notify") "0") (catch :default _ nil))
               (toast self (t "notif.off"))
               true)
             (js-await [perm (.requestPermission js/Notification)]
               (if-not (identical? "granted" perm)
                 (do (toast self (t "notif.blocked")) false)
                 (do
                   (try (js/localStorage.setItem (config/ns-key "notify") "1") (catch :default _ nil))
                   (toast self (t "notif.on"))
                   true))))))
        (.then (fn [changed]
                 (when (j/truthy? changed) (render-bell! self))
                 js/undefined)))))

(defn notify-incoming
  "Live incoming message: notify + badge when the app isn't being looked at."
  [self msg]
  (when-not (and (identical? "visible" (.-visibilityState js/document)) (js/document.hasFocus))
    (unchecked-set self "_unread" (inc (unchecked-get self "_unread")))
    (set! (.-title js/document) (str "(" (unchecked-get self "_unread") ") " (unchecked-get self "_baseTitle")))
    (let [f (unchecked-get js/navigator "setAppBadge")]
      (when (j/truthy? f) (.catch (.call f js/navigator (unchecked-get self "_unread")) (fn [_] nil))))
    (when (and (exists? js/Notification)
               (identical? "granted" (.-permission js/Notification))
               (notify-pref?))
      (let [text (unchecked-get msg "text")
            body (if (j/truthy? text) (.slice ^string text 0 120) (t "notif.photo"))
            title (str (unchecked-get msg "name") " · " ((unchecked-get (unchecked-get self "_hooks") "roomLabel")))
            sw (.-serviceWorker js/navigator)]
        ;; SW notifications work everywhere incl. installed iOS PWAs
        ;; (new Notification() doesn't)
        (-> (if (j/truthy? sw) (.-ready ^js sw) (js/Promise.reject (js/Error. "no sw")))
            (.then (fn [reg]
                     (.showNotification ^js reg title
                                        (j/ordered "body" body
                                                   ;; per-app tag: forks on one
                                                   ;; origin must not dedupe each
                                                   ;; other
                                                   "tag" (config/ns-key "room")
                                                   "icon" "./icons/icon-192.png"))))
            (.catch (fn [_]
                      (try (js/Notification. title (j/ordered "body" body))
                           (catch :default _ nil))
                      nil))))))
  js/undefined)

(defn- copy!
  "Copy with a graceful fallback sheet (no native prompt() dialogs).

   `text` is an invite URL from the header, and an Ed25519 SEED from the
   identity card's \"copy key\" — which is why it is parked on the instance and
   written onto the field by app/sheets' `[:fill-copy]` mount hook instead of
   riding into the view state where a snapshot could print it."
  [self text ok-msg]
  (-> (js/Promise.resolve nil)
      (.then (fn [_] (.writeText (.-clipboard js/navigator) text)))
      (.then (fn [_] (toast self ok-msg) js/undefined))
      (.catch (fn [_]
                (unchecked-set self "_copyText" text)
                ;; PUSHED, not swapped in: this can open over the identity sheet
                ;; (that is the only route to "copy key"), and closing it has to
                ;; give that sheet back
                (push-sheet! self (sheets/copy-sheet))
                nil))))

(defn- track-viewport! [self]
  (let [vv (unchecked-get js/window "visualViewport")]
    (when (j/truthy? vv)
      (let [update (fn []
                     (.setProperty (.-style js/document.documentElement) "--vvh"
                                   (str (.-height ^js vv) "px"))
                     ;; keyboard open (scale===1 excludes pinch-zoom): iOS shifts
                     ;; the layout viewport to reveal the focused field — pin it
                     ;; back so the header and composer both stay on screen
                     (when (and (identical? 1 (.-scale ^js vv))
                                (< (.-height ^js vv) (- (.-innerHeight js/window) 50)))
                       (js/scrollTo 0 0))
                     ;; standalone-PWA iOS sometimes refuses the un-shift:
                     ;; whatever offset remains after the pin is compensated by
                     ;; translating the app column
                     (.setProperty (.-style js/document.documentElement) "--vvt"
                                   (if (identical? 1 (.-scale ^js vv)) (str (.-offsetTop ^js vv) "px") "0px"))
                     (let [msgs-el ^js (unchecked-get self "_msgsEl")]
                       (when (near-bottom? msgs-el)
                         (set! (.-scrollTop msgs-el) (.-scrollHeight msgs-el))))
                     js/undefined)]
        (.addEventListener ^js vv "resize" update)
        ;; the keyboard shift arrives as a visual-viewport SCROLL in standalone
        ;; mode (no resize until the animation settles) — track both
        (.addEventListener ^js vv "scroll" update)
        ;; iOS fires the events late after focusing an input; nudge once the
        ;; keyboard animation has had time to land
        (.addEventListener ^js (unchecked-get self "_root") "focusin"
                           (fn [] (js/setTimeout update 350) js/undefined))
        (update))))
  js/undefined)

;; ---- class surface ---------------------------------------------------------

(let [proto (.-prototype UI)]
  (unchecked-set proto "setPeer" (fn [peer state name] (this-as self (set-peer self peer state name))))
  (unchecked-set proto "setPeerName" (fn [peer name] (this-as self (set-peer-name self peer name))))
  (unchecked-set proto "setPeerIdentity" (fn [peer id] (this-as self (set-peer-identity self peer id))))
  (unchecked-set proto "removePeer" (fn [peer] (this-as self (remove-peer self peer))))
  (unchecked-set proto "setSignaling" (fn [up] (this-as self (set-signaling self up))))
  (unchecked-set proto "toast" (fn [text] (this-as self (toast self text))))
  (unchecked-set proto "askName" (fn [current] (this-as self (ask-name self current))))
  (unchecked-set proto "scheduleRender" (fn [] (this-as self (schedule-render self))))
  (unchecked-set proto "bindSelf" (fn [author-id] (this-as self (bind-self self author-id))))
  (unchecked-set proto "updateCall"
                 (fn [st members local] (this-as self (update-call self st members local))))
  (unchecked-set proto "notifyIncoming" (fn [msg] (this-as self (notify-incoming self msg)))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git