From 44409a4793c0753f6ca4d1930363cd97e36ab667 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 9 Jun 2026 19:35:50 -0400 Subject: [PATCH] feat: add minibuffer conversation switcher with fuzzy completion --- noise-switcher.el | 110 ++++++++++++++++++++++++++++++++++++ test/noise-switcher-test.el | 82 +++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 noise-switcher.el create mode 100644 test/noise-switcher-test.el diff --git a/noise-switcher.el b/noise-switcher.el new file mode 100644 index 0000000..04705c2 --- /dev/null +++ b/noise-switcher.el @@ -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 diff --git a/test/noise-switcher-test.el b/test/noise-switcher-test.el new file mode 100644 index 0000000..6bee957 --- /dev/null +++ b/test/noise-switcher-test.el @@ -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))))