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

    reactions: Add message reactions support
---
 Makefile                       |   1 +
 doap.xml                       |   7 +
 lisp/jabber-chat.el            |  39 ++++-
 lisp/jabber-chatbuffer.el      |  26 +++-
 lisp/jabber-muc.el             |  38 ++---
 lisp/jabber-reactions.el       | 338 +++++++++++++++++++++++++++++++++++++++++
 lisp/jabber-sm.el              |  14 +-
 lisp/jabber.el                 |   1 +
 tests/jabber-test-reactions.el | 212 ++++++++++++++++++++++++++
 tests/jabber-test-sm.el        |  24 +++
 10 files changed, 666 insertions(+), 34 deletions(-)

diff --git a/Makefile b/Makefile
index 5c52407ccd..3a1b048e2f 100644
--- a/Makefile
+++ b/Makefile
@@ -44,6 +44,7 @@ TESTS ?= tests/jabber-test-activity.el \
          tests/jabber-test-openpgp-legacy.el \
          tests/jabber-test-presence.el \
          tests/jabber-test-pubsub.el \
+         tests/jabber-test-reactions.el \
          tests/jabber-test-receipts.el \
          tests/jabber-test-roster.el \
          tests/jabber-test-sm.el \
diff --git a/doap.xml b/doap.xml
index 90123bbe63..4a2468d02b 100644
--- a/doap.xml
+++ b/doap.xml
@@ -537,6 +537,13 @@
         <xmpp:since>0.10.0</xmpp:since>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0444.html"/>
