Fix Signal TUI receive handling

ober

a02d790010d36a2776a4331cadab3bb2bd372b03

diff --git a/README.md b/README.md
index 7a49d88..e238779 100644
--- a/README.md
+++ b/README.md
@@ -65,15 +65,17 @@ make tui-shim
 ./jerboa-signal tui
 ```
 
-The TUI opens in `signal-cli jsonRpc --receive-mode on-connection`, so new
-Signal events arrive while it is running. Shift+Up / Shift+Down move the
+The TUI opens `signal-cli jsonRpc --receive-mode manual`, then subscribes with
+`subscribeReceive` once the app reader is active, so new Signal events arrive
+while it is running without racing the UI startup. Shift+Up / Shift+Down move the
 selected conversation up or down the list, so you can keep the people you talk
 to at the top and push the rest down (your terminal must forward modified arrow
 keys; ordering is per-session since history is in memory). At startup it
 preloads active groups
 and known contacts into the conversation pane. The current interface keeps
 in-memory conversations for this session, routes incoming
-data/sync/receipt/typing events into threads, and sends plain text from the
+messages, edits, reactions, deletes, calls, receipts, and typing events into
+threads, and sends plain text from the
 composer to the selected conversation with Enter. Use Up/Down to change the
 selected conversation. Use Ctrl-N to open the new-message picker, type to
 filter contacts/groups or enter a `+number`, and press Enter to select. Use
@@ -115,7 +117,8 @@ blank to skip logging for that session. To run unattended, set
 
 The database lives at `~/.local/share/jerboa-signal/messages-<account>.db`. Each
 inbound notification and outbound send is one append-only row (with the full raw
-JSON); remote-deletes and edits are logged as new rows and never overwrite the
+JSON); remote-deletes, edits, reactions, calls, attachments, and other
+displayable events are logged as new rows and never overwrite the
 original. Read it back with the matching key:
 
 ```sh
diff --git a/docs/TUI_PLAN.md b/docs/TUI_PLAN.md
index 1e335df..eff3745 100644
--- a/docs/TUI_PLAN.md
+++ b/docs/TUI_PLAN.md
@@ -11,9 +11,9 @@ protocol compatibility.
 - `signal-cli` is the protocol engine for now.
 - `signal-cli` must stay current because Signal server behavior changes.
 - The TUI must not mutate UI state from worker threads.
-- The TUI runs `signal-cli jsonRpc --receive-mode on-connection` so incoming
-  messages are delivered as JSON-RPC `receive` notifications while the UI is
-  open.
+- The TUI runs `signal-cli jsonRpc --receive-mode manual`, then calls
+  `subscribeReceive` after the JSON-RPC reader is active so incoming messages
+  are delivered as `receive` notifications while the UI is open.
 - Local message storage must be explicit because Signal history is sensitive.
 - A newly linked device cannot fetch arbitrary old phone history; it can display
   messages received after linking and whatever local history we store.
@@ -91,20 +91,22 @@ The first milestone is intentionally backend-heavy:
 - Incoming notifications are available through a non-blocking event drain.
 - `jerboa-signal tui` starts the backend in receive mode and opens a terminal
   shell when a TTY is available. In noninteractive contexts it prints the
-  backend status and exits cleanly.
+  backend status and exits cleanly without subscribing to receive messages.
 
 After that, the terminal renderer can be added on top without changing the
 Signal process boundary.
 
 ## Current Status
 
-- `jerboa-signal tui` uses `--receive-mode on-connection`.
+- `jerboa-signal tui` uses `--receive-mode manual` plus `subscribeReceive`.
 - Startup calls `listGroups` and `listContacts` through the JSON-RPC actor to
   seed selectable conversations.
 - Ctrl-N opens a new-message picker that filters loaded contacts/groups and can
   create a direct conversation for a typed `+number`.
-- Incoming receive notifications are routed into in-memory conversations.
+- Incoming receive notifications are routed into conversations, including
+  ordinary messages, sent-message sync events, edits, reactions, remote deletes,
+  calls, receipts, and typing indicators.
 - The conversation pane supports Up/Down selection and unread counts.
 - Enter sends the current composer text to the selected direct or group
   conversation through `signal-cli` JSON-RPC.
-- Local history is still ephemeral; quitting the TUI forgets the session.
+- Local history is session-only unless encrypted logging is enabled explicitly.
diff --git a/signal/capture.ss b/signal/capture.ss
index f9a7bb4..c8ed555 100644
--- a/signal/capture.ss
+++ b/signal/capture.ss
@@ -70,6 +70,7 @@
           [sync (htref env "syncMessage")]
           [receipt (htref env "receiptMessage")]
           [typing (htref env "typingMessage")]
