feat: add minibuffer conversation switcher with fuzzy completion

This commit is contained in:
2026-06-09 19:35:50 -04:00
parent b3cd5a7e9e
commit 44409a4793
2 changed files with 192 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
;;; noise-switcher.el --- Minibuffer conversation switcher for Noise -*- lexical-binding: t; -*-
;;; Commentary:
;; Provides minibuffer completion for switching conversations.
;; Collects conversations and contacts from the database, presents them
;; with fuzzy matching in a vertico-style minibuffer completion.
;;; Code:
(require 'noise-db)
(defun noise-switcher ()
"Switch to a Signal conversation via minibuffer completion.
Shows existing conversations and contacts without conversations."
(interactive)
(noise-db-init)
(let* ((collection (noise-switcher--collection))
(choice (completing-read
"Switch to chat: "
(lambda (string pred action)
(if (eq action 'metadata)
'(metadata
(display-sort-function . identity)
(annotation-function . noise-switcher--annotate))
(complete-with-action action collection string pred)))
nil t)))
(when choice
(let ((item (cdr (assoc choice collection))))
(if item
(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))
(message "Unknown selection: %s" choice))))))
(defun noise-switcher--collection ()
"Build the completion collection.
Returns an alist of (display-string . hash-table)."
(let ((conversations (noise-db-get-all-conversations))
(contacts (noise-db-get-all-contacts))
(collection nil))
;; Add conversations
(dolist (conv conversations)
(let ((name (gethash "name" conv)))
(push (cons name conv) collection)))
;; Add contacts without conversations
(let ((conv-contacts (make-hash-table :test 'equal)))
(dolist (conv conversations)
(let ((members (noise-db-get-conversation-members (gethash "id" conv))))
(dolist (m members)
(puthash (gethash "contact_id" m) t conv-contacts))))
(dolist (contact contacts)
(let ((contact-id (gethash "id" contact))
(name (gethash "name" contact)))
(unless (gethash contact-id conv-contacts)
;; Contact without conversation - add a stub
(let ((ht (make-hash-table :test 'equal)))
(puthash "name" name ht)
(puthash "type" "direct" ht)
(puthash "unread_count" 0 ht)
(puthash "last_message_time" 0 ht)
(push (cons name ht) collection))))))
collection))
(defun noise-switcher--annotate (candidate)
"Annotate CANDIDATE with type, unread, and time info for completing-read."
(let* ((collection (noise-switcher--collection))
(item (cdr (assoc candidate collection))))
(when item
(let* ((type (gethash "type" item "direct"))
(unread (gethash "unread_count" item 0))
(time (gethash "last_message_time" item 0))
(time-str (noise-switcher--format-time-annot time))
(annotation (format "%s" type)))
(when (> unread 0)
(setq annotation (concat annotation (format " · %d unread" unread))))
(when (> time 0)
(setq annotation (concat annotation (format " · %s" time-str))))
(format " %s" annotation)))))
(defun noise-switcher--format-time-annot (timestamp)
"Format TIMESTAMP for annotation display."
(if (<= timestamp 0)
""
(let* ((msg-time (seconds-to-time timestamp))
(diff-days (/ (float-time (time-subtract (current-time) msg-time)) 86400)))
(cond
((< diff-days 1) (format-time-string "%H:%M" msg-time))
((< diff-days 7) (format-time-string "%a" msg-time))
(t (format-time-string "%b %e" msg-time))))))
(defun noise-switcher--format-candidate (name info)
"Format a switcher candidate for display."
(let* ((type (or (cdr (assoc "type" info)) "direct"))
(unread (or (cdr (assoc "unread_count" info)) 0))
(time (or (cdr (assoc "last_message_time" info)) 0))
(time-str (if (> time 0) (noise-switcher--format-time-annot time) ""))
(annotation (concat type (if (> unread 0) (format " · %d unread" unread) "")
(if (> time 0) (format " · %s" time-str) ""))))
(format "%-25s %s" name annotation)))
(defun noise-switcher--filter (query candidates)
"Filter CANDIDATES by QUERY using substring matching."
(let ((q (downcase query)))
(seq-filter (lambda (c) (string-match-p (regexp-quote q) (downcase c))) candidates)))
(provide 'noise-switcher)
;;; noise-switcher.el ends here
+82
View File
@@ -0,0 +1,82 @@
;;; noise-switcher-test.el --- Tests for noise-switcher -*- lexical-binding: t; -*-
(require 'ert)
(require 'noise-switcher)
(defun noise-switcher-test--setup ()
"Set up test data in the database."
(require 'noise-db)
(let ((noise-db-file (expand-file-name "noise-switcher-test.db" temporary-file-directory)))
(when (file-exists-p noise-db-file)
(delete-file noise-db-file))
(noise-db-init)
;; Insert test contacts
(noise-db-upsert-contact "+1" :name "Sarah")
(noise-db-upsert-contact "+2" :name "Samir Darwish")
(noise-db-upsert-contact "+3" :name "Bob Smith")
;; Insert a conversation for Sarah
(noise-db-upsert-conversation "conv-sarah" :type "direct" :name "Sarah"
:members '("+1" "+2")
:unread-count 1
:last-message-time (float-time))))
(defun noise-switcher-test--teardown ()
"Clean up test database."
(require 'noise-db)
(noise-db--close)
(let ((noise-db-file (expand-file-name "noise-switcher-test.db" temporary-file-directory)))
(when (file-exists-p noise-db-file)
(delete-file noise-db-file))))
(ert-deftest noise-switcher--collection-includes-conversations ()
"Test that the switcher collection includes existing conversations."
(unwind-protect
(progn
(noise-switcher-test--setup)
(let ((coll (noise-switcher--collection)))
(should coll)
;; Should include the conversation for Sarah
(should (cl-find "Sarah" coll
:test #'string=
:key (lambda (c) (car c))))))
(noise-switcher-test--teardown)))
(ert-deftest noise-switcher--collection-includes-contacts ()
"Test that contacts without conversations are included."
(unwind-protect
(progn
(noise-switcher-test--setup)
(let ((coll (noise-switcher--collection)))
;; Should include Bob Smith (contact without conversation)
(should (cl-find "Bob Smith" coll
:test #'string=
:key (lambda (c) (car c))))))
(noise-switcher-test--teardown)))
(ert-deftest noise-switcher--filter-matches-substring ()
"Test that the filter matches on substring."
(let ((result (noise-switcher--filter "Sa" '("Sarah" "Samir Darwish" "Bob Smith"))))
(should (= 2 (length result)))
(should (member "Sarah" result))
(should (member "Samir Darwish" result))))
(ert-deftest noise-switcher--format-candidate-with-unread ()
"Test candidate formatting with unread count."
(let* ((candidate `(("name" . "Sarah")
("type" . "direct")
("unread_count" . 3)
("last_message_time" . ,(float-time))))
(result (noise-switcher--format-candidate "Sarah" candidate)))
(should (string-match "Sarah" result))
(should (string-match "direct" result))
(should (string-match "3" result))))
(ert-deftest noise-switcher--format-candidate-no-unread ()
"Test candidate formatting without unread count."
(let* ((candidate `(("name" . "Bob Smith")
("type" . "direct")
("unread_count" . 0)
("last_message_time" . ,(float-time))))
(result (noise-switcher--format-candidate "Bob Smith" candidate)))
(should (string-match "Bob Smith" result))
(should (string-match "direct" result))))