+        <xmpp:status>partial</xmpp:status>
+        <xmpp:note>Message Reactions display is in-memory only, with no 
persistence, no restriction discovery, and provisional MUC actor 
identity.</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0454.html"/>
diff --git a/lisp/jabber-chat.el b/lisp/jabber-chat.el
index 7ac2823ab8..db95e0fe19 100644
--- a/lisp/jabber-chat.el
+++ b/lisp/jabber-chat.el
@@ -29,8 +29,10 @@
 (require 'jabber-core)
 (require 'jabber-alert)
 (require 'jabber-chatbuffer)
+(require 'jabber-reactions)
 (require 'ewoc)
 (require 'goto-addr)
+(require 'subr-x)
 (require 'url-parse)
 (require 'url-queue)
 (require 'hex-util)
@@ -368,10 +370,7 @@ JC is the Jabber connection."
       ;; Catch up missed 1:1 messages from MAM.
       (jabber-mam-chat-opened jc (jabber-jid-user chat-with))
 
-      (when-let* ((win (get-buffer-window (current-buffer))))
-        (with-selected-window win
-          (goto-char jabber-point-insert)
-          (recenter -1))))
+      (jabber-chat-buffer-recenter-input))
 
     ;; Make sure the connection variable is up to date.
     (setq jabber-buffer-connection jc)
@@ -880,6 +879,30 @@ or X for undelivered."
       (when indicator
         (insert indicator)))))
 
+(defun jabber-chat--reaction-sender ()
+  "Return the local sender key for reaction display."
+  (jabber-reactions--local-sender))
+
+(defun jabber-chat--reaction-entry-string (entry)
+  "Return propertized reaction summary text for ENTRY."
+  (propertize (format "%d%s"
+                      (plist-get entry :count)
+                      (plist-get entry :reaction))
+              'face (if (plist-get entry :chosen)
+                        'jabber-reaction-chosen
+                      'jabber-reaction)))
+
+(defun jabber-chat--insert-reactions (msg)
+  "Insert compact reaction summaries for MSG."
+  (unless (plist-get msg :retracted)
+    (when-let* ((entries (jabber-reactions--display-entries
+                          (plist-get msg :reactions)
+                          (jabber-chat--reaction-sender))))
+      (insert "\n"
+              (string-join
+               (mapcar #'jabber-chat--reaction-entry-string entries)
+               "  ")))))
+
 (defun jabber-chat-pp--local (data)
   "Render a locally sent message from DATA."
   (let* ((msg (cadr data))
@@ -892,6 +915,7 @@ or X for undelivered."
     (when (plist-get msg :edited)
       (insert (propertize " (edited)" 'face 'shadow)))
     (jabber-chat--insert-status-indicator msg)
+    (jabber-chat--insert-reactions msg)
     (insert "\n")))
 
 (defun jabber-chat-pp--foreign (data)
@@ -905,6 +929,7 @@ or X for undelivered."
       (run-hook-with-args 'jabber-chat-printers msg :foreign :insert))
     (when (plist-get msg :edited)
       (insert (propertize " (edited)" 'face 'shadow)))
+    (jabber-chat--insert-reactions msg)
     (insert "\n")))
 
 (defun jabber-chat--insert-tombstone (msg)
@@ -933,7 +958,8 @@ or X for undelivered."
               (append jabber-muc-printers jabber-chat-printers)))
       (when (plist-get msg :edited)
         (insert (propertize " (edited)" 'face 'shadow)))
-      (jabber-chat--insert-status-indicator msg))
+      (jabber-chat--insert-status-indicator msg)
+      (jabber-chat--insert-reactions msg))
     (insert "\n")))
 
 (defun jabber-chat-pp--muc-foreign (data)
@@ -948,7 +974,8 @@ or X for undelivered."
         (mapc (lambda (f) (funcall f msg :muc-foreign :insert))
               (append jabber-muc-printers jabber-chat-printers)))
       (when (plist-get msg :edited)
-        (insert (propertize " (edited)" 'face 'shadow))))
+        (insert (propertize " (edited)" 'face 'shadow)))
+      (jabber-chat--insert-reactions msg))
     (insert "\n")))
 
 (defun jabber-chat-pp--error (data)
diff --git a/lisp/jabber-chatbuffer.el b/lisp/jabber-chatbuffer.el
index e49323ec65..e722d93c05 100644
--- a/lisp/jabber-chatbuffer.el
+++ b/lisp/jabber-chatbuffer.el
@@ -34,6 +34,23 @@
 (defvar jabber-point-insert nil
   "Position where the message being composed starts.")
 
+(defun jabber-chat-buffer--recenter-input-p (window)
+  "Return non-nil when WINDOW should recenter to the input area."
+  (and (window-live-p window)
+       (with-current-buffer (window-buffer window)
+         (and (markerp jabber-point-insert)
+              (eq (marker-buffer jabber-point-insert)
+                  (window-buffer window))
+              (>= (window-point window) jabber-point-insert)))))
+
+(defun jabber-chat-buffer-recenter-input ()
+  "Recenter the visible current buffer window to the input area."
+  (when-let* ((window (get-buffer-window (current-buffer))))
+    (when (jabber-chat-buffer--recenter-input-p window)
+      (with-selected-window window
+        (goto-char jabber-point-insert)
+        (recenter -1)))))
+
 (defvar jabber-send-function nil
   "Function for sending a message from a chat buffer.")
 
@@ -83,6 +100,7 @@ previous sequence detect the mismatch and stop.")
 (declare-function jabber-chat-reply "jabber-message-reply" ())
 (declare-function jabber-correct-last-message "jabber-message-correct" ())
 (declare-function jabber-chat-cancel-reply "jabber-message-reply" ())
+(declare-function jabber-reactions-react-at-point-or-insert "jabber-reactions" 
())
 
 ;;
 
@@ -415,7 +433,8 @@ MAM sync in this buffer.  Set via the operations menu.")
   "C-c C-e" #'jabber-chat-encryption-menu
   "C-c C-m" #'jabber-muc-menu
   "C-c C-r" #'jabber-chat-reply
-  "C-c C-k" #'jabber-chat-cancel-reply)
+  "C-c C-k" #'jabber-chat-cancel-reply
+  "!" #'jabber-reactions-react-at-point-or-insert)
 
 (define-derived-mode jabber-chat-mode fundamental-mode "jabber-chat"
   "Major mode for Jabber chat buffers.
@@ -622,10 +641,7 @@ at the bottom of the window."
            (setq jabber-chat-encryption saved))
          (jabber-chat-encryption--update-header)
          (force-mode-line-update)
-         (when-let* ((win (get-buffer-window buffer)))
-           (with-selected-window win
-             (goto-char jabber-point-insert)
-             (recenter -1)))))
+         (jabber-chat-buffer-recenter-input)))
      (seq-filter
       (lambda (buffer)
         (with-current-buffer buffer
diff --git a/lisp/jabber-muc.el b/lisp/jabber-muc.el
index 915b6821d0..005ce86cc0 100644
--- a/lisp/jabber-muc.el
+++ b/lisp/jabber-muc.el
@@ -331,6 +331,8 @@ The format is that of `mode-line-format' and 
`header-line-format'."
                   (xml-data))
 (declare-function jabber-message-correct--apply "jabber-message-correct"
                   (replace-id new-body new-from muc-p buffer))
+(declare-function jabber-reactions--reaction-only-p "jabber-reactions"
+                  (xml-data))
 (defvar jabber-silent-mode)             ; jabber.el
 (defvar jabber-alert-muc-function)      ; jabber-alert.el
 (defvar jabber-body-printers)           ; jabber-chat.el
@@ -408,10 +410,7 @@ JC is the Jabber connection."
              #'jabber-chat-display-buffer-images
              jabber-chat--backlog-generation))))
 
-      (when-let* ((win (get-buffer-window (current-buffer))))
-        (with-selected-window win
-          (goto-char jabber-point-insert)
-          (recenter -1))))
+      (jabber-chat-buffer-recenter-input))
 
     ;; Make sure the connection variable is up to date.
     (setq jabber-buffer-connection jc)