+          [call (htref env "callMessage")]
           [src-name (first-string (htref env "sourceName")
                                   (htref env "sourceNumber")
                                   (htref env "source")
@@ -85,7 +86,7 @@
                  src-name
                  (or (htref data "timestamp") env-ts)
                  (data-kind data)
-                 (or (htref data "message") (body-marker data))))]
+                 (or (nonempty (htref data "message")) (body-marker data))))]
         [(hashtable? sync)
          (let ([sent (htref sync "sentMessage")])
            (if (hashtable? sent)
@@ -98,12 +99,15 @@
                      "You"
                      (or (htref sent "timestamp") env-ts)
                      (data-kind sent)
-                     (or (htref sent "message") (body-marker sent))))
+                     (or (nonempty (htref sent "message")) (body-marker sent))))
              (list "out" "" "You" env-ts "sync" "")))]
         [(hashtable? receipt)
          (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "receipt" "")]
         [(hashtable? typing)
          (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "typing" "")]
+        [(hashtable? call)
+         (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "call"
+               (call-marker call))]
         [else
          (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "event" "")])))
 
@@ -111,10 +115,56 @@
     (cond
       [(htref msg "remoteDelete") "remote-delete"]
       [(htref msg "editMessage") "edit"]
+      [(htref msg "reaction") "reaction"]
+      [(htref msg "sticker") "sticker"]
+      [(nonempty-list? (htref msg "contacts")) "contact"]
+      [(nonempty-list? (htref msg "preview")) "preview"]
+      [(nonempty-list? (htref msg "attachments")) "attachment"]
       [else "data"]))
 
   (def (body-marker msg)
-    (if (htref msg "attachments") "[attachment]" ""))
+    (or (edit-body (htref msg "editMessage"))
+        (reaction-marker (htref msg "reaction"))
+        (and (htref msg "remoteDelete") "[message deleted]")
+        (and (htref msg "sticker") "[sticker]")
+        (and (nonempty-list? (htref msg "attachments")) "[attachment]")
+        (and (nonempty-list? (htref msg "contacts")) "[contact]")
+        (and (nonempty-list? (htref msg "preview")) "[link preview]")
+        ""))
+
+  (def (edit-body edit)
+    (and (hashtable? edit)
+         (let* ([nested (or (htref edit "dataMessage")
+                            (htref edit "message"))]
+                [body (cond
+                        [(hashtable? nested)
+                         (or (nonempty (htref nested "message"))
+                             (body-marker nested))]
+                        [(string? nested) nested]
+                        [else #f])])
+           (if (nonempty body)
+             (string-append "[edited] " body)
+             "[edited message]"))))
+
+  (def (reaction-marker reaction)
+    (and (hashtable? reaction)
+         (let ([emoji (first-string (htref reaction "emoji")
+                                    (htref reaction "reaction"))]
+               [remove? (or (htref reaction "isRemove")
+                            (htref reaction "remove"))])
+           (if (nonempty emoji)
+             (string-append (if remove? "[removed reaction: " "[reaction: ")
+                            emoji
+                            "]")
+             (if remove? "[removed reaction]" "[reaction]")))))
+
+  (def (call-marker call)
+    (cond
+      [(htref call "offerMessage") "[call offer]"]
+      [(htref call "answerMessage") "[call answer]"]
+      [(htref call "hangupMessage") "[call ended]"]
+      [(htref call "busyMessage") "[call busy]"]
+      [else "[call event]"]))
 
   (def (conv-id gid target)
     (string-append (if gid "group:" "direct:")
@@ -148,6 +198,8 @@
 
   (def (nonempty s) (and (string? s) (not (string=? s "")) s))
 
+  (def (nonempty-list? x) (and (list? x) (pair? x)))
+
   (def (first-string . xs)
     (cond
       [(null? xs) #f]
diff --git a/signal/cmd-tui.ss b/signal/cmd-tui.ss
index 2ca762c..fee4a83 100644
--- a/signal/cmd-tui.ss
+++ b/signal/cmd-tui.ss
@@ -1,8 +1,9 @@
 #!chezscheme
 ;;; signal/cmd-tui -- terminal UI entry point.
 ;;;
-;;; Starts the long-lived JSON-RPC actor in on-connection receive mode and runs
-;;; the termbox2 TUI when stdin/stdout are attached to a terminal.
+;;; Starts the long-lived JSON-RPC actor in manual receive mode, subscribes
+;;; after the reader is running, and then runs the termbox2 TUI when
+;;; stdin/stdout are attached to a terminal.
 
 (library (signal cmd-tui)
   (export cmd-tui)
@@ -22,7 +23,7 @@
           (signal tui main))
 
   (def (cmd-tui account)
-    (let* ([receive-mode "on-connection"]
+    (let* ([receive-mode "manual"]
            [actor (start-signal-actor account receive-mode)])
       (dynamic-wind
         (lambda () (void))
@@ -33,7 +34,12 @@
                             "unknown")])
             (if (and (tty? (current-input-port))
                      (tty? (current-output-port)))
-              (run-tui-terminal account actor version receive-mode)
+              (begin
+                ;; signal-cli's JSON-RPC manual recommends manual receive +
+                ;; subscribeReceive when embedding, so no receive starts before
+                ;; the app reader is ready to demultiplex notifications.
+                (actor-call actor "subscribeReceive" #f)
+                (run-tui-terminal account actor version receive-mode))
               (begin
                 (display "jerboa-signal tui backend scaffold\n")
                 (display "signal-cli actor pid: ")
@@ -45,6 +51,8 @@
                 (display "receive mode: ")
                 (display receive-mode)
                 (newline)
+                (display "receive subscription: not started (requires a TTY)")
+                (newline)
                 (display "terminal renderer: requires a TTY")
                 (newline)
                 (display "roadmap: docs/TUI_PLAN.md\n")))))
diff --git a/signal/log_shim.c b/signal/log_shim.c
index 30f44ef..ea890a9 100644
--- a/signal/log_shim.c
+++ b/signal/log_shim.c
@@ -130,10 +130,10 @@ long long signal_log_count(void *handle) {
     return n;
 }
 
-/* Begin reading the most recent displayable rows, newest first. Only data
- * and edit rows with a body and a conversation qualify -- receipts, typing
- * indicators and bare events are noise for history. Returns a statement
- * handle (step/text/int/close below) or NULL.
+/* Begin reading the most recent displayable rows, newest first. Data-like rows
+ * with a body and a conversation qualify; receipts, typing indicators and bare
+ * events are noise for history. Returns a statement handle
+ * (step/text/int/close below) or NULL.
  * Columns: 0 direction, 1 conversation, 2 sender, 3 timestamp, 4 kind, 5 body. */
 void *signal_log_recent(void *handle, long long limit) {
     if (!handle) return NULL;
@@ -141,7 +141,8 @@ void *signal_log_recent(void *handle, long long limit) {
     static const char *SQL =
         "SELECT direction, conversation, sender, timestamp, kind, body"
         " FROM messages"
-        " WHERE kind IN ('data','edit')"
+        " WHERE kind IN ('data','edit','reaction','remote-delete','sticker',"
+        "                'contact','preview','attachment','call')"
         "   AND direction IN ('in','out')"
         "   AND conversation IS NOT NULL AND conversation <> ''"
         "   AND body IS NOT NULL AND body <> ''"
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index d0a5c8a..8de5ef9 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -1950,7 +1950,8 @@
     (let ([data (hashtable-ref envelope "dataMessage" #f)]
           [sync (hashtable-ref envelope "syncMessage" #f)]
           [receipt (hashtable-ref envelope "receiptMessage" #f)]
-          [typing (hashtable-ref envelope "typingMessage" #f)])
+          [typing (hashtable-ref envelope "typingMessage" #f)]
+          [call (hashtable-ref envelope "callMessage" #f)])
       (cond
         [(hashtable? data)
          (data-message->chat-event account envelope data)]
@@ -1960,6 +1961,9 @@
          (receipt->chat-event receipt)]
         [(hashtable? typing)
          (typing->chat-event envelope typing)]
+        [(hashtable? call)
+         (cons 'message
+               (simple-envelope-event envelope (call-message-text call) 'call))]
         [else
          (cons 'message
                (simple-envelope-event envelope "received Signal event" 'event))])))
@@ -1977,7 +1981,8 @@
            [text (message-text data)]
            [timestamp (or (hashtable-ref data "timestamp" #f)
                           (hashtable-ref envelope "timestamp" #f))]
-           [msg (make-chat-message 'in source-title text timestamp 'data #f)])
+           [msg (make-chat-message 'in source-title text timestamp
+                                   (message-kind-symbol data) #f)])
       (list 'message id title kind target msg)))
 
   (def (sync-message->chat-event account envelope sync)
@@ -1998,7 +2003,8 @@
                   [text (message-text sent)]
                   [timestamp (or (hashtable-ref sent "timestamp" #f)
                                  (hashtable-ref envelope "timestamp" #f))]
-                  [msg (make-chat-message 'out "You" text timestamp 'data #f)])
+                  [msg (make-chat-message 'out "You" text timestamp
+                                          (message-kind-symbol sent) #f)])
              (list 'message id title kind target msg)))))
 
   (def (simple-envelope-event envelope text kind)
@@ -2060,10 +2066,73 @@
                    (safe-display (or target fallback "unknown"))))
 
   (def (message-text msg)
-    (let ([text (hashtable-ref msg "message" #f)])
-      (if (non-empty-string? text)
-        text
-        (message-kind msg))))
+    (or (first-non-empty-string (hashtable-ref msg "message" #f))
+        (edit-message-text (hashtable-ref msg "editMessage" #f))
+        (reaction-message-text (hashtable-ref msg "reaction" #f))
+        (remote-delete-message-text (hashtable-ref msg "remoteDelete" #f))
+        (quote-message-text (hashtable-ref msg "quote" #f))
+        (message-kind msg)))
+
+  (def (message-kind-symbol msg)
+    (cond
+      [(and (hashtable? msg) (hashtable-ref msg "editMessage" #f)) 'edit]
+      [(and (hashtable? msg) (hashtable-ref msg "remoteDelete" #f)) 'remote-delete]
+      [(and (hashtable? msg) (hashtable-ref msg "reaction" #f)) 'reaction]
+      [(and (hashtable? msg) (hashtable-ref msg "sticker" #f)) 'sticker]
+      [(and (hashtable? msg) (nonempty-list? (hashtable-ref msg "contacts" #f))) 'contact]
+      [(and (hashtable? msg) (nonempty-list? (hashtable-ref msg "preview" #f))) 'preview]
+      [(and (hashtable? msg) (nonempty-list? (hashtable-ref msg "attachments" #f))) 'attachment]
+      [else 'data]))
+
+  (def (edit-message-text edit)
+    (and (hashtable? edit)
+         (let* ([nested (or (hashtable-ref edit "dataMessage" #f)
+                            (hashtable-ref edit "message" #f))]
+                [body (cond
+                        [(hashtable? nested)
+                         (or (first-non-empty-string
+                               (hashtable-ref nested "message" #f)
+                               (hashtable-ref nested "body" #f))
+                             (message-kind nested))]
+                        [(string? nested) nested]
+                        [else #f])])
+           (if (non-empty-string? body)
+             (string-append "[edited] " body)
+             "[edited message]"))))
+
+  (def (reaction-message-text reaction)
+    (and (hashtable? reaction)
+         (let* ([emoji (first-non-empty-string
+                         (hashtable-ref reaction "emoji" #f)
+                         (hashtable-ref reaction "reaction" #f))]
+                [remove? (or (hashtable-ref reaction "isRemove" #f)
+                             (hashtable-ref reaction "remove" #f))]
+                [prefix (if remove? "removed reaction" "reacted")])
+           (if (non-empty-string? emoji)
+             (string-append "[" prefix ": " emoji "]")
+             (string-append "[" prefix "]")))))
+
+  (def (remote-delete-message-text remote-delete)
+    (and (hashtable? remote-delete) "[message deleted]"))
+
+  (def (quote-message-text quote)
+    (and (hashtable? quote)
+         (let ([text (first-non-empty-string
+                       (hashtable-ref quote "text" #f)
+                       (hashtable-ref quote "message" #f))])
+           (and text (string-append "[quote] " text)))))
+
+  (def (call-message-text call)
+    (let ([kind (cond
+                  [(hashtable-ref call "offerMessage" #f) "call offer"]
+                  [(hashtable-ref call "answerMessage" #f) "call answer"]
+                  [(hashtable-ref call "hangupMessage" #f) "call ended"]
+                  [(hashtable-ref call "busyMessage" #f) "call busy"]
+                  [else "call event"])])
+      (string-append "[" kind "]")))
+
+  (def (nonempty-list? x)
+    (and (list? x) (pair? x)))
 
   (def (message-group-id msg)
     (or (first-non-empty-string (hashtable-ref msg "groupId" #f))
@@ -2147,8 +2216,17 @@
   (def (message-kind msg)
     (cond
       [(and (hashtable? msg)
-            (hashtable-ref msg "attachments" #f))
+            (hashtable-ref msg "sticker" #f))
+       "[sticker]"]
+      [(and (hashtable? msg)
+            (nonempty-list? (hashtable-ref msg "attachments" #f)))
        "[attachment]"]
+      [(and (hashtable? msg)
+            (nonempty-list? (hashtable-ref msg "contacts" #f)))
+       "[contact]"]
+      [(and (hashtable? msg)
+            (nonempty-list? (hashtable-ref msg "preview" #f)))
+       "[link preview]"]
       [else "[message]"]))
 
   ;; --- Colors ---