branch: elpa/jabber
commit a5b4b920e4a81a74c7d3c2f6cc3dbe6ecc7b3469
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>

    chatstates: Add MUC typing indicators
---
 lisp/jabber-chatbuffer.el       |   7 +-
 lisp/jabber-chatstates.el       | 169 +++++++++++++---
 lisp/jabber-muc.el              |  12 +-
 tests/jabber-test-chatbuffer.el |  68 +++++++
 tests/jabber-test-chatstates.el | 435 ++++++++++++++++++++++++++++++++++++++++
 5 files changed, 657 insertions(+), 34 deletions(-)

diff --git a/lisp/jabber-chatbuffer.el b/lisp/jabber-chatbuffer.el
index e722d93c05..5983335200 100644
--- a/lisp/jabber-chatbuffer.el
+++ b/lisp/jabber-chatbuffer.el
@@ -544,6 +544,11 @@ EWOC-PP is the pretty-printer function for the message 
EWOC."
                   (buffer entries callback &optional generation))
 (declare-function jabber-chat-display-buffer-images "jabber-chat" ())
 
+(defun jabber-chat-buffer--refresh-complete ()
+  "Finish a chat buffer refresh after backlog insertion completes."
+  (jabber-chat-display-buffer-images)
+  (jabber-chat-buffer-recenter-input))
+
 (defun jabber-chat-buffer-refresh ()
   "Refresh the current chat buffer from the database without killing it.
 Clears the ewoc and reloads backlog entries in place.  Cancels any
@@ -581,7 +586,7 @@ Uses `jabber-chat-buffer-msg-count' for the number of 
messages."
               (float-time (plist-get (car (last entries)) :timestamp)))
         (jabber-chat--insert-backlog-chunked
          (current-buffer) entries
-         #'jabber-chat-display-buffer-images
+         #'jabber-chat-buffer--refresh-complete
          generation)))))
 
 (defun jabber-chat-buffer-send ()
diff --git a/lisp/jabber-chatstates.el b/lisp/jabber-chatstates.el
index 348d43c351..8b1cc075d6 100644
--- a/lisp/jabber-chatstates.el
+++ b/lisp/jabber-chatstates.el
@@ -29,6 +29,7 @@
 ;;; Code:
 
 (require 'cl-lib)
+(require 'subr-x)
 (require 'jabber-util)
 (require 'ewoc)
 (require 'jabber-core)
@@ -41,6 +42,9 @@
 (defvar jabber-chat-ewoc)               ; jabber-chatbuffer.el
 (defvar jabber-chatting-with)           ; jabber-chat.el
 
+(declare-function jabber-muc-find-buffer "jabber-muc" (group))
+(declare-function jabber-muc-nickname "jabber-muc" (group &optional jc))
+
 (defgroup jabber-chatstates nil
   "Chat state notifications."
   :group 'jabber)
@@ -65,6 +69,9 @@ Non-nil means send states, nil means don't.")
 (defvar-local jabber-chatstates--ewoc-node nil
   "Ewoc node for the typing indicator, or nil.")
 
+(defvar-local jabber-chatstates--muc-composers nil
+  "Ordered list of MUC occupants currently composing in this buffer.")
+
 (defvar-local jabber-chatstates-composing-sent nil
   "Has composing notification been sent?
 It can be sent and cancelled several times.")
@@ -85,26 +92,120 @@ It can be sent and cancelled several times.")
   "Show or remove the typing indicator ewoc node for STATE."
   (let ((inhibit-read-only t))
     (if (eq state 'composing)
-        (unless jabber-chatstates--ewoc-node
-          (setq jabber-chatstates--ewoc-node
-                (jabber-chat-ewoc-enter
-                 (list :typing
-                       (format "%s is typing..."
-                               (jabber-jid-displayname 
jabber-chatting-with))))))
-      (when jabber-chatstates--ewoc-node
-        (jabber-chat-ewoc-delete jabber-chatstates--ewoc-node)
-        (setq jabber-chatstates--ewoc-node nil)))))
+        (progn
+          (when (and jabber-chatstates--ewoc-node
+                     (not (jabber-chatstates--live-ewoc-node-p
+                           jabber-chatstates--ewoc-node)))
+            (setq jabber-chatstates--ewoc-node nil))
+          (unless jabber-chatstates--ewoc-node
+            (setq jabber-chatstates--ewoc-node
+                  (jabber-chat-ewoc-enter
+                   (list :typing
+                         (format "%s is typing..."
+                                 (jabber-jid-displayname 
jabber-chatting-with)))))))
+      (jabber-chatstates--delete-typing-node))))
+
+(defun jabber-chatstates--composing-state-p (state)
+  "Return non-nil when STATE is the XEP-0085 composing state."
+  (eq state 'composing))
+
+(defun jabber-chatstates--muc-add-composer (composers nick)
+  "Return COMPOSERS with NICK appended once, preserving order."
+  (if (member nick composers)
+      composers
+    (append composers (list nick))))
+
+(defun jabber-chatstates--muc-remove-composer (composers nick)
+  "Return COMPOSERS without NICK, preserving order."
+  (remove nick composers))
+
+(defun jabber-chatstates--muc-composers-for-state (composers nick state)
+  "Return COMPOSERS updated for NICK's chat STATE."
+  (if (jabber-chatstates--composing-state-p state)
+      (jabber-chatstates--muc-add-composer composers nick)
+    (jabber-chatstates--muc-remove-composer composers nick)))
+
+(defun jabber-chatstates--format-muc-composers (composers)
+  "Return typing text for COMPOSERS, or nil when no one is composing."
+  (pcase composers
+    ('nil nil)
+    (`(,nick) (format "%s is typing..." nick))
+    (_ (format "%s are typing..." (string-join composers ", ")))))
+
+(defun jabber-chatstates--live-ewoc-node-p (node)
+  "Return non-nil when NODE still has a live EWOC marker."
+  (and-let* ((marker (ignore-errors (ewoc-location node))))
+    (marker-buffer marker)))
+
+(defun jabber-chatstates--delete-typing-node ()
+  "Remove the current typing indicator node without changing state."
+  (when jabber-chatstates--ewoc-node
+    (when (jabber-chatstates--live-ewoc-node-p jabber-chatstates--ewoc-node)
+      (jabber-chat-ewoc-delete jabber-chatstates--ewoc-node))
+    (setq jabber-chatstates--ewoc-node nil)))
+
+(defun jabber-chatstates--muc-reinsert-typing ()
+  "Reinsert the current buffer's MUC typing indicator at the bottom."
+  (jabber-chatstates--delete-typing-node)
+  (when-let* ((message (jabber-chatstates--format-muc-composers
+                        jabber-chatstates--muc-composers)))
+    (setq jabber-chatstates--ewoc-node
+          (jabber-chat-ewoc-enter (list :typing message)))))
+
+(defun jabber-chatstates--update-muc-ewoc ()
+  "Refresh the current buffer's MUC typing indicator at the bottom."
+  (let ((inhibit-read-only t))
+    (jabber-chatstates--muc-reinsert-typing)))
+
+(defun jabber-chatstates--muc-remove-nick (nick)
+  "Remove MUC NICK from the current buffer's composer state."
+  (setq jabber-chatstates--muc-composers
+        (jabber-chatstates--muc-remove-composer
+         jabber-chatstates--muc-composers nick)))
+
+(defun jabber-chatstates--muc-clear-nick (nick)
+  "Remove MUC NICK from the current buffer's typing indicator."
+  (jabber-chatstates--muc-remove-nick nick)
+  (jabber-chatstates--update-muc-ewoc))
 
 (defun jabber-chatstates--clear-typing ()
   "Remove the typing indicator ewoc node if present."
-  (when jabber-chatstates--ewoc-node
-    (jabber-chat-ewoc-delete jabber-chatstates--ewoc-node)
-    (setq jabber-chatstates--ewoc-node nil)))
+  (jabber-chatstates--delete-typing-node))
+
+(defun jabber-chatstates--clear-send-typing ()
+  "Remove direct-chat typing state while preserving active MUC composers."
+  (unless jabber-chatstates--muc-composers
+    (jabber-chatstates--clear-typing)))
+
+(defun jabber-chatstates--message-state (xml-data)
+  "Return the chat state symbol from XML-DATA, or nil."
+  (jabber-xml-node-name
+   (cl-find jabber-chatstates-xmlns
+            (jabber-xml-node-children xml-data)
+            :key (lambda (x) (jabber-xml-get-attribute x 'xmlns))
+            :test #'string=)))
+
+(defun jabber-chatstates--muc-self-nick-p (group nick jc)
+  "Return non-nil when NICK is our nickname in GROUP on JC."
+  (and-let* ((self-nick (jabber-muc-nickname group jc)))
+    (string= nick self-nick)))
+
+(defun jabber-chatstates--handle-muc-state (jc from state)
+  "Apply incoming MUC chat STATE from FROM on JC."
+  (when-let* ((group (jabber-jid-user from))
+              (nick (jabber-jid-resource from))
+              (buffer (jabber-muc-find-buffer group)))
+    (with-current-buffer buffer
+      (unless (jabber-chatstates--muc-self-nick-p group nick jc)
+        (setq jabber-chatstates--muc-composers
+              (jabber-chatstates--muc-composers-for-state
+               jabber-chatstates--muc-composers nick state)))
+      (jabber-chatstates--update-muc-ewoc))))
 
 (add-hook 'jabber-chat-send-hooks #'jabber-chatstates-when-sending)
 (defun jabber-chatstates-when-sending (_text _id)
   "Chat-send hook: cancel state timers and attach an `active' element."
-  (jabber-chatstates--clear-typing)
+  (jabber-chatstates--clear-send-typing)
   (jabber-chatstates-stop-timer)
   (when jabber-chatstates-confirm
     (setq jabber-chatstates-composing-sent nil)
@@ -186,10 +287,9 @@ Added to `kill-buffer-hook' in chat buffers."
 
 ;;; COMMON
 
-(defun jabber-handle-incoming-message-chatstates (jc xml-data)
-  "Update the chat buffer's typing indicator from XML-DATA on JC."
-  (when-let* ((from (jabber-xml-get-attribute xml-data 'from))
-              (buffer (get-buffer (jabber-chat-get-buffer from jc))))
+(defun jabber-chatstates--handle-direct-state (jc xml-data from)
+  "Update the direct chat buffer from XML-DATA sent by FROM on JC."
+  (when-let* ((buffer (get-buffer (jabber-chat-get-buffer from jc))))
     (with-current-buffer buffer
       (cond
        ;; If we get an error message, we shouldn't report any
@@ -199,20 +299,25 @@ Added to `kill-buffer-hook' in chat buffers."
         (setq jabber-chatstates-requested nil))
 
        (t
-       (let ((state
-              (jabber-xml-node-name
-               (cl-find jabber-chatstates-xmlns
-                        (jabber-xml-node-children xml-data)
-                        :key (lambda (x) (jabber-xml-get-attribute x 'xmlns))
-                        :test #'string=))))
-         ;; Set up hooks for composition notification
-         (when (and jabber-chatstates-confirm state)
-           (setq jabber-chatstates-requested t)
-           (add-hook 'post-command-hook #'jabber-chatstates-after-change nil t)
-           (add-hook 'kill-buffer-hook #'jabber-chatstates-send-gone nil t))
-
-         (setq jabber-chatstates-last-state state)
-         (jabber-chatstates--update-ewoc state)))))))
+        (let ((state (jabber-chatstates--message-state xml-data)))
+          ;; Set up hooks for composition notification
+          (when (and jabber-chatstates-confirm state)
+            (setq jabber-chatstates-requested t)
+            (add-hook 'post-command-hook #'jabber-chatstates-after-change nil 
t)
+            (add-hook 'kill-buffer-hook #'jabber-chatstates-send-gone nil t))
+
+          (setq jabber-chatstates-last-state state)
+          (jabber-chatstates--update-ewoc state)))))))
+
+(defun jabber-handle-incoming-message-chatstates (jc xml-data)
+  "Update the chat buffer's typing indicator from XML-DATA on JC."
+  (when-let* ((from (jabber-xml-get-attribute xml-data 'from)))
+    (if (string= (jabber-xml-get-attribute xml-data 'type) "groupchat")
+        (let ((state (jabber-chatstates--message-state xml-data)))
+          (when (and (not (string= (jabber-xml-get-attribute xml-data 'type) 
"error"))
+                     (or state (jabber-xml-get-children xml-data 'body)))
+            (jabber-chatstates--handle-muc-state jc from state)))
+      (jabber-chatstates--handle-direct-state jc xml-data from))))
 
 (jabber-chain-add 'jabber-message-chain 
#'jabber-handle-incoming-message-chatstates 50)
 
@@ -220,4 +325,4 @@ Added to `kill-buffer-hook' in chat buffers."
 
 (provide 'jabber-chatstates)
 
-;;; jabber-chatstates.el ends here
\ No newline at end of file
+;;; jabber-chatstates.el ends here
diff --git a/lisp/jabber-muc.el b/lisp/jabber-muc.el
index 005ce86cc0..7db2d2b91b 100644
--- a/lisp/jabber-muc.el
+++ b/lisp/jabber-muc.el
@@ -333,6 +333,10 @@ The format is that of `mode-line-format' and 
`header-line-format'."
                   (replace-id new-body new-from muc-p buffer))
 (declare-function jabber-reactions--reaction-only-p "jabber-reactions"
                   (xml-data))
+(declare-function jabber-chatstates--delete-typing-node "jabber-chatstates" ())
+(declare-function jabber-chatstates--muc-reinsert-typing "jabber-chatstates" 
())
+(declare-function jabber-chatstates--muc-remove-nick "jabber-chatstates"
+                  (nick))
 (defvar jabber-silent-mode)             ; jabber.el
 (defvar jabber-alert-muc-function)      ; jabber-alert.el
 (defvar jabber-body-printers)           ; jabber-chat.el
@@ -1809,6 +1813,10 @@ come from the stanza."
     (jabber-muc-remove-participant group nickname)
     (when-let* ((buffer (jabber-muc-find-buffer group)))
       (with-current-buffer buffer
+        (when (and (fboundp 'jabber-chatstates--muc-remove-nick)
+                   (fboundp 'jabber-chatstates--delete-typing-node))
+          (jabber-chatstates--muc-remove-nick nickname)
+          (jabber-chatstates--delete-typing-node))
         (jabber-maybe-print-rare-time
          (jabber-chat-ewoc-enter
           (list :muc-notice
@@ -1824,7 +1832,9 @@ come from the stanza."
                           (jabber-xml-get-attribute item 'nick)))
                  (t
                   (concat name " has left the chatroom")))
-                :time (current-time))))))))
+                :time (current-time))))
+        (when (fboundp 'jabber-chatstates--muc-reinsert-typing)
+          (jabber-chatstates--muc-reinsert-typing))))))
 
 (defun jabber-muc--room-created-message ()
   "Return a string with buttons for configuring a newly created room."
diff --git a/tests/jabber-test-chatbuffer.el b/tests/jabber-test-chatbuffer.el
index 76783da4d4..4e8ab6fb31 100644
--- a/tests/jabber-test-chatbuffer.el
+++ b/tests/jabber-test-chatbuffer.el
@@ -14,6 +14,7 @@
 
 ;; jabber-chat requires this via jabber-muc
 (defvar jabber-muc-xmlns-user "http://jabber.org/protocol/muc#user";)
+(defvar jabber-group nil)
 (defvar jabber-muc-participants nil)
 
 ;;; Test helpers
@@ -480,6 +481,73 @@ again, and the ewoc created on the first call must 
survive."
         (jabber-chat-mode-setup 'jc-new #'ignore)
         (should (eq 'jc-new jabber-buffer-connection))))))
 
+;;; Group 11: Refresh completion
+
+(ert-deftest jabber-test-chatbuffer-refresh-recenters-after-chunked-insert ()
+  "Refresh recenters only from the chunked insertion completion callback."
+  (jabber-test-chatbuffer-with-ewoc
+    (let ((events nil)
+          (callback nil)
+          (insert-generation nil)
+          (jabber-buffer-connection 'fake-jc)
+          (jabber-chatting-with "[email protected]")
+          (jabber-group nil)
+          (jabber-chat-buffer-msg-count nil)
+          (jabber-backlog-number 10)
+          (jabber-chat-earliest-backlog nil)
+          (entries (list (list :timestamp (current-time) :body "hello"))))
+      (cl-letf (((symbol-function 'jabber-connection-bare-jid)
+                 (lambda (_jc) "[email protected]"))
+                ((symbol-function 'jabber-db-backlog)
+                 (lambda (&rest _) entries))
+                ((symbol-function 'jabber-muc-sender-p)
+                 (lambda (&rest _) nil))
+                ((symbol-function 'jabber-chat--insert-backlog-chunked)
+                 (lambda (_buffer _entries cb &optional generation)
+                   (setq events (append events '(insert-start))
+                         callback cb
+                         insert-generation generation)))
+                ((symbol-function 'jabber-chat-display-buffer-images)
+                 (lambda ()
+                   (setq events (append events '(images)))))
+                ((symbol-function 'jabber-chat-buffer-recenter-input)
+                 (lambda ()
+                   (setq events (append events '(recenter))))))
+        (jabber-chat-buffer-refresh)
+        (should (equal '(insert-start) events))
+        (should callback)
+        (should (= insert-generation jabber-chat--backlog-generation))
+        (funcall callback)
+        (should (equal '(insert-start images recenter) events))))))
+
+(ert-deftest jabber-test-chatbuffer-refresh-empty-skips-completion-callbacks ()
+  "Empty refresh preserves behavior by skipping insert completion callbacks."
+  (jabber-test-chatbuffer-with-ewoc
+    (let ((events nil)
+          (jabber-buffer-connection 'fake-jc)
+          (jabber-chatting-with "[email protected]")
+          (jabber-group nil)
+          (jabber-chat-buffer-msg-count nil)
+          (jabber-backlog-number 10)
+          (jabber-chat-earliest-backlog nil))
+      (cl-letf (((symbol-function 'jabber-connection-bare-jid)
+                 (lambda (_jc) "[email protected]"))
+                ((symbol-function 'jabber-db-backlog)
+                 (lambda (&rest _) nil))
+                ((symbol-function 'jabber-muc-sender-p)
+                 (lambda (&rest _) nil))
+                ((symbol-function 'jabber-chat--insert-backlog-chunked)
+                 (lambda (&rest _)
+                   (setq events (append events '(insert-start)))))
+                ((symbol-function 'jabber-chat-display-buffer-images)
+                 (lambda ()
+                   (setq events (append events '(images)))))
+                ((symbol-function 'jabber-chat-buffer-recenter-input)
+                 (lambda ()
+                   (setq events (append events '(recenter))))))
+        (jabber-chat-buffer-refresh)
+        (should-not events)))))
+
 (provide 'jabber-test-chatbuffer)
 
 ;;; jabber-test-chatbuffer.el ends here
diff --git a/tests/jabber-test-chatstates.el b/tests/jabber-test-chatstates.el
index 027d2f6e22..4146fe14a0 100644
--- a/tests/jabber-test-chatstates.el
+++ b/tests/jabber-test-chatstates.el
@@ -9,6 +9,32 @@
 (require 'ert)
 (require 'jabber-chatstates)
 
+(defun jabber-test-chatstates--message (from type state)
+  "Return a message sexp from FROM with TYPE and chat STATE."
+  `(message ((from . ,from)
+             (type . ,type))
+            (,state ((xmlns . ,jabber-chatstates-xmlns)))))
+
+(defun jabber-test-chatstates--plain-message (from type)
+  "Return a message sexp from FROM with TYPE, body, and no chat state."
+  `(message ((from . ,from)
+             (type . ,type))
+            (body nil "hello")))
+
+(defun jabber-test-chatstates--reaction-message (from type)
+  "Return a bodyless reaction message sexp from FROM with TYPE."
+  `(message ((from . ,from)
+             (type . ,type))
+            (reactions ((xmlns . "urn:xmpp:reactions:0")
+                        (id . "target-1"))
+                       (reaction nil "👍"))))
+
+(defun jabber-test-chatstates--ewoc-data ()
+  "Return the current EWOC data in display order."
+  (let (data)
+    (ewoc-map (lambda (item) (push item data)) jabber-chat-ewoc)
+    (nreverse data)))
+
 ;;; Group 1: Composing notification fix
 
 (ert-deftest jabber-test-chatstates-composing-after-first-send ()
@@ -143,6 +169,415 @@ nil after the first message, breaking subsequent 
composing detection."
       (jabber-chatstates-after-change)
       (should-not (memq jabber-chatstates-inactive-timer timer-list)))))
 
+;;; Group 3: MUC typing helpers
+
+(ert-deftest jabber-test-chatstates-format-no-muc-composers ()
+  (should-not (jabber-chatstates--format-muc-composers nil)))
+
+(ert-deftest jabber-test-chatstates-format-one-muc-composer ()
+  (should (string= (jabber-chatstates--format-muc-composers '("alice"))
+                   "alice is typing...")))
+
+(ert-deftest jabber-test-chatstates-format-multiple-muc-composers ()
+  (should (string= (jabber-chatstates--format-muc-composers '("alice" "bob"))
+                   "alice, bob are typing...")))
+
+(ert-deftest jabber-test-chatstates-muc-composers-adds-on-composing ()
+  (should (equal (jabber-chatstates--muc-composers-for-state
+                  '("alice") "bob" 'composing)
+                 '("alice" "bob"))))
+
+(ert-deftest jabber-test-chatstates-muc-composers-does-not-duplicate ()
+  (should (equal (jabber-chatstates--muc-composers-for-state
+                  '("alice" "bob") "alice" 'composing)
+                 '("alice" "bob"))))
+
+(ert-deftest jabber-test-chatstates-muc-composers-removes-on-non-composing ()
+  (should (equal (jabber-chatstates--muc-composers-for-state
+                  '("alice" "bob" "carol") "bob" 'paused)
+                 '("alice" "carol"))))
+
+(ert-deftest jabber-test-chatstates-muc-composers-removal-is-idempotent ()
+  (should (equal (jabber-chatstates--muc-composers-for-state
+                  '("alice" "bob") "carol" 'active)
+                 '("alice" "bob"))))
+
+(ert-deftest jabber-test-chatstates-direct-send-clears-typing-node ()
+  "Local direct-chat send clears the peer typing node."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (node (ewoc-enter-last jabber-chat-ewoc
+                                  '(:typing "alice is typing..."))))
+      (setq-local jabber-chatstates-confirm t)
+      (setq-local jabber-chatstates--ewoc-node node)
+      (jabber-chatstates-when-sending "hello" "id-1")
+      (should-not jabber-chatstates--ewoc-node)
+      (should-not (jabber-test-chatstates--ewoc-data)))))
+
+(ert-deftest jabber-test-chatstates-muc-send-preserves-remote-composers ()
+  "Local MUC send preserves remote composers and their typing node."
+  (let ((deleted nil))
+    (with-temp-buffer
+      (setq-local jabber-chatstates-confirm t)
+      (setq-local jabber-chatstates--muc-composers '("alice"))
+      (setq-local jabber-chatstates--ewoc-node 'node)
+      (cl-letf (((symbol-function 'jabber-chat-ewoc-delete)
+                 (lambda (node) (setq deleted node))))
+        (jabber-chatstates-when-sending "hello" "id-1")
+        (should-not deleted)
+        (should (equal jabber-chatstates--muc-composers '("alice")))
+        (should (eq jabber-chatstates--ewoc-node 'node))))))
+
+(ert-deftest jabber-test-chatstates-clear-typing-forgets-stale-node ()
+  "Clearing a stale typing node forgets it without deleting again."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (node (ewoc-enter-last jabber-chat-ewoc
+                                  '(:typing "alice is typing..."))))
+      (ewoc-delete jabber-chat-ewoc node)
+      (setq-local jabber-chatstates--ewoc-node node)
+      (should-not (jabber-chatstates--live-ewoc-node-p node))
+      (jabber-chatstates--clear-typing)
+      (should-not jabber-chatstates--ewoc-node))))
+
+(ert-deftest jabber-test-chatstates-direct-send-forgets-stale-typing-node ()
+  "Local direct-chat send ignores stale typing nodes."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (node (ewoc-enter-last jabber-chat-ewoc
+                                  '(:typing "alice is typing..."))))
+      (ewoc-delete jabber-chat-ewoc node)
+      (setq-local jabber-chatstates-confirm t)
+      (setq-local jabber-chatstates--ewoc-node node)
+      (jabber-chatstates-when-sending "hello" "id-1")
+      (should-not jabber-chatstates--ewoc-node))))
+
+(ert-deftest jabber-test-chatstates-muc-reinsert-after-stale-node ()
+  "A stale MUC typing node does not block bottom reinsertion."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (node (ewoc-enter-last jabber-chat-ewoc
+                                  '(:typing "alice is typing..."))))
+      (ewoc-delete jabber-chat-ewoc node)
+      (ewoc-enter-last jabber-chat-ewoc '(:muc-message "alice: hello"))
+      (setq-local jabber-chatstates--muc-composers '("bob"))
+      (setq-local jabber-chatstates--ewoc-node node)
+      (jabber-chatstates--update-muc-ewoc)
+      (should (equal (jabber-test-chatstates--ewoc-data)
+                     '((:muc-message "alice: hello")
+                       (:typing "bob is typing..."))))
+      (should-not (eq jabber-chatstates--ewoc-node node)))))
+
+(ert-deftest jabber-test-chatstates-muc-ewoc-update-reinserts-node ()
+  "Updating existing MUC typing text reinserts the node at bottom."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (first (ewoc-enter-last jabber-chat-ewoc '(:muc-notice "joined")))
+           (old-node (ewoc-enter-last jabber-chat-ewoc
+                                      '(:typing "alice is typing..."))))
+      (setq-local jabber-chatstates--muc-composers '("alice" "bob"))
+      (setq-local jabber-chatstates--ewoc-node old-node)
+      (jabber-chatstates--update-muc-ewoc)
+      (should (equal (ewoc-data first) '(:muc-notice "joined")))
+      (should-not (eq jabber-chatstates--ewoc-node old-node))
+      (should (equal (jabber-test-chatstates--ewoc-data)
+                     '((:muc-notice "joined")
+                       (:typing "alice, bob are typing...")))))))
+
+(ert-deftest jabber-test-chatstates-muc-clear-nick-deletes-typing-node ()
+  "Clearing the last MUC composer deletes the typing node."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (node (ewoc-enter-last jabber-chat-ewoc
+                                  '(:typing "alice is typing..."))))
+      (setq-local jabber-chatstates--muc-composers '("alice"))
+      (setq-local jabber-chatstates--ewoc-node node)
+      (jabber-chatstates--muc-clear-nick "alice")
+      (should-not jabber-chatstates--muc-composers)
+      (should-not jabber-chatstates--ewoc-node)
+      (should-not (jabber-test-chatstates--ewoc-data)))))
+
+(ert-deftest jabber-test-chatstates-muc-clear-nick-reinserts-typing-node ()
+  "Clearing one MUC composer reinserts the node for remaining composers."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (old-node (ewoc-enter-last jabber-chat-ewoc
+                                      '(:typing "alice, bob are typing..."))))
+      (setq-local jabber-chatstates--muc-composers '("alice" "bob"))
+      (setq-local jabber-chatstates--ewoc-node old-node)
+      (jabber-chatstates--muc-clear-nick "alice")
+      (should (equal jabber-chatstates--muc-composers '("bob")))
+      (should-not (eq jabber-chatstates--ewoc-node old-node))
+      (should (equal (jabber-test-chatstates--ewoc-data)
+                     '((:typing "bob is typing...")))))))
+
+(ert-deftest jabber-test-chatstates-groupchat-message-keeps-typing-at-bottom ()
+  "Plain groupchat message cleanup moves remaining typing below the message."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (old-node (ewoc-enter-last jabber-chat-ewoc
+                                      '(:typing "alice, bob are typing..."))))
+      (setq-local jabber-chatstates--muc-composers '("alice" "bob"))
+      (setq-local jabber-chatstates--ewoc-node old-node)
+      (ewoc-enter-last jabber-chat-ewoc '(:muc-message "alice: hello"))
+      (let ((muc-buffer (current-buffer)))
+        (cl-letf (((symbol-function 'jabber-muc-find-buffer)
+                   (lambda (_group) muc-buffer))
+                  ((symbol-function 'jabber-muc-nickname) #'ignore))
+          (jabber-handle-incoming-message-chatstates
+           'fake-jc
+           (jabber-test-chatstates--plain-message
+            "[email protected]/alice" "groupchat"))))
+      (should (equal jabber-chatstates--muc-composers '("bob")))
+      (should (equal (jabber-test-chatstates--ewoc-data)
+                     '((:muc-message "alice: hello")
+                       (:typing "bob is typing...")))))))
+
+(ert-deftest jabber-test-chatstates-muc-leave-cleanup-keeps-typing-at-bottom ()
+  "Leave cleanup can remove, print notice, and reinsert typing at bottom."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (old-node (ewoc-enter-last jabber-chat-ewoc
+                                      '(:typing "alice, bob are typing..."))))
+      (setq-local jabber-chatstates--muc-composers '("alice" "bob"))
+      (setq-local jabber-chatstates--ewoc-node old-node)
+      (jabber-chatstates--muc-remove-nick "alice")
+      (jabber-chatstates--delete-typing-node)
+      (ewoc-enter-last jabber-chat-ewoc '(:muc-notice "alice has left"))
+      (jabber-chatstates--muc-reinsert-typing)
+      (should (equal jabber-chatstates--muc-composers '("bob")))
+      (should (equal (jabber-test-chatstates--ewoc-data)
+                     '((:muc-notice "alice has left")
+                       (:typing "bob is typing...")))))))
+
+;;; Group 4: Incoming MUC routing
+
+(ert-deftest jabber-test-chatstates-groupchat-composing-routes-to-muc-buffer ()
+  "Incoming groupchat composing updates the room buffer by bare JID."
+  (let ((entered nil)
+        (seen-group nil))
+    (with-temp-buffer
+      (let ((muc-buffer (current-buffer)))
+        (cl-letf (((symbol-function 'jabber-muc-find-buffer)
+                   (lambda (group)
+                     (setq seen-group group)
+                     muc-buffer))
+                  ((symbol-function 'jabber-muc-nickname) #'ignore)
+                  ((symbol-function 'jabber-chat-ewoc-enter)
+                   (lambda (data)
+                     (setq entered data)
+                     'node)))
+          (jabber-handle-incoming-message-chatstates
+           'fake-jc
+           (jabber-test-chatstates--message
+            "[email protected]/alice" "groupchat" 'composing))
+          (should (string= seen-group "[email protected]"))
+          (should (equal jabber-chatstates--muc-composers '("alice")))
+          (should (equal entered '(:typing "alice is typing..."))))))))
+
+(ert-deftest jabber-test-chatstates-groupchat-active-removes-from-muc-buffer ()
+  "Incoming groupchat active removes the occupant from the room buffer."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (node (ewoc-enter-last jabber-chat-ewoc
+                                  '(:typing "alice is typing..."))))
+      (setq-local jabber-chatstates--muc-composers '("alice"))
+      (setq-local jabber-chatstates--ewoc-node node)
+      (let ((muc-buffer (current-buffer)))
+        (cl-letf (((symbol-function 'jabber-muc-find-buffer)
+                   (lambda (_group) muc-buffer))
+                  ((symbol-function 'jabber-muc-nickname) #'ignore))
+          (jabber-handle-incoming-message-chatstates
+           'fake-jc
+           (jabber-test-chatstates--message
+            "[email protected]/alice" "groupchat" 'active))
+          (should-not jabber-chatstates--muc-composers)
+          (should-not jabber-chatstates--ewoc-node)
+          (should-not (jabber-test-chatstates--ewoc-data)))))))
+
+(ert-deftest jabber-test-chatstates-groupchat-message-clears-composing ()
+  "Incoming groupchat message without chatstate clears occupant typing."
+  (with-temp-buffer
+    (let ((muc-buffer (current-buffer))
+          (jabber-chat-ewoc (ewoc-create #'ignore)))
+      (cl-letf (((symbol-function 'jabber-muc-find-buffer)
+                 (lambda (_group) muc-buffer))
+                ((symbol-function 'jabber-muc-nickname) #'ignore))
+        (jabber-handle-incoming-message-chatstates
+         'fake-jc
+         (jabber-test-chatstates--message
+          "[email protected]/alice" "groupchat" 'composing))
+        (should (equal jabber-chatstates--muc-composers '("alice")))
+        (should (equal (jabber-test-chatstates--ewoc-data)
+                       '((:typing "alice is typing..."))))
+        (jabber-handle-incoming-message-chatstates
+         'fake-jc
+         (jabber-test-chatstates--plain-message
+          "[email protected]/alice" "groupchat"))
+        (should-not jabber-chatstates--muc-composers)
+        (should-not jabber-chatstates--ewoc-node)
+        (should-not (jabber-test-chatstates--ewoc-data))))))
+
+(ert-deftest jabber-test-chatstates-groupchat-reaction-preserves-composing ()
+  "Incoming groupchat reaction-only stanza does not clear occupant typing."
+  (with-temp-buffer
+    (let ((muc-buffer (current-buffer))
+          (jabber-chat-ewoc (ewoc-create #'ignore)))
+      (cl-letf (((symbol-function 'jabber-muc-find-buffer)
+                 (lambda (_group) muc-buffer))
+                ((symbol-function 'jabber-muc-nickname) #'ignore))
+        (jabber-handle-incoming-message-chatstates
+         'fake-jc
+         (jabber-test-chatstates--message
+          "[email protected]/alice" "groupchat" 'composing))
+        (jabber-handle-incoming-message-chatstates
+         'fake-jc
+         (jabber-test-chatstates--reaction-message
+          "[email protected]/alice" "groupchat"))
+        (should (equal jabber-chatstates--muc-composers '("alice")))
+        (should (equal (jabber-test-chatstates--ewoc-data)
+                       '((:typing "alice is typing..."))))))))
+
+(ert-deftest jabber-test-chatstates-groupchat-self-nick-is-ignored ()
+  "Incoming groupchat state from our nick refreshes without mutating 
composers."
+  (let ((find-called nil)
+        (entered nil))
+    (with-temp-buffer
+      (cl-letf (((symbol-function 'jabber-muc-find-buffer)
+                 (lambda (_group)
+                   (setq find-called t)
+                   (current-buffer)))
+                ((symbol-function 'jabber-muc-nickname)
+                 (lambda (_group _jc) "alice"))
+                ((symbol-function 'jabber-chat-ewoc-enter)
+                 (lambda (data)
+                   (setq entered data)
+                   'node)))
+        (jabber-handle-incoming-message-chatstates
+         'fake-jc
+         (jabber-test-chatstates--message
+          "[email protected]/alice" "groupchat" 'composing))
+        (should find-called)
+        (should-not jabber-chatstates--muc-composers)
+        (should-not entered)))))
+
+(ert-deftest 
jabber-test-chatstates-self-groupchat-message-keeps-typing-at-bottom ()
+  "Self groupchat echo refreshes remote typing below the echoed message."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (old-node (ewoc-enter-last jabber-chat-ewoc
+                                      '(:typing "bob is typing..."))))
+      (setq-local jabber-chatstates--muc-composers '("bob"))
+      (setq-local jabber-chatstates--ewoc-node old-node)
+      (ewoc-enter-last jabber-chat-ewoc '(:muc-message "alice: hello"))
+      (let ((muc-buffer (current-buffer)))
+        (cl-letf (((symbol-function 'jabber-muc-find-buffer)
+                   (lambda (_group) muc-buffer))
+                  ((symbol-function 'jabber-muc-nickname)
+                   (lambda (_group _jc) "alice")))
+          (jabber-handle-incoming-message-chatstates
+           'fake-jc
+           (jabber-test-chatstates--plain-message
+            "[email protected]/alice" "groupchat"))))
+      (should (equal jabber-chatstates--muc-composers '("bob")))
+      (should-not (eq jabber-chatstates--ewoc-node old-node))
+      (should (equal (jabber-test-chatstates--ewoc-data)
+                     '((:muc-message "alice: hello")
+                       (:typing "bob is typing...")))))))
+
+(ert-deftest jabber-test-chatstates-direct-chat-keeps-direct-routing ()
+  "Incoming direct chat states keep using the direct chat buffer lookup."
+  (let ((direct-called nil)
+        (muc-called nil)
+        (entered nil))
+    (with-temp-buffer
+      (rename-buffer " *jabber-direct-chatstates-test*" t)
+      (setq-local jabber-chatting-with "[email protected]/resource")
+      (let ((chat-buffer (current-buffer)))
+        (cl-letf (((symbol-function 'jabber-chat-get-buffer)
+                   (lambda (from jc)
+                     (setq direct-called (list from jc))
+                     (buffer-name chat-buffer)))
+                  ((symbol-function 'jabber-muc-find-buffer)
+                   (lambda (_group)
+                     (setq muc-called t)
+                     nil))
+                  ((symbol-function 'jabber-chat-ewoc-enter)
+                   (lambda (data)
+                     (setq entered data)
+                     'node)))
+          (jabber-handle-incoming-message-chatstates
+           'fake-jc
+           (jabber-test-chatstates--message
+            "[email protected]/resource" "chat" 'composing))
+          (should (equal direct-called '("[email protected]/resource" 
fake-jc)))
+          (should-not muc-called)
+          (should (eq jabber-chatstates-last-state 'composing))
+          (should (equal entered '(:typing "[email protected] is 
typing..."))))))))
+
+(ert-deftest jabber-test-chatstates-direct-active-forgets-stale-node ()
+  "Incoming direct active clears a stale typing node without error."
+  (with-temp-buffer
+    (rename-buffer " *jabber-direct-chatstates-active-stale-test*" t)
+    (let* ((chat-buffer (current-buffer))
+           (jabber-chat-ewoc (ewoc-create #'ignore))
+           (node (ewoc-enter-last jabber-chat-ewoc
+                                  '(:typing "alice is typing..."))))
+      (ewoc-delete jabber-chat-ewoc node)
+      (setq-local jabber-chatstates--ewoc-node node)
+      (cl-letf (((symbol-function 'jabber-chat-get-buffer)
+                 (lambda (_from _jc) (buffer-name chat-buffer))))
+        (jabber-handle-incoming-message-chatstates
+         'fake-jc
+         (jabber-test-chatstates--message
+          "[email protected]/resource" "chat" 'active))
+        (should-not jabber-chatstates--ewoc-node)
+        (should-not (jabber-test-chatstates--ewoc-data))))))
+
+(ert-deftest jabber-test-chatstates-direct-composing-replaces-stale-node ()
+  "Incoming direct composing inserts a fresh node after stale node cleanup."
+  (with-temp-buffer
+    (rename-buffer " *jabber-direct-chatstates-composing-stale-test*" t)
+    (let* ((chat-buffer (current-buffer))
+           (jabber-chat-ewoc (ewoc-create #'ignore))
+           (node (ewoc-enter-last jabber-chat-ewoc
+                                  '(:typing "alice is typing..."))))
+      (ewoc-delete jabber-chat-ewoc node)
+      (setq-local jabber-chatting-with "[email protected]/resource")
+      (setq-local jabber-chatstates--ewoc-node node)
+      (cl-letf (((symbol-function 'jabber-chat-get-buffer)
+                 (lambda (_from _jc) (buffer-name chat-buffer))))
+        (jabber-handle-incoming-message-chatstates
+         'fake-jc
+         (jabber-test-chatstates--message
+          "[email protected]/resource" "chat" 'composing))
+        (should jabber-chatstates--ewoc-node)
+        (should-not (eq jabber-chatstates--ewoc-node node))
+        (should (equal (jabber-test-chatstates--ewoc-data)
+                       '((:typing "[email protected] is typing..."))))))))
+
+(ert-deftest jabber-test-chatstates-direct-composing-keeps-live-node ()
+  "Repeated incoming direct composing preserves the live typing node."
+  (with-temp-buffer
+    (rename-buffer " *jabber-direct-chatstates-composing-live-test*" t)
+    (let ((chat-buffer (current-buffer))
+          (jabber-chat-ewoc (ewoc-create #'ignore)))
+      (setq-local jabber-chatting-with "[email protected]/resource")
+      (cl-letf (((symbol-function 'jabber-chat-get-buffer)
+                 (lambda (_from _jc) (buffer-name chat-buffer))))
+        (jabber-handle-incoming-message-chatstates
+         'fake-jc
+         (jabber-test-chatstates--message
+          "[email protected]/resource" "chat" 'composing))
+        (let ((node jabber-chatstates--ewoc-node))
+          (jabber-handle-incoming-message-chatstates
+           'fake-jc
+           (jabber-test-chatstates--message
+            "[email protected]/resource" "chat" 'composing))
+          (should (eq jabber-chatstates--ewoc-node node))
+          (should (equal (jabber-test-chatstates--ewoc-data)
+                         '((:typing "[email protected] is typing...")))))))))
+
 (provide 'jabber-test-chatstates)
 
 ;;; jabber-test-chatstates.el ends here


Reply via email to