diff --git a/README.org b/README.org index 610b566..def6a14 100644 --- a/README.org +++ b/README.org @@ -136,6 +136,13 @@ Once signal-cli is running and Noise is installed: - =M-x noise-select-account= — pick which registered signal-cli account to use (sets =noise-account=) - =M-x noise-new-chat= — start a new conversation by searching contacts - =M-x noise-new-chat-sync-contacts= — re-sync contacts from signal-cli +- =M-x noise-receive= — fetch new messages from signal-cli once + +While =noise-mode= is enabled, Noise polls signal-cli for new messages +every =noise-receive-interval= seconds (default 5). Incoming messages, +delivery/read receipts, typing indicators, and messages sent from your +other linked devices are all stored locally and reflected in open +buffers automatically. If signal-cli has more than one account registered, set =noise-account= to the phone number you want to use (E.164 format), or run @@ -158,6 +165,20 @@ account it can stay nil. | =q= | Quit chat list window | | =C-x b= | Switch conversation (minibuffer) | +**** Conversation buffer (*signal:NAME*) + +Compose inline at the =>= prompt at the bottom of the buffer. Message +history above the prompt is read-only. + +| Key | Action | +|-----------+---------------------------------| +| =RET= | Send the composed message | +| =M-RET= | Insert a newline (multi-line) | +| =C-c C-g= | Refresh conversation from store | + +Sent messages show a =✓= once sent and =✓✓= once delivered or read. +When the contact is typing, the modeline shows =NAME is typing…=. + **** Minibuffer switcher =M-x noise-switcher= (or =C-x b= from the chat list) opens a fuzzy diff --git a/noise-chat-list.el b/noise-chat-list.el index 09060e8..4a95133 100644 --- a/noise-chat-list.el +++ b/noise-chat-list.el @@ -9,6 +9,7 @@ (require 'noise-db) (require 'noise-new-chat) +(require 'noise-conversation) (require 'cl-lib) (defvar noise-chat-list-mode-map @@ -113,21 +114,29 @@ CONV is a hash table with string keys from noise-db." preview-str " " (propertize time-str 'face 'shadow)))) +(defun noise-chat-list--conversation-at-point () + "Return the conversation on the current line, or nil." + (let ((conversations (noise-db-get-all-conversations)) + (idx (1- (line-number-at-pos)))) + (nth idx conversations))) + (defun noise-chat-list-open () "Open the conversation at point." (interactive) - (let* ((conversations (noise-db-get-all-conversations)) - (idx (1- (line-number-at-pos))) - (conv (nth idx conversations))) + (let ((conv (noise-chat-list--conversation-at-point))) (if conv - (message "Open conversation: %s (not yet implemented)" - (gethash "name" conv)) + (noise-conversation-open (gethash "id" conv)) (message "No conversation on this line.")))) (defun noise-chat-list-mark-read () "Mark the conversation at point as read." (interactive) - (message "Mark read: not yet connected to signal-cli")) + (let ((conv (noise-chat-list--conversation-at-point))) + (if conv + (progn + (noise-db-mark-conversation-read (gethash "id" conv)) + (noise-chat-list--render)) + (message "No conversation on this line.")))) (defun noise-chat-list-refresh () "Refresh the chat list (re-render from database)." diff --git a/noise-conversation.el b/noise-conversation.el new file mode 100644 index 0000000..95ab8f3 --- /dev/null +++ b/noise-conversation.el @@ -0,0 +1,249 @@ +;;; noise-conversation.el --- Conversation buffer for Noise -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Provides the *signal:NAME* conversation buffer: full message history +;; with day separators and delivery receipts, plus an inline compose +;; prompt at the bottom. RET sends, M-RET inserts a newline. + +;;; Code: + +(require 'noise-rpc) +(require 'noise-db) +(require 'cl-lib) + +(declare-function noise-chat-list--render "noise-chat-list") + +(defconst noise-conversation--width 71 + "Column width used for day separators and receipt alignment.") + +(defvar-local noise-conversation--id nil + "ID of the conversation shown in this buffer.") + +(defvar-local noise-conversation--input-marker nil + "Marker at the start of the compose input area.") + +(defvar-local noise-conversation--typing nil + "Display name of the contact currently typing, or nil.") + +(defvar-local noise-conversation--typing-timer nil + "Timer that clears a stale typing indicator.") + +(defvar noise-conversation-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "RET") #'noise-conversation-send) + (define-key map (kbd "M-RET") #'noise-conversation-newline) + (define-key map (kbd "C-c C-g") #'noise-conversation-refresh) + map) + "Keymap for `noise-conversation-mode'.") + +(define-derived-mode noise-conversation-mode text-mode "Signal Chat" + "Major mode for a Noise Signal conversation buffer. +\\{noise-conversation-mode-map}" + :group 'noise) + +;;;###autoload +(defun noise-conversation-open (conversation-id) + "Open (or switch to) the buffer for CONVERSATION-ID." + (noise-db-init) + (let ((conv (noise-db-get-conversation conversation-id))) + (unless conv + (user-error "No such conversation: %s" conversation-id)) + (let* ((name (noise-conversation--display-name conv)) + (buf (get-buffer-create (format "*signal:%s*" name)))) + (switch-to-buffer buf) + (unless (derived-mode-p 'noise-conversation-mode) + (noise-conversation-mode)) + (setq noise-conversation--id conversation-id) + (noise-db-mark-conversation-read conversation-id) + (noise-conversation--render) + (noise-conversation--refresh-chat-list) + buf))) + +(defun noise-conversation--display-name (conv) + "Pick the buffer display name for CONV, falling back to its ID." + (let ((name (gethash "name" conv))) + (if (and name (not (string-empty-p name))) name (gethash "id" conv)))) + +(defun noise-conversation--buffer-for (conversation-id) + "Return the live buffer showing CONVERSATION-ID, or nil." + (cl-find-if (lambda (buf) + (with-current-buffer buf + (and (derived-mode-p 'noise-conversation-mode) + (equal noise-conversation--id conversation-id)))) + (buffer-list))) + +(defun noise-conversation-refresh-for (conversation-id) + "Re-render the buffer for CONVERSATION-ID if one is open. +Returns the buffer, or nil if none." + (when-let ((buf (noise-conversation--buffer-for conversation-id))) + (with-current-buffer buf + (noise-conversation--render)) + buf)) + +(defun noise-conversation-refresh () + "Re-render the current conversation from the database." + (interactive) + (noise-conversation--render)) + +;;; Rendering + +(defun noise-conversation--render () + "Render message history and the compose prompt. +Preserves any input already typed at the prompt." + (let ((input (noise-conversation--current-input)) + (inhibit-read-only t) + (messages (noise-db-get-messages noise-conversation--id)) + (last-day nil)) + (erase-buffer) + (dolist (msg messages) + (let ((day (format-time-string + "%a, %b %-e" (gethash "timestamp" msg)))) + (unless (equal day last-day) + (insert (noise-conversation--day-separator day) "\n") + (setq last-day day)) + (insert (noise-conversation--format-message msg) "\n"))) + (insert " \n" + (propertize (make-string noise-conversation--width ?─) + 'face 'shadow) + "\n" + (propertize "> " 'face 'bold)) + (add-text-properties (point-min) (point) + '(read-only t + front-sticky (read-only) + rear-nonsticky (read-only))) + (setq noise-conversation--input-marker (point-marker)) + (when input (insert input)) + (goto-char (point-max)) + (noise-conversation--update-modeline) + (set-buffer-modified-p nil))) + +(defun noise-conversation--current-input () + "Return the text typed at the compose prompt, or nil before first render." + (when (and noise-conversation--input-marker + (marker-position noise-conversation--input-marker)) + (buffer-substring-no-properties noise-conversation--input-marker + (point-max)))) + +(defun noise-conversation--day-separator (day) + "Format DAY as a centered separator line like ──── Tue, Jun 9 ────." + (let* ((label (concat " " day " ")) + (total (max 2 (- noise-conversation--width (length label)))) + (left (/ total 2))) + (propertize (concat (make-string left ?─) label + (make-string (- total left) ?─)) + 'face 'shadow))) + +(defun noise-conversation--format-message (msg) + "Format MSG (a hash table from noise-db) as a [HH:MM] sender> body line." + (let* ((source (or (gethash "source" msg) "")) + (me (string= source "me")) + (body (or (gethash "body" msg) "")) + (receipt (or (gethash "receipt_state" msg) "")) + (time-str (format-time-string "[%H:%M]" (gethash "timestamp" msg))) + (name (cond (me "me") + ((string-empty-p source) "?") + (t source)))) + (when (eq (gethash "has_attachment" msg) 1) + (let ((file (file-name-nondirectory + (or (gethash "attachment_path" msg) "")))) + (setq body (concat body (if (string-empty-p body) "" " ") + (propertize + (format "[attachment: %s — RET to view]" file) + 'face 'link))))) + (let ((line (concat (propertize time-str 'face 'shadow) " " + (propertize name 'face 'bold) + (propertize ">" 'face 'shadow) " " + body))) + (if (and me (not (string-empty-p receipt))) + (let* ((check (if (string= receipt "sent") "✓" "✓✓")) + (pad (max 1 (- noise-conversation--width + (string-width line) + (string-width check))))) + (concat line (make-string pad ?\s) + (propertize check 'face 'success))) + line)))) + +(defun noise-conversation--update-modeline () + "Update the mode line with the E2E indicator and typing status." + (setq mode-line-format + (format " %s %s %s%s ─ U:%%- ─ Bot ───" + (propertize "⌁ E2E" 'face 'success) + (propertize (buffer-name) 'face 'bold) + (if noise-conversation--typing + (format "%s is typing… " noise-conversation--typing) + "") + "(Signal Chat)")) + (force-mode-line-update)) + +;;; Composing and sending + +(defun noise-conversation-newline () + "Insert a newline at the compose prompt without sending." + (interactive) + (insert "\n")) + +(defun noise-conversation-send () + "Send the text composed at the prompt via signal-cli." + (interactive) + (let ((input (string-trim (or (noise-conversation--current-input) "")))) + (if (string-empty-p input) + (message "Nothing to send") + (let* ((conv (noise-db-get-conversation noise-conversation--id)) + (group (string= (gethash "type" conv) "group")) + (result (if group + (noise-rpc-call-for-account + "send" :groupId noise-conversation--id :message input) + (noise-rpc-call-for-account + "send" :recipient (vector noise-conversation--id) + :message input))) + (ts-ms (and (hash-table-p result) (gethash "timestamp" result))) + (ts (if ts-ms (/ ts-ms 1000.0) (float-time)))) + (noise-db-insert-message noise-conversation--id + :timestamp ts + :source "me" + :body input + :receipt-state "sent") + (noise-conversation--clear-input) + (noise-conversation--render) + (noise-conversation--refresh-chat-list))))) + +(defun noise-conversation--clear-input () + "Delete the text typed at the compose prompt." + (when (and noise-conversation--input-marker + (marker-position noise-conversation--input-marker)) + (delete-region noise-conversation--input-marker (point-max)))) + +;;; Typing indicator + +(defun noise-conversation-set-typing (conversation-id name action) + "Show or clear the typing indicator for CONVERSATION-ID. +NAME is the contact typing; ACTION is signal-cli's STARTED or STOPPED." + (when-let ((buf (noise-conversation--buffer-for conversation-id))) + (with-current-buffer buf + (when noise-conversation--typing-timer + (cancel-timer noise-conversation--typing-timer) + (setq noise-conversation--typing-timer nil)) + (if (equal action "STARTED") + (setq noise-conversation--typing name + noise-conversation--typing-timer + (run-with-timer 8 nil #'noise-conversation--clear-typing buf)) + (setq noise-conversation--typing nil)) + (noise-conversation--update-modeline)))) + +(defun noise-conversation--clear-typing (buf) + "Clear the typing indicator in BUF after a timeout." + (when (buffer-live-p buf) + (with-current-buffer buf + (setq noise-conversation--typing nil + noise-conversation--typing-timer nil) + (noise-conversation--update-modeline)))) + +(defun noise-conversation--refresh-chat-list () + "Re-render the *signal* chat list buffer if it exists." + (when-let ((buf (get-buffer "*signal*"))) + (with-current-buffer buf + (noise-chat-list--render)))) + +(provide 'noise-conversation) +;;; noise-conversation.el ends here diff --git a/noise-db.el b/noise-db.el index 7e586ec..06e85c5 100644 --- a/noise-db.el +++ b/noise-db.el @@ -249,6 +249,34 @@ Keyword arguments: :limit N, :offset N." (setq sql-params (nconc sql-params (list offset)))) (apply #'noise-db--query sql-str sql-params))) +(defun noise-db-mark-conversation-read (id) + "Set the unread count of conversation ID to zero." + (noise-db--exec "UPDATE conversations SET unread_count = 0 WHERE id = ?" id)) + +(defun noise-db-increment-unread (id) + "Increment the unread count of conversation ID by one." + (noise-db--exec + "UPDATE conversations SET unread_count = unread_count + 1 WHERE id = ?" id)) + +(defun noise-db-set-last-message-sender (id sender) + "Set the last-message SENDER shown in previews for conversation ID." + (noise-db--exec + "UPDATE conversations SET last_message_sender = ? WHERE id = ?" sender id)) + +(defun noise-db-update-receipt-state (timestamp state) + "Upgrade the receipt state of own messages with TIMESTAMP to STATE. +Only moves forward (sent < delivered < read), never downgrades." + (noise-db--exec + "UPDATE messages SET receipt_state = ? + WHERE source = 'me' AND timestamp = ? + AND (CASE receipt_state + WHEN 'read' THEN 3 WHEN 'delivered' THEN 2 WHEN 'sent' THEN 1 + ELSE 0 END) + < (CASE ? + WHEN 'read' THEN 3 WHEN 'delivered' THEN 2 WHEN 'sent' THEN 1 + ELSE 0 END)" + state timestamp state)) + (defun noise-db-get-conversation-members (conversation-id) "Get member contact IDs for CONVERSATION-ID." (noise-db--query diff --git a/noise-new-chat.el b/noise-new-chat.el index 43d3096..f81363b 100644 --- a/noise-new-chat.el +++ b/noise-new-chat.el @@ -11,6 +11,7 @@ (require 'noise-rpc) (require 'noise-db) +(require 'noise-conversation) (require 'cl-lib) (declare-function noise-chat-list--render "noise-chat-list") @@ -72,9 +73,7 @@ The conversation ID for a direct chat is the contact ID itself." (when-let ((buf (get-buffer "*signal*"))) (with-current-buffer buf (noise-chat-list--render))) - ;; Conversation buffers are not implemented yet; land on the chat list. - (message "Conversation with %s ready (conversation buffer not yet implemented)" - (or name contact-id))) + (noise-conversation-open contact-id)) (defun noise-new-chat--candidate (contact) "Format CONTACT (a hash table from noise-db) as a completion candidate. diff --git a/noise-receive.el b/noise-receive.el new file mode 100644 index 0000000..e278203 --- /dev/null +++ b/noise-receive.el @@ -0,0 +1,224 @@ +;;; noise-receive.el --- Receive messages from signal-cli -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Fetches new envelopes from signal-cli via the receive JSON-RPC method +;; 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-polling' polls on a timer. + +;;; Code: + +(require 'noise-rpc) +(require 'noise-db) +(require 'noise-conversation) +(require 'cl-lib) + +(declare-function noise-chat-list--render "noise-chat-list") + +(defcustom noise-receive-interval 5 + "Seconds between automatic polls of signal-cli for new messages." + :type 'integer + :group 'noise) + +(defvar noise-receive--timer nil + "Active polling timer, or nil.") + +(defvar noise-receive--last-poll-error nil + "Last error message shown by the poller, to avoid repeating it.") + +;;;###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)) + +;;;###autoload +(defun noise-receive-start-polling () + "Start polling signal-cli for new messages every `noise-receive-interval'." + (interactive) + (noise-receive-stop-polling) + (setq noise-receive--timer + (run-with-timer 0 noise-receive-interval #'noise-receive--poll))) + +(defun noise-receive-stop-polling () + "Stop the message polling timer." + (interactive) + (when noise-receive--timer + (cancel-timer noise-receive--timer) + (setq noise-receive--timer nil))) + +(defun noise-receive--poll () + "Poll for messages. +Errors are shown in the echo area once per distinct error rather than +on every poll, and never abort the timer." + (condition-case err + (progn + (noise-receive) + (setq noise-receive--last-poll-error nil)) + (error + (let ((text (error-message-string err))) + (unless (equal text noise-receive--last-poll-error) + (setq noise-receive--last-poll-error text) + (message "Noise: receiving failed: %s%s" + text + (if (string-match-p "account parameter" text) + " — set `noise-account' or run M-x noise-select-account" + ""))))))) + +;;; 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 diff --git a/noise-switcher.el b/noise-switcher.el index 102b42c..ffa3253 100644 --- a/noise-switcher.el +++ b/noise-switcher.el @@ -10,6 +10,7 @@ (require 'noise-db) (require 'noise-new-chat) +(require 'noise-conversation) (defun noise-switcher () "Switch to a Signal conversation via minibuffer completion. @@ -31,7 +32,7 @@ Shows existing conversations and contacts without conversations." (if item (if (gethash "id" item) ;; It's a conversation (has an id) - (message "Open conversation: %s (not yet implemented)" (gethash "name" item)) + (noise-conversation-open (gethash "id" item)) ;; It's a contact without a conversation — start one (noise-new-chat-with-contact (gethash "contact_id" item) (gethash "name" item))) diff --git a/noise.el b/noise.el index 2c10319..8c842fd 100644 --- a/noise.el +++ b/noise.el @@ -13,7 +13,9 @@ (require 'noise-rpc) (require 'noise-chat-list) +(require 'noise-conversation) (require 'noise-new-chat) +(require 'noise-receive) (defgroup noise nil "Signal Messenger client for Emacs." @@ -76,7 +78,10 @@ Queries the daemon for registered accounts and sets `noise-account'." :lighter " Noise" :group 'noise (if noise-mode - (noise-chat-list) + (progn + (noise-chat-list) + (noise-receive-start-polling)) + (noise-receive-stop-polling) (message "Noise mode disabled"))) (provide 'noise) diff --git a/test/noise-conversation-test.el b/test/noise-conversation-test.el new file mode 100644 index 0000000..25353a8 --- /dev/null +++ b/test/noise-conversation-test.el @@ -0,0 +1,155 @@ +;;; noise-conversation-test.el --- Tests for noise-conversation -*- lexical-binding: t; -*- + +(require 'ert) +(require 'noise-conversation) + +(defvar noise-conversation-test--db + (expand-file-name "noise-conversation-test.db" temporary-file-directory)) + +(defmacro noise-conversation-test--with-db (&rest body) + "Run BODY with a fresh test database and clean up conversation buffers." + `(let ((noise-db-file noise-conversation-test--db)) + (unwind-protect + (progn + (when (file-exists-p noise-db-file) + (delete-file noise-db-file)) + (noise-db-init) + ,@body) + (noise-db--close) + (when (file-exists-p noise-conversation-test--db) + (delete-file noise-conversation-test--db)) + (dolist (buf (buffer-list)) + (when (string-prefix-p "*signal:" (buffer-name buf)) + (kill-buffer buf)))))) + +(defun noise-conversation-test--seed () + "Insert a direct conversation with Sarah and two messages." + (noise-db-upsert-contact "+1" :name "Sarah") + (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah" + :members '("+1")) + (noise-db-insert-message "+1" :timestamp 1717930000.0 + :source "Sarah" :body "are you coming?") + (noise-db-insert-message "+1" :timestamp 1717930100.0 + :source "me" :body "yes, leaving now" + :receipt-state "delivered")) + +(ert-deftest noise-conversation-open-renders-history () + "Opening a conversation renders messages, a day separator, and the prompt." + (noise-conversation-test--with-db + (noise-conversation-test--seed) + (with-current-buffer (noise-conversation-open "+1") + (let ((text (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "Sarah> are you coming\\?" text)) + (should (string-match-p "me> yes, leaving now" text)) + ;; day separator present + (should (string-match-p "─── .* ───" text)) + ;; delivered receipt + (should (string-match-p "✓✓" text)) + ;; compose prompt at the bottom + (should (string-match-p "^> " text)))))) + +(ert-deftest noise-conversation-open-marks-read () + "Opening a conversation resets its unread count." + (noise-conversation-test--with-db + (noise-conversation-test--seed) + (noise-db-upsert-conversation "+1" :unread-count 4) + (noise-conversation-open "+1") + (should (= 0 (gethash "unread_count" (noise-db-get-conversation "+1")))))) + +(ert-deftest noise-conversation-history-is-read-only () + "Typing into the history region should fail; the prompt is editable." + (noise-conversation-test--with-db + (noise-conversation-test--seed) + (with-current-buffer (noise-conversation-open "+1") + (goto-char (point-min)) + (should-error (insert "x")) + (goto-char (point-max)) + (insert "hello") + (should (string= "hello" (noise-conversation--current-input)))))) + +(ert-deftest noise-conversation-send-stores-and-clears () + "Sending stores the message with the server timestamp and clears input." + (noise-conversation-test--with-db + (noise-conversation-test--seed) + (with-current-buffer (noise-conversation-open "+1") + (goto-char (point-max)) + (insert "on my way") + (let (sent-params) + (cl-letf (((symbol-function 'noise-rpc-call-for-account) + (lambda (method &rest params) + (should (string= "send" method)) + (setq sent-params params) + (let ((ht (make-hash-table :test 'equal))) + (puthash "timestamp" 1717930200123 ht) + ht)))) + (noise-conversation-send)) + (should (equal ["+1"] (plist-get sent-params :recipient))) + (should (string= "on my way" (plist-get sent-params :message)))) + ;; input cleared, message rendered + (should (string= "" (noise-conversation--current-input))) + (let ((msgs (noise-db-get-messages "+1"))) + (should (= 3 (length msgs))) + (let ((last (car (last msgs)))) + (should (string= "me" (gethash "source" last))) + (should (string= "on my way" (gethash "body" last))) + (should (string= "sent" (gethash "receipt_state" last))) + (should (= 1717930200.123 (gethash "timestamp" last)))))))) + +(ert-deftest noise-conversation-send-group-uses-group-id () + "Sending in a group conversation uses the groupId parameter." + (noise-conversation-test--with-db + (noise-db-upsert-conversation "grp1" :type "group" :name "cycling crew") + (with-current-buffer (noise-conversation-open "grp1") + (goto-char (point-max)) + (insert "saturday 7am?") + (let (sent-params) + (cl-letf (((symbol-function 'noise-rpc-call-for-account) + (lambda (_method &rest params) + (setq sent-params params) + nil))) + (noise-conversation-send)) + (should (string= "grp1" (plist-get sent-params :groupId))) + (should-not (plist-get sent-params :recipient)))))) + +(ert-deftest noise-conversation-send-empty-does-nothing () + "Sending with no input should not call signal-cli." + (noise-conversation-test--with-db + (noise-conversation-test--seed) + (with-current-buffer (noise-conversation-open "+1") + (cl-letf (((symbol-function 'noise-rpc-call-for-account) + (lambda (&rest _) (error "Should not be called")))) + (noise-conversation-send)) + (should (= 2 (length (noise-db-get-messages "+1"))))))) + +(ert-deftest noise-conversation-render-preserves-input () + "Re-rendering keeps text already typed at the prompt." + (noise-conversation-test--with-db + (noise-conversation-test--seed) + (with-current-buffer (noise-conversation-open "+1") + (goto-char (point-max)) + (insert "draft text") + (noise-conversation--render) + (should (string= "draft text" (noise-conversation--current-input)))))) + +(ert-deftest noise-conversation-format-attachment () + "Attachment messages render the [attachment: ...] hint." + (noise-conversation-test--with-db + (noise-conversation-test--seed) + (noise-db-insert-message "+1" :timestamp 1717930300.0 + :source "Sarah" :body "" + :has-attachment t + :attachment-path "IMG_2041.jpg") + (with-current-buffer (noise-conversation-open "+1") + (should (string-match-p "\\[attachment: IMG_2041\\.jpg — RET to view\\]" + (buffer-string)))))) + +(ert-deftest noise-conversation-typing-indicator () + "Typing STARTED/STOPPED toggles the modeline indicator." + (noise-conversation-test--with-db + (noise-conversation-test--seed) + (with-current-buffer (noise-conversation-open "+1") + (noise-conversation-set-typing "+1" "Sarah" "STARTED") + (should (string= "Sarah" noise-conversation--typing)) + (should (string-match-p "Sarah is typing…" mode-line-format)) + (noise-conversation-set-typing "+1" "Sarah" "STOPPED") + (should-not noise-conversation--typing)))) diff --git a/test/noise-new-chat-test.el b/test/noise-new-chat-test.el index 57b22c7..e2fb4cc 100644 --- a/test/noise-new-chat-test.el +++ b/test/noise-new-chat-test.el @@ -28,7 +28,10 @@ (progn (noise-new-chat-test--setup) ,@body) - (noise-new-chat-test--teardown)))) + (noise-new-chat-test--teardown) + (dolist (buf (buffer-list)) + (when (string-prefix-p "*signal:" (buffer-name buf)) + (kill-buffer buf)))))) (defun noise-new-chat-test--contact (&rest kvs) "Build a hash table simulating a signal-cli contact from KVS pairs." @@ -135,13 +138,19 @@ (noise-db-get-conversation-members "+1"))))))) (ert-deftest noise-new-chat-with-contact-is-idempotent () - "Starting a chat twice should not clobber the existing conversation." + "Starting a chat twice should not clobber the existing conversation. +Opening the conversation marks it read, but history metadata survives." (noise-new-chat-test--with-db - (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah" - :members '("+1") - :unread-count 3 - :last-message-preview "hello") - (noise-new-chat-with-contact "+1" "Sarah") - (let ((conv (noise-db-get-conversation "+1"))) - (should (= 3 (gethash "unread_count" conv))) - (should (string= "hello" (gethash "last_message_preview" conv)))))) + (unwind-protect + (progn + (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah" + :members '("+1") + :unread-count 3 + :last-message-preview "hello") + (noise-new-chat-with-contact "+1" "Sarah") + (let ((conv (noise-db-get-conversation "+1"))) + ;; opening the chat marks it read + (should (= 0 (gethash "unread_count" conv))) + (should (string= "hello" (gethash "last_message_preview" conv))))) + (when (get-buffer "*signal:Sarah*") + (kill-buffer "*signal:Sarah*"))))) diff --git a/test/noise-receive-poll-test.el b/test/noise-receive-poll-test.el new file mode 100644 index 0000000..40387c1 --- /dev/null +++ b/test/noise-receive-poll-test.el @@ -0,0 +1,42 @@ +;;; noise-receive-poll-test.el --- Tests for the receive poller -*- lexical-binding: t; -*- + +(require 'ert) +(require 'noise-receive) + +(ert-deftest noise-receive--poll-surfaces-error-once () + "A failing poll messages the user once, not on every retry." + (let ((noise-receive--last-poll-error nil) + (messages nil)) + (cl-letf (((symbol-function 'noise-receive) + (lambda () + (signal 'noise-rpc-error + '("signal-cli error -32602: Method requires valid account parameter")))) + ((symbol-function 'message) + (lambda (fmt &rest args) + (push (apply #'format fmt args) messages)))) + (noise-receive--poll) + (noise-receive--poll)) + (should (= 1 (length messages))) + (should (string-match-p "account parameter" (car messages))) + ;; the hint points at the fix + (should (string-match-p "noise-select-account" (car messages))))) + +(ert-deftest noise-receive--poll-recovers-and-resets () + "After a successful poll, a recurring error is shown again." + (let ((noise-receive--last-poll-error nil) + (messages nil) + (fail t)) + (cl-letf (((symbol-function 'noise-receive) + (lambda () + (when fail + (signal 'noise-rpc-error '("connection refused"))) + 0)) + ((symbol-function 'message) + (lambda (fmt &rest args) + (push (apply #'format fmt args) messages)))) + (noise-receive--poll) ; error -> message + (setq fail nil) + (noise-receive--poll) ; success -> resets + (setq fail t) + (noise-receive--poll)) ; error again -> message again + (should (= 2 (length messages))))) diff --git a/test/noise-receive-test.el b/test/noise-receive-test.el new file mode 100644 index 0000000..9dec3dc --- /dev/null +++ b/test/noise-receive-test.el @@ -0,0 +1,191 @@ +;;; noise-receive-test.el --- Tests for noise-receive -*- lexical-binding: t; -*- + +(require 'ert) +(require 'noise-receive) + +(defvar noise-receive-test--db + (expand-file-name "noise-receive-test.db" temporary-file-directory)) + +(defmacro noise-receive-test--with-db (&rest body) + "Run BODY with a fresh test database and clean up conversation buffers." + `(let ((noise-db-file noise-receive-test--db)) + (unwind-protect + (progn + (when (file-exists-p noise-db-file) + (delete-file noise-db-file)) + (noise-db-init) + ,@body) + (noise-db--close) + (when (file-exists-p noise-receive-test--db) + (delete-file noise-receive-test--db)) + (dolist (buf (buffer-list)) + (when (string-prefix-p "*signal:" (buffer-name buf)) + (kill-buffer buf)))))) + +(defun noise-receive-test--ht (&rest kvs) + "Build a hash table from KVS key/value pairs." + (let ((ht (make-hash-table :test 'equal))) + (while kvs + (puthash (car kvs) (cadr kvs) ht) + (setq kvs (cddr kvs))) + ht)) + +(defun noise-receive-test--run (&rest envelopes) + "Run `noise-receive' against mocked ENVELOPES, returning its result." + (cl-letf (((symbol-function 'noise-rpc-call-for-account) + (lambda (method &rest _) + (should (string= "receive" method)) + (apply #'vector + (mapcar (lambda (e) + (noise-receive-test--ht "envelope" e)) + envelopes))))) + (noise-receive))) + +(ert-deftest noise-receive-direct-message () + "An incoming direct message creates contact, conversation, and message." + (noise-receive-test--with-db + (should (= 1 (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+1" "sourceName" "Sarah" + "dataMessage" (noise-receive-test--ht + "timestamp" 1717930000000 + "message" "are you coming?"))))) + (should (string= "Sarah" (gethash "name" (noise-db-get-contact "+1")))) + (let ((conv (noise-db-get-conversation "+1"))) + (should (string= "direct" (gethash "type" conv))) + (should (string= "Sarah" (gethash "name" conv))) + (should (= 1 (gethash "unread_count" conv))) + ;; direct chats have no sender prefix in the preview + (should (string= "" (gethash "last_message_sender" conv))) + (should (string= "are you coming?" + (gethash "last_message_preview" conv)))) + (let ((msg (car (noise-db-get-messages "+1")))) + (should (string= "Sarah" (gethash "source" msg))) + (should (= 1717930000.0 (gethash "timestamp" msg)))))) + +(ert-deftest noise-receive-group-message () + "An incoming group message creates a group conversation keyed by groupId." + (noise-receive-test--with-db + (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+2" "sourceName" "Priya" + "dataMessage" (noise-receive-test--ht + "timestamp" 1717930000000 + "message" "saturday 7am, usual spot?" + "groupInfo" (noise-receive-test--ht + "groupId" "grp1" + "groupName" "cycling crew")))) + (let ((conv (noise-db-get-conversation "grp1"))) + (should (string= "group" (gethash "type" conv))) + (should (string= "cycling crew" (gethash "name" conv))) + ;; group previews keep the sender prefix + (should (string= "Priya" (gethash "last_message_sender" conv)))))) + +(ert-deftest noise-receive-attachment-only-message () + "An attachment-only message is stored and gets a photo preview." + (noise-receive-test--with-db + (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+1" "sourceName" "Sarah" + "dataMessage" (noise-receive-test--ht + "timestamp" 1717930000000 + "message" :null + "attachments" (vector (noise-receive-test--ht + "filename" "IMG_2041.jpg"))))) + (let ((msg (car (noise-db-get-messages "+1")))) + (should (= 1 (gethash "has_attachment" msg))) + (should (string= "IMG_2041.jpg" (gethash "attachment_path" msg)))) + (should (string= "photo: IMG_2041.jpg" + (gethash "last_message_preview" + (noise-db-get-conversation "+1")))))) + +(ert-deftest noise-receive-sync-message () + "A sync sentMessage from another device is stored as from `me'." + (noise-receive-test--with-db + (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+me" + "syncMessage" (noise-receive-test--ht + "sentMessage" (noise-receive-test--ht + "timestamp" 1717930000000 + "destinationNumber" "+1" + "message" "sent from my phone")))) + (let ((msg (car (noise-db-get-messages "+1")))) + (should (string= "me" (gethash "source" msg))) + (should (string= "sent" (gethash "receipt_state" msg))) + (should (string= "sent from my phone" (gethash "body" msg)))) + ;; own messages don't bump unread + (should (= 0 (gethash "unread_count" (noise-db-get-conversation "+1")))))) + +(ert-deftest noise-receive-delivery-receipt () + "A delivery receipt upgrades the receipt state of the matching message." + (noise-receive-test--with-db + (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah") + (noise-db-insert-message "+1" :timestamp 1717930000.0 + :source "me" :body "hi" :receipt-state "sent") + (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+1" + "receiptMessage" (noise-receive-test--ht + "isDelivery" t "isRead" :false + "timestamps" (vector 1717930000000)))) + (should (string= "delivered" + (gethash "receipt_state" + (car (noise-db-get-messages "+1"))))))) + +(ert-deftest noise-receive-receipt-never-downgrades () + "A delivery receipt arriving after a read receipt is ignored." + (noise-receive-test--with-db + (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah") + (noise-db-insert-message "+1" :timestamp 1717930000.0 + :source "me" :body "hi" :receipt-state "read") + (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+1" + "receiptMessage" (noise-receive-test--ht + "isDelivery" t "isRead" :false + "timestamps" (vector 1717930000000)))) + (should (string= "read" + (gethash "receipt_state" + (car (noise-db-get-messages "+1"))))))) + +(ert-deftest noise-receive-receipt-ignores-incoming-messages () + "Receipts only apply to own messages, not the contact's." + (noise-receive-test--with-db + (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah") + (noise-db-insert-message "+1" :timestamp 1717930000.0 + :source "Sarah" :body "hi") + (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+1" + "receiptMessage" (noise-receive-test--ht + "isDelivery" t "isRead" :false + "timestamps" (vector 1717930000000)))) + (should (string= "" + (gethash "receipt_state" + (car (noise-db-get-messages "+1"))))))) + +(ert-deftest noise-receive-typing-without-buffer () + "Typing indicators for conversations without open buffers are harmless." + (noise-receive-test--with-db + (should (= 0 (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+1" "sourceName" "Sarah" + "typingMessage" (noise-receive-test--ht + "action" "STARTED" + "timestamp" 1717930000000))))))) + +(ert-deftest noise-receive-refreshes-open-conversation () + "Receiving a message re-renders an open conversation buffer." + (noise-receive-test--with-db + (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah" + :members '("+1")) + (require 'noise-conversation) + (with-current-buffer (noise-conversation-open "+1") + (noise-receive-test--run + (noise-receive-test--ht + "sourceNumber" "+1" "sourceName" "Sarah" + "dataMessage" (noise-receive-test--ht + "timestamp" 1717930000000 + "message" "new message here"))) + (should (string-match-p "Sarah> new message here" (buffer-string))))))