master 5d3fadb2478 6/9: Use erc-match API for erc-desktop-notifications
"F. Jason Park" <[email protected]> Tue, 30 Jun 2026 23:37:22 -0400 (EDT)
| Newsgroups | gmane.emacs.diffs |
|---|---|
| Message-ID | <[email protected]> |
branch: master commit 5d3fadb2478af116972f65e777796d0ea96a3773 Author: F. Jason Park <[email protected]> Commit: F. Jason Park <[email protected]> Use erc-match API for erc-desktop-notifications * etc/ERC-NEWS: New entries for the `erc-match' API and notification options. * lisp/erc/erc-desktop-notifications.el (erc-desktop-notifications-ignored-when-focused): New option. (erc-desktop-notifications-skip-predicates): New option. (erc-notifications-notify): Address ancient comment regarding PRIVP parameter possibly being unneeded when the current target matches the nick. (erc-notifications-PRIVMSG): Deprecate. (erc-desktop-notifications-untracked-p, erc-desktop-notifications-fool-p) (erc-desktop-notifications-focused-p): New functions for `erc-desktop-notifications-skip-predicates'. (erc-notifications-notify-on-match): Account for new options, and use new `erc-match' API. (erc-notifications-mode, erc-notifications-enable) (erc-notifications-disable): Instead of the "PRIVMSG" response-handler hook, use the `erc-match' API. (erc-desktop-notifications--setup): New function. (erc-desktop-notifications--query-NOTICE-p): New variable. (erc-desktop-notifications-match-query): New struct type. (erc-desktop-notifications--query-p): New function. (erc-desktop-notifications--query-notify): New function. * test/lisp/erc/erc-desktop-notifications-tests.el: New file. (Bug#73798) --- etc/ERC-NEWS | 20 ++++ lisp/erc/erc-desktop-notifications.el | 134 +++++++++++++++++++--- lisp/erc/erc-match.el | 2 +- test/lisp/erc/erc-desktop-notifications-tests.el | 139 +++++++++++++++++++++++ 4 files changed, 276 insertions(+), 19 deletions(-) diff --git a/etc/ERC-NEWS b/etc/ERC-NEWS index d28c0670a73..2088345f97e 100644 --- a/etc/ERC-NEWS +++ b/etc/ERC-NEWS @@ -14,6 +14,26 @@ GNU Emacs since Emacs version 22.1. * Changes in ERC 5.7 +** A flexible 'match' API. +To the frustration many, ERC has long lacked of a flexible API for +reacting to a message's contents and envelope info, like its sender and +associated IRC command. While the long-term plan is to transition to an +object-based formatting model that should offer more useful access to +the various parts of a message before they're assembled, it's become +obvious that users need something practical in the interim. This +'match' API is based on a simple hook-like handler system that offers +more opportunities for things like filtering and styling. See Info node +'Match API' to find out more. + +** Exercise more control in skipping certain desktop notifications. +Skip select notifications that ERC would otherwise emit with new hook +option 'erc-desktop-notifications-skip-predicates'. Its default members +skip messages from "fools" and those in untracked buffers. + +** Opt out of desktop notifications from the active buffer. +Option 'erc-desktop-notifications-ignored-when-focused' can spare users +from being notified when interacting with the initiating buffer. + ** Changes in the library API. *** Module setup runs in query buffers on reconnect. diff --git a/lisp/erc/erc-desktop-notifications.el b/lisp/erc/erc-desktop-notifications.el index e4a62c614a4..a3b728a5e53 100644 --- a/lisp/erc/erc-desktop-notifications.el +++ b/lisp/erc/erc-desktop-notifications.el @@ -47,6 +47,28 @@ "Icon to use for notification." :type '(choice (const :tag "No icon" nil) file)) +(defcustom erc-desktop-notifications-ignored-when-focused () + "Contexts in which to suppress notification in the selected window. +Assumes `erc-desktop-notifications-focused-p' is a member of +`erc-desktop-notifications-skip-predicates'. If the option's value +contains the symbol `query', ERC skips all notifications in focused +query buffers. And if it contains `mention', ERC skips notifications +upon mention of the user's nick in a focused channel buffer." + :package-version '(ERC . "5.7") + :type '(set (const query) (const mention))) + +(defcustom erc-desktop-notifications-skip-predicates + '(erc-desktop-notifications-focused-p + erc-desktop-notifications-untracked-p + erc-desktop-notifications-fool-p) + "Abnormal hook whose members return non-nil to suppress notification. +Called in match buffer with a matching `erc-match-user' object." + :options '(erc-desktop-notifications-focused-p + erc-desktop-notifications-untracked-p + erc-desktop-notifications-fool-p) + :package-version '(ERC . "5.7") + :type 'hook) + (defcustom erc-notifications-bus :session "D-Bus bus to use for notification." :version "25.1" @@ -60,16 +82,18 @@ (defun erc-notifications-notify (nick msg &optional privp) "Notify that NICK send some MSG, where PRIVP should be non-nil for PRIVMSGs. This will replace the last notification sent with this function." - ;; TODO: can we do this without PRIVP? (by "fixing" ERC's not - ;; setting the current buffer to the existing query buffer) (dbus-ignore-errors (setq erc-notifications-last-notification - (let* ((channel (if privp (erc-get-buffer nick) (current-buffer))) - (title (format "%s in %s" - (erc-compat--xml-escape-string nick t) - channel)) - (body (erc-compat--xml-escape-string (erc-controls-strip msg) - t))) + (let* ((channel (or (and privp (not (equal nick (erc-target))) + (erc-get-buffer nick)) + (current-buffer))) + (title (if (or privp (equal nick (erc-target))) + (erc-compat--xml-escape-string nick t) + (format "%s in %s" + (erc-compat--xml-escape-string nick t) + channel))) + (body (erc-compat--xml-escape-string + (erc-controls-strip msg) t))) (funcall (cond ((featurep 'android) #'android-notifications-notify) ((featurep 'haiku) @@ -85,6 +109,7 @@ This will replace the last notification sent with this function." (pop-to-buffer channel))))))) (defun erc-notifications-PRIVMSG (_proc parsed) + (declare (obsolete "switched to `erc-match-type' API" "31.1")) (let ((nick (car (erc-parse-user (erc-response.sender parsed)))) (target (car (erc-response.command-args parsed))) (msg (erc-response.contents parsed))) @@ -96,23 +121,96 @@ This will replace the last notification sent with this function." ;; Return nil to continue processing by ERC nil) -(defun erc-notifications-notify-on-match (match-type nickuserhost msg) +(defun erc-desktop-notifications-untracked-p (&rest _) + "Return non-nil if current buffer's target appears in `erc-track-exclude'." + (and (boundp 'erc-track-exclude) (member (erc-target) erc-track-exclude))) + +(defun erc-desktop-notifications-fool-p (&rest _) + "Return non-nil if the current message has a \"match type\" of `fool'." + (erc-match-get-match 'erc-match-opt-fool)) + +(defun erc-desktop-notifications-focused-p (match) + "Return non-nil if the frame is focused and suppressed by context. +See `erc-desktop-notifications-ignored-when-focused' for contexts." + (and (eq (current-buffer) (window-buffer)) + (cond + ((erc-query-buffer-p) + (memq 'query erc-desktop-notifications-ignored-when-focused)) + ((erc-match-opt-current-nick-p match) + (memq 'mention erc-desktop-notifications-ignored-when-focused))) + (frame-focus-state))) + +(defun erc-notifications-notify-on-match (match-type _ msg) + "Emit MSG if MATCH-TYPE is `current-nick' and other conditions allow." (when (eq match-type 'current-nick) - (let ((nick (nth 0 (erc-parse-user nickuserhost)))) - (unless (or (string-match-p "^Server:" nick) - (when (boundp 'erc-track-exclude) - (member nick erc-track-exclude))) - (erc-notifications-notify nick msg))))) + (let ((match erc-match-highlight-matched)) + (cl-assert (erc-match-opt-current-nick-p match)) + (when-let* ((nick (erc-match-nick match))) + (unless (run-hook-with-args-until-success + 'erc-desktop-notifications-skip-predicates + match) + (erc-notifications-notify nick msg)))))) ;;;###autoload(autoload 'erc-notifications-mode "erc-desktop-notifications" "" t) (define-erc-module notifications nil "Send notifications on private message reception and mentions." ;; Enable - ((add-hook 'erc-server-PRIVMSG-functions #'erc-notifications-PRIVMSG) - (add-hook 'erc-text-matched-hook #'erc-notifications-notify-on-match)) + ((unless erc--updating-modules-p + (erc-buffer-do #'erc-desktop-notifications--setup)) + (add-hook 'erc-mode-hook #'erc-desktop-notifications--setup)) ;; Disable - ((remove-hook 'erc-server-PRIVMSG-functions #'erc-notifications-PRIVMSG) - (remove-hook 'erc-text-matched-hook #'erc-notifications-notify-on-match))) + ((erc-buffer-do #'erc-desktop-notifications--setup) + (remove-hook 'erc-mode-hook #'erc-desktop-notifications--setup))) + +(defun erc-desktop-notifications--setup () + (if erc-notifications-mode + (progn + (add-hook 'erc-match-functions + ;; Run after default value to detect fools. + #'erc-desktop-notifications-match-query 20 t) + (add-hook 'erc-text-matched-hook #'erc-notifications-notify-on-match + 20 t)) + (remove-hook 'erc-match-functions + #'erc-desktop-notifications-match-query t) + (remove-hook 'erc-text-matched-hook + #'erc-notifications-notify-on-match t))) + +;; This flag is most likely only temporary and exists as a hedge against +;; a likely thinko involving NOTICEs sent to query buffers. At the time +;; of writing, it's unclear whether the current behavior of suppressing +;; query NOTICEs outright is TRT. For example, a user might want +;; NOTICEs from a particular bot to trigger notifications because it's +;; monitoring critical updates to some library they use. When the +;; picture becomes clearer, the introduction of a new option/predicate +;; pair resembling `erc-desktop-notifications-ignored-when-focused' and +;; `erc-desktop-notifications-focused-p' may be warranted. +(defvar erc-desktop-notifications--query-NOTICE-p nil + "Whether to notify on receiving a \"NOTICE\" in a query. +Bots and services typically send these.") + +(cl-defstruct (erc-desktop-notifications-match-query + (:constructor erc-desktop-notifications-match-query) + (:include erc-match-user + (category nil) + (predicate #'erc-desktop-notifications--query-p) + (handler #'erc-desktop-notifications--query-notify))) + "Desktop notification match type for queries.") + +(defun erc-desktop-notifications--query-p (match) + "Return non-nil if MATCH object describes a \"PRIVMSG\" query." + (and (erc-query-buffer-p) + (or erc-desktop-notifications--query-NOTICE-p + (eq (erc-match-command match) 'PRIVMSG)) + (progn + (cl-assert (erc-match-nick match)) + (not (run-hook-with-args-until-success + 'erc-desktop-notifications-skip-predicates match))))) + +(defun erc-desktop-notifications--query-notify (match) + ;; No need for PRIVP arg because current buffer is correct. + (erc-notifications-notify (erc-target) + (erc-match-get-message-body match))) + (provide 'erc-desktop-notifications) diff --git a/lisp/erc/erc-match.el b/lisp/erc/erc-match.el index 7c77960994d..c6675053e55 100644 --- a/lisp/erc/erc-match.el +++ b/lisp/erc/erc-match.el @@ -298,7 +298,7 @@ available via universal argument." "Type constructors for \\+`match' processing. See the struct `erc-match' as well as Info node `(erc) Match API' for details." - :package-version '(ERC . "5.7") ; FIXME sync on release + :package-version '(ERC . "5.7") :type '(hook :options (erc-match-opt-pal erc-match-opt-fool erc-match-opt-dangerous-host diff --git a/test/lisp/erc/erc-desktop-notifications-tests.el b/test/lisp/erc/erc-desktop-notifications-tests.el new file mode 100644 index 00000000000..690c0e37ac3 --- /dev/null +++ b/test/lisp/erc/erc-desktop-notifications-tests.el @@ -0,0 +1,139 @@ +;;; erc-desktop-notifications-tests.el --- Notifications tests -*- lexical-binding:t -*- + +;; Copyright (C) 2026 Free Software Foundation, Inc. + +;; This file is part of GNU Emacs. +;; +;; GNU Emacs is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published +;; by the Free Software Foundation, either version 3 of the License, +;; or (at your option) any later version. +;; +;; GNU Emacs is distributed in the hope that it will be useful, but +;; WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +;; General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>. + +;;; Commentary: +;;; Code: +(require 'erc-desktop-notifications) + +(require 'ert-x) +(eval-and-compile + (let ((load-path (cons (ert-resource-directory) load-path))) + (require 'erc-tests-common))) + +(defun erc-desktop-notifications-tests--perform (test) + (erc-tests-common-make-server-buf) + (erc-notifications-mode +1) + (setq erc-server-current-nick "tester") + + (unwind-protect + (cl-letf* ((calls nil) + ((frame-parameter nil 'last-focus-update) + t) + ((symbol-function 'erc-notifications-notify) + (lambda (&rest r) (push r calls)))) + (with-current-buffer (erc--open-target "#chan") + (funcall test (lambda () (prog1 calls (setq calls nil)))))) + + (when noninteractive + (erc-notifications-mode -1) + (erc-tests-common-kill-buffers)))) + +(defun erc-desktop-notifications-tests--populate-chan (test) + (erc-desktop-notifications-tests--perform + (lambda (check) + (erc-tests-common-add-cmem "bob") + (erc-tests-common-add-cmem "alice") + + (erc-tests-common-simulate-line + ":irc.foonet.org 353 tester = #chan :alice bob tester") + (erc-tests-common-simulate-line + ":irc.foonet.org 366 tester #chan :End of NAMES list") + (erc-tests-common-simulate-privmsg "bob" "hi tester") + + (should (equal (current-buffer) (get-buffer "#chan"))) + (should (not (eq (current-buffer) (window-buffer)))) ; *ert* or *scratch* + (funcall test check)))) + +(ert-deftest erc-desktop-notifications-focused-contexts/default () + (should-not erc-desktop-notifications-ignored-when-focused) + + (erc-desktop-notifications-tests--populate-chan + (lambda (check) + + ;; A private query triggers a notification. + (erc-tests-common-simulate-line ":[email protected] PRIVMSG tester yo") + (should (eq (current-buffer) (get-buffer "bob"))) + + ;; A NOTICE command doesn't trigger a notification (unless + ;; `erc-desktop-notifications--query-NOTICE-p' is non-nil). + (erc-tests-common-simulate-line ":irc.foonet.org NOTICE tester nope") + + (should (equal (funcall check) + '(("bob" "yo") + ("bob" "hi tester\n")))) + + ;; Setting the window to the buffer where insertions are happening + ;; makes no difference: notifications are still sent. + (erc-tests-common-simulate-line ":[email protected] PRIVMSG tester ho") + + (ert-with-buffer-selected "#chan" + (erc-tests-common-simulate-privmsg "alice" "hi tester") + + (should (equal (funcall check) + '(("alice" "hi tester\n") ("bob" "ho")))))))) + +(ert-deftest erc-desktop-notifications-focused-contexts/unselected () + (should-not erc-desktop-notifications-ignored-when-focused) + + (let ((erc-desktop-notifications-ignored-when-focused '(query mention))) + + (erc-desktop-notifications-tests--populate-chan + (lambda (check) + (should (equal (funcall check) '(("bob" "hi tester\n")))) + + ;; Buffer #chan is current and displayed in the selected window, + ;; so no notification is sent. + (ert-with-buffer-selected "#chan" + (erc-tests-common-simulate-privmsg "alice" "hi tester") + + ;; A new query arrives for a buffer that doesn't exist. The + ;; option `erc-receive-query-display' tells ERC to switch to + ;; that buffer and show it before insertion. Therefore, no + ;; notification is sent. + (let ((erc-receive-query-display 'buffer)) + (erc-tests-common-simulate-line + ":[email protected] PRIVMSG tester yo"))) + + (should-not (funcall check)))))) + +(ert-deftest erc-desktop-notifications-skip-predicates/fools () + (erc-desktop-notifications-tests--populate-chan + (lambda (check) + + ;; A private query triggers a notification. + (erc-tests-common-simulate-line ":[email protected] PRIVMSG tester yo") + (should (eq (current-buffer) (get-buffer "bob"))) + + (should (equal (funcall check) + '(("bob" "yo") + ("bob" "hi tester\n")))) + + (let ((erc-fools '("bob"))) + + ;; A query from is suppressed if bob is a fool. + (erc-tests-common-simulate-line ":[email protected] PRIVMSG tester ho") + (should-not (funcall check)) + + ;; A mention from bob is suppressed if bob is a fool. + (with-current-buffer "#chan" + (erc-tests-common-simulate-privmsg "bob" "hi tester") + (erc-tests-common-simulate-privmsg "alice" "hi tester") + (should (equal (funcall check) '(("alice" "hi tester\n"))))))))) + +;;; erc-desktop-notifications-tests.el ends here