feat: add chat list buffer with conversation rendering and navigation

This commit is contained in:
2026-06-09 19:35:06 -04:00
parent a88d3af8a2
commit b3cd5a7e9e
3 changed files with 196 additions and 1 deletions
+142
View File
@@ -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
+2 -1
View File
@@ -12,6 +12,7 @@
;;; Code: ;;; Code:
(require 'noise-rpc) (require 'noise-rpc)
(require 'noise-chat-list)
(defgroup noise nil (defgroup noise nil
"Signal Messenger client for Emacs." "Signal Messenger client for Emacs."
@@ -41,7 +42,7 @@ Format: http://HOST:PORT"
:lighter " Noise" :lighter " Noise"
:group 'noise :group 'noise
(if noise-mode (if noise-mode
(message "Noise mode enabled — signal-cli at %s" noise-signal-cli-address) (noise-chat-list)
(message "Noise mode disabled"))) (message "Noise mode disabled")))
(provide 'noise) (provide 'noise)
+52
View File
@@ -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))))