Files
noise/noise-receive.el
T
jbrechtel cedd82738a fix: show notifications and unread count for SSE messages arriving when chat closed
Three gaps caused messages received via SSE to be invisible when
the conversation buffer wasn't already open:

1. Echo-area notification (noise-receive.el): when an SSE message
   arrives and the conversation buffer isn't in a visible window,
   show "[Noise] <name>" in the echo area.

2. Modeline unread counter (noise.el): the noise-mode lighter now
   shows Noise[N] for N unread messages via a lightweight SUM
   aggregation across all conversations.

3. Auto-refresh hook (noise-conversation.el): a
   window-buffer-change-functions hook re-renders *signal:* buffers
   when they become visible, covering direct C-x b switches.

Also adds regression test:
noise-receive-sse-stores-then-open-shows-message —
store via SSE handler with no buffer open, then open and verify.
2026-06-14 12:57:05 -04:00

218 lines
8.9 KiB
EmacsLisp

;;; noise-receive.el --- Receive messages from signal-cli -*- lexical-binding: t; -*-
;;; Commentary:
;; Fetches new envelopes from signal-cli via SSE or JSON-RPC
;; and stores them in the local database: incoming messages, messages
;; sent from other linked devices (sync), delivery/read receipts, and
;; typing indicators. `noise-receive-start-sse' connects to the SSE
;; endpoint for real-time message reception.
;;; Code:
(require 'noise-rpc)
(require 'noise-db)
(require 'noise-conversation)
(require 'noise-sse)
(require 'cl-lib)
(declare-function noise-chat-list--render "noise-chat-list")
;;;###autoload
(defun noise-receive ()
"Fetch and store new envelopes from signal-cli.
Returns the number of new messages stored."
(interactive)
(noise-db-init)
(let ((entries (noise-rpc-call-for-account "receive"))
(new 0)
(touched nil))
(seq-do
(lambda (entry)
(when-let ((envelope (noise-receive--field entry "envelope")))
(when-let ((conv-id (noise-receive--process-envelope envelope)))
(cl-incf new)
(cl-pushnew conv-id touched :test #'equal))))
entries)
(when touched
(dolist (conv-id touched)
(noise-conversation-refresh-for conv-id))
(when-let ((buf (get-buffer "*signal*")))
(with-current-buffer buf
(noise-chat-list--render))))
(when (called-interactively-p 'interactive)
(message "Received %d new message%s" new (if (= new 1) "" "s")))
new))
(defun noise-receive--announce-message (conv-id)
"Show a brief echo-area notification for a new message in CONV-ID."
(let* ((conv (noise-db-get-conversation conv-id))
(name (if conv (or (gethash "name" conv) conv-id) conv-id)))
(message "[Noise] %s" name)))
(defun noise-receive--handle-sse-event (event)
"Handle an SSE EVENT from signal-cli.
Processes the envelope and updates UI."
(noise-db-init)
(when-let ((envelope (noise-receive--field event "envelope")))
(when-let ((conv-id (noise-receive--process-envelope envelope)))
(let ((buf (noise-conversation--buffer-for conv-id)))
(if (and buf (get-buffer-window buf t))
(noise-conversation-refresh-for conv-id)
(noise-receive--announce-message conv-id)))
(when-let ((buf (get-buffer "*signal*")))
(with-current-buffer buf
(noise-chat-list--render))))))
;; Register the receive handler with the SSE client.
(setq noise-sse--event-handler #'noise-receive--handle-sse-event)
;;;###autoload
(defun noise-receive-start-sse ()
"Start receiving messages via SSE."
(interactive)
(noise-sse-connect))
;;;###autoload
(defun noise-receive-stop-sse ()
"Stop receiving messages via SSE."
(interactive)
(noise-sse-disconnect))
;;; Envelope processing
(defun noise-receive--field (obj key)
"Get KEY from hash table OBJ, treating JSON null as nil."
(when (hash-table-p obj)
(let ((v (gethash key obj)))
(unless (eq v :null) v))))
(defun noise-receive--process-envelope (envelope)
"Process one ENVELOPE from signal-cli.
Returns the conversation ID when a new message was stored, else nil."
(let* ((sender-id (or (noise-receive--field envelope "sourceNumber")
(noise-receive--field envelope "source")
(noise-receive--field envelope "sourceUuid")))
(sender-name (or (noise-receive--field envelope "sourceName")
sender-id)))
(cond
((noise-receive--field envelope "dataMessage")
(noise-receive--store-incoming
(noise-receive--field envelope "dataMessage") sender-id sender-name))
((noise-receive--field envelope "syncMessage")
(noise-receive--store-sync
(noise-receive--field envelope "syncMessage")))
((noise-receive--field envelope "receiptMessage")
(noise-receive--apply-receipt
(noise-receive--field envelope "receiptMessage") sender-id)
nil)
((noise-receive--field envelope "typingMessage")
(noise-receive--apply-typing
(noise-receive--field envelope "typingMessage") sender-id sender-name)
nil))))
(defun noise-receive--timestamp (ms)
"Convert a signal-cli millisecond timestamp MS to float seconds."
(/ ms 1000.0))
(defun noise-receive--attachment-name (data-message)
"Return the filename of the first attachment in DATA-MESSAGE, or nil."
(let ((attachments (noise-receive--field data-message "attachments")))
(when (and attachments (> (length attachments) 0))
(let ((a (elt attachments 0)))
(or (noise-receive--field a "filename")
(noise-receive--field a "id"))))))
(defun noise-receive--store-incoming (dm sender-id sender-name)
"Store an incoming data message DM from SENDER-ID / SENDER-NAME.
Returns the conversation ID, or nil if there was nothing to store."
(let* ((body (or (noise-receive--field dm "message") ""))
(group (noise-receive--field dm "groupInfo"))
(group-id (and group (noise-receive--field group "groupId")))
(attachment (noise-receive--attachment-name dm))
(conv-id (or group-id sender-id))
(ts (noise-receive--timestamp
(noise-receive--field dm "timestamp"))))
(when (and conv-id (or (not (string-empty-p body)) attachment))
(when sender-id
(noise-db-upsert-contact sender-id :name sender-name))
(unless (noise-db-get-conversation conv-id)
(noise-db-upsert-conversation
conv-id
:type (if group-id "group" "direct")
:name (if group-id
(or (noise-receive--field group "groupName") "")
sender-name)
:members (and sender-id (list sender-id))))
(noise-db-insert-message conv-id
:timestamp ts
:source sender-name
:body body
:has-attachment (and attachment t)
:attachment-path (or attachment ""))
;; Direct chats show the preview without a sender prefix (the chat
;; list only prefixes group senders and "me").
(unless group-id
(noise-db-set-last-message-sender conv-id ""))
(when (and attachment (string-empty-p body))
(noise-db-upsert-conversation
conv-id :last-message-preview (format "photo: %s" attachment)))
(let ((buf (noise-conversation--buffer-for conv-id)))
(if (and buf (get-buffer-window buf t))
(noise-db-mark-conversation-read conv-id)
(noise-db-increment-unread conv-id)))
conv-id)))
(defun noise-receive--store-sync (sync)
"Store a message sent from another linked device, found in SYNC.
Returns the conversation ID, or nil."
(when-let ((sent (noise-receive--field sync "sentMessage")))
(let* ((body (or (noise-receive--field sent "message") ""))
(group (noise-receive--field sent "groupInfo"))
(group-id (and group (noise-receive--field group "groupId")))
(dest (or (noise-receive--field sent "destinationNumber")
(noise-receive--field sent "destination")
(noise-receive--field sent "destinationUuid")))
(conv-id (or group-id dest))
(attachment (noise-receive--attachment-name sent))
(ts (noise-receive--timestamp
(noise-receive--field sent "timestamp"))))
(when (and conv-id (or (not (string-empty-p body)) attachment))
(unless (noise-db-get-conversation conv-id)
(noise-db-upsert-conversation
conv-id
:type (if group-id "group" "direct")
:name (if group-id "" (or dest ""))
:members (and dest (not group-id) (list dest))))
(noise-db-insert-message conv-id
:timestamp ts
:source "me"
:body body
:receipt-state "sent"
:has-attachment (and attachment t)
:attachment-path (or attachment ""))
conv-id))))
(defun noise-receive--apply-receipt (rm sender-id)
"Apply receipt message RM from SENDER-ID to stored messages."
(let ((state (cond ((eq (noise-receive--field rm "isRead") t) "read")
((eq (noise-receive--field rm "isDelivery") t) "delivered")))
(timestamps (noise-receive--field rm "timestamps")))
(when (and state timestamps)
(seq-do (lambda (ts-ms)
(noise-db-update-receipt-state
(noise-receive--timestamp ts-ms) state))
timestamps)
(when sender-id
(noise-conversation-refresh-for sender-id)))))
(defun noise-receive--apply-typing (tm sender-id sender-name)
"Apply typing message TM from SENDER-ID / SENDER-NAME."
(let ((conv-id (or (noise-receive--field tm "groupId") sender-id))
(action (noise-receive--field tm "action")))
(when conv-id
(noise-conversation-set-typing conv-id sender-name action))))
(provide 'noise-receive)
;;; noise-receive.el ends here