mirror of
https://github.com/tanrax/org-social.el
synced 2026-01-07 05:43:32 +01:00
341 lines
13 KiB
EmacsLisp
341 lines
13 KiB
EmacsLisp
;;; org-social-realtime.el --- Real-time notifications via SSE for Org-social -*- lexical-binding: t -*- -*- coding: utf-8 -*-
|
|
|
|
;; SPDX-License-Identifier: GPL-3.0
|
|
;; Author: Andros Fenollosa <hi@andros.dev>
|
|
;; Version: 2.10
|
|
;; URL: https://github.com/tanrax/org-social.el
|
|
|
|
;;; Commentary:
|
|
|
|
;; Real-time notifications system using Server-Sent Events (SSE) from the relay.
|
|
;; Connects to the relay's /sse/notifications/ endpoint and shows desktop
|
|
;; notifications for mentions, reactions, replies, and boosts.
|
|
|
|
;;; Code:
|
|
|
|
(require 'url-parse)
|
|
(require 'json)
|
|
(require 'notifications)
|
|
(require 'org-social-variables)
|
|
|
|
;; Forward declarations
|
|
(declare-function org-social-ui-thread "org-social-ui-thread" (post-url))
|
|
|
|
;;; Variables
|
|
|
|
(defvar org-social-realtime--process nil
|
|
"Network process for the SSE connection.")
|
|
|
|
(defvar org-social-realtime--buffer nil
|
|
"Buffer for the SSE connection data.")
|
|
|
|
(defvar org-social-realtime--partial-data ""
|
|
"Buffer for incomplete SSE event data.")
|
|
|
|
(defvar org-social-realtime--headers-received nil
|
|
"Whether HTTP response headers have been received and skipped.")
|
|
|
|
(defvar org-social-realtime--reconnect-timer nil
|
|
"Timer for automatic reconnection after disconnect.")
|
|
|
|
(defvar org-social-realtime--reconnect-delay 5
|
|
"Seconds to wait before attempting to reconnect after disconnect.")
|
|
|
|
;;; Helper functions
|
|
|
|
(defun org-social-realtime--get-logo-path ()
|
|
"Get the path to the org-social logo image.
|
|
Returns the absolute path to org-social-logo.png in the package directory."
|
|
(let ((logo-file "org-social-logo.png"))
|
|
;; Try to find the logo in the load-path
|
|
(or (locate-library logo-file)
|
|
;; Fallback: try relative to this file
|
|
(expand-file-name logo-file
|
|
(file-name-directory
|
|
(or load-file-name buffer-file-name))))))
|
|
|
|
(defun org-social-realtime--get-author-nick (post-url)
|
|
"Extract author feed URL from POST-URL and get their nick.
|
|
POST-URL format: https://example.com/username/social.org#timestamp"
|
|
(when (string-match "\\(.*\\)#" post-url)
|
|
(let* ((author-url (match-string 1 post-url))
|
|
(feed (seq-find (lambda (f)
|
|
(string= (alist-get 'url f) author-url))
|
|
(when (boundp 'org-social-variables--feeds)
|
|
org-social-variables--feeds))))
|
|
(if feed
|
|
(alist-get 'nick feed)
|
|
;; Fallback: extract username from URL path
|
|
(when (string-match "://[^/]+/\\([^/]+\\)/" author-url)
|
|
(match-string 1 author-url))))))
|
|
|
|
(defun org-social-realtime--show-notification (notification-data)
|
|
"Show desktop notification for NOTIFICATION-DATA.
|
|
NOTIFICATION-DATA is a parsed JSON object from the SSE event."
|
|
(let* ((type (alist-get 'type notification-data))
|
|
(post (alist-get 'post notification-data))
|
|
(author-nick (org-social-realtime--get-author-nick post))
|
|
(title "")
|
|
(body "")
|
|
(logo-path (org-social-realtime--get-logo-path)))
|
|
|
|
;; Build notification title and body based on type
|
|
(pcase type
|
|
("mention"
|
|
(setq title "New Mention")
|
|
(setq body (format "%s mentioned you" author-nick)))
|
|
|
|
("reaction"
|
|
(let ((emoji (alist-get 'emoji notification-data)))
|
|
(setq title "New Reaction")
|
|
(setq body (format "%s reacted with %s" author-nick emoji))))
|
|
|
|
("reply"
|
|
(setq title "New Reply")
|
|
(setq body (format "%s replied to your post" author-nick)))
|
|
|
|
("boost"
|
|
(setq title "New Boost")
|
|
(setq body (format "%s boosted your post" author-nick))))
|
|
|
|
;; Show notification
|
|
(when (and title body)
|
|
(notifications-notify
|
|
:title title
|
|
:body body
|
|
:app-name "Org Social"
|
|
:category "social"
|
|
:urgency 'normal
|
|
:image-path logo-path
|
|
:actions '("show" "Show")
|
|
:on-action (lambda (_id action)
|
|
(when (string= action "show")
|
|
(org-social-realtime--open-thread post)))))))
|
|
|
|
(defun org-social-realtime--open-thread (post-url)
|
|
"Open thread buffer for POST-URL."
|
|
(when (fboundp 'org-social-ui-thread)
|
|
(require 'org-social-ui-thread)
|
|
(org-social-ui-thread post-url)))
|
|
|
|
(defun org-social-realtime--process-sse-event (event-type event-data)
|
|
"Process an SSE event with EVENT-TYPE and EVENT-DATA."
|
|
(cond
|
|
;; Connection established
|
|
((string= event-type "connected")
|
|
(message "Org Social: Connected to real-time notifications"))
|
|
|
|
;; Notification event
|
|
((string= event-type "notification")
|
|
(condition-case err
|
|
(let ((notification (json-read-from-string event-data)))
|
|
(org-social-realtime--show-notification notification))
|
|
(error
|
|
(message "Org Social [ERROR]: Failed to parse notification: %s" (error-message-string err)))))))
|
|
|
|
(defun org-social-realtime--parse-sse-data (data)
|
|
"Parse SSE DATA and extract event type and data fields.
|
|
Returns a cons cell (EVENT-TYPE . EVENT-DATA) or nil if incomplete."
|
|
(let ((lines (split-string data "\n"))
|
|
(event-type nil)
|
|
(event-data nil))
|
|
(dolist (line lines)
|
|
(cond
|
|
((string-prefix-p "event: " line)
|
|
(setq event-type (string-trim (substring line 7))))
|
|
((string-prefix-p "data: " line)
|
|
(setq event-data (string-trim (substring line 6))))))
|
|
(when (and event-type event-data)
|
|
(cons event-type event-data))))
|
|
|
|
(defun org-social-realtime--filter (_proc string)
|
|
"Process filter for SSE connection receiving STRING."
|
|
;; Append new data to partial buffer
|
|
(setq org-social-realtime--partial-data
|
|
(concat org-social-realtime--partial-data string))
|
|
|
|
;; Skip HTTP headers on first data
|
|
(unless org-social-realtime--headers-received
|
|
(when (string-match "\r\n\r\n" org-social-realtime--partial-data)
|
|
;; Headers received, keep everything after them
|
|
(setq org-social-realtime--partial-data
|
|
(substring org-social-realtime--partial-data (match-end 0)))
|
|
(setq org-social-realtime--headers-received t)))
|
|
|
|
;; Process complete events (separated by double newline) only after headers
|
|
(when org-social-realtime--headers-received
|
|
(while (string-match "\n\n" org-social-realtime--partial-data)
|
|
(let* ((event-str (substring org-social-realtime--partial-data
|
|
0 (match-beginning 0))))
|
|
;; Remove processed data first
|
|
(setq org-social-realtime--partial-data
|
|
(substring org-social-realtime--partial-data (match-end 0)))
|
|
|
|
;; Only parse and process if event-str is not empty
|
|
(unless (string-empty-p (string-trim event-str))
|
|
(let ((parsed (org-social-realtime--parse-sse-data event-str)))
|
|
(when parsed
|
|
(org-social-realtime--process-sse-event (car parsed) (cdr parsed)))))))))
|
|
|
|
(defun org-social-realtime--sentinel (proc event)
|
|
"Process sentinel for SSE connection PROC with EVENT."
|
|
(unless (process-live-p proc)
|
|
(message "Org Social: Real-time notifications disconnected (reason: %s)" (string-trim event))
|
|
(setq org-social-realtime--process nil)
|
|
|
|
;; Schedule automatic reconnection if enabled
|
|
(when (and (boundp 'org-social-realtime-notifications)
|
|
org-social-realtime-notifications
|
|
org-social-relay
|
|
org-social-my-public-url)
|
|
(message "Org Social: Reconnecting in %d seconds..." org-social-realtime--reconnect-delay)
|
|
(when org-social-realtime--reconnect-timer
|
|
(cancel-timer org-social-realtime--reconnect-timer))
|
|
(setq org-social-realtime--reconnect-timer
|
|
(run-at-time org-social-realtime--reconnect-delay nil
|
|
#'org-social-realtime-connect)))))
|
|
|
|
;;; Public functions
|
|
|
|
;;;###autoload
|
|
(defun org-social-realtime-connect ()
|
|
"Connect to the relay's SSE endpoint for real-time notifications.
|
|
Requires `org-social-relay' and `org-social-my-public-url' to be configured."
|
|
(interactive)
|
|
|
|
;; Cancel any pending reconnection timer
|
|
(when org-social-realtime--reconnect-timer
|
|
(cancel-timer org-social-realtime--reconnect-timer)
|
|
(setq org-social-realtime--reconnect-timer nil))
|
|
|
|
;; Check requirements
|
|
(unless (and org-social-relay org-social-my-public-url)
|
|
(user-error "Both org-social-relay and org-social-my-public-url must be configured"))
|
|
|
|
;; Disconnect if already connected
|
|
(when (and org-social-realtime--process
|
|
(process-live-p org-social-realtime--process))
|
|
(org-social-realtime-disconnect))
|
|
|
|
;; Parse relay URL
|
|
(let* ((feed-encoded (url-hexify-string org-social-my-public-url))
|
|
(sse-url (format "%s/sse/notifications/?feed=%s" org-social-relay feed-encoded))
|
|
(parsed-url (url-generic-parse-url sse-url))
|
|
(host (url-host parsed-url))
|
|
(port (or (url-port parsed-url)
|
|
(if (string= (url-type parsed-url) "https") 443 80)))
|
|
(path (url-filename parsed-url))
|
|
(use-tls (string= (url-type parsed-url) "https")))
|
|
|
|
(message "Org Social: Connecting to real-time notifications...")
|
|
|
|
;; Reset state
|
|
(setq org-social-realtime--partial-data "")
|
|
(setq org-social-realtime--headers-received nil)
|
|
|
|
;; Create buffer for connection
|
|
(setq org-social-realtime--buffer
|
|
(generate-new-buffer " *org-social-notifications*"))
|
|
|
|
;; Start network process
|
|
(condition-case err
|
|
(let ((proc (open-network-stream
|
|
"org-social-notifications"
|
|
org-social-realtime--buffer
|
|
host
|
|
port
|
|
:type (if use-tls 'tls 'plain)
|
|
:nowait nil)))
|
|
|
|
;; Set process handlers
|
|
(set-process-filter proc #'org-social-realtime--filter)
|
|
(set-process-sentinel proc #'org-social-realtime--sentinel)
|
|
(set-process-coding-system proc 'utf-8 'utf-8)
|
|
|
|
;; Send HTTP GET request with same headers as curl
|
|
(process-send-string
|
|
proc
|
|
(format "GET %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: Emacs/%s org-social\r\nAccept: */*\r\n\r\n"
|
|
path host emacs-version))
|
|
|
|
(setq org-social-realtime--process proc))
|
|
|
|
(error
|
|
(message "Org Social [ERROR]: Failed to connect: %s" (error-message-string err))
|
|
(when (buffer-live-p org-social-realtime--buffer)
|
|
(kill-buffer org-social-realtime--buffer))
|
|
(setq org-social-realtime--process nil)
|
|
(setq org-social-realtime--buffer nil)))))
|
|
|
|
;;;###autoload
|
|
(defun org-social-realtime-disconnect ()
|
|
"Disconnect from the relay's SSE endpoint."
|
|
(interactive)
|
|
;; Cancel any pending reconnection timer
|
|
(when org-social-realtime--reconnect-timer
|
|
(cancel-timer org-social-realtime--reconnect-timer)
|
|
(setq org-social-realtime--reconnect-timer nil))
|
|
|
|
(when org-social-realtime--process
|
|
(delete-process org-social-realtime--process)
|
|
(setq org-social-realtime--process nil)
|
|
(message "Org Social: Disconnected from real-time notifications"))
|
|
(when org-social-realtime--buffer
|
|
(kill-buffer org-social-realtime--buffer)
|
|
(setq org-social-realtime--buffer nil)))
|
|
|
|
;;;###autoload
|
|
(defun org-social-realtime-toggle ()
|
|
"Toggle real-time notifications connection."
|
|
(interactive)
|
|
(if (and org-social-realtime--process
|
|
(process-live-p org-social-realtime--process))
|
|
(org-social-realtime-disconnect)
|
|
(org-social-realtime-connect)))
|
|
|
|
;;;###autoload
|
|
(defun org-social-realtime-status ()
|
|
"Show the status of the real-time notifications connection."
|
|
(interactive)
|
|
(if (and org-social-realtime--process
|
|
(process-live-p org-social-realtime--process))
|
|
(message "Org Social: Real-time notifications CONNECTED")
|
|
(message "Org Social: Real-time notifications DISCONNECTED")))
|
|
|
|
;;; Auto-connect on Emacs startup
|
|
|
|
;;;###autoload
|
|
(defun org-social-realtime-maybe-connect ()
|
|
"Connect to real-time notifications if enabled in configuration.
|
|
Automatically called on Emacs startup when realtime notifications enabled."
|
|
(interactive)
|
|
(cond
|
|
;; Already connected
|
|
((and org-social-realtime--process
|
|
(process-live-p org-social-realtime--process))
|
|
(message "Org Social: Real-time notifications already connected"))
|
|
|
|
;; Feature not enabled
|
|
((not (and (boundp 'org-social-realtime-notifications)
|
|
org-social-realtime-notifications))
|
|
(message "Org Social: Real-time notifications disabled"))
|
|
|
|
;; Missing configuration
|
|
((not (and org-social-relay org-social-my-public-url))
|
|
(message "Org Social: Waiting for account configuration"))
|
|
|
|
;; All good, connect
|
|
(t
|
|
(org-social-realtime-connect))))
|
|
|
|
;; Auto-connect when Emacs starts if configuration is enabled
|
|
;; Using after-init-hook instead of emacs-startup-hook for better timing
|
|
;; (runs after init files are loaded, ensuring account configuration is present)
|
|
;;;###autoload
|
|
(with-eval-after-load 'org-social
|
|
(add-hook 'after-init-hook #'org-social-realtime-maybe-connect))
|
|
|
|
(provide 'org-social-realtime)
|
|
|
|
;;; org-social-realtime.el ends here
|