From 02d78765bcf645f8d949cd913274ce14c685126a Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Wed, 10 Jun 2026 12:12:31 -0400 Subject: [PATCH] Adds account switching --- AGENTS.md | 1 + README.org | 12 +++ noise-chat-list.el | 2 + noise-new-chat.el | 120 +++++++++++++++++++++++++++++ noise-rpc.el | 11 +++ noise-switcher.el | 7 +- noise.el | 32 ++++++++ test/noise-new-chat-test.el | 147 ++++++++++++++++++++++++++++++++++++ 8 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 noise-new-chat.el create mode 100644 test/noise-new-chat-test.el diff --git a/AGENTS.md b/AGENTS.md index e98ec33..1aa6de8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Noise is a Signal Messenger client for Emacs, written in Elisp. It communicates - The JSON-RPC interface is called over HTTP (not piped subprocess stdio), so requests are `POST http://localhost:9128/api/v1/rpc` with JSON-RPC 2.0 payloads. - SQLite is the single source of truth for local state: contacts, conversations, messages, read/unread markers. - The address is configurable via the `noise-signal-cli-address` defcustom. +- The Signal account is configurable via the `noise-account` defcustom (nil for a single-account daemon; required when signal-cli has multiple accounts registered). Account-scoped RPC calls go through `noise-rpc-call-for-account`. ## User Experience Principles diff --git a/README.org b/README.org index 569907f..610b566 100644 --- a/README.org +++ b/README.org @@ -63,6 +63,9 @@ Below are recipes for several popular Emacs package managers. ;; Optional: set a custom signal-cli address ;; (setq noise-signal-cli-address "http://localhost:8080") + ;; Required when signal-cli has multiple accounts registered: + ;; (setq noise-account "+15551234567") + ;; Start Noise automatically (noise-mode 1)) #+end_src @@ -130,6 +133,14 @@ Once signal-cli is running and Noise is installed: - =M-x noise-mode= — toggle the Noise client (opens the chat list) - =M-x noise-check-connection= — test connectivity to signal-cli +- =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 + +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 +=M-x noise-select-account= to choose interactively. With a single +account it can stay nil. *** Keybindings @@ -140,6 +151,7 @@ Once signal-cli is running and Noise is installed: | =n= | Next chat | | =p= | Previous chat | | =RET= | Open selected chat | +| =c= | New chat (search contacts) | | =m= | Mark chat as read | | =g= | Refresh chat list | | =/= | Filter chats | diff --git a/noise-chat-list.el b/noise-chat-list.el index b9648d4..09060e8 100644 --- a/noise-chat-list.el +++ b/noise-chat-list.el @@ -8,6 +8,7 @@ ;;; Code: (require 'noise-db) +(require 'noise-new-chat) (require 'cl-lib) (defvar noise-chat-list-mode-map @@ -16,6 +17,7 @@ (define-key map (kbd "p") #'previous-line) (define-key map (kbd "RET") #'noise-chat-list-open) (define-key map (kbd "m") #'noise-chat-list-mark-read) + (define-key map (kbd "c") #'noise-new-chat) (define-key map (kbd "g") #'noise-chat-list-refresh) (define-key map (kbd "/") #'noise-chat-list-filter) (define-key map (kbd "q") #'quit-window) diff --git a/noise-new-chat.el b/noise-new-chat.el new file mode 100644 index 0000000..43d3096 --- /dev/null +++ b/noise-new-chat.el @@ -0,0 +1,120 @@ +;;; noise-new-chat.el --- Start new conversations from contacts -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Provides `noise-new-chat' — start a new conversation by searching +;; contacts with minibuffer completion. Contacts are synced from +;; signal-cli via the listContacts JSON-RPC method and cached in the +;; local SQLite database. + +;;; Code: + +(require 'noise-rpc) +(require 'noise-db) +(require 'cl-lib) + +(declare-function noise-chat-list--render "noise-chat-list") + +;;;###autoload +(defun noise-new-chat () + "Start a new conversation by searching contacts. +Syncs contacts from signal-cli when the local database has none. +Candidates match on both name and phone number." + (interactive) + (noise-db-init) + (unless (noise-db-get-all-contacts) + (noise-new-chat-sync-contacts)) + (let* ((contacts (noise-db-get-all-contacts)) + (collection (mapcar (lambda (c) + (cons (noise-new-chat--candidate c) c)) + contacts))) + (unless collection + (user-error "No contacts found. Is signal-cli synced?")) + (let* ((choice (completing-read "New chat with: " collection nil t)) + (contact (cdr (assoc choice collection)))) + (when contact + (noise-new-chat-with-contact (gethash "id" contact) + (gethash "name" contact)))))) + +;;;###autoload +(defun noise-new-chat-sync-contacts () + "Fetch contacts from signal-cli and store them in the local database. +Returns the number of contacts synced." + (interactive) + (noise-db-init) + (let ((contacts (noise-rpc-call-for-account "listContacts")) + (count 0)) + (seq-do + (lambda (c) + (when-let ((id (noise-new-chat--contact-id c))) + (noise-db-upsert-contact + id + :name (noise-new-chat--contact-name c) + :color (noise-new-chat--field c "color") + :profile-name (noise-new-chat--profile-name c)) + (cl-incf count))) + contacts) + (when (called-interactively-p 'interactive) + (message "Synced %d contacts from signal-cli" count)) + count)) + +(defun noise-new-chat-with-contact (contact-id name) + "Open (creating if needed) a direct conversation with CONTACT-ID. +NAME is used as the conversation name for a new conversation. +The conversation ID for a direct chat is the contact ID itself." + (unless (noise-db-get-conversation contact-id) + (noise-db-upsert-conversation contact-id + :type "direct" + :name (if (and name (not (string-empty-p name))) + name + contact-id) + :members (list contact-id))) + (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))) + +(defun noise-new-chat--candidate (contact) + "Format CONTACT (a hash table from noise-db) as a completion candidate. +Includes the phone number so completion can match on it." + (let ((id (gethash "id" contact)) + (name (gethash "name" contact))) + (if (or (null name) (string-empty-p name) (string= name id)) + id + (format "%s (%s)" name id)))) + +(defun noise-new-chat--field (obj key) + "Get KEY from hash table OBJ, treating JSON null as nil." + (when obj + (let ((v (gethash key obj))) + (unless (eq v :null) v)))) + +(defun noise-new-chat--contact-id (contact) + "Extract the contact ID from a signal-cli CONTACT object. +Prefers the phone number, falling back to the UUID." + (or (noise-new-chat--field contact "number") + (noise-new-chat--field contact "uuid"))) + +(defun noise-new-chat--profile-name (contact) + "Extract the profile display name from a signal-cli CONTACT object." + (when-let ((profile (noise-new-chat--field contact "profile"))) + (let ((full (string-trim + (concat (or (noise-new-chat--field profile "givenName") "") + " " + (or (noise-new-chat--field profile "familyName") ""))))) + (unless (string-empty-p full) full)))) + +(defun noise-new-chat--contact-name (contact) + "Pick the best display name for a signal-cli CONTACT object. +Prefers the local contact name, then the profile name, then the +username, then the phone number." + (or (let ((name (noise-new-chat--field contact "name"))) + (and name (not (string-empty-p name)) name)) + (noise-new-chat--profile-name contact) + (noise-new-chat--field contact "username") + (noise-new-chat--contact-id contact))) + +(provide 'noise-new-chat) +;;; noise-new-chat.el ends here diff --git a/noise-rpc.el b/noise-rpc.el index 9814f6e..2487e44 100644 --- a/noise-rpc.el +++ b/noise-rpc.el @@ -11,6 +11,7 @@ (require 'cl-lib) (defvar noise-signal-cli-address) +(defvar noise-account) (define-error 'noise-rpc-error "signal-cli JSON-RPC error") @@ -67,6 +68,16 @@ Signals `noise-rpc-error` on failure or connection error." result)) (kill-buffer buf))))) +(defun noise-rpc-call-for-account (method &rest params) + "Call signal-cli JSON-RPC METHOD with PARAMS for the configured account. +Like `noise-rpc-call', but when `noise-account' is set the `account' +parameter is added to the request. Account-scoped methods (such as +listContacts or send) require it when the daemon serves multiple +accounts." + (if (and (boundp 'noise-account) noise-account) + (apply #'noise-rpc-call method :account noise-account params) + (apply #'noise-rpc-call method params))) + (defun noise-rpc--parse-response (raw) "Parse RAW JSON-RPC response body. Returns the `result` on success. diff --git a/noise-switcher.el b/noise-switcher.el index 04705c2..102b42c 100644 --- a/noise-switcher.el +++ b/noise-switcher.el @@ -9,6 +9,7 @@ ;;; Code: (require 'noise-db) +(require 'noise-new-chat) (defun noise-switcher () "Switch to a Signal conversation via minibuffer completion. @@ -31,8 +32,9 @@ Shows existing conversations and contacts without conversations." (if (gethash "id" item) ;; It's a conversation (has an id) (message "Open conversation: %s (not yet implemented)" (gethash "name" item)) - ;; It's just a contact — create conversation stub - (message "New conversation with: %s (not yet implemented)" choice)) + ;; It's a contact without a conversation — start one + (noise-new-chat-with-contact (gethash "contact_id" item) + (gethash "name" item))) (message "Unknown selection: %s" choice)))))) (defun noise-switcher--collection () @@ -57,6 +59,7 @@ Returns an alist of (display-string . hash-table)." (unless (gethash contact-id conv-contacts) ;; Contact without conversation - add a stub (let ((ht (make-hash-table :test 'equal))) + (puthash "contact_id" contact-id ht) (puthash "name" name ht) (puthash "type" "direct" ht) (puthash "unread_count" 0 ht) diff --git a/noise.el b/noise.el index 3024dab..2c10319 100644 --- a/noise.el +++ b/noise.el @@ -13,6 +13,7 @@ (require 'noise-rpc) (require 'noise-chat-list) +(require 'noise-new-chat) (defgroup noise nil "Signal Messenger client for Emacs." @@ -26,6 +27,37 @@ The default port is 8080, matching signal-cli's `--http` default." :type 'string :group 'noise) +(defcustom noise-account nil + "The Signal account to use, as a phone number in E.164 format. +signal-cli can have multiple accounts registered. When the daemon +serves more than one account, every request must say which account it +is for, so this must be set (e.g. \"+15551234567\"). When nil, the +daemon is assumed to serve a single account (started with `-a`). + +Use `noise-select-account' to pick one interactively from the +accounts registered with signal-cli." + :type '(choice (const :tag "Single-account daemon" nil) + (string :tag "Phone number (E.164)")) + :group 'noise) + +;;;###autoload +(defun noise-select-account () + "Select which signal-cli account Noise should use. +Queries the daemon for registered accounts and sets `noise-account'." + (interactive) + (let* ((accounts (noise-rpc-call "listAccounts")) + (numbers (mapcar (lambda (a) (gethash "number" a)) + (append accounts nil)))) + (cond + ((null numbers) + (user-error "No accounts registered with signal-cli")) + ((null (cdr numbers)) + (setq noise-account (car numbers))) + (t + (setq noise-account + (completing-read "Signal account: " numbers nil t)))) + (message "Using Signal account %s" noise-account))) + ;;;###autoload (defun noise-check-connection () "Check connectivity to the signal-cli JSON-RPC daemon." diff --git a/test/noise-new-chat-test.el b/test/noise-new-chat-test.el new file mode 100644 index 0000000..57b22c7 --- /dev/null +++ b/test/noise-new-chat-test.el @@ -0,0 +1,147 @@ +;;; noise-new-chat-test.el --- Tests for noise-new-chat -*- lexical-binding: t; -*- + +(require 'ert) +(require 'noise-new-chat) + +(defvar noise-new-chat-test--db + (expand-file-name "noise-new-chat-test.db" temporary-file-directory)) + +(defun noise-new-chat-test--setup () + "Set up a fresh test database." + (require 'noise-db) + (let ((noise-db-file noise-new-chat-test--db)) + (when (file-exists-p noise-db-file) + (delete-file noise-db-file)) + (noise-db-init))) + +(defun noise-new-chat-test--teardown () + "Clean up the test database." + (require 'noise-db) + (noise-db--close) + (when (file-exists-p noise-new-chat-test--db) + (delete-file noise-new-chat-test--db))) + +(defmacro noise-new-chat-test--with-db (&rest body) + "Run BODY with a fresh test database bound to `noise-db-file'." + `(let ((noise-db-file noise-new-chat-test--db)) + (unwind-protect + (progn + (noise-new-chat-test--setup) + ,@body) + (noise-new-chat-test--teardown)))) + +(defun noise-new-chat-test--contact (&rest kvs) + "Build a hash table simulating a signal-cli contact from KVS pairs." + (let ((ht (make-hash-table :test 'equal))) + (while kvs + (puthash (car kvs) (cadr kvs) ht) + (setq kvs (cddr kvs))) + ht)) + +(ert-deftest noise-new-chat--contact-id-prefers-number () + "Contact ID should prefer the phone number over the UUID." + (let ((c (noise-new-chat-test--contact "number" "+15551234567" + "uuid" "abc-123"))) + (should (string= "+15551234567" (noise-new-chat--contact-id c))))) + +(ert-deftest noise-new-chat--contact-id-falls-back-to-uuid () + "Contact ID should fall back to UUID when number is null." + (let ((c (noise-new-chat-test--contact "number" :null + "uuid" "abc-123"))) + (should (string= "abc-123" (noise-new-chat--contact-id c))))) + +(ert-deftest noise-new-chat--contact-name-prefers-local-name () + "Display name should prefer the local contact name." + (let ((c (noise-new-chat-test--contact + "number" "+15551234567" + "name" "Alice" + "profile" (noise-new-chat-test--contact "givenName" "Alicia")))) + (should (string= "Alice" (noise-new-chat--contact-name c))))) + +(ert-deftest noise-new-chat--contact-name-uses-profile () + "Display name should fall back to the profile name." + (let ((c (noise-new-chat-test--contact + "number" "+15551234567" + "name" "" + "profile" (noise-new-chat-test--contact "givenName" "Alicia" + "familyName" "Jones")))) + (should (string= "Alicia Jones" (noise-new-chat--contact-name c))))) + +(ert-deftest noise-new-chat--contact-name-falls-back-to-number () + "Display name should fall back to the phone number when nothing else." + (let ((c (noise-new-chat-test--contact "number" "+15551234567" + "name" "" + "profile" :null))) + (should (string= "+15551234567" (noise-new-chat--contact-name c))))) + +(ert-deftest noise-new-chat--candidate-includes-number () + "Completion candidates should include name and number." + (let ((ht (make-hash-table :test 'equal))) + (puthash "id" "+15551234567" ht) + (puthash "name" "Alice" ht) + (should (string= "Alice (+15551234567)" (noise-new-chat--candidate ht))))) + +(ert-deftest noise-new-chat--candidate-number-only () + "Candidates with no name should be just the number." + (let ((ht (make-hash-table :test 'equal))) + (puthash "id" "+15551234567" ht) + (puthash "name" "" ht) + (should (string= "+15551234567" (noise-new-chat--candidate ht))))) + +(ert-deftest noise-new-chat-sync-contacts-stores-contacts () + "Syncing should upsert contacts returned by signal-cli into the DB." + (noise-new-chat-test--with-db + (cl-letf (((symbol-function 'noise-rpc-call-for-account) + (lambda (method &rest _) + (should (string= "listContacts" method)) + (vector + (noise-new-chat-test--contact "number" "+1" "name" "Sarah") + (noise-new-chat-test--contact "number" "+2" "name" "" + "profile" + (noise-new-chat-test--contact + "givenName" "Samir" + "familyName" "Darwish")) + ;; No number and no uuid — should be skipped + (noise-new-chat-test--contact "name" "Ghost"))))) + (should (= 2 (noise-new-chat-sync-contacts))) + (should (string= "Sarah" (gethash "name" (noise-db-get-contact "+1")))) + (should (string= "Samir Darwish" + (gethash "name" (noise-db-get-contact "+2"))))))) + +(ert-deftest noise-new-chat-sync-contacts-uses-account () + "Syncing should go through the account-aware RPC helper." + (noise-new-chat-test--with-db + (defvar noise-account) + (let ((noise-account "+19998887777") + (seen-account nil)) + (cl-letf (((symbol-function 'noise-rpc-call) + (lambda (_method &rest params) + (setq seen-account (plist-get params :account)) + (vector)))) + (noise-new-chat-sync-contacts) + (should (string= "+19998887777" seen-account)))))) + +(ert-deftest noise-new-chat-with-contact-creates-conversation () + "Starting a chat with a contact should create a direct conversation." + (noise-new-chat-test--with-db + (noise-db-upsert-contact "+1" :name "Sarah") + (noise-new-chat-with-contact "+1" "Sarah") + (let ((conv (noise-db-get-conversation "+1"))) + (should conv) + (should (string= "direct" (gethash "type" conv))) + (should (string= "Sarah" (gethash "name" conv))) + (should (equal '("+1") + (mapcar (lambda (m) (gethash "contact_id" m)) + (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." + (noise-new-chat-test--with-db + (noise-db-upsert-conversation "+1" :type "direct" :name "Sarah" + :members '("+1") + :unread-count 3 + :last-message-preview "hello") + (noise-new-chat-with-contact "+1" "Sarah") + (let ((conv (noise-db-get-conversation "+1"))) + (should (= 3 (gethash "unread_count" conv))) + (should (string= "hello" (gethash "last_message_preview" conv))))))