;;; 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'.") (defconst noise-conversation--mode-header (concat (propertize "RET" 'face 'help-key-binding) " send " (propertize "M-RET" 'face 'help-key-binding) " newline " (propertize "C-c C-g" 'face 'help-key-binding) " refresh") "Header line showing key hints 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 (setq-local header-line-format noise-conversation--mode-header) (when (bound-and-true-p evil-mode) (let ((m (make-sparse-keymap))) (define-key m (kbd "RET") #'noise-conversation-send) (setq-local evil-normal-state-local-map m)))) ;;;###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 'link)) (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 "[photo: %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 Fill)")) (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)))) ;;; Auto-refresh when conversation buffers become visible (defun noise-conversation--on-buffer-visible (_frame) "Refresh any stale `*signal:*' conversation buffer that just became visible." (dolist (window (window-list nil 'never (selected-frame))) (when-let ((buf (window-buffer window))) (when (and (string-prefix-p "*signal:" (buffer-name buf)) (not (string= "*signal*" (buffer-name buf)))) (with-current-buffer buf (when (derived-mode-p 'noise-conversation-mode) (noise-conversation--render))))))) (add-hook 'window-buffer-change-functions #'noise-conversation--on-buffer-visible) (provide 'noise-conversation) ;;; noise-conversation.el ends here