Re: bug#64301: 30.0.50; ERC 5.6: Make speaker labels easier to work with

"J.P." <[email protected]>
Newsgroups gmane.emacs.erc.general
Message-ID <[email protected]>
v2 (invisibility API). Move primary message-hiding function to erc.el.
Invert meaning of invisible-bounds switch and make old non-nil behavior
new default.
0000-v1-v2.diff (text/x-patch, 11.2 KB)
From 8f0f4c1d424e0a70c6e46b97ca5b75699f80c892 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email protected]>
Date: Thu, 20 Jul 2023 05:38:35 -0700
Subject: [PATCH 0/1] *** NOT A PATCH ***

*** BLURB HERE ***

F. Jason Park (1):
  [5.6] Improve ERC's internal invisibility API

 etc/ERC-NEWS                         |  9 +++
 lisp/erc/erc-fill.el                 | 16 +++--
 lisp/erc/erc-match.el                | 46 +++++++-------
 lisp/erc/erc.el                      | 32 +++++++++-
 test/lisp/erc/erc-scenarios-match.el | 95 ++++++++++++++++++++--------
 test/lisp/erc/erc-tests.el           | 50 +++++++++++++--
 6 files changed, 183 insertions(+), 65 deletions(-)

Interdiff:
diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 4c881e32ab4..16577c73b2e 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -287,6 +287,15 @@ The 'fill' module is now defined by 'define-erc-module'.  The same
 goes for ERC's imenu integration, which has 'imenu' now appearing in
 the default value of 'erc-modules'.
 
+*** Hidden messages contain a preceding rather than trailing newline.
+ERC has traditionally only offered to hide messages involving fools,
+but plans are to make hiding more powerful.  Anyone depending on the
+existing behavior should be aware that hidden messages now start and
+end one character earlier, so that hidden line endings precede rather
+than follow accompanying text.  However, an escape hatch is available
+in the variable 'erc-legacy-invisible-bounds-p'.  It reinstates the
+old behavior, which is unsupported by newer modules and features.
+
 *** 'erc-display-message' optionally combines faces.
 Users may notice that ERC now inserts some important error messages in
 a combination of 'erc-error-face' and 'erc-notice-face'.  This is
