More features

This commit is contained in:
2026-06-10 12:43:24 -04:00
parent 02d78765bc
commit dcead245a7
12 changed files with 954 additions and 21 deletions
+21
View File
@@ -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-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= — start a new conversation by searching contacts
- =M-x noise-new-chat-sync-contacts= — re-sync contacts from signal-cli - =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= 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 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 | | =q= | Quit chat list window |
| =C-x b= | Switch conversation (minibuffer) | | =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 **** Minibuffer switcher
=M-x noise-switcher= (or =C-x b= from the chat list) opens a fuzzy =M-x noise-switcher= (or =C-x b= from the chat list) opens a fuzzy
+15 -6
View File
@@ -9,6 +9,7 @@
(require 'noise-db) (require 'noise-db)
(require 'noise-new-chat) (require 'noise-new-chat)
(require 'noise-conversation)
(require 'cl-lib) (require 'cl-lib)
(defvar noise-chat-list-mode-map (defvar noise-chat-list-mode-map
@@ -113,21 +114,29 @@ CONV is a hash table with string keys from noise-db."
preview-str " " preview-str " "
(propertize time-str 'face 'shadow)))) (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 () (defun noise-chat-list-open ()
"Open the conversation at point." "Open the conversation at point."
(interactive) (interactive)
(let* ((conversations (noise-db-get-all-conversations)) (let ((conv (noise-chat-list--conversation-at-point)))
(idx (1- (line-number-at-pos)))
(conv (nth idx conversations)))
(if conv (if conv
(message "Open conversation: %s (not yet implemented)" (noise-conversation-open (gethash "id" conv))
(gethash "name" conv))
(message "No conversation on this line.")))) (message "No conversation on this line."))))
(defun noise-chat-list-mark-read () (defun noise-chat-list-mark-read ()
"Mark the conversation at point as read." "Mark the conversation at point as read."
(interactive) (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 () (defun noise-chat-list-refresh ()
"Refresh the chat list (re-render from database)." "Refresh the chat list (re-render from database)."
+249
View File
@@ -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
+28
View File
@@ -249,6 +249,34 @@ Keyword arguments: :limit N, :offset N."
(setq sql-params (nconc sql-params (list offset)))) (setq sql-params (nconc sql-params (list offset))))
(apply #'noise-db--query sql-str sql-params))) (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) (defun noise-db-get-conversation-members (conversation-id)
"Get member contact IDs for CONVERSATION-ID." "Get member contact IDs for CONVERSATION-ID."
(noise-db--query (noise-db--query
+2 -3
View File
@@ -11,6 +11,7 @@
(require 'noise-rpc) (require 'noise-rpc)
(require 'noise-db) (require 'noise-db)
(require 'noise-conversation)
(require 'cl-lib) (require 'cl-lib)
(declare-function noise-chat-list--render "noise-chat-list") (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*"))) (when-let ((buf (get-buffer "*signal*")))
(with-current-buffer buf (with-current-buffer buf
(noise-chat-list--render))) (noise-chat-list--render)))
;; Conversation buffers are not implemented yet; land on the chat list. (noise-conversation-open contact-id))
(message "Conversation with %s ready (conversation buffer not yet implemented)"
(or name contact-id)))
(defun noise-new-chat--candidate (contact) (defun noise-new-chat--candidate (contact)
"Format CONTACT (a hash table from noise-db) as a completion candidate. "Format CONTACT (a hash table from noise-db) as a completion candidate.
+224
View File
@@ -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
+2 -1
View File
@@ -10,6 +10,7 @@
(require 'noise-db) (require 'noise-db)
(require 'noise-new-chat) (require 'noise-new-chat)
(require 'noise-conversation)
(defun noise-switcher () (defun noise-switcher ()
"Switch to a Signal conversation via minibuffer completion. "Switch to a Signal conversation via minibuffer completion.
@@ -31,7 +32,7 @@ Shows existing conversations and contacts without conversations."
(if item (if item
(if (gethash "id" item) (if (gethash "id" item)
;; It's a conversation (has an id) ;; 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 ;; It's a contact without a conversation — start one
(noise-new-chat-with-contact (gethash "contact_id" item) (noise-new-chat-with-contact (gethash "contact_id" item)
(gethash "name" item))) (gethash "name" item)))
+5
View File
@@ -13,7 +13,9 @@
(require 'noise-rpc) (require 'noise-rpc)
(require 'noise-chat-list) (require 'noise-chat-list)
(require 'noise-conversation)
(require 'noise-new-chat) (require 'noise-new-chat)
(require 'noise-receive)
(defgroup noise nil (defgroup noise nil
"Signal Messenger client for Emacs." "Signal Messenger client for Emacs."
@@ -76,7 +78,10 @@ Queries the daemon for registered accounts and sets `noise-account'."
:lighter " Noise" :lighter " Noise"
:group 'noise :group 'noise
(if noise-mode (if noise-mode
(progn
(noise-chat-list) (noise-chat-list)
(noise-receive-start-polling))
(noise-receive-stop-polling)
(message "Noise mode disabled"))) (message "Noise mode disabled")))
(provide 'noise) (provide 'noise)
+155
View File
@@ -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))))
+13 -4
View File
@@ -28,7 +28,10 @@
(progn (progn
(noise-new-chat-test--setup) (noise-new-chat-test--setup)
,@body) ,@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) (defun noise-new-chat-test--contact (&rest kvs)
"Build a hash table simulating a signal-cli contact from KVS pairs." "Build a hash table simulating a signal-cli contact from KVS pairs."
@@ -135,13 +138,19 @@
(noise-db-get-conversation-members "+1"))))))) (noise-db-get-conversation-members "+1")))))))
(ert-deftest noise-new-chat-with-contact-is-idempotent () (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-new-chat-test--with-db
(unwind-protect
(progn
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah" (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah"
:members '("+1") :members '("+1")
:unread-count 3 :unread-count 3
:last-message-preview "hello") :last-message-preview "hello")
(noise-new-chat-with-contact "+1" "Sarah") (noise-new-chat-with-contact "+1" "Sarah")
(let ((conv (noise-db-get-conversation "+1"))) (let ((conv (noise-db-get-conversation "+1")))
(should (= 3 (gethash "unread_count" conv))) ;; opening the chat marks it read
(should (string= "hello" (gethash "last_message_preview" conv)))))) (should (= 0 (gethash "unread_count" conv)))
(should (string= "hello" (gethash "last_message_preview" conv)))))
(when (get-buffer "*signal:Sarah*")
(kill-buffer "*signal:Sarah*")))))
+42
View File
@@ -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)))))
+191
View File
@@ -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))))))