summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThanos Apollo <public@thanosapollo.org>2026-04-22 22:38:18 +0300
committerThanos Apollo <public@thanosapollo.org>2026-04-22 22:38:18 +0300
commitac2005f11d866a03f9cebee8302fc0dd3fbd3db5 (patch)
treeba328550e9225a2c181396c4bc00c5420472d7e3
parentfe87282faf28321febd63b2038511d265713feec (diff)
notification: Add polling, mode-line indicator, list buffer
-rw-r--r--Makefile5
-rw-r--r--forgejo-notification.el300
2 files changed, 303 insertions, 2 deletions
diff --git a/Makefile b/Makefile
index e0479f1..cfc9036 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,7 @@ EMACS_CMD = $(EMACS) -Q --batch -L .
SRCS = forgejo.el forgejo-api.el forgejo-db.el forgejo-utils.el \
forgejo-buffer.el forgejo-repo.el forgejo-issue.el forgejo-pull.el \
- forgejo-vc.el forgejo-tl.el forgejo-transient.el
+ forgejo-vc.el forgejo-tl.el forgejo-notification.el forgejo-transient.el
TESTS = tests/forgejo-test-api.el tests/forgejo-test-db.el \
tests/forgejo-test-buffer.el tests/forgejo-test-issue.el \
@@ -40,7 +40,8 @@ load: clean
(add-to-list 'load-path \"$(CURDIR)\") \
(dolist (sym '(forgejo-issue-list-mode-map forgejo-pull-list-mode-map \
forgejo-pull-view-mode-map forgejo-issue-view-mode-map \
- forgejo-repo-search-mode-map forgejo-buffer-diff-map \
+ forgejo-repo-search-mode-map forgejo-notification-list-mode-map \
+ forgejo-buffer-diff-map \
forgejo-buffer-ref-map forgejo-buffer-commit-map)) \
(when (boundp sym) (makunbound sym))))" > /dev/null
@for f in $(SRCS); do \
diff --git a/forgejo-notification.el b/forgejo-notification.el
new file mode 100644
index 0000000..e7c5510
--- /dev/null
+++ b/forgejo-notification.el
@@ -0,0 +1,300 @@
+;;; forgejo-notification.el --- Notification polling and display -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Thanos Apollo
+
+;; Author: Thanos Apollo <public@thanosapollo.org>
+;; Keywords: extensions
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Notification polling, activity tracking, and mode-line indicator
+;; for Forgejo instances. Follows the hook-based architecture of
+;; jabber.el (jabber-activity.el / jabber-modeline.el) adapted from
+;; event-driven XMPP to timer-polled REST.
+;;
+;; Currently polls the Forgejo notification API and caches results in
+;; the local DB. The hook system and DB table are designed to also
+;; support future local watch rules (custom per-repo/label filters
+;; that generate notifications from the sync process).
+;;
+;; Enable `forgejo-modeline-mode' to start polling and display the
+;; unread count in the mode line. Use `forgejo-notification-list'
+;; to browse notifications in a tabulated-list buffer.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'url-parse)
+(require 'forgejo)
+(require 'forgejo-api)
+(require 'forgejo-tl)
+
+(declare-function forgejo-db-save-notifications "forgejo-db.el"
+ (host notifications))
+(declare-function forgejo-db-get-notifications "forgejo-db.el"
+ (host &optional status))
+(declare-function forgejo-db-notification-unread-count "forgejo-db.el"
+ (host))
+(declare-function forgejo-db-mark-notification-read "forgejo-db.el"
+ (host id))
+(declare-function forgejo-db-clear-notifications "forgejo-db.el"
+ (host))
+(declare-function forgejo-issue-view "forgejo-issue.el"
+ (owner repo number))
+(declare-function forgejo-pull-view "forgejo-pull.el"
+ (owner repo number))
+(declare-function forgejo-buffer--relative-time "forgejo-buffer.el"
+ (time-string))
+
+(defvar forgejo-host)
+(defvar forgejo-repo--host)
+
+;;; Customization
+
+(defcustom forgejo-notification-poll-interval 300
+ "Seconds between notification polls."
+ :type 'integer
+ :group 'forgejo)
+
+(defcustom forgejo-notification-hooks nil
+ "Hook run when new notifications arrive.
+Each function receives one argument: the list of new notification alists.
+This hook fires for both API-sourced and future locally-generated
+notifications."
+ :type 'hook
+ :group 'forgejo)
+
+;;; Faces
+
+(defface forgejo-notification-face
+ '((t :inherit warning))
+ "Face for the mode-line notification indicator."
+ :group 'forgejo)
+
+;;; State
+
+(defvar forgejo-notification--timer nil
+ "Timer for periodic notification polling.")
+
+(defvar forgejo-notification--unread-count 0
+ "Cached count of unread notifications.")
+
+(defvar forgejo-notification-mode-string ""
+ "Mode-line string showing unread notification count.")
+
+(defvar forgejo-notification--eval-form
+ '(:eval forgejo-notification-mode-string)
+ "Form added to `global-mode-string' for the mode-line indicator.")
+
+;;; Polling
+
+(defun forgejo-notification--poll ()
+ "Fetch unread notifications and update state.
+Saves to DB, updates mode-line, runs hooks on new notifications,
+and refreshes the list buffer if visible."
+ (let ((host (url-host (url-generic-parse-url forgejo-host))))
+ (forgejo-api-get
+ "notifications"
+ '(("status-types" . "unread"))
+ (lambda (data _headers)
+ (let ((old-count forgejo-notification--unread-count))
+ (forgejo-db-save-notifications host data)
+ (setq forgejo-notification--unread-count (length data))
+ (forgejo-notification--mode-line-update)
+ (when (> forgejo-notification--unread-count old-count)
+ (run-hook-with-args 'forgejo-notification-hooks data))
+ (forgejo-notification--refresh-list-buffer host))))))
+
+(defun forgejo-notification--refresh-list-buffer (host)
+ "Re-render the notification list buffer for HOST if visible."
+ (when-let* ((buf (get-buffer "*forgejo-notifications*")))
+ (when (get-buffer-window buf)
+ (with-current-buffer buf
+ (forgejo-notification--render host)))))
+
+;;; Mode-line
+
+(defun forgejo-notification--mode-line-update ()
+ "Update the mode-line notification string."
+ (setq forgejo-notification-mode-string
+ (if (> forgejo-notification--unread-count 0)
+ (propertize (format " [F:%d]" forgejo-notification--unread-count)
+ 'face 'forgejo-notification-face)
+ ""))
+ (force-mode-line-update 'all))
+
+;;; Global minor mode
+
+;;;###autoload
+(define-minor-mode forgejo-modeline-mode
+ "Toggle Forgejo notification indicator in the mode line.
+Polls the Forgejo API periodically for unread notifications."
+ :global t
+ :lighter nil
+ :group 'forgejo
+ (if forgejo-modeline-mode
+ (progn
+ (unless (member forgejo-notification--eval-form global-mode-string)
+ (push forgejo-notification--eval-form global-mode-string))
+ (setq forgejo-notification--timer
+ (run-with-timer 0 forgejo-notification-poll-interval
+ #'forgejo-notification--poll))
+ (add-hook 'kill-emacs-hook #'forgejo-notification--cleanup))
+ (forgejo-notification--cleanup)))
+
+(defun forgejo-notification--cleanup ()
+ "Cancel the polling timer and clear mode-line state."
+ (when forgejo-notification--timer
+ (cancel-timer forgejo-notification--timer)
+ (setq forgejo-notification--timer nil))
+ (setq forgejo-notification--unread-count 0
+ forgejo-notification-mode-string "")
+ (setq global-mode-string
+ (delete forgejo-notification--eval-form global-mode-string))
+ (remove-hook 'kill-emacs-hook #'forgejo-notification--cleanup)
+ (force-mode-line-update 'all))
+
+;;; Notification list buffer
+
+(defvar-local forgejo-notification--host nil
+ "Hostname for the current notification list buffer.")
+
+(defvar forgejo-notification-list-mode-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "RET") #'forgejo-notification-view-at-point)
+ (define-key map (kbd "r") #'forgejo-notification-mark-read-at-point)
+ (define-key map (kbd "R") #'forgejo-notification-mark-all-read)
+ (define-key map (kbd "g") #'forgejo-notification-list-refresh)
+ (define-key map (kbd "q") #'quit-window)
+ map)
+ "Keymap for `forgejo-notification-list-mode'.")
+
+(define-derived-mode forgejo-notification-list-mode tabulated-list-mode
+ "Forgejo Notifications"
+ "Major mode for browsing Forgejo notifications."
+ :group 'forgejo
+ (setq tabulated-list-padding 1
+ tabulated-list-format
+ (vector '("Repo" 25 t)
+ '("Type" 8 nil)
+ '("Title" 50 t)
+ '("Status" 8 nil)
+ '("Updated" 12 t)))
+ (tabulated-list-init-header))
+
+(defun forgejo-notification--subject-number (url)
+ "Extract the issue/PR number from a Forgejo API subject URL.
+URL looks like \"https://host/api/v1/repos/owner/repo/issues/42\"."
+ (when (and url (string-match "/\\([0-9]+\\)\\'" url))
+ (string-to-number (match-string 1 url))))
+
+(defun forgejo-notification--entries (rows)
+ "Convert notification ROWS from DB to `tabulated-list-entries'.
+Each ROW: 0=id 1=subject_type 2=subject_title 3=subject_url
+ 4=subject_state 5=repo_owner 6=repo_name 7=status 8=updated_at.
+The entry ID is (notification-id . subject-url) so view-at-point
+can extract the issue/PR number without re-querying the DB."
+ (mapcar
+ (lambda (row)
+ (list (cons (nth 0 row) (nth 3 row))
+ (vector
+ (format "%s/%s" (or (nth 5 row) "") (or (nth 6 row) ""))
+ (or (nth 1 row) "")
+ (or (nth 2 row) "")
+ (or (nth 7 row) "")
+ (forgejo-buffer--relative-time (nth 8 row)))))
+ rows))
+
+(defun forgejo-notification--render (host)
+ "Render notifications from DB for HOST into the current buffer."
+ (let ((rows (forgejo-db-get-notifications host)))
+ (setq tabulated-list-entries (forgejo-notification--entries rows))
+ (forgejo-tl-print)
+ (goto-char (point-min))))
+
+;;;###autoload
+(defun forgejo-notification-list ()
+ "Browse Forgejo notifications."
+ (interactive)
+ (let* ((host (url-host (url-generic-parse-url forgejo-host)))
+ (buf (get-buffer-create "*forgejo-notifications*")))
+ (with-current-buffer buf
+ (forgejo-notification-list-mode)
+ (setq forgejo-notification--host host
+ forgejo-repo--host forgejo-host)
+ (forgejo-notification--render host)
+ (switch-to-buffer buf))
+ (forgejo-notification--poll)))
+
+(defun forgejo-notification-list-refresh ()
+ "Refresh the notification list."
+ (interactive)
+ (forgejo-notification--poll))
+
+;;; Actions
+
+(defun forgejo-notification-view-at-point ()
+ "Jump to the issue or PR for the notification at point."
+ (interactive)
+ (when-let* ((id-cell (tabulated-list-get-id))
+ (entry (tabulated-list-get-entry))
+ (notif-id (car id-cell))
+ (subject-url (cdr id-cell))
+ (number (forgejo-notification--subject-number subject-url)))
+ (let* ((repo-str (aref entry 0))
+ (type (aref entry 1))
+ (parts (split-string repo-str "/"))
+ (owner (nth 0 parts))
+ (repo (nth 1 parts)))
+ ;; Mark as read locally and on the server
+ (forgejo-db-mark-notification-read forgejo-notification--host notif-id)
+ (forgejo-api-put (format "notifications/threads/%d" notif-id))
+ ;; Navigate
+ (forgejo-with-host forgejo-repo--host
+ (if (string= type "Pull")
+ (forgejo-pull-view owner repo number)
+ (forgejo-issue-view owner repo number))))))
+
+(defun forgejo-notification-mark-read-at-point ()
+ "Mark the notification at point as read."
+ (interactive)
+ (when-let* ((id-cell (tabulated-list-get-id))
+ (notif-id (car id-cell)))
+ (forgejo-db-mark-notification-read forgejo-notification--host notif-id)
+ (forgejo-api-put
+ (format "notifications/threads/%d" notif-id)
+ nil
+ (lambda (_data _headers)
+ (message "Notification %d marked as read" notif-id)))
+ (forgejo-notification--render forgejo-notification--host)))
+
+(defun forgejo-notification-mark-all-read ()
+ "Mark all notifications as read."
+ (interactive)
+ (when (y-or-n-p "Mark all notifications as read? ")
+ (let ((host forgejo-notification--host))
+ (forgejo-api-put
+ "notifications"
+ nil
+ (lambda (_data _headers)
+ (forgejo-db-clear-notifications host)
+ (setq forgejo-notification--unread-count 0)
+ (forgejo-notification--mode-line-update)
+ (message "All notifications marked as read")
+ (forgejo-notification--refresh-list-buffer host))))))
+
+(provide 'forgejo-notification)
+;;; forgejo-notification.el ends here