branch: elpa/jabber
commit 28382b54ef549ffd7b07446b7ef6e49ddbf62746
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
db: Persist reply metadata across buffer reloads
---
CHANGELOG.org | 1 +
lisp/jabber-chat.el | 13 +++-
lisp/jabber-db.el | 150 +++++++++++++++++++++++++++++++++++++++-------
lisp/jabber-mam.el | 3 +-
tests/jabber-test-chat.el | 23 +++++++
tests/jabber-test-db.el | 116 ++++++++++++++++++++++++++++++++++-
6 files changed, 280 insertions(+), 26 deletions(-)
diff --git a/CHANGELOG.org b/CHANGELOG.org
index fe7ac2a014..6d86808dd8 100644
--- a/CHANGELOG.org
+++ b/CHANGELOG.org
@@ -3,6 +3,7 @@
* [Unreleased]
** Added
+- Reply context survives buffer reloads: reply metadata is now stored in the
message database
- Outgoing messages carry an XEP-0359 origin-id, so replies from other clients
reference a stable id
- OMEMO signed pre-keys now rotate automatically on connect
(~jabber-omemo-signed-pre-key-rotation-period~, 7 days default)
diff --git a/lisp/jabber-chat.el b/lisp/jabber-chat.el
index b38645a9f9..e0cd72c429 100644
--- a/lisp/jabber-chat.el
+++ b/lisp/jabber-chat.el
@@ -232,13 +232,19 @@ added to the outgoing message.")
Hooks holding state for the next composed message (e.g. pending
reply data) should stay inert instead of consuming it.")
+(defvar jabber-chat--send-hook-stanza nil
+ "The outgoing stanza, bound while `jabber-chat-send-hooks' run.
+Lets late hooks (e.g. the DB store) read elements that earlier
+hooks attached, without changing the (BODY ID) hook signature.")
+
(defun jabber-chat--run-send-hooks (stanza body id)
"Run `jabber-chat-send-hooks' and nconc results onto STANZA.
BODY and ID are passed to each hook function.
When STANZA is an XEP-0308 correction, the hooks run with
`jabber-chat--sending-correction' bound non-nil so that hooks
holding state for the next composed message stay inert."
- (let ((jabber-chat--sending-correction
+ (let ((jabber-chat--send-hook-stanza stanza)
+ (jabber-chat--sending-correction
(and (jabber-xml-child-with-xmlns
stanza "urn:xmpp:message-correct:0")
t)))
@@ -282,7 +288,7 @@ holding state for the next composed message stay inert."
(account peer direction type body timestamp
&optional resource stanza-id
server-id occupant-id oob-entries
- encrypted))
+ encrypted reply))
(declare-function jabber-db--extract-occupant-id "jabber-db.el" (xml-data))
(declare-function jabber-message-correct--replace-id "jabber-message-correct"
(xml-data))
@@ -593,7 +599,8 @@ updates the original row instead."
(when from (jabber-jid-resource from))
stanza-id
nil (jabber-db--extract-occupant-id xml-data) nil
- encrypted)))))
+ encrypted
+ (jabber-chat--reply-fields xml-data))))))
(defun jabber-chat--select-buffer (jc from &optional carbon-buffer)
"Return the chat buffer for an incoming message from FROM.
diff --git a/lisp/jabber-db.el b/lisp/jabber-db.el
index f23a36463d..cb8cb2c9b7 100644
--- a/lisp/jabber-db.el
+++ b/lisp/jabber-db.el
@@ -103,7 +103,11 @@ in the message history.")
displayed_at INTEGER,
retracted_by TEXT,
retraction_reason TEXT,
- edited INTEGER DEFAULT 0)"
+ edited INTEGER DEFAULT 0,
+ reply_to_id TEXT,
+ reply_to_jid TEXT,
+ fallback_start INTEGER,
+ fallback_end INTEGER)"
"CREATE INDEX IF NOT EXISTS idx_msg_peer_ts
ON message(account, peer, timestamp)"
"CREATE INDEX IF NOT EXISTS idx_msg_stanza_id
@@ -263,7 +267,7 @@ WHERE updated_at < (
(jabber-db--ensure-reaction-actor-table db)
(jabber-db--backfill-reaction-actors db)))
-(defconst jabber-db--schema-version 6
+(defconst jabber-db--schema-version 7
"Current schema version.
Bump this when adding migrations. A database whose version
exceeds this value is from a newer (or development) build and
@@ -353,6 +357,12 @@ CREATE INDEX IF NOT EXISTS idx_reaction_message_id
(sqlite-execute db "PRAGMA user_version=6")
(setq version 6))
(when (= version 6)
+ (dolist (col '("reply_to_id TEXT" "reply_to_jid TEXT"
+ "fallback_start INTEGER" "fallback_end INTEGER"))
+ (sqlite-execute db (concat "ALTER TABLE message ADD COLUMN " col)))
+ (sqlite-execute db "PRAGMA user_version=7")
+ (setq version 7))
+ (when (= version 7)
(jabber-db--repair-reaction-actors db))))
(defun jabber-db-ensure-open ()
@@ -445,6 +455,61 @@ SELECT identities, features FROM caps_cache
;;; Storage
+(defun jabber-db--extract-reply-fields (xml-data)
+ "Return XEP-0461 reply metadata in XML-DATA as a plist, or nil.
+Keys are :reply-to-id, :reply-to-jid and :fallback-range. Mirrors
+`jabber-chat--reply-fields' in jabber-chat.el; duplicated here for
+the same layering reason as `jabber-db--stanza-id-element'."
+ (and-let* ((reply-el
+ (seq-find
+ (lambda (child)
+ (and (eq (jabber-xml-node-name child) 'reply)
+ (equal (jabber-xml-get-xmlns child)
+ "urn:xmpp:reply:0")))
+ (jabber-xml-node-children xml-data))))
+ (list :reply-to-id (jabber-xml-get-attribute reply-el 'id)
+ :reply-to-jid (jabber-xml-get-attribute reply-el 'to)
+ :fallback-range (jabber-db--reply-fallback-range xml-data))))
+
+(defun jabber-db--reply-fallback-range (xml-data)
+ "Return the XEP-0428 fallback range for replies in XML-DATA.
+Same return values as `jabber-chat--reply-fallback-range': a
+\(START END) list, `all', or nil."
+ (when-let* ((fallback
+ (seq-find
+ (lambda (child)
+ (and (eq (jabber-xml-node-name child) 'fallback)
+ (equal (jabber-xml-get-xmlns child)
+ "urn:xmpp:fallback:0")
+ (equal (jabber-xml-get-attribute child 'for)
+ "urn:xmpp:reply:0")))
+ (jabber-xml-node-children xml-data))))
+ (if-let* ((body (car (jabber-xml-get-children fallback 'body))))
+ (let ((start (jabber-xml-get-attribute body 'start))
+ (end (jabber-xml-get-attribute body 'end)))
+ (if (or start end)
+ (and start end
+ (string-match-p "\\`[0-9]+\\'" start)
+ (string-match-p "\\`[0-9]+\\'" end)
+ (list (string-to-number start) (string-to-number end)))
+ 'all))
+ 'all)))
+
+(defun jabber-db--fallback-range-cols (range)
+ "Encode RANGE for storage as a (START . END) cons of column values.
+RANGE is nil, `all', or a (START END) list; `all' is stored
+as -1/-1, nil as NULL/NULL."
+ (pcase range
+ ('all '(-1 . -1))
+ (`(,start ,end) (cons start end))
+ (_ '(nil . nil))))
+
+(defun jabber-db--decode-fallback-range (start end)
+ "Decode fallback columns START and END back into a range value.
+Inverse of `jabber-db--fallback-range-cols'."
+ (cond ((and (eql start -1) (eql end -1)) 'all)
+ ((and start end) (list start end))))
+
(defun jabber-db--detect-duplicate (db account peer timestamp body
stanza-id server-id &optional type)
"Check whether a message for ACCOUNT already exists in DB.
@@ -482,18 +547,26 @@ WHERE account = ? AND peer = ? AND timestamp = ? AND body
= ? LIMIT 1"
(defun jabber-db--insert-message (db account peer resource occupant-id
direction type body timestamp
stanza-id server-id encrypted
- oob-entries)
+ oob-entries reply)
"Insert a new message row into DB for ACCOUNT and attach OOB-ENTRIES.
PEER, RESOURCE, OCCUPANT-ID, DIRECTION, TYPE, BODY, TIMESTAMP,
-STANZA-ID, SERVER-ID and ENCRYPTED fill the corresponding columns."
- (sqlite-execute
- db
- "INSERT INTO message \
+STANZA-ID, SERVER-ID and ENCRYPTED fill the corresponding columns.
+REPLY is a plist from `jabber-db--extract-reply-fields', or nil."
+ (pcase-let ((`(,fb-start . ,fb-end)
+ (jabber-db--fallback-range-cols
+ (plist-get reply :fallback-range))))
+ (sqlite-execute
+ db
+ "INSERT INTO message \
(account, peer, resource, occupant_id, direction, type, body, timestamp, \
-stanza_id, server_id, encrypted) \
-VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
- (list account peer resource occupant-id direction type body timestamp
- stanza-id server-id (if encrypted 1 0)))
+stanza_id, server_id, encrypted, reply_to_id, reply_to_jid, \
+fallback_start, fallback_end) \
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ (list account peer resource occupant-id direction type body timestamp
+ stanza-id server-id (if encrypted 1 0)
+ (plist-get reply :reply-to-id)
+ (plist-get reply :reply-to-jid)
+ fb-start fb-end)))
(when oob-entries
(let ((msg-id (caar (sqlite-select db "SELECT last_insert_rowid()"))))
(dolist (entry oob-entries)
@@ -570,10 +643,26 @@ WHERE account = ? AND peer = ? AND timestamp = ? AND body
= ? \
AND stanza_id IS NULL AND server_id IS NULL"
(list stanza-id server-id account peer timestamp body))))
+(defun jabber-db--backfill-reply-fields (db account peer stanza-id reply)
+ "Fill NULL reply columns for ACCOUNT/PEER's row with STANZA-ID from REPLY.
+Completes rows stored before the reply elements were attached to
+the outgoing stanza (e.g. the OMEMO pending echo)."
+ (pcase-let ((`(,fb-start . ,fb-end)
+ (jabber-db--fallback-range-cols
+ (plist-get reply :fallback-range))))
+ (sqlite-execute
+ db
+ "UPDATE message SET reply_to_id = ?, reply_to_jid = ?, \
+fallback_start = ?, fallback_end = ? \
+WHERE stanza_id = ? AND account = ? AND peer = ? AND reply_to_id IS NULL"
+ (list (plist-get reply :reply-to-id)
+ (plist-get reply :reply-to-jid)
+ fb-start fb-end stanza-id account peer))))
+
(defun jabber-db-store-message (account peer direction type body timestamp
&optional resource stanza-id
server-id occupant-id oob-entries
- encrypted)
+ encrypted reply)
"Store a message in the database.
ACCOUNT is the bare JID of the local account.
PEER is the bare JID of the contact or room.
@@ -587,7 +676,9 @@ Optional SERVER-ID is the XEP-0359 server-assigned id.
Optional OCCUPANT-ID is the XEP-0421 occupant id.
Optional OOB-ENTRIES is a list of (URL . DESC) cons cells for
jabber:x:oob elements.
-Optional ENCRYPTED is non-nil if the message was OMEMO-encrypted."
+Optional ENCRYPTED is non-nil if the message was OMEMO-encrypted.
+Optional REPLY is a reply metadata plist from
+`jabber-db--extract-reply-fields'."
(when-let* ((db (jabber-db-ensure-open)))
(let ((dup-id-col (jabber-db--detect-duplicate
db account peer timestamp body stanza-id server-id
@@ -596,11 +687,15 @@ Optional ENCRYPTED is non-nil if the message was
OMEMO-encrypted."
('nil
(jabber-db--insert-message db account peer resource occupant-id
direction type body timestamp
- stanza-id server-id encrypted oob-entries))
+ stanza-id server-id encrypted oob-entries
+ reply))
((or 'stanza_id 'server_id)
(jabber-db--update-duplicate-ids db account peer timestamp body
stanza-id server-id oob-entries
- dup-id-col))
+ dup-id-col)
+ (when (and reply stanza-id)
+ (jabber-db--backfill-reply-fields db account peer stanza-id
+ reply)))
('content
(jabber-db--upgrade-content-match db account peer timestamp body
stanza-id server-id))))))
@@ -822,7 +917,8 @@ ROW columns match the SELECT in `jabber-db-backlog'.
The :oob-entries key is populated later by `jabber-db--attach-oob-entries'."
(seq-let (id account peer direction body timestamp resource type
encrypted stanza-id delivered-at
- displayed-at server-id retracted-by retraction-reason edited)
+ displayed-at server-id retracted-by retraction-reason edited
+ reply-to-id reply-to-jid fallback-start fallback-end)
row
(let ((from (cond
;; Incoming: peer/resource (or just peer if no resource).
@@ -846,6 +942,10 @@ The :oob-entries key is populated later by
`jabber-db--attach-oob-entries'."
:retracted-by retracted-by
:retraction-reason retraction-reason
:edited (and edited (not (zerop edited)))
+ :reply-to-id reply-to-id
+ :reply-to-jid reply-to-jid
+ :fallback-range (jabber-db--decode-fallback-range
+ fallback-start fallback-end)
:direction direction
:msg-type type
:oob-entries nil
@@ -907,7 +1007,8 @@ MSG-TYPE, when non-nil, filters to messages of that type
only
(t 0)))
(base-cols "SELECT id, account, peer, direction, body, timestamp, \
resource, type, encrypted, stanza_id, delivered_at, displayed_at, \
-server_id, retracted_by, retraction_reason, edited FROM message")
+server_id, retracted_by, retraction_reason, edited, \
+reply_to_id, reply_to_jid, fallback_start, fallback_end FROM message")
(sql (cond
(resource
(concat base-cols " WHERE account = ? AND peer = ? \
@@ -1113,12 +1214,15 @@ XML-DATA is the parsed stanza."
server-id
(jabber-db--extract-occupant-id xml-data)
oob-entries
- encrypted)))))
+ encrypted
+ (jabber-db--extract-reply-fields xml-data))))))
(defun jabber-db--outgoing-handler (body id)
"Store outgoing chat message in the database.
BODY is the message text. ID is the stanza id for dedup.
-Called from `jabber-chat-send-hooks'."
+Called from `jabber-chat-send-hooks'. Reply metadata is read from
+`jabber-chat--send-hook-stanza' when the hooks that emit the reply
+elements have already run."
(when (and jabber-chatting-with jabber-buffer-connection)
(jabber-db-store-message
(jabber-connection-bare-jid jabber-buffer-connection)
@@ -1131,7 +1235,9 @@ Called from `jabber-chat-send-hooks'."
(jabber-jid-resource jabber-chatting-with))
id
nil nil nil
- (memq jabber-chat-encryption '(omemo openpgp openpgp-legacy))))
+ (memq jabber-chat-encryption '(omemo openpgp openpgp-legacy))
+ (and (bound-and-true-p jabber-chat--send-hook-stanza)
+ (jabber-db--extract-reply-fields jabber-chat--send-hook-stanza))))
nil)
(defun jabber-db--store-outgoing (jc to body type)
@@ -1233,7 +1339,9 @@ files, depending on the value of
`jabber-use-global-history'."
;;; Registration
(jabber-chain-add 'jabber-message-chain #'jabber-db--message-handler 90)
-(add-hook 'jabber-chat-send-hooks #'jabber-db--outgoing-handler)
+;; Depth 90: run after the hooks that attach reply/receipt elements,
+;; so the stored row sees the complete stanza.
+(add-hook 'jabber-chat-send-hooks #'jabber-db--outgoing-handler 90)
(add-hook 'jabber-post-connect-hooks #'jabber-db--on-connect)
(add-hook 'jabber-pre-disconnect-hook #'jabber-db--on-disconnect)
(add-hook 'kill-emacs-hook #'jabber-db-close)
diff --git a/lisp/jabber-mam.el b/lisp/jabber-mam.el
index 1e15955fa1..881b105712 100644
--- a/lisp/jabber-mam.el
+++ b/lisp/jabber-mam.el
@@ -379,7 +379,8 @@ JC is the Jabber connection. XML-DATA is the stanza."
body ts (jabber-jid-resource (plist-get fields :from))
(plist-get fields :stanza-id) archive-id
(jabber-db--extract-occupant-id inner-msg)
- (plist-get fields :oob-entries) encrypted)))
+ (plist-get fields :oob-entries) encrypted
+ (jabber-db--extract-reply-fields inner-msg))))
(jabber-mam--track-sync-ids qid archive-id
(plist-get fields :stanza-id) ts)
(jabber-mam--mark-dirty peer (plist-get fields :type))
diff --git a/tests/jabber-test-chat.el b/tests/jabber-test-chat.el
index df2794bf5f..a824448ee7 100644
--- a/tests/jabber-test-chat.el
+++ b/tests/jabber-test-chat.el
@@ -261,6 +261,29 @@
(plist (jabber-chat--msg-plist-from-stanza stanza)))
(should-not (plist-get plist :fallback-range))))
+(ert-deftest jabber-test-chat-outgoing-handler-stores-reply-metadata ()
+ "The DB outgoing handler reads reply elements off the final stanza."
+ (require 'jabber-db)
+ (with-temp-buffer
+ (setq-local jabber-chatting-with "[email protected]")
+ (setq-local jabber-buffer-connection 'fake-jc)
+ (let (stored-reply)
+ (cl-letf (((symbol-function 'jabber-connection-bare-jid)
+ (lambda (_jc) "[email protected]"))
+ ((symbol-function 'jabber-muc-sender-p)
+ (lambda (_jid) nil))
+ ((symbol-function 'jabber-db-store-message)
+ (lambda (&rest args) (setq stored-reply (nth 12 args)))))
+ (let ((stanza '(message ((to . "[email protected]")
+ (type . "chat")
+ (id . "m-9"))
+ (body () "> q\nanswer")
+ (reply ((xmlns . "urn:xmpp:reply:0")
+ (id . "orig-9")))))
+ (jabber-chat-send-hooks (list #'jabber-db--outgoing-handler)))
+ (jabber-chat--run-send-hooks stanza "> q\nanswer" "m-9")))
+ (should (equal "orig-9" (plist-get stored-reply :reply-to-id))))))
+
(ert-deftest jabber-test-chat-send-hooks-stamp-origin-id ()
"The default send hooks stamp an XEP-0359 origin-id on outgoing stanzas."
(with-temp-buffer
diff --git a/tests/jabber-test-db.el b/tests/jabber-test-db.el
index 3150836232..a4b191cefe 100644
--- a/tests/jabber-test-db.el
+++ b/tests/jabber-test-db.el
@@ -1629,9 +1629,15 @@ CREATE TABLE omemo_store (
(unwind-protect
(progn
;; Create a minimal v5 database with a store row. The
- ;; reaction tables satisfy the post-migration repair step.
+ ;; reaction tables satisfy the post-migration repair step;
+ ;; the message table is needed by the v6->v7 migration.
(let ((db (sqlite-open jabber-db-path)))
(sqlite-execute db "\
+CREATE TABLE message (
+ id INTEGER PRIMARY KEY, account TEXT NOT NULL, peer TEXT NOT NULL,
+ direction TEXT NOT NULL, type TEXT, body TEXT,
+ timestamp INTEGER NOT NULL, stanza_id TEXT)")
+ (sqlite-execute db "\
CREATE TABLE omemo_store (
account TEXT PRIMARY KEY,
store_blob BLOB NOT NULL)")
@@ -1667,6 +1673,114 @@ CREATE TABLE message_reaction_actor (
(when (file-directory-p jabber-test-db--dir)
(delete-directory jabber-test-db--dir t)))))
+(ert-deftest jabber-test-db-migration-v6-to-v7 ()
+ "Migration v6->v7 adds the reply metadata columns."
+ (let* ((jabber-test-db--dir (make-temp-file "jabber-db-test" t))
+ (jabber-db-path (expand-file-name "test.sqlite" jabber-test-db--dir))
+ (jabber-db--connection nil))
+ (unwind-protect
+ (progn
+ (let ((db (sqlite-open jabber-db-path)))
+ (sqlite-execute db "\
+CREATE TABLE message (
+ id INTEGER PRIMARY KEY, account TEXT NOT NULL, peer TEXT NOT NULL,
+ direction TEXT NOT NULL, type TEXT, body TEXT,
+ timestamp INTEGER NOT NULL, stanza_id TEXT)")
+ (sqlite-execute db "\
+CREATE TABLE message_reaction (
+ message_id INTEGER NOT NULL, sender TEXT NOT NULL,
+ reaction TEXT NOT NULL, updated_at INTEGER NOT NULL,
+ PRIMARY KEY (message_id, sender, reaction))")
+ (sqlite-execute db "\
+CREATE TABLE message_reaction_actor (
+ message_id INTEGER NOT NULL, sender TEXT NOT NULL,
+ updated_at INTEGER NOT NULL, PRIMARY KEY (message_id, sender))")
+ (sqlite-execute db "\
+CREATE TABLE omemo_store (
+ account TEXT PRIMARY KEY, store_blob BLOB NOT NULL,
+ spk_rotated_at INTEGER)")
+ (sqlite-execute db "PRAGMA user_version=6")
+ (sqlite-close db))
+ (jabber-db-ensure-open)
+ (should (= jabber-db--schema-version
+ (caar (sqlite-select jabber-db--connection
+ "PRAGMA user_version"))))
+ (let ((cols (mapcar #'car
+ (sqlite-select jabber-db--connection
+ "SELECT name FROM
pragma_table_info('message')"))))
+ (dolist (col '("reply_to_id" "reply_to_jid"
+ "fallback_start" "fallback_end"))
+ (should (member col cols)))))
+ (jabber-db-close)
+ (when (file-directory-p jabber-test-db--dir)
+ (delete-directory jabber-test-db--dir t)))))
+
+;;; Group: Reply metadata persistence
+
+(ert-deftest jabber-test-db-reply-metadata-round-trip ()
+ "Reply metadata stored with a message comes back in the backlog."
+ (jabber-test-db-with-db
+ (jabber-db-store-message
+ "[email protected]" "[email protected]" "in" "chat" "> quote\nanswer"
+ (floor (float-time)) "phone" "msg-1" nil nil nil nil
+ '(:reply-to-id "orig-1" :reply-to-jid "[email protected]"
+ :fallback-range (0 8)))
+ (let ((msg (car (jabber-db-backlog "[email protected]" "[email protected]"))))
+ (should (equal "orig-1" (plist-get msg :reply-to-id)))
+ (should (equal "[email protected]" (plist-get msg :reply-to-jid)))
+ (should (equal '(0 8) (plist-get msg :fallback-range))))))
+
+(ert-deftest jabber-test-db-reply-metadata-all-range ()
+ "A whole-body fallback range survives the -1 encoding."
+ (jabber-test-db-with-db
+ (jabber-db-store-message
+ "[email protected]" "[email protected]" "in" "chat" "> just a quote"
+ (floor (float-time)) "phone" "msg-2" nil nil nil nil
+ '(:reply-to-id "orig-2" :fallback-range all))
+ (let ((msg (car (jabber-db-backlog "[email protected]" "[email protected]"))))
+ (should (eq 'all (plist-get msg :fallback-range))))))
+
+(ert-deftest jabber-test-db-reply-metadata-absent ()
+ "A message without reply metadata reads back nil fields."
+ (jabber-test-db-with-db
+ (jabber-db-store-message
+ "[email protected]" "[email protected]" "in" "chat" "plain"
+ (floor (float-time)) "phone" "msg-3")
+ (let ((msg (car (jabber-db-backlog "[email protected]" "[email protected]"))))
+ (should-not (plist-get msg :reply-to-id))
+ (should-not (plist-get msg :fallback-range)))))
+
+(ert-deftest jabber-test-db-reply-metadata-backfill-on-dedup ()
+ "A duplicate store with reply metadata backfills NULL reply columns."
+ (jabber-test-db-with-db
+ (let ((ts (floor (float-time))))
+ ;; First store without reply metadata (OMEMO pending echo shape).
+ (jabber-db-store-message
+ "[email protected]" "[email protected]" "out" "chat" "answer" ts nil "msg-4")
+ ;; Same stanza-id again, now with reply metadata.
+ (jabber-db-store-message
+ "[email protected]" "[email protected]" "out" "chat" "answer" ts nil "msg-4"
+ nil nil nil nil '(:reply-to-id "orig-4" :fallback-range all))
+ (let ((msg (car (jabber-db-backlog "[email protected]" "[email protected]"))))
+ (should (equal "orig-4" (plist-get msg :reply-to-id)))
+ (should (eq 'all (plist-get msg :fallback-range)))))))
+
+(ert-deftest jabber-test-db-extract-reply-fields ()
+ "Reply extraction matches the chat-side parser's shape."
+ (let ((stanza '(message ((from . "[email protected]") (type . "chat"))
+ (body () "> q\nanswer")
+ (reply ((xmlns . "urn:xmpp:reply:0")
+ (to . "[email protected]")
+ (id . "orig-5")))
+ (fallback ((xmlns . "urn:xmpp:fallback:0")
+ (for . "urn:xmpp:reply:0"))
+ (body ((start . "0") (end . "4")))))))
+ (should (equal '(:reply-to-id "orig-5" :reply-to-jid "[email protected]"
+ :fallback-range (0 4))
+ (jabber-db--extract-reply-fields stanza))))
+ (should-not (jabber-db--extract-reply-fields
+ '(message ((from . "[email protected]")) (body () "plain")))))
+
(ert-deftest jabber-test-db-oob-dedup-replacement ()
"Failed-decrypt replacement updates OOB entries."
(jabber-test-db-with-db