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

From: "J.P." <[email-masked]>
Date: Thu, 20 Jul 2023 06:29:13 -0700

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.

>From 8f0f4c1d424e0a70c6e46b97ca5b75699f80c892 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
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

>From 8f0f4c1d424e0a70c6e46b97ca5b75699f80c892 Mon Sep 17 00:00:00 2001
From: "F. Jason Park" <[email-masked]>
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