250 lines
10 KiB
EmacsLisp
250 lines
10 KiB
EmacsLisp
;;; 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
|