More features

This commit is contained in:
2026-06-10 12:43:24 -04:00
parent 02d78765bc
commit dcead245a7
12 changed files with 954 additions and 21 deletions
+249
View File
@@ -0,0 +1,249 @@
;;; noise-conversation.el --- Conversation buffer for Noise -*- lexical-binding: t; -*-
;;; Commentary:
;; Provides the *signal:NAME* conversation buffer: full message history
;; with day separators and delivery receipts, plus an inline compose
;; prompt at the bottom. RET sends, M-RET inserts a newline.
;;; Code:
(require 'noise-rpc)
(require 'noise-db)
(require 'cl-lib)
(declare-function noise-chat-list--render "noise-chat-list")
(defconst noise-conversation--width 71
"Column width used for day separators and receipt alignment.")
(defvar-local noise-conversation--id nil
"ID of the conversation shown in this buffer.")
(defvar-local noise-conversation--input-marker nil
"Marker at the start of the compose input area.")
(defvar-local noise-conversation--typing nil
"Display name of the contact currently typing, or nil.")
(defvar-local noise-conversation--typing-timer nil
"Timer that clears a stale typing indicator.")
(defvar noise-conversation-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'noise-conversation-send)
(define-key map (kbd "M-RET") #'noise-conversation-newline)
(define-key map (kbd "C-c C-g") #'noise-conversation-refresh)
map)
"Keymap for `noise-conversation-mode'.")
(define-derived-mode noise-conversation-mode text-mode "Signal Chat"
"Major mode for a Noise Signal conversation buffer.
\\{noise-conversation-mode-map}"
:group 'noise)
;;;###autoload
(defun noise-conversation-open (conversation-id)
"Open (or switch to) the buffer for CONVERSATION-ID."
(noise-db-init)
(let ((conv (noise-db-get-conversation conversation-id)))
(unless conv
(user-error "No such conversation: %s" conversation-id))
(let* ((name (noise-conversation--display-name conv))
(buf (get-buffer-create (format "*signal:%s*" name))))
(switch-to-buffer buf)
(unless (derived-mode-p 'noise-conversation-mode)
(noise-conversation-mode))
(setq noise-conversation--id conversation-id)
(noise-db-mark-conversation-read conversation-id)
(noise-conversation--render)
(noise-conversation--refresh-chat-list)
buf)))
(defun noise-conversation--display-name (conv)
"Pick the buffer display name for CONV, falling back to its ID."
(let ((name (gethash "name" conv)))
(if (and name (not (string-empty-p name))) name (gethash "id" conv))))
(defun noise-conversation--buffer-for (conversation-id)
"Return the live buffer showing CONVERSATION-ID, or nil."
(cl-find-if (lambda (buf)
(with-current-buffer buf
(and (derived-mode-p 'noise-conversation-mode)
(equal noise-conversation--id conversation-id))))
(buffer-list)))
(defun noise-conversation-refresh-for (conversation-id)
"Re-render the buffer for CONVERSATION-ID if one is open.
Returns the buffer, or nil if none."
(when-let ((buf (noise-conversation--buffer-for conversation-id)))
(with-current-buffer buf
(noise-conversation--render))
buf))
(defun noise-conversation-refresh ()
"Re-render the current conversation from the database."
(interactive)
(noise-conversation--render))
;;; Rendering
(defun noise-conversation--render ()
"Render message history and the compose prompt.
Preserves any input already typed at the prompt."
(let ((input (noise-conversation--current-input))
(inhibit-read-only t)
(messages (noise-db-get-messages noise-conversation--id))
(last-day nil))
(erase-buffer)
(dolist (msg messages)
(let ((day (format-time-string
"%a, %b %-e" (gethash "timestamp" msg))))
(unless (equal day last-day)
(insert (noise-conversation--day-separator day) "\n")
(setq last-day day))
(insert (noise-conversation--format-message msg) "\n")))
(insert " \n"
(propertize (make-string noise-conversation--width ?─)
'face 'shadow)
"\n"
(propertize "> " 'face 'bold))
(add-text-properties (point-min) (point)
'(read-only t
front-sticky (read-only)
rear-nonsticky (read-only)))
(setq noise-conversation--input-marker (point-marker))
(when input (insert input))
(goto-char (point-max))
(noise-conversation--update-modeline)
(set-buffer-modified-p nil)))
(defun noise-conversation--current-input ()
"Return the text typed at the compose prompt, or nil before first render."
(when (and noise-conversation--input-marker
(marker-position noise-conversation--input-marker))
(buffer-substring-no-properties noise-conversation--input-marker
(point-max))))
(defun noise-conversation--day-separator (day)
"Format DAY as a centered separator line like ──── Tue, Jun 9 ────."
(let* ((label (concat " " day " "))
(total (max 2 (- noise-conversation--width (length label))))
(left (/ total 2)))
(propertize (concat (make-string left ?─) label
(make-string (- total left) ?─))
'face 'shadow)))
(defun noise-conversation--format-message (msg)
"Format MSG (a hash table from noise-db) as a [HH:MM] sender> body line."
(let* ((source (or (gethash "source" msg) ""))
(me (string= source "me"))
(body (or (gethash "body" msg) ""))
(receipt (or (gethash "receipt_state" msg) ""))
(time-str (format-time-string "[%H:%M]" (gethash "timestamp" msg)))
(name (cond (me "me")
((string-empty-p source) "?")
(t source))))
(when (eq (gethash "has_attachment" msg) 1)
(let ((file (file-name-nondirectory
(or (gethash "attachment_path" msg) ""))))
(setq body (concat body (if (string-empty-p body) "" " ")
(propertize
(format "[attachment: %s — RET to view]" file)
'face 'link)))))
(let ((line (concat (propertize time-str 'face 'shadow) " "
(propertize name 'face 'bold)
(propertize ">" 'face 'shadow) " "
body)))
(if (and me (not (string-empty-p receipt)))
(let* ((check (if (string= receipt "sent") "" "✓✓"))
(pad (max 1 (- noise-conversation--width
(string-width line)
(string-width check)))))
(concat line (make-string pad ?\s)
(propertize check 'face 'success)))
line))))
(defun noise-conversation--update-modeline ()
"Update the mode line with the E2E indicator and typing status."
(setq mode-line-format
(format " %s %s %s%s ─ U:%%- ─ Bot ───"
(propertize "⌁ E2E" 'face 'success)
(propertize (buffer-name) 'face 'bold)
(if noise-conversation--typing
(format "%s is typing… " noise-conversation--typing)
"")
"(Signal Chat)"))
(force-mode-line-update))
;;; Composing and sending
(defun noise-conversation-newline ()
"Insert a newline at the compose prompt without sending."
(interactive)
(insert "\n"))
(defun noise-conversation-send ()
"Send the text composed at the prompt via signal-cli."
(interactive)
(let ((input (string-trim (or (noise-conversation--current-input) ""))))
(if (string-empty-p input)
(message "Nothing to send")
(let* ((conv (noise-db-get-conversation noise-conversation--id))
(group (string= (gethash "type" conv) "group"))
(result (if group
(noise-rpc-call-for-account
"send" :groupId noise-conversation--id :message input)
(noise-rpc-call-for-account
"send" :recipient (vector noise-conversation--id)
:message input)))
(ts-ms (and (hash-table-p result) (gethash "timestamp" result)))
(ts (if ts-ms (/ ts-ms 1000.0) (float-time))))
(noise-db-insert-message noise-conversation--id
:timestamp ts
:source "me"
:body input
:receipt-state "sent")
(noise-conversation--clear-input)
(noise-conversation--render)
(noise-conversation--refresh-chat-list)))))
(defun noise-conversation--clear-input ()
"Delete the text typed at the compose prompt."
(when (and noise-conversation--input-marker
(marker-position noise-conversation--input-marker))
(delete-region noise-conversation--input-marker (point-max))))
;;; Typing indicator
(defun noise-conversation-set-typing (conversation-id name action)
"Show or clear the typing indicator for CONVERSATION-ID.
NAME is the contact typing; ACTION is signal-cli's STARTED or STOPPED."
(when-let ((buf (noise-conversation--buffer-for conversation-id)))
(with-current-buffer buf
(when noise-conversation--typing-timer
(cancel-timer noise-conversation--typing-timer)
(setq noise-conversation--typing-timer nil))
(if (equal action "STARTED")
(setq noise-conversation--typing name
noise-conversation--typing-timer
(run-with-timer 8 nil #'noise-conversation--clear-typing buf))
(setq noise-conversation--typing nil))
(noise-conversation--update-modeline))))
(defun noise-conversation--clear-typing (buf)
"Clear the typing indicator in BUF after a timeout."
(when (buffer-live-p buf)
(with-current-buffer buf
(setq noise-conversation--typing nil
noise-conversation--typing-timer nil)
(noise-conversation--update-modeline))))
(defun noise-conversation--refresh-chat-list ()
"Re-render the *signal* chat list buffer if it exists."
(when-let ((buf (get-buffer "*signal*")))
(with-current-buffer buf
(noise-chat-list--render))))
(provide 'noise-conversation)
;;; noise-conversation.el ends here