Fix Signal TUI receive handling
ober
a02d790010d36a2776a4331cadab3bb2bd372b03
--- 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 --- 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. --- 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] --- 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"))))) --- 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 <> ''" --- 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 ---