More features
This commit is contained in:
+21
@@ -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
@@ -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)."
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))))
|
||||||
@@ -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*")))))
|
||||||
|
|||||||
@@ -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)))))
|
||||||
@@ -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))))))
|
||||||
Reference in New Issue
Block a user