Files
andros cb89039bb7 Fix reactions stacking and splitting multi-line anchor messages
Anchor reaction lines to the end of the request-id property run instead
of forward-line 1, so multi-line messages are not split. Bound the
reaction-line scan by the reaction property rather than the prompt
marker, so reactions to the last message merge instead of stacking
duplicate lines.
2026-06-09 07:57:53 +02:00

2196 lines
85 KiB
EmacsLisp

;;; meshmonitor-chat.el --- Chat client for MeshMonitor (Meshtastic) -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Andros Fenollosa
;; Author: Andros Fenollosa <hi@andros.dev>
;; Maintainer: Andros Fenollosa <hi@andros.dev>
;; Version: 1.0.0
;; Package-Requires: ((emacs "28.1"))
;; Keywords: comm
;; URL: https://git.andros.dev/andros/meshmonitor-chat.el
;; SPDX-License-Identifier: GPL-3.0-or-later
;; This file is NOT part of GNU Emacs.
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation, either version 3 of the
;; License, or (at your option) any later version.
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; Commentary:
;; Emacs chat client for MeshMonitor, a web-based Meshtastic network
;; monitor. Provides channel and direct message chat buffers.
;;
;; Configure `meshmonitor-chat-host', `meshmonitor-chat-port' and
;; `meshmonitor-chat-token' in your init file. Then:
;;
;; M-x meshmonitor-chat - welcome screen
;; M-x meshmonitor-chat-channels - list channels
;; M-x meshmonitor-chat-nodes - list nodes by hops
;; M-x meshmonitor-chat-unread - nodes with unread DMs
;; M-x meshmonitor-chat-direct-messages - list DM conversations
;;; Code:
(require 'json)
(require 'url)
(require 'url-http)
(require 'cl-lib)
(require 'notifications)
(require 'ring)
(require 'seq)
;;;; Customization
(defgroup meshmonitor-chat nil
"Chat client for MeshMonitor (Meshtastic)."
:group 'communication
:prefix "meshmonitor-chat-")
(defcustom meshmonitor-chat-host ""
"MeshMonitor server hostname or IP address."
:type 'string)
(defcustom meshmonitor-chat-port 3000
"MeshMonitor server port."
:type 'integer)
(defcustom meshmonitor-chat-use-tls nil
"Non-nil means use HTTPS instead of HTTP."
:type 'boolean)
(defcustom meshmonitor-chat-token nil
"Bearer token for MeshMonitor API.
When set, username/password login is skipped."
:type '(choice (const nil) string))
(defcustom meshmonitor-chat-username "admin"
"Username for MeshMonitor authentication."
:type 'string)
(defcustom meshmonitor-chat-password ""
"Password for MeshMonitor authentication."
:type 'string)
(defcustom meshmonitor-chat-login-endpoint "/auth/login"
"API endpoint path for login."
:type 'string)
(defcustom meshmonitor-chat-poll-interval 10
"Seconds between polling for new messages."
:type 'integer)
(defcustom meshmonitor-chat-notify t
"Non-nil means show desktop notifications for new messages."
:type 'boolean)
(defcustom meshmonitor-chat-message-limit 50
"Number of messages to fetch per request."
:type 'integer)
(defcustom meshmonitor-chat-timestamp-format "%H:%M"
"Format string for message timestamps."
:type 'string)
;;;; Faces
(defface meshmonitor-chat-timestamp-face
'((t :foreground "gray50"))
"Face for message timestamps.")
(defface meshmonitor-chat-nick-self-face
'((t :foreground "sea green" :weight bold))
"Face for own nick in messages.")
(defface meshmonitor-chat-nick-other-face
'((t :foreground "dodger blue" :weight bold))
"Face for other nicks in messages.")
(defface meshmonitor-chat-prompt-face
'((t :foreground "cyan" :weight bold))
"Face for the input prompt.")
(defface meshmonitor-chat-system-face
'((t :foreground "gray60" :slant italic))
"Face for system messages.")
(defface meshmonitor-chat-delivery-pending-face
'((t :foreground "gray50"))
"Face for pending delivery icon.")
(defface meshmonitor-chat-delivery-confirmed-face
'((t :foreground "green"))
"Face for confirmed delivery icon.")
(defface meshmonitor-chat-delivery-failed-face
'((t :foreground "red"))
"Face for failed delivery icon.")
;;;; Internal state
(defvar meshmonitor-chat--base-url nil
"Base URL for the MeshMonitor instance.")
(defvar meshmonitor-chat--auth-token nil
"Bearer token for API requests.")
(defvar meshmonitor-chat--csrf-token nil
"CSRF token for session-authenticated requests.")
(defvar meshmonitor-chat--connected nil
"Non-nil when connected to MeshMonitor.")
(defvar meshmonitor-chat--nodes (make-hash-table :test 'equal)
"Hash table mapping node IDs and nums to node alists.")
(defvar meshmonitor-chat--my-node-id nil
"Node ID of the connected MeshMonitor node.")
(defvar meshmonitor-chat--my-node-num nil
"Node number of the connected MeshMonitor node.")
(defvar meshmonitor-chat--channels nil
"Cached list of channel alists from the API.")
(defvar meshmonitor-chat--chat-buffers nil
"Alist of ((TYPE . TARGET) . BUFFER) for open chat buffers.")
(defvar meshmonitor-chat--read-timestamps (make-hash-table :test 'equal)
"Hash table mapping node IDs to last-read Unix timestamp.")
(defvar meshmonitor-chat--poll-timer nil
"Timer for periodic message polling.")
(defvar meshmonitor-chat--last-poll-time nil
"Timestamp of the last successful poll, for gap detection.")
;;;; Buffer-local variables
(defvar-local meshmonitor-chat--target nil
"Target for this buffer: channel number or node ID string.")
(defvar-local meshmonitor-chat--target-type nil
"Type of target: symbol `channel' or `dm'.")
(defvar-local meshmonitor-chat--prompt-start nil
"Marker at the beginning of the prompt.")
(defvar-local meshmonitor-chat--prompt-end nil
"Marker at the end of the prompt.")
(defvar-local meshmonitor-chat--last-timestamp nil
"Unix timestamp of the last rendered message.")
(defvar-local meshmonitor-chat--seen-ids nil
"Hash table of rendered message IDs for deduplication.")
(defvar-local meshmonitor-chat--input-ring nil
"Ring holding previous input strings.")
(defvar-local meshmonitor-chat--input-ring-index 0
"Current index in the input ring.")
(defvar-local meshmonitor-chat--pending-deliveries nil
"Alist of (REQUEST-ID . MARKER) for pending delivery icons.")
(defvar-local meshmonitor-chat--reply-to nil
"Cons of (REQUEST-ID . SENDER-NAME) for the message being replied to.")
(defvar-local meshmonitor-chat--reactions nil
"Hash table mapping requestId to list of (EMOJI . SENDER) pairs.")
;;;; Timestamp helpers
(defun meshmonitor-chat--parse-timestamp (ts)
"Convert TS to a Unix timestamp in seconds.
Handles millisecond timestamps from MeshMonitor API."
(cond
((numberp ts)
(if (> ts 9999999999) (/ ts 1000) ts))
((stringp ts)
(condition-case nil
(truncate (float-time (date-to-time ts)))
(error 0)))
(t 0)))
(defun meshmonitor-chat--format-time (ts)
"Format timestamp TS for display."
(condition-case nil
(format-time-string
meshmonitor-chat-timestamp-format
(seconds-to-time (meshmonitor-chat--parse-timestamp ts)))
(error "??:??")))
;;;; HTTP helpers
(defun meshmonitor-chat--build-url (endpoint)
"Build full URL for API ENDPOINT."
(concat meshmonitor-chat--base-url endpoint))
(defun meshmonitor-chat--request (method endpoint &optional data callback)
"Make an HTTP request with METHOD to ENDPOINT.
DATA is an alist to send as JSON body.
When CALLBACK is non-nil, make an async request and call
CALLBACK with (STATUS-CODE . JSON-BODY) or nil on error.
When CALLBACK is nil, make a synchronous request and return
the same cons cell."
(let ((url-request-method method)
(url-cookie-confirmation nil)
(url-request-extra-headers
(append '(("Content-Type" . "application/json")
("Accept" . "application/json"))
(when meshmonitor-chat--auth-token
`(("Authorization"
. ,(concat "Bearer "
meshmonitor-chat--auth-token))))))
(url-request-data
(when data
(encode-coding-string (json-encode data) 'utf-8)))
(full-url (meshmonitor-chat--build-url endpoint)))
(if callback
(url-retrieve
full-url
(lambda (status cb)
(let ((result nil)
(resp-buf (current-buffer)))
(unwind-protect
(progn
(unless (plist-get status :error)
(setq result
(meshmonitor-chat--parse-response)))
(funcall cb result))
(when (buffer-live-p resp-buf)
(kill-buffer resp-buf)))))
(list callback) t)
(let ((buf (url-retrieve-synchronously full-url t nil 15)))
(when buf
(unwind-protect
(with-current-buffer buf
(meshmonitor-chat--parse-response))
(kill-buffer buf)))))))
(defun meshmonitor-chat--parse-response ()
"Parse HTTP response in current buffer.
Return (STATUS-CODE . JSON-BODY) or (STATUS-CODE . nil)."
(goto-char (point-min))
(let ((status-code 0))
(when (re-search-forward "HTTP/[0-9.]+ \\([0-9]+\\)" nil t)
(setq status-code (string-to-number (match-string 1))))
(goto-char (point-min))
(if (re-search-forward "\r?\n\r?\n" nil t)
(condition-case nil
(progn
;; url.el returns a unibyte buffer; convert to
;; multibyte so json-read decodes UTF-8 correctly.
(set-buffer-multibyte t)
(let ((json-object-type 'alist)
(json-array-type 'list)
(json-key-type 'symbol))
(cons status-code (json-read))))
(error (cons status-code nil)))
(cons status-code nil))))
;;;; Authentication and connection
(defun meshmonitor-chat--login ()
"Login to MeshMonitor using configured credentials.
Return non-nil on success."
(let ((scheme (if meshmonitor-chat-use-tls "https" "http")))
(setq meshmonitor-chat--base-url
(format "%s://%s:%d" scheme
meshmonitor-chat-host
meshmonitor-chat-port)))
(cond
(meshmonitor-chat-token
(setq meshmonitor-chat--auth-token meshmonitor-chat-token)
t)
(t
(let ((result (meshmonitor-chat--request
"POST" meshmonitor-chat-login-endpoint
`((username . ,meshmonitor-chat-username)
(password . ,meshmonitor-chat-password)))))
(when result
(let ((body (cdr result)))
(when body
(let ((token (or (alist-get 'token body)
(alist-get 'accessToken body))))
(when token
(setq meshmonitor-chat--auth-token token)))))
(< (car result) 400))))))
(defun meshmonitor-chat--ensure-connected ()
"Ensure connection to MeshMonitor, auto-connecting if needed."
(unless meshmonitor-chat--connected
(when (string-empty-p meshmonitor-chat-host)
(user-error "Set `meshmonitor-chat-host' in your init file"))
(unless (meshmonitor-chat--login)
(user-error "MeshMonitor: login failed at %s"
meshmonitor-chat--base-url))
(setq meshmonitor-chat--connected t)
;; Fetch status (sync) for our node identity.
(let ((info (meshmonitor-chat--fetch-status-sync)))
(when info
(let ((status (meshmonitor-chat--normalize-status
(cdr info))))
(setq meshmonitor-chat--my-node-id
(alist-get 'node-id status))
(setq meshmonitor-chat--my-node-num
(alist-get 'node-num status)))))
;; Fetch nodes (sync) for name resolution.
(meshmonitor-chat--fetch-nodes-sync)
;; Fetch channels (sync).
(meshmonitor-chat--fetch-channels-sync)
;; Start polling.
(meshmonitor-chat--start-polling)
(message "MeshMonitor: connected to %s" meshmonitor-chat-host))
t)
;;;; Node resolution
(defun meshmonitor-chat--process-nodes (data)
"Process and cache node DATA list."
(when (listp data)
(clrhash meshmonitor-chat--nodes)
(dolist (node data)
(let ((num (alist-get 'nodeNum node))
(id (alist-get 'nodeId node)))
(when num
(puthash (number-to-string num) node
meshmonitor-chat--nodes))
(when id
(puthash id node meshmonitor-chat--nodes))))))
(defun meshmonitor-chat--fetch-nodes-sync ()
"Fetch and cache nodes synchronously."
(let ((result (meshmonitor-chat--request "GET" "/api/v1/nodes")))
(when result
(meshmonitor-chat--process-nodes
(alist-get 'data (cdr result))))))
(defun meshmonitor-chat--fetch-nodes (&optional callback)
"Fetch and cache nodes asynchronously.
Call CALLBACK with no arguments when done."
(meshmonitor-chat--request
"GET" "/api/v1/nodes" nil
(lambda (result)
(when result
(meshmonitor-chat--process-nodes
(alist-get 'data (cdr result))))
(when callback (funcall callback)))))
(defun meshmonitor-chat--node-name (id)
"Return display name for node ID.
Falls back to ID itself when no name is cached."
(let* ((key (if (numberp id) (number-to-string id) (or id "")))
(node (gethash key meshmonitor-chat--nodes)))
(if node
(or (alist-get 'longName node)
(alist-get 'shortName node)
key)
key)))
;;;; Channel helpers
(defun meshmonitor-chat--fetch-channels-sync ()
"Fetch and cache channels synchronously."
(let ((result (meshmonitor-chat--request
"GET" "/api/v1/channels")))
(when result
(setq meshmonitor-chat--channels
(alist-get 'data (cdr result))))))
(defun meshmonitor-chat--fetch-channels (&optional callback)
"Fetch channels asynchronously.
Call CALLBACK with the channel list when done."
(meshmonitor-chat--request
"GET" "/api/v1/channels" nil
(lambda (result)
(when result
(setq meshmonitor-chat--channels
(alist-get 'data (cdr result))))
(when callback
(funcall callback meshmonitor-chat--channels)))))
(defun meshmonitor-chat--channel-name (id)
"Return display name for channel ID."
(let ((ch (seq-find (lambda (c) (equal (alist-get 'id c) id))
meshmonitor-chat--channels)))
(if ch
(or (let ((name (alist-get 'name ch)))
(and name (not (string-empty-p name)) name))
(format "%d" id))
(format "%d" id))))
;;;; API wrappers
(defun meshmonitor-chat--api-messages (params callback)
"Fetch messages with query PARAMS alist.
Call CALLBACK with (STATUS . BODY)."
(let ((query (mapconcat
(lambda (p)
(format "%s=%s"
(url-hexify-string (symbol-name (car p)))
(url-hexify-string (format "%s" (cdr p)))))
params "&")))
(meshmonitor-chat--request
"GET" (concat "/api/v1/messages?" query) nil callback)))
(defun meshmonitor-chat--api-send (text &optional channel to-node
reply-id callback)
"Send TEXT message to CHANNEL or TO-NODE.
REPLY-ID is the requestId of the message being replied to.
Call CALLBACK with (STATUS . BODY)."
(let ((data `((text . ,text))))
(when channel (push `(channel . ,channel) data))
(when to-node (push `(toNodeId . ,to-node) data))
(when reply-id (push `(replyId . ,reply-id) data))
(meshmonitor-chat--request
"POST" "/api/v1/messages" data callback)))
;;;; Session-authenticated requests (for internal API)
(defun meshmonitor-chat--ensure-session ()
"Ensure we have a session cookie and CSRF token.
Logs in with username/password if needed."
(unless meshmonitor-chat--csrf-token
(when (and (not (string-empty-p meshmonitor-chat-username))
(not (string-empty-p meshmonitor-chat-password)))
;; Login to get session cookie (url.el stores it automatically).
(let ((url-cookie-confirmation nil))
(meshmonitor-chat--request
"POST" meshmonitor-chat-login-endpoint
`((username . ,meshmonitor-chat-username)
(password . ,meshmonitor-chat-password))))
;; Fetch CSRF token.
(let ((result (meshmonitor-chat--request
"GET" "/api/csrf-token")))
(when result
(let ((token (alist-get 'csrfToken (cdr result))))
(when token
(setq meshmonitor-chat--csrf-token token))))))))
(defun meshmonitor-chat--session-request (method endpoint
&optional data
callback)
"Make a session-authenticated request with METHOD to ENDPOINT.
Includes CSRF token for POST requests.
DATA is an alist for the JSON body.
CALLBACK works like `meshmonitor-chat--request'."
(meshmonitor-chat--ensure-session)
(let ((url-request-method method)
(url-cookie-confirmation nil)
(url-request-extra-headers
(append '(("Content-Type" . "application/json")
("Accept" . "application/json"))
(when meshmonitor-chat--csrf-token
`(("X-CSRF-Token"
. ,meshmonitor-chat--csrf-token)))))
(url-request-data
(when data
(encode-coding-string (json-encode data) 'utf-8)))
(full-url (meshmonitor-chat--build-url endpoint)))
(if callback
(url-retrieve
full-url
(lambda (status cb)
(let ((result nil)
(resp-buf (current-buffer)))
(unwind-protect
(progn
(unless (plist-get status :error)
(setq result
(meshmonitor-chat--parse-response)))
(funcall cb result))
(when (buffer-live-p resp-buf)
(kill-buffer resp-buf)))))
(list callback) t)
(let ((buf (url-retrieve-synchronously full-url t nil 15)))
(when buf
(unwind-protect
(with-current-buffer buf
(meshmonitor-chat--parse-response))
(kill-buffer buf)))))))
;;;; Node actions
(defun meshmonitor-chat--node-has-pkc-p (node-id)
"Return non-nil if NODE-ID has exchanged encryption keys."
(let* ((node (gethash node-id meshmonitor-chat--nodes)))
(and node
(meshmonitor-chat--json-true-p
(alist-get 'hasPKC node)))))
(defun meshmonitor-chat-traceroute ()
"Send a traceroute to the node at point."
(interactive)
(let ((node-id (meshmonitor-chat--get-node-id-at-point)))
(if node-id
(progn
(message "MeshMonitor: traceroute to %s..."
(meshmonitor-chat--node-name node-id))
(meshmonitor-chat--session-request
"POST" "/api/traceroute"
`((destination . ,node-id))
(lambda (result)
(if (and result (< (car result) 400))
(message "MeshMonitor: traceroute sent to %s"
(meshmonitor-chat--node-name node-id))
(message "MeshMonitor: traceroute failed (HTTP %s)"
(if result (car result) "timeout"))))))
(user-error "No node at point"))))
(defun meshmonitor-chat-request-position ()
"Request position from the node at point."
(interactive)
(let ((node-id (meshmonitor-chat--get-node-id-at-point)))
(if node-id
(progn
(message "MeshMonitor: requesting position from %s..."
(meshmonitor-chat--node-name node-id))
(meshmonitor-chat--session-request
"POST" "/api/position/request"
`((destination . ,node-id))
(lambda (result)
(if (and result (< (car result) 400))
(message "MeshMonitor: position requested from %s"
(meshmonitor-chat--node-name node-id))
(message "MeshMonitor: position request failed (HTTP %s)"
(if result (car result) "timeout"))))))
(user-error "No node at point"))))
(defun meshmonitor-chat--get-node-id-at-point ()
"Get node ID from the current tabulated-list entry."
(let ((entry (tabulated-list-get-entry)))
(when entry
;; Node ID is in column index 2 for node list, or column 1 for DM/unread.
(or (and (> (length entry) 2) (aref entry 2))
(and (> (length entry) 1) (aref entry 1))))))
;;;; Delivery icons
(defun meshmonitor-chat--delivery-icon (state)
"Return propertized delivery icon string for STATE."
(let ((icon (pcase state
('confirmed "")
('failed "")
(_ "·")))
(face (pcase state
('confirmed 'meshmonitor-chat-delivery-confirmed-face)
('failed 'meshmonitor-chat-delivery-failed-face)
(_ 'meshmonitor-chat-delivery-pending-face))))
(propertize icon 'face face 'read-only t 'rear-nonsticky t)))
(defun meshmonitor-chat--update-delivery-icon (request-id state)
"Update delivery icon for REQUEST-ID to STATE in current buffer."
(let ((entry (assoc request-id meshmonitor-chat--pending-deliveries)))
(when entry
(let ((marker (cdr entry))
(inhibit-read-only t))
(when (and marker (marker-buffer marker))
(save-excursion
(goto-char marker)
(delete-char 1)
(insert (meshmonitor-chat--delivery-icon state))
(set-marker marker (1- (point))))))
(unless (eq state 'pending)
(setq meshmonitor-chat--pending-deliveries
(delq entry meshmonitor-chat--pending-deliveries))))))
(defun meshmonitor-chat--check-delivery (msg buffer)
"Check if MSG confirms delivery of a pending message in BUFFER."
(when (buffer-live-p buffer)
(let ((req-id (alist-get 'requestId msg)))
(when req-id
(with-current-buffer buffer
(when (assoc req-id meshmonitor-chat--pending-deliveries)
(let ((state (cond
((equal (alist-get 'ackFailed msg) 1)
'failed)
((member (alist-get 'deliveryState msg)
'("confirmed" "delivered"))
'confirmed)
(t nil))))
(when state
(meshmonitor-chat--update-delivery-icon
req-id state)
(let ((msg-id (alist-get 'id msg)))
(when msg-id
(puthash msg-id t
meshmonitor-chat--seen-ids)))
(puthash (cons 'req req-id) t
meshmonitor-chat--seen-ids)))))))))
;;;; Chat mode
(defvar meshmonitor-chat-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'meshmonitor-chat-send-input)
(define-key map (kbd "M-p") #'meshmonitor-chat-previous-input)
(define-key map (kbd "M-n") #'meshmonitor-chat-next-input)
(define-key map (kbd "C-c C-r") #'meshmonitor-chat-reply)
(define-key map (kbd "C-c C-s") #'meshmonitor-chat-resend)
(define-key map (kbd "C-c C-e") #'meshmonitor-chat-react)
(define-key map (kbd "C-c C-k") #'meshmonitor-chat-cancel-reply)
(define-key map (kbd "C-c C-d") #'meshmonitor-chat-dm-at-point)
(define-key map (kbd "C-c C-l") #'meshmonitor-chat-refresh-chat)
map)
"Keymap for `meshmonitor-chat-mode'.")
(define-derived-mode meshmonitor-chat-mode fundamental-mode "MeshChat"
"Major mode for MeshMonitor chat buffers.
Provides an input prompt at the bottom with message history above."
:group 'meshmonitor-chat
(setq-local meshmonitor-chat--prompt-start (make-marker))
(setq-local meshmonitor-chat--prompt-end (make-marker))
(setq-local meshmonitor-chat--input-ring (make-ring 64))
(setq-local meshmonitor-chat--input-ring-index 0)
(setq-local meshmonitor-chat--seen-ids
(make-hash-table :test 'equal))
(setq-local meshmonitor-chat--pending-deliveries nil)
(setq-local meshmonitor-chat--reactions
(make-hash-table :test 'equal))
(setq-local meshmonitor-chat--last-timestamp nil)
(setq mode-line-process
'(" " (:eval (meshmonitor-chat--mode-line-status))))
(add-hook 'post-command-hook #'force-mode-line-update nil t))
(defvar meshmonitor-chat--max-message-bytes 600
"Maximum message size in bytes (3 parts x ~200 bytes).")
(defun meshmonitor-chat--input-byte-length ()
"Return the byte length of the current input text."
(if (and meshmonitor-chat--prompt-end
(marker-position meshmonitor-chat--prompt-end))
(string-bytes
(buffer-substring-no-properties
meshmonitor-chat--prompt-end (point-max)))
0))
(defun meshmonitor-chat--mode-line-status ()
"Return mode-line status string with connection and input size."
(let* ((conn (if meshmonitor-chat--connected "[on]" "[off]"))
(len (meshmonitor-chat--input-byte-length)))
(if (> len 0)
(let ((face (cond
((> len meshmonitor-chat--max-message-bytes)
'error)
((> len 200) 'warning)
(t nil))))
(format "%s %s"
conn
(if face
(propertize (format "[%d/%d B]" len
meshmonitor-chat--max-message-bytes)
'face face)
(format "[%d B]" len))))
conn)))
(defun meshmonitor-chat--prompt-string ()
"Return the prompt string for the current buffer."
(let ((base (pcase meshmonitor-chat--target-type
('channel (format "#%s> "
(meshmonitor-chat--channel-name
meshmonitor-chat--target)))
('dm (format "%s> "
(meshmonitor-chat--node-name
meshmonitor-chat--target)))
(_ "MeshMonitor> "))))
(if meshmonitor-chat--reply-to
(format "[reply %s] %s"
(cdr meshmonitor-chat--reply-to) base)
base)))
(defun meshmonitor-chat--setup-prompt ()
"Insert the input prompt at the end of the current buffer."
(goto-char (point-max))
(let ((start (point))
(txt (meshmonitor-chat--prompt-string)))
(insert (propertize txt
'face 'meshmonitor-chat-prompt-face
'read-only t
'front-sticky t
'rear-nonsticky t))
(set-marker meshmonitor-chat--prompt-start start)
(set-marker meshmonitor-chat--prompt-end (point))))
(defun meshmonitor-chat--refresh-prompt ()
"Refresh the prompt text, preserving input."
(let ((inhibit-read-only t)
(input (buffer-substring-no-properties
meshmonitor-chat--prompt-end (point-max))))
(save-excursion
(delete-region meshmonitor-chat--prompt-start (point-max))
(goto-char meshmonitor-chat--prompt-start)
(let ((txt (meshmonitor-chat--prompt-string)))
(insert (propertize txt
'face 'meshmonitor-chat-prompt-face
'read-only t
'front-sticky t
'rear-nonsticky t))
(set-marker meshmonitor-chat--prompt-end (point)))
(insert input))))
;;;; Message rendering
(defun meshmonitor-chat--insert-at-prompt (buffer fn)
"In BUFFER, execute FN at the prompt insertion point.
Handles marker manipulation and cursor restoration."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(let ((inhibit-read-only t)
(at-end (>= (point) meshmonitor-chat--prompt-end)))
(save-excursion
(set-marker-insertion-type
meshmonitor-chat--prompt-start t)
(set-marker-insertion-type
meshmonitor-chat--prompt-end t)
(goto-char meshmonitor-chat--prompt-start)
(funcall fn)
(set-marker-insertion-type
meshmonitor-chat--prompt-start nil)
(set-marker-insertion-type
meshmonitor-chat--prompt-end nil))
(when at-end
(goto-char meshmonitor-chat--prompt-end))))))
(defun meshmonitor-chat--insert-msg (buffer ts sender text
&optional selfp sysp
request-id from-id
reply-to-name)
"Insert a chat message into BUFFER.
TS is the timestamp, SENDER the display name, TEXT the content.
SELFP non-nil marks the message as from the local node.
SYSP non-nil renders a system notification instead.
REQUEST-ID is stored as text property for reply support.
FROM-ID is the sender node ID for opening DMs.
REPLY-TO-NAME shows a reply indicator when non-nil."
(meshmonitor-chat--insert-at-prompt
buffer
(lambda ()
(insert
(propertize
(if sysp
(format "*** %s\n" text)
(concat
(propertize
(format "[%s] "
(if ts
(meshmonitor-chat--format-time ts)
"--:--"))
'face 'meshmonitor-chat-timestamp-face)
(propertize
(format "<%s> " sender)
'face (if selfp
'meshmonitor-chat-nick-self-face
'meshmonitor-chat-nick-other-face))
(when reply-to-name
(propertize (format "↩ %s: " reply-to-name)
'face 'meshmonitor-chat-system-face))
text "\n"))
'read-only t
'rear-nonsticky t
'front-sticky t
'face (when sysp 'meshmonitor-chat-system-face)
'meshmonitor-chat-msg-text text
'meshmonitor-chat-request-id request-id
'meshmonitor-chat-sender sender
'meshmonitor-chat-from-id from-id)))))
(defun meshmonitor-chat--insert-sent-msg (buffer ts sender text
delivery
&optional request-id)
"Insert a self message into BUFFER with DELIVERY state icon.
TS is the timestamp, SENDER the display name, TEXT the content.
DELIVERY is a symbol: `pending', `confirmed' or `failed'.
REQUEST-ID enables delivery tracking when DELIVERY is `pending'."
(meshmonitor-chat--insert-at-prompt
buffer
(lambda ()
(insert
(propertize
(concat
(propertize
(format "[%s] "
(if ts
(meshmonitor-chat--format-time ts)
"--:--"))
'face 'meshmonitor-chat-timestamp-face)
(propertize (format "<%s> " sender)
'face 'meshmonitor-chat-nick-self-face)
text " ")
'read-only t 'rear-nonsticky t 'front-sticky t
'meshmonitor-chat-msg-text text))
(let ((icon-pos (point)))
(insert (meshmonitor-chat--delivery-icon delivery))
(insert (propertize "\n" 'read-only t 'rear-nonsticky t))
(when (and request-id (eq delivery 'pending))
(push (cons request-id (copy-marker icon-pos))
meshmonitor-chat--pending-deliveries))))))
(defun meshmonitor-chat--is-self-p (msg)
"Return non-nil if MSG is from the local node."
(let ((from-id (alist-get 'fromNodeId msg))
(from-num (alist-get 'fromNodeNum msg)))
(or (and meshmonitor-chat--my-node-id from-id
(equal from-id meshmonitor-chat--my-node-id))
(and meshmonitor-chat--my-node-num from-num
(equal from-num meshmonitor-chat--my-node-num)))))
(defun meshmonitor-chat--msg-delivery-state (msg)
"Return delivery state symbol for MSG, or nil."
(cond
((equal (alist-get 'ackFailed msg) 1) 'failed)
((alist-get 'deliveryState msg)
(let ((ds (alist-get 'deliveryState msg)))
(cond
((member ds '("confirmed" "delivered")) 'confirmed)
((member ds '("failed" "error")) 'failed)
((equal ds "pending") 'pending)
(t nil))))
(t nil)))
(defun meshmonitor-chat--extract-request-id (msg)
"Extract the request ID from MSG for reply support.
Use the requestId field if present, otherwise extract from
the id field (format nodeNum_requestId)."
(or (alist-get 'requestId msg)
(let ((id (alist-get 'id msg)))
(when (and id (stringp id) (string-match "_\\([0-9]+\\)$" id))
(string-to-number (match-string 1 id))))))
(defun meshmonitor-chat--text-message-p (msg)
"Return non-nil if MSG is a text message (not traceroute etc)."
(let ((pn (alist-get 'portnum msg)))
(or (null pn) (equal pn 1))))
(defun meshmonitor-chat--reaction-p (msg)
"Return non-nil if MSG is an emoji reaction."
(equal (alist-get 'emoji msg) 1))
(defun meshmonitor-chat--find-reply-sender (buffer reply-id)
"Find the sender name of the message with REQUEST-ID REPLY-ID in BUFFER."
(when (and reply-id (buffer-live-p buffer))
(with-current-buffer buffer
(save-excursion
(goto-char (point-min))
(let ((limit (marker-position meshmonitor-chat--prompt-start))
(found nil))
(while (and (not found) (< (point) limit))
(when (equal (get-text-property (point)
'meshmonitor-chat-request-id)
reply-id)
(setq found (get-text-property (point)
'meshmonitor-chat-sender)))
(goto-char (or (next-single-property-change
(point) 'meshmonitor-chat-request-id
nil limit)
limit)))
found)))))
(defun meshmonitor-chat--find-request-id-pos (request-id)
"Find buffer position of message with REQUEST-ID, or nil."
(save-excursion
(goto-char (point-min))
(let ((limit (marker-position meshmonitor-chat--prompt-start))
(found nil))
(while (and (not found) (< (point) limit))
(if (equal (get-text-property (point)
'meshmonitor-chat-request-id)
request-id)
(setq found (point))
(goto-char (or (next-single-property-change
(point) 'meshmonitor-chat-request-id
nil limit)
limit))))
found)))
(defun meshmonitor-chat--render-reaction-line (buffer reply-id)
"Render the reaction line for REPLY-ID in BUFFER.
All consecutive reaction lines under the anchor message are merged
into a single line so that reactions arriving with distinct REPLY-ID
values but targeting the same visible message no longer stack."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(let ((inhibit-read-only t)
(msg-pos (meshmonitor-chat--find-request-id-pos reply-id)))
(when msg-pos
(save-excursion
;; Move past the whole anchor message, not just one line, so
;; multi-line messages are never split by the reaction line.
(goto-char (or (next-single-property-change
msg-pos 'meshmonitor-chat-request-id)
(point-max)))
(let ((start (point))
(reply-ids (list reply-id)))
;; Collect reply-ids from every adjacent reaction line. The
;; scan is bounded by the reaction property itself, not by the
;; prompt marker: when the anchor is the last message the line
;; sits at the prompt boundary, and a marker-based limit would
;; miss it and stack a duplicate instead of merging.
(while (get-text-property
(point) 'meshmonitor-chat-reaction-for)
(let ((rid (get-text-property
(point) 'meshmonitor-chat-reaction-for)))
(unless (member rid reply-ids)
(push rid reply-ids)))
(forward-line 1))
(delete-region start (point))
;; Merge reactions from every collected bucket, deduped.
(let ((seen (make-hash-table :test 'equal))
(merged '()))
(dolist (rid reply-ids)
(dolist (r (gethash
rid meshmonitor-chat--reactions))
(unless (gethash r seen)
(puthash r t seen)
(push r merged))))
(when merged
(goto-char start)
;; Keep the reaction line inside the message area: when it
;; is inserted exactly at the prompt, advance the marker so
;; the prompt stays below it.
(set-marker-insertion-type
meshmonitor-chat--prompt-start t)
(insert
(propertize
(format " ↳ %s\n"
(mapconcat
(lambda (r)
(format "%s %s" (car r) (cdr r)))
(nreverse merged) ", "))
'face 'meshmonitor-chat-system-face
'read-only t
'rear-nonsticky t
'front-sticky t
'meshmonitor-chat-reaction-for reply-id))
(set-marker-insertion-type
meshmonitor-chat--prompt-start nil))))))))))
(defun meshmonitor-chat--mark-seen (buffer id req-id unix-ts)
"Mark message as seen in BUFFER and update last timestamp to UNIX-TS.
ID is the message id (unstable across lifecycle). REQ-ID is the
stable Meshtastic request id, tracked under a namespaced key so the
poll can dedup even when the id string format changes."
(with-current-buffer buffer
(when id
(puthash id t meshmonitor-chat--seen-ids))
(when req-id
(puthash (cons 'req req-id) t meshmonitor-chat--seen-ids))
(when (or (null meshmonitor-chat--last-timestamp)
(> unix-ts meshmonitor-chat--last-timestamp))
(setq meshmonitor-chat--last-timestamp unix-ts))))
(defun meshmonitor-chat--render-messages (buffer messages)
"Render MESSAGES into BUFFER with deduplication.
MESSAGES is a list of message alists from the API."
(when (and (buffer-live-p buffer) messages)
(let* ((text-msgs (seq-filter #'meshmonitor-chat--text-message-p
(copy-sequence messages)))
(sorted (sort text-msgs
(lambda (a b)
(< (meshmonitor-chat--parse-timestamp
(alist-get 'timestamp a))
(meshmonitor-chat--parse-timestamp
(alist-get 'timestamp b))))))
(regular (seq-remove #'meshmonitor-chat--reaction-p
sorted))
(reactions (seq-filter #'meshmonitor-chat--reaction-p
sorted)))
;; Render regular messages.
(dolist (msg regular)
(let ((id (alist-get 'id msg))
(req-id (meshmonitor-chat--extract-request-id msg)))
;; Always check delivery, even for already-seen messages.
(meshmonitor-chat--check-delivery msg buffer)
(unless (with-current-buffer buffer
(or (and id (gethash id
meshmonitor-chat--seen-ids))
(and req-id
(gethash (cons 'req req-id)
meshmonitor-chat--seen-ids))))
(let* ((from (or (alist-get 'fromNodeId msg)
(alist-get 'from msg)))
(sender (meshmonitor-chat--node-name from))
(text (or (alist-get 'text msg) ""))
(ts (alist-get 'timestamp msg))
(selfp (meshmonitor-chat--is-self-p msg))
(unix-ts (meshmonitor-chat--parse-timestamp ts))
(reply-id (alist-get 'replyId msg))
(reply-name (meshmonitor-chat--find-reply-sender
buffer reply-id))
(delivery (when selfp
(meshmonitor-chat--msg-delivery-state
msg))))
(if (and selfp delivery)
(meshmonitor-chat--insert-sent-msg
buffer ts sender text delivery req-id)
(meshmonitor-chat--insert-msg
buffer ts sender text selfp nil req-id from
reply-name)
(unless (or selfp (get-buffer-window buffer))
(with-current-buffer buffer
(meshmonitor-chat--notify
sender text
meshmonitor-chat--target-type
meshmonitor-chat--target))))
(meshmonitor-chat--mark-seen
buffer id req-id unix-ts)))))
;; Process emoji reactions.
(dolist (msg reactions)
(let ((id (alist-get 'id msg))
(reply-id (alist-get 'replyId msg))
(emoji-text (or (alist-get 'text msg) ""))
(from (or (alist-get 'fromNodeId msg)
(alist-get 'from msg)))
(unix-ts (meshmonitor-chat--parse-timestamp
(alist-get 'timestamp msg))))
(unless (and id (with-current-buffer buffer
(gethash id meshmonitor-chat--seen-ids)))
(when reply-id
(with-current-buffer buffer
(let ((existing (gethash reply-id
meshmonitor-chat--reactions)))
(push (cons emoji-text
(meshmonitor-chat--node-name from))
existing)
(puthash reply-id existing
meshmonitor-chat--reactions)))
(meshmonitor-chat--render-reaction-line
buffer reply-id))
(meshmonitor-chat--mark-seen
buffer id nil unix-ts))))
;; Mark conversation as read when buffer is visible.
(when (get-buffer-window buffer)
(with-current-buffer buffer
(puthash (cons meshmonitor-chat--target-type
meshmonitor-chat--target)
(or meshmonitor-chat--last-timestamp 0)
meshmonitor-chat--read-timestamps))))))
;;;; Input handling
(defun meshmonitor-chat-send-input ()
"Send the text after the prompt as a message."
(interactive)
(if (< (point) meshmonitor-chat--prompt-end)
(goto-char (point-max))
(let ((input (buffer-substring-no-properties
meshmonitor-chat--prompt-end (point-max))))
(when (string-blank-p input)
(user-error "Empty message"))
(ring-insert meshmonitor-chat--input-ring input)
(setq meshmonitor-chat--input-ring-index 0)
(let ((inhibit-read-only t))
(delete-region meshmonitor-chat--prompt-end (point-max)))
(meshmonitor-chat--send-text input))))
(defun meshmonitor-chat--send-text (text)
"Send TEXT to the current buffer target."
(unless meshmonitor-chat--connected
(user-error "Not connected to MeshMonitor"))
(let ((buf (current-buffer))
(target meshmonitor-chat--target)
(ttype meshmonitor-chat--target-type)
(reply-id (car meshmonitor-chat--reply-to)))
(when meshmonitor-chat--reply-to
(setq meshmonitor-chat--reply-to nil)
(meshmonitor-chat--refresh-prompt))
(let ((cb (lambda (result)
(let ((status (if result (car result) 0))
(data (alist-get 'data (cdr result))))
(cond
((and result (< status 400))
(let ((req-id (alist-get 'requestId data))
(msg-id (alist-get 'messageId data))
(parts (or (alist-get 'messageCount data)
1))
(sender
(meshmonitor-chat--node-name
(or meshmonitor-chat--my-node-id "me"))))
(when (buffer-live-p buf)
(unless (with-current-buffer buf
(or (and msg-id
(gethash
msg-id
meshmonitor-chat--seen-ids))
(and req-id
(gethash
(cons 'req req-id)
meshmonitor-chat--seen-ids))))
(meshmonitor-chat--insert-sent-msg
buf (float-time) sender text
'pending req-id))
(when msg-id
(with-current-buffer buf
(puthash msg-id t
meshmonitor-chat--seen-ids)))
(when req-id
(with-current-buffer buf
(puthash (cons 'req req-id) t
meshmonitor-chat--seen-ids))))
(when (> parts 1)
(meshmonitor-chat--insert-msg
buf nil nil
(format "Message split into %d parts"
parts)
nil t))))
((and result (= status 413))
(meshmonitor-chat--insert-msg
buf nil nil
"Message too long (max ~600 bytes, 3 parts)"
nil t))
((and result (= status 503))
(meshmonitor-chat--insert-msg
buf nil nil
"Meshtastic node not connected" nil t))
(t
(meshmonitor-chat--insert-msg
buf nil nil
(format "Send failed (HTTP %s)"
(or status "timeout"))
nil t)))))))
(pcase ttype
('channel
(meshmonitor-chat--api-send text target nil reply-id cb))
('dm
(meshmonitor-chat--api-send text nil target reply-id cb))
(_ (user-error "No target set for this buffer"))))))
(defun meshmonitor-chat--navigate-input (direction)
"Navigate input history in DIRECTION (1 for previous, -1 for next)."
(when (> (ring-length meshmonitor-chat--input-ring) 0)
(let ((inhibit-read-only t))
(delete-region meshmonitor-chat--prompt-end (point-max))
(when (= direction -1)
(setq meshmonitor-chat--input-ring-index
(mod (1- meshmonitor-chat--input-ring-index)
(ring-length meshmonitor-chat--input-ring))))
(insert (ring-ref meshmonitor-chat--input-ring
meshmonitor-chat--input-ring-index))
(when (= direction 1)
(setq meshmonitor-chat--input-ring-index
(mod (1+ meshmonitor-chat--input-ring-index)
(ring-length meshmonitor-chat--input-ring)))))))
(defun meshmonitor-chat-previous-input ()
"Replace current input with previous entry from history."
(interactive)
(meshmonitor-chat--navigate-input 1))
(defun meshmonitor-chat-next-input ()
"Replace current input with next entry from history."
(interactive)
(meshmonitor-chat--navigate-input -1))
(defun meshmonitor-chat--get-msg-property-at-line (prop)
"Get text property PROP from the current line."
(let ((value nil)
(start (line-beginning-position))
(end (line-end-position)))
(save-excursion
(goto-char start)
(while (and (not value) (< (point) end))
(setq value (get-text-property (point) prop))
(goto-char (or (next-single-property-change
(point) prop nil end)
end))))
value))
(defun meshmonitor-chat-resend ()
"Resend the message at point."
(interactive)
(let ((text (meshmonitor-chat--get-msg-property-at-line
'meshmonitor-chat-msg-text)))
(if text
(progn
(goto-char meshmonitor-chat--prompt-end)
(meshmonitor-chat--send-text text))
(user-error "No sent message at point"))))
(defun meshmonitor-chat-reply ()
"Set reply context to the message at point.
The next sent message will be a reply to this one."
(interactive)
(let ((req-id (meshmonitor-chat--get-msg-property-at-line
'meshmonitor-chat-request-id))
(sender (meshmonitor-chat--get-msg-property-at-line
'meshmonitor-chat-sender)))
(if req-id
(progn
(setq meshmonitor-chat--reply-to (cons req-id sender))
(meshmonitor-chat--refresh-prompt)
(goto-char (point-max))
(message "Replying to %s (C-c C-k to cancel)" sender))
(user-error "No message at point"))))
(defun meshmonitor-chat-cancel-reply ()
"Cancel the current reply context."
(interactive)
(setq meshmonitor-chat--reply-to nil)
(meshmonitor-chat--refresh-prompt)
(message "Reply cancelled"))
(declare-function emojify-completing-read "emojify" (&optional prompt))
(defun meshmonitor-chat-react ()
"React with an emoji to the message at point.
Uses `emojify-completing-read' when available for emoji selection.
Shows the reaction immediately and sends it via the API."
(interactive)
(let ((req-id (meshmonitor-chat--get-msg-property-at-line
'meshmonitor-chat-request-id)))
(if req-id
(let ((emoji (if (fboundp 'emojify-completing-read)
(emojify-completing-read "Reaction: ")
(read-string "Emoji: "))))
(when (and emoji (not (string-empty-p emoji)))
(let ((buf (current-buffer))
(target meshmonitor-chat--target)
(ttype meshmonitor-chat--target-type)
(sender (meshmonitor-chat--node-name
(or meshmonitor-chat--my-node-id "me")))
(reaction (cons emoji nil)))
;; Optimistic: show reaction immediately.
(setcdr reaction sender)
(let ((existing (gethash req-id
meshmonitor-chat--reactions)))
(push reaction existing)
(puthash req-id existing
meshmonitor-chat--reactions))
(meshmonitor-chat--render-reaction-line buf req-id)
;; Send via API.
(meshmonitor-chat--api-send
emoji
(when (eq ttype 'channel) target)
(when (eq ttype 'dm) target)
req-id
(lambda (result)
(if (and result (< (car result) 400))
;; Mark as seen to prevent polling duplicate.
(let ((msg-id
(alist-get
'messageId
(alist-get 'data (cdr result)))))
(when (and msg-id (buffer-live-p buf))
(with-current-buffer buf
(puthash msg-id t
meshmonitor-chat--seen-ids))))
;; On error, remove the optimistic reaction.
(when (buffer-live-p buf)
(with-current-buffer buf
(let ((rs (gethash req-id
meshmonitor-chat--reactions)))
(puthash req-id (delete reaction rs)
meshmonitor-chat--reactions))
(meshmonitor-chat--render-reaction-line
buf req-id))
(meshmonitor-chat--insert-msg
buf nil nil "Reaction failed" nil t))))))))
(user-error "No message at point"))))
(defun meshmonitor-chat-dm-at-point ()
"Open a DM chat with the sender of the message at point."
(interactive)
(let ((from-id (meshmonitor-chat--get-msg-property-at-line
'meshmonitor-chat-from-id)))
(if from-id
(meshmonitor-chat-open-dm from-id)
(user-error "No message at point"))))
(defun meshmonitor-chat-refresh-chat ()
"Reload message history for the current chat buffer."
(interactive)
(unless meshmonitor-chat--target
(user-error "Not a chat buffer"))
(let ((buf (current-buffer)))
(pcase meshmonitor-chat--target-type
('channel
(meshmonitor-chat--api-messages
`((channel . ,meshmonitor-chat--target)
(limit . ,meshmonitor-chat-message-limit))
(lambda (result)
(when result
(let ((msgs (alist-get 'data (cdr result))))
(when msgs
(meshmonitor-chat--render-messages buf msgs)))))))
('dm
(meshmonitor-chat--fetch-dm-both-directions
meshmonitor-chat--target meshmonitor-chat-message-limit
(lambda (msgs)
(when msgs
(meshmonitor-chat--render-messages buf msgs))))))
(message "MeshMonitor: refreshed")))
;;;; Buffer management
(defun meshmonitor-chat--buffer-name (ttype target)
"Generate buffer name for target type TTYPE and TARGET."
(pcase ttype
('channel (format "*MeshMonitor: #%s*"
(meshmonitor-chat--channel-name target)))
('dm (format "*MeshMonitor: DM %s*"
(meshmonitor-chat--node-name target)))))
(defun meshmonitor-chat--get-or-create-buffer (ttype target)
"Get or create a chat buffer for TTYPE and TARGET."
(let* ((name (meshmonitor-chat--buffer-name ttype target))
(buf (get-buffer name)))
(if (and buf (buffer-live-p buf))
buf
(setq buf (get-buffer-create name))
(with-current-buffer buf
(meshmonitor-chat-mode)
(setq meshmonitor-chat--target target)
(setq meshmonitor-chat--target-type ttype)
(meshmonitor-chat--setup-prompt))
(push (cons (cons ttype target) buf)
meshmonitor-chat--chat-buffers)
buf)))
;;;; Channel list mode
(defun meshmonitor-chat-channel-list-open-by-number ()
"Open channel by the digit key pressed (0-7)."
(interactive)
(let ((id (- last-command-event ?0)))
(meshmonitor-chat-open-channel id)))
(defvar meshmonitor-chat-channel-list-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map tabulated-list-mode-map)
(define-key map (kbd "RET")
#'meshmonitor-chat-channel-list-open)
(define-key map (kbd "g")
#'meshmonitor-chat-channel-list-refresh)
(dotimes (i 8)
(define-key map (kbd (number-to-string i))
#'meshmonitor-chat-channel-list-open-by-number))
map)
"Keymap for `meshmonitor-chat-channel-list-mode'.")
(define-derived-mode meshmonitor-chat-channel-list-mode
tabulated-list-mode "MeshChannels"
"Major mode for listing MeshMonitor channels."
:group 'meshmonitor-chat
(setq tabulated-list-format
[("ID" 4 t)
("Name" 20 t)
("Role" 12 t)])
(setq tabulated-list-padding 2)
(tabulated-list-init-header))
(defun meshmonitor-chat-channel-list-open ()
"Open the channel at point."
(interactive)
(let ((entry (tabulated-list-get-entry)))
(when entry
(meshmonitor-chat-open-channel
(string-to-number (aref entry 0))))))
(defun meshmonitor-chat-channel-list-refresh ()
"Refresh the channel list from the server."
(interactive)
(meshmonitor-chat--fetch-channels
(lambda (_channels)
(let ((buf (get-buffer "*MeshMonitor: Channels*")))
(when (buffer-live-p buf)
(with-current-buffer buf
(meshmonitor-chat--populate-channel-list)))))))
(defun meshmonitor-chat--populate-channel-list ()
"Populate the current buffer with cached channel data."
(let ((role-names '((0 . "Disabled")
(1 . "Primary")
(2 . "Secondary"))))
(setq tabulated-list-entries
(mapcar
(lambda (ch)
(let ((id (alist-get 'id ch))
(name (or (alist-get 'name ch) ""))
(role (or (alist-get 'role ch) 0)))
(list id
(vector (number-to-string id)
name
(or (cdr (assq role role-names))
(format "%d" role))))))
(seq-filter
(lambda (ch) (> (or (alist-get 'role ch) 0) 0))
meshmonitor-chat--channels)))
(tabulated-list-print t)))
;;;; DM list mode
(defvar meshmonitor-chat-dm-list-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map tabulated-list-mode-map)
(define-key map (kbd "RET")
#'meshmonitor-chat-dm-list-open)
(define-key map (kbd "g")
#'meshmonitor-chat-dm-list-refresh)
map)
"Keymap for `meshmonitor-chat-dm-list-mode'.")
(define-derived-mode meshmonitor-chat-dm-list-mode
tabulated-list-mode "MeshDMs"
"Major mode for listing MeshMonitor DM conversations."
:group 'meshmonitor-chat
(setq tabulated-list-format
[("Name" 20 t)
("Node ID" 14 t)
("Last message" 40 nil)])
(setq tabulated-list-padding 2)
(tabulated-list-init-header))
(defun meshmonitor-chat-dm-list-open ()
"Open DM chat with the node at point."
(interactive)
(let ((entry (tabulated-list-get-entry)))
(when entry
(meshmonitor-chat-open-dm (aref entry 1)))))
(defun meshmonitor-chat-dm-list-refresh ()
"Refresh the DM conversation list from the server."
(interactive)
(meshmonitor-chat--fetch-dm-conversations
(lambda (partners)
(let ((buf (get-buffer "*MeshMonitor: Direct Messages*")))
(when (buffer-live-p buf)
(with-current-buffer buf
(meshmonitor-chat--populate-dm-list partners)))))))
(defun meshmonitor-chat--fetch-dm-conversations (callback)
"Fetch DM conversations and call CALLBACK with partner alist.
Each element is (NODE-ID . LAST-MESSAGE-ALIST)."
(meshmonitor-chat--api-messages
`((limit . 200))
(lambda (result)
(let ((partners nil))
(when result
(let* ((msgs (alist-get 'data (cdr result)))
(dm-msgs (seq-filter
(lambda (m)
(and (equal (alist-get 'channel m) -1)
(meshmonitor-chat--text-message-p m)
(not (meshmonitor-chat--reaction-p
m))))
msgs)))
(setq partners
(meshmonitor-chat--extract-dm-partners
dm-msgs))))
(funcall callback partners)))))
(defun meshmonitor-chat--extract-dm-partners (messages)
"Extract unique DM conversation partners from MESSAGES.
Return alist of (NODE-ID . LAST-MESSAGE-ALIST)."
(let ((partners (make-hash-table :test 'equal)))
(dolist (msg messages)
(let ((from (alist-get 'fromNodeId msg))
(to (alist-get 'toNodeId msg))
(ts (meshmonitor-chat--parse-timestamp
(alist-get 'timestamp msg))))
(let ((partner
(cond
((and meshmonitor-chat--my-node-id
(equal from meshmonitor-chat--my-node-id))
to)
((and meshmonitor-chat--my-node-id
(equal to meshmonitor-chat--my-node-id))
from)
(t from))))
(when (and partner
(not (equal partner "broadcast"))
(not (equal partner
meshmonitor-chat--my-node-id)))
(let ((existing (gethash partner partners)))
(when (or (null existing)
(> ts (meshmonitor-chat--parse-timestamp
(alist-get 'timestamp existing))))
(puthash partner msg partners)))))))
(let ((result nil))
(maphash (lambda (k v) (push (cons k v) result)) partners)
result)))
(defun meshmonitor-chat--populate-dm-list (partners)
"Populate the current buffer with DM PARTNERS alist."
(setq tabulated-list-entries
(mapcar
(lambda (entry)
(let* ((node-id (car entry))
(msg (cdr entry))
(name (meshmonitor-chat--node-name node-id))
(text (or (alist-get 'text msg) "")))
(list node-id
(vector name
node-id
(truncate-string-to-width
text 40 nil nil "...")))))
partners))
(tabulated-list-print t))
;;;; Node list mode
(defvar meshmonitor-chat-node-list-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map tabulated-list-mode-map)
(define-key map (kbd "RET")
#'meshmonitor-chat-node-list-open)
(define-key map (kbd "g")
#'meshmonitor-chat-node-list-refresh)
(define-key map (kbd "t")
#'meshmonitor-chat-traceroute)
(define-key map (kbd "p")
#'meshmonitor-chat-request-position)
map)
"Keymap for `meshmonitor-chat-node-list-mode'.")
(define-derived-mode meshmonitor-chat-node-list-mode
tabulated-list-mode "MeshNodes"
"Major mode for listing MeshMonitor nodes sorted by hops."
:group 'meshmonitor-chat
(setq tabulated-list-format
[("Hops" 5 meshmonitor-chat--sort-by-hops)
("Name" 30 t)
("Node ID" 14 t)
("Last heard" 12 t)])
(setq tabulated-list-padding 2)
(setq tabulated-list-sort-key '("Hops"))
(tabulated-list-init-header))
(defun meshmonitor-chat--sort-by-hops (a b)
"Sort entries A and B by hop count numerically."
(let ((ha (string-to-number (aref (cadr a) 0)))
(hb (string-to-number (aref (cadr b) 0))))
(< ha hb)))
(defun meshmonitor-chat-node-list-open ()
"Open DM chat with the node at point.
Refuses if the node has not exchanged encryption keys."
(interactive)
(let ((entry (tabulated-list-get-entry)))
(when entry
(let ((node-id (aref entry 2)))
(if (meshmonitor-chat--node-has-pkc-p node-id)
(meshmonitor-chat-open-dm node-id)
(user-error "Node %s has no key exchange, DM not possible"
(meshmonitor-chat--node-name node-id)))))))
(defun meshmonitor-chat-node-list-refresh ()
"Refresh the node list from the server."
(interactive)
(meshmonitor-chat--fetch-nodes
(lambda ()
(let ((buf (get-buffer "*MeshMonitor: Nodes*")))
(when (buffer-live-p buf)
(with-current-buffer buf
(meshmonitor-chat--populate-node-list)))))))
(defun meshmonitor-chat--format-last-heard (ts)
"Format last heard timestamp TS as relative time."
(if (and ts (numberp ts) (> ts 0))
(let* ((secs (- (float-time) (if (> ts 9999999999)
(/ ts 1000) ts)))
(mins (/ secs 60))
(hours (/ mins 60))
(days (/ hours 24)))
(cond
((< mins 1) "now")
((< mins 60) (format "%dm" (truncate mins)))
((< hours 24) (format "%dh" (truncate hours)))
(t (format "%dd" (truncate days)))))
"?"))
(defvar meshmonitor-chat--online-threshold 900
"Seconds since last heard to consider a node online (default 15 min).")
(defun meshmonitor-chat--node-online-p (last-heard)
"Return non-nil if LAST-HEARD timestamp is recent enough."
(and last-heard (numberp last-heard) (> last-heard 0)
(< (- (float-time) (if (> last-heard 9999999999)
(/ last-heard 1000) last-heard))
meshmonitor-chat--online-threshold)))
(defun meshmonitor-chat--populate-node-list ()
"Populate the current buffer with cached node data."
(let ((entries nil))
(maphash
(lambda (key node)
(when (string-prefix-p "!" key)
(let* ((hops (or (alist-get 'hopsAway node) 99))
(name (or (alist-get 'longName node) ""))
(last-heard (alist-get 'lastHeard node))
(online (meshmonitor-chat--node-online-p
last-heard))
(pkc (meshmonitor-chat--json-true-p
(alist-get 'hasPKC node)))
(indicator (concat (if online "🟢" "")
(if pkc " 🔑" ""))))
(push (list key
(vector (number-to-string hops)
(format "%s %s" indicator name)
key
(meshmonitor-chat--format-last-heard
last-heard)))
entries))))
meshmonitor-chat--nodes)
(setq tabulated-list-entries entries)
(tabulated-list-print t)))
;;;; Unread messages mode
(defvar meshmonitor-chat-unread-list-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map tabulated-list-mode-map)
(define-key map (kbd "RET")
#'meshmonitor-chat-unread-list-open)
(define-key map (kbd "g")
#'meshmonitor-chat-unread-list-refresh)
map)
"Keymap for `meshmonitor-chat-unread-list-mode'.")
(define-derived-mode meshmonitor-chat-unread-list-mode
tabulated-list-mode "MeshUnread"
"Major mode for listing nodes with unread messages."
:group 'meshmonitor-chat
(setq tabulated-list-format
[("Unread" 7 meshmonitor-chat--sort-by-unread)
("Name" 25 t)
("Node ID" 14 t)
("Last message" 40 nil)])
(setq tabulated-list-padding 2)
(setq tabulated-list-sort-key '("Unread" . t))
(tabulated-list-init-header))
(defun meshmonitor-chat--sort-by-unread (a b)
"Sort entries A and B by unread count numerically."
(let ((ua (string-to-number (aref (cadr a) 0)))
(ub (string-to-number (aref (cadr b) 0))))
(< ua ub)))
(defun meshmonitor-chat-unread-list-open ()
"Open DM chat with the node at point."
(interactive)
(let ((entry (tabulated-list-get-entry)))
(when entry
(meshmonitor-chat-open-dm (aref entry 2)))))
(defun meshmonitor-chat-unread-list-refresh ()
"Refresh the unread messages list from the server."
(interactive)
(meshmonitor-chat--fetch-unread
(lambda (unread)
(let ((buf (get-buffer "*MeshMonitor: Unread*")))
(when (buffer-live-p buf)
(with-current-buffer buf
(meshmonitor-chat--populate-unread-list unread)))))))
(defun meshmonitor-chat--fetch-unread (callback)
"Fetch messages and find unread conversations.
Call CALLBACK with alist of (NODE-ID COUNT LAST-MSG)."
(meshmonitor-chat--api-messages
`((limit . 200))
(lambda (result)
(let ((unread nil))
(when result
(let ((msgs (alist-get 'data (cdr result))))
(when msgs
(let ((by-node (make-hash-table :test 'equal)))
(dolist (msg msgs)
(let* ((from (alist-get 'fromNodeId msg))
(ts (meshmonitor-chat--parse-timestamp
(alist-get 'timestamp msg)))
(is-dm (and (equal (alist-get 'channel msg)
-1)
(meshmonitor-chat--text-message-p
msg)
(not (meshmonitor-chat--reaction-p
msg))))
(is-self (meshmonitor-chat--is-self-p msg)))
(when (and is-dm (not is-self) from)
(let ((read-ts
(or (gethash (cons 'dm from)
meshmonitor-chat--read-timestamps)
0)))
(when (> ts read-ts)
(let ((entry (gethash from by-node)))
(if entry
(progn
(cl-incf (car entry))
(when (> ts
(meshmonitor-chat--parse-timestamp
(alist-get 'timestamp
(cdr entry))))
(setcdr entry msg)))
(puthash from (cons 1 msg)
by-node))))))))
(maphash (lambda (k v)
(push (list k (car v) (cdr v)) unread))
by-node)))))
(funcall callback unread)))))
(defun meshmonitor-chat--populate-unread-list (unread)
"Populate current buffer with UNREAD conversation data.
Each element of UNREAD is (NODE-ID COUNT LAST-MSG)."
(setq tabulated-list-entries
(mapcar
(lambda (entry)
(let* ((node-id (nth 0 entry))
(count (nth 1 entry))
(msg (nth 2 entry))
(name (meshmonitor-chat--node-name node-id))
(text (or (alist-get 'text msg) "")))
(list node-id
(vector (number-to-string count)
name
node-id
(truncate-string-to-width
text 40 nil nil "...")))))
unread))
(tabulated-list-print t))
;;;; Open chat buffers
(defun meshmonitor-chat-open-channel (channel-id)
"Open a chat buffer for CHANNEL-ID and load message history."
(meshmonitor-chat--ensure-connected)
(let ((buf (meshmonitor-chat--get-or-create-buffer
'channel channel-id)))
(switch-to-buffer buf)
(meshmonitor-chat--api-messages
`((channel . ,channel-id)
(limit . ,meshmonitor-chat-message-limit))
(lambda (result)
(when result
(let ((msgs (alist-get 'data (cdr result))))
(when msgs
(meshmonitor-chat--render-messages buf msgs))))))))
(defun meshmonitor-chat--fetch-dm-both-directions (node-id limit
callback)
"Fetch DM messages with NODE-ID in both directions.
LIMIT is the max messages per direction.
Call CALLBACK with the merged message list."
(let ((all-messages nil)
(pending 2))
(let ((handler
(lambda (result)
(when result
(let ((msgs (alist-get 'data (cdr result))))
(when msgs
(setq all-messages
(append msgs all-messages)))))
(setq pending (1- pending))
(when (zerop pending)
(funcall callback all-messages)))))
(meshmonitor-chat--api-messages
`((toNodeId . ,node-id) (limit . ,limit)) handler)
(meshmonitor-chat--api-messages
`((fromNodeId . ,node-id) (limit . ,limit)) handler))))
(defun meshmonitor-chat-open-dm (node-id)
"Open a DM chat buffer with NODE-ID and load history."
(meshmonitor-chat--ensure-connected)
(let ((buf (meshmonitor-chat--get-or-create-buffer 'dm node-id)))
(switch-to-buffer buf)
(meshmonitor-chat--fetch-dm-both-directions
node-id meshmonitor-chat-message-limit
(lambda (msgs)
(when msgs
(meshmonitor-chat--render-messages buf msgs))))))
;;;; Polling
(defun meshmonitor-chat--start-polling ()
"Start the periodic message polling timer."
(meshmonitor-chat--stop-polling)
(setq meshmonitor-chat--poll-timer
(run-with-timer
meshmonitor-chat-poll-interval
meshmonitor-chat-poll-interval
#'meshmonitor-chat--poll)))
(defun meshmonitor-chat--stop-polling ()
"Stop the periodic message polling timer."
(when meshmonitor-chat--poll-timer
(cancel-timer meshmonitor-chat--poll-timer)
(setq meshmonitor-chat--poll-timer nil)))
(defun meshmonitor-chat--poll ()
"Poll for new messages in all open chat buffers.
Detects gaps from sleep/suspend and does a full fetch if needed."
(when meshmonitor-chat--connected
(let* ((now (float-time))
(gap-p (and meshmonitor-chat--last-poll-time
(> (- now meshmonitor-chat--last-poll-time)
(* 2 meshmonitor-chat-poll-interval)))))
(setq meshmonitor-chat--last-poll-time now)
(setq meshmonitor-chat--chat-buffers
(seq-filter (lambda (e) (buffer-live-p (cdr e)))
meshmonitor-chat--chat-buffers))
(dolist (entry meshmonitor-chat--chat-buffers)
(let* ((key (car entry))
(buf (cdr entry))
(ttype (car key))
(target (cdr key))
(since (unless gap-p
(with-current-buffer buf
meshmonitor-chat--last-timestamp)))
(limit (if gap-p
meshmonitor-chat-message-limit 20))
(render-cb
(lambda (result)
(when result
(let ((msgs (alist-get 'data (cdr result))))
(when msgs
(meshmonitor-chat--render-messages
buf msgs)))))))
(pcase ttype
('channel
(let ((params `((channel . ,target)
(limit . ,limit))))
(when since
(push `(since . ,(1+ since)) params))
(meshmonitor-chat--api-messages params render-cb)))
('dm
(let ((params-from
`((fromNodeId . ,target) (limit . ,limit)))
(params-to
`((toNodeId . ,target) (limit . ,limit)))
(all-msgs nil)
(dm-pending 2))
(when since
(push `(since . ,(1+ since)) params-from)
(push `(since . ,(1+ since)) params-to))
(let ((dm-handler
(lambda (result)
(when result
(let ((msgs (alist-get 'data
(cdr result))))
(when msgs
(setq all-msgs
(append msgs all-msgs)))))
(setq dm-pending (1- dm-pending))
(when (zerop dm-pending)
(when all-msgs
(meshmonitor-chat--render-messages
buf all-msgs))))))
(meshmonitor-chat--api-messages
params-from dm-handler)
(meshmonitor-chat--api-messages
params-to dm-handler))))))))))
;;;; Notifications
(defun meshmonitor-chat--notify (sender text target-type target)
"Show desktop notification for a message from SENDER.
TEXT is the message content, TARGET-TYPE and TARGET identify the chat."
(when meshmonitor-chat-notify
(let ((title (pcase target-type
('channel (format "#%s"
(meshmonitor-chat--channel-name
target)))
('dm sender)
(_ "MeshMonitor")))
(body (truncate-string-to-width
(format "%s: %s" sender text) 100 nil nil "...")))
(notifications-notify
:title title
:body body
:app-name "MeshMonitor"
:category "im.received"
:urgency 'normal))))
;;;; Cleanup
;;;###autoload
(defun meshmonitor-chat-disconnect ()
"Disconnect from MeshMonitor and clean up state."
(interactive)
(meshmonitor-chat--stop-polling)
(setq meshmonitor-chat--connected nil
meshmonitor-chat--auth-token nil
meshmonitor-chat--csrf-token nil
meshmonitor-chat--last-poll-time nil
meshmonitor-chat--base-url nil
meshmonitor-chat--my-node-id nil
meshmonitor-chat--my-node-num nil
meshmonitor-chat--channels nil
meshmonitor-chat--chat-buffers nil)
(clrhash meshmonitor-chat--nodes)
(clrhash meshmonitor-chat--read-timestamps)
(message "MeshMonitor: disconnected"))
;;;; Status API
(defun meshmonitor-chat--fetch-status-sync ()
"Fetch server status synchronously.
Try legacy endpoint first (more data), fall back to v1."
(let ((result (meshmonitor-chat--request "GET" "/api/status")))
(if (and result (< (car result) 400))
result
(meshmonitor-chat--request "GET" "/api/v1/status"))))
(defun meshmonitor-chat--fetch-status (callback)
"Fetch server status asynchronously.
Try legacy endpoint first (more data), fall back to v1.
Call CALLBACK with (STATUS-CODE . BODY)."
(meshmonitor-chat--request
"GET" "/api/status" nil
(lambda (result)
(if (and result (< (car result) 400))
(funcall callback result)
(meshmonitor-chat--request
"GET" "/api/v1/status" nil callback)))))
(defun meshmonitor-chat--json-true-p (value)
"Return non-nil if JSON VALUE is true.
Handles `json-read' representation where false is `:json-false'."
(and value (not (eq value :json-false))))
(defun meshmonitor-chat--normalize-status (body)
"Normalize status BODY from v1 or legacy API into a common alist.
Returns alist with keys: connected, node-name, node-id,
node-num, version, uptime, nodes, messages, channels."
(let* ((v1-data (alist-get 'data body))
(conn (alist-get 'connection body))
(local-node (and conn (alist-get 'localNode conn)))
(stats (alist-get 'statistics body)))
;; v1: { success, data: { connected, localNodeId, longName, ... } }
;; Legacy: { version, uptime, connection: { connected, localNode },
;; statistics: { nodes, messages, channels } }
`((connected . ,(meshmonitor-chat--json-true-p
(if v1-data
(alist-get 'connected v1-data)
(and conn (alist-get 'connected conn)))))
(node-name . ,(or (and v1-data (alist-get 'longName v1-data))
(and local-node
(alist-get 'longName local-node))))
(node-id . ,(or (and v1-data (alist-get 'localNodeId v1-data))
(and local-node
(alist-get 'nodeId local-node))))
(node-num . ,(or (and v1-data
(alist-get 'localNodeNum v1-data))
(and local-node
(alist-get 'nodeNum local-node))))
(version . ,(alist-get 'version body))
(uptime . ,(alist-get 'uptime body))
(nodes . ,(and stats (alist-get 'nodes stats)))
(messages . ,(and stats (alist-get 'messages stats)))
(channels . ,(and stats (alist-get 'channels stats))))))
;;;; Welcome buffer
(defface meshmonitor-chat-welcome-title-face
'((t :weight bold :height 1.3))
"Face for the welcome buffer title.")
(defface meshmonitor-chat-welcome-heading-face
'((t :weight bold))
"Face for section headings in the welcome buffer.")
(defface meshmonitor-chat-welcome-key-face
'((t :foreground "cyan" :weight bold))
"Face for keyboard shortcuts in the welcome buffer.")
(defvar meshmonitor-chat-welcome-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "c") #'meshmonitor-chat-channels)
(define-key map (kbd "n") #'meshmonitor-chat-nodes)
(define-key map (kbd "d") #'meshmonitor-chat-direct-messages)
(define-key map (kbd "u") #'meshmonitor-chat-unread)
(define-key map (kbd "g") #'meshmonitor-chat-refresh)
(define-key map (kbd "q") #'quit-window)
map)
"Keymap for `meshmonitor-chat-welcome-mode'.")
(define-derived-mode meshmonitor-chat-welcome-mode special-mode
"MeshMonitor"
"Major mode for the MeshMonitor welcome screen."
:group 'meshmonitor-chat
(buffer-disable-undo)
(setq truncate-lines t))
(defun meshmonitor-chat--format-uptime (seconds)
"Format SECONDS as a human-readable uptime string."
(let* ((days (floor (/ seconds 86400)))
(hours (floor (/ (mod seconds 86400) 3600)))
(mins (floor (/ (mod seconds 3600) 60))))
(cond
((> days 0) (format "%dd %dh" days hours))
((> hours 0) (format "%dh %dm" hours mins))
(t (format "%dm" mins)))))
(defun meshmonitor-chat--insert-welcome-shortcut (key label)
"Insert a formatted shortcut KEY with LABEL."
(insert " ")
(insert (propertize (format "[%s]" key)
'face 'meshmonitor-chat-welcome-key-face))
(insert (format " %-17s" label)))
(defun meshmonitor-chat--render-welcome (status-data)
"Render the welcome buffer with STATUS-DATA."
(let ((buf (get-buffer-create "*MeshMonitor*"))
(info (meshmonitor-chat--normalize-status
(cdr status-data))))
(with-current-buffer buf
(let ((inhibit-read-only t))
(erase-buffer)
(let* ((connected (alist-get 'connected info))
(version (or (alist-get 'version info) "?"))
(uptime (alist-get 'uptime info))
(node-name (alist-get 'node-name info))
(node-id (alist-get 'node-id info))
(sep (propertize (format " %s\n"
(make-string 38 ?─))
'face 'meshmonitor-chat-timestamp-face)))
;; Title.
(insert "\n")
(insert (propertize " MeshMonitor Chat\n"
'face 'meshmonitor-chat-welcome-title-face))
(insert (propertize (format " %s\n" (make-string 38 ?═))
'face 'meshmonitor-chat-timestamp-face))
(insert "\n")
;; Connection.
(insert (propertize " Connection\n"
'face 'meshmonitor-chat-welcome-heading-face))
(insert (format " Server: %s:%d\n"
meshmonitor-chat-host
meshmonitor-chat-port))
(insert (format " Version: %s\n" version))
(insert (format " Status: %s\n"
(propertize
(if connected "Connected" "Disconnected")
'face (if connected
'meshmonitor-chat-delivery-confirmed-face
'meshmonitor-chat-delivery-failed-face))))
(when node-name
(insert (format " Node: %s (%s)\n"
node-name node-id)))
(when uptime
(insert (format " Uptime: %s\n"
(meshmonitor-chat--format-uptime uptime))))
(insert "\n")
;; Statistics.
(let ((nodes (alist-get 'nodes info))
(messages (alist-get 'messages info))
(channels (alist-get 'channels info)))
(when (or nodes messages channels)
(insert (propertize " Statistics\n"
'face 'meshmonitor-chat-welcome-heading-face))
(when nodes
(insert (format " Nodes: %s\n" nodes)))
(when messages
(insert (format " Messages: %s\n" messages)))
(when channels
(insert (format " Channels: %s\n" channels)))
(insert "\n")))
;; Separator.
(insert sep)
(insert "\n")
;; Navigation.
(meshmonitor-chat--insert-welcome-shortcut "c" "Channels")
(meshmonitor-chat--insert-welcome-shortcut "n" "Nodes")
(insert "\n")
(meshmonitor-chat--insert-welcome-shortcut "d" "Direct Messages")
(meshmonitor-chat--insert-welcome-shortcut "u" "Unread")
(insert "\n")
(meshmonitor-chat--insert-welcome-shortcut "g" "Refresh")
(meshmonitor-chat--insert-welcome-shortcut "q" "Quit")
(insert "\n\n")
(insert sep)))
(meshmonitor-chat-welcome-mode)
(goto-char (point-min)))))
(defun meshmonitor-chat-refresh ()
"Refresh the MeshMonitor welcome buffer."
(interactive)
(meshmonitor-chat--fetch-status
(lambda (result)
(when result
(meshmonitor-chat--render-welcome result)))))
;;;; Entry points
(defun meshmonitor-chat--show-list-buffer (name mode fetch-fn
populate-fn)
"Show a list buffer named NAME with MODE.
FETCH-FN fetches data and calls its callback with results.
POPULATE-FN receives the data and populates the buffer."
(meshmonitor-chat--ensure-connected)
(let ((buf (get-buffer-create name)))
(with-current-buffer buf
(unless (eq major-mode mode)
(funcall mode)))
(switch-to-buffer buf)
(funcall fetch-fn
(lambda (data)
(when (buffer-live-p buf)
(with-current-buffer buf
(funcall populate-fn data)))))))
;;;###autoload
(defun meshmonitor-chat-channels ()
"Show the MeshMonitor channel list."
(interactive)
(meshmonitor-chat--show-list-buffer
"*MeshMonitor: Channels*"
'meshmonitor-chat-channel-list-mode
(lambda (cb) (meshmonitor-chat--fetch-channels
(lambda (_) (funcall cb nil))))
(lambda (_) (meshmonitor-chat--populate-channel-list))))
;;;###autoload
(defun meshmonitor-chat-direct-messages ()
"Show the MeshMonitor DM conversation list."
(interactive)
(meshmonitor-chat--show-list-buffer
"*MeshMonitor: Direct Messages*"
'meshmonitor-chat-dm-list-mode
#'meshmonitor-chat--fetch-dm-conversations
#'meshmonitor-chat--populate-dm-list))
;;;###autoload
(defun meshmonitor-chat-nodes ()
"Show all MeshMonitor nodes sorted by hop count."
(interactive)
(meshmonitor-chat--show-list-buffer
"*MeshMonitor: Nodes*"
'meshmonitor-chat-node-list-mode
(lambda (cb) (meshmonitor-chat--fetch-nodes
(lambda () (funcall cb nil))))
(lambda (_) (meshmonitor-chat--populate-node-list))))
;;;###autoload
(defun meshmonitor-chat-unread ()
"Show nodes with unread direct messages."
(interactive)
(meshmonitor-chat--show-list-buffer
"*MeshMonitor: Unread*"
'meshmonitor-chat-unread-list-mode
#'meshmonitor-chat--fetch-unread
#'meshmonitor-chat--populate-unread-list))
;;;###autoload
(defun meshmonitor-chat ()
"Open the MeshMonitor welcome screen."
(interactive)
(meshmonitor-chat--ensure-connected)
(meshmonitor-chat--fetch-status
(lambda (result)
(when result
(meshmonitor-chat--render-welcome result)
(switch-to-buffer "*MeshMonitor*")))))
(provide 'meshmonitor-chat)
;;; meshmonitor-chat.el ends here