(require 'color)
(require 'ewoc)
(require 'mailcap)
(require 'shr)
(require 'subr-x)
(require 'mwheel)
(require 'dnd)
(require 'ement-api)
(require 'ement-lib)
(require 'ement-macros)
(require 'ement-structs)
(cl-defstruct ement-room-membership-events
"Struct grouping membership events.
After adding events, use `ement-room-membership-events--update'
to sort events and update other slots."
(events nil :documentation "Membership events, latest first.")
(earliest-ts nil :documentation "Timestamp of earliest event.")
(latest-ts nil :documentation "Timestamp of latest event."))
(defun ement-room-membership-events--update (struct)
"Return STRUCT having sorted its events and updated its slots."
(setf (ement-room-membership-events-events struct) (cl-delete-duplicates (ement-room-membership-events-events struct)
:key #'ement-event-id :test #'equal)
(ement-room-membership-events-events struct) (cl-sort (ement-room-membership-events-events struct) #'>
:key #'ement-event-origin-server-ts)
(ement-room-membership-events-earliest-ts struct) (ement-event-origin-server-ts
(car (last (ement-room-membership-events-events struct))))
(ement-room-membership-events-latest-ts struct) (ement-event-origin-server-ts
(car (ement-room-membership-events-events struct))))
struct)
(defvar-local ement-ewoc nil
"EWOC for Ement room buffers.")
(defvar-local ement-room nil
"Ement room for current buffer.")
(defvar-local ement-session nil
"Ement session for current buffer.")
(defvar-local ement-room-retro-loading nil
"Non-nil when earlier messages are being loaded.
Used to avoid overlapping requests.")
(defvar-local ement-room-replying-to-event nil
"When non-nil, the user is replying to this event.
Used by `ement-room-send-message'.")
(defvar-local ement-room-replying-to-overlay nil
"Used by `ement-room-write-reply'.")
(defvar-local ement-room-read-receipt-request nil
"Maps event ID to request updating read receipt to that event.
An alist of one entry.")
(defvar ement-room-read-string-setup-hook nil
"Normal hook run by `ement-room-read-string' after switching to minibuffer.
Should be used to, e.g. propagate variables to the minibuffer.")
(defvar ement-room-compose-hook nil
"Hook run in compose buffers when created.
Used to, e.g. call `ement-room-compose-org'.")
(declare-function ement-room-list "ement-room-list.el")
(declare-function ement-notify-switch-to-mentions-buffer "ement-notify")
(declare-function ement-notify-switch-to-notifications-buffer "ement-notify")
(defvar ement-room-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "?") #'ement-room-transient)
(define-key map (kbd "n") #'ement-room-goto-next)
(define-key map (kbd "p") #'ement-room-goto-prev)
(define-key map (kbd "SPC") #'ement-room-scroll-up-mark-read)
(define-key map (kbd "S-SPC") #'ement-room-scroll-down-command)
(define-key map (kbd "M-g M-p") #'ement-room-goto-fully-read-marker)
(define-key map (kbd "m") #'ement-room-mark-read)
(define-key map [remap scroll-down-command] #'ement-room-scroll-down-command)
(define-key map [remap mwheel-scroll] #'ement-room-mwheel-scroll)
(define-key map (kbd "M-g M-l") #'ement-room-list)
(define-key map (kbd "M-g M-r") #'ement-view-room)
(define-key map (kbd "M-g M-m") #'ement-notify-switch-to-mentions-buffer)
(define-key map (kbd "M-g M-n") #'ement-notify-switch-to-notifications-buffer)
(define-key map (kbd "q") #'quit-window)
(define-key map (kbd "RET") #'ement-room-send-message)
(define-key map (kbd "S-<return>") #'ement-room-write-reply)
(define-key map (kbd "M-RET") #'ement-room-compose-message)
(define-key map (kbd "<insert>") #'ement-room-edit-message)
(define-key map (kbd "C-k") #'ement-room-delete-message)
(define-key map (kbd "s r") #'ement-room-send-reaction)
(define-key map (kbd "s e") #'ement-room-send-emote)
(define-key map (kbd "s f") #'ement-room-send-file)
(define-key map (kbd "s i") #'ement-room-send-image)
(define-key map (kbd "v") #'ement-room-view-event)
(define-key map (kbd "u RET") #'ement-send-direct-message)
(define-key map (kbd "u i") #'ement-invite-user)
(define-key map (kbd "u I") #'ement-ignore-user)
(define-key map (kbd "M-s o") #'ement-room-occur)
(define-key map (kbd "r d") #'ement-describe-room)
(define-key map (kbd "r m") #'ement-list-members)
(define-key map (kbd "r t") #'ement-room-set-topic)
(define-key map (kbd "r f") #'ement-room-set-message-format)
(define-key map (kbd "r n") #'ement-room-set-notification-state)
(define-key map (kbd "r N") #'ement-room-override-name)
(define-key map (kbd "r T") #'ement-tag-room)
(define-key map (kbd "R c") #'ement-create-room)
(define-key map (kbd "R j") #'ement-join-room)
(define-key map (kbd "R l") #'ement-leave-room)
(define-key map (kbd "R F") #'ement-forget-room)
(define-key map (kbd "R n") #'ement-room-set-display-name)
(define-key map (kbd "R s") #'ement-room-toggle-space)
(define-key map (kbd "g") #'ement-room-sync)
map)
"Keymap for Ement room buffers.")
(defvar ement-room-minibuffer-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map minibuffer-local-map)
(define-key map (kbd "C-c '") #'ement-room-compose-from-minibuffer)
map)
"Keymap used in `ement-room-read-string'.")
(defvar ement-room-sender-in-headers nil
"Non-nil when sender is displayed in headers.
In that case, sender names are aligned to the margin edge.")
(defvar ement-room-messages-filter
'((lazy_load_members . t))
"Default RoomEventFilter for /messages requests.")
(defvar ement-room-typing-timer nil
"Timer used to send notifications while typing.")
(defvar ement-room-matrix.to-url-regexp
(rx "http" (optional "s") "://"
"matrix.to" "/#/"
(group (or "!" "#") (1+ (not (any "/"))))
(optional "/" (group "$" (1+ (not (any "?" "/")))))
(optional "?" (group (1+ anything))))
"Regexp matching \"matrix.to\" URLs.")
(defvar ement-room-message-history nil
"History list of messages entered with `ement-room' commands.
Does not include filenames, emotes, etc.")
(defvar ement-room-emote-history nil
"History list of emotes entered with `ement-room' commands.")
(defvar ement-sessions)
(defvar ement-syncs)
(defvar ement-auto-sync)
(defvar ement-users)
(defvar ement-images-queue)
(defvar ement-notify-limit-room-name-width)
(defvar ement-view-room-display-buffer-action)
(defvar browse-url-handlers)
(defgroup ement-room nil
"Options for room buffers."
:group 'ement)
(defcustom ement-room-timestamp-header-align 'right
"Where to align timestamp headers."
:type '(choice (const :tag "Left" left)
(const :tag "Center" center)
(const :tag "Right" right)))
(defcustom ement-room-view-hook
'(ement-room-view-hook-room-list-auto-update)
"Functions called when `ement-room-view' is called.
Called with two arguments, the room and the session."
:type 'hook)
(defface ement-room-name
'((t (:inherit font-lock-function-name-face)))
"Room name shown in header line.")
(defface ement-room-membership
'((t (:height 0.8 :inherit font-lock-comment-face)))
"Membership events (join/part).")
(defface ement-room-reactions
'((t (:inherit font-lock-comment-face :height 0.9)))
"Reactions to messages (including the user count).")
(defface ement-room-reactions-key
'((t (:inherit ement-room-reactions :height 1.5)))
"Reactions to messages (the key, i.e. the emoji part).
Uses a separate face to allow the key to be shown at a different
size, because in some fonts, emojis are too small relative to
normal text.")
(defface ement-room-timestamp
'((t (:inherit font-lock-comment-face)))
"Event timestamps.")
(defface ement-room-user
'((t (:inherit font-lock-function-name-face :weight bold :overline t)))
"Usernames.")
(defface ement-room-self
'((t (:inherit (font-lock-variable-name-face ement-room-user) :weight bold)))
"Own username.")
(defface ement-room-message-text
'((t (:inherit default)))
"Text message bodies.")
(defface ement-room-message-emote
'((t (:inherit italic)))
"Emote message bodies.")
(defface ement-room-quote
'((t (:height 0.9 :inherit font-lock-comment-face)))
"Quoted parts of messages.
Anything wrapped by HTML BLOCKQUOTE tag.")
(defface ement-room-redacted
'((t (:strike-through t)))
"Redacted messages.")
(defface ement-room-self-message
'((t (:inherit (font-lock-variable-name-face))))
"Oneself's message bodies.
Note that this does not need to inherit
`ement-room-message-text', because that face is combined with
this one automatically.")
(defface ement-room-timestamp-header
'((t (:inherit header-line :weight bold :height 1.1)))
"Timestamp headers.")
(defface ement-room-mention
(if (version< emacs-version "27.1")
'((t (:inherit hl-line)))
'((t (:inherit hl-line :extend t))))
"Messages that mention the local user.")
(defface ement-room-wrap-prefix
`((t :inherit highlight))
"Face applied to `ement-room-wrap-prefix', which see.")
(defcustom ement-room-ellipsis "⋮"
"String used when abbreviating certain strings."
:type 'string)
(defcustom ement-room-avatars (display-images-p)
"Show room avatars."
:type 'boolean)
(defcustom ement-room-avatar-max-width 32
"Maximum width in pixels of room avatars shown in header lines."
:type 'integer)
(defcustom ement-room-avatar-max-height 32
"Maximum height in pixels of room avatars shown in header lines."
:type 'integer)
(defcustom ement-room-coalesce-events t
"Coalesce certain events in room buffers.
For example, membership events can be overwhelming in large
rooms, especially ones bridged to IRC. This option groups them
together so they take less space."
:type 'boolean)
(defcustom ement-room-header-line-format
'(:eval (concat (if ement-room-avatars
(or (ement-room-avatar ement-room)
"")
"")
" " (propertize (ement-room--escape-%
(or (ement-room-display-name ement-room)
"[no room name]"))
'face 'ement-room-name)
": " (propertize (ement-room--escape-%
(or (ement-room-topic ement-room)
"[no topic]"))
'help-echo (ement-room-topic ement-room))))
"Header line format for room buffers.
See Info node `(elisp)Header lines'."
:type 'sexp)
(put 'ement-room-header-line-format 'risky-local-variable t)
(defcustom ement-room-buffer-name-prefix "*Ement Room: "
"Prefix for Ement room buffer names."
:type 'string)
(defcustom ement-room-buffer-name-suffix "*"
"Suffix for Ement room buffer names."
:type 'string)
(defcustom ement-room-timestamp-format "%H:%M:%S"
"Format string for event timestamps.
See function `format-time-string'."
:type '(choice (const "%H:%M:%S")
(const "%Y-%m-%d %H:%M:%S")
string))
(defcustom ement-room-left-margin-width 0
"Width of left margin in room buffers.
When using a non-graphical display, this should be set slightly
wider than when using a graphical display, to prevent sender
display names from colliding with event text."
:type 'integer)
(defcustom ement-room-right-margin-width (length ement-room-timestamp-format)
"Width of right margin in room buffers."
:type 'integer)
(defcustom ement-room-sender-headers t
"Show sender headers.
Automatically set by setting `ement-room-message-format-spec',
but may be overridden manually."
:type 'boolean)
(defcustom ement-room-unread-only-counts-notifications t
"Only use notification counts to mark rooms unread.
Notification counts are set by the server based on each room's
notification settings. Otherwise, whether a room is marked
unread depends on the room's fully-read marker, read-receipt
marker, whether the local user sent the latest events, etc."
:type 'boolean)
(defvar ement-room-sender-in-left-margin nil
"Whether sender is shown in left margin.
Set by `ement-room-message-format-spec-setter'.")
(defun ement-room-message-format-spec-setter (option value &optional local)
"Set relevant options for `ement-room-message-format-spec', which see.
To be used as that option's setter. OPTION and VALUE are
received from setting the customization option. If LOCAL is
non-nil, set the variables buffer-locally (i.e. when called from
`ement-room-set-message-format'."
(cl-macrolet ((set-vars (&rest pairs)
`(progn
,@(cl-loop for (symbol value) on pairs by #'cddr
collect `(if local
(set (make-local-variable ',symbol) ,value)
(set ',symbol ,value))))))
(if local
(set (make-local-variable option) value)
(set-default option value))
(pcase value
("%B%r%R%t" (set-vars ement-room-left-margin-width 0
ement-room-right-margin-width 8
ement-room-sender-headers t
ement-room-sender-in-headers t
ement-room-sender-in-left-margin nil))
("%S%L%B%r%R%t" (set-vars ement-room-left-margin-width 12
ement-room-right-margin-width 8
ement-room-sender-headers nil
ement-room-sender-in-headers nil
ement-room-sender-in-left-margin t))
("[%t] %S> %B%r" (set-vars ement-room-left-margin-width 0
ement-room-right-margin-width 0
ement-room-sender-headers nil
ement-room-sender-in-headers nil
ement-room-sender-in-left-margin nil))
(_ (set-vars ement-room-left-margin-width
(if (string-match-p "%L" value)
12 0)
ement-room-right-margin-width
(if (string-match-p "%R" value)
8 0)
ement-room-sender-in-left-margin
(if (string-match-p (rx (1+ anything) (or "%S" "%s") (1+ anything) "%L") value)
t nil)
ement-room-sender-headers
(if (string-match-p (or "%S" "%s") value)
nil t)
ement-room-sender-in-headers
(if (string-match-p (rx (or "%S" "%s")) value)
nil t))
(message "Ement: When using custom message format, setting margin widths may be necessary")))
(unless ement-room-sender-in-headers
(require 'face-remap)
(if local
(progn
(face-remap-reset-base 'ement-room-user)
(face-remap-add-relative 'ement-room-user '(:overline nil)))
(set-face-attribute 'ement-room-user nil :overline nil)))
(unless local
(when (and (bound-and-true-p ement-sessions) (car ement-sessions))
(message "Ement: Kill and reopen room buffers to display in new format")))))
(defcustom ement-room-message-format-spec "%S%L%B%r%R%t"
"Format messages according to this spec.
It may contain these specifiers:
%L End of left margin
%R Start of right margin
%W End of wrap-prefix
%b Message body (plain-text)
%B Message body (formatted if available)
%i Event ID
%O Room display name (used for mentions buffer)
%r Reactions
%s Sender ID
%S Sender display name
%t Event timestamp, formatted according to
`ement-room-timestamp-format'
Note that margin sizes must be set manually with
`ement-room-left-margin-width' and
`ement-room-right-margin-width'."
:type '(choice (const :tag "IRC-style using margins" "%S%L%B%r%R%t")
(const :tag "IRC-style without margins" "[%t] %S> %B%r")
(const :tag "IRC-style without margins, with wrap-prefix" "[%t] %S> %W%B%r")
(const :tag "IRC-style with right margin, with wrap-prefix" "%S> %W%B%r%R%t")
(const :tag "Elemental" "%B%r%R%t")
(string :tag "Custom format"))
:set #'ement-room-message-format-spec-setter
:set-after '(ement-room-left-margin-width ement-room-right-margin-width
ement-room-sender-headers)
:require 'ement-room)
(defcustom ement-room-retro-messages-number 30
"Number of messages to retrieve when loading earlier messages."
:type 'integer)
(defcustom ement-room-timestamp-header-format " %H:%M "
"Format string for timestamp headers where date is unchanged.
See function `format-time-string'. If this string ends in a
newline, its background color will extend to the end of the
line."
:type '(choice (const :tag "Time-only" " %H:%M ")
(const :tag "Always show date" " %Y-%m-%d %H:%M ")
string))
(defcustom ement-room-timestamp-header-with-date-format " %Y-%m-%d (%A)\n"
"Format string for timestamp headers where date changes.
See function `format-time-string'. If this string ends in a
newline, its background color will extend to the end of the
line."
:type '(choice (const " %Y-%m-%d (%A)\n")
string))
(defcustom ement-room-replace-edited-messages t
"Replace edited messages with their new content.
When nil, edited messages are displayed as new messages, leaving
the original messages visible."
:type 'boolean)
(defcustom ement-room-shr-use-fonts nil
"Enable `shr' variable-pitch fonts for formatted bodies.
If non-nil, `shr' may use variable-pitch fonts for formatted
bodies (which include most replies), which means that some
messages won't display in the same font as others."
:type '(choice (const :tag "Disable variable-pitch fonts" nil)
(const :tag "Enable variable-pitch fonts" t)))
(defcustom ement-room-username-display-property '(raise -0.25)
"Display property applied to username strings.
See Info node `(elisp)Other Display Specs'."
:type '(choice (list :tag "Raise" (const raise :tag "Raise") (number :tag "Factor"))
(list :tag "Height" (const height)
(choice (list :tag "Larger" (const + :tag "Larger") (number :tag "Steps"))
(list :tag "Smaller" (const - :tag "Smaller") (number :tag "Steps"))
(number :tag "Factor")
(function :tag "Function")
(sexp :tag "Form"))) ))
(defcustom ement-room-event-separator-display-property '(space :ascent 50)
"Display property applied to invisible space string after events.
Allows visual separation between events without, e.g. inserting
newlines.
See Info node `(elisp)Specified Space'."
:type 'sexp)
(defcustom ement-room-timestamp-header-delta 600
"Show timestamp header where events are at least this many seconds apart."
:type 'integer)
(defcustom ement-room-send-message-filter nil
"Function through which to pass message content before sending.
Used to, e.g. send an Org-formatted message by exporting it to
HTML first."
:type '(choice (const :tag "Send messages as-is" nil)
(const :tag "Send messages in Org format" ement-room-send-org-filter)
(function :tag "Custom filter function"))
:set (lambda (option value)
(set-default option value)
(pcase value
('ement-room-send-org-filter
(add-hook 'ement-room-compose-hook #'ement-room-compose-org))
(_ (remove-hook 'ement-room-compose-hook #'ement-room-compose-org)))))
(defcustom ement-room-mark-rooms-read t
"Mark rooms as read automatically.
Moves read and fully-read markers in rooms on the server when
`ement-room-scroll-up-mark-read' is called at the end of a
buffer. When `send', also marks room as read when sending a
message in it. When disabled, rooms may still be marked as read
manually by calling `ement-room-mark-read'. Note that this is
not strictly the same as read receipts."
:type '(choice (const :tag "When scrolling past end of buffer" t)
(const :tag "Also when sending" send)
(const :tag "Never" nil)))
(defcustom ement-room-send-typing t
"Send typing notifications to the server while typing a message."
:type 'boolean)
(defcustom ement-room-join-view-buffer t
"View room buffer when joining a room."
:type 'boolean)
(defcustom ement-room-leave-kill-buffer t
"Kill room buffer when leaving a room.
When disabled, the room's buffer will remain open, but
Matrix-related commands in it will fail."
:type 'boolean)
(defcustom ement-room-warn-for-already-seen-messages nil
"Warn when a sent message has already been seen.
Such a case could very rarely indicate a reused transaction ID,
which would prevent further messages from being sent (and would
be solved by logging in with a new session, generating a new
token), but most often it happens when the server echoes back a
sent message before acknowledging the sending of the
message (which is harmless and can be ignored)."
:type 'boolean)
(defcustom ement-room-wrap-prefix
(concat (propertize " "
'face 'ement-room-wrap-prefix)
" ")
"String prefixing certain events in room buffers.
Events include membership events, image attachments, etc.
Generally users should prefer to customize the face
`ement-room-wrap-prefix' rather than this option, because this
option's default value has that face applied to it where
appropriate; if users customize this option, they will need to
apply the face to the string themselves, if desired."
:type 'string)
(defgroup ement-room-prism nil
"Colorize usernames and messages in rooms."
:group 'ement-room)
(defcustom ement-room-prism 'name
"Display users' names and messages in unique colors."
:type '(choice (const :tag "Name only" name)
(const :tag "Name and message" both)
(const :tag "Neither" nil)))
(defcustom ement-room-prism-addressee t
"Show addressees' names in their respective colors.
Applies to room member names at the beginning of messages,
preceded by a colon or comma.
Note that a limitation applies to the current implementation: if
a message from the addressee is not yet visible in a room at the
time the addressed message is formatted, the color may not be
applied."
:type 'boolean)
(defcustom ement-room-prism-color-adjustment 0
"Number used to tweak computed username colors.
This may be used to adjust your favorite users' colors if you
don't like the default ones. (The only way to do it is by
experimentation--there is no direct mapping available, nor a
per-user setting.)
The number is added to the hashed user ID before converting it to
a color. Note that, since user ID hashes are ratioed against
`most-positive-fixnum', this number must be very large in order
to have any effect; it should be at least 1e13.
After changing this option, a room's buffer must be killed and
recreated to see the effect."
:type 'number
:set (lambda (option value)
(unless (or (= 0 value) (>= value 1e13))
(user-error "This option must be a very large number, at least 1e13"))
(set-default option value)))
(defcustom ement-room-prism-minimum-contrast 6
"Attempt to enforce this minimum contrast ratio for user faces.
This should be a reasonable number from, e.g. 0-7 or so."
:type 'number)
(defcustom ement-room-prism-message-desaturation 25
"Desaturate user colors by this percent for message bodies.
Makes message bodies a bit less intense."
:type 'integer)
(defcustom ement-room-prism-message-lightening 10
"Lighten user colors by this percent for message bodies.
Makes message bodies a bit less intense.
When using a light theme, it may be necessary to use a negative
number (to darken rather than lighten)."
:type 'integer)
(defmacro ement-room-with-highlighted-event-at (position &rest body)
"Highlight event at POSITION while evaluating BODY."
(declare (indent 1))
`(let* ((node (ewoc-locate ement-ewoc ,position))
(event (ewoc-data node))
ement-room-replying-to-event ement-room-replying-to-overlay)
(unless (and (ement-event-p event)
(ement-event-id event))
(error "No event at point"))
(unwind-protect
(progn
(setf ement-room-replying-to-event event
ement-room-replying-to-overlay
(make-overlay (ewoc-location node)
(if (ewoc-next ement-ewoc node)
(ewoc-location (ewoc-next ement-ewoc node))
(point-max))))
(overlay-put ement-room-replying-to-overlay 'face 'highlight)
,@body)
(when (overlayp ement-room-replying-to-overlay)
(delete-overlay ement-room-replying-to-overlay))
(setf ement-room-replying-to-event nil
ement-room-replying-to-overlay nil))))
(defmacro ement-room-with-typing (&rest body)
"Send typing notifications around BODY.
When `ement-room-send-typing' is enabled, typing notifications
are sent while BODY is executing. BODY is wrapped in an
`unwind-protect' form that cancels `ement-room-typing-timer' and
sends a not-typing notification."
(declare (indent defun))
`(unwind-protect
(progn
(when ement-room-send-typing
(when ement-room-typing-timer
(cancel-timer ement-room-typing-timer))
(setf ement-room-typing-timer (run-at-time nil 15 #'ement-room--send-typing ement-session ement-room)))
,@body)
(when ement-room-send-typing
(when ement-room-typing-timer
(cancel-timer ement-room-typing-timer)
(setf ement-room-typing-timer nil))
(ement-room--send-typing ement-session ement-room :typing nil))))
(defmacro ement-room-wrap-prefix (string-form &rest properties)
"Wrap STRING-FORM with `ement-room-wrap-prefix'.
Concats `ement-room-wrap-prefix' to STRING-FORM and applies it as
the `wrap-prefix' property. Also applies any PROPERTIES."
(declare (indent defun))
`(concat ement-room-wrap-prefix
(propertize ,string-form
'wrap-prefix ement-room-wrap-prefix
,@properties)))
(defsubst ement-room--concat-property (string property value &optional append)
"Return STRING having concatted VALUE with PROPERTY on it.
If APPEND, append it; otherwise prepend. Assumes PROPERTY is
constant throughout STRING."
(declare (indent defun))
(let* ((old-value (get-text-property 0 property string))
(new-value (if append
(concat old-value value)
(concat value old-value))))
(propertize string property new-value)))
(defvar ement-room-event-formatters nil
"Alist mapping characters to event-formatting functions.
Each function is called with three arguments: the event, the
room, and the session. See macro
`ement-room-define-event-formatter'.")
(defvar ement-room--format-message-margin-p nil
"Set by margin-related event formatters.")
(defvar ement-room--format-message-wrap-prefix nil
"Set by margin-related event formatters.")
(defmacro ement-room-define-event-formatter (char docstring &rest body)
"Define an event formatter for CHAR with DOCSTRING and BODY.
BODY is wrapped in a lambda form that binds `event', `room', and
`session', and the lambda is added to the variable
`ement-room-event-formatters', which see."
(declare (indent defun)
(debug (characterp stringp def-body)))
`(setf (alist-get ,char ement-room-event-formatters nil nil #'equal)
(lambda (event room session)
,docstring
,@body)))
(ement-room-define-event-formatter ?L
"Text before this is shown in the left margin."
(ignore event room session)
(setf ement-room--format-message-margin-p t)
(propertize " " 'left-margin-end t))
(ement-room-define-event-formatter ?R
"Text after this is shown in the right margin."
(ignore event room session)
(setf ement-room--format-message-margin-p t)
(propertize " " 'right-margin-start t))
(ement-room-define-event-formatter ?W
"Text before this is the length of the event's wrap-prefix.
This emulates the effect of using the left margin (the \"%L\"
spec) without requiring all events to use the same margin width."
(ignore event room session)
(setf ement-room--format-message-wrap-prefix t)
(propertize " " 'wrap-prefix-end t))
(ement-room-define-event-formatter ?b
"Plain-text body content."
(let* ((body (save-match-data
(ement-room--format-message-body event :formatted-p nil)))
(body-length (length body))
(face (ement-room--event-body-face event room session))
(quote-start (ement--text-property-search-forward 'face
(lambda (value)
(pcase value
('ement-room-quote t)
((pred listp) (member 'ement-room-quote value))))
body))
(quote-end (when quote-start
(ement--text-property-search-backward 'face
(lambda (value)
(pcase value
('ement-room-quote t)
((pred listp) (member 'ement-room-quote value))))
body))))
(add-face-text-property (or quote-end 0) body-length face 'append body)
(when ement-room-prism-addressee
(ement-room--add-member-face body room))
body))
(ement-room-define-event-formatter ?B
"Formatted body content (i.e. rendered HTML)."
(let* ((body (save-match-data
(ement-room--format-message-body event)))
(body-length (length body))
(face (ement-room--event-body-face event room session))
(quote-start (ement--text-property-search-forward 'face
(lambda (value)
(pcase value
('ement-room-quote t)
((pred listp) (member 'ement-room-quote value))))
body))
(quote-end (when quote-start
(ement--text-property-search-backward 'face
(lambda (value)
(pcase value
('ement-room-quote t)
((pred listp) (member 'ement-room-quote value))))
body :start (length body)))))
(add-face-text-property (or quote-end 0) body-length face 'append body)
(when ement-room-prism-addressee
(ement-room--add-member-face body room))
body))
(ement-room-define-event-formatter ?i
"Event ID."
(ignore room session)
(ement-event-id event))
(ement-room-define-event-formatter ?o
"Room avatar."
(ignore event session)
(or (alist-get 'room-list-avatar (ement-room-local room)) ""))
(ement-room-define-event-formatter ?O
"Room display name."
(ignore event session)
(let ((room-name (propertize (or (ement-room-display-name room)
(ement--room-display-name room))
'face 'ement-room-name
'help-echo (or (ement-room-canonical-alias room)
(ement-room-id room)))))
(when ement-notify-limit-room-name-width
(setf room-name (truncate-string-to-width room-name ement-notify-limit-room-name-width
nil nil ement-room-ellipsis)))
room-name))
(ement-room-define-event-formatter ?s
"Sender MXID."
(ignore room session)
(concat (propertize (ement-user-id (ement-event-sender event))
'face 'ement-room-user)
""))
(ement-room-define-event-formatter ?S
"Sender display name."
(ignore session)
(pcase-let ((sender (ement--format-user (ement-event-sender event) room))
((cl-struct ement-room (local (map buffer))) room))
(with-current-buffer (or buffer (current-buffer))
(when ement-room-sender-in-left-margin
(setf sender
(if (< (string-width sender) ement-room-left-margin-width)
(concat (make-string (- ement-room-left-margin-width (string-width sender))
? )
sender)
(ement-room--concat-property
(truncate-string-to-width sender ement-room-left-margin-width nil nil "…")
'help-echo (concat sender " "))))))
(concat sender "")))
(ement-room-define-event-formatter ?r
"Reactions."
(ignore room session)
(ement-room--format-reactions event))
(ement-room-define-event-formatter ?t
"Timestamp."
(ignore room session)
(propertize (format-time-string ement-room-timestamp-format (/ (ement-event-origin-server-ts event) 1000))
'face 'ement-room-timestamp
'help-echo (format-time-string "%Y-%m-%d %H:%M:%S"
(/ (ement-event-origin-server-ts event) 1000))))
(defun ement-room--event-body-face (event room session)
"Return face definition for EVENT in ROOM on SESSION."
(ignore room) (pcase-let* (((cl-struct ement-event sender
(content (map msgtype))
(unsigned (map ('redacted_by unsigned-redacted-by)))
(local (map ('redacted-by local-redacted-by))))
event)
((cl-struct ement-user (id sender-id)) sender)
((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
(self-message-p (equal sender-id user-id))
(type-face (pcase msgtype
("m.emote" 'ement-room-message-emote)
(_ 'ement-room-message-text)))
(context-face (cond (self-message-p
'ement-room-self-message)
((or (ement-room--event-mentions-user-p event user)
(ement--event-mentions-room-p event))
'ement-room-mention)))
(prism-color (unless self-message-p
(when (eq 'both ement-room-prism)
(or (ement-user-message-color sender)
(setf (ement-user-message-color sender)
(let ((message-color (color-desaturate-name (ement--user-color sender)
ement-room-prism-message-desaturation)))
(if (ement--color-dark-p (color-name-to-rgb (face-background 'default)))
(color-lighten-name message-color ement-room-prism-message-lightening)
(color-darken-name message-color ement-room-prism-message-lightening))))))))
(redacted-face (when (or local-redacted-by unsigned-redacted-by)
'ement-room-redacted))
(body-face (list :inherit (delq nil (list redacted-face context-face type-face)))))
(if prism-color
(plist-put body-face :foreground prism-color)
body-face)))
(defun ement-room--add-member-face (string room)
"Add member faces in ROOM to STRING.
If STRING begins with the name of a member in ROOM followed by a
colon or comma (as if STRING is a message addressing that
member), apply that member's displayname color face to that part
of the string.
Note that, if ROOM has no buffer, STRING is returned unchanged."
(pcase-let (((cl-struct ement-room (local (map buffer))) room))
(if (buffer-live-p buffer)
(save-match-data
(when (string-match (rx bos (group (1+ (not blank))) (or ":" ",") (1+ blank)) string)
(when-let* ((member-name (match-string 1 string))
(user (save-match-data
(with-current-buffer buffer
(save-excursion
(goto-char (point-min))
(cl-labels ((found-sender-p (ewoc-data)
(when (ement-event-p ewoc-data)
(equal member-name
(gethash (ement-event-sender ewoc-data) (ement-room-displaynames room))))))
(cl-loop with regexp = (regexp-quote member-name)
while (re-search-forward regexp nil t)
for maybe-event = (ewoc-data (ewoc-locate ement-ewoc))
when (found-sender-p maybe-event)
return (ement-event-sender maybe-event)))))))
(prism-color (or (ement-user-color user)
(setf (ement-user-color user)
(ement-room--user-color user)))))
(add-face-text-property (match-beginning 1) (match-end 1)
(list :foreground prism-color) nil string))))
string)))
(require 'bookmark)
(defun ement-room-bookmark-make-record ()
"Return a bookmark record for the current `ement-room' buffer."
(pcase-let* (((cl-struct ement-room (id room-id) canonical-alias display-name) ement-room)
((cl-struct ement-session user) ement-session)
((cl-struct ement-user (id session-id)) user))
(list (concat "Ement room: " display-name " (" canonical-alias ")")
(cons 'session-id session-id)
(cons 'room-id room-id)
(cons 'handler #'ement-room-bookmark-handler))))
(defun ement-room-bookmark-handler (bookmark)
"Show Ement room buffer for BOOKMARK."
(pcase-let* ((`(,_name . ,(map session-id room-id)) bookmark)
(session (ement-aprog1
(alist-get session-id ement-sessions nil nil #'equal)
(unless it
(user-error "Session %s not connected: call `ement-connect' first" session-id))))
(room (ement-aprog1
(ement-afirst (equal room-id (ement-room-id it))
(ement-session-rooms session))
(cl-assert it nil "Room %S not found on session %S" room-id session-id))))
(ement-view-room room session)
(run-at-time nil nil (lambda ()
(goto-char (point-max))))))
(defun ement-room-override-name (name room session)
"Set display NAME override for ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. If NAME is the empty string, remove
the override.
Sets account-data event of type
\"org.matrix.msc3015.m.room.name.override\". This name is only
used by clients that respect this proposed override. See
<https://github.com/matrix-org/matrix-spec-proposals/pull/3015#issuecomment-1451017296>."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Set name override (%s): " (ement--format-room ement-room)))
(name (read-string prompt nil nil (ement-room-display-name ement-room))))
(list name ement-room ement-session))))
(ement-put-account-data session "org.matrix.msc3015.m.room.name.override"
(if (string-empty-p name)
(make-hash-table)
(ement-alist "name" name))
:room room))
(defun ement-room-flush-colors ()
"Flush generated username/message colors.
Also, redisplay events in all open buffers. The colors will be
regenerated according to the current background color. Helpful
when switching themes or adjusting `ement-prism' options."
(interactive)
(cl-loop for user being the hash-values of ement-users
do (setf (ement-user-color user) nil
(ement-user-message-color user) nil))
(dolist (buffer (buffer-list))
(when (eq 'ement-room-mode (buffer-local-value 'major-mode buffer))
(with-current-buffer buffer
(ewoc-refresh ement-ewoc))))
(cl-loop for (_id . session) in ement-sessions
do (cl-loop for room in (ement-session-rooms session)
do (setf (alist-get 'notify-background-color (ement-room-local room)) nil)))
)
(defun ement-room-browse-url (url &rest args)
"Browse URL, using Ement for matrix.to URLs when possible.
Otherwise, fall back to `browse-url'. When called outside of an
`ement-room' buffer, the variable `ement-session' must be bound
to the session in which to look for URL's room and event. ARGS
are passed to `browse-url'."
(interactive)
(when (string-match ement-room-matrix.to-url-regexp url)
(let* ((room-id (when (string-prefix-p "!" (match-string 1 url))
(match-string 1 url)))
(room-alias (when (string-prefix-p "#" (match-string 1 url))
(match-string 1 url)))
(event-id (match-string 2 url))
(room (when (or
(and room-id (equal room-id (ement-room-id ement-room)))
(and room-alias (equal room-alias (ement-room-canonical-alias ement-room)))
(and room-id (cl-find room-id (ement-session-rooms ement-session)
:key #'ement-room-id))
(and room-alias (cl-find room-alias (ement-session-rooms ement-session)
:key #'ement-room-canonical-alias)))
ement-room)))
(if room
(progn
(ement-view-room room ement-session)
(when event-id
(ement-room-find-event event-id)))
(pcase-exhaustive (completing-read
(format "Room <%s> not joined on current session. Join it, or load link with browser?"
(or room-alias room-id))
'("Join room" "Load link with browser") nil t)
("Join room" (ement-join-room (or room-alias room-id) ement-session
:then (when event-id
(lambda (room session)
(ement-view-room room session)
(ement-room-find-event event-id)))))
("Load link with browser" (apply #'browse-url url args)))))))
(defun ement-room-find-event (event-id)
"Go to EVENT-ID in current buffer."
(interactive)
(cl-labels ((goto-event (event-id)
(push-mark)
(goto-char
(ewoc-location
(ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal event-id (ement-event-id data)))))))))
(if (or (cl-find event-id (ement-room-timeline ement-room)
:key #'ement-event-id :test #'equal)
(cl-find event-id (ement-room-state ement-room)
:key #'ement-event-id :test #'equal))
(goto-event event-id)
(message "Event %s not seen in current room. Looking in history..." event-id)
(let ((room ement-room))
(ement-room-retro-to ement-room ement-session event-id
:then (lambda ()
(with-current-buffer (alist-get 'buffer (ement-room-local room))
(goto-event event-id))))))))
(defun ement-room-set-composition-format (&optional localp)
"Set message composition format.
If LOCALP (interactively, with prefix), set in current room's
buffer. Sets `ement-room-send-message-filter'."
(interactive (list current-prefix-arg))
(let* ((formats (list (cons "Plain-text" nil)
(cons "Org-mode" #'ement-room-send-org-filter)))
(selected-name (completing-read "Composition format: " formats nil 'require-match nil nil
ement-room-send-message-filter))
(selected-filter (alist-get selected-name formats nil nil #'equal)))
(if localp
(setq-local ement-room-send-message-filter selected-filter)
(setq ement-room-send-message-filter selected-filter))))
(defun ement-room-set-message-format (format-spec)
"Set `ement-room-message-format-spec' in current buffer to FORMAT-SPEC.
Interactively, prompts for the spec using suggested values of the
option."
(interactive (list (let* ((choices (thread-last
(get 'ement-room-message-format-spec 'custom-type)
cdr
(seq-filter (lambda (it)
(eq (car it) 'const)))
(mapcar (lambda (it)
(cons (nth 2 it) (nth 3 it))))))
(choice (completing-read "Format: " (mapcar #'car choices))))
(or (alist-get choice choices nil nil #'equal)
choice))))
(cl-assert ement-ewoc)
(ement-room-message-format-spec-setter 'ement-room-message-format-spec format-spec 'local)
(setf left-margin-width ement-room-left-margin-width
right-margin-width ement-room-right-margin-width)
(set-window-margins nil left-margin-width right-margin-width)
(if ement-room-sender-in-headers
(ement-room--insert-sender-headers ement-ewoc)
(ewoc-filter ement-ewoc (lambda (node-data)
(not (ement-user-p node-data)))))
(ewoc-refresh ement-ewoc))
(defun ement-room-set-topic (session room topic)
"Set ROOM's TOPIC on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
(interactive
(ement-with-room-and-session
(list ement-session ement-room
(read-string (format "New topic (%s): "
(ement-room-display-name ement-room))
(ement-room-topic ement-room) nil nil 'inherit-input-method))))
(pcase-let* (((cl-struct ement-room (id room-id) display-name) room)
(endpoint (format "rooms/%s/state/m.room.topic" (url-hexify-string room-id)))
(data (ement-alist "topic" topic)))
(ement-api session endpoint :method 'put :data (json-encode data)
:then (lambda (_data)
(message "Topic set (%s): %s" display-name topic)))))
(cl-defun ement-room-send-file (file body room session &key (msgtype "m.file"))
"Send FILE to ROOM on SESSION, using message BODY and MSGTYPE.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
(interactive
(ement-with-room-and-session
(ement-room-with-typing
(let* ((file (read-file-name (format "Send file (%s): " (ement-room-display-name ement-room))
nil nil 'confirm))
(body (ement-room-read-string
(format "Message body (%s): " (ement-room-display-name ement-room))
(file-name-nondirectory file) 'file-name-history nil 'inherit-input-method)))
(list file body ement-room ement-session)))))
(when (yes-or-no-p (format "Upload file %S to room %S? "
file (ement-room-display-name room)))
(pcase-let* ((filename (file-name-nondirectory file))
(extension (or (file-name-extension file) ""))
(mime-type (mailcap-extension-to-mime extension))
(data `(file ,file))
(size (file-attribute-size (file-attributes file))))
(ement-upload session :data data :filename filename :content-type mime-type
:then (lambda (data)
(message "Uploaded file %S. Sending message..." file)
(pcase-let* (((map ('content_uri content-uri)) data)
((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/send/%s/%s" (url-hexify-string room-id)
"m.room.message" (ement--update-transaction-id session)))
(content (ement-alist "msgtype" msgtype
"url" content-uri
"body" body
"filename" filename
"info" (ement-alist "mimetype" mime-type
"size" size))))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (apply-partially #'ement-room-send-event-callback
:room room :session session :content content :data))))))))
(defun ement-room-send-image (file body room session)
"Send image FILE to ROOM on SESSION, using message BODY.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
(interactive
(ement-with-room-and-session
(ement-room-with-typing
(let* ((file (read-file-name (format "Send image file (%s): " (ement-room-display-name ement-room))
nil nil 'confirm))
(body (ement-room-read-string
(format "Message body (%s): " (ement-room-display-name ement-room))
(file-name-nondirectory file) 'file-name-history nil 'inherit-input-method)))
(list file body ement-room ement-session)))))
(ement-room-send-file file body room session :msgtype "m.image"))
(defun ement-room-dnd-upload-file (uri _action)
"Upload the file as specified by URI to the current room."
(when-let ((file (dnd-get-local-file-name uri t)))
(ement-room-send-file file (file-name-nondirectory file) ement-room ement-session
:msgtype (if (string-prefix-p "image/" (mailcap-file-name-to-mime-type file))
"m.image"
"m.file"))))
(cl-defun ement-room-join (id-or-alias session &key then)
"Join room by ID-OR-ALIAS on SESSION.
THEN may be a function to call after joining the room (and when
`ement-room-join-view-buffer' is non-nil, after viewing the room
buffer). It receives two arguments, the room and the session."
(interactive (list (read-string "Join room (ID or alias): ")
(or ement-session
(ement-complete-session))))
(cl-assert id-or-alias) (cl-assert session)
(unless (string-match-p
(rx bos (or "#" "!") (1+ (not (any ":")))
":" (1+ (or alnum (any "-."))))
id-or-alias)
(user-error "Invalid room ID or alias (use, e.g. \"#ROOM-ALIAS:SERVER\")"))
(let ((endpoint (format "join/%s" (url-hexify-string id-or-alias))))
(ement-api session endpoint :method 'post :data ""
:then (lambda (data)
(pcase-let* (((map ('room_id room-id)) data)
(then-fns (delq nil
(list (when ement-room-join-view-buffer
(lambda (room session)
(ement-view-room room session)))
then)))
(then-fn-symbol (gensym (format "ement-join-%s" id-or-alias)))
(then-fn (lambda (session)
(when-let ((room (cl-loop for room in (ement-session-rooms session)
when (equal room-id (ement-room-id room))
return room)))
(remove-hook 'ement-sync-callback-hook then-fn-symbol)
(dolist (fn then-fns)
(funcall fn room session))))))
(setf (symbol-function then-fn-symbol) then-fn)
(add-hook 'ement-sync-callback-hook then-fn-symbol)
(message "Joined room: %s" room-id)))
:else (lambda (plz-error)
(pcase-let* (((cl-struct plz-error response) plz-error)
((cl-struct plz-response status body) response)
((map error) (json-read-from-string body)))
(pcase status
((or 403 429) (error "Unable to join room %s: %s" id-or-alias error))
(_ (error "Unable to join room %s: %s %S" id-or-alias status plz-error))))))))
(defalias 'ement-join-room #'ement-room-join)
(defun ement-room-goto-prev ()
"Go to the previous message in buffer."
(interactive)
(if (>= (point) (- (point-max) 2))
(ewoc-goto-node ement-ewoc (ement-room--ewoc-last-matching ement-ewoc #'ement-event-p))
(ement-room-goto-next :next-fn #'ewoc-prev)))
(cl-defun ement-room-goto-next (&key (next-fn #'ewoc-next))
"Go to the next message in buffer.
NEXT-FN is passed to `ement-room--ewoc-next-matching', which
see."
(interactive)
(if-let (node (ement-room--ewoc-next-matching ement-ewoc
(ewoc-locate ement-ewoc) #'ement-event-p next-fn))
(ewoc-goto-node ement-ewoc node)
(if (= (point) (point-max))
(user-error "End of events")
(goto-char (point-max)))))
(defun ement-room-scroll-down-command ()
"Scroll down, and load NUMBER earlier messages when at top."
(interactive)
(condition-case _err
(scroll-down nil)
(beginning-of-buffer
(call-interactively #'ement-room-retro))))
(defun ement-room-mwheel-scroll (event)
"Scroll according to EVENT, loading earlier messages when at top."
(interactive "e")
(with-selected-window (posn-window (event-start event))
(mwheel-scroll event)
(when (= (point-min) (window-start))
(call-interactively #'ement-room-retro))))
(cl-defun ement-room-retro
(room session number &key buffer
(then (apply-partially #'ement-room-retro-callback room session)))
"Retrieve NUMBER older messages in ROOM on SESSION."
(interactive (list ement-room ement-session
(cl-typecase current-prefix-arg
(null ement-room-retro-messages-number)
(list (read-number "Number of messages: "))
(number current-prefix-arg))
:buffer (current-buffer)))
(unless ement-room-retro-loading
(pcase-let* (((cl-struct ement-room id prev-batch) room)
(endpoint (format "rooms/%s/messages" (url-hexify-string id))))
(ement-api session endpoint :timeout 30
:params (list (list "from" prev-batch)
(list "dir" "b")
(list "limit" (number-to-string number))
(list "filter" (json-encode ement-room-messages-filter)))
:then then
:else (lambda (plz-error)
(when buffer
(with-current-buffer buffer
(setf ement-room-retro-loading nil)))
(signal 'ement-api-error (list (format "Loading %s earlier messages failed" number)
plz-error))))
(message "Loading %s earlier messages..." number)
(setf ement-room-retro-loading t))))
(cl-defun ement-room-retro-to (room session event-id &key then (batch-size 100) (limit 1000))
"Retrieve messages in ROOM on SESSION back to EVENT-ID.
When event is found, call function THEN. Search in batches of
BATCH-SIZE events up to a total of LIMIT."
(declare (indent defun))
(cl-assert
(not (gethash event-id (ement-session-events session))))
(let* ((total-retrieved 0)
(callback-symbol (gensym "ement-room-retro-to-callback-"))
(callback (lambda (data)
(ement-room-retro-callback room session data)
(if (gethash event-id (ement-session-events session))
(progn
(message "Found event %S" event-id)
(when then
(funcall then)))
(if (>= (cl-incf total-retrieved batch-size) limit)
(message "%s older events retrieved without finding event %S"
limit event-id)
(message "Looking back for event %S (%s/%s events retrieved)"
event-id total-retrieved limit)
(ement-room-retro room session batch-size
:buffer (alist-get 'buffer (ement-room-local room))
:then callback-symbol))))))
(fset callback-symbol callback)
(ement-room-retro room session batch-size
:buffer (alist-get 'buffer (ement-room-local room))
:then callback-symbol)))
(cl-defun ement-room-retro-to-token (room session from to
&key (batch-size 100) (limit 1000))
"Retrieve messages in ROOM on SESSION back from FROM to TO.
Retrieve batches of BATCH-SIZE up to total LIMIT. FROM and TO
are sync batch tokens. Used for, e.g. filling gaps in
\"limited\" sync responses."
(pcase-let* (((cl-struct ement-room id) room)
(endpoint (format "rooms/%s/messages" (url-hexify-string id)))
(then
(lambda (data)
(ement-room-retro-callback room session data
:set-prev-batch nil)
(pcase-let* (((map end chunk) data))
(unless (< (length chunk) batch-size)
(let ((remaining-limit (- limit batch-size)))
(if (not (> remaining-limit 0))
(display-warning 'ement-room-retro-to-token
(format "Loaded events in %S (%S) without filling gap; not filling further"
(ement-room-display-name room)
(or (ement-room-canonical-alias room)
(ement-room-id room))))
(message "Ement: Continuing to fill gap in %S (%S) (remaining limit: %s)"
(ement-room-display-name room)
(or (ement-room-canonical-alias room)
(ement-room-id room))
remaining-limit)
(ement-room-retro-to-token
room session end to :limit remaining-limit))))))))
(message "Ement: Filling gap in %S (%S)"
(ement-room-display-name room)
(or (ement-room-canonical-alias room)
(ement-room-id room)))
(ement-api session endpoint :timeout 30
:params (list (list "from" from)
(list "to" to)
(list "dir" "b")
(list "limit" (number-to-string batch-size))
(list "filter" (json-encode ement-room-messages-filter)))
:then then
:else (lambda (plz-error)
(signal 'ement-api-error
(list (format "Filling gap in %S (%S) failed"
(ement-room-display-name room)
(or (ement-room-canonical-alias room)
(ement-room-id room)))
plz-error))))))
(declare-function ement--sync "ement.el" t t)
(defun ement-room-sync (session &optional force)
"Sync SESSION (interactively, current buffer's).
If FORCE (interactively, with prefix), cancel any outstanding
sync requests. Also, update any room list buffers."
(interactive (list ement-session current-prefix-arg))
(ement--sync session :force force)
(cl-loop for buffer in (buffer-list)
when (member (buffer-local-value 'major-mode buffer)
'(ement-room-list-mode ement-tabulated-room-list-mode))
do (with-current-buffer buffer
(revert-buffer))))
(defun ement-room-view-event (event)
"Pop up buffer showing details of EVENT (interactively, the one at point).
EVENT should be an `ement-event' or `ement-room-membership-events' struct."
(interactive (list (ewoc-data (ewoc-locate ement-ewoc))))
(require 'pp)
(cl-labels ((event-alist (event)
(ement-alist :id (ement-event-id event)
:sender (ement-user-id (ement-event-sender event))
:content (ement-event-content event)
:origin-server-ts (ement-event-origin-server-ts event)
:type (ement-event-type event)
:state-key (ement-event-state-key event)
:unsigned (ement-event-unsigned event)
:receipts (ement-event-receipts event)
:local (ement-event-local event))))
(let* ((buffer-name (format "*Ement event: %s*"
(cl-typecase event
(ement-room-membership-events "[multiple events]")
(ement-event (ement-event-id event)))))
(event (cl-typecase event
(ement-room-membership-events
(mapcar #'event-alist (ement-room-membership-events-events event)))
(ement-event (event-alist event))))
(inhibit-read-only t))
(with-current-buffer (get-buffer-create buffer-name)
(erase-buffer)
(pp event (current-buffer))
(view-mode)
(pop-to-buffer (current-buffer))))))
(cl-defun ement-room-send-message (room session &key body formatted-body replying-to-event)
"Send message to ROOM on SESSION with BODY and FORMATTED-BODY.
Interactively, with prefix, prompt for room and session,
otherwise use current room.
REPLYING-TO-EVENT may be an event the message is in reply to; the
message will reference it appropriately.
If `ement-room-send-message-filter' is non-nil, the message's
content alist is passed through it before sending. This may be
used to, e.g. process the BODY into another format and add it to
the content (e.g. see `ement-room-send-org-filter')."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Send message (%s): " (ement-room-display-name ement-room)))
(body (ement-room-with-typing
(ement-room-read-string prompt nil 'ement-room-message-history
nil 'inherit-input-method))))
(list ement-room ement-session :body body))))
(ement-send-message room session :body body :formatted-body formatted-body
:replying-to-event replying-to-event :filter ement-room-send-message-filter
:then #'ement-room-send-event-callback)
(when-let* ((buffer (alist-get 'buffer (ement-room-local room)))
(window (get-buffer-window buffer)))
(with-selected-window window
(when (>= (window-point) (ewoc-location (ewoc-nth ement-ewoc -1)))
(setf (window-point) (point-max))))))
(cl-defun ement-room-send-emote (room session &key body)
"Send emote to ROOM on SESSION with BODY.
Interactively, with prefix, prompt for room and session,
otherwise use current room.
If `ement-room-send-message-filter' is non-nil, the message's
content alist is passed through it before sending. This may be
used to, e.g. process the BODY into another format and add it to
the content (e.g. see `ement-room-send-org-filter')."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Send emote (%s): " (ement-room-display-name ement-room)))
(body (ement-room-with-typing
(ement-room-read-string prompt nil 'ement-room-emote-history
nil 'inherit-input-method))))
(list ement-room ement-session :body body))))
(cl-assert (not (string-empty-p body)))
(pcase-let* (((cl-struct ement-room (id room-id) (local (map buffer))) room)
(window (when buffer (get-buffer-window buffer)))
(endpoint (format "rooms/%s/send/m.room.message/%s" (url-hexify-string room-id)
(ement--update-transaction-id session)))
(content (ement-aprog1
(ement-alist "msgtype" "m.emote"
"body" body))))
(when ement-room-send-message-filter
(setf content (funcall ement-room-send-message-filter content room)))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (apply-partially #'ement-room-send-event-callback :room room :session session
:content content :data)) (when window
(with-selected-window window
(when (>= (window-point) (ewoc-location (ewoc-nth ement-ewoc -1)))
(setf (window-point) (point-max)))))))
(cl-defun ement-room-send-event-callback (&key data room session content)
"Callback for event-sending functions.
DATA is the parsed JSON object. If DATA's event ID is already
present in SESSION's events table, show an appropriate warning
mentioning the ROOM and CONTENT."
(pcase-let* (((map ('event_id event-id)) data))
(when (and ement-room-warn-for-already-seen-messages
(gethash event-id (ement-session-events session)))
(let ((message (format "Event ID %S already seen in session %S. This may indicate a reused transaction ID, which could mean that the event was not sent to the room (%S). You may need to disconnect, delete the `ement-sessions-file', and connect again to start a new session. Alternatively, this can happen if the event's sent-confirmation is received after the event itself is received in the next sync response, in which case no action is needed."
event-id (ement-user-id (ement-session-user session))
(ement-room-display-name room))))
(when content
(setf message (concat message (format " Event content: %S" content))))
(display-warning 'ement-room-send-event-callback message)))
(when (eq 'send ement-room-mark-rooms-read)
(when-let ((buffer (alist-get 'buffer (ement-room-local room))))
(with-current-buffer buffer
(save-excursion
(goto-char (ewoc-location
(ement-room--ewoc-last-matching ement-ewoc #'ement-event-p)))
(call-interactively #'ement-room-mark-read)))))))
(defun ement-room-edit-message (event room session body)
"Edit EVENT in ROOM on SESSION to have new BODY.
The message must be one sent by the local user."
(interactive (ement-room-with-highlighted-event-at (point)
(cl-assert ement-session) (cl-assert ement-room)
(pcase-let* ((event (ewoc-data (ewoc-locate ement-ewoc)))
((cl-struct ement-session user events) ement-session)
((cl-struct ement-event sender id
(content (map body ('m.relates_to relates-to))))
event))
(unless (equal (ement-user-id sender) (ement-user-id user))
(user-error "You may only edit your own messages"))
(when relates-to
(setf event (gethash id events)))
(setf body (replace-regexp-in-string (rx bos "*" (1+ space)) "" body t t))
(ement-room-with-typing
(let* ((prompt (format "Edit message (%s): "
(ement-room-display-name ement-room)))
(body (ement-room-read-string prompt body 'ement-room-message-history
nil 'inherit-input-method)))
(when (string-empty-p body)
(user-error "To delete a message, use command `ement-room-delete-message'"))
(when (yes-or-no-p (format "Edit message to: %S? " body))
(list event ement-room ement-session body)))))))
(let* ((endpoint (format "rooms/%s/send/%s/%s" (url-hexify-string (ement-room-id room))
"m.room.message" (ement--update-transaction-id session)))
(new-content (ement-alist "body" body
"msgtype" "m.text"))
(_ (when ement-room-send-message-filter
(setf new-content (funcall ement-room-send-message-filter new-content room))))
(content (ement-alist "msgtype" "m.text"
"body" body
"m.new_content" new-content
"m.relates_to" (ement-alist "rel_type" "m.replace"
"event_id" (ement-event-id event)))))
(setf body (concat "* " body))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (apply-partially #'ement-room-send-event-callback :room room :session session
:content content :data))))
(defun ement-room-delete-message (event room session &optional reason)
"Delete EVENT in ROOM on SESSION, optionally with REASON."
(interactive (ement-room-with-highlighted-event-at (point)
(if (yes-or-no-p "Delete this event? ")
(list (ewoc-data (ewoc-locate ement-ewoc))
ement-room ement-session (read-string "Reason (optional): " nil nil nil 'inherit-input-method))
(user-error "Message not deleted"))))
(ement-redact event room session reason))
(defun ement-room-write-reply ()
"Send a reply to event at point."
(interactive)
(cl-assert ement-ewoc) (cl-assert ement-room) (cl-assert ement-session)
(cl-assert (ement-event-p (ewoc-data (ewoc-locate ement-ewoc))))
(ement-room-with-highlighted-event-at (point)
(pcase-let* ((event (ewoc-data (ewoc-locate ement-ewoc)))
(room ement-room)
(session ement-session)
(prompt (format "Send reply (%s): " (ement-room-display-name room)))
(ement-room-read-string-setup-hook
(lambda ()
(setq-local ement-room-replying-to-event event)))
(body (ement-room-with-typing
(ement-room-read-string prompt nil 'ement-room-message-history
nil 'inherit-input-method))))
(ement-room-send-message room session :body body :replying-to-event event))))
(defun ement-room-send-reaction (key position)
"Send reaction of KEY to event at POSITION.
Interactively, send reaction to event at point. KEY should be a
reaction string, e.g. \"👍\"."
(interactive
(list (char-to-string (read-char-by-name "Reaction (prepend \"*\" for substring search): "))
(point)))
(ement-room-with-highlighted-event-at position
(pcase-let* ((event (or (ewoc-data (ewoc-locate ement-ewoc position))
(user-error "No event at point")))
((cl-struct ement-event (id event-id)) event)
((cl-struct ement-room (id room-id)) ement-room)
(endpoint (format "rooms/%s/send/m.reaction/%s" (url-hexify-string room-id)
(ement--update-transaction-id ement-session)))
(content (ement-alist "m.relates_to"
(ement-alist "rel_type" "m.annotation"
"event_id" event-id
"key" key))))
(ement-api ement-session endpoint :method 'put :data (json-encode content)
:then (apply-partially #'ement-room-send-event-callback
:room ement-room :session ement-session :content content
:data)))))
(defun ement-room-toggle-reaction (key event room session)
"Toggle reaction of KEY to EVENT in ROOM on SESSION."
(interactive
(cl-labels
((face-at-point-p (face)
(let ((face-at-point (get-text-property (point) 'face)))
(or (eq face face-at-point)
(and (listp face-at-point)
(member face face-at-point)))))
(buffer-substring-while (beg pred &key (forward-fn #'forward-char))
"Return substring of current buffer from BEG while PRED is true."
(save-excursion
(goto-char beg)
(cl-loop while (funcall pred)
do (funcall forward-fn)
finally return (buffer-substring-no-properties beg (point)))))
(key-at (pos)
(cond ((face-at-point-p 'ement-room-reactions-key)
(buffer-substring-while
pos (lambda () (face-at-point-p 'ement-room-reactions-key))))
((face-at-point-p 'ement-room-reactions)
(buffer-substring-while
(button-start (button-at pos))
(lambda () (face-at-point-p 'ement-room-reactions-key)))))))
(list (or (key-at (point))
(char-to-string (read-char-by-name "Reaction (prepend \"*\" for substring search): ")))
(ewoc-data (ewoc-locate ement-ewoc))
ement-room ement-session)))
(pcase-let* (((cl-struct ement-event (local (map reactions))) event)
((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user))
(if-let (reaction-event (cl-find-if (lambda (event)
(and (equal user-id (ement-user-id (ement-event-sender event)))
(equal key (map-nested-elt (ement-event-content event) '(m.relates_to key)))))
reactions))
(ement-redact reaction-event room session)
(ement-room-send-reaction key (point)))))
(defun ement-room-reaction-button-action (button)
"Push reaction BUTTON at point."
(save-excursion
(goto-char (button-start button))
(call-interactively #'ement-room-toggle-reaction)))
(defun ement-room-toggle-space (room space session)
"Toggle ROOM's membership in SPACE on SESSION."
(interactive
(ement-with-room-and-session
:prompt-form (ement-complete-room :session ement-session
:predicate (lambda (room) (not (ement--space-p room))) )
(pcase-let* ((prompt (format "Toggle room %S's membership in space: "
(ement--format-room ement-room)))
(`(,space ,_session) (ement-complete-room :session ement-session :prompt prompt :suggest nil
:predicate #'ement--space-p)))
(list ement-room space ement-session))))
(pcase-let* (((cl-struct ement-room (id child-id)) room)
(routing-server (progn
(string-match (rx (1+ (not (any ":"))) ":" (group (1+ anything))) child-id)
(match-string 1 child-id)))
(action (if (ement--room-in-space-p room space)
'remove 'add))
(data (pcase action
('add (ement-alist "via" (vector
routing-server)))
('remove (make-hash-table)))))
(ement-put-state space "m.space.child" child-id data session
:then (lambda (response-data)
(pcase-let* (((map event_id) response-data)
((cl-struct ement-session user) session)
((cl-struct ement-room (id child-id)) room)
(fake-event (make-ement-event :id event_id :type "m.space.child"
:sender user :state-key child-id
:content (json-read-from-string (json-encode data)))))
(push fake-event (ement-room-timeline space))
(run-hook-with-args 'ement-event-hook fake-event space session))
(ement-message "Room %S %s space %S"
(ement--format-room room)
(pcase action
('add "added to")
('remove "removed from"))
(ement--format-room space))))))
(defun ement-room-view (room session)
"Switch to a buffer showing ROOM on SESSION.
Uses action `ement-view-room-display-buffer-action', which see."
(interactive (ement-complete-room :session (ement-complete-session) :suggest nil
:predicate (lambda (room)
(not (ement--space-p room)))))
(pcase-let* (((cl-struct ement-room (local (map buffer))) room))
(unless (buffer-live-p buffer)
(setf buffer (ement-room--buffer session room (ement-room--buffer-name room))
(alist-get 'buffer (ement-room-local room)) buffer))
(pop-to-buffer buffer ement-view-room-display-buffer-action)
(run-hook-with-args 'ement-room-view-hook room session)))
(defalias 'ement-view-room #'ement-room-view)
(defun ement-room-view-hook-room-list-auto-update (_room session)
"Call `ement-room-list-auto-update' with SESSION.
To be used in `ement-room-view-hook', which see."
(declare (function ement-room-list-auto-update "ement-room-list"))
(ement-room-list-auto-update session))
(defun ement-room--buffer-name (room)
"Return name for ROOM's buffer."
(concat ement-room-buffer-name-prefix
(or (ement-room-display-name room)
(setf (ement-room-display-name room)
(ement--room-display-name room)))
ement-room-buffer-name-suffix))
(defun ement-room-goto-event (event)
"Go to EVENT in current buffer."
(if-let ((node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal (ement-event-id event) (ement-event-id data)))))))
(goto-char (ewoc-location node))
(error "Event not found in buffer: %S" (ement-event-id event))))
(cl-defun ement-room-retro-callback (room session data
&key (set-prev-batch t))
"Push new DATA to ROOM on SESSION and add events to room buffer.
If SET-PREV-BATCH is nil, don't set ROOM's prev-batch slot to the
\"prev_batch\" token in response DATA (this should be set,
e.g. when filling timeline gaps as opposed to retrieving messages
before the earliest-seen message)."
(declare (function ement--make-event "ement.el")
(function ement--put-event "ement.el"))
(pcase-let* (((cl-struct ement-room local) room)
((map _start end chunk state) data)
((map buffer) local)
(num-events (length chunk))
(progress-max-value (* 3 num-events)))
(ement-debug num-events progress-max-value)
(setf chunk (nreverse chunk)
state (nreverse state))
(cl-loop for event across-ref state
do (setf event (ement--make-event event))
finally do (setf (ement-room-state room)
(append (ement-room-state room) (append state nil))))
(ement-with-progress-reporter (:reporter ("Ement: Processing earlier events..." 0 progress-max-value))
(cl-loop for event across-ref chunk
do (setf event (ement--make-event event))
(ement--put-event event nil session)
(ement-progress-update)
finally do (setf (ement-room-timeline room)
(append (ement-room-timeline room) (append chunk nil))))
(when buffer
(with-current-buffer buffer
(save-window-excursion
(when-let ((buffer-window (get-buffer-window buffer)))
(select-window buffer-window))
(ement-room--process-events chunk)
(when set-prev-batch
(setf (ement-room-prev-batch room) end))
(setf ement-room-retro-loading nil)))))
(message "Ement: Loaded %s earlier events." num-events)))
(defun ement-room--insert-events (events &optional retro)
"Insert EVENTS into current buffer.
Calls `ement-room--insert-event' for each event and inserts
timestamp headers into appropriate places while maintaining
point's position. If RETRO is non-nil, assume EVENTS are earlier
than any existing events, and only insert timestamp headers up to
the previously oldest event."
(let (buffer-window point-node orig-first-node point-max-p)
(when (get-buffer-window (current-buffer))
(setf buffer-window (get-buffer-window (current-buffer))
point-max-p (= (point) (point-max))))
(when (and buffer-window retro)
(setf point-node (ewoc-locate ement-ewoc (window-start buffer-window))
orig-first-node (ewoc-nth ement-ewoc 0)))
(save-window-excursion
(when buffer-window
(select-window buffer-window))
(cl-loop for event being the elements of events
do (ement-room--process-event event)
do (ement-progress-update)))
(ement-room--insert-ts-headers nil (when retro orig-first-node))
(when ement-room-sender-in-headers
(ement-room--insert-sender-headers ement-ewoc))
(when buffer-window
(cond (retro (with-selected-window buffer-window
(set-window-start buffer-window (ewoc-location point-node))
(forward-line -1)))
(point-max-p (set-window-point buffer-window (point-max)))))))
(cl-defun ement-room--send-typing (session room &key (typing t))
"Send a typing notification for ROOM on SESSION."
(pcase-let* (((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/typing/%s"
(url-hexify-string room-id) (url-hexify-string user-id)))
(data (ement-alist "typing" typing "timeout" 20000)))
(ement-api session endpoint :method 'put :data (json-encode data)
:then #'ignore)))
(define-derived-mode ement-room-mode fundamental-mode
`("Ement-Room"
(:eval (unless (map-elt ement-syncs ement-session)
(propertize ":Not-syncing"
'face 'font-lock-warning-face
'help-echo "Automatic syncing was interrupted; press \"g\" to resume"))))
"Major mode for Ement room buffers.
This mode initializes a buffer to be used for showing events in
an Ement room. It kills all local variables, removes overlays,
and erases the buffer."
(let ((inhibit-read-only t))
(erase-buffer))
(remove-overlays)
(setf buffer-read-only t
left-margin-width ement-room-left-margin-width
right-margin-width ement-room-right-margin-width
imenu-create-index-function #'ement-room--imenu-create-index-function
ement-ewoc (ewoc-create #'ement-room--pp-thing))
(let ((handler (cons ement-room-matrix.to-url-regexp #'ement-room-browse-url)))
(if (boundp 'browse-url-handlers)
(setq-local browse-url-handlers (cons handler browse-url-handlers))
(setq-local browse-url-browser-function
(cons handler
(if (consp browse-url-browser-function)
browse-url-browser-function
(and browse-url-browser-function
(list (cons "." browse-url-browser-function))))))))
(setq-local completion-at-point-functions
'(ement-room--complete-members-at-point ement-room--complete-rooms-at-point))
(setq-local dnd-protocol-alist (append '(("^file:///" . ement-room-dnd-upload-file)
("^file:" . ement-room-dnd-upload-file))
dnd-protocol-alist)))
(add-hook 'ement-room-mode-hook 'visual-line-mode)
(defun ement-room-read-string (prompt &optional initial-input history default-value inherit-input-method)
"Call `read-from-minibuffer', binding variables and keys for Ement.
Arguments PROMPT, INITIAL-INPUT, HISTORY, DEFAULT-VALUE, and
INHERIT-INPUT-METHOD are as those expected by `read-string',
which see. Runs hook `ement-room-read-string-setup-hook', which
see."
(let ((room ement-room)
(session ement-session))
(minibuffer-with-setup-hook
(lambda ()
"Bind keys and variables locally (to be called in minibuffer)."
(setq-local ement-room room)
(setq-local ement-session session)
(setq-local completion-at-point-functions
'(ement-room--complete-members-at-point ement-room--complete-rooms-at-point))
(visual-line-mode 1)
(run-hooks 'ement-room-read-string-setup-hook))
(read-from-minibuffer prompt initial-input ement-room-minibuffer-map
nil history default-value inherit-input-method))))
(defun ement-room--buffer (session room name)
"Return buffer named NAME showing ROOM's events on SESSION.
If ROOM has no buffer, one is made and stored in the room's local
data slot."
(declare (function ement-view-space "ement-directory"))
(or (map-elt (ement-room-local room) 'buffer)
(let ((new-buffer (generate-new-buffer name)))
(with-current-buffer new-buffer
(ement-room-mode)
(setf header-line-format (when ement-room-header-line-format
'ement-room-header-line-format)
ement-session session
ement-room room
list-buffers-directory (or (ement-room-canonical-alias room)
(ement-room-id room))
(map-elt (ement-room-local room) 'buffer) (current-buffer))
(add-hook 'kill-buffer-hook
(lambda ()
(setf (map-elt (ement-room-local room) 'buffer) nil))
nil 'local)
(setq-local bookmark-make-record-function #'ement-room-bookmark-make-record)
(let ((header (if (cl-loop for state in (list (ement-room-state ement-room)
(ement-room-invite-state ement-room))
thereis (cl-find "m.room.encryption" state
:test #'equal :key #'ement-event-type))
(propertize "This appears to be an encrypted room, which is not natively supported by Ement.el. (See information about using Pantalaimon in Ement.el documentation.)"
'face 'font-lock-warning-face)
""))
(footer (pcase (ement-room-status ement-room)
('invite
(concat (propertize "You've been invited to this room. "
'face 'font-lock-warning-face)
(propertize "[Join this room]"
'button '(t)
'category 'default-button
'mouse-face 'highlight
'follow-link t
'action (lambda (_button)
(let ((room ement-room)
(session ement-session))
(kill-buffer)
(message "Joining room... (buffer will be reopened after joining)")
(ement-room-join (ement-room-id room) session))))))
(_ (if (ement--space-p room)
(concat (propertize "This room is a space. It is not for messaging, but only a grouping of other rooms. "
'face 'font-lock-type-face)
(propertize "[View rooms in this space]"
'button '(t)
'category 'default-button
'mouse-face 'highlight
'follow-link t
'action (lambda (_button)
(let ((room ement-room)
(session ement-session))
(kill-buffer)
(message "Viewing space...")
(ement-view-space room session)))))
"")))))
(ewoc-set-hf ement-ewoc header footer))
(setf
(alist-get 'new-events (ement-room-local room)) nil
(alist-get 'buffer (ement-room-local room)) new-buffer)
(ement-room--process-events (reverse (ement-room-state room)))
(ement-room--process-events (reverse (ement-room-timeline room)))
(ement-room--insert-ts-headers)
(when ement-room-sender-in-headers
(ement-room--insert-sender-headers ement-ewoc))
(ement-room-move-read-markers room
:read-event (when-let ((event (alist-get "m.read" (ement-room-account-data room) nil nil #'equal)))
(map-nested-elt event '(content event_id)))
:fully-read-event (when-let ((event (alist-get "m.fully_read" (ement-room-account-data room) nil nil #'equal)))
(map-nested-elt event '(content event_id)))))
new-buffer)))
(defun ement-room--event-data (id)
"Return event struct for event ID in current buffer."
(cl-loop with node = (ewoc-nth ement-ewoc -1)
while node
for data = (ewoc-data node)
when (and (ement-event-p data)
(equal id (ement-event-id data)))
return data
do (setf node (ewoc-prev ement-ewoc node))))
(defun ement-room--escape-% (string)
"Return STRING with \"%\" escaped.
Needed to display things in the header line."
(replace-regexp-in-string (rx "%") "%%" string t t))
(defconst ement-room-timestamp-header-imenu-format "%Y-%m-%d (%A) %H:%M"
"Format string for timestamps in Imenu indexes.")
(defun ement-room--imenu-create-index-function ()
"Return Imenu index for the current buffer.
For use as `imenu-create-index-function'."
(let ((timestamp-nodes (ement-room--ewoc-collect-nodes
ement-ewoc (lambda (node)
(pcase (ewoc-data node)
(`(ts . ,_) t))))))
(cl-loop for node in timestamp-nodes
collect (pcase-let*
((`(ts ,timestamp) (ewoc-data node))
(formatted (format-time-string ement-room-timestamp-header-imenu-format timestamp)))
(cons formatted (ewoc-location node))))))
(defvar-local ement-room-occur-pred nil
"Predicate used to refresh `ement-room-occur' buffers.")
(define-derived-mode ement-room-occur-mode ement-room-mode "Ement-Room-Occur")
(progn
(define-key ement-room-occur-mode-map [remap ement-room-send-message] #'ement-room-occur-find-event)
(define-key ement-room-occur-mode-map (kbd "g") #'revert-buffer)
(define-key ement-room-occur-mode-map (kbd "n") #'ement-room-occur-next)
(define-key ement-room-occur-mode-map (kbd "p") #'ement-room-occur-prev))
(cl-defun ement-room-occur (&key user-id regexp pred header)
"Show known events in current buffer matching args in a new buffer.
If REGEXP, show events whose sender or body content match it. Or
if USER-ID, show events from that user. Or if PRED, show events
matching it. HEADER is used if given, or set according to other
arguments."
(interactive (let* ((regexp (read-regexp "Regexp (leave empty to select user instead)"))
(user-id (when (string-empty-p regexp)
(ement-complete-user-id))))
(list :regexp regexp :user-id user-id)))
(let* ((session ement-session)
(room ement-room)
(occur-buffer (get-buffer-create (format "*Ement Room Occur: %s*" (ement-room-display-name room))))
(pred (cond (pred)
((not (string-empty-p regexp))
(lambda (data)
(and (ement-event-p data)
(or (string-match regexp (ement-user-id (ement-event-sender data)))
(when-let ((room-display-name
(gethash (ement-event-sender data) (ement-room-displaynames room))))
(string-match regexp room-display-name))
(when-let ((body (alist-get 'body (ement-event-content data))))
(string-match regexp body))))))
(user-id
(lambda (data)
(and (ement-event-p data)
(equal user-id (ement-user-id (ement-event-sender data))))))))
(header (cond (header)
((not (string-empty-p regexp))
(format "Events matching %S in %s" regexp (ement-room-display-name room)))
(user-id
(format "Events from %s in %s" user-id (ement-room-display-name room))))))
(with-current-buffer occur-buffer
(let ((inhibit-read-only t))
(erase-buffer))
(ement-room-occur-mode)
(setf header-line-format header
ement-session session
ement-room room)
(setq-local revert-buffer-function (lambda (&rest _)
(interactive)
(let ((event-at-point (ewoc-data (ewoc-locate ement-ewoc))))
(with-current-buffer (alist-get 'buffer (ement-room-local room))
(ement-room-occur :pred pred :header header)
(when-let ((node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(eq event-at-point data)))))
(ewoc-goto-node ement-ewoc node))))))
(ement-room--process-events (reverse (ement-room-state room)))
(ement-room--process-events (reverse (ement-room-timeline room)))
(ewoc-filter ement-ewoc pred)
(ement-room--insert-ts-headers))
(pop-to-buffer occur-buffer)))
(defun ement-room-occur-find-event (event)
"Find EVENT in room's main buffer."
(interactive (list (ewoc-data (ewoc-locate ement-ewoc))))
(pcase-let* (((cl-struct ement-room (local (map buffer))) ement-room)
((cl-struct ement-event id) event))
(display-buffer buffer)
(with-selected-window (get-buffer-window buffer)
(ement-room-find-event id))))
(cl-defun ement-room-occur-next (&optional (n 1))
"Go to Nth next event."
(interactive)
(let ((command (if (> n 0)
#'ement-room-goto-next
#'ement-room-goto-prev)))
(cl-loop for i below (abs n)
do (call-interactively command))
(ement-room-occur-find-event (ewoc-data (ewoc-locate ement-ewoc)))))
(cl-defun ement-room-occur-prev (&optional (n 1))
"Go to Nth previous event."
(interactive)
(ement-room-occur-next (- n)))
(defvar ement-users)
(defvar ement-room-event-fns nil
"Alist mapping event types to functions which process events in room buffers.")
(defun ement-room--process-events (events)
"Process EVENTS in current buffer.
Calls `ement-progress-update' for each event. Calls
`ement-room--insert-ts-headers' when done. Uses handlers defined
in `ement-room-event-fns'. The current buffer should be a room's
buffer."
(cl-loop for event being the elements of events for handler = (alist-get (ement-event-type event) ement-room-event-fns nil nil #'equal)
when handler
do (funcall handler event)
do (ement-progress-update))
(ement-room--insert-ts-headers))
(defun ement-room--process-event (event)
"Process EVENT in current buffer.
Uses handlers defined in `ement-room-event-fns'. The current
buffer should be a room's buffer."
(when-let ((handler (alist-get (ement-event-type event) ement-room-event-fns nil nil #'equal)))
(with-demoted-errors "Ement (ement-room--process-event): Error processing event: %S"
(funcall handler event))))
(defmacro ement-room-defevent (type &rest body)
"Define an event handling function for events of TYPE.
Around the BODY, the variable `event' is bound to the event being
processed. The function is called in the room's buffer. Adds
function to `ement-room-event-fns', which see."
(declare (debug (stringp def-body))
(indent defun))
`(setf (alist-get ,type ement-room-event-fns nil nil #'string=)
(lambda (event)
,(concat "`ement-room' handler function for " type " events.")
,@body)))
(ement-room-defevent "m.reaction"
(pcase-let* (((cl-struct ement-event content) event)
((map ('m.relates_to relates-to)) content)
((map ('event_id related-id) ('rel_type rel-type) _key) relates-to))
(pcase rel-type
("m.annotation"
(if-let ((related-event (cl-loop with fake-event = (make-ement-event :id related-id)
for timeline-event in (ement-room-timeline ement-room)
when (ement--events-equal-p fake-event timeline-event)
return timeline-event)))
(progn
(cl-pushnew event (map-elt (ement-event-local related-event) 'reactions))
(when-let ((nodes (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal related-id (ement-event-id data)))))))
(ewoc-invalidate ement-ewoc nodes)))
(ement-debug "No known related event for" event))))))
(ement-room-defevent "m.room.power_levels"
(ement-room--insert-event event))
(defun ement-room--format-power-levels-event (event room _session)
"Return power-levels EVENT in ROOM formatted as a string."
(pcase-let (((cl-struct ement-event sender
(content (map ('users new-users)))
(unsigned (map ('prev_content (map ('users old-users))))))
event))
(when old-users
(pcase-let* ((sender-id (ement-user-id sender))
(sender-displayname (ement--user-displayname-in room sender))
(`(,changed-user-id-symbol . ,new-level)
(cl-find-if (lambda (new-user)
(let ((old-user (cl-find (car new-user) old-users
:key #'car)))
(or (not old-user)
(not (equal (cdr new-user) (cdr old-user))))))
new-users))
(changed-user-id (symbol-name changed-user-id-symbol))
(changed-user (when changed-user-id-symbol
(gethash changed-user-id ement-users)))
(user-displayname (if changed-user
(ement--user-displayname-in room changed-user)
changed-user-id)))
(ement-room-wrap-prefix
(if (not changed-user)
(format "%s sent a power-level event"
(propertize sender-displayname
'help-echo sender-id))
(format "%s set %s's power level to %s"
(propertize sender-displayname
'help-echo sender-id)
(propertize user-displayname 'help-echo changed-user-id)
new-level))
'face 'ement-room-membership)))))
(ement-room-defevent "m.room.canonical_alias"
(ement-room--insert-event event))
(defun ement-room--format-canonical-alias-event (event room _session)
"Return canonical alias EVENT in ROOM formatted as a string."
(pcase-let (((cl-struct ement-event sender
(content (map alias)))
event))
(ement-room-wrap-prefix
(format "%s set the canonical alias to <%s>"
(propertize (ement--user-displayname-in room sender)
'help-echo (ement-user-id sender))
alias)
'face 'ement-room-membership)))
(ement-room-defevent "m.room.redaction"
(pcase-let* (((cl-struct ement-event (local (map ('redacts redacted-id)))) event)
((cl-struct ement-room timeline) ement-room)
(redacted-event (cl-find redacted-id timeline
:key #'ement-event-id :test #'equal)))
(when redacted-event
(pcase-let* (((cl-struct ement-event (content
(map ('m.relates_to
(map ('event_id related-id)
('rel_type rel-type))))))
redacted-event))
(cl-pushnew event (alist-get 'redacted-by (ement-event-local redacted-event)))
(pcase rel-type
("m.annotation"
(when-let (annotated-event (cl-find related-id timeline
:key #'ement-event-id :test #'equal))
(setf (map-elt (ement-event-local annotated-event) 'reactions)
(cl-remove redacted-id (map-elt (ement-event-local annotated-event) 'reactions)
:key #'ement-event-id :test #'equal))
(when-let (node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal related-id (ement-event-id data))))))
(ewoc-invalidate ement-ewoc node)))))
(when-let (node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal redacted-id (ement-event-id data))))))
(ewoc-invalidate ement-ewoc node))))))
(ement-room-defevent "m.typing"
(pcase-let* (((cl-struct ement-session user) ement-session)
((cl-struct ement-user (id local-user-id)) user)
((cl-struct ement-event content) event)
((map ('user_ids user-ids)) content)
(usernames) (footer))
(setf user-ids (delete local-user-id user-ids))
(if (zerop (length user-ids))
(setf footer "")
(setf usernames (cl-loop for id across user-ids
for user = (gethash id ement-users)
if user
collect (ement--user-displayname-in ement-room user)
else collect id)
footer (propertize (concat "Typing: " (string-join usernames ", "))
'face 'font-lock-comment-face)))
(with-silent-modifications
(ewoc-set-hf ement-ewoc "" footer))))
(ement-room-defevent "m.room.avatar"
(ement-room--insert-event event))
(ement-room-defevent "org.matrix.msc3015.m.room.name.override"
(ignore event)
(setf (ement-room-display-name ement-room) (ement--room-display-name ement-room))
(rename-buffer (ement-room--buffer-name ement-room)))
(ement-room-defevent "m.room.member"
(with-silent-modifications
(ement-room--insert-event event)))
(ement-room-defevent "m.room.message"
(pcase-let* (((cl-struct ement-event content unsigned) event)
((map ('m.relates_to (map ('rel_type rel-type) ('event_id replaces-event-id)))) content)
((map ('m.relations (map ('m.replace (map ('event_id replaced-by-id)))))) unsigned))
(if (and ement-room-replace-edited-messages
replaces-event-id (equal "m.replace" rel-type))
(or (ement-room--replace-event event)
(progn
(ement-debug "Unable to replace event ID: inserting instead." replaces-event-id)
(ement-room--insert-event event)))
(if replaced-by-id
(ement-debug "Event replaced: not inserting." replaced-by-id)
(ement-room--insert-event event)))))
(ement-room-defevent "m.room.tombstone"
(pcase-let* (((cl-struct ement-event content) event)
((map body ('replacement_room new-room-id)) content)
(session ement-session)
(button (ement--button-buttonize
(propertize new-room-id 'help-echo "Join replacement room")
(lambda (_)
(ement-room-join new-room-id session))))
(banner (format "This room has been replaced. Explanation:%S Replacement room: <%s>" body button)))
(add-face-text-property 0 (length banner) 'font-lock-warning-face t banner)
(ement-room--insert-event event)
(ewoc-set-hf ement-ewoc banner banner)))
(ement-room-defevent "m.read"
(ement-room-move-read-markers ement-room
:read-event (ement-event-id event)))
(ement-room-defevent "m.fully_read"
(ement-room-move-read-markers ement-room
:fully-read-event (ement-event-id event)))
(defvar-local ement-room-read-receipt-marker nil
"EWOC node for the room's read-receipt marker.")
(defvar-local ement-room-fully-read-marker nil
"EWOC node for the room's fully-read marker.")
(defface ement-room-read-receipt-marker
'((t (:inherit show-paren-match)))
"Read marker line in rooms."
:group 'ement-room)
(defface ement-room-fully-read-marker
'((t (:inherit isearch)))
"Fully read marker line in rooms."
:group 'ement-room)
(defcustom ement-room-send-read-receipts t
"Whether to send read receipts.
Also controls whether the read-receipt marker in a room is moved
automatically."
:type 'boolean
:group 'ement-room)
(defun ement-room-read-receipt-idle-timer ()
"Update read receipts in visible Ement room buffers.
To be called from timer stored in
`ement-read-receipt-idle-timer'."
(when ement-room-send-read-receipts
(dolist (window (window-list))
(when (and (eq 'ement-room-mode (buffer-local-value 'major-mode (window-buffer window)))
(buffer-local-value 'ement-room (window-buffer window)))
(ement-room-update-read-receipt window)))))
(defun ement-room-update-read-receipt (window)
"Update read receipt for room displayed in WINDOW.
Also, mark room's buffer as unmodified."
(with-selected-window window
(let ((read-receipt-node (ement-room--ewoc-last-matching ement-ewoc
(lambda (node-data)
(eq 'ement-room-read-receipt-marker node-data))))
(window-end-node (or (ewoc-locate ement-ewoc (window-end nil t))
(ewoc-nth ement-ewoc -1))))
(when (or
(and read-receipt-node
(>= (window-end nil t) (ewoc-location read-receipt-node)))
(not read-receipt-node))
(let* ((event-node (when window-end-node
(cl-typecase (ewoc-data window-end-node)
(ement-event window-end-node)
(t (ement-room--ewoc-next-matching ement-ewoc window-end-node
#'ement-event-p #'ewoc-prev)))))
(node-after-event (ewoc-next ement-ewoc event-node))
(event))
(when event-node
(unless (or (when node-after-event
(<= (ewoc-location node-after-event) (window-end nil t)))
(>= (window-end) (point-max)))
(setf event-node (ement-room--ewoc-next-matching ement-ewoc event-node
#'ement-event-p #'ewoc-prev)))
(setf event (ewoc-data event-node))
(set-buffer-modified-p nil)
(unless (alist-get event ement-room-read-receipt-request)
(when-let ((request-process (car (map-values ement-room-read-receipt-request))))
(when (process-live-p request-process)
(interrupt-process request-process)))
(setf ement-room-read-receipt-request nil)
(setf (alist-get event ement-room-read-receipt-request)
(ement-room-mark-read ement-room ement-session
:read-event event)))))))))
(defun ement-room-goto-fully-read-marker ()
"Move to the fully-read marker in the current room."
(interactive)
(if-let ((fully-read-pos (when ement-room-fully-read-marker
(ewoc-location ement-room-fully-read-marker))))
(with-suppressed-warnings ((obsolete point))
(setf (point) fully-read-pos (window-start) fully-read-pos))
(if-let* ((fully-read-event (alist-get "m.fully_read" (ement-room-account-data ement-room) nil nil #'equal))
(fully-read-event-id (map-nested-elt fully-read-event '(content event_id))))
(if (gethash fully-read-event-id (ement-session-events ement-session))
(ement-room-mark-read ement-room ement-session
:fully-read-event (gethash fully-read-event-id (ement-session-events ement-session)))
(let ((buffer (current-buffer)))
(message "Searching for first unread event...")
(ement-room-retro-to ement-room ement-session fully-read-event-id
:then (lambda ()
(with-current-buffer buffer
(ement-room-move-read-markers ement-room)
(ement-room-goto-fully-read-marker))))))
(error "Room has no fully-read event"))))
(cl-defun ement-room-mark-read (room session &key read-event fully-read-event)
"Mark ROOM on SESSION as read on the server.
Set \"m.read\" to READ-EVENT and \"m.fully_read\" to
FULLY-READ-EVENT. Return the API request.
Interactively, mark both types as read up to event at point."
(declare (indent defun))
(interactive
(progn
(cl-assert (equal 'ement-room-mode major-mode) nil
"This command is to be used in `ement-room-mode' buffers")
(let* ((node (ewoc-locate ement-ewoc))
(event-at-point (cl-typecase (ewoc-data node)
(ement-event (ewoc-data node))
(t (when-let ((prev-event-node (ement-room--ewoc-next-matching ement-ewoc node
#'ement-event-p #'ewoc-prev)))
(ewoc-data prev-event-node)))))
(last-event (ewoc-data (ement-room--ewoc-last-matching ement-ewoc #'ement-event-p)))
(event-to-mark-read (if (eq event-at-point last-event)
(car (ement-room-timeline ement-room))
event-at-point)))
(list ement-room ement-session
:read-event event-to-mark-read
:fully-read-event event-to-mark-read))))
(cl-assert room) (cl-assert session) (cl-assert (or read-event fully-read-event))
(if (not fully-read-event)
(ement-room-send-receipt room session read-event)
(pcase-let* (((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/read_markers" (url-hexify-string room-id)))
(data (ement-alist "m.fully_read" (ement-event-id fully-read-event))))
(when read-event
(push (cons "m.read" (ement-event-id read-event)) data))
(let ((request-process (ement-api session endpoint :method 'post :data (json-encode data)
:then (lambda (_data)
(ement-room-move-read-markers room
:read-event read-event :fully-read-event fully-read-event))
:else (lambda (plz-error)
(pcase (plz-error-message plz-error)
("curl process interrupted"
nil)
(_ (signal 'ement-api-error
(list (format "Ement: (ement-room-mark-read) Unexpected API error: %s"
plz-error)
plz-error))))))))
(when-let ((room-buffer (alist-get 'buffer (ement-room-local room))))
(with-current-buffer room-buffer
(when-let ((request-process (car (map-values ement-room-read-receipt-request))))
(when (process-live-p request-process)
(interrupt-process request-process)))
(setf ement-room-read-receipt-request nil
(alist-get read-event ement-room-read-receipt-request) request-process)))))))
(cl-defun ement-room-send-receipt (room session event &key (type "m.read"))
"Send receipt of TYPE for EVENT to ROOM on SESSION."
(pcase-let* (((cl-struct ement-room (id room-id)) room)
((cl-struct ement-event (id event-id)) event)
(endpoint (format "rooms/%s/receipt/%s/%s"
(url-hexify-string room-id) type
(url-hexify-string event-id))))
(ement-api session endpoint :method 'post :data "{}"
:then (pcase type
("m.read" (lambda (_data)
(ement-room-move-read-markers room
:read-event event)))
(_ #'ignore)))))
(cl-defun ement-room-move-read-markers
(room &key
(read-event (when-let ((event (alist-get "m.read" (ement-room-account-data room) nil nil #'equal)))
(map-nested-elt event '(content event_id))))
(fully-read-event (when-let ((event (alist-get "m.fully_read" (ement-room-account-data room) nil nil #'equal)))
(map-nested-elt event '(content event_id)))))
"Move read markers in ROOM to READ-EVENT and FULLY-READ-EVENT.
Each event may be an `ement-event' struct or an event ID. This
updates the markers in ROOM's buffer, not on the server; see
`ement-room-mark-read' for that."
(declare (indent defun))
(cl-labels ((update-marker (symbol to-event)
(let* ((old-node (symbol-value symbol))
(new-event-id (cl-etypecase to-event
(ement-event (ement-event-id to-event))
(string to-event)))
(event-node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal (ement-event-id data) new-event-id)))))
(inhibit-read-only t))
(with-silent-modifications
(when old-node
(ewoc-delete ement-ewoc old-node))
(set symbol (when event-node
(ewoc-enter-after ement-ewoc event-node symbol)))))))
(when-let ((buffer (alist-get 'buffer (ement-room-local room))))
(with-current-buffer buffer
(when read-event
(update-marker 'ement-room-read-receipt-marker read-event))
(when fully-read-event
(update-marker 'ement-room-fully-read-marker fully-read-event))))
nil))
(defun ement-room-scroll-up-mark-read ()
"Scroll buffer contents up, move fully read marker, and bury when at end.
Moves fully read marker to the top of the window (when the
marker's position is within the range of received events). At
end-of-buffer, moves fully read marker to after the last event,
buries the buffer and shows the next unread room, if any."
(declare (function ement-tabulated-room-list-next-unread "ement-tabulated-room-list")
(function ement-room-list-next-unread "ement-room-list"))
(interactive)
(if (= (window-point) (point-max))
(progn
(when ement-room-mark-rooms-read
(ement-room-mark-read ement-room ement-session
:read-event (ewoc-data (ement-room--ewoc-last-matching ement-ewoc
(lambda (data) (ement-event-p data))))
:fully-read-event (ewoc-data (ement-room--ewoc-last-matching ement-ewoc
(lambda (data) (ement-event-p data))))))
(set-buffer-modified-p nil)
(if-let ((rooms-window (cl-find-if (lambda (window)
(member (buffer-name (window-buffer window))
'("*Ement Taxy*" "*Ement Rooms*")))
(window-list))))
(progn
(select-window rooms-window)
(funcall (pcase-exhaustive major-mode
('ement-tabulated-room-list-mode #'ement-tabulated-room-list-next-unread)
('ement-room-list-mode #'ement-room-list-next-unread))))
(bury-buffer))
(when (member major-mode '(ement-tabulated-room-list-mode ement-room-list-mode))
(revert-buffer)))
(condition-case _err
(scroll-up-command)
(end-of-buffer (set-window-point nil (point-max))))
(when-let* ((node (ewoc-locate ement-ewoc (window-start)))
(event-node (ement-room--ewoc-next-matching ement-ewoc node
#'ement-event-p #'ewoc-prev))
(fully-read-pos (and ement-room-fully-read-marker
(ewoc-location ement-room-fully-read-marker)))
((< fully-read-pos (ewoc-location event-node))))
(ement-room-mark-read ement-room ement-session :fully-read-event (ewoc-data event-node)))))
(cl-defun ement-room--ewoc-next-matching (ewoc node pred &optional (move-fn #'ewoc-next))
"Return the next node in EWOC after NODE that PRED is true of.
PRED is called with node's data. Moves to next node by MOVE-FN."
(declare (indent defun))
(cl-loop do (setf node (funcall move-fn ewoc node))
until (or (null node)
(funcall pred (ewoc-data node)))
finally return node))
(defun ement-room--ewoc-last-matching (ewoc predicate)
"Return the last node in EWOC matching PREDICATE.
PREDICATE is called with node's data. Searches backward from
last node."
(declare (indent defun))
(cl-loop with node = (ewoc-nth ewoc -1)
while node
when (funcall predicate (ewoc-data node))
return node
do (setf node (ewoc-prev ewoc node))))
(defun ement-room--ewoc-collect-nodes (ewoc predicate)
"Collect all nodes in EWOC matching PREDICATE.
PREDICATE is called with the full node."
(cl-loop with node = (ewoc-nth ewoc 0)
do (setf node (ewoc-next ewoc node))
while node
when (funcall predicate node)
collect node))
(defun ement-room--insert-ts-headers (&optional start-node end-node)
"Insert timestamp headers into current buffer's `ement-ewoc'.
Inserts headers between START-NODE and END-NODE, which default to
the first and last nodes in the buffer, respectively."
(let* ((type-predicate (lambda (node-data)
(and (ement-event-p node-data)
(not (equal "m.room.member" (ement-event-type node-data))))))
(ewoc ement-ewoc)
(end-node (or end-node
(ewoc-nth ewoc -1)))
(end-pos (if end-node
(ewoc-location end-node)
(point-max)))
(node-b (or start-node (ewoc-nth ewoc 0)))
node-a)
(while (and (setf node-a (ement-room--ewoc-next-matching ewoc (or node-a node-b) type-predicate)
node-b (when node-a
(ement-room--ewoc-next-matching ewoc node-a type-predicate)))
(not (or (> (ewoc-location node-a) end-pos)
(when node-b
(> (ewoc-location node-b) end-pos)))))
(cl-labels ((format-event (event)
(format "TS:%S (%s) Sender:%s Message:%S"
(/ (ement-event-origin-server-ts (ewoc-data event)) 1000)
(format-time-string "%Y-%m-%d %H:%M:%S"
(/ (ement-event-origin-server-ts (ewoc-data event)) 1000))
(ement-user-id (ement-event-sender (ewoc-data event)))
(when (alist-get 'body (ement-event-content (ewoc-data event)))
(substring-no-properties
(truncate-string-to-width (alist-get 'body (ement-event-content (ewoc-data event))) 20))))))
(ement-debug "Comparing event timestamps:"
(list 'A (format-event node-a))
(list 'B (format-event node-b))))
(let* ((a-ts (/ (ement-event-origin-server-ts (ewoc-data node-a)) 1000))
(b-ts (/ (ement-event-origin-server-ts (ewoc-data node-b)) 1000))
(diff-seconds (- b-ts a-ts))
(ement-room-timestamp-header-format ement-room-timestamp-header-format))
(when (and (>= diff-seconds ement-room-timestamp-header-delta)
(not (when-let ((node-after-a (ewoc-next ewoc node-a)))
(pcase (ewoc-data node-after-a)
(`(ts . ,_) t)
((or 'ement-room-read-receipt-marker 'ement-room-fully-read-marker) t)))))
(unless (equal (time-to-days a-ts) (time-to-days b-ts))
(let ((ement-room-timestamp-header-format ement-room-timestamp-header-with-date-format))
(setf node-a (ewoc-enter-after ewoc node-a (list 'ts b-ts)))))
(with-silent-modifications
(ewoc-enter-after ewoc node-a (list 'ts b-ts))))))))
(cl-defun ement-room--insert-sender-headers
(ewoc &optional (start-node (ewoc-nth ewoc 0)) (end-node (ewoc-nth ewoc -1)))
"Insert sender headers into EWOC.
Inserts headers between START-NODE and END-NODE, which default to
the first and last nodes in the buffer, respectively."
(cl-labels ((read-marker-p (data)
(member data '(ement-room-fully-read-marker
ement-room-read-receipt-marker)))
(message-event-p (data)
(and (ement-event-p data)
(equal "m.room.message" (ement-event-type data))))
(insert-sender-before (node)
(ewoc-enter-before ewoc node (ement-event-sender (ewoc-data node)))))
(let* ((event-node (if (ement-event-p (ewoc-data start-node))
start-node
(ement-room--ewoc-next-matching ewoc start-node
#'ement-event-p)))
(prev-node (when event-node
(ewoc-prev ewoc event-node))))
(while (and event-node
(<= (ewoc-location event-node) (ewoc-location end-node)))
(when (message-event-p (ewoc-data event-node))
(if (not prev-node)
(insert-sender-before event-node)
(when (read-marker-p (ewoc-data prev-node))
(setf prev-node (ement-room--ewoc-next-matching ewoc prev-node
(lambda (data)
(not (read-marker-p data)))
#'ewoc-prev)))
(when prev-node
(cl-typecase (ewoc-data prev-node)
(ement-event
(when (and (message-event-p (ewoc-data prev-node))
(not (equal (ement-event-sender (ewoc-data prev-node))
(ement-event-sender (ewoc-data event-node)))))
(insert-sender-before event-node)))
((or ement-user ement-room-membership-events)
nil)
(t
(insert-sender-before event-node))))))
(setf event-node (ement-room--ewoc-next-matching ewoc event-node
#'ement-event-p)
prev-node (when event-node
(ewoc-prev ewoc event-node)))))))
(defun ement-room--coalesce-nodes (a b ewoc)
"Try to coalesce events in nodes A and B in EWOC.
Return absorbing node if coalesced."
(cl-labels ((coalescable-p (node)
(or (and (ement-event-p (ewoc-data node))
(member (ement-event-type (ewoc-data node)) '("m.room.member")))
(ement-room-membership-events-p (ewoc-data node)))))
(when (and (coalescable-p a) (coalescable-p b))
(let* ((absorbing-node (if (or (ement-room-membership-events-p (ewoc-data a))
(not (ement-room-membership-events-p (ewoc-data b))))
a b))
(absorbed-node (if (eq absorbing-node a) b a)))
(cl-etypecase (ewoc-data absorbing-node)
(ement-room-membership-events nil)
(ement-event (setf (ewoc-data absorbing-node) (ement-room-membership-events--update
(make-ement-room-membership-events
:events (list (ewoc-data absorbing-node)))))))
(push (ewoc-data absorbed-node) (ement-room-membership-events-events (ewoc-data absorbing-node)))
(ement-room-membership-events--update (ewoc-data absorbing-node))
(ewoc-delete ewoc absorbed-node)
(ewoc-invalidate ewoc absorbing-node)
absorbing-node))))
(defun ement-room--insert-event (event)
"Insert EVENT into current buffer."
(cl-labels ((format-event (event)
(format "TS:%S (%s) Sender:%s Message:%S"
(/ (ement-event-origin-server-ts event) 1000)
(format-time-string "%Y-%m-%d %H:%M:%S"
(/ (ement-event-origin-server-ts event) 1000))
(ement-user-id (ement-event-sender event))
(when (alist-get 'body (ement-event-content event))
(substring-no-properties
(truncate-string-to-width (alist-get 'body (ement-event-content event)) 20)))))
(find-node-if (ewoc pred &key (move #'ewoc-prev) (start (ewoc-nth ewoc -1)))
"Return node in EWOC whose data matches PRED.
Search starts from node START and moves by NEXT."
(cl-loop for node = start then (funcall move ewoc node)
while node
when (funcall pred (ewoc-data node))
return node))
(timestamped-node-p (data)
(pcase data
((pred ement-event-p) t)
((pred ement-room-membership-events-p) t)
(`(ts . ,_) t)))
(node-ts (data)
(pcase data
((pred ement-event-p) (ement-event-origin-server-ts data))
((pred ement-room-membership-events-p)
(ement-room-membership-events-earliest-ts data))
(`(ts ,ts)
(* 1000 ts))))
(node< (a b)
"Return non-nil if event A's timestamp is before B's."
(< (node-ts a) (node-ts b))))
(ement-debug "INSERTING NEW EVENT: " (format-event event))
(let* ((ewoc ement-ewoc)
(event-node-before (ement-room--ewoc-node-before ewoc event #'node< :pred #'timestamped-node-p))
new-node)
(cl-loop for node-after-node-before = (ewoc-next ewoc event-node-before)
while node-after-node-before
while (not (ement-event-p (ewoc-data node-after-node-before)))
do (setf event-node-before node-after-node-before))
(setf new-node (if (not event-node-before)
(progn
(ement-debug "No event before it: add first.")
(if-let ((first-node (ewoc-nth ewoc 0)))
(progn
(ement-debug "EWOC not empty.")
(if (and (ement-user-p (ewoc-data first-node))
(equal (ement-event-sender event)
(ewoc-data first-node)))
(progn
(ement-debug "First node is header for this sender: insert after it, instead.")
(setf event-node-before first-node)
(ewoc-enter-after ewoc first-node event))
(ement-debug "First node is not header for this sender: insert first.")
(ewoc-enter-first ewoc event)))
(ement-debug "EWOC empty: add first.")
(ewoc-enter-first ewoc event)))
(ement-debug "Found event before new event: insert after it.")
(when-let ((next-node (ewoc-next ewoc event-node-before)))
(when (and (ement-user-p (ewoc-data next-node))
(equal (ement-event-sender event)
(ewoc-data next-node)))
(ement-debug "Next node is header for this sender: insert after it, instead.")
(setf event-node-before next-node)))
(ement-debug "Inserting after event"
)
(ewoc-enter-after ewoc event-node-before event)))
(when ement-room-coalesce-events
(setf new-node (or (when event-node-before
(ement-room--coalesce-nodes event-node-before new-node ewoc))
(when (ewoc-next ewoc new-node)
(ement-room--coalesce-nodes new-node (ewoc-next ewoc new-node) ewoc))
new-node)))
(when ement-room-sender-in-headers
(ement-room--insert-sender-headers ewoc new-node new-node))
new-node)))
(defun ement-room--replace-event (new-event)
"Replace appropriate event with NEW-EVENT in current buffer.
If replaced event is not found, return nil, otherwise non-nil."
(let* ((ewoc ement-ewoc)
(old-event-node (ement-room--ewoc-last-matching ewoc
(lambda (data)
(cl-typecase data
(ement-event (ement--events-equal-p data new-event)))))))
(when old-event-node
(let ((node-before (ewoc-prev ewoc old-event-node))
(inhibit-read-only t))
(ewoc-delete ewoc old-event-node)
(if node-before
(ewoc-enter-after ewoc node-before new-event)
(ewoc-enter-first ewoc new-event))))))
(cl-defun ement-room--ewoc-node-before (ewoc data <-fn
&key (from 'last) (pred #'identity))
"Return node in EWOC that matches PRED and belongs before DATA by <-FN.
Search from FROM (either `first' or `last')."
(cl-assert (member from '(first last)))
(if (null (ewoc-nth ewoc 0))
(ement-debug "EWOC is empty: returning nil.")
(ement-debug "EWOC has data: add at appropriate place.")
(cl-labels ((next-matching (ewoc node next-fn pred)
(cl-loop do (setf node (funcall next-fn ewoc node))
until (or (null node)
(funcall pred (ewoc-data node)))
finally return node)))
(let* ((next-fn (pcase from ('first #'ewoc-next) ('last #'ewoc-prev)))
(start-node (ewoc-nth ewoc (pcase from ('first 0) ('last -1)))))
(unless (funcall pred (ewoc-data start-node))
(setf start-node (next-matching ewoc start-node next-fn pred)))
(if (funcall <-fn (ewoc-data start-node) data)
(progn
(ement-debug "New data goes before start node.")
start-node)
(ement-debug "New data goes after start node: find node before new data.")
(let ((compare-node start-node))
(cl-loop while (setf compare-node (next-matching ewoc compare-node next-fn pred))
until (funcall <-fn (ewoc-data compare-node) data)
finally return (if compare-node
(progn
(ement-debug "Found place: enter there.")
compare-node)
(ement-debug "Reached end of collection: insert there.")
(pcase from
('first (ewoc-nth ewoc -1))
('last nil))))))))))
(defun ement-room--pp-thing (thing)
"Pretty-print THING.
To be used as the pretty-printer for `ewoc-create'. THING may be
an `ement-event' or `ement-user' struct, or a list like `(ts
TIMESTAMP)', where TIMESTAMP is a Unix timestamp number of
seconds."
(pcase-exhaustive thing
((pred ement-event-p)
(insert "" (ement-room--format-event thing ement-room ement-session)))
((pred ement-user-p)
(insert (propertize (ement--format-user thing)
'display ement-room-username-display-property)))
(`(ts ,(and (pred numberp) ts)) (let* ((string (format-time-string ement-room-timestamp-header-format ts))
(width (string-width string))
(maybe-newline (if (equal ement-room-timestamp-header-format ement-room-timestamp-header-with-date-format)
(progn
(cl-incf width 3)
"\n")
""))
(alignment-space (pcase ement-room-timestamp-header-align
('right (propertize " "
'display `(space :align-to (- text ,(1+ width)))))
('center (propertize " "
'display `(space :align-to (- center ,(/ (1+ width) 2)))))
(_ " "))))
(insert maybe-newline
alignment-space
(propertize string
'face 'ement-room-timestamp-header))))
((or 'ement-room-read-receipt-marker 'ement-room-fully-read-marker)
(insert (propertize " "
'display '(space :width text :height (1))
'face thing)))
((pred ement-room-membership-events-p)
(let ((formatted-events (ement-room--format-membership-events thing ement-room)))
(add-face-text-property 0 (length formatted-events)
'ement-room-membership 'append formatted-events)
(insert (ement-room-wrap-prefix formatted-events))))))
(defun ement-room--format-event (event room session)
"Return EVENT in ROOM on SESSION formatted.
Formats according to `ement-room-message-format-spec', which see."
(concat (pcase (ement-event-type event)
("m.room.message" (ement-room--format-message event room session))
("m.room.member"
(widget-create 'ement-room-membership
:button-face 'ement-room-membership
:value event)
"")
("m.reaction"
"")
("m.room.avatar"
(ement-room-wrap-prefix
(format "%s changed the room's avatar."
(propertize (ement--user-displayname-in room (ement-event-sender event))
'help-echo (ement-user-id (ement-event-sender event))))
'face 'ement-room-membership))
("m.room.power_levels"
(ement-room--format-power-levels-event event room session))
("m.room.canonical_alias"
(ement-room--format-canonical-alias-event event room session))
(_ (ement-room-wrap-prefix
(format "[sender:%s type:%s]"
(ement-user-id (ement-event-sender event))
(ement-event-type event))
'help-echo (format "%S" (ement-event-content event)))))
(propertize " "
'display ement-room-event-separator-display-property)))
(defun ement-room--format-reactions (event)
"Return formatted reactions to EVENT."
(if-let ((reactions (map-elt (ement-event-local event) 'reactions)))
(cl-labels ((format-reaction (ks)
(pcase-let* ((`(,key . ,senders) ks)
(key (propertize key 'face 'ement-room-reactions-key))
(count (propertize (format " (%s)" (length senders))
'face 'ement-room-reactions))
(string
(propertize (concat key count)
'button '(t)
'category 'default-button
'action #'ement-room-reaction-button-action
'follow-link t
'help-echo (lambda (_window buffer _pos)
(concat
(get-char-code-property (string-to-char key) 'name) ": "
(senders-names senders (buffer-local-value 'ement-room buffer))))))
(local-user-p (cl-member (ement-user-id (ement-session-user ement-session)) senders
:key #'ement-user-id :test #'equal)))
(when local-user-p
(add-face-text-property 0 (length string) '(:box (:style pressed-button) :inverse-video t)
nil string))
(ement--remove-face-property string 'button)
string))
(senders-names (senders room)
(cl-loop for sender in senders
collect (ement--user-displayname-in room sender)
into names
finally return (string-join names ", "))))
(cl-loop with keys-senders
for reaction in reactions
for key = (map-nested-elt (ement-event-content reaction) '(m.relates_to key))
for sender = (ement-event-sender reaction)
do (push sender (alist-get key keys-senders nil nil #'string=))
finally do (setf keys-senders (cl-sort keys-senders #'> :key (lambda (pair) (length (cdr pair)))))
finally return (concat "\n " (mapconcat #'format-reaction keys-senders " "))))
""))
(cl-defun ement-room--format-message (event room session &optional (format ement-room-message-format-spec))
"Return EVENT in ROOM on SESSION formatted according to FORMAT.
Format defaults to `ement-room-message-format-spec', which see."
(let ((ement-room--format-message-margin-p)
(left-margin-width ement-room-left-margin-width)
(right-margin-width ement-room-right-margin-width))
(with-temp-buffer
(setf ement-session session
ement-room room)
(setq-local ement-room-left-margin-width left-margin-width)
(setq-local ement-room-right-margin-width right-margin-width)
(insert format)
(goto-char (point-min))
(while (search-forward "%" nil t)
(cond
((eq (char-after) ?%)
(delete-char 1))
((looking-at "\\([-0-9.]*\\)\\([a-zA-Z]\\)")
(let* ((num (match-string 1))
(spec (string-to-char (match-string 2)))
(_
(delete-region (1- (match-beginning 0)) (match-end 0)))
(formatter (or (alist-get spec ement-room-event-formatters)
(error "Invalid format character: `%%%c'" spec)))
(val (or (funcall formatter event room session)
(let ((print-level 1))
(propertize (format "[Event has no value for spec \"?%s\"]" (char-to-string spec))
'face 'font-lock-comment-face
'help-echo (format "%S" event)))))
(text (format (concat "%" num "s") val)))
(insert text)))
(t
(error "ement-room--format-message: Invalid format string: %S" format))))
(when ement-room--format-message-wrap-prefix
(when-let ((wrap-prefix-end (next-single-property-change (point-min) 'wrap-prefix-end)))
(let* ((prefix-width (string-width
(buffer-substring-no-properties (point-min) wrap-prefix-end)))
(prefix (propertize " " 'display `((space :width ,prefix-width)))))
(goto-char wrap-prefix-end)
(delete-char 1)
(put-text-property (point-min) (point-max) 'wrap-prefix prefix)
(put-text-property (point) (point-max) 'line-prefix prefix))))
(when ement-room--format-message-margin-p
(when-let ((left-margin-end (next-single-property-change (point-min) 'left-margin-end)))
(goto-char left-margin-end)
(delete-char 1)
(let ((left-margin-text-width (string-width (buffer-substring-no-properties (point-min) (point)))))
(put-text-property (point-min) (point)
'display `((margin left-margin)
,(buffer-substring (point-min) (point))))
(save-excursion
(goto-char (point-min))
(insert (propertize " " 'display `((margin left-margin)
(space :width (- left-margin ,left-margin-text-width))))))))
(when-let ((right-margin-start (next-single-property-change (point-min) 'right-margin-start)))
(goto-char right-margin-start)
(delete-char 1)
(let ((string (buffer-substring (point) (point-max))))
(delete-region (point) (point-max))
(goto-char (point-min))
(insert-and-inherit
(propertize " "
'display `((margin right-margin) ,string))))))
(buffer-string))))
(cl-defun ement-room--format-message-body (event &key (formatted-p t))
"Return formatted body of \"m.room.message\" EVENT.
If FORMATTED-P, return the formatted body content, when available."
(pcase-let* (((cl-struct ement-event content
(unsigned (map ('redacted_by unsigned-redacted-by)))
(local (map ('redacted-by local-redacted-by))))
event)
((map ('body main-body) msgtype ('format content-format) ('formatted_body formatted-body)
('m.relates_to (map ('rel_type rel-type)))
('m.new_content (map ('body new-body) ('formatted_body new-formatted-body)
('format new-content-format))))
content)
(body (or new-body main-body))
(formatted-body (or new-formatted-body formatted-body))
(body (if (or (not formatted-p) (not formatted-body))
(copy-sequence body)
(pcase (or new-content-format content-format)
("org.matrix.custom.html"
(save-match-data
(ement-room--render-html formatted-body)))
(_ (format "[unknown body format: %s] %s"
(or new-content-format content-format) body)))))
(appendix (pcase msgtype
((or "m.text" "m.emote" "m.notice") nil)
("m.image" (ement-room--format-m.image event))
("m.file" (ement-room--format-m.file event))
("m.video" (ement-room--format-m.video event))
(_ (if (or local-redacted-by unsigned-redacted-by)
nil
(format "[unsupported msgtype: %s]" msgtype ))))))
(when body
(setf body (ement-room--linkify-urls body)))
(unless body
(setf body (copy-sequence
(if (or local-redacted-by unsigned-redacted-by)
"[redacted]"
"[message has no body content]"))))
(when appendix
(setf body (concat body " " appendix)))
(when (equal "m.replace" rel-type)
(setf body (concat body " " (propertize "[edited]" 'face 'font-lock-comment-face))))
body))
(defun ement-room--render-html (string)
"Return rendered version of HTML STRING.
HTML is rendered to Emacs text using `shr-insert-document'."
(with-temp-buffer
(insert string)
(save-excursion
(let ((shr-use-fonts ement-room-shr-use-fonts)
(old-fn (symbol-function 'shr-tag-blockquote))) (cl-letf (((symbol-function 'shr-fill-line) #'ignore)
((symbol-function 'shr-tag-blockquote)
(lambda (dom)
(let ((beg (point-marker)))
(funcall old-fn dom)
(add-text-properties beg (point-max)
'( wrap-prefix " "
line-prefix " "))
(add-face-text-property beg (point-max) 'ement-room-quote 'append)))))
(shr-insert-document
(libxml-parse-html-region (point-min) (point-max))))))
(string-trim (buffer-substring (point) (point-max)))))
(cl-defun ement-room--event-mentions-user-p (event user &optional (room ement-room))
"Return non-nil if EVENT in ROOM mentions USER."
(pcase-let* (((cl-struct ement-event content) event)
((map body formatted_body) content)
(body (or formatted_body body)))
(when body
(cl-macrolet ((matches-body-p
(form) `(when-let ((string ,form))
(string-match-p (regexp-quote string) body))))
(or (matches-body-p (ement-user-username user))
(matches-body-p (ement--user-displayname-in room user))
(matches-body-p (ement-user-id user)))))))
(defun ement-room--linkify-urls (string)
"Return STRING with URLs in it made clickable."
(with-temp-buffer
(insert string)
(goto-char (point-min))
(cl-loop while (re-search-forward (rx bow "http" (optional "s") "://" (1+ (not space)))
nil 'noerror)
do (make-text-button (match-beginning 0) (match-end 0)
'mouse-face 'highlight
'face 'link
'help-echo (match-string 0)
'action #'browse-url-at-mouse
'follow-link t))
(buffer-string)))
(defun ement-room--user-color (user)
"Return a color in which to display USER's messages."
(cl-labels ((relative-luminance (rgb)
(cl-loop for k in '(0.2126 0.7152 0.0722)
for x in rgb
sum (* k (if (<= x 0.03928)
(/ x 12.92)
(expt (/ (+ x 0.055) 1.055) 2.4)))))
(contrast-ratio (a b)
(let ((ct (/ (+ (relative-luminance a) 0.05)
(+ (relative-luminance b) 0.05))))
(max ct (/ ct))))
(increase-contrast (color against target toward)
(let ((gradient (cdr (color-gradient color toward 20)))
new-color)
(cl-loop do (setf new-color (pop gradient))
while new-color
until (>= (contrast-ratio new-color against) target)
finally return (or new-color color)))))
(let* ((id (ement-user-id user))
(id-hash (float (+ (abs (sxhash id)) ement-room-prism-color-adjustment)))
(ratio (/ id-hash (float most-positive-fixnum)))
(color-num (round (* (* 255 255 255) ratio)))
(color-rgb (list (/ (float (logand color-num 255)) 255)
(/ (float (ash (logand color-num 65280) -8)) 255)
(/ (float (ash (logand color-num 16711680) -16)) 255)))
(background-rgb (color-name-to-rgb (face-background 'default))))
(when (< (contrast-ratio color-rgb background-rgb) ement-room-prism-minimum-contrast)
(setf color-rgb (increase-contrast color-rgb background-rgb ement-room-prism-minimum-contrast
(color-name-to-rgb (face-foreground 'default)))))
(apply #'color-rgb-to-hex (append color-rgb (list 2))))))
(defvar-local ement-room-compose-buffer nil
"Non-nil in buffers that are composing a message to a room.")
(cl-defun ement-room-compose-message (room session &key body)
"Compose a message to ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. With BODY, use it as the initial
message contents."
(interactive
(ement-with-room-and-session
(list ement-room ement-session)))
(let* ((compose-buffer (generate-new-buffer (format "*Ement compose: %s*" (ement--room-display-name ement-room))))
(send-message-filter ement-room-send-message-filter))
(with-current-buffer compose-buffer
(ement-room-init-compose-buffer room session)
(setf ement-room-send-message-filter send-message-filter)
(when body
(insert body))
(run-hooks 'ement-room-compose-hook))
(pop-to-buffer compose-buffer)))
(defun ement-room-compose-from-minibuffer ()
"Edit the current message in a compose buffer.
To be called from a minibuffer opened from
`ement-room-read-string'."
(interactive)
(cl-assert (minibufferp)) (cl-assert ement-room) (cl-assert ement-session)
(let* ((body (minibuffer-contents))
(compose-fn-symbol (gensym (format "ement-compose-%s" (or (ement-room-canonical-alias ement-room)
(ement-room-id ement-room)))))
(input-method current-input-method) (send-message-filter ement-room-send-message-filter)
(replying-to-event ement-room-replying-to-event)
(compose-fn (lambda ()
(remove-hook 'minibuffer-exit-hook compose-fn-symbol)
(ement-room-compose-message ement-room ement-session :body body)
(setf ement-room-send-message-filter send-message-filter)
(setq-local ement-room-replying-to-event replying-to-event)
(when replying-to-event
(setq-local header-line-format
(concat header-line-format
(format " (Replying to message from %s)"
(ement--user-displayname-in
ement-room (ement-event-sender replying-to-event))))))
(let* ((compose-buffer (current-buffer))
(show-buffer-fn-symbol (gensym "ement-show-compose-buffer"))
(show-buffer-fn (lambda ()
(remove-hook 'window-configuration-change-hook show-buffer-fn-symbol)
(pop-to-buffer compose-buffer)
(set-input-method input-method))))
(fset show-buffer-fn-symbol show-buffer-fn)
(add-hook 'window-configuration-change-hook show-buffer-fn-symbol)))))
(fset compose-fn-symbol compose-fn)
(add-hook 'minibuffer-exit-hook compose-fn-symbol)
(deactivate-input-method)
(abort-recursive-edit)))
(defun ement-room-compose-send ()
"Prompt to send the current compose buffer's contents.
To be called from an `ement-room-compose' buffer."
(interactive)
(cl-assert ement-room-compose-buffer)
(cl-assert ement-room) (cl-assert ement-session)
(kill-new (string-trim (buffer-string)))
(let ((room ement-room)
(session ement-session)
(input-method current-input-method)
(send-message-filter ement-room-send-message-filter)
(replying-to-event ement-room-replying-to-event))
(quit-restore-window nil 'kill)
(ement-view-room room session)
(let* ((prompt (format "Send message (%s): " (ement-room-display-name ement-room)))
(current-input-method input-method) (ement-room-send-message-filter send-message-filter)
(pos (when replying-to-event
(ewoc-location (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(eq data replying-to-event))))))
(body (if replying-to-event
(ement-room-with-highlighted-event-at pos
(ement-room-read-string prompt (car kill-ring) 'ement-room-message-history
nil 'inherit-input-method))
(ement-room-read-string prompt (car kill-ring) 'ement-room-message-history
nil 'inherit-input-method)) ))
(ement-room-send-message ement-room ement-session :body body :replying-to-event replying-to-event))))
(defun ement-room-init-compose-buffer (room session)
"Eval BODY, setting up the current buffer as a compose buffer.
Sets ROOM and SESSION buffer-locally, binds `save-buffer' in
a copy of the local keymap, and sets `header-line-format'."
(setq-local ement-room room)
(setq-local ement-session session)
(setf ement-room-compose-buffer t)
(setq-local completion-at-point-functions
(append '(ement-room--complete-members-at-point ement-room--complete-rooms-at-point)
completion-at-point-functions))
(use-local-map (if (current-local-map)
(copy-keymap (current-local-map))
(make-sparse-keymap)))
(local-set-key [remap save-buffer] #'ement-room-compose-send)
(setq header-line-format (substitute-command-keys
(format " Press \\[save-buffer] to send message to room (%s)"
(ement-room-display-name room)))))
(require 'widget)
(define-widget 'ement-room-membership 'item
"Widget for membership events."
:format (let ((zws (propertize " " 'display "")))
(concat "%{" zws "%v" zws "%}"))
:sample-face 'ement-room-membership
:value-create (lambda (widget)
(pcase-let* ((event (widget-value widget)))
(insert (ement-room-wrap-prefix
(ement-room--format-member-event event ement-room))))))
(defun ement-room--format-member-event (event room)
"Return formatted string for \"m.room.member\" EVENT in ROOM."
(pcase-let* (((cl-struct ement-event sender state-key
(content (map reason ('avatar_url new-avatar-url)
('membership new-membership) ('displayname new-displayname)))
(unsigned (map ('prev_content (map ('avatar_url old-avatar-url)
('membership prev-membership)
('displayname prev-displayname))))))
event)
(sender-name (ement--user-displayname-in ement-room sender)))
(cl-macrolet ((nes (var)
`(when (and ,var (not (string-empty-p ,var)))
,var))
(sender-name-id-string ()
`(propertize sender-name
'help-echo (ement-user-id sender)))
(new-displayname-sender-name-state-key-string ()
`(propertize (or (nes new-displayname) (nes sender-name) (nes state-key))
'help-echo state-key))
(sender-name-state-key-string ()
`(propertize sender-name
'help-echo state-key))
(prev-displayname-id-string ()
`(propertize (or prev-displayname sender-name)
'help-echo (ement-user-id sender))))
(pcase-exhaustive new-membership
("invite"
(pcase prev-membership
((or "leave" '())
(format "%s invited %s"
(sender-name-id-string)
(new-displayname-sender-name-state-key-string)))
(_ (format "%s sent unrecognized invite event for %s"
(sender-name-id-string)
(new-displayname-sender-name-state-key-string)))))
("join"
(pcase prev-membership
("invite"
(format "%s accepted invitation to join"
(sender-name-state-key-string)))
("join"
(cond ((not (equal new-displayname prev-displayname))
(propertize (format "%s changed name to %s"
prev-displayname (or new-displayname (ement--user-displayname-in room sender)))
'help-echo state-key))
((not (equal new-avatar-url old-avatar-url))
(format "%s changed avatar"
(new-displayname-sender-name-state-key-string)))
(t (format "Unrecognized membership event for %s"
(sender-name-state-key-string)))))
("leave"
(format "%s rejoined"
(sender-name-state-key-string)))
(`nil
(format "%s joined"
(new-displayname-sender-name-state-key-string)))
(_ (format "%s sent unrecognized join event for %s"
(sender-name-id-string)
(new-displayname-sender-name-state-key-string)))))
("leave"
(pcase prev-membership
("invite"
(pcase state-key
((pred (equal (ement-user-id sender)))
(format "%s rejected invitation"
(sender-name-id-string)))
(_ (format "%s revoked %s's invitation"
(sender-name-id-string)
(new-displayname-sender-name-state-key-string)))))
("join"
(pcase state-key
((pred (equal (ement-user-id sender)))
(format "%s left%s"
(prev-displayname-id-string)
(if reason
(format " (%s)" reason)
"")))
(_ (format "%s kicked %s%s"
(sender-name-id-string)
(propertize (or prev-displayname state-key)
'help-echo state-key)
(if reason
(format " (%s)" reason)
"")))))
("ban"
(format "%s unbanned %s"
(sender-name-id-string)
state-key))
(_ (format "%s left%s"
(prev-displayname-id-string)
(if reason
(format " (%s)" reason)
"")))))
("ban"
(pcase prev-membership
((or "invite" "leave")
(format "%s banned %s%s"
(sender-name-id-string)
(propertize (or prev-displayname state-key)
'help-echo state-key)
(if reason
(format " (%s)" reason)
"")))
("join"
(format "%s kicked and banned %s%s"
(sender-name-id-string)
(propertize (or prev-displayname state-key)
'help-echo state-key)
(if reason
(format " (%s)" reason)
"")))
(_ (format "%s sent unrecognized ban event for %s"
(sender-name-id-string)
(propertize (or prev-displayname state-key)
'help-echo state-key)))))))))
(defun ement-room--format-membership-events (struct room)
"Return string for STRUCT in ROOM.
STRUCT should be an `ement-room-membership-events' struct."
(cl-labels ((event-user (event)
(propertize (if-let (user (gethash (ement-event-state-key event) ement-users))
(ement--user-displayname-in room user)
(ement-event-state-key event))
'help-echo (concat (ement-room--format-member-event event room)
" <" (ement-event-state-key event) ">")))
(old-membership (event)
(map-nested-elt (ement-event-unsigned event) '(prev_content membership)))
(new-membership (event)
(alist-get 'membership (ement-event-content event))))
(pcase-let* (((cl-struct ement-room-membership-events events) struct))
(pcase (length events)
(0 (warn "No events in `ement-room-membership-events' struct"))
(1 (ement-room--format-member-event (car events) room))
(_ (let* ((left-events (cl-remove-if-not (lambda (event)
(and (equal "leave" (new-membership event))
(not (member (old-membership event) '("ban" "invite")))))
events))
(join-events (cl-remove-if-not (lambda (event)
(and (equal "join" (new-membership event))
(not (equal "join" (old-membership event)))))
events))
(rejoin-events (cl-remove-if-not (lambda (event)
(and (equal "join" (new-membership event))
(equal "leave" (old-membership event))))
events))
(invite-events (cl-remove-if-not (lambda (event)
(equal "invite" (new-membership event)))
events))
(reject-events (cl-remove-if-not (lambda (event)
(and (equal "invite" (old-membership event))
(equal "leave" (new-membership event))))
events))
(ban-events (cl-remove-if-not (lambda (event)
(and (member (old-membership event) '("invite" "leave"))
(equal "ban" (new-membership event))))
events))
(unban-events (cl-remove-if-not (lambda (event)
(and (equal "ban" (old-membership event))
(equal "leave" (new-membership event))))
events))
(kick-and-ban-events (cl-remove-if-not (lambda (event)
(and (equal "join" (old-membership event))
(equal "ban" (new-membership event))))
events))
(rename-events (cl-remove-if-not (lambda (event)
(and (equal "join" (old-membership event))
(equal "join" (new-membership event))
(equal (alist-get 'avatar_url (ement-event-content event))
(map-nested-elt (ement-event-unsigned event)
'(prev_content avatar_url)))))
events))
(avatar-events (cl-remove-if-not (lambda (event)
(and (equal "join" (old-membership event))
(equal "join" (new-membership event))
(not (equal (alist-get 'avatar_url (ement-event-content event))
(map-nested-elt (ement-event-unsigned event)
'(prev_content avatar_url))))))
events))
join-and-leave-events rejoin-and-leave-events)
(setf join-events (cl-delete-if (lambda (event)
(cl-find (ement-event-state-key event) rejoin-events
:test #'equal :key #'ement-event-state-key))
join-events)
rejoin-events (cl-delete-if (lambda (event)
(cl-find (ement-event-state-key event) join-events
:test #'equal :key #'ement-event-state-key))
rejoin-events)
join-and-leave-events (cl-loop for join-event in join-events
for left-event = (cl-find (ement-event-state-key join-event) left-events
:test #'equal :key #'ement-event-state-key)
when left-event
collect left-event
and do (setf join-events (cl-delete (ement-event-state-key join-event) join-events
:test #'equal :key #'ement-event-state-key)
left-events (cl-delete (ement-event-state-key left-event) left-events
:test #'equal :key #'ement-event-state-key)))
rejoin-and-leave-events (cl-loop for rejoin-event in rejoin-events
for left-event = (cl-find (ement-event-state-key rejoin-event) left-events
:test #'equal :key #'ement-event-state-key)
when left-event
collect left-event
and do (setf rejoin-events (cl-delete
(ement-event-state-key rejoin-event) rejoin-events
:test #'equal :key #'ement-event-state-key)
left-events (cl-delete (ement-event-state-key left-event) left-events
:test #'equal :key #'ement-event-state-key))))
(format "Membership: %s."
(string-join (cl-loop for (type . events)
in (ement-alist "rejoined" rejoin-events
"joined" join-events
"left" left-events
"joined and left" join-and-leave-events
"rejoined and left" rejoin-and-leave-events
"invited" invite-events
"rejected invitation" reject-events
"banned" ban-events
"unbanned" unban-events
"kicked and banned" kick-and-ban-events
"changed name" rename-events
"changed avatar" avatar-events)
for users = (mapcar #'event-user
(cl-delete-duplicates
events :key #'ement-event-state-key))
for number = (length users)
when events
collect (format "%s %s (%s)" number
(propertize type 'face 'bold)
(string-join users ", ")))
"; "))))))))
(require 'image)
(defvar ement-room-image-keymap
(let ((map (make-sparse-keymap)))
(set-keymap-parent map image-map)
(define-key map (kbd "M-RET") #'ement-room-image-scale)
(define-key map (kbd "RET") #'ement-room-image-show)
(define-key map [mouse-1] #'ement-room-image-scale-mouse)
(define-key map [double-mouse-1] #'ement-room-image-show-mouse)
map)
"Keymap for images in room buffers.")
(defgroup ement-room-images nil
"Showing images in rooms."
:group 'ement-room)
(defcustom ement-room-images t
"Download and show images in messages, avatars, etc."
:type 'boolean
:set (lambda (option value)
(if (or (fboundp 'imagemagick-types)
(when (fboundp 'image-transforms-p)
(image-transforms-p)))
(set-default option value)
(set-default option nil)
(when (and value (display-images-p))
(display-warning 'ement "This Emacs was not built with ImageMagick support, nor does it support Cairo/XRender scaling, so images can't be displayed in Ement")))))
(defcustom ement-room-image-initial-height 0.2
"Limit images' initial display height.
If a number, it should be no larger than 1 (because Emacs can't
display images larger than the window body height)."
:type '(choice (const :tag "Use full window width" nil)
(number :tag "Limit to this multiple of the window body height")))
(defun ement-room-image-scale-mouse (event)
"Toggle scale of image at mouse EVENT.
Scale image to fit within the window's body. If image is already
fit to the window, reduce its max-height to 10% of the window's
height."
(interactive "e")
(let* ((pos (event-start event))
(window (posn-window pos)))
(with-selected-window window
(ement-room-image-scale (posn-point pos)))))
(defun ement-room-image-scale (pos)
"Toggle scale of image at POS.
Scale image to fit within the window's body. If image is already
fit to the window, reduce its max-height to 10% of the window's
height."
(interactive "d")
(pcase-let* ((image (get-text-property pos 'display))
(window-width (window-body-width nil t))
(window-height (window-body-height nil t))
(new-height (if (= window-height (or (image-property image :max-height) -1))
(/ window-height 10)
window-height)))
(when (fboundp 'imagemagick-types)
(setf (image-property image :type) 'imagemagick))
(setf (image-property image :scale) nil
(image-property image :max-width) window-width
(image-property image :max-height) new-height)))
(defun ement-room-image-show-mouse (event)
"Show image at mouse EVENT in a new buffer."
(interactive "e")
(let* ((pos (event-start event))
(window (posn-window pos)))
(with-selected-window window
(ement-room-image-show (posn-point pos)))))
(defun ement-room-image-show (pos)
"Show image at POS in a new buffer."
(interactive "d")
(pcase-let* ((image (copy-sequence (get-text-property pos 'display)))
(ement-event (ewoc-data (ewoc-locate ement-ewoc pos)))
((cl-struct ement-event id) ement-event)
(buffer-name (format "*Ement image: %s*" id))
(new-buffer (get-buffer-create buffer-name)))
(when (fboundp 'imagemagick-types)
(setf (image-property image :type) 'imagemagick))
(setf (image-property image :scale) 1.0
(image-property image :max-width) nil
(image-property image :max-height) nil)
(with-current-buffer new-buffer
(erase-buffer)
(insert-image image)
(image-mode))
(pop-to-buffer new-buffer '((display-buffer-pop-up-frame)))
(set-frame-parameter nil 'fullscreen 'maximized)))
(defun ement-room--format-m.image (event)
"Return \"m.image\" EVENT formatted as a string.
When `ement-room-images' is non-nil, also download it and then
show it in the buffer."
(pcase-let* (((cl-struct ement-event content (local event-local)) event)
((cl-struct ement-room local) ement-room)
((map buffer) local)
((map ('url mxc) info ) content)
((map thumbnail_info) info)
((map ('h _thumbnail-height) ('w _thumbnail-width)) thumbnail_info)
((map image) event-local)
(url (when mxc
(ement--mxc-to-url mxc ement-session)))
)
(if (and ement-room-images image)
(condition-case err
(let ((image (create-image image nil 'data-p :ascent 'center))
(buffer-window (when buffer
(get-buffer-window buffer)))
max-height max-width)
(cond (ement-room-image-initial-height
(setf max-height (truncate
(* (window-body-height buffer-window t)
ement-room-image-initial-height))
max-width (window-body-width buffer-window t)))
(buffer-window
(setf max-height (window-body-height buffer-window t)
max-width (window-body-width buffer-window t)))
(t
(setf max-height (frame-pixel-height)
max-width (frame-pixel-width))))
(when (fboundp 'imagemagick-types)
(setf (image-property image :type) 'imagemagick))
(setf (image-property image :max-width) max-width
(image-property image :max-height) max-height
(image-property image :relief) 2
(image-property image :margin) 5
(image-property image :pointer) 'hand)
(concat "\n"
(ement-room-wrap-prefix " "
'display image
'keymap ement-room-image-keymap)))
(error (format "\n [error inserting image: %s]" (error-message-string err))))
(prog1
(ement-room-wrap-prefix "[image]"
'action #'browse-url
'button t
'button-data url
'category t
'face 'button
'follow-link t
'help-echo url
'keymap button-map
'mouse-face 'highlight)
(when (and ement-room-images url)
(plz-run
(plz-queue ement-images-queue
'get url :as 'binary
:then (apply-partially #'ement-room--m.image-callback event ement-room)
:noquery t)))))))
(defun ement-room--m.image-callback (event room data)
"Add downloaded image from DATA to EVENT in ROOM.
Then invalidate EVENT's node to show the image."
(pcase-let* (((cl-struct ement-room (local (map buffer))) room))
(setf (map-elt (ement-event-local event) 'image) data)
(when (buffer-live-p buffer)
(with-current-buffer buffer
(if-let (node (ement-room--ewoc-last-matching ement-ewoc
(lambda (node-data)
(eq node-data event))))
(ewoc-invalidate ement-ewoc node)
(display-warning 'ement-room--m.image-callback
(format "Event %S not found in room %S (a very rare, as-yet unexplained bug, which can be safely ignored; you may disconnect and reconnect if you wish, but it isn't strictly necessary)"
(ement-event-id event)
(ement-room-display-name room))))))))
(defun ement-room--format-m.file (event)
"Return \"m.file\" EVENT formatted as a string."
(pcase-let* (((cl-struct ement-event
(content (map filename
('info (map mimetype size))
('url mxc-url))))
event)
(url (when mxc-url
(ement--mxc-to-url mxc-url ement-session)))
(human-size (when size
(file-size-human-readable size)))
(string (format "[file: %s (%s) (%s)]" filename mimetype human-size)))
(concat (propertize string
'action #'browse-url
'button t
'button-data url
'category t
'face 'button
'follow-link t
'help-echo url
'keymap button-map
'mouse-face 'highlight)
(propertize " "
'display '(space :relative-height 1.5)))))
(defun ement-room--format-m.video (event)
"Return \"m.video\" EVENT formatted as a string."
(pcase-let* (((cl-struct ement-event
(content (map body
('info (map mimetype size w h))
('url mxc-url))))
event)
(url (when mxc-url
(ement--mxc-to-url mxc-url ement-session)))
(human-size (file-size-human-readable size))
(string (format "[video: %s (%s) (%sx%s) (%s)]" body mimetype w h human-size)))
(concat (propertize string
'action #'browse-url
'button t
'button-data url
'category t
'face 'button
'follow-link t
'help-echo url
'keymap button-map
'mouse-face 'highlight)
(propertize " "
'display '(space :relative-height 1.5)))))
(defvar org-export-with-toc)
(defvar org-export-with-broken-links)
(defvar org-export-with-section-numbers)
(defvar org-export-with-sub-superscripts)
(defvar org-html-inline-images)
(declare-function org-element-property "org-element")
(declare-function org-export-data "ox")
(declare-function org-export-get-caption "ox")
(declare-function org-export-get-ordinal "ox")
(declare-function org-export-get-reference "ox")
(declare-function org-export-read-attribute "ox")
(declare-function org-html--has-caption-p "ox-html")
(declare-function org-html--textarea-block "ox-html")
(declare-function org-html--translate "ox-html")
(declare-function org-html-export-as-html "ox-html")
(declare-function org-html-format-code "ox-html")
(defun ement-room-compose-org ()
"Activate `org-mode' in current compose buffer.
Configures the buffer appropriately so that saving it will export
the Org buffer's contents."
(interactive)
(unless ement-room-compose-buffer
(user-error "This command should be run in a compose buffer. Use `ement-room-compose-message' first"))
(let ((room ement-room)
(session ement-session))
(org-mode)
(ement-room-init-compose-buffer room session))
(setq-local ement-room-send-message-filter #'ement-room-send-org-filter))
(defun ement-room-send-org-filter (content room)
"Return event CONTENT for ROOM having processed its Org content.
The CONTENT's body is exported with
`org-html-export-as-html' (with some adjustments for
compatibility), and the result is added to the CONTENT as
\"formatted_body\"."
(require 'ox-html)
(pcase-let* ((body (alist-get "body" content nil nil #'equal))
(formatted-body
(save-window-excursion
(with-temp-buffer
(insert (ement--format-body-mentions body room
:template "[[https://matrix.to/#/%s][%s]]"))
(cl-letf (((symbol-function 'org-html-src-block)
(symbol-function 'ement-room--org-html-src-block)))
(let ((org-export-with-toc nil)
(org-export-with-broken-links t)
(org-export-with-section-numbers nil)
(org-export-with-sub-superscripts nil)
(org-html-inline-images nil))
(org-html-export-as-html nil nil nil 'body-only)))
(with-current-buffer "*Org HTML Export*"
(prog1 (string-trim (buffer-string))
(kill-buffer)))))))
(setf (alist-get "formatted_body" content nil nil #'equal) formatted-body
(alist-get "format" content nil nil #'equal) "org.matrix.custom.html")
content))
(defun ement-room--org-html-src-block (src-block _contents info)
"Transcode a SRC-BLOCK element from Org to HTML.
CONTENTS holds the contents of the item. INFO is a plist holding
contextual information.
This is a copy of `org-html-src-block' that uses Riot
Web-compatible HTML output, using HTML like:
<pre><code class=\"language-python\">..."
(if (org-export-read-attribute :attr_html src-block :textarea)
(org-html--textarea-block src-block)
(let ((lang (pcase (org-element-property :language src-block)
("elisp" "lisp")
(else else)))
(code (org-html-format-code src-block info))
(label (let ((lbl (and (org-element-property :name src-block)
(org-export-get-reference src-block info))))
(if lbl (format " id=\"%s\"" lbl) ""))))
(if (not lang) (format "<pre class=\"example\"%s>\n%s</pre>" label code)
(format "<div class=\"org-src-container\">\n%s%s\n</div>"
(let ((caption (org-export-get-caption src-block)))
(if (not caption) ""
(let ((listing-number
(format
"<span class=\"listing-number\">%s </span>"
(format
(org-html--translate "Listing %d:" info)
(org-export-get-ordinal
src-block info nil #'org-html--has-caption-p)))))
(format "<label class=\"org-src-name\">%s%s</label>"
listing-number
(string-trim (org-export-data caption info))))))
(format "<pre><code class=\"src language-%s\"%s>%s</code></pre>"
lang label code))))))
(defun ement-room--complete-members-at-point ()
"Complete member names and IDs at point.
Uses members in the current buffer's room. For use in
`completion-at-point-functions'."
(let ((beg (save-excursion
(when (re-search-backward (rx (or bol bos blank)) nil t)
(if (minibufferp)
(1+ (point))
(point)))))
(end (point))
(collection-fn (completion-table-dynamic
(lambda (_ignore)
(ement-room--member-names-and-ids)))))
(when beg
(list beg end collection-fn :exclusive 'no))))
(defun ement-room--complete-rooms-at-point ()
"Complete room aliases and IDs at point.
For use in `completion-at-point-functions'."
(let ((beg (save-excursion
(when (re-search-backward (rx (or bol bos blank) (or "!" "#")) nil t)
(if (minibufferp)
(1+ (point))
(point)))))
(end (point))
(collection-fn (completion-table-dynamic
(lambda (_ignore)
(ement-room--room-aliases-and-ids)))))
(when beg
(list beg end collection-fn :exclusive 'no))))
(defun ement-room--member-names-and-ids ()
"Return a list of member names and IDs seen in current room.
If room's `members' table is filled, use it; otherwise, fetch
members list and return already-seen members instead. For use in
`completion-at-point-functions'."
(pcase-let* ((room (if (minibufferp)
(buffer-local-value
'ement-room (window-buffer (minibuffer-selected-window)))
ement-room))
(session (if (minibufferp)
(buffer-local-value
'ement-session (window-buffer (minibuffer-selected-window)))
ement-session))
((cl-struct ement-room members) room)
(members (if (alist-get 'fetched-members-p (ement-room-local room))
(hash-table-values members)
(ement-singly (alist-get 'getting-members-p (ement-room-local room))
(ement--get-joined-members room session
:then (lambda (_) (setf (alist-get 'getting-members-p (ement-room-local room)) nil))
:else (lambda (_) (setf (alist-get 'getting-members-p (ement-room-local room)) nil))))
(mapcar #'ement-event-sender
(ement-room-timeline ement-room)))))
(delete-dups
(cl-loop for member in members
collect (ement-user-id member)
collect (ement--user-displayname-in room member)))))
(defun ement-room--room-aliases-and-ids ()
"Return a list of room names and aliases seen in current session.
For use in `completion-at-point-functions'."
(let* ((session (if (minibufferp)
(buffer-local-value
'ement-session (window-buffer (minibuffer-selected-window)))
ement-session)))
(delete-dups
(delq nil (cl-loop for room in (ement-session-rooms session)
collect (ement-room-id room)
collect (ement-room-canonical-alias room))))))
(require 'transient)
(transient-define-prefix ement-room-transient ()
"Transient for Ement Room buffers."
[:pad-keys t
["Movement"
("TAB" "Next event" ement-room-goto-next)
("<backtab>" "Previous event" ement-room-goto-prev)
("SPC" "Scroll up and mark read" ement-room-scroll-up-mark-read)
("S-SPC" "Scroll down" ement-room-scroll-down-command)
("M-SPC" "Jump to fully-read marker" ement-room-goto-fully-read-marker)
("m" "Move read markers to point" ement-room-mark-read)]
["Switching"
("M-g M-l" "List rooms" ement-room-list)
("M-g M-r" "Switch to other room" ement-view-room)
("M-g M-m" "Switch to mentions buffer" ement-notify-switch-to-mentions-buffer)
("M-g M-n" "Switch to notifications buffer" ement-notify-switch-to-notifications-buffer)
("q" "Quit window" quit-window)]]
[:pad-keys t
["Messages"
("c" "Composition format" ement-room-set-composition-format
:description (lambda ()
(concat "Composition format: "
(propertize (car (cl-rassoc ement-room-send-message-filter
(list (cons "Plain-text" nil)
(cons "Org-mode" 'ement-room-send-org-filter))
:test #'equal))
'face 'transient-value))))
("RET" "Write message" ement-room-send-message)
("S-RET" "Write reply" ement-room-write-reply)
("M-RET" "Compose message in buffer" ement-room-compose-message)
("<insert>" "Edit message" ement-room-edit-message)
("C-k" "Delete message" ement-room-delete-message)
("s r" "Send reaction" ement-room-send-reaction)
("s e" "Send emote" ement-room-send-emote)
("s f" "Send file" ement-room-send-file)
("s i" "Send image" ement-room-send-image)]
["Users"
("u RET" "Send direct message" ement-send-direct-message)
("u i" "Invite user" ement-invite-user)
("u I" "Ignore user" ement-ignore-user)]]
[:pad-keys t
["Room"
("M-s o" "Occur search in room" ement-room-occur)
("r d" "Describe room" ement-describe-room)
("r m" "List members" ement-list-members)
("r t" "Set topic" ement-room-set-topic)
("r f" "Set message format" ement-room-set-message-format)
("r N" "Override name" ement-room-override-name
:description (lambda ()
(format "Name override: %s"
(if-let* ((event (alist-get "org.matrix.msc3015.m.room.name.override"
(ement-room-account-data ement-room) nil nil #'equal))
(name (map-nested-elt event '(content name))))
(propertize name 'face 'transient-value)
(propertize "none" 'face 'transient-inactive-value)))))
("r n" "Set notification state" ement-room-set-notification-state
:description (lambda ()
(let ((state (ement-room-notification-state ement-room ement-session)))
(format "Notifications (%s|%s|%s|%s|%s)"
(propertize "default"
'face (pcase state
(`nil 'transient-value)
(_ 'transient-inactive-value)))
(propertize "all-loud"
'face (pcase state
('all-loud 'transient-value)
(_ 'transient-inactive-value)))
(propertize "all"
'face (pcase state
('all 'transient-value)
(_ 'transient-inactive-value)))
(propertize "mentions"
'face (pcase state
('mentions-and-keywords 'transient-value)
(_ 'transient-inactive-value)))
(propertize "none"
'face (pcase state
('none 'transient-value)
(_ 'transient-inactive-value)))))))
("r T" "Tag/untag room" ement-tag-room
:description (lambda ()
(format "Tag/untag room (%s|%s)"
(propertize "Fav"
'face (if (ement--room-tagged-p "m.favourite" ement-room)
'transient-value 'transient-inactive-value))
(propertize "Low-prio"
'face (if (ement--room-tagged-p "m.lowpriority" ement-room)
'transient-value 'transient-inactive-value)))))]
["Room membership"
("R c" "Create room" ement-create-room)
("R j" "Join room" ement-join-room)
("R l" "Leave room" ement-leave-room)
("R F" "Forget room" ement-forget-room)
("R n" "Set nick" ement-room-set-display-name
:description (lambda ()
(format "Set nick (%s)"
(propertize (ement--user-displayname-in
ement-room (gethash (ement-user-id (ement-session-user ement-session))
ement-users))
'face 'transient-value))))
("R s" "Toggle spaces" ement-room-toggle-space
:description (lambda ()
(format "Toggle spaces (%s)"
(if-let ((spaces (ement--room-spaces ement-room ement-session)))
(string-join
(mapcar (lambda (space)
(propertize (ement-room-display-name space)
'face 'transient-value))
spaces)
", ")
(propertize "none" 'face 'transient-inactive-value)))))]]
["Other"
("v" "View event" ement-room-view-event)
("g" "Sync new messages" ement-room-sync
:if (lambda ()
(interactive)
(or (not ement-auto-sync)
(not (map-elt ement-syncs ement-session)))))])
(provide 'ement-room)