feat: add chat list buffer with conversation rendering and navigation
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))))
|
||||||
Reference in New Issue
Block a user