(require 'cl-lib)
(require 'dns)
(require 'files)
(require 'map)
(require 'ement-lib)
(require 'ement-room)
(require 'ement-notifications)
(require 'ement-notify)
(defvar ement-sessions nil
"Alist of active `ement-session' sessions, keyed by MXID.")
(defvar ement-syncs nil
"Alist of outstanding sync processes for each session.")
(defvar ement-users (make-hash-table :test #'equal)
"Hash table storing user structs keyed on user ID.")
(defvar ement-progress-reporter nil
"Used to report progress while processing sync events.")
(defvar ement-progress-value nil
"Used to report progress while processing sync events.")
(defvar ement-sync-callback-hook
'(ement--update-room-buffers ement--auto-sync ement-tabulated-room-list-auto-update
ement-room-list-auto-update)
"Hook run after `ement--sync-callback'.
Hooks are called with one argument, the session that was
synced.")
(defvar ement-event-hook
'(ement-notify ement--process-event ement--put-event)
"Hook called for events.
Each function is called with three arguments: the event, the
room, and the session. This hook isn't intended to be modified
by users; ones who do so should know what they're doing.")
(defvar ement-default-sync-filter
'((room (state (lazy_load_members . t))
(timeline (lazy_load_members . t))))
"Default filter for sync requests.")
(defvar ement-images-queue (make-plz-queue :limit 5)
"`plz' HTTP request queue for image requests.")
(defvar ement-read-receipt-idle-timer nil
"Idle timer used to update read receipts.")
(defvar ement-connect-user-id-history nil
"History list of user IDs entered into `ement-connect'.")
(defvar ement-room-avatar-max-width)
(defvar ement-room-avatar-max-height)
(defgroup ement nil
"Options for Ement, the Matrix client."
:group 'comm)
(defcustom ement-save-sessions nil
"Save session to disk.
Writes the session file when Emacs is killed."
:type 'boolean
:set (lambda (option value)
(set-default option value)
(if value
(add-hook 'kill-emacs-hook #'ement--kill-emacs-hook)
(remove-hook 'kill-emacs-hook #'ement--kill-emacs-hook))))
(defcustom ement-sessions-file "~/.cache/ement.el"
"Save username and access token to this file."
:type 'file)
(defcustom ement-auto-sync t
"Automatically sync again after syncing."
:type 'boolean)
(defcustom ement-after-initial-sync-hook
'(ement-room-list--after-initial-sync ement-view-initial-rooms ement--link-children ement--run-idle-timer)
"Hook run after initial sync.
Run with one argument, the session synced."
:type 'hook)
(defcustom ement-initial-sync-timeout 40
"Timeout in seconds for initial sync requests.
For accounts in many rooms, the Matrix server may take some time
to prepare the initial sync response, and increasing this timeout
might be necessary."
:type 'integer)
(defcustom ement-auto-view-rooms nil
"Rooms to view after initial sync.
Alist mapping user IDs to a list of room aliases/IDs to open buffers for."
:type '(alist :key-type (string :tag "Local user ID")
:value-type (repeat (string :tag "Room alias/ID"))))
(defcustom ement-disconnect-hook '(ement-kill-buffers ement--stop-idle-timer)
"Functions called when disconnecting.
That is, when calling command `ement-disconnect'. Functions are
called with no arguments."
:type 'hook)
(defcustom ement-view-room-display-buffer-action '(display-buffer-same-window)
"Display buffer action to use when opening room buffers.
See function `display-buffer' and info node `(elisp) Buffer
Display Action Functions'."
:type 'function)
(defcustom ement-auto-view-room-display-buffer-action '(display-buffer-no-window)
"Display buffer action to use when automatically opening room buffers.
That is, rooms listed in `ement-auto-view-rooms', which see. See
function `display-buffer' and info node `(elisp) Buffer Display
Action Functions'."
:type 'function)
(defcustom ement-interrupted-sync-hook '(ement-interrupted-sync-warning)
"Functions to call when syncing of a session is interrupted.
Only called when `ement-auto-sync' is non-nil. Functions are
called with one argument, the session whose sync was interrupted.
This hook allows the user to customize how sync interruptions are
handled (e.g. how to be notified)."
:type 'hook
:options '(ement-interrupted-sync-message ement-interrupted-sync-warning))
(defcustom ement-sso-server-port 4567
"TCP port used for local HTTP server for SSO logins.
It shouldn't usually be necessary to change this."
:type 'integer)
(cl-defun ement-connect (&key user-id password uri-prefix session)
"Connect to Matrix with USER-ID and PASSWORD, or using SESSION.
Interactively, with prefix, ignore a saved session and log in
again; otherwise, use a saved session if `ement-save-sessions' is
enabled and a saved session is available, or prompt to log in if
not enabled or available.
If USERID or PASSWORD are not specified, the user will be
prompted for them.
If URI-PREFIX is specified, it should be the prefix of the
server's API URI, including protocol, hostname, and optionally
the port, e.g.
\"https://matrix-client.matrix.org\"
\"http://localhost:8080\""
(interactive (if current-prefix-arg
(list :user-id (read-string "User ID: " nil 'ement-connect-user-id-history))
(unless ement-sessions
(condition-case err
(setf ement-sessions (ement--read-sessions))
(error (display-warning 'ement (format "Unable to read session data from disk (%s). Prompting to log in again."
(error-message-string err))))))
(cl-case (length ement-sessions)
(0 (list :user-id (read-string "User ID: " nil 'ement-connect-user-id-history)))
(1 (list :session (cdar ement-sessions)))
(otherwise (list :session (ement-complete-session))))))
(let (sso-server-process)
(cl-labels ((new-session ()
(unless (string-match (rx bos "@" (group (1+ (not (any ":")))) ":" (group (optional (1+ (not (any blank)))))) user-id)
(user-error "Invalid user ID format: use @USERNAME:SERVER"))
(let* ((username (match-string 1 user-id))
(server-name (match-string 2 user-id))
(uri-prefix (or uri-prefix (ement--hostname-uri server-name)))
(user (make-ement-user :id user-id :username username))
(server (make-ement-server :name server-name :uri-prefix uri-prefix))
(transaction-id (ement--initial-transaction-id))
(initial-device-display-name (format "Ement.el: %s@%s"
(or user-login-name "[unknown user-login-name]")
(or (system-name) "[unknown system-name]")))
(device-id (secure-hash 'sha256 initial-device-display-name)))
(make-ement-session :user user :server server :transaction-id transaction-id
:device-id device-id :initial-device-display-name initial-device-display-name
:events (make-hash-table :test #'equal))))
(password-login ()
(pcase-let* (((cl-struct ement-session user device-id initial-device-display-name) session)
((cl-struct ement-user id) user)
(data (ement-alist "type" "m.login.password"
"identifier"
(ement-alist "type" "m.id.user"
"user" id)
"password" (or password
(read-passwd (format "Password for %s: " id)))
"device_id" device-id
"initial_device_display_name" initial-device-display-name)))
(ement-api session "login" :method 'post :data (json-encode data)
:then (apply-partially #'ement--login-callback session))
(ement-message "Logging in with password...")))
(sso-filter (process string)
(when (string-match (rx "GET /?loginToken=" (group (0+ nonl)) " " (0+ nonl)) string)
(unwind-protect
(pcase-let* ((token (match-string 1 string))
((cl-struct ement-session user device-id initial-device-display-name)
session)
((cl-struct ement-user id) user)
(data (ement-alist
"type" "m.login.token"
"identifier" (ement-alist "type" "m.id.user"
"user" id)
"token" token
"device_id" device-id
"initial_device_display_name" initial-device-display-name)))
(ement-api session "login" :method 'post
:data (json-encode data)
:then (apply-partially #'ement--login-callback session))
(process-send-string process "HTTP/1.0 202 Accepted
Content-Type: text/plain; charset=utf-8
Ement: SSO login accepted; session token received. Connecting to Matrix server. (You may close this page.)")
(process-send-eof process))
(delete-process sso-server-process)
(delete-process process))))
(sso-login ()
(setf sso-server-process
(make-network-process
:name "ement-sso" :family 'ipv4 :host 'local :service ement-sso-server-port
:filter #'sso-filter :server t :noquery t))
(run-at-time 120 nil (lambda ()
(when (process-live-p sso-server-process)
(delete-process sso-server-process))))
(let ((url (concat (ement-server-uri-prefix (ement-session-server session))
"/_matrix/client/r0/login/sso/redirect?redirectUrl=http://localhost:"
(number-to-string ement-sso-server-port))))
(funcall browse-url-secondary-browser-function url)
(message "Browsing to single sign-on page <%s>..." url)))
(flows-callback (data)
(let ((flows (cl-loop for flow across (map-elt data 'flows)
for type = (map-elt flow 'type)
when (member type '("m.login.password" "m.login.sso"))
collect type)))
(pcase (length flows)
(0 (error "Ement: No supported login flows: Server:%S Supported flows:%S"
(ement-server-uri-prefix (ement-session-server session))
(map-elt data 'flows)))
(1 (pcase (car flows)
("m.login.password" (password-login))
("m.login.sso" (sso-login))
(_ (error "Ement: Unsupported login flow: %s Server:%S Supported flows:%S"
(car flows) (ement-server-uri-prefix (ement-session-server session))
(map-elt data 'flows)))))
(_ (pcase (completing-read "Select authentication method: "
(cl-loop for flow in flows
collect (string-trim-left flow (rx "m.login."))))
("password" (password-login))
("sso" (sso-login))
(else (error "Ement: Unsupported login flow:%S Server:%S Supported flows:%S"
else (ement-server-uri-prefix (ement-session-server session))
(map-elt data 'flows)))))))))
(if session
(let ((user-id (ement-user-id (ement-session-user session))))
(setf (alist-get user-id ement-sessions nil nil #'equal) session)
(ement--sync session :timeout ement-initial-sync-timeout))
(unless user-id
(setf user-id (read-string "User ID: " nil 'ement-connect-user-id-history)))
(setf session (new-session))
(when (ement-api session "login" :then #'flows-callback)
(message "Ement: Checking server's login flows..."))))))
(defun ement-disconnect (sessions)
"Disconnect from SESSIONS.
Interactively, with prefix, disconnect from all sessions. If
`ement-auto-sync' is enabled, stop syncing, and clear the session
data. When enabled, write the session to disk. Any existing
room buffers are left alive and can be read, but other commands
in them won't work."
(interactive (list (if current-prefix-arg
(mapcar #'cdr ement-sessions)
(list (ement-complete-session)))))
(when ement-save-sessions
(ement--write-sessions ement-sessions))
(dolist (session sessions)
(let ((user-id (ement-user-id (ement-session-user session))))
(when-let ((process (map-elt ement-syncs session)))
(ignore-errors
(delete-process process)))
(setf (alist-get session ement-syncs nil nil #'equal) nil
(alist-get user-id ement-sessions nil 'remove #'equal) nil)))
(unless ement-sessions
(clrhash ement-users))
(run-hooks 'ement-disconnect-hook)
(message "Ement: Disconnected (%s)"
(string-join (cl-loop for session in sessions
collect (ement-user-id (ement-session-user session)))
", ")))
(defun ement-kill-buffers ()
"Kill all Ement buffers.
Useful in, e.g. `ement-disconnect-hook', which see."
(interactive)
(dolist (buffer (buffer-list))
(when (string-prefix-p "ement-" (symbol-name (buffer-local-value 'major-mode buffer)))
(kill-buffer buffer))))
(defun ement--login-callback (session data)
"Record DATA from logging in to SESSION and do initial sync."
(pcase-let* (((cl-struct ement-session (user (cl-struct ement-user (id user-id)))) session)
((map ('access_token token) ('device_id device-id)) data))
(setf (ement-session-token session) token
(ement-session-device-id session) device-id
(alist-get user-id ement-sessions nil nil #'equal) session)
(ement--sync session :timeout ement-initial-sync-timeout)))
(defun ement-interrupted-sync-warning (session)
"Display a warning that syncing of SESSION was interrupted."
(display-warning
'ement
(format
(substitute-command-keys
"\\<ement-room-mode-map>Syncing of session <%s> was interrupted. Use command `ement-room-sync' in a room buffer to retry.")
(ement-user-id (ement-session-user session)))
:error))
(defun ement-interrupted-sync-message (session)
"Display a message that syncing of SESSION was interrupted."
(message
(substitute-command-keys
"\\<ement-room-mode-map>Syncing of session <%s> was interrupted. Use command `ement-room-sync' in a room buffer to retry.")
(ement-user-id (ement-session-user session))))
(defun ement--run-idle-timer (&rest _ignore)
"Run idle timer that updates read receipts.
To be called from `ement-after-initial-sync-hook'. Timer is
stored in `ement-read-receipt-idle-timer'."
(unless (timerp ement-read-receipt-idle-timer)
(setf ement-read-receipt-idle-timer (run-with-idle-timer 3 t #'ement-room-read-receipt-idle-timer))))
(defun ement--stop-idle-timer (&rest _ignore)
"Stop idle timer stored in `ement-read-receipt-idle-timer'.
To be called from `ement-disconnect-hook'."
(unless ement-sessions
(when (timerp ement-read-receipt-idle-timer)
(cancel-timer ement-read-receipt-idle-timer)
(setf ement-read-receipt-idle-timer nil))))
(defun ement-view-initial-rooms (session)
"View rooms for SESSION configured in `ement-auto-view-rooms'."
(when-let (rooms (alist-get (ement-user-id (ement-session-user session))
ement-auto-view-rooms nil nil #'equal))
(dolist (alias/id rooms)
(when-let (room (cl-find-if (lambda (room)
(or (equal alias/id (ement-room-canonical-alias room))
(equal alias/id (ement-room-id room))))
(ement-session-rooms session)))
(let ((ement-view-room-display-buffer-action ement-auto-view-room-display-buffer-action))
(ement-view-room room session))))))
(defun ement--initial-transaction-id ()
"Return an initial transaction ID for a new session."
(cl-parse-integer
(secure-hash 'sha256 (prin1-to-string (list (current-time) (system-name))))
:end 8 :radix 16))
(defsubst ement--sync-messages-p (session)
"Return non-nil if sync-related messages should be shown for SESSION."
(or (not (ement-session-has-synced-p session))
(not ement-auto-sync)))
(defun ement--hostname-uri (hostname)
"Return the \".well-known\" URI for server HOSTNAME.
If no URI is found, prompt the user for the hostname."
(cl-labels ((fail-prompt ()
(let ((input (read-string "Auto-discovery of server's well-known URI failed. Input server hostname, or leave blank to use server name: ")))
(pcase input
("" hostname)
(_ input))))
(parse (string)
(if-let* ((object (ignore-errors (json-read-from-string string)))
(url (map-nested-elt object '(m.homeserver base_url)))
((string-match-p
(rx bos "http" (optional "s") "://" (1+ nonl))
url)))
url
(fail-prompt))))
(condition-case err
(let ((response (plz 'get (concat "https://" hostname "/.well-known/matrix/client")
:as 'response :then 'sync)))
(if (plz-response-p response)
(pcase (plz-response-status response)
(200 (parse (plz-response-body response)))
(404 (fail-prompt))
(_ (warn "Ement: `plz' request for .well-known URI returned unexpected code: %s"
(plz-response-status response))
(fail-prompt)))
(warn "Ement: `plz' request for .well-known URI did not return a `plz' response")
(fail-prompt)))
(error (warn "Ement: `plz' request for .well-known URI signaled an error: %S" err)
(fail-prompt)))))
(cl-defun ement--sync (session &key force quiet
(timeout 40) (filter ement-default-sync-filter))
"Send sync request for SESSION.
If SESSION has a `next-batch' token, it's used. If FORCE, first
delete any outstanding sync processes. If QUIET, don't show a
message about syncing this time. Cancel request after TIMEOUT
seconds.
FILTER may be an alist representing a raw event filter (i.e. not
a filter ID). When unspecified, the value of
`ement-default-sync-filter' is used. The filter is encoded with
`json-encode'. To use no filter, specify FILTER as nil."
(when (map-elt ement-syncs session)
(if force
(condition-case err
(delete-process (map-elt ement-syncs session))
(ement-api-error (cl-assert (equal "curl process killed" (plz-error-message (cl-third err))))
(message "Ement: Forcing new sync")))
(user-error "Ement: Already syncing this session")))
(pcase-let* (((cl-struct ement-session next-batch) session)
(params (remove
nil (list (list "full_state" (if next-batch "false" "true"))
(when filter
(list "filter" (json-encode filter)))
(when next-batch
(list "since" next-batch))
(when next-batch
(list "timeout" "30000")))))
(sync-start-time (time-to-seconds))
(process (ement-api session "sync" :params params
:timeout timeout
:then (apply-partially #'ement--sync-callback session)
:else (lambda (plz-error)
(setf (map-elt ement-syncs session) nil)
(pcase-let (((cl-struct plz-error curl-error response) plz-error)
(reason))
(cond ((when response
(pcase (plz-response-status response)
((or 429 502) (setf reason "failed")))))
((pcase curl-error
(`(28 . ,_) (setf reason "timed out")))))
(if reason
(if (not ement-auto-sync)
(run-hook-with-args 'ement-interrupted-sync-hook session)
(message "Ement: Sync %s (%s). Syncing again..."
reason (ement-user-id (ement-session-user session)))
(ement--sync session :timeout timeout :quiet t))
(pcase curl-error
(`(,code . ,message)
(signal 'ement-api-error (list (format "Ement: Network error: %s: %s" code message)
plz-error)))
(_ (signal 'ement-api-error (list "Ement: Unrecognized network error" plz-error)))))))
:json-read-fn (lambda ()
"Print a message, then call `ement--json-parse-buffer'."
(when (ement--sync-messages-p session)
(message "Ement: Response arrived after %.2f seconds. Reading %s JSON response..."
(- (time-to-seconds) sync-start-time)
(file-size-human-readable (buffer-size))))
(let ((start-time (time-to-seconds)))
(prog1 (ement--json-parse-buffer)
(when (ement--sync-messages-p session)
(message "Ement: Reading JSON took %.2f seconds"
(- (time-to-seconds) start-time)))))))))
(when process
(setf (map-elt ement-syncs session) process)
(when (and (not quiet) (ement--sync-messages-p session))
(ement-message "Sync request sent. Waiting for response...")))))
(defun ement--sync-callback (session data)
"Process sync DATA for SESSION.
Runs `ement-sync-callback-hook' with SESSION."
(setf (map-elt ement-syncs session) nil)
(pcase-let* (((map rooms ('next_batch next-batch) ('account_data (map ('events account-data-events))))
data)
((map ('join joined-rooms) ('invite invited-rooms) ('leave left-rooms)) rooms)
(num-events (+
(* 3 (cl-loop for (_id . room) in joined-rooms
sum (length (map-nested-elt room '(state events)))
sum (length (map-nested-elt room '(timeline events)))))
(cl-loop for (_id . room) in invited-rooms
sum (length (map-nested-elt room '(invite_state events)))))))
(cl-callf2 append (cl-coerce account-data-events 'list) (ement-session-account-data session))
(ement-with-progress-reporter (:when (ement--sync-messages-p session)
:reporter ("Ement: Reading events..." 0 num-events))
(mapc (apply-partially #'ement--push-left-room-events session) left-rooms)
(mapc (apply-partially #'ement--push-invite-room-events session) invited-rooms)
(mapc (apply-partially #'ement--push-joined-room-events session) joined-rooms))
(setf (ement-session-next-batch session) next-batch)
(run-hook-with-args 'ement-sync-callback-hook session)
(when (ement--sync-messages-p session)
(message (concat "Ement: Sync done."
(unless (ement-session-has-synced-p session)
(run-hook-with-args 'ement-after-initial-sync-hook session)
(setf (ement-session-has-synced-p session) t)
" Use commands `ement-list-rooms' or `ement-view-room' to view a room."))))))
(defun ement--push-invite-room-events (session invited-room)
"Push events for INVITED-ROOM into that room in SESSION."
(ement--push-joined-room-events session invited-room 'invite))
(defun ement--auto-sync (session)
"If `ement-auto-sync' is non-nil, sync SESSION again."
(when ement-auto-sync
(ement--sync session)))
(defun ement--update-room-buffers (session)
"Insert new events into SESSION's rooms which have buffers.
To be called in `ement-sync-callback-hook'."
(let* ((buffers (cl-loop for room in (ement-session-rooms session)
for buffer = (map-elt (ement-room-local room) 'buffer)
when (buffer-live-p buffer)
collect buffer)))
(dolist (buffer buffers)
(with-current-buffer buffer
(save-window-excursion
(when-let ((buffer-window (get-buffer-window buffer)))
(select-window buffer-window))
(cl-assert ement-room)
(when (ement-room-ephemeral ement-room)
(ement-room--process-events (ement-room-ephemeral ement-room))
(setf (ement-room-ephemeral ement-room) nil))
(when-let ((new-events (alist-get 'new-events (ement-room-local ement-room))))
(ement-room--process-events (reverse new-events))
(setf (alist-get 'new-events (ement-room-local ement-room)) nil))
(when-let ((new-events (alist-get 'new-account-data-events (ement-room-local ement-room))))
(ement-room--process-events new-events)
(setf (alist-get 'new-account-data-events (ement-room-local ement-room)) nil)))))))
(cl-defun ement--push-joined-room-events (session joined-room &optional (status 'join))
"Push events for JOINED-ROOM into that room in SESSION.
Also used for left rooms, in which case STATUS should be set to
`leave'."
(pcase-let* ((`(,id . ,event-types) joined-room)
(id (symbol-name id)) (room (or (cl-find-if (lambda (room)
(equal id (ement-room-id room)))
(ement-session-rooms session))
(car (push (make-ement-room :id id) (ement-session-rooms session)))))
((map summary state ephemeral timeline
('invite_state (map ('events invite-state-events)))
('account_data (map ('events account-data-events)))
('unread_notifications unread-notifications))
event-types)
(latest-timestamp))
(setf (ement-room-status room) status
(ement-room-unread-notifications room) unread-notifications)
(cl-loop for event across-ref invite-state-events do
(setf event (ement--make-event event))
(push event (ement-room-invite-state room))
(run-hook-with-args 'ement-event-hook event room session))
(dolist (parameter '(m.heroes m.joined_member_count m.invited_member_count))
(when (alist-get parameter summary)
(setf (alist-get parameter (ement-room-summary room)) (alist-get parameter summary))))
(cl-loop for event across account-data-events
for type = (alist-get 'type event)
do (setf (alist-get type (ement-room-account-data room) nil nil #'equal) event))
(cl-callf2 append (mapcar #'ement--make-event account-data-events)
(alist-get 'new-account-data-events (ement-room-local room)))
(cl-macrolet ((push-events (type accessor)
`(let ((ts 0))
(cl-loop for event across-ref (alist-get 'events ,type)
do (setf event (ement--make-event event))
do (push event (,accessor room))
(when (ement--sync-messages-p session)
(ement-progress-update))
(when (> (ement-event-origin-server-ts event) ts)
(setf ts (ement-event-origin-server-ts event))))
ts)))
(setf latest-timestamp
(max (push-events state ement-room-state)
(push-events timeline ement-room-timeline)))
(cl-callf2 append (cl-coerce (alist-get 'events timeline) 'list)
(alist-get 'new-events (ement-room-local room)))
(when (> latest-timestamp (or (ement-room-latest-ts room) 0))
(setf (ement-room-latest-ts room) latest-timestamp))
(unless (ement-session-has-synced-p session)
(setf (ement-room-prev-batch room) (alist-get 'prev_batch timeline))))
(cl-loop for event across (alist-get 'events state)
do (run-hook-with-args 'ement-event-hook event room session)
(when (ement--sync-messages-p session)
(ement-progress-update)))
(cl-loop for event across (alist-get 'events timeline)
do (run-hook-with-args 'ement-event-hook event room session)
(when (ement--sync-messages-p session)
(ement-progress-update)))
(cl-loop for event across (alist-get 'events ephemeral)
for event-struct = (ement--make-event event)
do (push event-struct (ement-room-ephemeral room))
(ement--process-event event-struct room session))
(when (ement-session-has-synced-p session)
(when (eq t (alist-get 'limited timeline))
(ement-room-retro-to-token room session (alist-get 'prev_batch timeline)
(ement-session-next-batch session))))))
(defun ement--push-left-room-events (session left-room)
"Push events for LEFT-ROOM into that room in SESSION."
(ement--push-joined-room-events session left-room 'leave))
(defun ement--make-event (event)
"Return `ement-event' struct for raw EVENT list.
Adds sender to `ement-users' when necessary."
(pcase-let* (((map content type unsigned redacts
('event_id id) ('origin_server_ts ts)
('sender sender-id) ('state_key state-key))
event)
(sender (or (gethash sender-id ement-users)
(puthash sender-id (make-ement-user :id sender-id)
ement-users))))
(make-ement-event :id id :sender sender :type type :content content :state-key state-key
:origin-server-ts ts :unsigned unsigned
:local (when redacts
(ement-alist 'redacts redacts)))))
(defun ement--put-event (event _room session)
"Put EVENT on SESSION's events table."
(puthash (ement-event-id event) event (ement-session-events session)))
(defun ement--read-sessions ()
"Return saved sessions alist read from disk.
Returns nil if unable to read `ement-sessions-file'."
(cl-labels ((plist-to-session (plist)
(pcase-let* (((map (:user user-data) (:server server-data)
(:token token) (:transaction-id transaction-id))
plist)
(user (apply #'make-ement-user user-data))
(server (apply #'make-ement-server server-data))
(session (make-ement-session :user user :server server
:token token :transaction-id transaction-id)))
(setf (ement-session-events session) (make-hash-table :test #'equal))
session)))
(when (file-exists-p ement-sessions-file)
(pcase-let* ((read-circle t)
(sessions (with-temp-buffer
(insert-file-contents ement-sessions-file)
(read (current-buffer)))))
(prog1
(cl-loop for (id . plist) in sessions
collect (cons id (plist-to-session plist)))
(message "Ement: Read sessions."))))))
(defun ement--write-sessions (sessions-alist)
"Write SESSIONS-ALIST to disk."
(cl-labels ((session-plist (session)
(pcase-let* (((cl-struct ement-session user server token transaction-id) session)
((cl-struct ement-user (id user-id) username) user)
((cl-struct ement-server (name server-name) uri-prefix) server))
(list :user (list :id user-id
:username username)
:server (list :name server-name
:uri-prefix uri-prefix)
:token token
:transaction-id transaction-id))))
(message "Ement: Writing sessions...")
(with-temp-file ement-sessions-file
(pcase-let* ((print-level nil)
(print-length nil)
(print-circle t)
(sessions-alist-plist (cl-loop for (id . session) in sessions-alist
collect (cons id (session-plist session)))))
(prin1 sessions-alist-plist (current-buffer))))
(chmod ement-sessions-file #o600)))
(defun ement--kill-emacs-hook ()
"Function to be added to `kill-emacs-hook'.
Writes Ement session to disk when enabled."
(ignore-errors
(when (and ement-save-sessions
ement-sessions)
(ement--write-sessions ement-sessions))))
(defvar ement-event-handlers nil
"Alist mapping event types to functions which process an event of each type.
Each function is called with three arguments: the event, the
room, and the session. These handlers are run regardless of
whether a room has a live buffer.")
(defun ement--process-event (event room session)
"Process EVENT for ROOM in SESSION.
Uses handlers defined in `ement-event-handlers'. If no handler
is defined for EVENT's type, does nothing and returns nil. Any
errors signaled during processing are demoted in order to prevent
unexpected errors from arresting event processing and syncing."
(when-let ((handler (alist-get (ement-event-type event) ement-event-handlers nil nil #'equal)))
(with-demoted-errors "Ement (ement--process-event): Error processing event: %S"
(funcall handler event room session))))
(defmacro ement-defevent (type &rest body)
"Define an event handling function for events of TYPE, a string.
Around the BODY, the variable `event' is bound to the event being
processed, `room' to the room struct in which the event occurred,
and `session' to the session. Adds function to
`ement-event-handlers', which see."
(declare (indent defun))
`(setf (alist-get ,type ement-event-handlers nil nil #'string=)
(lambda (event room session)
,(concat "`ement-' handler function for " type " events.")
,@body)))
(ement-defevent "m.room.avatar"
(when ement-room-avatars
(if-let ((url (alist-get 'url (ement-event-content event))))
(plz-run
(plz-queue ement-images-queue
'get (ement--mxc-to-url url session) :as 'binary :noquery t
:then (lambda (data)
(when ement-room-avatars
(let ((image (create-image data nil 'data-p
:ascent 'center
:max-width ement-room-avatar-max-width
:max-height ement-room-avatar-max-height)))
(if (not image)
(progn
(display-warning 'ement (format "Room avatar seems unreadable: ROOM-ID:%S AVATAR-URL:%S"
(ement-room-id room) (ement--mxc-to-url url session)))
(setf (ement-room-avatar room) nil
(alist-get 'room-list-avatar (ement-room-local room)) nil))
(when (fboundp 'imagemagick-types)
(setf (image-property image :type) 'imagemagick))
(setf (ement-room-avatar room) (propertize " " 'display image)
(alist-get 'room-list-avatar (ement-room-local room)) nil)))))))
(setf (ement-room-avatar room) nil
(alist-get 'room-list-avatar (ement-room-local room)) nil))))
(ement-defevent "m.room.create"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map type))) event))
(when type
(setf (ement-room-type room) type))))
(ement-defevent "m.room.member"
"Put/update member on `ement-users' and room's members table."
(ignore session)
(pcase-let* (((cl-struct ement-room members) room)
((cl-struct ement-event state-key
(content (map displayname membership
('avatar_url avatar-url))))
event)
(user (or (gethash state-key ement-users)
(puthash state-key
(make-ement-user :id state-key :avatar-url avatar-url
:displayname displayname)
ement-users))))
(pcase membership
("join"
(puthash state-key user members)
(puthash user displayname (ement-room-displaynames room)))
(_ (remhash state-key members)
(remhash user (ement-room-displaynames room))))))
(ement-defevent "m.room.name"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map name))) event))
(when name
(setf (ement-room-display-name room) (ement--room-display-name room)))))
(ement-defevent "m.room.topic"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map topic))) event))
(when topic
(setf (ement-room-topic room) topic))))
(ement-defevent "m.receipt"
(ignore session)
(pcase-let (((cl-struct ement-event content) event)
((cl-struct ement-room (receipts room-receipts)) room))
(cl-loop for (event-id . receipts) in content
do (cl-loop for (user-id . receipt) in (alist-get 'm.read receipts)
do (puthash (symbol-name user-id)
(cons (symbol-name event-id) (alist-get 'ts receipt))
room-receipts)))))
(ement-defevent "m.space.child"
(pcase-let* ((space-room room)
((cl-struct ement-session rooms) session)
((cl-struct ement-room (id parent-room-id)) space-room)
((cl-struct ement-event (state-key child-room-id) (content (map via))) event)
(child-room (cl-find child-room-id rooms :key #'ement-room-id :test #'equal)))
(if via
(progn
(cl-pushnew child-room-id (alist-get 'children (ement-room-local space-room)) :test #'equal)
(when child-room
(cl-pushnew parent-room-id (alist-get 'parents (ement-room-local child-room)) :test #'equal)))
(setf (alist-get 'children (ement-room-local space-room))
(delete child-room-id (alist-get 'children (ement-room-local space-room))))
(when child-room
(setf (alist-get 'parents (ement-room-local child-room))
(delete parent-room-id (alist-get 'parents (ement-room-local child-room))))))))
(ement-defevent "m.room.canonical_alias"
(ignore session)
(pcase-let (((cl-struct ement-event (content (map alias))) event))
(setf (ement-room-canonical-alias room) alias)))
(defun ement--link-children (session)
"Link child rooms in SESSION.
To be called after initial sync."
(pcase-let (((cl-struct ement-session rooms) session))
(dolist (room rooms)
(pcase-let (((cl-struct ement-room (id parent-id) (local (map children))) room))
(when children
(dolist (child-id children)
(when-let ((child-room (cl-find child-id rooms :key #'ement-room-id :test #'equal)))
(cl-pushnew parent-id (alist-get 'parents (ement-room-local child-room)) :test #'equal))))))))
(defvar savehist-save-hook)
(with-eval-after-load 'savehist
(defun ement--savehist-save-hook ()
"Remove all `ement-' commands from `command-history'.
Because when `savehist' saves `command-history', it includes the
interactive arguments passed to the command, which in our case
includes large data structures that should never be persisted!"
(setf command-history
(cl-remove-if (pcase-lambda (`(,command . ,_))
(string-match-p (rx bos "ement-") (symbol-name command)))
command-history)))
(cl-pushnew 'ement--savehist-save-hook savehist-save-hook))
(provide 'ement)