diff --git a/lisp/erc/erc-fill.el b/lisp/erc/erc-fill.el
index 2dfae35b871..bb11b03e791 100644
--- a/lisp/erc/erc-fill.el
+++ b/lisp/erc/erc-fill.el
@@ -288,11 +288,17 @@ erc-fill-wrap-mode-map
   ;; Not sure if this is problematic because `erc-bol' takes no args.
   "<remap> <erc-bol>" #'erc-fill--wrap-beginning-of-line)
 
-(defvar erc-match-mode)
 (defvar erc-button-mode)
-(defvar erc-match--offset-invisible-bounds-p)
+(defvar erc-legacy-invisible-bounds-p)
 
 (defun erc-fill--wrap-ensure-dependencies ()
+  (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+    (when erc-legacy-invisible-bounds-p
+      (erc--warn-once-before-connect  'erc-fill-wrap-mode
+        "Module `fill-wrap' is incompatible with the obsolete compatibility"
+        " flag `erc-legacy-invisible-bounds-p'.  Disabling locally in %s."
+        (current-buffer))
+      (setq-local erc-legacy-invisible-bounds-p nil)))
   (let (missing-deps)
     (unless erc-fill-mode
       (push 'fill missing-deps)
@@ -336,11 +342,7 @@ fill-wrap
    ;; Internal integrations.
    (add-function :after (local 'erc-stamp--insert-date-function)
                  #'erc-fill--wrap-stamp-insert-prefixed-date)
-   (when (or erc-stamp-mode (memq 'stamp erc-modules))
-     (erc-stamp--display-margin-mode +1))
-   (when (or (bound-and-true-p erc-match-mode) (memq 'match erc-modules))
-     (require 'erc-match)
-     (setq erc-match--offset-invisible-bounds-p t))
+   (erc-stamp--display-margin-mode +1)
    (when erc-fill-wrap-merge
      (add-hook 'erc-button--prev-next-predicate-functions
                #'erc-fill--wrap-merged-button-p nil t))
diff --git a/lisp/erc/erc-match.el b/lisp/erc/erc-match.el
index 3fba35536b3..50db8a132ec 100644
--- a/lisp/erc/erc-match.el
+++ b/lisp/erc/erc-match.el
@@ -655,25 +655,10 @@ erc-go-to-log-matches-buffer
 					(get-buffer (car buffer-cons))))))
     (switch-to-buffer buffer-name)))
 
-(defvar-local erc-match--offset-invisible-bounds-p nil
-  "Whether to hide a message from its preceding newline.")
-
 (defun erc-hide-fools (match-type _nickuserhost _message)
   "Hide comments from designated fools."
   (when (eq match-type 'fool)
-    (erc-match--hide-message 'match-fools)))
-
-(defun erc-match--hide-message (prop)
-  (progn ; FIXME raise sexp
-    (if erc-match--offset-invisible-bounds-p
-        (let ((beg (point-min))
-              (end (point-max)))
-          (save-restriction
-            (widen)
-            (erc--merge-prop (1- beg) (1- end) 'invisible prop)))
-      ;; Before ERC 5.6, this also used to add an `intangible'
-      ;; property, but the docs say it's now obsolete.
-      (erc--merge-prop (point-min) (point-max) 'invisible prop))))
+    (erc--hide-message 'match-fools)))
 
 (defun erc-beep-on-match (match-type _nickuserhost _message)
   "Beep when text matches.
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index 4192ea9a9e2..972cf8d63f3 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -3007,6 +3007,9 @@ erc-display-line
 (defvar erc--compose-text-properties nil
   "Non-nil when `erc-put-text-property' defers to `erc--merge-prop'.")
 
+;; We could maintain a cache of all combined values and dispense
+;; archetype singleton objects so all occurrences of some list (a b)
+;; across all ERC buffers are actually the same object.
 (defun erc--merge-prop (from to prop val &optional object)
   "Compose existing PROP values with VAL between FROM and TO in OBJECT.
 For spans where PROP is non-nil, cons VAL onto the existing
@@ -3028,6 +3031,26 @@ erc--merge-prop
             old (get-text-property pos prop object)
             end (next-single-property-change pos prop object to)))))
 
+(defvar erc-legacy-invisible-bounds-p nil
+  "Whether to hide trailing rather than preceding newlines.
+Beginning in ERC 5.6, invisibility extends from a message's
+preceding newline to its last non-newline character.")
+(make-obsolete-variable 'erc-legacy-invisible-bounds-p
+                        "decremented interval now permanent" "30.1")
+
+(defun erc--hide-message (value)
+  "Apply `invisible' text-property with VALUE to current message.
+Expect to run in a narrowed buffer during message insertion."
+  (if erc-legacy-invisible-bounds-p
+      ;; Before ERC 5.6, this also used to add an `intangible'
+      ;; property, but the docs say it's now obsolete.
+      (erc--merge-prop (point-min) (point-max) 'invisible value)
+    (let ((beg (point-min))
+          (end (point-max)))
+      (save-restriction
+        (widen)
+        (erc--merge-prop (1- beg) (1- end) 'invisible value)))))
+
 (defun erc-display-message-highlight (type string)
   "Highlight STRING according to TYPE, where erc-TYPE-face is an ERC face.
 
diff --git a/test/lisp/erc/erc-scenarios-match.el b/test/lisp/erc/erc-scenarios-match.el
index a438635960e..cd899fddb98 100644
--- a/test/lisp/erc/erc-scenarios-match.el
+++ b/test/lisp/erc/erc-scenarios-match.el
@@ -151,6 +151,12 @@ erc-scenarios-match--stamp-left-fools-invisible
           (= (next-single-property-change msg-beg 'invisible nil (pos-eol))
              (pos-eol))))))))
 
+(defun erc-scenarios-match--find-bol ()
+  (save-excursion
+    (should (get-text-property (1- (point)) 'erc-command))
+    (goto-char (should (previous-single-property-change (point) 'erc-command)))
+    (pos-bol)))
+
 (defun erc-scenarios-match--find-eol ()
   (save-excursion
     (if-let ((next (next-single-property-change (point) 'erc-command)))
@@ -160,13 +166,14 @@ erc-scenarios-match--find-eol
     (pos-eol)))
 
 ;; In most cases, `erc-hide-fools' makes line endings invisible.
-(ert-deftest erc-scenarios-match--stamp-right-fools-invisible ()
+(defun erc-scenarios-match--stamp-right-fools-invisible ()
   :tags '(:expensive-test)
   (let ((erc-insert-timestamp-function #'erc-insert-timestamp-right))
     (erc-scenarios-match--invisible-stamp
 
      (lambda ()
-       (let ((end (erc-scenarios-match--find-eol)))
+       (let ((beg (erc-scenarios-match--find-bol))
+             (end (erc-scenarios-match--find-eol)))
          ;; The end of the message is a newline.
          (should (= ?\n (char-after end)))
 
@@ -178,7 +185,11 @@ erc-scenarios-match--stamp-right-fools-invisible
                         '(timestamp match-fools)))
 
          ;; The final newline is hidden by `match', not `stamps'
-         (should (equal (get-text-property end 'invisible) 'match-fools))
+         (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+           (if erc-legacy-invisible-bounds-p
+               (should (eq (get-text-property end 'invisible) 'match-fools))
+             (should (eq (get-text-property beg 'invisible) 'match-fools))
+             (should-not (get-text-property end 'invisible))))
 
          ;; The message proper has the `invisible' property `match-fools',
          ;; and it starts after the preceding newline.
@@ -204,6 +215,17 @@ erc-scenarios-match--stamp-right-fools-invisible
            (should (eq (get-text-property inv-beg 'invisible)
                        'timestamp))))))))
 
+(ert-deftest erc-scenarios-match--stamp-right-fools-invisible ()
+  :tags '(:expensive-test)
+  (erc-scenarios-match--stamp-right-fools-invisible))
+
+(ert-deftest erc-scenarios-match--stamp-right-fools-invisible--nooffset ()
+  :tags '(:expensive-test)
+  (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+    (should-not erc-legacy-invisible-bounds-p)
+    (let ((erc-legacy-invisible-bounds-p t))
+      (erc-scenarios-match--stamp-right-fools-invisible))))
+
 ;; This asserts that when `erc-fill-wrap-mode' is enabled, ERC hides
 ;; the preceding message's line ending.
 (ert-deftest erc-scenarios-match--stamp-right-invisible-fill-wrap ()
@@ -249,8 +271,7 @@ erc-scenarios-match--stamp-right-invisible-fill-wrap
        (let ((inv-beg (next-single-property-change (1- (pos-bol)) 'invisible)))
          (should (eq (get-text-property inv-beg 'invisible) 'timestamp)))))))
 
-(ert-deftest erc-scenarios-match--stamp-both-invisible-fill-static ()
-  :tags '(:expensive-test)
+(defun erc-scenarios-match--stamp-both-invisible-fill-static ()
   (should (eq erc-insert-timestamp-function
               #'erc-insert-timestamp-left-and-right))
 
@@ -292,7 +313,11 @@ erc-scenarios-match--stamp-both-invisible-fill-static
 
            ;; Line ending has the `invisible' property `match-fools'.
            (should (= (char-after mend) ?\n))
-           (should (eq (get-text-property mend'invisible) 'match-fools))))
+           (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+             (if erc-legacy-invisible-bounds-p
+                 (should (eq (get-text-property mend 'invisible) 'match-fools))
+               (should (eq (get-text-property mbeg 'invisible) 'match-fools))
+               (should-not (get-text-property mend 'invisible))))))
 
        ;; Only the message right after Alice speaks contains stamps.
        (when (= 1 bob-utterance-counter)
@@ -338,4 +363,15 @@ erc-scenarios-match--stamp-both-invisible-fill-static
        (should-not (eq (field-at-pos (1- (pos-eol))) 'erc-timestamp))
        (should-not (next-single-property-change (pos-bol) 'invisible))))))
 
+(ert-deftest erc-scenarios-match--stamp-both-invisible-fill-static ()
+  :tags '(:expensive-test)
+  (erc-scenarios-match--stamp-both-invisible-fill-static))
+
+(ert-deftest erc-scenarios-match--stamp-both-invisible-fill-static--nooffset ()
+  :tags '(:expensive-test)
+  (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+    (should-not erc-legacy-invisible-bounds-p)
+    (let ((erc-legacy-invisible-bounds-p t))
+      (erc-scenarios-match--stamp-both-invisible-fill-static))))
+
 ;;; erc-scenarios-match.el ends here
-- 
2.41.0
0001-5.6-Improve-ERC-s-internal-invisibility-API.patch (text/x-patch, 24.3 KB)
From 8f0f4c1d424e0a70c6e46b97ca5b75699f80c892 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email protected]>
Date: Fri, 14 Jul 2023 21:08:31 -0700
Subject: [PATCH 1/1] [5.6] Improve ERC's internal invisibility API

* etc/ERC-NEWS: Mention that line endings have moved from the end to
the beginning of hidden messages.
* lisp/erc/erc-fill.el (erc-fill--wrap-ensure-dependencies): Warn when
users have `erc-legacy-invisible-bounds-p' enabled, and force it to
its default value of nil in the current buffer.
(erc-fill-wrap-mode, erc-fill-wrap-enable): Move business involving
compat variable for enabling legacy hidden-message behavior to helper.
* lisp/erc/erc-match.el (erc-match--hide-fools-offset-bounds): Move
internal variable from to main library file and rename to
`erc-legacy-invisible-bounds-p'.  Also make obsolete and flip
semantics so non-nil enables the traditional behavior.
(erc-match--hide-message): Move to main library file and rename to
`erc--hide-message'.  Add a property-value parameter instead of
hard-coding to `erc-match'.  Also, condition behavior on inverted and
renamed compatibility flag `erc-legacy-invisible-bounds-p'.
(erc-hide-fools): Call `erc--hide-message' with own value for
`invisible' property specifically for fools.  That is, use
`match-fools' rather than `erc-match' or `erc-match-fools' to save
room when visually inspecting.  This retains the module name as a
prefix to hopefully minimize collisions with invisibility spec members
owned by non-ERC minor modes.  The `timestamp' spec member owned by
erc-stamp likewise lacks a namespace prefix, but its feature/group
is already inherent.
(erc-match--modify-invisibility-spec): Use toggle command
non-interactively for adding and removing invisibility spec member.
(erc-match-toggle-hidden-fools): Add explicit override argument and
defer to general helper for actually modifying spec.
(erc-match--toggle-hidden): New helper for toggling invisibility
spec.
* lisp/erc/erc.el (erc--merge-prop): If new value is a list, prepend
onto existing.  Add note about possible space optimization.
(erc-legacy-invisible-bounds-p): New obsolete compat variable to
enable traditional pre-5.6 invisibility interval on hidden messages.
Replaces `erc-match--hide-fools-offset-bounds-p' but has an inverted
meaning.  The new default value of nil means invisibility covers a
shifted interval consisting of the message body plus the line ending
immediately preceding it.
(erc--hide-message): New function, formerly `erc-match--hide-message'
from erc-match.el introduced in ERC 5.6.
* test/lisp/erc/erc-scenarios-match.el:
(erc-scenarios-match--invisible-stamp): Fix comment and use API
function in interactive convenience setup.
(erc-scenarios-match--find-bol): New test helper.
(erc-scenarios-match--find-eol): Fix bug affecting interactive use.
(erc-scenarios-match--stamp-left-fools-invisible,
erc-scenarios-match--stamp-right-fools-invisible,
erc-scenarios-match--stamp-right-invisible-fill-wrap,
erc-scenarios-match--stamp-both-invisible-fill-static): Update
`invisible' property from `erc-match' to `match-fools'.
(erc-scenarios-match--stamp-right-fools-invisible,
erc-scenarios-match--stamp-both-invisible-fill-static): Move test
body to function of same name for use in multiple cases.
(erc-scenarios-match--stamp-right-fools-invisible--nooffset,
erc-scenarios-match--stamp-both-invisible-fill-static--nooffset): New
test variants asserting proper hiding with old pre-5.6 invisibility
interval.
* test/lisp/erc/erc-tests.el (erc-tests--equal-including-properties):
Relocate macro in same file.
(erc--merge-prop): New test.  (Bug#64301)
---
 etc/ERC-NEWS                         |  9 +++
 lisp/erc/erc-fill.el                 | 16 +++--
 lisp/erc/erc-match.el                | 46 +++++++-------
 lisp/erc/erc.el                      | 32 +++++++++-
 test/lisp/erc/erc-scenarios-match.el | 95 ++++++++++++++++++++--------
 test/lisp/erc/erc-tests.el           | 50 +++++++++++++--
 6 files changed, 183 insertions(+), 65 deletions(-)

diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS
index 4c881e32ab4..16577c73b2e 100644
--- a/etc/ERC-NEWS
+++ b/etc/ERC-NEWS
@@ -287,6 +287,15 @@ The 'fill' module is now defined by 'define-erc-module'.  The same
 goes for ERC's imenu integration, which has 'imenu' now appearing in
 the default value of 'erc-modules'.
 
+*** Hidden messages contain a preceding rather than trailing newline.
+ERC has traditionally only offered to hide messages involving fools,
+but plans are to make hiding more powerful.  Anyone depending on the
+existing behavior should be aware that hidden messages now start and
+end one character earlier, so that hidden line endings precede rather
+than follow accompanying text.  However, an escape hatch is available
+in the variable 'erc-legacy-invisible-bounds-p'.  It reinstates the
+old behavior, which is unsupported by newer modules and features.
+
 *** 'erc-display-message' optionally combines faces.
 Users may notice that ERC now inserts some important error messages in
 a combination of 'erc-error-face' and 'erc-notice-face'.  This is
diff --git a/lisp/erc/erc-fill.el b/lisp/erc/erc-fill.el
index a65c95f1d85..bb11b03e791 100644
--- a/lisp/erc/erc-fill.el
+++ b/lisp/erc/erc-fill.el
@@ -288,11 +288,17 @@ erc-fill-wrap-mode-map
   ;; Not sure if this is problematic because `erc-bol' takes no args.
   "<remap> <erc-bol>" #'erc-fill--wrap-beginning-of-line)
 
-(defvar erc-match-mode)
 (defvar erc-button-mode)
-(defvar erc-match--hide-fools-offset-bounds)
+(defvar erc-legacy-invisible-bounds-p)
 
 (defun erc-fill--wrap-ensure-dependencies ()
+  (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+    (when erc-legacy-invisible-bounds-p
+      (erc--warn-once-before-connect  'erc-fill-wrap-mode
+        "Module `fill-wrap' is incompatible with the obsolete compatibility"
+        " flag `erc-legacy-invisible-bounds-p'.  Disabling locally in %s."
+        (current-buffer))
+      (setq-local erc-legacy-invisible-bounds-p nil)))
   (let (missing-deps)
     (unless erc-fill-mode
       (push 'fill missing-deps)
@@ -336,11 +342,7 @@ fill-wrap
    ;; Internal integrations.
    (add-function :after (local 'erc-stamp--insert-date-function)
                  #'erc-fill--wrap-stamp-insert-prefixed-date)
-   (when (or erc-stamp-mode (memq 'stamp erc-modules))
-     (erc-stamp--display-margin-mode +1))
-   (when (or (bound-and-true-p erc-match-mode) (memq 'match erc-modules))
-     (require 'erc-match)
-     (setq erc-match--hide-fools-offset-bounds t))
+   (erc-stamp--display-margin-mode +1)
    (when erc-fill-wrap-merge
      (add-hook 'erc-button--prev-next-predicate-functions
                #'erc-fill--wrap-merged-button-p nil t))
diff --git a/lisp/erc/erc-match.el b/lisp/erc/erc-match.el
index a5b0af41b2a..50db8a132ec 100644
--- a/lisp/erc/erc-match.el
+++ b/lisp/erc/erc-match.el
@@ -655,24 +655,10 @@ erc-go-to-log-matches-buffer
 					(get-buffer (car buffer-cons))))))
     (switch-to-buffer buffer-name)))
 
-(defvar-local erc-match--hide-fools-offset-bounds nil)
-
 (defun erc-hide-fools (match-type _nickuserhost _message)
   "Hide comments from designated fools."
   (when (eq match-type 'fool)
-    (erc-match--hide-message)))
-
-(defun erc-match--hide-message ()
-  (progn ; FIXME raise sexp
-    (if erc-match--hide-fools-offset-bounds
-        (let ((beg (point-min))
-              (end (point-max)))
-          (save-restriction
-            (widen)
-            (erc--merge-prop (1- beg) (1- end) 'invisible 'erc-match)))
-      ;; Before ERC 5.6, this also used to add an `intangible'
-      ;; property, but the docs say it's now obsolete.
-      (erc--merge-prop (point-min) (point-max) 'invisible 'erc-match))))
+    (erc--hide-message 'match-fools)))
 
 (defun erc-beep-on-match (match-type _nickuserhost _message)
   "Beep when text matches.
@@ -682,19 +668,31 @@ erc-beep-on-match
 
 (defun erc-match--modify-invisibility-spec ()
   "Add an `erc-match' property to the local spec."
+  ;; Hopefully, this will be extended to do the same for other
+  ;; invisible properties managed by this module.
   (if erc-match-mode
-      (add-to-invisibility-spec 'erc-match)
+      (erc-match-toggle-hidden-fools +1)
     (erc-with-all-buffers-of-server nil nil
-      (remove-from-invisibility-spec 'erc-match))))
+      (erc-match-toggle-hidden-fools -1))))
 
-(defun erc-match-toggle-hidden-fools ()
+(defun erc-match-toggle-hidden-fools (arg)
   "Toggle fool visibility.
-Expect `erc-hide-fools' or a function that does something similar
-to be in `erc-text-matched-hook'."
-  (interactive)
-  (if (memq 'erc-match (ensure-list buffer-invisibility-spec))
-      (remove-from-invisibility-spec 'erc-match)
-    (add-to-invisibility-spec 'erc-match)))
+Expect the function `erc-hide-fools' or similar to be present in
+`erc-text-matched-hook'."
+  (interactive "P")
+  (erc-match--toggle-hidden 'match-fools arg))
+
+(defun erc-match--toggle-hidden (prop arg)
+  "Toggle invisibility for spec member PROP.
+Treat ARG in a manner similar to mode toggles defined by
+`define-minor-mode'."
+  (when arg
+    (setq arg (prefix-numeric-value arg)))
+  (if (memq prop (ensure-list buffer-invisibility-spec))
+      (unless (natnump arg)
+        (remove-from-invisibility-spec prop))
+    (when (or (not arg) (natnump arg))
+      (add-to-invisibility-spec prop))))
 
 (provide 'erc-match)
 
diff --git a/lisp/erc/erc.el b/lisp/erc/erc.el
index eca6a90d706..972cf8d63f3 100644
--- a/lisp/erc/erc.el
+++ b/lisp/erc/erc.el
@@ -3007,22 +3007,50 @@ erc-display-line
 (defvar erc--compose-text-properties nil
   "Non-nil when `erc-put-text-property' defers to `erc--merge-prop'.")
 
+;; We could maintain a cache of all combined values and dispense
+;; archetype singleton objects so all occurrences of some list (a b)
+;; across all ERC buffers are actually the same object.
 (defun erc--merge-prop (from to prop val &optional object)
   "Compose existing PROP values with VAL between FROM and TO in OBJECT.
 For spans where PROP is non-nil, cons VAL onto the existing
 value, ensuring a proper list.  Otherwise, just set PROP to VAL.
-See also `erc-button-add-face'."
+When VAL is itself a list, prepend its members onto an existing
+value.  See also `erc-button-add-face'."
   (let ((old (get-text-property from prop object))
         (pos from)
         (end (next-single-property-change from prop object to))
         new)
     (while (< pos to)
-      (setq new (if old (cons val (ensure-list old)) val))
+      (setq new (if old
+                    (if (listp val)
+                        (append val (ensure-list old))
+                      (cons val (ensure-list old)))
+                  val))
       (put-text-property pos end prop new object)
       (setq pos end
             old (get-text-property pos prop object)
             end (next-single-property-change pos prop object to)))))
 
+(defvar erc-legacy-invisible-bounds-p nil
+  "Whether to hide trailing rather than preceding newlines.
+Beginning in ERC 5.6, invisibility extends from a message's
+preceding newline to its last non-newline character.")
+(make-obsolete-variable 'erc-legacy-invisible-bounds-p
+                        "decremented interval now permanent" "30.1")
+
+(defun erc--hide-message (value)
+  "Apply `invisible' text-property with VALUE to current message.
+Expect to run in a narrowed buffer during message insertion."
+  (if erc-legacy-invisible-bounds-p
+      ;; Before ERC 5.6, this also used to add an `intangible'
+      ;; property, but the docs say it's now obsolete.
+      (erc--merge-prop (point-min) (point-max) 'invisible value)
+    (let ((beg (point-min))
+          (end (point-max)))
+      (save-restriction
+        (widen)
+        (erc--merge-prop (1- beg) (1- end) 'invisible value)))))
+
 (defun erc-display-message-highlight (type string)
   "Highlight STRING according to TYPE, where erc-TYPE-face is an ERC face.
 
diff --git a/test/lisp/erc/erc-scenarios-match.el b/test/lisp/erc/erc-scenarios-match.el
index 8a718962c55..cd899fddb98 100644
--- a/test/lisp/erc/erc-scenarios-match.el
+++ b/test/lisp/erc/erc-scenarios-match.el
@@ -62,11 +62,15 @@ erc-scenarios-match--stamp-left-current-nick
                     'erc-current-nick-face))))))
 
 ;; When hacking on tests that use this fixture, it's best to run it
-;; interactively, and check for wierdness before and after doing
-;; M-: (remove-from-invisibility-spec 'erc-match) RET.
+;; interactively, and visually inspect the output with various
+;; combinations of:
+;;
+;;   M-x erc-match-toggle-hidden-fools RET
+;;   M-x erc-toggle-timestamps RET
+;;
 (defun erc-scenarios-match--invisible-stamp (hiddenp visiblep)
   (unless noninteractive
-    (kill-new "(remove-from-invisibility-spec 'erc-match)"))
+    (kill-new "erc-match-toggle-hidden-fools"))
 
   (erc-scenarios-common-with-cleanup
       ((erc-scenarios-common-dialog "join/legacy")
@@ -128,11 +132,11 @@ erc-scenarios-match--stamp-left-fools-invisible
 
        ;; Leading stamp has combined `invisible' property value.
        (should (equal (get-text-property (pos-bol) 'invisible)
-                      '(timestamp erc-match)))
+                      '(timestamp match-fools)))
 
-       ;; Message proper has the `invisible' property `erc-match'.
+       ;; Message proper has the `invisible' property `match-fools'.
        (let ((msg-beg (next-single-property-change (pos-bol) 'invisible)))
-         (should (eq (get-text-property msg-beg 'invisible) 'erc-match))
+         (should (eq (get-text-property msg-beg 'invisible) 'match-fools))
          (should (>= (next-single-property-change msg-beg 'invisible nil)
                      (pos-eol)))))
 
@@ -147,19 +151,29 @@ erc-scenarios-match--stamp-left-fools-invisible
           (= (next-single-property-change msg-beg 'invisible nil (pos-eol))
              (pos-eol))))))))
 
+(defun erc-scenarios-match--find-bol ()
+  (save-excursion
+    (should (get-text-property (1- (point)) 'erc-command))
+    (goto-char (should (previous-single-property-change (point) 'erc-command)))
+    (pos-bol)))
+
 (defun erc-scenarios-match--find-eol ()
   (save-excursion
-    (goto-char (next-single-property-change (point) 'erc-command))
+    (if-let ((next (next-single-property-change (point) 'erc-command)))
+        (goto-char next)
+      ;; We're already at the end of the message.
+      (should (get-text-property (1- (point)) 'erc-command)))
     (pos-eol)))
 
 ;; In most cases, `erc-hide-fools' makes line endings invisible.
-(ert-deftest erc-scenarios-match--stamp-right-fools-invisible ()
+(defun erc-scenarios-match--stamp-right-fools-invisible ()
   :tags '(:expensive-test)
   (let ((erc-insert-timestamp-function #'erc-insert-timestamp-right))
     (erc-scenarios-match--invisible-stamp
 
      (lambda ()
-       (let ((end (erc-scenarios-match--find-eol)))
+       (let ((beg (erc-scenarios-match--find-bol))
+             (end (erc-scenarios-match--find-eol)))
          ;; The end of the message is a newline.
          (should (= ?\n (char-after end)))
 
@@ -168,19 +182,23 @@ erc-scenarios-match--stamp-right-fools-invisible
 
          ;; Stamps have a combined `invisible' property value.
          (should (equal (get-text-property (1- end) 'invisible)
-                        '(timestamp erc-match)))
+                        '(timestamp match-fools)))
 
          ;; The final newline is hidden by `match', not `stamps'
-         (should (equal (get-text-property end 'invisible) 'erc-match))
+         (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+           (if erc-legacy-invisible-bounds-p
+               (should (eq (get-text-property end 'invisible) 'match-fools))
+             (should (eq (get-text-property beg 'invisible) 'match-fools))
+             (should-not (get-text-property end 'invisible))))
 
-         ;; The message proper has the `invisible' property `erc-match',
+         ;; The message proper has the `invisible' property `match-fools',
          ;; and it starts after the preceding newline.
-         (should (eq (get-text-property (pos-bol) 'invisible) 'erc-match))
+         (should (eq (get-text-property (pos-bol) 'invisible) 'match-fools))
 
          ;; It ends just before the timestamp.
          (let ((msg-end (next-single-property-change (pos-bol) 'invisible)))
            (should (equal (get-text-property msg-end 'invisible)
-                          '(timestamp erc-match)))
+                          '(timestamp match-fools)))
 
            ;; Stamp's `invisible' property extends throughout the stamp
            ;; and ends before the trailing newline.
@@ -197,6 +215,17 @@ erc-scenarios-match--stamp-right-fools-invisible
            (should (eq (get-text-property inv-beg 'invisible)
                        'timestamp))))))))
 
+(ert-deftest erc-scenarios-match--stamp-right-fools-invisible ()
+  :tags '(:expensive-test)
+  (erc-scenarios-match--stamp-right-fools-invisible))
+
+(ert-deftest erc-scenarios-match--stamp-right-fools-invisible--nooffset ()
+  :tags '(:expensive-test)
+  (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+    (should-not erc-legacy-invisible-bounds-p)
+    (let ((erc-legacy-invisible-bounds-p t))
+      (erc-scenarios-match--stamp-right-fools-invisible))))
+
 ;; This asserts that when `erc-fill-wrap-mode' is enabled, ERC hides
 ;; the preceding message's line ending.
 (ert-deftest erc-scenarios-match--stamp-right-invisible-fill-wrap ()
@@ -215,16 +244,16 @@ erc-scenarios-match--stamp-right-invisible-fill-wrap
 
        ;; Stamps have a combined `invisible' property value.
        (should (equal (get-text-property (1- (pos-eol)) 'invisible)
-                      '(timestamp erc-match)))
+                      '(timestamp match-fools)))
 
-       ;; The message proper has the `invisible' property `erc-match',
+       ;; The message proper has the `invisible' property `match-fools',
        ;; which starts at the preceding newline...
-       (should (eq (get-text-property (1- (pos-bol)) 'invisible) 'erc-match))
+       (should (eq (get-text-property (1- (pos-bol)) 'invisible) 'match-fools))
 
        ;; ... and ends just before the timestamp.
        (let ((msgend (next-single-property-change (1- (pos-bol)) 'invisible)))
          (should (equal (get-text-property msgend 'invisible)
-                        '(timestamp erc-match)))
+                        '(timestamp match-fools)))
 
          ;; The newline before `erc-insert-marker' is still visible.
          (should-not (get-text-property (pos-eol) 'invisible))
@@ -242,8 +271,7 @@ erc-scenarios-match--stamp-right-invisible-fill-wrap
        (let ((inv-beg (next-single-property-change (1- (pos-bol)) 'invisible)))
          (should (eq (get-text-property inv-beg 'invisible) 'timestamp)))))))
 
-(ert-deftest erc-scenarios-match--stamp-both-invisible-fill-static ()
-  :tags '(:expensive-test)
+(defun erc-scenarios-match--stamp-both-invisible-fill-static ()
   (should (eq erc-insert-timestamp-function
               #'erc-insert-timestamp-left-and-right))
 
@@ -265,8 +293,8 @@ erc-scenarios-match--stamp-both-invisible-fill-static
              (search-forward "[23:59]"))))
 
        (ert-info ("Line endings in Bob's messages are invisible")
-         ;; The message proper has the `invisible' property `erc-match'.
-         (should (eq (get-text-property (pos-bol) 'invisible) 'erc-match))
+         ;; The message proper has the `invisible' property `match-fools'.
+         (should (eq (get-text-property (pos-bol) 'invisible) 'match-fools))
          (let* ((mbeg (next-single-property-change (pos-bol) 'erc-command))
                 (mend (next-single-property-change mbeg 'erc-command)))
 
@@ -283,9 +311,13 @@ erc-scenarios-match--stamp-both-invisible-fill-static
            (should (= (next-single-property-change (pos-bol) 'erc-timestamp)
                       mend))
 
-           ;; Line ending has the `invisible' property `erc-match'.
+           ;; Line ending has the `invisible' property `match-fools'.
            (should (= (char-after mend) ?\n))
-           (should (eq (get-text-property mend'invisible) 'erc-match))))
+           (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+             (if erc-legacy-invisible-bounds-p
+                 (should (eq (get-text-property mend 'invisible) 'match-fools))
+               (should (eq (get-text-property mbeg 'invisible) 'match-fools))
+               (should-not (get-text-property mend 'invisible))))))
 
        ;; Only the message right after Alice speaks contains stamps.
        (when (= 1 bob-utterance-counter)
@@ -298,7 +330,7 @@ erc-scenarios-match--stamp-both-invisible-fill-static
              ;; Date stamp has a combined `invisible' property value
              ;; that extends until the start of the message proper.
              (should (equal (get-text-property (point) 'invisible)
-                            '(timestamp erc-match)))
+                            '(timestamp match-fools)))
              (should (= (next-single-property-change (point) 'invisible)
                         (1+ (pos-eol))))))
 
@@ -314,7 +346,7 @@ erc-scenarios-match--stamp-both-invisible-fill-static
            (let ((msgend (next-single-property-change (pos-bol) 'invisible)))
              ;; Stamp has a combined `invisible' property value.
              (should (equal (get-text-property msgend 'invisible)
-                            '(timestamp erc-match)))
+                            '(timestamp match-fools)))
 
              ;; Combined `invisible' property spans entire timestamp.
              (should (= (next-single-property-change msgend 'invisible)
@@ -331,4 +363,15 @@ erc-scenarios-match--stamp-both-invisible-fill-static
        (should-not (eq (field-at-pos (1- (pos-eol))) 'erc-timestamp))
        (should-not (next-single-property-change (pos-bol) 'invisible))))))
 
+(ert-deftest erc-scenarios-match--stamp-both-invisible-fill-static ()
+  :tags '(:expensive-test)
+  (erc-scenarios-match--stamp-both-invisible-fill-static))
+
+(ert-deftest erc-scenarios-match--stamp-both-invisible-fill-static--nooffset ()
+  :tags '(:expensive-test)
+  (with-suppressed-warnings ((obsolete erc-legacy-invisible-bounds-p))
+    (should-not erc-legacy-invisible-bounds-p)
+    (let ((erc-legacy-invisible-bounds-p t))
+      (erc-scenarios-match--stamp-both-invisible-fill-static))))
+
 ;;; erc-scenarios-match.el ends here
diff --git a/test/lisp/erc/erc-tests.el b/test/lisp/erc/erc-tests.el
index b5db5fe8764..610fb149a94 100644
--- a/test/lisp/erc/erc-tests.el
+++ b/test/lisp/erc/erc-tests.el
@@ -1272,6 +1272,50 @@ erc-process-input-line
 
           (should-not calls))))))
 
+(defmacro erc-tests--equal-including-properties (a b)
+  (list (if (< emacs-major-version 29)
+            'ert-equal-including-properties
+          'equal-including-properties)
+        a b))
+
+(ert-deftest erc--merge-prop ()
+  (with-current-buffer (get-buffer-create "*erc-test*")
+    ;; Baseline.
+    (insert "abc\n")
+    (erc--merge-prop 1 3 'erc-test 'x)
+    (should (erc-tests--equal-including-properties
+             (buffer-substring 1 4) #("abc" 0 2 (erc-test x))))
+    (erc--merge-prop 1 3 'erc-test 'y)
+    (should (erc-tests--equal-including-properties
+             (buffer-substring 1 4) #("abc" 0 2 (erc-test (y x)))))
+
+    ;; Multiple intervals.
+    (goto-char (point-min))
+    (insert "def\n")
+    (erc--merge-prop 1 2 'erc-test 'x)
+    (erc--merge-prop 2 3 'erc-test 'y)
+    (should (erc-tests--equal-including-properties
+             (buffer-substring 1 4)
+             #("def" 0 1 (erc-test x) 1 2 (erc-test y))))
+    (erc--merge-prop 1 3 'erc-test 'z)
+    (should (erc-tests--equal-including-properties
+             (buffer-substring 1 4)
+             #("def" 0 1 (erc-test (z x)) 1 2 (erc-test (z y)))))
+
+    ;; New val as list.
+    (goto-char (point-min))
+    (insert "ghi\n")
+    (erc--merge-prop 2 3 'erc-test '(y z))
+    (should (erc-tests--equal-including-properties
+             (buffer-substring 1 4) #("ghi" 1 2 (erc-test (y z)))))
+    (erc--merge-prop 1 3 'erc-test '(w x))
+    (should (erc-tests--equal-including-properties
+             (buffer-substring 1 4)
+             #("ghi" 0 1 (erc-test (w x)) 1 2 (erc-test (w x y z)))))
+
+    (when noninteractive
+      (kill-buffer))))
+
 (ert-deftest erc--split-string-shell-cmd ()
 
   ;; Leading and trailing space
@@ -1488,12 +1532,6 @@ erc-message
     (kill-buffer "ExampleNet")
     (kill-buffer "#chan")))
 
-(defmacro erc-tests--equal-including-properties (a b)
-  (list (if (< emacs-major-version 29)
-            'ert-equal-including-properties
-          'equal-including-properties)
-        a b))
-
 (ert-deftest erc-format-privmessage ()
   ;; Basic PRIVMSG
   (should (erc-tests--equal-including-properties
-- 
2.41.0
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.