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-new-chat= — start a new conversation by searching contacts
|
||||
- =M-x noise-new-chat-sync-contacts= — re-sync contacts from signal-cli
|
||||
- =M-x noise-receive= — fetch new messages from signal-cli once
|
||||
|
||||
While =noise-mode= is enabled, Noise polls signal-cli for new messages
|
||||
every =noise-receive-interval= seconds (default 5). Incoming messages,
|
||||
delivery/read receipts, typing indicators, and messages sent from your
|
||||
other linked devices are all stored locally and reflected in open
|
||||
buffers automatically.
|
||||
|
||||
If signal-cli has more than one account registered, set =noise-account=
|
||||
to the phone number you want to use (E.164 format), or run
|
||||
@@ -158,6 +165,20 @@ account it can stay nil.
|
||||
| =q= | Quit chat list window |
|
||||
| =C-x b= | Switch conversation (minibuffer) |
|
||||
|
||||
**** Conversation buffer (*signal:NAME*)
|
||||
|
||||
Compose inline at the =>= prompt at the bottom of the buffer. Message
|
||||
history above the prompt is read-only.
|
||||
|
||||
| Key | Action |
|
||||
|-----------+---------------------------------|
|
||||
| =RET= | Send the composed message |
|
||||
| =M-RET= | Insert a newline (multi-line) |
|
||||
| =C-c C-g= | Refresh conversation from store |
|
||||
|
||||
Sent messages show a =✓= once sent and =✓✓= once delivered or read.
|
||||
When the contact is typing, the modeline shows =NAME is typing…=.
|
||||
|
||||
**** Minibuffer switcher
|
||||
|
||||
=M-x noise-switcher= (or =C-x b= from the chat list) opens a fuzzy
|
||||
|
||||
+15
-6
@@ -9,6 +9,7 @@
|
||||
|
||||
(require 'noise-db)
|
||||
(require 'noise-new-chat)
|
||||
(require 'noise-conversation)
|
||||
(require 'cl-lib)
|
||||
|
||||
(defvar noise-chat-list-mode-map
|
||||
@@ -113,21 +114,29 @@ CONV is a hash table with string keys from noise-db."
|
||||
preview-str " "
|
||||
(propertize time-str 'face 'shadow))))
|
||||
|
||||
(defun noise-chat-list--conversation-at-point ()
|
||||
"Return the conversation on the current line, or nil."
|
||||
(let ((conversations (noise-db-get-all-conversations))
|
||||
(idx (1- (line-number-at-pos))))
|
||||
(nth idx conversations)))
|
||||
|
||||
(defun noise-chat-list-open ()
|
||||
"Open the conversation at point."
|
||||
(interactive)
|
||||
(let* ((conversations (noise-db-get-all-conversations))
|
||||
(idx (1- (line-number-at-pos)))
|
||||
(conv (nth idx conversations)))
|
||||
(let ((conv (noise-chat-list--conversation-at-point)))
|
||||
(if conv
|
||||
(message "Open conversation: %s (not yet implemented)"
|
||||
(gethash "name" conv))
|
||||
(noise-conversation-open (gethash "id" conv))
|
||||
(message "No conversation on this line."))))
|
||||
|
||||
(defun noise-chat-list-mark-read ()
|
||||
"Mark the conversation at point as read."
|
||||
(interactive)
|
||||
(message "Mark read: not yet connected to signal-cli"))
|
||||
(let ((conv (noise-chat-list--conversation-at-point)))
|
||||
(if conv
|
||||
(progn
|
||||
(noise-db-mark-conversation-read (gethash "id" conv))
|
||||
(noise-chat-list--render))
|
||||
(message "No conversation on this line."))))
|
||||
|
||||
(defun noise-chat-list-refresh ()
|
||||
"Refresh the chat list (re-render from database)."
|
||||
|
||||
@@ -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))))
|
||||
(apply #'noise-db--query sql-str sql-params)))
|
||||
|
||||
(defun noise-db-mark-conversation-read (id)
|
||||
"Set the unread count of conversation ID to zero."
|
||||
(noise-db--exec "UPDATE conversations SET unread_count = 0 WHERE id = ?" id))
|
||||
|
||||
(defun noise-db-increment-unread (id)
|
||||
"Increment the unread count of conversation ID by one."
|
||||
(noise-db--exec
|
||||
"UPDATE conversations SET unread_count = unread_count + 1 WHERE id = ?" id))
|
||||
|
||||
(defun noise-db-set-last-message-sender (id sender)
|
||||
"Set the last-message SENDER shown in previews for conversation ID."
|
||||
(noise-db--exec
|
||||
"UPDATE conversations SET last_message_sender = ? WHERE id = ?" sender id))
|
||||
|
||||
(defun noise-db-update-receipt-state (timestamp state)
|
||||
"Upgrade the receipt state of own messages with TIMESTAMP to STATE.
|
||||
Only moves forward (sent < delivered < read), never downgrades."
|
||||
(noise-db--exec
|
||||
"UPDATE messages SET receipt_state = ?
|
||||
WHERE source = 'me' AND timestamp = ?
|
||||
AND (CASE receipt_state
|
||||
WHEN 'read' THEN 3 WHEN 'delivered' THEN 2 WHEN 'sent' THEN 1
|
||||
ELSE 0 END)
|
||||
< (CASE ?
|
||||
WHEN 'read' THEN 3 WHEN 'delivered' THEN 2 WHEN 'sent' THEN 1
|
||||
ELSE 0 END)"
|
||||
state timestamp state))
|
||||
|
||||
(defun noise-db-get-conversation-members (conversation-id)
|
||||
"Get member contact IDs for CONVERSATION-ID."
|
||||
(noise-db--query
|
||||
|
||||
+2
-3
@@ -11,6 +11,7 @@
|
||||
|
||||
(require 'noise-rpc)
|
||||
(require 'noise-db)
|
||||
(require 'noise-conversation)
|
||||
(require 'cl-lib)
|
||||
|
||||
(declare-function noise-chat-list--render "noise-chat-list")
|
||||
@@ -72,9 +73,7 @@ The conversation ID for a direct chat is the contact ID itself."
|
||||
(when-let ((buf (get-buffer "*signal*")))
|
||||
(with-current-buffer buf
|
||||
(noise-chat-list--render)))
|
||||
;; Conversation buffers are not implemented yet; land on the chat list.
|
||||
(message "Conversation with %s ready (conversation buffer not yet implemented)"
|
||||
(or name contact-id)))
|
||||
(noise-conversation-open contact-id))
|
||||
|
||||
(defun noise-new-chat--candidate (contact)
|
||||
"Format CONTACT (a hash table from noise-db) as a completion candidate.
|
||||
|
||||
@@ -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-new-chat)
|
||||
(require 'noise-conversation)
|
||||
|
||||
(defun noise-switcher ()
|
||||
"Switch to a Signal conversation via minibuffer completion.
|
||||
@@ -31,7 +32,7 @@ Shows existing conversations and contacts without conversations."
|
||||
(if item
|
||||
(if (gethash "id" item)
|
||||
;; It's a conversation (has an id)
|
||||
(message "Open conversation: %s (not yet implemented)" (gethash "name" item))
|
||||
(noise-conversation-open (gethash "id" item))
|
||||
;; It's a contact without a conversation — start one
|
||||
(noise-new-chat-with-contact (gethash "contact_id" item)
|
||||
(gethash "name" item)))
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
|
||||
(require 'noise-rpc)
|
||||
(require 'noise-chat-list)
|
||||
(require 'noise-conversation)
|
||||
(require 'noise-new-chat)
|
||||
(require 'noise-receive)
|
||||
|
||||
(defgroup noise nil
|
||||
"Signal Messenger client for Emacs."
|
||||
@@ -76,7 +78,10 @@ Queries the daemon for registered accounts and sets `noise-account'."
|
||||
:lighter " Noise"
|
||||
:group 'noise
|
||||
(if noise-mode
|
||||
(progn
|
||||
(noise-chat-list)
|
||||
(noise-receive-start-polling))
|
||||
(noise-receive-stop-polling)
|
||||
(message "Noise mode disabled")))
|
||||
|
||||
(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
|
||||
(noise-new-chat-test--setup)
|
||||
,@body)
|
||||
(noise-new-chat-test--teardown))))
|
||||
(noise-new-chat-test--teardown)
|
||||
(dolist (buf (buffer-list))
|
||||
(when (string-prefix-p "*signal:" (buffer-name buf))
|
||||
(kill-buffer buf))))))
|
||||
|
||||
(defun noise-new-chat-test--contact (&rest kvs)
|
||||
"Build a hash table simulating a signal-cli contact from KVS pairs."
|
||||
@@ -135,13 +138,19 @@
|
||||
(noise-db-get-conversation-members "+1")))))))
|
||||
|
||||
(ert-deftest noise-new-chat-with-contact-is-idempotent ()
|
||||
"Starting a chat twice should not clobber the existing conversation."
|
||||
"Starting a chat twice should not clobber the existing conversation.
|
||||
Opening the conversation marks it read, but history metadata survives."
|
||||
(noise-new-chat-test--with-db
|
||||
(unwind-protect
|
||||
(progn
|
||||
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah"
|
||||
:members '("+1")
|
||||
:unread-count 3
|
||||
:last-message-preview "hello")
|
||||
(noise-new-chat-with-contact "+1" "Sarah")
|
||||
(let ((conv (noise-db-get-conversation "+1")))
|
||||
(should (= 3 (gethash "unread_count" conv)))
|
||||
(should (string= "hello" (gethash "last_message_preview" conv))))))
|
||||
;; opening the chat marks it read
|
||||
(should (= 0 (gethash "unread_count" conv)))
|
||||
(should (string= "hello" (gethash "last_message_preview" conv)))))
|
||||
(when (get-buffer "*signal:Sarah*")
|
||||
(kill-buffer "*signal:Sarah*")))))
|
||||
|
||||
@@ -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