@@ -1725,21 +1724,22 @@ messages."
 
 JC is the Jabber connection."
   (when (jabber-muc-message-p xml-data)
-    (let* ((xml-data (jabber-chat--decrypt-if-needed jc xml-data))
-           (from (jabber-xml-get-attribute xml-data 'from))
-           (group (jabber-jid-user from))
-           (nick (jabber-jid-resource from))
-           (type (jabber-muc--classify-message jc group nick xml-data))
-           (msg-plist (jabber-chat--msg-plist-from-stanza xml-data))
-           (replace-id (jabber-message-correct--replace-id xml-data)))
-      (if (and replace-id (not (jabber-muc--history-message-p xml-data)))
-          (jabber-message-correct--apply
-           replace-id
-           (plist-get msg-plist :body)
-           from
-           t
-           (jabber-muc-find-buffer group))
-        (jabber-muc--display-message jc xml-data group nick type msg-plist)))))
+    (let ((xml-data (jabber-chat--decrypt-if-needed jc xml-data)))
+      (unless (jabber-reactions--reaction-only-p xml-data)
+        (let* ((from (jabber-xml-get-attribute xml-data 'from))
+               (group (jabber-jid-user from))
+               (nick (jabber-jid-resource from))
+               (type (jabber-muc--classify-message jc group nick xml-data))
+               (msg-plist (jabber-chat--msg-plist-from-stanza xml-data))
+               (replace-id (jabber-message-correct--replace-id xml-data)))
+          (if (and replace-id (not (jabber-muc--history-message-p xml-data)))
+              (jabber-message-correct--apply
+               replace-id
+               (plist-get msg-plist :body)
+               from
+               t
+               (jabber-muc-find-buffer group))
+            (jabber-muc--display-message jc xml-data group nick type 
msg-plist)))))))
 
 (defun jabber-muc--format-actor-reason (actor reason)
   "Format optional \" by ACTOR\" / \" - \\='REASON\\='\" suffix."
diff --git a/lisp/jabber-reactions.el b/lisp/jabber-reactions.el
new file mode 100644
index 0000000000..efc3e7bbf6
--- /dev/null
+++ b/lisp/jabber-reactions.el
@@ -0,0 +1,338 @@
+;;; jabber-reactions.el --- XEP-0444 Message Reactions  -*- lexical-binding: 
t; -*-
+
+;; Copyright (C) 2026  Thanos Apollo
+
+;; Maintainer: Thanos Apollo <[email protected]>
+
+;; This file is a part of jabber.el.
+
+;; 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 2 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, write to the Free Software
+;; Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+;;; Commentary:
+
+;; XEP-0444 Message Reactions data helpers and outgoing stanza support.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ewoc)
+(require 'subr-x)
+(require 'jabber-disco)
+(require 'jabber-util)
+
+(defconst jabber-reactions-xmlns "urn:xmpp:reactions:0"
+  "XEP-0444 Message Reactions namespace.")
+
+(defconst jabber-reactions-hints-xmlns "urn:xmpp:hints"
+  "XEP-0334 Message Processing Hints namespace.")
+
+(defcustom jabber-reactions-default-choices
+  '("👍" "❤️" "😂" "🎉" "😮" "😢" "🙏")
+  "Reaction strings offered by the outgoing reaction picker.
+This list is only a default set of picker choices.  Incoming reactions
+are not filtered against it."
+  :type '(repeat string)
+  :group 'jabber)
+
+(defface jabber-reaction
+  '((t :inherit shadow))
+  "Face for message reaction summaries."
+  :group 'jabber)
+
+(defface jabber-reaction-chosen
+  '((t :inherit success))
+  "Face for message reactions selected by the local user."
+  :group 'jabber)
+
+(defvar jabber-buffer-connection)
+(defvar jabber-chat-ewoc)
+(defvar jabber-chatting-with)
+(defvar jabber-group)
+(defvar jabber-point-insert)
+
+(declare-function jabber-chat--unwrap-carbon "jabber-chat" (jc xml-data))
+(declare-function jabber-chat-ewoc-find-by-id "jabber-chatbuffer" (stanza-id))
+(declare-function jabber-chat-ewoc-invalidate "jabber-chatbuffer" (node))
+(declare-function jabber-chat-find-buffer "jabber-chat" (chat-with))
+(declare-function jabber-muc-find-buffer "jabber-muc" (group))
+(declare-function jabber-muc-nickname "jabber-muc" (group &optional jc))
+
+;;; Pure helpers
+
+(defun jabber-reactions--deduplicate (reactions)
+  "Return REACTIONS without duplicates, nils, or empty strings.
+The first occurrence of each non-empty string is kept."
+  (let ((seen nil)
+        (result nil))
+    (dolist (reaction reactions (nreverse result))
+      (when (and (stringp reaction)
+                 (not (string-empty-p reaction))
+                 (not (member reaction seen)))
+        (push reaction seen)
+        (push reaction result)))))
+
+(defun jabber-reactions--target-id (msg muc-p)
+  "Return the XEP-0444 target message ID from MSG.
+Use :server-id for MUC messages when MUC-P is non-nil, and :id for
+1:1 chat messages.  Return nil when the needed ID is unavailable."
+  (plist-get msg (if muc-p :server-id :id)))
+
+(defun jabber-reactions--message-attributes (to type outgoing-id)
+  "Return message attributes for reaction stanza TO TYPE OUTGOING-ID."
+  (append `((to . ,to) (type . ,type))
+          (and outgoing-id `((id . ,outgoing-id)))))
+
+(defun jabber-reactions--build-stanza (to type target-id reactions outgoing-id)
+  "Build an outgoing XEP-0444 reaction stanza.
+TO is the destination JID.  TYPE is the message type string.  TARGET-ID
+is the stanza ID of the message being reacted to.  REACTIONS is a list
+of selected reaction strings.  OUTGOING-ID is the ID for the outgoing
+message stanza, or nil to omit it."
+  `(message ,(jabber-reactions--message-attributes to type outgoing-id)
+            (reactions ((xmlns . ,jabber-reactions-xmlns)
+                        (id . ,target-id))
+                       ,@(mapcar (lambda (reaction)
+                                   `(reaction () ,reaction))
+                                 (jabber-reactions--deduplicate reactions)))
+            (store ((xmlns . ,jabber-reactions-hints-xmlns)))))
+
+(defun jabber-reactions--display-entry (reaction senders chosen-sender)
+  "Build a display entry for REACTION by SENDERS.
+Non-nil CHOSEN-SENDER marks entries selected by that sender."
+  (list :reaction reaction
+        :count (length senders)
+        :chosen (and chosen-sender (member chosen-sender senders) t)
+        :senders senders))
+
+(defun jabber-reactions--display-entries (sender-state &optional chosen-sender)
+  "Aggregate SENDER-STATE into reaction display entries.
+SENDER-STATE is an alist of (SENDER . REACTIONS), where REACTIONS is a
+list of reaction strings currently selected by SENDER.  CHOSEN-SENDER is
+used to mark locally selected reactions.  Return a list of plists with
+:reaction, :count, :chosen, and :senders keys, preserving the first-seen
+reaction order."
+  (let ((order nil)
+        (senders-by-reaction nil))
+    (dolist (entry sender-state)
+      (let ((sender (car entry)))
+        (dolist (reaction (jabber-reactions--deduplicate (cdr entry)))
+          (unless (assoc reaction senders-by-reaction)
+            (push reaction order))
+          (cl-pushnew sender (alist-get reaction senders-by-reaction nil nil 
#'equal)
+                      :test #'equal))))
+    (mapcar (lambda (reaction)
+              (jabber-reactions--display-entry
+               reaction
+               (nreverse (alist-get reaction senders-by-reaction nil nil 
#'equal))
+               chosen-sender))
+            (nreverse order))))
+
+(defun jabber-reactions--sender-reactions (sender msg)
+  "Return SENDER's current reactions from MSG."
+  (copy-sequence (alist-get sender (plist-get msg :reactions) nil nil 
#'equal)))
+
+(defun jabber-reactions--toggle-reaction (reaction reactions)
+  "Return REACTIONS with REACTION toggled.
+Existing REACTION is removed.  Missing REACTION is appended after the
+current deduplicated reaction list."
+  (let ((deduplicated (jabber-reactions--deduplicate reactions)))
+    (if (member reaction deduplicated)
+        (remove reaction deduplicated)
+      (append deduplicated (list reaction)))))
+
+(defun jabber-reactions--replace-sender-reactions (sender sender-reactions 
reactions)
+  "Return REACTIONS with SENDER replaced by SENDER-REACTIONS.
+When SENDER-REACTIONS is nil, remove SENDER from REACTIONS."
+  (let ((without-sender (cl-remove sender reactions :key #'car :test #'equal))
+        (deduplicated (jabber-reactions--deduplicate sender-reactions)))
+    (if deduplicated
+        (cons (cons sender deduplicated) without-sender)
+      without-sender)))
+
+(defun jabber-reactions--parse-element (reactions)
+  "Return (TARGET-ID REACTIONS) parsed from REACTIONS element.
+REACTIONS is a XEP-0444 `<reactions>' XML node.  Empty strings and
+repeated reaction strings are ignored.  The reaction list may be nil for
+an empty update."
+  (when-let* ((target-id (jabber-xml-get-attribute reactions 'id)))
+    (list target-id
+          (jabber-reactions--deduplicate
+           (mapcar (lambda (reaction)
+                     (car (jabber-xml-node-children reaction)))
+                   (jabber-xml-get-children reactions 'reaction))))))
+
+(defun jabber-reactions--reaction-only-p (xml-data)
+  "Return non-nil when XML-DATA is only a reaction update stanza.
+A reaction-only stanza has a XEP-0444 `<reactions>' payload and no
+`<body>', `<subject>', or `<error>' child."
+  (and (jabber-xml-child-with-xmlns xml-data jabber-reactions-xmlns)
+       (not (jabber-xml-get-children xml-data 'body))
+       (not (jabber-xml-get-children xml-data 'subject))
+       (not (jabber-xml-get-children xml-data 'error))))
+
+(defun jabber-reactions--incoming-sender (from type)
+  "Return the reaction sender key for incoming FROM and message TYPE."
+  (when from
+    (if (string= type "groupchat")
+        from
+      (jabber-jid-user from))))
+
+(defun jabber-reactions--buffer-for-stanza (from type)
+  "Return the displayed chat buffer for incoming FROM and message TYPE."
+  (when from
+    (if (string= type "groupchat")
+        (jabber-muc-find-buffer (jabber-jid-user from))
+      (jabber-chat-find-buffer (jabber-jid-user from)))))
+
+(defun jabber-reactions--unwrap-stanza (jc xml-data)
+  "Return (MESSAGE . BUFFER) for reaction-bearing XML-DATA on JC."
+  (if (or (string= (or (jabber-xml-get-attribute xml-data 'type) "") 
"groupchat")
+          (not (fboundp 'jabber-chat--unwrap-carbon)))
+      (cons xml-data nil)
+    (jabber-chat--unwrap-carbon jc xml-data)))
+
+(defun jabber-reactions--update-message (msg sender sender-reactions)
+  "Return MSG with SENDER's reactions replaced by SENDER-REACTIONS."
+  (plist-put (copy-sequence msg)
+             :reactions
+             (jabber-reactions--replace-sender-reactions
+              sender sender-reactions (plist-get msg :reactions))))
+
+(defun jabber-reactions--local-sender ()
+  "Return the local sender key for reaction state in the current buffer."
+  (when jabber-buffer-connection
+    (if (bound-and-true-p jabber-group)
+        (when-let* ((nick (jabber-muc-nickname jabber-group 
jabber-buffer-connection)))
+          (concat jabber-group "/" nick))
+      (jabber-connection-bare-jid jabber-buffer-connection))))
+
+;;; Command support
+
+(defun jabber-reactions--composition-point-p ()
+  "Return non-nil when point is in the chat composition area."
+  (and (boundp 'jabber-point-insert)
+       (markerp jabber-point-insert)
+       (>= (point) jabber-point-insert)))
+
+(defun jabber-reactions--message-id ()
+  "Return a generated message ID for outgoing reaction stanzas."
+  (format "emacs-reaction-%.6f" (float-time)))
+
+(defun jabber-reactions--reactable-node-at-point ()
+  "Return reaction context for the EWOC node at point, or nil.
+The returned list has the form (NODE MSG TARGET-ID)."
+  (unless (jabber-reactions--composition-point-p)
+    (when-let* ((node (and (bound-and-true-p jabber-chat-ewoc)
+                           (ewoc-locate jabber-chat-ewoc (point))))
+                (data (ewoc-data node))
+                ((listp data))
+                (msg (cadr data))
+                ((listp msg))
+                (target-id (jabber-reactions--target-id
+                            msg (bound-and-true-p jabber-group))))
+      (list node msg target-id))))
+
+(defun jabber-reactions--insert-literal-bang ()
+  "Insert a literal exclamation mark in the composition area."
+  (when (and (boundp 'jabber-point-insert)
+             (markerp jabber-point-insert)
+             (< (point) jabber-point-insert))
+    (goto-char jabber-point-insert))
+  (insert "!"))
+
+(defun jabber-reactions--chat-target ()
+  "Return reaction stanza target information for the current buffer.
+The returned list has the form (TO TYPE), or nil outside chat and MUC
+buffers with a known destination."
+  (cond
+   ((bound-and-true-p jabber-group)
+    (list jabber-group "groupchat"))
+   ((bound-and-true-p jabber-chatting-with)
+    (list jabber-chatting-with "chat"))))
+
+(defun jabber-reactions--optimistic-update (node msg sender sender-reactions)
+  "Update NODE's MSG reaction state for SENDER to SENDER-REACTIONS."
+  (let* ((reactions (plist-get msg :reactions))
+         (updated-msg (plist-put
+                       (copy-sequence msg)
+                       :reactions
+                       (jabber-reactions--replace-sender-reactions
+                        sender sender-reactions reactions))))
+    (setcar (cdr (ewoc-data node)) updated-msg)
+    (jabber-chat-ewoc-invalidate node)))
+
+;;;###autoload
+(defun jabber-reactions-react-at-point-or-insert ()
+  "React to the message at point, or insert a literal exclamation mark."
+  (interactive)
+  (pcase-let ((`(,node ,msg ,target-id)
+               (jabber-reactions--reactable-node-at-point))
+              (`(,to ,type) (jabber-reactions--chat-target))
+              (sender (jabber-reactions--local-sender)))
+    (if (and node msg target-id to type sender)
+        (let ((reaction (completing-read
+                         "Reaction: "
+                         jabber-reactions-default-choices
+                         nil nil)))
+          (when (string-empty-p reaction)
+            (user-error "Reaction cannot be empty"))
+          (let ((sender-reactions (jabber-reactions--toggle-reaction
+                                   reaction
+                                   (jabber-reactions--sender-reactions sender 
msg))))
+            (jabber-send-sexp
+             jabber-buffer-connection
+             (jabber-reactions--build-stanza to type target-id
+                                             sender-reactions
+                                             (jabber-reactions--message-id)))
+            (unless (bound-and-true-p jabber-group)
+              (jabber-reactions--optimistic-update node msg sender 
sender-reactions))))
+      (jabber-reactions--insert-literal-bang))))
+
+;;; Incoming updates
+
+(defun jabber-reactions--apply-incoming-update (node sender sender-reactions)
+  "Apply SENDER's SENDER-REACTIONS to the message stored in NODE."
+  (when-let* ((data (ewoc-data node))
+              ((listp data))
+              (msg (cadr data))
+              ((listp msg)))
+    (setcar (cdr data)
+            (jabber-reactions--update-message msg sender sender-reactions))
+    (jabber-chat-ewoc-invalidate node)))
+
+(defun jabber-reactions--handle-message (jc xml-data)
+  "Handle incoming XEP-0444 reaction updates in XML-DATA on JC."
+  (pcase-let* ((`(,message . ,carbon-buffer)
+                (jabber-reactions--unwrap-stanza jc xml-data)))
+    (when-let* ((reactions (jabber-xml-child-with-xmlns
+                            message jabber-reactions-xmlns))
+                (parsed (jabber-reactions--parse-element reactions))
+                (from (jabber-xml-get-attribute message 'from))
+                (type (jabber-xml-get-attribute message 'type))
+                (sender (jabber-reactions--incoming-sender from type))
+                (buffer (or carbon-buffer
+                            (jabber-reactions--buffer-for-stanza from type))))
+      (with-current-buffer buffer
+        (when-let* ((node (jabber-chat-ewoc-find-by-id (car parsed))))
+          (jabber-reactions--apply-incoming-update node sender (cadr 
parsed)))))))
+
+(jabber-chain-add 'jabber-message-chain #'jabber-reactions--handle-message -5)
+
+;;; Disco
+
+(jabber-disco-advertise-feature jabber-reactions-xmlns)
+
+(provide 'jabber-reactions)
+;;; jabber-reactions.el ends here
diff --git a/lisp/jabber-sm.el b/lisp/jabber-sm.el
index 5958d59763..cbc1e2b28a 100644
--- a/lisp/jabber-sm.el
+++ b/lisp/jabber-sm.el
@@ -319,14 +319,20 @@ Return updated STATE-DATA."
          (last-acked (plist-get state-data :sm-last-acked))
          (queue (plist-get state-data :sm-outbound-queue))
          (pruned (jabber-sm--prune-queue queue h)))
-    (when (not (jabber-sm--counter-<= h sent))
+    (cond
+     ((jabber-sm--counter-<= h last-acked)
+      state-data)
+     ((not (jabber-sm--counter-<= h sent))
       (message "SM warning: server acked more stanzas than sent (h=%d, 
sent=%d)"
-               h sent))
-    (when (not (jabber-sm--counter-<= h last-acked))
+               h sent)
+      (setq state-data (plist-put state-data :sm-outbound-count h))
       (setq state-data (plist-put state-data :sm-last-acked h))
       (setq state-data (plist-put state-data :sm-outbound-queue pruned))
       (plist-put state-data :sm-stall-since nil))
-    state-data))
+     (t
+      (setq state-data (plist-put state-data :sm-last-acked h))
+      (setq state-data (plist-put state-data :sm-outbound-queue pruned))
+      (plist-put state-data :sm-stall-since nil)))))
 
 ;;; Enable/resume XML generation
 
diff --git a/lisp/jabber.el b/lisp/jabber.el
index f19f231ee9..b888ec4f11 100644
--- a/lisp/jabber.el
+++ b/lisp/jabber.el
@@ -172,6 +172,7 @@ One disabled account with a non-standard port:
 (require 'jabber-moderation)
 (require 'jabber-message-correct)
 (require 'jabber-message-reply)
+(require 'jabber-reactions)
 (require 'jabber-styling)
 (require 'jabber-browse)
 (require 'jabber-compose)
diff --git a/tests/jabber-test-reactions.el b/tests/jabber-test-reactions.el
new file mode 100644
index 0000000000..ee9c7765bf
--- /dev/null
+++ b/tests/jabber-test-reactions.el
@@ -0,0 +1,212 @@
+;;; jabber-test-reactions.el --- Tests for jabber-reactions  -*- 
lexical-binding: t; -*-
+
+;;; Commentary:
+
+;; XEP-0444 Message Reactions helper tests.
+
+;;; Code:
+
+(require 'ert)
+(require 'jabber-reactions)
+
+;;; Group 1: Pure reaction state helpers
+
+(ert-deftest jabber-test-reactions-deduplicate-filters-empty-and-duplicates ()
+  "Deduplication keeps first non-empty string occurrences only."
+  (should (equal (jabber-reactions--deduplicate
+                  '(nil "" "👍" "" "🎉" "👍" nil "🎉" "❤️"))
+                 '("👍" "🎉" "❤️"))))
+
+(ert-deftest jabber-test-reactions-target-id-selects-chat-id ()
+  "Direct chat reactions target the message :id."
+  (should (equal (jabber-reactions--target-id '(:id "client-1"
+                                                    :server-id "server-1")
+                                              nil)
+                 "client-1")))
+
+(ert-deftest jabber-test-reactions-target-id-selects-muc-server-id ()
+  "MUC reactions target the message :server-id."
+  (should (equal (jabber-reactions--target-id '(:id "client-1"
+                                                    :server-id "server-1")
+                                              t)
+                 "server-1")))
+
+(ert-deftest jabber-test-reactions-display-entries-aggregate-counts-and-chosen 
()
+  "Display entries aggregate reactions and mark the chosen sender."
+  (let ((entries (jabber-reactions--display-entries
+                  '(("[email protected]" . ("👍" "🎉" "👍"))
+                    ("[email protected]" . ("👍" "❤️"))
+                    ("[email protected]" . ("🎉")))
+                  "[email protected]")))
+    (should (equal (mapcar (lambda (entry) (plist-get entry :reaction)) 
entries)
+                   '("👍" "🎉" "❤️")))
+    (let ((thumbs (cl-find "👍" entries
+                           :key (lambda (entry) (plist-get entry :reaction))
+                           :test #'equal))
+          (party (cl-find "🎉" entries
+                          :key (lambda (entry) (plist-get entry :reaction))
+                          :test #'equal))
+          (heart (cl-find "❤️" entries
+                          :key (lambda (entry) (plist-get entry :reaction))
+                          :test #'equal)))
+      (should (= (plist-get thumbs :count) 2))
+      (should (plist-get thumbs :chosen))
+      (should (= (plist-get party :count) 2))
+      (should (plist-get party :chosen))
+      (should (= (plist-get heart :count) 1))
+      (should-not (plist-get heart :chosen)))))
+
+(ert-deftest 
jabber-test-reactions-toggle-reaction-adds-and-removes-from-full-list ()
+  "Toggling updates the complete local reaction list."
+  (should (equal (jabber-reactions--toggle-reaction "❤️" '("👍" "👍" "🎉"))
+                 '("👍" "🎉" "❤️")))
+  (should (equal (jabber-reactions--toggle-reaction "👍" '("👍" "🎉" "👍"))
+                 '("🎉"))))
+
+(ert-deftest jabber-test-reactions-update-message-replaces-sender-reactions ()
+  "Incoming updates replace only the sender's full reaction list."
+  (let* ((msg '(:id "m1" :reactions (("alice" . ("👍"))
+                                     ("bob" . ("🎉")))))
+         (updated (jabber-reactions--update-message msg "alice" '("❤️" "❤️" 
""))))
+    (should (equal (alist-get "alice" (plist-get updated :reactions) nil nil 
#'equal)
+                   '("❤️")))
+    (should (equal (alist-get "bob" (plist-get updated :reactions) nil nil 
#'equal)
+                   '("🎉")))))
+
+(ert-deftest 
jabber-test-reactions-update-message-removes-sender-on-empty-update ()
+  "Incoming empty updates remove that sender from reaction state."
+  (let* ((msg '(:id "m1" :reactions (("alice" . ("👍"))
+                                     ("bob" . ("🎉")))))
+         (updated (jabber-reactions--update-message msg "alice" nil)))
+    (should-not (assoc "alice" (plist-get updated :reactions)))
+    (should (equal (alist-get "bob" (plist-get updated :reactions) nil nil 
#'equal)
+                   '("🎉")))))
+
+;;; Group 2: Outgoing stanza helpers
+
+(ert-deftest jabber-test-reactions-build-stanza-includes-store-hint ()
+  "Outgoing reaction stanzas include the XEP-0334 store hint."
+  (let* ((stanza (jabber-reactions--build-stanza
+                  "[email protected]" "chat" "target-1" '("👍" "👍" "🎉")
+                  "reaction-1"))
+         (store (assq 'store (cddr stanza))))
+    (should (equal (cdr (assq 'xmlns (cadr store)))
+                   jabber-reactions-hints-xmlns))))
+
+(ert-deftest jabber-test-reactions-build-stanza-keeps-empty-reactions-element 
()
+  "An empty full local reaction list sends an empty reactions element."
+  (let* ((stanza (jabber-reactions--build-stanza
+                  "[email protected]" "groupchat" "target-1" nil "reaction-1"))
+         (reactions (assq 'reactions (cddr stanza))))
+    (should reactions)
+    (should (equal (cadr reactions)
+                   `((xmlns . ,jabber-reactions-xmlns) (id . "target-1"))))
+    (should-not (cddr reactions))))
+
+;;; Group 3: Incoming stanza classification
+
+(ert-deftest jabber-test-reactions-reaction-only-p-accepts-bodyless-update ()
+  "A bodyless stanza with only reactions is reaction-only."
+  (should (jabber-reactions--reaction-only-p
+           `(message ((from . "[email protected]") (type . "chat"))
+                     (reactions ((xmlns . ,jabber-reactions-xmlns)
+                                 (id . "target-1"))
+                                (reaction nil "👍"))))))
+
+(ert-deftest 
jabber-test-reactions-reaction-only-p-rejects-body-subject-or-error ()
+  "Reaction stanzas with body, subject, or error are not reaction-only."
+  (dolist (extra '((body nil "hello")
+                   (subject nil "topic")
+                   (error ((type . "cancel")))))
+    (should-not
+     (jabber-reactions--reaction-only-p
+      `(message ((from . "[email protected]") (type . "chat"))
+                (reactions ((xmlns . ,jabber-reactions-xmlns)
+                            (id . "target-1"))
+                           (reaction nil "👍"))
+                ,extra)))))
+
+(ert-deftest jabber-test-reactions-react-allows-custom-picker-input ()
+  "Outgoing reaction picker accepts custom reactions outside defaults."
+  (let ((sent nil)
+        (require-match :unset))
+    (cl-letf (((symbol-function 'jabber-reactions--reactable-node-at-point)
+               (lambda () (list 'node '(:id "target-1") "target-1")))
+              ((symbol-function 'jabber-reactions--chat-target)
+               (lambda () (list "[email protected]" "chat")))
+              ((symbol-function 'jabber-reactions--local-sender)
+               (lambda () "[email protected]"))
+              ((symbol-function 'completing-read)
+               (lambda (_prompt _collection _predicate require &rest _args)
+                 (setq require-match require)
+                 "🔥"))
+              ((symbol-function 'jabber-send-sexp)
+               (lambda (_jc stanza) (setq sent stanza)))
+              ((symbol-function 'jabber-reactions--optimistic-update) 
#'ignore))
+      (with-temp-buffer
+        (setq-local jabber-buffer-connection 'fake-jc)
+        (jabber-reactions-react-at-point-or-insert)
+        (should-not require-match)
+        (should (equal (cadr (assq 'reaction (cddr (assq 'reactions (cddr 
sent)))))
+                       nil))
+        (should (equal (car (last (assq 'reaction
+                                        (cddr (assq 'reactions (cddr sent))))))
+                       "🔥"))))))
+
+(ert-deftest jabber-test-reactions-handle-carbon-wrapped-update ()
+  "Incoming carbon-wrapped reaction updates the targeted message node."
+  (with-temp-buffer
+    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
+           (msg '(:id "target-1" :body "hello"))
+           (node (ewoc-enter-last jabber-chat-ewoc (list :foreign msg)))
+           (inner `(message ((from . "[email protected]/laptop")
+                             (to . "[email protected]")
+                             (type . "chat"))
+                            (reactions ((xmlns . ,jabber-reactions-xmlns)
+                                        (id . "target-1"))
+                                       (reaction nil "👍"))))
+           (outer `(message ((from . "[email protected]") (type . "chat"))
+                            (received ((xmlns . "urn:xmpp:carbons:2"))
+                                      (forwarded ((xmlns . 
"urn:xmpp:forward:0"))
+                                                 ,inner)))))
+      (cl-letf (((symbol-function 'jabber-chat--unwrap-carbon)
+                 (lambda (_jc _xml-data) (cons inner nil)))
+                ((symbol-function 'jabber-chat-find-buffer)
+                 (lambda (_chat-with) (current-buffer)))
+                ((symbol-function 'jabber-chat-ewoc-find-by-id)
+                 (lambda (_stanza-id) node))
+                ((symbol-function 'jabber-chat-ewoc-invalidate) #'ignore))
+        (jabber-reactions--handle-message 'fake-jc outer)
+        (should (equal (plist-get (cadr (ewoc-data node)) :reactions)
+                       '(("[email protected]" . ("👍")))))))))
+
+(ert-deftest jabber-test-reactions-handle-sent-carbon-uses-carbon-buffer ()
+  "Sent carbon reaction updates the buffer returned by carbon unwrapping."
+  (with-temp-buffer
+    (let* ((carbon-buffer (current-buffer))
+           (jabber-chat-ewoc (ewoc-create #'ignore))
+           (msg '(:id "target-1" :body "hello"))
+           (node (ewoc-enter-last jabber-chat-ewoc (list :local msg)))
+           (inner `(message ((from . "[email protected]/phone")
+                             (to . "[email protected]")
+                             (type . "chat"))
+                            (reactions ((xmlns . ,jabber-reactions-xmlns)
+                                        (id . "target-1"))
+                                       (reaction nil "🔥"))))
+           (outer `(message ((from . "[email protected]") (type . "chat"))
+                            (sent ((xmlns . "urn:xmpp:carbons:2"))
+                                  (forwarded ((xmlns . "urn:xmpp:forward:0"))
+                                             ,inner)))))
+      (cl-letf (((symbol-function 'jabber-chat--unwrap-carbon)
+                 (lambda (_jc _xml-data) (cons inner carbon-buffer)))
+                ((symbol-function 'jabber-chat-find-buffer)
+                 (lambda (_chat-with) nil))
+                ((symbol-function 'jabber-chat-ewoc-find-by-id)
+                 (lambda (_stanza-id) node))
+                ((symbol-function 'jabber-chat-ewoc-invalidate) #'ignore))
+        (jabber-reactions--handle-message 'fake-jc outer)
+        (should (equal (plist-get (cadr (ewoc-data node)) :reactions)
+                       '(("[email protected]" . ("🔥")))))))))
+
+(provide 'jabber-test-reactions)
+;;; jabber-test-reactions.el ends here
diff --git a/tests/jabber-test-sm.el b/tests/jabber-test-sm.el
index 3ac1d733bb..155a784f90 100644
--- a/tests/jabber-test-sm.el
+++ b/tests/jabber-test-sm.el
@@ -201,6 +201,30 @@
     (should (= (length (plist-get result :sm-outbound-queue)) 1))
     (should (= (caar (plist-get result :sm-outbound-queue)) 3))))
 
+(ert-deftest jabber-test-sm-process-ack-ahead-recovers ()
+  "Ack-ahead recovery keeps outbound counters consistent."
+  (let* ((sd (list :sm-enabled t
+                   :sm-outbound-count 11501
+                   :sm-outbound-queue (list (cons 11501 'a))
+                   :sm-last-acked 11501
+                   :sm-stall-since 1.0))
+         (ack '(a ((xmlns . "urn:xmpp:sm:3") (h . "11502"))))
+         (result (jabber-sm--process-ack sd ack)))
+    (should (= (plist-get result :sm-outbound-count) 11502))
+    (should (= (plist-get result :sm-last-acked) 11502))
+    (should (= (jabber-sm--in-flight-count result) 0))
+    (should-not (plist-get result :sm-stall-since)))
+  (dolist (h '("11502" "11501"))
+    (let* ((sd (list :sm-enabled t
+                     :sm-outbound-count 11502
+                     :sm-outbound-queue (list (cons 11502 'a))
+                     :sm-last-acked 11502
+                     :sm-stall-since 1.0))
+           (before (copy-tree sd))
+           (ack `(a ((xmlns . "urn:xmpp:sm:3") (h . ,h))))
+           (result (jabber-sm--process-ack sd ack)))
+      (should (equal result before)))))
+
 ;;; FSM routing helper
 
 (ert-deftest jabber-test-sm-maybe-enable-with-sm ()


Reply via email to