Files
noise/noise-new-chat.el
T
2026-06-10 12:43:24 -04:00

120 lines
4.6 KiB
EmacsLisp

;;; 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 'noise-conversation)
(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)))
(noise-conversation-open 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