From b3cd5a7e9ec5661d296cef8759cbfbcec5effc87 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 9 Jun 2026 19:35:06 -0400 Subject: [PATCH] feat: add chat list buffer with conversation rendering and navigation --- noise-chat-list.el | 142 +++++++++++++++++++++++++++++++++++ noise.el | 3 +- test/noise-chat-list-test.el | 52 +++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 noise-chat-list.el create mode 100644 test/noise-chat-list-test.el diff --git a/noise-chat-list.el b/noise-chat-list.el new file mode 100644 index 0000000..b9648d4 --- /dev/null +++ b/noise-chat-list.el @@ -0,0 +1,142 @@ +;;; noise-chat-list.el --- Chat list buffer for Noise -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Provides the *signal* buffer — an ibuffer-style list of all conversations +;; with unread counts, last message previews, and timestamps. + +;;; Code: + +(require 'noise-db) +(require 'cl-lib) + +(defvar noise-chat-list-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "n") #'next-line) + (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 "g") #'noise-chat-list-refresh) + (define-key map (kbd "/") #'noise-chat-list-filter) + (define-key map (kbd "q") #'quit-window) + map) + "Keymap for `noise-chat-list-mode'.") + +(define-derived-mode noise-chat-list-mode special-mode "Signal Chats" + "Major mode for the Noise Signal chat list. +\\{noise-chat-list-mode-map}" + :group 'noise + (setq buffer-read-only t) + (setq-local header-line-format + (concat " U " + (format "%-20s" "Name") + (format "%-40s" "Last message") + "When"))) + +;;;###autoload +(defun noise-chat-list () + "Display the Noise Signal chat list." + (interactive) + (noise-db-init) + (switch-to-buffer "*signal*") + (noise-chat-list-mode) + (noise-chat-list--render)) + +(defun noise-chat-list--render () + "Render the conversation list in the *signal* buffer." + (let ((inhibit-read-only t) + (conversations (noise-db-get-all-conversations))) + (erase-buffer) + (if (null conversations) + (insert "No conversations. Sync from signal-cli with `g`.\n") + (dolist (conv conversations) + (insert (noise-chat-list--format-row conv) "\n"))) + (noise-chat-list--update-modeline conversations) + (goto-char (point-min)) + (set-buffer-modified-p nil))) + +(defun noise-chat-list--update-modeline (conversations) + "Update the mode line with chat statistics." + (let* ((total (length conversations)) + (unread-total (cl-reduce #'+ + (mapcar (lambda (c) (or (gethash "unread_count" c) 0)) + conversations) + :initial-value 0))) + (setq mode-line-format + (format " ⌁ %s %d chats %s %s (%s) ─ U:%%- ─ Top ───" + (propertize "*signal*" 'face 'bold) + total + (if (> unread-total 0) + (format "· %d unread" unread-total) + "") + "" + "Signal Chats")))) + +(defun noise-chat-list--format-time (timestamp) + "Format TIMESTAMP (unix seconds) as a human-readable relative time string. +Today: HH:MM, this week: Ddd, older: Mon DD." + (let* ((now (current-time)) + (msg-time (seconds-to-time timestamp)) + (diff (float-time (time-subtract now msg-time))) + (one-day 86400) + (one-week (* 7 one-day))) + (cond + ((< diff one-day) + (format-time-string "%H:%M" msg-time)) + ((< diff one-week) + (format-time-string "%a" msg-time)) + (t + (format-time-string "%b %e" msg-time))))) + +(defun noise-chat-list--format-row (conv) + "Format a single conversation row for display. +CONV is a hash table with string keys from noise-db." + (let* ((unread (or (gethash "unread_count" conv) 0)) + (name (or (gethash "name" conv) "Unknown")) + (preview (or (gethash "last_message_preview" conv) "")) + (sender (or (gethash "last_message_sender" conv) "")) + (timestamp (or (gethash "last_message_time" conv) 0)) + (fringe (if (> unread 0) "●" " ")) + (unread-str (if (> unread 0) (format "%3d" unread) " ")) + (name-str (truncate-string-to-width name 18 nil nil "…")) + (preview-prefix + (cond + ((string= sender "") preview) + ((string= sender "me") (concat "me: " preview)) + (t (concat sender ": " preview)))) + (preview-str (truncate-string-to-width preview-prefix 36 nil nil "…")) + (time-str (noise-chat-list--format-time timestamp))) + (concat " " fringe " " unread-str " " + (propertize (format "%-18s" name-str) 'face 'bold) " " + preview-str " " + (propertize time-str 'face 'shadow)))) + +(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))) + (if conv + (message "Open conversation: %s (not yet implemented)" + (gethash "name" 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")) + +(defun noise-chat-list-refresh () + "Refresh the chat list (re-render from database)." + (interactive) + (noise-chat-list--render) + (message "Chat list refreshed.")) + +(defun noise-chat-list-filter () + "Filter the chat list with a search query." + (interactive) + (message "Filter: not yet implemented")) + +(provide 'noise-chat-list) +;;; noise-chat-list.el ends here diff --git a/noise.el b/noise.el index ee4eb3d..c66e0eb 100644 --- a/noise.el +++ b/noise.el @@ -12,6 +12,7 @@ ;;; Code: (require 'noise-rpc) +(require 'noise-chat-list) (defgroup noise nil "Signal Messenger client for Emacs." @@ -41,7 +42,7 @@ Format: http://HOST:PORT" :lighter " Noise" :group 'noise (if noise-mode - (message "Noise mode enabled — signal-cli at %s" noise-signal-cli-address) + (noise-chat-list) (message "Noise mode disabled"))) (provide 'noise) diff --git a/test/noise-chat-list-test.el b/test/noise-chat-list-test.el new file mode 100644 index 0000000..17c87e4 --- /dev/null +++ b/test/noise-chat-list-test.el @@ -0,0 +1,52 @@ +;;; noise-chat-list-test.el --- Tests for noise-chat-list -*- lexical-binding: t; -*- + +(require 'ert) +(require 'noise-chat-list) + +(defun noise-chat-list-test--make-row (id name unread preview sender time) + "Create a hash table representing a conversation row for testing." + (let ((ht (make-hash-table :test 'equal))) + (puthash "id" id ht) + (puthash "name" name ht) + (puthash "type" "direct" ht) + (puthash "unread_count" unread ht) + (puthash "last_message_preview" preview ht) + (puthash "last_message_sender" sender ht) + (puthash "last_message_time" time ht) + ht)) + +(ert-deftest noise-chat-list--format-time-today () + "Test relative time formatting for today shows HH:MM." + (let ((result (noise-chat-list--format-time (float-time)))) + (should (string-match "^[0-9][0-9]:[0-9][0-9]$" result)))) + +(ert-deftest noise-chat-list--format-time-yesterday () + "Test relative time formatting for recent days." + (let* ((one-day 86400) + (yesterday (float-time (time-subtract (current-time) (seconds-to-time one-day)))) + (result (noise-chat-list--format-time yesterday))) + (should (string-match "^[A-Z][a-z][a-z]$" result)))) + +(ert-deftest noise-chat-list--format-time-older () + "Test relative time formatting for older dates shows Mon DD." + (let* ((two-weeks-ago (float-time (time-subtract (current-time) (seconds-to-time (* 14 86400)))))) + (let ((result (noise-chat-list--format-time two-weeks-ago))) + (should (string-match "^[A-Z][a-z][a-z] [0-9]+$" result))))) + +(ert-deftest noise-chat-list--format-row-with-unread () + "Test formatting a conversation row with unread messages." + (let ((row (noise-chat-list--format-row + (noise-chat-list-test--make-row "conv-1" "Sarah" 3 + "are you taking the girls?" + "Sarah" (float-time))))) + (should (string-match "●" row)) + (should (string-match "Sarah" row)) + (should (string-match "are you taking" row)))) + +(ert-deftest noise-chat-list--format-row-no-unread () + "Test formatting a conversation row without unread messages." + (let ((row (noise-chat-list--format-row + (noise-chat-list-test--make-row "conv-2" "Mark Okafor" 0 + "merged" "me" (float-time))))) + (should (string-match "Mark Okafor" row)) + (should-not (string-match "●" row))))