ement.el/ement-room.el

468 lines
20 KiB
EmacsLisp
Raw Normal View History

2020-11-30 10:56:54 -06:00
;;; ement-room.el --- Ement room buffers -*- lexical-binding: t; -*-
;; Copyright (C) 2020 Adam Porter
;; Author: Adam Porter <adam@alphapapa.net>
;; Keywords:
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
2020-12-01 02:03:32 -06:00
;; EWOC is a great library. If I had known about it and learned it
;; sooner, it would have saved me a lot of time in other projects.
;; I'm glad I decided to try it for this one.
2020-11-30 10:56:54 -06:00
;;; Code:
2020-12-01 03:57:44 -06:00
;;;; Debugging
(eval-and-compile
(setq-local warning-minimum-log-level nil)
(setq-local warning-minimum-log-level :debug))
2020-11-30 10:56:54 -06:00
;;;; Requirements
(require 'ewoc)
2020-12-01 01:24:18 -06:00
(require 'shr)
2020-11-30 10:56:54 -06:00
(require 'subr-x)
2020-11-30 18:19:23 -06:00
(require 'ement-api)
2020-11-30 10:56:54 -06:00
(require 'ement-macros)
(require 'ement-structs)
;;;; Variables
2020-12-01 02:03:32 -06:00
(defvar-local ement-ewoc nil
2020-11-30 10:56:54 -06:00
"EWOC for Ement room buffers.")
2020-11-30 15:42:58 -06:00
(defvar-local ement-room nil
"Ement room for current buffer.")
2020-11-30 18:19:23 -06:00
(defvar-local ement-session nil
"Ement session for current buffer.")
2020-12-01 04:06:38 -06:00
(defvar-local ement-room-retro-loading nil
"Non-nil when earlier messages are being loaded.
Used to avoid overlapping requests.")
2020-12-01 03:24:03 -06:00
(declare-function ement-view-room "ement.el")
2020-11-30 16:24:41 -06:00
(defvar ement-room-mode-map
(let ((map (make-sparse-keymap)))
2020-11-30 19:36:04 -06:00
(define-key map (kbd "g") #'ement-room-sync)
2020-12-01 02:09:20 -06:00
(define-key map (kbd "r") #'ement-view-room)
2020-12-01 02:03:32 -06:00
(define-key map (kbd "q") #'quit-window)
2020-11-30 18:19:23 -06:00
(define-key map (kbd "v") #'ement-room-view-event)
(define-key map (kbd "RET") #'ement-room-send-message)
2020-12-01 00:34:00 -06:00
(define-key map (kbd "<backtab>") #'ement-room-goto-prev)
(define-key map (kbd "TAB") #'ement-room-goto-next)
2020-12-01 00:28:13 -06:00
(define-key map [remap scroll-down-command] #'ement-room-scroll-down-command)
2020-12-01 04:06:38 -06:00
(define-key map [remap mwheel-scroll] #'ement-room-mwheel-scroll)
2020-11-30 16:24:41 -06:00
map)
"Keymap for Ement room buffers.")
2020-11-30 10:56:54 -06:00
;;;; Customization
(defgroup ement-room nil
"Options for room buffers."
:group 'ement)
2020-11-30 19:42:00 -06:00
(defcustom ement-room-buffer-prefix "*Ement Room: "
2020-11-30 10:56:54 -06:00
"Prefix for Ement room buffer names."
:type 'string)
(defcustom ement-room-buffer-suffix "*"
"Suffix for Ement room buffer names."
:type 'string)
2020-11-30 20:17:55 -06:00
(defcustom ement-room-timestamp-format " [%H:%M:%S]"
2020-11-30 10:56:54 -06:00
"Format string for event timestamps.
See function `format-time-string'."
2020-11-30 20:17:55 -06:00
:type '(choice (const " [%H:%M:%S]")
(const " [%Y-%m-%d %H:%M:%S]")
2020-11-30 13:28:55 -06:00
string))
2020-11-30 10:56:54 -06:00
2020-12-01 00:28:13 -06:00
(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"))) ))
2020-12-01 02:03:32 -06:00
(defface ement-room-membership
'((t (:inherit font-lock-comment-face)))
"Membership events (join/part).")
2020-11-30 10:56:54 -06:00
(defface ement-room-timestamp
2020-12-01 00:28:13 -06:00
'((t (:inherit font-lock-comment-face)))
2020-11-30 10:56:54 -06:00
"Event timestamps.")
(defface ement-room-user
2020-12-01 00:28:13 -06:00
'((t (:inherit font-lock-function-name-face :weight bold)))
2020-11-30 10:56:54 -06:00
"Usernames.")
2020-12-01 00:42:29 -06:00
(defface ement-room-self
'((t (:inherit font-lock-variable-name-face :weight bold)))
"Own username.")
(defface ement-room-self-message
'((t (:inherit font-lock-variable-name-face)))
"Own messages.")
2020-11-30 10:56:54 -06:00
;;;; Commands
2020-12-01 00:34:00 -06:00
(defun ement-room-goto-prev (num)
"Goto the NUM'th previous message in buffer."
(interactive "p")
2020-12-01 02:03:32 -06:00
(ewoc-goto-prev ement-ewoc num))
2020-12-01 00:34:00 -06:00
(defun ement-room-goto-next (num)
"Goto the NUM'th next message in buffer."
(interactive "p")
2020-12-01 02:03:32 -06:00
(ewoc-goto-next ement-ewoc num))
2020-12-01 00:34:00 -06:00
2020-12-01 00:28:13 -06:00
(defun ement-room-scroll-down-command ()
2020-12-01 04:06:38 -06:00
"Scroll down, and load NUMBER earlier messages when at top."
2020-12-01 00:28:13 -06:00
(interactive)
2020-12-01 16:45:06 -06:00
(condition-case _err
(scroll-down nil)
(beginning-of-buffer
(when (call-interactively #'ement-room-retro)
(message "Loading earlier messages...")))))
2020-12-01 00:28:13 -06:00
2020-12-01 04:06:38 -06:00
(defun ement-room-mwheel-scroll (event)
"Scroll according to EVENT, loading earlier messages when at top."
(interactive "e")
2020-12-01 05:16:39 -06:00
(with-selected-window (posn-window (event-start event))
(condition-case _err
(mwheel-scroll event)
(beginning-of-buffer
(when (call-interactively #'ement-room-retro)
(message "Loading earlier messages..."))))))
(defun ement-room-retro (session room number &optional buffer)
2020-12-01 02:03:32 -06:00
;; FIXME: Naming things is hard.
2020-12-01 00:28:13 -06:00
"Retrieve NUMBER older messages in ROOM on SESSION."
(interactive (list ement-session ement-room
2020-12-01 04:06:38 -06:00
(if current-prefix-arg
(read-number "Number of messages: ")
2020-12-01 05:16:39 -06:00
10)
(current-buffer)))
2020-12-01 04:06:38 -06:00
(unless ement-room-retro-loading
(pcase-let* (((cl-struct ement-session server token) session)
((cl-struct ement-room id prev-batch) room)
2020-12-01 05:16:39 -06:00
(endpoint (format "rooms/%s/messages" (url-hexify-string id))))
2020-12-01 04:06:38 -06:00
(ement-api server token endpoint
(apply-partially #'ement-room-retro-callback room)
2020-12-01 05:16:39 -06:00
:timeout 5
2020-12-01 04:06:38 -06:00
:params (list (list "from" prev-batch)
(list "dir" "b")
(list "limit" (number-to-string number)))
:else (lambda (&rest args)
2020-12-01 05:16:39 -06:00
(when buffer
(with-current-buffer buffer
(setf ement-room-retro-loading nil)))
(signal 'error (format "Ement: loading earlier messages failed (%S)" args))))
2020-12-01 04:06:38 -06:00
(setf ement-room-retro-loading t))))
2020-12-01 00:28:13 -06:00
2020-12-01 03:24:03 -06:00
(declare-function ement--make-event "ement.el")
2020-12-01 00:28:13 -06:00
(defun ement-room-retro-callback (room data)
"Push new DATA to ROOM on SESSION and add events to room buffer."
(pcase-let* (((cl-struct ement-room) room)
((map _start end chunk state) data)
(buffer (cl-loop for buffer in (buffer-list)
when (equal room (buffer-local-value 'ement-room buffer))
return buffer)))
;; FIXME: These are pushed onto the front of the lists. Doesn't
;; really matter, but maybe better to put them at the other end.
(cl-loop for event across state
;; FIXME: Need to use make-event
do (push event (ement-room-state room)))
(cl-loop for event across-ref chunk
do (setf event (ement--make-event event))
(push event (ement-room-timeline room)))
(when buffer
(with-current-buffer buffer
2020-12-01 05:16:39 -06:00
(when-let* ((window (get-buffer-window buffer))
(point-node (with-selected-window window
2020-12-01 16:45:06 -06:00
(ewoc-locate ement-ewoc (window-start)))))
2020-12-01 05:16:39 -06:00
(cl-loop for event across chunk
do (ement-room--insert-event event))
(with-selected-window (get-buffer-window buffer)
2020-12-01 16:45:06 -06:00
(set-window-start nil (ewoc-location point-node))
;; FIXME: Experiment with this.
(forward-line -1)))
2020-12-01 05:16:39 -06:00
(setf (ement-room-prev-batch room) end
ement-room-retro-loading nil)))))
2020-12-01 00:28:13 -06:00
2020-11-30 19:36:04 -06:00
;; FIXME: What is the best way to do this, with ement--sync being in another file?
(declare-function ement--sync "ement.el")
(defun ement-room-sync (session)
"Sync SESSION (interactively, current buffer's)."
(interactive (list ement-session))
(ement--sync session))
2020-11-30 16:24:41 -06:00
(defun ement-room-view-event (event)
"Pop up buffer showing details of EVENT (interactively, the one at point)."
2020-12-01 02:03:32 -06:00
(interactive (list (ewoc-data (ewoc-locate ement-ewoc))))
2020-11-30 16:24:41 -06:00
(require 'pp)
(let* ((buffer-name (format "*Ement event: %s*" (ement-event-id event)))
(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)
:unsigned (ement-event-unsigned event))))
(with-current-buffer (get-buffer-create buffer-name)
(erase-buffer)
(pp event (current-buffer))
2020-11-30 19:42:00 -06:00
(view-mode)
2020-11-30 16:24:41 -06:00
(pop-to-buffer (current-buffer)))))
2020-11-30 18:19:23 -06:00
(defun ement-room-send-message ()
"Send message in current buffer's room."
(interactive)
2020-11-30 19:36:04 -06:00
(cl-assert ement-room) (cl-assert ement-session)
2020-11-30 18:19:23 -06:00
(let ((body (read-string "Send message: ")))
(unless (string-empty-p body)
2020-11-30 19:36:04 -06:00
(pcase-let* (((cl-struct ement-session server token) ement-session)
2020-11-30 18:19:23 -06:00
((cl-struct ement-room id) ement-room)
2020-11-30 19:36:04 -06:00
(endpoint (format "rooms/%s/send/%s/%s" (url-hexify-string id)
"m.room.message" (cl-incf (ement-session-transaction-id ement-session))))
2020-11-30 18:19:23 -06:00
(json-string (json-encode (ement-alist "msgtype" "m.text"
"body" body))))
2020-12-01 03:24:03 -06:00
(ement-api server token endpoint
2020-11-30 18:19:23 -06:00
(lambda (&rest args)
(message "SEND MESSAGE CALLBACK: %S" args))
:data json-string
:method 'put)))))
2020-11-30 10:56:54 -06:00
;;;; Functions
2020-12-01 02:03:32 -06:00
(define-derived-mode ement-room-mode fundamental-mode "Ement Room"
"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 (length ement-room-timestamp-format)
;; TODO: Use EWOC header/footer for, e.g. typing messages.
ement-ewoc (ewoc-create #'ement-room--pp-event)))
2020-11-30 18:19:23 -06:00
(defun ement-room--buffer (session room name)
"Return a buffer named NAME showing ROOM's events on SESSION."
(or (get-buffer name)
(with-current-buffer (get-buffer-create name)
(ement-room-mode)
2020-11-30 13:25:05 -06:00
;; FIXME: Move visual-line-mode to a hook.
(visual-line-mode 1)
2020-11-30 18:19:23 -06:00
(setf ement-session session
ement-room room)
2020-11-30 16:24:41 -06:00
(mapc #'ement-room--insert-event (ement-room-timeline room))
(mapc #'ement-room--insert-event (ement-room-timeline* room))
;; Move new events to main list.
(setf (ement-room-timeline room) (append (ement-room-timeline* room) (ement-room-timeline room))
(ement-room-timeline* room) nil)
2020-12-01 02:03:32 -06:00
;; Return the buffer!
(current-buffer))))
(defun ement-room--user-display-name (user room)
"Return the displayname for USER in ROOM."
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#calculating-the-display-name-for-a-user>.
(if-let ((member-state-event (cl-loop for event in (ement-room-state room)
when (and (equal "m.room.member" (ement-event-type event))
(equal user (ement-event-sender event)))
return event)))
(or (alist-get 'displayname (ement-event-content member-state-event))
;; FIXME: Add step 3 of the spec. For now we skip to step 4.
;; No displayname given: use raw user ID.
(ement-user-id user))
;; No membership state event: use pre-calculated displayname or ID.
(or (ement-user-displayname user)
(ement-user-id user))))
2020-11-30 10:56:54 -06:00
2020-12-01 02:03:32 -06:00
;;;;; EWOC
2020-11-30 10:56:54 -06:00
(defun ement-room--insert-event (event)
"Insert EVENT into current buffer."
2020-12-01 02:03:32 -06:00
(let* ((ewoc ement-ewoc)
2020-11-30 10:56:54 -06:00
(event< (lambda (a b)
"Return non-nil if event A's timestamp is before B's."
(< (ement-event-origin-server-ts a)
(ement-event-origin-server-ts b))))
(node-before (ement-room--ewoc-node-before ewoc event event< :pred #'ement-event-p))
new-node)
(setf new-node (if (not node-before)
(progn
2020-12-01 16:45:40 -06:00
(ement-debug "No event before it: add first.")
2020-11-30 10:56:54 -06:00
(if-let ((first-node (ewoc-nth ewoc 0)))
(progn
2020-12-01 16:45:40 -06:00
(ement-debug "EWOC not empty.")
2020-11-30 10:56:54 -06:00
(if (and (ement-user-p (ewoc-data first-node))
(equal (ement-event-sender event)
(ewoc-data first-node)))
(progn
2020-12-01 16:45:40 -06:00
(ement-debug "First node is header for this sender: insert after it, instead.")
2020-11-30 10:56:54 -06:00
(setf node-before first-node)
(ewoc-enter-after ewoc first-node event))
2020-12-01 16:45:40 -06:00
(ement-debug "First node is not header for this sender: insert first.")
2020-11-30 10:56:54 -06:00
(ewoc-enter-first ewoc event)))
2020-12-01 16:45:40 -06:00
(ement-debug "EWOC empty: add first.")
2020-11-30 10:56:54 -06:00
(ewoc-enter-first ewoc event)))
2020-12-01 16:45:40 -06:00
(ement-debug "Found event before new event: insert after it.")
2020-11-30 10:56:54 -06:00
(when-let ((next-node (ewoc-next ewoc node-before)))
(when (and (ement-user-p (ewoc-data next-node))
(equal (ement-event-sender event)
(ewoc-data next-node)))
2020-12-01 16:45:40 -06:00
(ement-debug "Next node is header for this sender: insert after it, instead.")
2020-11-30 10:56:54 -06:00
(setf node-before next-node)))
(ewoc-enter-after ewoc node-before event)))
;; Insert sender where necessary.
(if (not node-before)
(progn
2020-12-01 16:45:40 -06:00
(ement-debug "No event before: Add sender before new node.")
2020-11-30 10:56:54 -06:00
(ewoc-enter-before ewoc new-node (ement-event-sender event)))
2020-12-01 16:45:40 -06:00
(ement-debug "Event before: compare sender.")
2020-11-30 10:56:54 -06:00
(if (equal (ement-event-sender event)
(cl-typecase (ewoc-data node-before)
(ement-event (ement-event-sender (ewoc-data node-before)))
(ement-user (ewoc-data node-before))))
2020-12-01 16:45:40 -06:00
(ement-debug "Same sender.")
(ement-debug "Different sender: insert new sender node.")
2020-11-30 10:56:54 -06:00
(ewoc-enter-before ewoc new-node (ement-event-sender event))
(when-let* ((next-node (ewoc-next ewoc new-node)))
(when (ement-event-p (ewoc-data next-node))
2020-12-01 16:45:40 -06:00
(ement-debug "Event after from different sender: insert its sender before it.")
2020-11-30 10:56:54 -06:00
(ewoc-enter-before ewoc next-node (ement-event-sender (ewoc-data next-node)))))))))
(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 according to COMPARATOR."
(cl-assert (member from '(first last)))
(if (null (ewoc-nth ewoc 0))
2020-12-01 16:45:40 -06:00
(ement-debug "EWOC is empty: returning nil.")
(ement-debug "EWOC has data: add at appropriate place.")
2020-11-30 10:56:54 -06:00
(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
2020-12-01 16:45:40 -06:00
(ement-debug "New data goes before start node.")
2020-11-30 10:56:54 -06:00
start-node)
2020-12-01 16:45:40 -06:00
(ement-debug "New data goes after start node: find node before new data.")
2020-11-30 10:56:54 -06:00
(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
2020-12-01 16:45:40 -06:00
(ement-debug "Found place: enter there.")
2020-11-30 10:56:54 -06:00
compare-node)
2020-12-01 16:45:40 -06:00
(ement-debug "Reached end of collection: insert there.")
2020-11-30 10:56:54 -06:00
(pcase from
('first (ewoc-nth ewoc -1))
('last nil))))))))))
2020-12-01 02:03:32 -06:00
;;;;; Formatting
(defun ement-room--pp-event (struct)
"Pretty-print STRUCT.
To be used as the pretty-printer for `ewoc-create'."
(cl-etypecase struct
(ement-event (insert "" (ement-room--format-event struct)))
(ement-user (insert (ement-room--format-user struct)))))
(defun ement-room--format-event (event)
"Format `ement-event' EVENT."
(pcase-let* (((cl-struct ement-event sender type content origin-server-ts) event)
((map body format ('formatted_body formatted-body)) content)
(ts (/ origin-server-ts 1000)) ; Matrix timestamps are in milliseconds.
(body (if (not formatted-body)
body
(pcase format
("org.matrix.custom.html"
(ement-room--render-html formatted-body))
(_ (format "[unknown formatted-body format: %s] %s" format body)))))
(timestamp (propertize
" " 'display `((margin left-margin)
,(propertize (format-time-string ement-room-timestamp-format ts)
'face 'ement-room-timestamp))))
(body-face (pcase type
("m.room.member" 'ement-room-membership)
(_ (if (equal (ement-user-id sender)
(ement-user-id (ement-session-user ement-session)))
'ement-room-self-message 'default))))
(string (pcase type
("m.room.message" body)
("m.room.member" "")
(_ (format "[unknown event-type: %s] %s" type body)))))
(add-face-text-property 0 (length body) body-face 'append body)
(prog1 (concat timestamp string)
;; Hacky or elegant? We return the string, but for certain event
;; types, we also insert a widget (this function is called by
;; EWOC with point at the insertion position). Seems to work...
(pcase type
("m.room.member"
(widget-create 'ement-room-membership
:button-face 'ement-room-membership
:value (list (alist-get 'membership content))))))))
(defun ement-room--render-html (string)
2020-12-01 16:45:40 -06:00
"Return rendered version of HTML STRING.
2020-12-01 02:03:32 -06:00
HTML is rendered to Emacs text using `shr-insert-document'."
(with-temp-buffer
(insert string)
(save-excursion
(cl-letf (((symbol-function 'shr-fill-line) #'ignore))
(shr-insert-document
(libxml-parse-html-region (point-min) (point-max)))))
(string-trim (buffer-substring (point) (point-max)))))
(defun ement-room--format-user (user)
"Format `ement-user' USER for current buffer's room."
(let ((face (if (equal (ement-user-id user) (ement-user-id (ement-session-user ement-session)))
'ement-room-self 'ement-room-user)))
(propertize (or (gethash ement-room (ement-user-room-display-names user))
(puthash ement-room (ement-room--user-display-name user ement-room)
(ement-user-room-display-names user)))
'display ement-room-username-display-property
'face face)))
;;;;; Widgets
2020-12-01 01:24:18 -06:00
(require 'widget)
(define-widget 'ement-room-membership 'item
"Widget for membership events."
:format "%{ %v %}"
:sample-face 'ement-room-membership)
2020-11-30 10:56:54 -06:00
;;;; Footer
(provide 'ement-room)
;;; ement-room.el ends here