X67DZ4FXQ4UA22XDE6YI6QR66Y5MD77MK6VRMCD32TEEPFNTMWFAC
Good signature from 066DAFCB81E42C40 GNU ELPA Signing Agent (2019) <elpasign@elpa.gnu.org> (trust undefined) created at 2023-07-09T17:05:02-0400 using RSA
;;; test-plz.el --- Tests for plz -*- lexical-binding: t; -*-
;; Copyright (C) 2019-2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This file is part of GNU Emacs.
;;; License:
;; 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:
;; NOTE: NOTE: NOTE: NOTE: Yes, four NOTEs, because this is important:
;; As of this moment, all of the tests pass when run with makem.sh.
;; And when running them in an interactive Emacs with ERT, one test at
;; a time, individual tests pass, or almost always do (depending on
;; whether the httpbin.org server is overloaded). But when running
;; multiple tests in ERT at one time,
;; i.e. (ert-run-tests-interactively "plz-"), multiple, if not most,
;; tests fail, but not the same ones every time.
;; I have now spent hours trying to figure out why, inserting many
;; debug statements in many functions, and come up with nothing. I
;; tried changing the way `accept-process-output' is called, like
;; using timeouts or JUST-THIS-ONE, but it made no difference. I
;; tried calling it extra times, nope. I tried calling the sentinel
;; extra times when it seemed that it hadn't run the THEN function,
;; nope. Nothing seems to make a difference.
;; I even checked out an earlier commit, before the commit that
;; rewrote/merged the synchronous request code into the `plz'
;; function, thinking that surely I broke something--but, nope, they
;; apparently failed the same way back then: passing with makem.sh,
;; passing individually, but failing when run in close succession by
;; ERT.
;; After inserting enough debug statements, I noticed that the process
;; sentinel sometimes seemed to run for the last time after the ERT
;; test had returned, which suggests that ERT might be doing something
;; weird, or somehow its instrumentation interferes with the
;; process-handling code. But if that's not the cause, then I'm out
;; of ideas.
;; So then I tried rewriting the synchronous request code to use
;; `call-process-region', instead of calling `accept-process-output'
;; in a loop to block on the curl process (which is how the Elisp
;; manual says to do it), but that still made no difference: even the
;; async requests fail in the same way with ERT. So that doesn't
;; appear to be the problem, either.
;; So is there some kind of fundamental flaw in the `plz' design?
;; Maybe. Is there a simple, logical oversight in its code that only
;; manifests under certain conditions? Maybe. Is ERT doing something
;; weird that's interfering with process-related code? Maybe. Is
;; Emacs's own process-handling code still broken in some mysterious
;; way? Maybe.
;; But despite all of that, when using `plz' "in anger", in `ement',
;; it seems to work reliably for me. I did get one report from one
;; user that sounded like the same kind of problem I'm seeing with ERT
;; here, but then he tried `ement-connect' again, and it worked. And
;; I'm sitting here watching `ement' constantly using `plz' to talk to
;; the matrix.org server, and I haven't had a single error or failure,
;; even after hours of being connected. It *seems* to *actually*
;; work.
;; So, if you're reading this, and you're wondering whether you should
;; use `plz': Well, please do, and please let me know if you have any
;; problems; I do need to know whether it's working for other users.
;; And if you think you might know what's going wrong when running the
;; tests in ERT, please let me know, because I'm out of ideas: as far
;; as I can tell, when it comes to process-handling in Emacs, "there
;; be dragons."
;;; Code:
;;;; Requirements
(require 'ert)
(require 'json)
(require 'let-alist)
(require 'map)
(require 'plz)
;;;; Variables
(defvar plz-test-uri-prefix
;; "https://httpbin.org"
"http://localhost"
"URI prefix for HTTP requests, without trailing slash.
If running httpbin locally, set to \"http://localhost\".")
;;;; Customization
;;;; Commands
;;;; Macros
(cl-defun plz-test-wait (process &optional (seconds 0.1) (times 100))
"Wait for SECONDS seconds TIMES times for PROCESS to finish."
(when process
;; Sometimes it seems that the process is killed, the THEN
;; function called by its sentinel, and its buffer killed, all
;; before this function gets called with the process argument;
;; when that happens, tests that use this can fail. Testing
;; whether PROCESS is non-nil seems to fix it, but it's possible
;; that something funny is going on...
(cl-loop for i upto times ;; 10 seconds
while (equal 'run (process-status process))
do (sleep-for seconds))))
(cl-defmacro plz-deftest (name () &body docstring-keys-and-body)
"Like `ert-deftest', but defines tests for both HTTP/1.1 and HTTP/2."
(declare (debug (&define [&name "test@" symbolp]
sexp [&optional stringp]
[&rest keywordp sexp] def-body))
(doc-string 3)
(indent 2))
`(progn
,@(cl-loop for http-version in '("1.1" "2")
collect (let ((name (intern (format "%s-http%s" name http-version))))
`(ert-deftest ,name ()
(let ((plz-curl-default-args
',(append plz-curl-default-args (list (format "--http%s" http-version)))))
,@docstring-keys-and-body))))))
;;;; Functions
(defun plz-test-url (url-part)
"Return URL-PART appended to `plz-test-uri-prefix'.
Also, any instance of \"URI-PREFIX\" in URL-PART is replaced with
`plz-test-uri-prefix' in URL-encoded form."
(setf url-part (replace-regexp-in-string "URI-PREFIX" (url-hexify-string plz-test-uri-prefix)
url-part t t))
(concat plz-test-uri-prefix url-part))
(defmacro plz-test-get-response (response)
"Test parts of RESPONSE with `should'."
`(progn
(should (plz-response-p ,response))
(should (numberp (plz-response-version ,response)))
(should (eq 200 (plz-response-status ,response)))
(should (equal "application/json" (alist-get 'content-type (plz-response-headers ,response))))
(should (string-match "curl"
(map-nested-elt (json-read-from-string (plz-response-body ,response))
'(headers User-Agent))))))
;;;; Tests
;;;;; Async
(plz-deftest plz-get-string nil
(let* ((test-string)
(process (plz 'get (plz-test-url "/get")
:as 'string
:then (lambda (string)
(setf test-string string)))))
(plz-test-wait process)
(should (string-match "curl" test-string))))
(plz-deftest plz-get-buffer nil
(let* ((result-buffer)
(process (plz 'get (plz-test-url "/get")
:as 'buffer :then (lambda (buffer)
(setf result-buffer buffer)))))
(unwind-protect
(progn
(plz-test-wait process)
(should (buffer-live-p result-buffer))
(with-current-buffer result-buffer
(should-not (looking-at-p plz-http-response-status-line-regexp))
(should (string-match "curl" (buffer-string)))))
(kill-buffer result-buffer)
(should-not (buffer-live-p result-buffer)))))
(plz-deftest plz-get-response nil
(let* ((test-response)
(process (plz 'get (plz-test-url "/get")
:as 'response
:then (lambda (response)
(setf test-response response)))))
(plz-test-wait process)
(plz-test-get-response test-response)))
(plz-deftest plz-get-json nil
(let* ((test-json)
(process (plz 'get (plz-test-url "/get")
:as #'json-read
:then (lambda (json)
(setf test-json json)))))
(plz-test-wait process)
(let-alist test-json
(should (string-match "curl" .headers.User-Agent)))))
(plz-deftest plz-post-json-string nil
(let* ((json-string (json-encode (list (cons "key" "value"))))
(response-json)
(process (plz 'post (plz-test-url "/post")
:headers '(("Content-Type" . "application/json"))
:body json-string
:as #'json-read
:then (lambda (json)
(setf response-json json)))))
(plz-test-wait process)
(let-alist response-json
(should (string-match "curl" .headers.User-Agent))
(should (string= "value" (alist-get 'key (json-read-from-string .data)))))))
(plz-deftest plz-post-jpeg-string nil
(let* ((jpeg-to-upload (plz 'get (plz-test-url "/image/jpeg")
:as 'binary :then 'sync))
(_ (unless jpeg-to-upload
(error "jpeg-to-upload is nil")))
(response-json)
(response-jpeg)
(process (plz 'post (plz-test-url "/post")
:headers '(("Content-Type" . "image/jpeg"))
:body jpeg-to-upload :body-type 'binary
:as #'json-read
:then (lambda (json)
(setf response-json json
response-jpeg
(base64-decode-string
(string-remove-prefix "data:application/octet-stream;base64,"
(alist-get 'data json))))))))
(should (equal 'jpeg (image-type-from-data jpeg-to-upload)))
(plz-test-wait process)
(should response-json)
(should (equal 'jpeg (image-type-from-data response-jpeg)))
(should (equal (length jpeg-to-upload) (length response-jpeg)))
(should (equal jpeg-to-upload response-jpeg))))
;; TODO: POST JSON buffer.
(plz-deftest plz-put-json-string nil
(let* ((json-string (json-encode (list (cons "key" "value"))))
(response-json)
(process (plz 'put (plz-test-url "/put")
:headers '(("Content-Type" . "application/json"))
:body json-string
:as #'json-read
:then (lambda (json)
(setf response-json json)))))
(plz-test-wait process)
(let-alist response-json
(should (string-match "curl" .headers.User-Agent))
(should (string= "value" (alist-get 'key (json-read-from-string .data)))))))
;; TODO: Put JSON buffer.
;;;;; Sync
(plz-deftest plz-get-string-sync nil
(let-alist (json-read-from-string (plz 'get (plz-test-url "/get")
:as 'string :then 'sync))
(should (equal (plz-test-url "/get") .url))))
(plz-deftest plz-get-response-sync nil
(plz-test-get-response (plz 'get (plz-test-url "/get")
:as 'response :then 'sync)))
(plz-deftest plz-get-json-sync nil
(let-alist (plz 'get (plz-test-url "/get")
:as #'json-read :then 'sync)
(should (string-match "curl" .headers.User-Agent))))
(plz-deftest plz-get-buffer-sync nil
(let ((buffer (plz 'get (plz-test-url "/get")
:as 'buffer :then 'sync)))
(unwind-protect
(should (buffer-live-p buffer))
(kill-buffer buffer))))
;;;;; Headers
;; These tests were added when plz--curl was changed to send headers
;; with "--config" rather than on the command line.
(plz-deftest plz-get-with-headers ()
(let* ((response-json)
(process (plz 'get (plz-test-url "/get")
:headers '(("X-Plz-Test-Header" . "plz-test-header-value"))
:as #'json-read
:then (lambda (json)
(setf response-json json)))))
(plz-test-wait process)
(let-alist response-json
(should (equal "plz-test-header-value" .headers.X-Plz-Test-Header)))))
(plz-deftest plz-post-with-headers ()
(let* ((alist (list (cons "key" "value")))
(response-json)
(process (plz 'post (plz-test-url "/post")
:headers '(("Content-Type" . "application/json")
("X-Plz-Test-Header" . "plz-test-header-value"))
:body (json-encode alist)
:as #'json-read
:then (lambda (json)
(setf response-json json)))))
(plz-test-wait process)
(let-alist response-json
(should (equal "plz-test-header-value" .headers.X-Plz-Test-Header))
(should (equal "value" (alist-get 'key (json-read-from-string .data)))))))
(plz-deftest plz-get-json-with-headers-sync ()
(let-alist (plz 'get (plz-test-url "/get")
:headers '(("X-Plz-Test-Header" . "plz-test-header-value"))
:as #'json-read :then 'sync)
(should (string-match "curl" .headers.User-Agent))
(should (equal "plz-test-header-value" .headers.X-Plz-Test-Header))))
;;;;; HEAD requests
;; NOTE: httpbin.org doesn't appear to support a "/head" endpoint,
;; so we'll use "/get".
(plz-deftest plz-head-without-headers ()
;; I'm not sure how useful it may be to make a HEAD request without
;; caring about the headers, but perhaps it could be useful as a
;; lightweight way to test a server's presence, so we should
;; probably support it. This merely tests that no error is
;; signaled, which should mean that the HEAD request succeeded.
(should (plz 'head (plz-test-url "/get"))))
(plz-deftest plz-head-as-response ()
(let ((response (plz 'head (plz-test-url "/get")
:as 'response)))
(should (equal "application/json"
(alist-get 'content-type
(plz-response-headers response))))))
;;;;; POST requests
(plz-deftest plz-post-empty-body ()
(should (equal ""
(alist-get 'data
(json-read-from-string
(plz 'post (plz-test-url "/post"))))))
(should (equal "application/json"
(alist-get 'content-type
(plz-response-headers
(plz 'post (plz-test-url "/post") :as 'response))))))
;;;;; Status codes
(plz-deftest plz-201-succeeds ()
;; This merely tests that a 201 response does not signal an error.
(should (plz 'get (plz-test-url "/status/201"))))
(plz-deftest plz-400-errors ()
(should-error (plz 'get (plz-test-url "/status/400"))))
(plz-deftest plz-500-errors ()
(should-error (plz 'get (plz-test-url "/status/500"))))
;;;;; Redirects
(plz-deftest plz-301-redirects ()
(plz-test-get-response
(plz 'get (plz-test-url "/redirect-to?url=URI-PREFIX%2Fget&status_code=301")
:as 'response :then 'sync)))
(plz-deftest plz-302-redirects ()
(plz-test-get-response
(plz 'get (plz-test-url "/redirect-to?url=URI-PREFIX%2Fget&status_code=302")
:as 'response :then 'sync)))
(plz-deftest plz-307-redirects ()
(plz-test-get-response
(plz 'get (plz-test-url "/redirect-to?url=URI-PREFIX%2Fget&status_code=307")
:as 'response :then 'sync)))
(plz-deftest plz-308-redirects ()
(plz-test-get-response
(plz 'get (plz-test-url "/redirect-to?url=URI-PREFIX%2Fget&status_code=308")
:as 'response :then 'sync)))
;;;;; Errors
;; TODO: Sync requests with ":as 'response" should return response for errors rather than signaling.
(plz-deftest plz-get-curl-error-async nil
;; Async.
(let* ((err)
(process (plz 'get "https://httpbinnnnnn.org/get/status/404"
:as 'string :then #'ignore
:else (lambda (e)
(setf err e)))))
(plz-test-wait process)
(should (plz-error-p err))
(should (equal '(6 . "Couldn't resolve host. The given remote host was not resolved.")
(plz-error-curl-error err)))))
;; FIXME: This test works interactively but not in batch mode: it
;; stalls the Emacs process indefinitely, using either sleep-for or
;; sit-for.
;; (plz-deftest plz-get-killed-error nil
;; ;; Async.
;; (let* ((err)
;; (process (plz 'get "https://httpbinnnnnn.org/get/status/404"
;; :as 'string
;; :else (lambda (e)
;; (setf err e)))))
;; (sit-for 0.01)
;; (delete-process process)
;; (should (not (process-live-p process)))
;; (should (plz-error-p err))
;; (should (equal "curl process killed"
;; (plz-error-message err)))))
(plz-deftest plz-get-curl-error-sync nil
;; Sync.
(pcase-let ((`(,_signal . (,_message ,data))
(should-error (plz 'get "https://httpbinnnnnn.org/get/status/404"
:as 'string :then 'sync)
:type 'plz-error)))
(should (plz-error-p data))
(should (equal '(6 . "Couldn't resolve host. The given remote host was not resolved.")
(plz-error-curl-error data)))))
(plz-deftest plz-get-404-error-sync nil
(pcase-let ((`(,_signal . (,_message ,data))
(should-error (plz 'get (plz-test-url "/get/status/404")
:as 'string :then 'sync)
:type 'plz-error)))
(should (plz-error-p data))
(should (plz-response-p (plz-error-response data)))
(should (eq 404 (plz-response-status (plz-error-response data))))))
(plz-deftest plz-get-404-error-async nil
(let* ((err)
(process (plz 'get (plz-test-url "/get/status/404")
:as 'string :then #'ignore
:else (lambda (e)
(setf err e)))))
(plz-test-wait process)
(should (plz-error-p err))
(should (plz-response-p (plz-error-response err)))
(should (eq 404 (plz-response-status (plz-error-response err))))))
(plz-deftest plz-get-timeout-error-sync nil
(pcase-let* ((start-time (current-time))
(`(,_signal . (,_message ,(cl-struct plz-error (curl-error `(,code . ,message)))))
(should-error (plz 'get (plz-test-url "/delay/5")
:as 'string :then 'sync :timeout 1)
:type 'plz-error))
(end-time (current-time)))
(should (eq 28 code))
(should (equal "Operation timeout." message))
(should (< (time-to-seconds (time-subtract end-time start-time)) 1.1))))
(plz-deftest plz-get-timeout-error-async nil
(let* ((start-time (current-time))
(end-time)
(plz-error)
(process (plz 'get (plz-test-url "/delay/5")
:as 'response :timeout 1 :then #'ignore
:else (lambda (e)
(setf end-time (current-time)
plz-error e)))))
(plz-test-wait process)
(should (eq 28 (car (plz-error-curl-error plz-error))))
(should (equal "Operation timeout." (cdr (plz-error-curl-error plz-error))))
(should (< (time-to-seconds (time-subtract end-time start-time)) 1.1))))
;;;;; Finally
(plz-deftest plz-get-finally nil
(let* ((finally-null t)
(process (plz 'get (plz-test-url "/get")
:as 'string
:then #'ignore
:finally (lambda ()
(setf finally-null nil)))))
(plz-test-wait process)
(should-not finally-null)))
;;;;; Binary
(plz-deftest plz-get-jpeg ()
(let* ((test-jpeg)
(process (plz 'get (plz-test-url "/image/jpeg")
:as 'binary
:then (lambda (string)
(setf test-jpeg string)))))
(plz-test-wait process)
(should (equal 'jpeg (image-type-from-data test-jpeg)))))
(plz-deftest plz-get-jpeg-sync ()
(let ((jpeg (plz 'get (plz-test-url "/image/jpeg")
:as 'binary :then 'sync)))
(should (equal 'jpeg (image-type-from-data jpeg)))))
;;;;; Downloading to files
(plz-deftest plz-get-temp-file ()
(let ((filename (plz 'get (plz-test-url "/image/jpeg")
:as 'file :then 'sync)))
(unwind-protect
(let ((jpeg-data (with-temp-buffer
(insert-file-contents filename)
(buffer-string))))
(should (equal 'jpeg (image-type-from-data jpeg-data))))
;; It's a temp file, so it should always be deleted.
(delete-file filename))))
(plz-deftest plz-get-named-file ()
(let ((filename (make-temp-file "plz-")))
;; HACK: Delete the temp file and reuse its name, because
;; `make-temp-name' is less convenient to use.
(delete-file filename)
(unwind-protect
(progn
(plz 'get (plz-test-url "/image/jpeg")
:as `(file ,filename) :then 'sync)
(let ((jpeg-data (with-temp-buffer
(insert-file-contents filename)
(buffer-string))))
(should (equal 'jpeg (image-type-from-data jpeg-data)))))
;; It's a temp file, so it should always be deleted.
(when (file-exists-p filename)
(delete-file filename)))))
(plz-deftest plz-upload-file-by-name ()
(let ((filename (make-temp-file "plz-"))
response-json process)
(unwind-protect
(progn
(with-temp-file filename
(insert "deadbeef"))
(setf process
(plz 'put (plz-test-url "/put")
:body `(file ,filename)
:as #'json-read
:then (lambda (json)
(setf response-json json))))
(plz-test-wait process)
(should (equal "deadbeef" (alist-get 'data response-json)))
(should-not (alist-get 'files response-json)))
(delete-file filename))))
;;;;; Queue
;; TODO: Test that limit is enforced (though it seems to work fine).
(plz-deftest plz-queue-with-finally ()
"Ensure that a queue with a FINALLY function calls it correctly.
That is, that the function is called after the queue is emptied,
and only called once."
(let* ((finally-called-at nil)
(finally-called-times 0)
(queue (make-plz-queue :limit 2
:finally (lambda ()
(setf finally-called-at (current-time))
(cl-incf finally-called-times))))
(urls (list (plz-test-url "/delay/2")))
completed-urls queue-started-at)
(dolist (url urls)
(plz-queue queue
'get url :then (lambda (_)
(push url completed-urls))))
(setf queue-started-at (current-time))
(plz-run queue)
(cl-loop with waits = 0
while (and (plz-queue-active queue) (< waits 60))
do (progn
(sleep-for 0.1)
(cl-incf waits)))
(should (seq-set-equal-p urls completed-urls))
(should (zerop (plz-length queue)))
(should (= 1 finally-called-times))
(should (>= (float-time (time-subtract finally-called-at queue-started-at))
2))))
(plz-deftest plz-queue-without-finally ()
"Ensure that a queue without a FINALLY function doesn't signal an error."
(let* ((queue (make-plz-queue :limit 2))
(urls (list (plz-test-url "/get?foo=0")
(plz-test-url "/get?foo=1")))
completed-urls)
(dolist (url urls)
(plz-queue queue
'get url :then (lambda (_)
(push url completed-urls))))
(plz-run queue)
(cl-loop with waits = 0
while (and (plz-queue-active queue) (< waits 20))
do (progn
(sleep-for 0.1)
(cl-incf waits)))
(should (seq-set-equal-p urls completed-urls))
(should (zerop (plz-length queue)))))
;; TODO: Add test for canceling queue.
;;;; Footer
(provide 'test-plz)
;;; test-plz.el ends here
This is README.info, produced by makeinfo version 6.7 from README.texi.
INFO-DIR-SECTION Emacs
START-INFO-DIR-ENTRY
* Plz: (plz). HTTP library using Curl as a backend.
END-INFO-DIR-ENTRY
File: README.info, Node: Top, Next: Installation, Up: (dir)
plz.el
******
file:http://elpa.gnu.org/packages/plz.svg
(http://elpa.gnu.org/packages/plz.html)
‘plz’ is an HTTP library for Emacs. It uses ‘curl’ as a backend,
which avoids some of the issues with using Emacs’s built-in ‘url’
library. It supports both synchronous and asynchronous requests. Its
API is intended to be simple, natural, and expressive. Its code is
intended to be simple and well-organized. Every feature is tested
against httpbin (https://httpbin.org/).
* Menu:
* Installation::
* Usage::
* Changelog::
* Credits::
* Development::
* License::
— The Detailed Node Listing —
Installation
* GNU ELPA::
* Manual::
Usage
* Examples::
* Functions::
* Queueing::
* Tips::
Changelog
* 0.7: 07.
* 0.6: 06.
* 0.5.4: 054.
* 0.5.3: 053.
* 0.5.2: 052.
* 0.5.1: 051.
* 0.5: 05.
* 0.4: 04.
* 0.3: 03.
* 0.2.1: 021.
* 0.2: 02.
* 0.1: 01.
Development
* Copyright assignment::
File: README.info, Node: Installation, Next: Usage, Prev: Top, Up: Top
1 Installation
**************
* Menu:
* GNU ELPA::
* Manual::
File: README.info, Node: GNU ELPA, Next: Manual, Up: Installation
1.1 GNU ELPA
============
‘plz’ is available in GNU ELPA (http://elpa.gnu.org/packages/plz.html).
It may be installed in Emacs using the ‘package-install’ command.
File: README.info, Node: Manual, Prev: GNU ELPA, Up: Installation
1.2 Manual
==========
‘plz’ has no dependencies other than Emacs and ‘curl’. It’s known to
work on Emacs 26.3 or later. To install it manually, simply place
‘plz.el’ in your ‘load-path’ and ‘(require 'plz)’.
File: README.info, Node: Usage, Next: Changelog, Prev: Installation, Up: Top
2 Usage
*******
The main public function is ‘plz’, which sends an HTTP request and
returns either the result of the specified type (for a synchronous
request), or the ‘curl’ process object (for asynchronous requests). For
asynchronous requests, callback, error-handling, and finalizer functions
may be specified, as well as various other options.
* Menu:
* Examples::
* Functions::
* Queueing::
* Tips::
File: README.info, Node: Examples, Next: Functions, Up: Usage
2.1 Examples
============
Synchronously ‘GET’ a URL and return the response body as a decoded
string (here, raw JSON):
(plz 'get "https://httpbin.org/user-agent")
"{\n \"user-agent\": \"curl/7.35.0\"\n}\n"
Synchronously ‘GET’ a URL that returns a JSON object, and parse and
return it as an alist:
(plz 'get "https://httpbin.org/get" :as #'json-read)
((args)
(headers
(Accept . "*/*")
(Accept-Encoding . "deflate, gzip")
(Host . "httpbin.org")
(User-Agent . "curl/7.35.0"))
(url . "https://httpbin.org/get"))
Asynchronously ‘POST’ a JSON object in the request body, then parse a
JSON object from the response body, and call a function with the result:
(plz 'post "https://httpbin.org/post"
:headers '(("Content-Type" . "application/json"))
:body (json-encode '(("key" . "value")))
:as #'json-read
:then (lambda (alist)
(message "Result: %s" (alist-get 'data alist))))
Result: {"key":"value"}
Synchronously download a JPEG file, then create an Emacs image object
from the data:
(let ((jpeg-data (plz 'get "https://httpbin.org/image/jpeg" :as 'binary)))
(create-image jpeg-data nil 'data))
(image :type jpeg :data ""ÿØÿà^@^PJFIF...")
File: README.info, Node: Functions, Next: Queueing, Prev: Examples, Up: Usage
2.2 Functions
=============
‘plz’
_(method url &key headers body else finally noquery (as ’string)
(then ’sync) (body-type ’text) (decode t decode-s) (connect-timeout
plz-connect-timeout) (timeout plz-timeout))_
Request ‘METHOD’ from ‘URL’ with curl. Return the curl process
object or, for a synchronous request, the selected result.
‘HEADERS’ may be an alist of extra headers to send with the
request.
‘BODY’ may be a string, a buffer, or a list like ‘(file FILENAME)’
to upload a file from disk.
‘BODY-TYPE’ may be ‘text’ to send ‘BODY’ as text, or ‘binary’ to
send it as binary.
‘AS’ selects the kind of result to pass to the callback function
‘THEN’, or the kind of result to return for synchronous requests.
It may be:
• ‘buffer’ to pass the response buffer, which will be narrowed
to the response body and decoded according to ‘DECODE’.
• ‘binary’ to pass the response body as an un-decoded string.
• ‘string’ to pass the response body as a decoded string.
• ‘response’ to pass a ‘plz-response’ structure.
• ‘file’ to pass a temporary filename to which the response body
has been saved without decoding.
• ‘(file ~FILENAME)’ to pass ‘FILENAME’ after having saved the
response body to it without decoding. ‘FILENAME’ must be a
non-existent file; if it exists, it will not be overwritten,
and an error will be signaled.
• A function, which is called in the response buffer with it
narrowed to the response body (suitable for, e.g.
‘json-read’).
If ‘DECODE’ is non-nil, the response body is decoded automatically.
For binary content, it should be nil. When ‘AS’ is ‘binary’,
‘DECODE’ is automatically set to nil.
‘THEN’ is a callback function, whose sole argument is selected
above with ‘AS’; if the request fails and no ‘ELSE’ function is
given (see below), the argument will be a ‘plz-error’ structure
describing the error. Or ‘THEN’ may be ‘sync’ to make a
synchronous request, in which case the result is returned directly
from this function.
‘ELSE’ is an optional callback function called when the request
fails (i.e. if curl fails, or if the ‘HTTP’ response has a non-2xx
status code). It is called with one argument, a ‘plz-error’
structure. If ‘ELSE’ is nil, a ‘plz-curl-error’ or
‘plz-http-error’ is signaled when the request fails, with a
‘plz-error’ structure as the error data. For synchronous requests,
this argument is ignored.
‘NOTE’: In v0.8 of ‘plz’, only one error will be signaled:
‘plz-error’. The existing errors, ‘plz-curl-error’ and
‘plz-http-error’, inherit from ‘plz-error’ to allow applications to
update their code while using v0.7 (i.e. any ‘condition-case’
forms should now handle only ‘plz-error’, not the other two).
‘FINALLY’ is an optional function called without argument after
‘THEN’ or ‘ELSE’, as appropriate. For synchronous requests, this
argument is ignored.
‘CONNECT-TIMEOUT’ and ‘TIMEOUT’ are a number of seconds that limit
how long it takes to connect to a host and to receive a response
from a host, respectively.
‘NOQUERY’ is passed to ‘make-process’, which see.
File: README.info, Node: Queueing, Next: Tips, Prev: Functions, Up: Usage
2.3 Queueing
============
‘plz’ provides a simple system for queueing HTTP requests. First, make
a ‘plz-queue’ struct by calling ‘make-plz-queue’. Then call ‘plz-queue’
with the struct as the first argument, and the rest of the arguments
being the same as those passed to ‘plz’. Then call ‘plz-run’ to run the
queued requests.
All of the queue-related functions return the queue as their value,
making them easy to use. For example:
(defvar my-queue (make-plz-queue :limit 2))
(plz-run
(plz-queue my-queue
'get "https://httpbin.org/get?foo=0"
:then (lambda (body) (message "%s" body))))
Or:
(let ((queue (make-plz-queue :limit 2
:finally (lambda ()
(message "Queue empty."))))
(urls '("https://httpbin.org/get?foo=0"
"https://httpbin.org/get?foo=1")))
(plz-run
(dolist (url urls queue)
(plz-queue queue 'get url
:then (lambda (body) (message "%s" body))))))
You may also clear a queue with ‘plz-clear’, which cancels any active
or queued requests and calls their ‘:else’ functions. And ‘plz-length’
returns the number of a queue’s active and queued requests.
File: README.info, Node: Tips, Prev: Queueing, Up: Usage
2.4 Tips
========
• You can customize settings in the ‘plz’ group, but this can only be
used to adjust a few defaults. It’s not intended that changing or
binding global variables be necessary for normal operation.
File: README.info, Node: Changelog, Next: Credits, Prev: Usage, Up: Top
3 Changelog
***********
* Menu:
* 0.7: 07.
* 0.6: 06.
* 0.5.4: 054.
* 0.5.3: 053.
* 0.5.2: 052.
* 0.5.1: 051.
* 0.5: 05.
* 0.4: 04.
* 0.3: 03.
* 0.2.1: 021.
* 0.2: 02.
* 0.1: 01.
File: README.info, Node: 07, Next: 06, Up: Changelog
3.1 0.7
=======
*Changes*
• A new error signal, ‘plz-error’, is defined. The existing signals,
‘plz-curl-error’ and ‘plz-http-error’, inherit from it, so handling
‘plz-error’ catches both.
*NOTE:* The existing signals, ‘plz-curl-error’ and
‘plz-http-error’, are hereby deprecated, and they will be removed
in v0.8. Applications should be updated while using v0.7 to only
expect ‘plz-error’.
*Fixes*
• Significant improvement in reliability by implementing failsafes
and workarounds for Emacs’s process-handling code. (See #3
(https://github.com/alphapapa/plz.el/issues/3).)
• STDERR output from curl processes is not included in response
bodies (which sometimes happened, depending on Emacs’s internal
race conditions). (Fixes #23
(https://github.com/alphapapa/plz.el/issues/23).)
• Use ‘with-local-quit’ for synchronous requests (preventing Emacs
from complaining sometimes). (Fixes #26
(https://github.com/alphapapa/plz.el/issues/26).)
• Various fixes for ‘:as 'buffer’ result type: decode body when
appropriate; unset multibyte for binary; narrow to body; don’t kill
buffer prematurely.
• When clearing a queue, don’t try to kill finished processes.
*Internal*
• Response processing now happens outside the process sentinel, so
any errors (e.g. in user callbacks) are not signaled from inside
the sentinel. (This avoids the 2-second pause Emacs imposes in
such cases.)
• Tests run against a local instance of httpbin
(https://github.com/postmanlabs/httpbin) (since the ‘httpbin.org’
server is often overloaded).
• No buffer-local variables are defined anymore; process properties
are used instead.
File: README.info, Node: 06, Next: 054, Prev: 07, Up: Changelog
3.2 0.6
=======
*Additions*
• Function ‘plz’’s ‘:body’ argument now accepts a list like ‘(file
FILENAME)’ to upload a file from disk (by passing the filename to
curl, rather than reading its content into Emacs and sending it to
curl through the pipe).
*Fixes*
• Function ‘plz’’s docstring now mentions that the ‘:body’ argument
may also be a buffer (an intentional feature that was accidentally
undocumented).
• Handle HTTP 3xx redirects when using ‘:as 'response’.
File: README.info, Node: 054, Next: 053, Prev: 06, Up: Changelog
3.3 0.5.4
=========
*Fixes*
• Only run queue’s ‘finally’ function after queue is empty. (New
features should not be designed and released on a Friday.)
File: README.info, Node: 053, Next: 052, Prev: 054, Up: Changelog
3.4 0.5.3
=========
*Fixes*
• Move new slot in ‘plz-queue’ struct to end to prevent invalid
byte-compiler expansions for already-compiled applications (which
would require them to be recompiled after upgrading ‘plz’).
File: README.info, Node: 052, Next: 051, Prev: 053, Up: Changelog
3.5 0.5.2
=========
*Fixes*
• When clearing a queue, only call ‘plz-queue’’s ‘finally’ function
when specified.
File: README.info, Node: 051, Next: 05, Prev: 052, Up: Changelog
3.6 0.5.1
=========
*Fixes*
• Only call ‘plz-queue’’s ‘finally’ function when specified. (Thanks
to Dan Oriani (https://github.com/redchops) for reporting.)
File: README.info, Node: 05, Next: 04, Prev: 051, Up: Changelog
3.7 0.5
=======
*Additions*
• Struct ‘plz-queue’’s ‘finally’ slot, a function called when the
queue is finished.
File: README.info, Node: 04, Next: 03, Prev: 05, Up: Changelog
3.8 0.4
=======
*Additions*
• Support for HTTP ‘HEAD’ requests. (Thanks to Inc. for
sponsoring.)
*Changes*
• Allow sending ‘POST’ and ‘PUT’ requests without bodies. (#16
(https://github.com/alphapapa/plz.el/issues/16). Thanks to Joseph
Turner (https://github.com/josephmturner) for reporting. Thanks to
Inc. for sponsoring.)
*Fixes*
• All 2xx HTTP status codes are considered successful. (#17
(https://github.com/alphapapa/plz.el/issues/17). Thanks to Joseph
Turner (https://github.com/josephmturner) for reporting. Thanks to
Inc. for sponsoring.)
• Errors are signaled with error data correctly.
*Internal*
• Test suite explicitly tests with both HTTP/1.1 and HTTP/2.
• Test suite also tests with Emacs versions 27.2, 28.1, and 28.2.
File: README.info, Node: 03, Next: 021, Prev: 04, Up: Changelog
3.9 0.3
=======
*Additions*
• Handle HTTP proxy headers from Curl. (#2
(https://github.com/alphapapa/plz.el/issues/2). Thanks to Alan
Third (https://github.com/alanthird) and Sawyer Zheng
(https://github.com/sawyerzheng) for reporting.)
*Fixes*
• Replaced words not in Ispell’s default dictionaries (so ‘checkdoc’
linting succeeds).
File: README.info, Node: 021, Next: 02, Prev: 03, Up: Changelog
3.10 0.2.1
==========
*Fixes*
• Handle when Curl process is interrupted.
File: README.info, Node: 02, Next: 01, Prev: 021, Up: Changelog
3.11 0.2
========
*Added*
• Simple request queueing.
File: README.info, Node: 01, Prev: 02, Up: Changelog
3.12 0.1
========
Initial release.
File: README.info, Node: Credits, Next: Development, Prev: Changelog, Up: Top
4 Credits
*********
• Thanks to Chris Wellons (https://github.com/skeeto), author of the
Elfeed (https://github.com/skeeto/elfeed) feed reader and the
popular blog null program (https://nullprogram.com/), for his
invaluable advice, review, and encouragement.
File: README.info, Node: Development, Next: License, Prev: Credits, Up: Top
5 Development
*************
Bug reports, feature requests, suggestions — _oh my_!
Note that ‘plz’ is a young library, and its only client so far is
Ement.el (https://github.com/alphapapa/ement.el). There are a variety
of HTTP and ‘curl’ features it does not yet support, since they have not
been needed by the author. Patches are welcome, as long as they include
passing tests.
* Menu:
* Copyright assignment::
File: README.info, Node: Copyright assignment, Up: Development
5.1 Copyright assignment
========================
This package is part of GNU Emacs (https://www.gnu.org/software/emacs/),
being distributed in GNU ELPA (https://elpa.gnu.org/). Contributions to
this project must follow GNU guidelines, which means that, as with other
parts of Emacs, patches of more than a few lines must be accompanied by
having assigned copyright for the contribution to the FSF. Contributors
who wish to do so may contact emacs-devel@gnu.org <emacs-devel@gnu.org>
to request the assignment form.
File: README.info, Node: License, Prev: Development, Up: Top
6 License
*********
GPLv3
Tag Table:
Node: Top199
Node: Installation1180
Node: GNU ELPA1323
Node: Manual1569
Node: Usage1875
Node: Examples2376
Node: Functions3743
Node: Queueing7440
Node: Tips8823
Node: Changelog9124
Node: 079385
Node: 0611261
Node: 05411872
Node: 05312115
Node: 05212431
Node: 05112638
Node: 0512890
Node: 0413096
Node: 0314002
Node: 02114450
Node: 0214601
Node: 0114732
Node: Credits14828
Node: Development15194
Node: Copyright assignment15708
Node: License16296
End Tag Table
Local Variables:
coding: utf-8
End:
;;; plz.el --- HTTP library -*- lexical-binding: t; -*-
;; Copyright (C) 2019-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; URL: https://github.com/alphapapa/plz.el
;; Version: 0.7
;; Package-Requires: ((emacs "26.3"))
;; Keywords: comm, network, http
;; This file is part of GNU Emacs.
;;; License:
;; 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:
;;
;; An HTTP library that uses curl as a backend. Inspired by, and some
;; code copied from, Christopher Wellons's library, elfeed-curl.el.
;;
;; Why this package?
;;
;; 1. `url' works well for many things, but it has some issues.
;; 2. `request' works well for many things, but it has some issues.
;; 3. Chris Wellons doesn't have time to factor his excellent
;; elfeed-curl.el library out of Elfeed. This will have to do.
;;
;; Why is it called `plz'?
;;
;; 1. There's already a package called `http'.
;; 2. There's already a package called `request'.
;; 3. Naming things is hard.
;;;; Usage:
;; FIXME(v0.8): Remove the following note.
;; NOTE: In v0.8 of plz, only one error will be signaled: `plz-error'.
;; The existing errors, `plz-curl-error' and `plz-http-error', inherit
;; from `plz-error' to allow applications to update their code while
;; using v0.7 (i.e. any `condition-case' forms should now handle only
;; `plz-error', not the other two).
;; Call function `plz' to make an HTTP request. Its docstring
;; explains its arguments. `plz' also supports other HTTP methods,
;; uploading and downloading binary files, sending URL parameters and
;; HTTP headers, configurable timeouts, error-handling "else" and
;; always-called "finally" functions, and more.
;; Basic usage is simple. For example, to make a synchronous request
;; and return the HTTP response body as a string:
;;
;; (plz 'get "https://httpbin.org/get")
;;
;; Which returns the JSON object as a string:
;;
;; "{
;; \"args\": {},
;; \"headers\": {
;; \"Accept\": \"*/*\",
;; \"Accept-Encoding\": \"deflate, gzip\",
;; \"Host\": \"httpbin.org\",
;; \"User-Agent\": \"curl/7.35.0\"
;; },
;; \"origin\": \"xxx.xxx.xxx.xxx\",
;; \"url\": \"https://httpbin.org/get\"
;; }"
;;
;; To make the same request asynchronously, decoding the JSON and
;; printing a message with a value from it:
;;
;; (plz 'get "https://httpbin.org/get" :as #'json-read
;; :then (lambda (alist) (message "URL: %s" (alist-get 'url alist))))
;;
;; Which, after the request returns, prints:
;;
;; URL: https://httpbin.org/get
;;;; Credits:
;; Thanks to Chris Wellons for inspiration, encouragement, and advice.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'rx)
(require 'subr-x)
;;;; Errors
(define-error 'plz-error "plz error")
(define-error 'plz-curl-error "plz: Curl error" 'plz-error)
(define-error 'plz-http-error "plz: HTTP error" 'plz-error)
;;;; Structs
(cl-defstruct plz-response
version status headers body)
(cl-defstruct plz-error
curl-error response message)
;;;; Constants
(defconst plz-http-response-status-line-regexp
(rx "HTTP/" (group (or "1.0" "1.1" "2")) " "
;; Status code
(group (1+ digit)) " "
;; Reason phrase
(optional (group (1+ (not (any "\r\n")))))
(or
;; HTTP 1
"\r\n"
;; HTTP 2
"\n"))
"Regular expression matching HTTP response status line.")
(defconst plz-http-end-of-headers-regexp
(rx (or "\r\n\r\n" "\n\n"))
"Regular expression matching the end of HTTP headers.
This must work with both HTTP/1 (using CRLF) and HTTP/2 (using
only LF).")
(defconst plz-curl-errors
;; Copied from elfeed-curl.el.
'((1 . "Unsupported protocol.")
(2 . "Failed to initialize.")
(3 . "URL malformed. The syntax was not correct.")
(4 . "A feature or option that was needed to perform the desired request was not enabled or was explicitly disabled at build-time.")
(5 . "Couldn't resolve proxy. The given proxy host could not be resolved.")
(6 . "Couldn't resolve host. The given remote host was not resolved.")
(7 . "Failed to connect to host.")
(8 . "FTP weird server reply. The server sent data curl couldn't parse.")
(9 . "FTP access denied.")
(11 . "FTP weird PASS reply.")
(13 . "FTP weird PASV reply.")
(14 . "FTP weird 227 format.")
(15 . "FTP can't get host.")
(17 . "FTP couldn't set binary.")
(18 . "Partial file. Only a part of the file was transferred.")
(19 . "FTP couldn't download/access the given file, the RETR (or similar) command failed.")
(21 . "FTP quote error. A quote command returned error from the server.")
(22 . "HTTP page not retrieved.")
(23 . "Write error.")
(25 . "FTP couldn't STOR file.")
(26 . "Read error. Various reading problems.")
(27 . "Out of memory. A memory allocation request failed.")
(28 . "Operation timeout.")
(30 . "FTP PORT failed.")
(31 . "FTP couldn't use REST.")
(33 . "HTTP range error. The range \"command\" didn't work.")
(34 . "HTTP post error. Internal post-request generation error.")
(35 . "SSL connect error. The SSL handshaking failed.")
(36 . "FTP bad download resume.")
(37 . "FILE couldn't read file.")
(38 . "LDAP bind operation failed.")
(39 . "LDAP search failed.")
(41 . "Function not found. A required LDAP function was not found.")
(42 . "Aborted by callback.")
(43 . "Internal error. A function was called with a bad parameter.")
(45 . "Interface error. A specified outgoing interface could not be used.")
(47 . "Too many redirects.")
(48 . "Unknown option specified to libcurl.")
(49 . "Malformed telnet option.")
(51 . "The peer's SSL certificate or SSH MD5 fingerprint was not OK.")
(52 . "The server didn't reply anything, which here is considered an error.")
(53 . "SSL crypto engine not found.")
(54 . "Cannot set SSL crypto engine as default.")
(55 . "Failed sending network data.")
(56 . "Failure in receiving network data.")
(58 . "Problem with the local certificate.")
(59 . "Couldn't use specified SSL cipher.")
(60 . "Peer certificate cannot be authenticated with known CA certificates.")
(61 . "Unrecognized transfer encoding.")
(62 . "Invalid LDAP URL.")
(63 . "Maximum file size exceeded.")
(64 . "Requested FTP SSL level failed.")
(65 . "Sending the data requires a rewind that failed.")
(66 . "Failed to initialise SSL Engine.")
(67 . "The user name, password, or similar was not accepted and curl failed to log in.")
(68 . "File not found on TFTP server.")
(69 . "Permission problem on TFTP server.")
(70 . "Out of disk space on TFTP server.")
(71 . "Illegal TFTP operation.")
(72 . "Unknown TFTP transfer ID.")
(73 . "File already exists (TFTP).")
(74 . "No such user (TFTP).")
(75 . "Character conversion failed.")
(76 . "Character conversion functions required.")
(77 . "Problem with reading the SSL CA cert (path? access rights?).")
(78 . "The resource referenced in the URL does not exist.")
(79 . "An unspecified error occurred during the SSH session.")
(80 . "Failed to shut down the SSL connection.")
(82 . "Could not load CRL file, missing or wrong format (added in 7.19.0).")
(83 . "Issuer check failed (added in 7.19.0).")
(84 . "The FTP PRET command failed")
(85 . "RTSP: mismatch of CSeq numbers")
(86 . "RTSP: mismatch of Session Identifiers")
(87 . "unable to parse FTP file list")
(88 . "FTP chunk callback reported error")
(89 . "No connection available, the session will be queued")
(90 . "SSL public key does not matched pinned public key"))
"Alist mapping curl error code integers to helpful error messages.")
;;;; Customization
(defgroup plz nil
"Options for `plz'."
:group 'network
:link '(url-link "https://github.com/alphapapa/plz.el"))
(defcustom plz-curl-program "curl"
"Name of curl program to call."
:type 'string)
(defcustom plz-curl-default-args
'("--silent"
"--compressed"
"--location")
"Default arguments to curl.
Note that these arguments are passed on the command line, which
may be visible to other users on the local system."
:type '(repeat string))
(defcustom plz-connect-timeout 5
"Default connection timeout in seconds.
This limits how long the connection phase may last (the
\"--connect-timeout\" argument to curl)."
:type 'number)
(defcustom plz-timeout 60
"Default request timeout in seconds.
This limits how long an entire request may take, including the
connection phase and waiting to receive the response (the
\"--max-time\" argument to curl)."
:type 'number)
;;;; Functions
;;;;; Public
(cl-defun plz (method url &rest rest &key headers body else finally noquery
(as 'string) (then 'sync)
(body-type 'text) (decode t decode-s)
(connect-timeout plz-connect-timeout) (timeout plz-timeout))
"Request METHOD from URL with curl.
Return the curl process object or, for a synchronous request, the
selected result.
HEADERS may be an alist of extra headers to send with the
request.
BODY may be a string, a buffer, or a list like `(file FILENAME)'
to upload a file from disk.
BODY-TYPE may be `text' to send BODY as text, or `binary' to send
it as binary.
AS selects the kind of result to pass to the callback function
THEN, or the kind of result to return for synchronous requests.
It may be:
- `buffer' to pass the response buffer, which will be narrowed to
the response body and decoded according to DECODE.
- `binary' to pass the response body as an un-decoded string.
- `string' to pass the response body as a decoded string.
- `response' to pass a `plz-response' structure.
- `file' to pass a temporary filename to which the response body
has been saved without decoding.
- `(file FILENAME)' to pass FILENAME after having saved the
response body to it without decoding. FILENAME must be a
non-existent file; if it exists, it will not be overwritten,
and an error will be signaled.
- A function, which is called in the response buffer with it
narrowed to the response body (suitable for, e.g. `json-read').
If DECODE is non-nil, the response body is decoded automatically.
For binary content, it should be nil. When AS is `binary',
DECODE is automatically set to nil.
THEN is a callback function, whose sole argument is selected
above with AS; if the request fails and no ELSE function is
given (see below), the argument will be a `plz-error' structure
describing the error. Or THEN may be `sync' to make a
synchronous request, in which case the result is returned
directly from this function.
ELSE is an optional callback function called when the request
fails (i.e. if curl fails, or if the HTTP response has a non-2xx
status code). It is called with one argument, a `plz-error'
structure. If ELSE is nil, a `plz-curl-error' or
`plz-http-error' is signaled when the request fails, with a
`plz-error' structure as the error data. For synchronous
requests, this argument is ignored.
NOTE: In v0.8 of `plz', only one error will be signaled:
`plz-error'. The existing errors, `plz-curl-error' and
`plz-http-error', inherit from `plz-error' to allow applications
to update their code while using v0.7 (i.e. any `condition-case'
forms should now handle only `plz-error', not the other two).
FINALLY is an optional function called without argument after
THEN or ELSE, as appropriate. For synchronous requests, this
argument is ignored.
CONNECT-TIMEOUT and TIMEOUT are a number of seconds that limit
how long it takes to connect to a host and to receive a response
from a host, respectively.
NOQUERY is passed to `make-process', which see.
\(To silence checkdoc, we mention the internal argument REST.)"
;; FIXME(v0.8): Remove the note about error changes from the docstring.
;; FIXME(v0.8): Update error signals in docstring.
(declare (indent defun))
(setf decode (if (and decode-s (not decode))
nil decode))
;; NOTE: By default, for PUT requests and POST requests >1KB, curl sends an
;; "Expect:" header, which causes servers to send a "100 Continue" response, which
;; we don't want to have to deal with, so we disable it by setting the header to
;; the empty string. See <https://gms.tf/when-curl-sends-100-continue.html>.
;; TODO: Handle "100 Continue" responses and remove this workaround.
(push (cons "Expect" "") headers)
(let* ((data-arg (pcase-exhaustive body-type
('binary "--data-binary")
('text "--data")))
(curl-command-line-args (append plz-curl-default-args
(list "--config" "-")))
(curl-config-header-args (cl-loop for (key . value) in headers
collect (cons "--header" (format "%s: %s" key value))))
(curl-config-args (append curl-config-header-args
(list (cons "--url" url))
(when connect-timeout
(list (cons "--connect-timeout"
(number-to-string connect-timeout))))
(when timeout
(list (cons "--max-time" (number-to-string timeout))))
;; NOTE: To make a HEAD request
;; requires using the "--head"
;; option rather than "--request
;; HEAD", and doing so with
;; "--dump-header" duplicates the
;; headers, so we must instead
;; specify that for each other
;; method.
(pcase method
('get
(list (cons "--dump-header" "-")))
((or 'put 'post)
(list (cons "--dump-header" "-")
(cons "--request" (upcase (symbol-name method)))
;; It appears that this must be the last argument
;; in order to pass data on the rest of STDIN.
(pcase body
(`(file ,filename)
;; Use `expand-file-name' because curl doesn't
;; expand, e.g. "~" into "/home/...".
(cons "--upload-file" (expand-file-name filename)))
(_ (cons data-arg "@-")))))
('delete
(list (cons "--dump-header" "-")
(cons "--request" (upcase (symbol-name method)))))
('head
(list (cons "--head" "")
(cons "--request" "HEAD"))))))
(curl-config (cl-loop for (key . value) in curl-config-args
concat (format "%s \"%s\"\n" key value)))
(decode (pcase as
('binary nil)
(_ decode)))
(default-directory
;; Avoid making process in a nonexistent directory (in case the current
;; default-directory has since been removed). It's unclear what the best
;; directory is, but this seems to make sense, and it should still exist.
temporary-file-directory)
(process-buffer (generate-new-buffer " *plz-request-curl*"))
(stderr-process (make-pipe-process :name "plz-request-curl-stderr"
:buffer (generate-new-buffer " *plz-request-curl-stderr*")
:noquery t
:sentinel #'plz--stderr-sentinel))
(process (make-process :name "plz-request-curl"
:buffer process-buffer
:coding 'binary
:command (append (list plz-curl-program) curl-command-line-args)
:connection-type 'pipe
:sentinel #'plz--sentinel
:stderr stderr-process
:noquery noquery))
sync-p)
(when (eq 'sync then)
(setf sync-p t
then (lambda (result)
(process-put process :plz-result result))
else nil))
(setf
;; Set the callbacks, etc. as process properties.
(process-get process :plz-then)
(pcase-exhaustive as
((or 'binary 'string)
(lambda ()
(let ((coding-system (or (plz--coding-system) 'utf-8)))
(pcase as
('binary (set-buffer-multibyte nil)))
(plz--narrow-to-body)
(when decode
(decode-coding-region (point) (point-max) coding-system))
(funcall then (or (buffer-string)
(make-plz-error :message (format "buffer-string is nil in buffer:%S" process-buffer)))))))
('buffer (progn
(setf (process-get process :plz-as) 'buffer)
(lambda ()
(let ((coding-system (or (plz--coding-system) 'utf-8)))
(pcase as
('binary (set-buffer-multibyte nil)))
(plz--narrow-to-body)
(when decode
(decode-coding-region (point) (point-max) coding-system)))
(funcall then (current-buffer)))))
('response (lambda ()
(funcall then (or (plz--response :decode-p decode)
(make-plz-error :message (format "response is nil for buffer:%S buffer-string:%S"
process-buffer (buffer-string)))))))
('file (lambda ()
(set-buffer-multibyte nil)
(plz--narrow-to-body)
(let ((filename (make-temp-file "plz-")))
(condition-case err
(progn
(write-region (point-min) (point-max) filename)
(funcall then filename))
;; In case of an error writing to the file, delete the temp file
;; and signal the error. Ignore any errors encountered while
;; deleting the file, which would obscure the original error.
(error (ignore-errors
(delete-file filename))
(funcall then (make-plz-error :message (format "error while writing to file %S: %S" filename err))))))))
(`(file ,(and (pred stringp) filename))
(lambda ()
(set-buffer-multibyte nil)
(plz--narrow-to-body)
(condition-case err
(progn
(write-region (point-min) (point-max) filename nil nil nil 'excl)
(funcall then filename))
;; Since we are creating the file, it seems sensible to delete it in case of an
;; error while writing to it (e.g. a disk-full error). And we ignore any errors
;; encountered while deleting the file, which would obscure the original error.
(error (ignore-errors
(when (file-exists-p filename)
(delete-file filename)))
(funcall then (make-plz-error :message (format "error while writing to file %S: %S" filename err)))))))
((pred functionp) (lambda ()
(let ((coding-system (or (plz--coding-system) 'utf-8)))
(plz--narrow-to-body)
(when decode
(decode-coding-region (point) (point-max) coding-system))
(funcall then (funcall as))))))
(process-get process :plz-else) else
(process-get process :plz-finally) finally
(process-get process :plz-sync) sync-p
;; Record list of arguments for debugging purposes (e.g. when
;; using Edebug in a process buffer, this allows determining
;; which request the buffer is for).
(process-get process :plz-args) (apply #'list method url rest)
;; HACK: We set the result to a sentinel value so that any other
;; value, even nil, means that the response was processed, and
;; the sentinel does not need to be called again (see below).
(process-get process :plz-result) :plz-result)
;; Send --config arguments.
(process-send-string process curl-config)
(when body
(cl-typecase body
(string (process-send-string process body))
(buffer (with-current-buffer body
(process-send-region process (point-min) (point-max))))))
(process-send-eof process)
(if sync-p
(unwind-protect
(with-local-quit
;; See Info node `(elisp)Accepting Output'.
(unless (and process stderr-process)
(error "Process unexpectedly nil"))
(while (accept-process-output process))
(while (accept-process-output stderr-process))
(when (eq :plz-result (process-get process :plz-result))
;; HACK: Sentinel seems to not have been called: call it again. (Although
;; this is a hack, it seems to be a necessary one due to Emacs's process
;; handling.) See <https://github.com/alphapapa/plz.el/issues/3> and
;; <https://debbugs.gnu.org/cgi/bugreport.cgi?bug=50166>.
(plz--sentinel process "finished\n")
(when (eq :plz-result (process-get process :plz-result))
(error "Plz: NO RESULT FROM PROCESS:%S ARGS:%S"
process rest)))
;; Sentinel seems to have been called: check the result.
(pcase (process-get process :plz-result)
((and (pred plz-error-p) data)
;; The AS function signaled an error, which was collected
;; into a `plz-error' struct: re-signal the error here,
;; outside of the sentinel.
(if (plz-error-response data)
;; FIXME(v0.8): Signal only plz-error.
(signal 'plz-http-error (list "HTTP error" data))
(signal 'plz-curl-error (list "Curl error" data))))
(else
;; The AS function returned a value: return it.
else)))
(unless (eq as 'buffer)
(kill-buffer process-buffer))
(kill-buffer (process-buffer stderr-process)))
;; Async request: return the process object.
process)))
;;;;; Queue
;; A simple queue system.
(cl-defstruct plz-queued-request
"Structure representing a queued `plz' HTTP request.
For more details on these slots, see arguments to the function
`plz'."
method url headers body else finally noquery
as then body-type decode
connect-timeout timeout
next previous process)
(cl-defstruct plz-queue
"Structure forming a queue for `plz' requests.
The queue may be appended to (the default) and pre-pended to, and
items may be removed from the front of the queue (i.e. by
default, it's FIFO). Use functions `plz-queue', `plz-run', and
`plz-clear' to queue, run, and clear requests, respectively."
(limit 1
:documentation "Number of simultaneous requests.")
(active nil
:documentation "Active requests.")
(requests nil
:documentation "Queued requests.")
(canceled-p nil
:documentation "Non-nil when queue has been canceled.")
first-active last-active
first-request last-request
(finally nil
:documentation "Function called with no arguments after queue has been emptied or canceled."))
(defun plz-queue (queue &rest args)
"Queue request for ARGS on QUEUE and return QUEUE.
To pre-pend to QUEUE rather than append, it may be a list of the
form (`prepend' QUEUE). QUEUE is a `plz-request' queue. ARGS
are those passed to `plz', which see. Use `plz-run' to start
making QUEUE's requests."
(declare (indent defun))
(cl-assert (not (equal 'sync (plist-get (cddr args) :then))) nil
"Only async requests may be queued")
(pcase-let* ((`(,method ,url . ,rest) args)
(args `(:method ,method :url ,url ,@rest))
(request (apply #'make-plz-queued-request args)))
(pcase queue
(`(prepend ,queue) (plz--queue-prepend request queue))
(_ (plz--queue-append request queue))))
queue)
(defun plz--queue-append (request queue)
"Add REQUEST to end of QUEUE and return QUEUE."
(cl-check-type request plz-queued-request
"REQUEST must be a `plz-queued-request' structure.")
(cl-check-type queue plz-queue
"QUEUE must be a `plz-queue' structure.")
(when (plz-queue-last-request queue)
(setf (plz-queued-request-next (plz-queue-last-request queue)) request))
(setf (plz-queued-request-previous request) (plz-queue-last-request queue)
(plz-queue-last-request queue) request)
(unless (plz-queue-first-request queue)
(setf (plz-queue-first-request queue) request))
(unless (plz-queue-last-request queue)
(setf (plz-queue-last-request queue) request))
(push request (plz-queue-requests queue))
queue)
(defun plz--queue-prepend (request queue)
"Add REQUEST to front of QUEUE and return QUEUE."
(cl-check-type request plz-queued-request
"REQUEST must be a `plz-queued-request' structure.")
(cl-check-type queue plz-queue
"QUEUE must be a `plz-queue' structure.")
(when (plz-queue-requests queue)
(setf (plz-queued-request-next request) (car (plz-queue-requests queue))
(plz-queued-request-previous (plz-queued-request-next request)) request))
(setf (plz-queue-first-request queue) request)
(unless (plz-queue-first-request queue)
(setf (plz-queue-first-request queue) request))
(unless (plz-queue-last-request queue)
(setf (plz-queue-last-request queue) request))
(push request (plz-queue-requests queue))
queue)
(defun plz--queue-pop (queue)
"Return the first queued request on QUEUE and remove it from QUEUE."
(let* ((request (plz-queue-first-request queue))
(next (plz-queued-request-next request)))
(when next
(setf (plz-queued-request-previous next) nil))
(setf (plz-queue-first-request queue) next
(plz-queue-requests queue) (delq request (plz-queue-requests queue)))
(when (eq request (plz-queue-last-request queue))
(setf (plz-queue-last-request queue) nil))
request))
(defun plz-run (queue)
"Process requests in QUEUE and return QUEUE.
Return when QUEUE is at limit or has no more queued requests.
QUEUE should be a `plz-queue' structure."
(cl-labels ((readyp
(queue) (and (not (plz-queue-canceled-p queue))
(plz-queue-requests queue)
;; With apologies to skeeto...
(< (length (plz-queue-active queue)) (plz-queue-limit queue)))))
(while (readyp queue)
(pcase-let* ((request (plz--queue-pop queue))
((cl-struct plz-queued-request method url
headers body finally noquery as body-type decode connect-timeout timeout
(else orig-else) (then orig-then))
request)
(then (lambda (response)
(unwind-protect
;; Ensure any errors in the THEN function don't abort the queue.
(funcall orig-then response)
(setf (plz-queue-active queue) (delq request (plz-queue-active queue)))
(plz-run queue))))
(else (lambda (arg)
;; FIXME(v0.8): This should be done in `plz-queue' because
;; `plz-clear' will call the second queued-request's ELSE
;; before it can be set by `plz-run'.
(unwind-protect
;; Ensure any errors in the THEN function don't abort the queue.
(when orig-else
(funcall orig-else arg))
(setf (plz-queue-active queue) (delq request (plz-queue-active queue)))
(plz-run queue))))
(args (list method url
;; Omit arguments for which `plz' has defaults so as not to nil them.
:headers headers :body body :finally finally :noquery noquery
:connect-timeout connect-timeout :timeout timeout)))
;; Add arguments which override defaults.
(when as
(setf args (plist-put args :as as)))
(when else
(setf args (plist-put args :else else)))
(when then
(setf args (plist-put args :then then)))
(when decode
(setf args (plist-put args :decode decode)))
(when body-type
(setf args (plist-put args :body-type body-type)))
(when connect-timeout
(setf args (plist-put args :connect-timeout connect-timeout)))
(when timeout
(setf args (plist-put args :timeout timeout)))
(setf (plz-queued-request-process request) (apply #'plz args))
(push request (plz-queue-active queue))))
(when (and (plz-queue-finally queue)
(zerop (length (plz-queue-active queue)))
(zerop (length (plz-queue-requests queue))))
(funcall (plz-queue-finally queue)))
queue))
(defun plz-clear (queue)
"Clear QUEUE and return it.
Cancels any active or pending requests and calls the queue's
FINALLY function. For pending requests, their ELSE functions
will be called with a `plz-error' structure with the message,
\"`plz' queue cleared; request canceled.\"; active requests will
have their curl processes killed and their ELSE functions called
with the corresponding data."
(setf (plz-queue-canceled-p queue) t)
(dolist (request (plz-queue-active queue))
(when (process-live-p (plz-queued-request-process request))
(kill-process (plz-queued-request-process request)))
(setf (plz-queue-active queue) (delq request (plz-queue-active queue))))
(dolist (request (plz-queue-requests queue))
(funcall (plz-queued-request-else request)
(make-plz-error :message "`plz' queue cleared; request canceled."))
(setf (plz-queue-requests queue) (delq request (plz-queue-requests queue))))
(when (plz-queue-finally queue)
(funcall (plz-queue-finally queue)))
(setf (plz-queue-first-active queue) nil
(plz-queue-last-active queue) nil
(plz-queue-first-request queue) nil
(plz-queue-last-request queue) nil
(plz-queue-canceled-p queue) nil)
queue)
(defun plz-length (queue)
"Return number of of QUEUE's outstanding requests.
Includes active and queued requests."
(+ (length (plz-queue-active queue))
(length (plz-queue-requests queue))))
;;;;; Private
(defun plz--sentinel (process status)
"Sentinel for curl PROCESS.
STATUS should be the process's event string (see info
node `(elisp) Sentinels'). Calls `plz--respond' to process the
HTTP response (directly for synchronous requests, or from a timer
for asynchronous ones)."
(pcase status
((or "finished\n" "killed\n" "interrupt\n"
(pred numberp)
(rx "exited abnormally with code " (group (1+ digit))))
(let ((buffer (process-buffer process)))
(if (process-get process :plz-sync)
(plz--respond process buffer status)
(run-at-time 0 nil #'plz--respond process buffer status))))))
(defun plz--respond (process buffer status)
"Respond to HTTP response from PROCESS in BUFFER.
Parses the response and calls the THEN/ELSE callbacks
accordingly. To be called from `plz--sentinel'. STATUS is the
argument passed to `plz--sentinel', which see."
;; Is it silly to call this function "please respond"? Perhaps, but
;; naming things is hard. The term "process" has another meaning in
;; this context, and the old standby, "handle," is much overused.
;; "Respond" also means "to react to something," which is what this
;; does--react to receiving the HTTP response--and it's an internal
;; name, so why not.
(unwind-protect
(with-current-buffer buffer
(pcase-exhaustive status
((or 0 "finished\n")
;; Curl exited normally: check HTTP status code.
(goto-char (point-min))
(plz--skip-proxy-headers)
(while (plz--skip-redirect-headers))
(pcase (plz--http-status)
((and status (guard (<= 200 status 299)))
;; Any 2xx response is considered successful.
(ignore status) ; Byte-compiling in Emacs <28 complains without this.
(funcall (process-get process :plz-then)))
(_
;; TODO: If using ":as 'response", the HTTP response
;; should be passed to the THEN function, regardless
;; of the status code. Only for curl errors should
;; the ELSE function be called. (Maybe in v0.8.)
;; Any other status code is considered unsuccessful
;; (for now, anyway).
(let ((err (make-plz-error :response (plz--response))))
(pcase-exhaustive (process-get process :plz-else)
(`nil (process-put process :plz-result err))
((and (pred functionp) fn) (funcall fn err)))))))
((or (and (pred numberp) code)
(rx "exited abnormally with code " (let code (group (1+ digit)))))
;; Curl error.
(let* ((curl-exit-code (cl-typecase code
(string (string-to-number code))
(number code)))
(curl-error-message (alist-get curl-exit-code plz-curl-errors))
(err (make-plz-error :curl-error (cons curl-exit-code curl-error-message))))
(pcase-exhaustive (process-get process :plz-else)
(`nil (process-put process :plz-result err))
((and (pred functionp) fn) (funcall fn err)))))
((and (or "killed\n" "interrupt\n") status)
;; Curl process killed or interrupted.
(let* ((message (pcase status
("killed\n" "curl process killed")
("interrupt\n" "curl process interrupted")))
(err (make-plz-error :message message)))
(pcase-exhaustive (process-get process :plz-else)
(`nil (process-put process :plz-result err))
((and (pred functionp) fn) (funcall fn err)))))))
(when-let ((finally (process-get process :plz-finally)))
(funcall finally))
(unless (or (process-get process :plz-sync)
(eq 'buffer (process-get process :plz-as)))
(kill-buffer buffer))))
(defun plz--stderr-sentinel (process status)
"Sentinel for STDERR buffer.
Arguments are PROCESS and STATUS (ok, checkdoc?)."
(pcase status
((or "finished\n" "killed\n" "interrupt\n"
(pred numberp)
(rx "exited abnormally with code " (1+ digit)))
(kill-buffer (process-buffer process)))))
;;;;;; HTTP Responses
;; Functions for parsing HTTP responses.
(defun plz--skip-proxy-headers ()
"Skip proxy headers in current buffer."
(when (looking-at plz-http-response-status-line-regexp)
(let* ((status-code (string-to-number (match-string 2)))
(reason-phrase (match-string 3)))
(when (and (equal 200 status-code)
(equal "Connection established" reason-phrase))
;; Skip proxy headers (curl apparently offers no way to omit
;; them).
(unless (re-search-forward "\r\n\r\n" nil t)
(signal 'plz-http-error '("plz--response: End of proxy headers not found")))))))
(defun plz--skip-redirect-headers ()
"Skip HTTP redirect headers in current buffer."
(when (and (looking-at plz-http-response-status-line-regexp)
(member (string-to-number (match-string 2)) '(301 302 307 308)))
;; Skip redirect headers ("--dump-header" forces redirect headers to be included
;; even when used with "--location").
(or (re-search-forward "\r\n\r\n" nil t)
(signal 'plz-http-error '("plz--response: End of redirect headers not found")))))
(cl-defun plz--response (&key (decode-p t))
"Return response structure for HTTP response in current buffer.
When DECODE-P is non-nil, decode the response body automatically
according to the apparent coding system.
Assumes that point is at beginning of HTTP response."
(save-excursion
;; Parse HTTP version and status code.
(unless (looking-at plz-http-response-status-line-regexp)
(signal 'plz-http-error
(list "plz--response: Unable to parse HTTP response status line"
(buffer-substring (point) (line-end-position)))))
(let* ((http-version (string-to-number (match-string 1)))
(status-code (string-to-number (match-string 2)))
(headers (plz--headers))
(coding-system (or (plz--coding-system headers) 'utf-8)))
(plz--narrow-to-body)
(when decode-p
(decode-coding-region (point) (point-max) coding-system))
(make-plz-response
:version http-version
:status status-code
:headers headers
:body (buffer-string)))))
(defun plz--coding-system (&optional headers)
"Return coding system for HTTP response in current buffer.
HEADERS may optionally be an alist of parsed HTTP headers to
refer to rather than the current buffer's un-parsed headers."
(let* ((headers (or headers (plz--headers)))
(content-type (alist-get 'content-type headers)))
(when content-type
(coding-system-from-name content-type))))
(defun plz--http-status ()
"Return HTTP status code for HTTP response in current buffer.
Assumes point is at start of HTTP response."
(when (looking-at plz-http-response-status-line-regexp)
(string-to-number (match-string 2))))
(defun plz--headers ()
"Return headers alist for HTTP response in current buffer.
Assumes point is at start of HTTP response."
(save-excursion
(forward-line 1)
(let ((limit (save-excursion
(re-search-forward plz-http-end-of-headers-regexp nil)
(point))))
(cl-loop while (re-search-forward (rx bol (group (1+ (not (in ":")))) ":" (1+ blank)
(group (1+ (not (in "\r\n")))))
limit t)
;; NOTE: Some HTTP servers send all-lowercase header keys, which means an alist
;; lookup with `equal' or `string=' fails when the case differs. We don't want
;; users to have to worry about this, so for consistency, we downcase the
;; header name. And while we're at it, we might as well intern it so we can
;; use `alist-get' without having to add "nil nil #'equal" every time.
collect (cons (intern (downcase (match-string 1))) (match-string 2))))))
(defun plz--narrow-to-body ()
"Narrow to body of HTTP response in current buffer.
Assumes point is at start of HTTP response."
(unless (re-search-forward plz-http-end-of-headers-regexp nil t)
(signal 'plz-http-error '("plz--narrow-to-body: Unable to find end of headers")))
(narrow-to-region (point) (point-max)))
;;;; Footer
(provide 'plz)
;;; plz.el ends here
;; Generated package description from plz.el -*- no-byte-compile: t -*-
(define-package "plz" "0.7" "HTTP library" '((emacs "26.3")) :commit "70ebd6edea2b5c376776cd747bc378b07f0e6646" :authors '(("Adam Porter" . "adam@alphapapa.net")) :maintainer '("Adam Porter" . "adam@alphapapa.net") :keywords '("comm" "network" "http") :url "https://github.com/alphapapa/plz.el")
;;; plz-autoloads.el --- automatically extracted autoloads (do not edit) -*- lexical-binding: t -*-
;; Generated by the `loaddefs-generate' function.
;; This file is part of GNU Emacs.
;;; Code:
(add-to-list 'load-path (or (and load-file-name (directory-file-name (file-name-directory load-file-name))) (car load-path)))
;;; Generated autoloads from plz.el
(register-definition-prefixes "plz" '("plz-"))
;;; End of scraped data
(provide 'plz-autoloads)
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; no-native-compile: t
;; coding: utf-8-emacs-unix
;; End:
;;; plz-autoloads.el ends here
This is the file .../info/dir, which contains the
topmost node of the Info hierarchy, called (dir)Top.
The first time you invoke Info you start off looking at this node.
File: dir, Node: Top This is the top of the INFO tree
This (the Directory node) gives a menu of major topics.
Typing "q" exits, "H" lists all Info commands, "d" returns here,
"h" gives a primer for first-timers,
"mEmacs<Return>" visits the Emacs manual, etc.
In Emacs, you can click mouse button 2 on a menu item or cross reference
to select it.
* Menu:
#+TITLE: plz.el
#+PROPERTY: LOGGING nil
# Note: This readme works with the org-make-toc <https://github.com/alphapapa/org-make-toc> package, which automatically updates the table of contents.
[[http://elpa.gnu.org/packages/plz.html][file:http://elpa.gnu.org/packages/plz.svg]]
#+HTML: <img src="images/mascot.png" align="right">
~plz~ is an HTTP library for Emacs. It uses ~curl~ as a backend, which avoids some of the issues with using Emacs's built-in ~url~ library. It supports both synchronous and asynchronous requests. Its API is intended to be simple, natural, and expressive. Its code is intended to be simple and well-organized. Every feature is tested against [[https://httpbin.org/][httpbin]].
* Contents :noexport:
:PROPERTIES:
:TOC: :include siblings
:END:
:CONTENTS:
- [[#installation][Installation]]
- [[#usage][Usage]]
- [[#examples][Examples]]
- [[#functions][Functions]]
- [[#queueing][Queueing]]
- [[#changelog][Changelog]]
- [[#credits][Credits]]
- [[#development][Development]]
- [[#copyright-assignment][Copyright assignment]]
:END:
* Installation
:PROPERTIES:
:TOC: :depth 0
:END:
** GNU ELPA
~plz~ is available in [[http://elpa.gnu.org/packages/plz.html][GNU ELPA]]. It may be installed in Emacs using the ~package-install~ command.
** Manual
~plz~ has no dependencies other than Emacs and ~curl~. It's known to work on Emacs 26.3 or later. To install it manually, simply place =plz.el= in your ~load-path~ and ~(require 'plz)~.
* Usage
:PROPERTIES:
:TOC: :depth 1
:END:
The main public function is ~plz~, which sends an HTTP request and returns either the result of the specified type (for a synchronous request), or the ~curl~ process object (for asynchronous requests). For asynchronous requests, callback, error-handling, and finalizer functions may be specified, as well as various other options.
** Examples
Synchronously =GET= a URL and return the response body as a decoded string (here, raw JSON):
#+BEGIN_SRC elisp :exports both :results value code :cache yes
(plz 'get "https://httpbin.org/user-agent")
#+END_SRC
#+RESULTS[47fef7e4780e9fac6c99d7661c29de580bf0fa14]:
#+begin_src elisp
"{\n \"user-agent\": \"curl/7.35.0\"\n}\n"
#+end_src
Synchronously =GET= a URL that returns a JSON object, and parse and return it as an alist:
#+BEGIN_SRC elisp :exports both :results value code :cache yes
(plz 'get "https://httpbin.org/get" :as #'json-read)
#+END_SRC
#+RESULTS[a117174ba62b2be3ea3f23e5c43662047b81bccf]:
#+begin_src elisp
((args)
(headers
(Accept . "*/*")
(Accept-Encoding . "deflate, gzip")
(Host . "httpbin.org")
(User-Agent . "curl/7.35.0"))
(url . "https://httpbin.org/get"))
#+end_src
Asynchronously =POST= a JSON object in the request body, then parse a JSON object from the response body, and call a function with the result:
#+BEGIN_SRC elisp :exports both :cache yes
(plz 'post "https://httpbin.org/post"
:headers '(("Content-Type" . "application/json"))
:body (json-encode '(("key" . "value")))
:as #'json-read
:then (lambda (alist)
(message "Result: %s" (alist-get 'data alist))))
#+END_SRC
#+RESULTS[3f4fdd16c4980bf36c3930e91f69cc379cca4a35]:
: Result: {"key":"value"}
Synchronously download a JPEG file, then create an Emacs image object from the data:
#+BEGIN_SRC elisp :exports both :cache yes
(let ((jpeg-data (plz 'get "https://httpbin.org/image/jpeg" :as 'binary)))
(create-image jpeg-data nil 'data))
#+END_SRC
#+RESULTS[fbe8a6c8cb097ac08e992ea90bdbd50e7337a385]:
: (image :type jpeg :data ""ÿØÿà^@^PJFIF...")
** Functions
+ ~plz~ :: /(method url &key headers body else finally noquery (as 'string) (then 'sync) (body-type 'text) (decode t decode-s) (connect-timeout plz-connect-timeout) (timeout plz-timeout))/
Request ~METHOD~ from ~URL~ with curl. Return the curl process object or, for a synchronous request, the selected result.
~HEADERS~ may be an alist of extra headers to send with the request.
~BODY~ may be a string, a buffer, or a list like ~(file FILENAME)~ to upload a file from disk.
~BODY-TYPE~ may be ~text~ to send ~BODY~ as text, or ~binary~ to send it as binary.
~AS~ selects the kind of result to pass to the callback function ~THEN~, or the kind of result to return for synchronous requests. It may be:
- ~buffer~ to pass the response buffer, which will be narrowed to the response body and decoded according to ~DECODE~.
- ~binary~ to pass the response body as an un-decoded string.
- ~string~ to pass the response body as a decoded string.
- ~response~ to pass a ~plz-response~ structure.
- ~file~ to pass a temporary filename to which the response body has been saved without decoding.
- ~(file ~FILENAME)~ to pass ~FILENAME~ after having saved the response body to it without decoding. ~FILENAME~ must be a non-existent file; if it exists, it will not be overwritten, and an error will be signaled.
- A function, which is called in the response buffer with it narrowed to the response body (suitable for, e.g. ~json-read~).
If ~DECODE~ is non-nil, the response body is decoded automatically. For binary content, it should be nil. When ~AS~ is ~binary~, ~DECODE~ is automatically set to nil.
~THEN~ is a callback function, whose sole argument is selected above with ~AS~; if the request fails and no ~ELSE~ function is given (see below), the argument will be a ~plz-error~ structure describing the error. Or ~THEN~ may be ~sync~ to make a synchronous request, in which case the result is returned directly from this function.
~ELSE~ is an optional callback function called when the request fails (i.e. if curl fails, or if the ~HTTP~ response has a non-2xx status code). It is called with one argument, a ~plz-error~ structure. If ~ELSE~ is nil, a ~plz-curl-error~ or ~plz-http-error~ is signaled when the request fails, with a ~plz-error~ structure as the error data. For synchronous requests, this argument is ignored.
~NOTE~: In v0.8 of ~plz~, only one error will be signaled: ~plz-error~. The existing errors, ~plz-curl-error~ and ~plz-http-error~, inherit from ~plz-error~ to allow applications to update their code while using v0.7 (i.e. any ~condition-case~ forms should now handle only ~plz-error~, not the other two).
~FINALLY~ is an optional function called without argument after ~THEN~ or ~ELSE~, as appropriate. For synchronous requests, this argument is ignored.
~CONNECT-TIMEOUT~ and ~TIMEOUT~ are a number of seconds that limit how long it takes to connect to a host and to receive a response from a host, respectively.
~NOQUERY~ is passed to ~make-process~, which see.
** Queueing
~plz~ provides a simple system for queueing HTTP requests. First, make a ~plz-queue~ struct by calling ~make-plz-queue~. Then call ~plz-queue~ with the struct as the first argument, and the rest of the arguments being the same as those passed to ~plz~. Then call ~plz-run~ to run the queued requests.
All of the queue-related functions return the queue as their value, making them easy to use. For example:
#+begin_src elisp :exports code
(defvar my-queue (make-plz-queue :limit 2))
(plz-run
(plz-queue my-queue
'get "https://httpbin.org/get?foo=0"
:then (lambda (body) (message "%s" body))))
#+end_src
Or:
#+begin_src elisp :exports code
(let ((queue (make-plz-queue :limit 2
:finally (lambda ()
(message "Queue empty."))))
(urls '("https://httpbin.org/get?foo=0"
"https://httpbin.org/get?foo=1")))
(plz-run
(dolist (url urls queue)
(plz-queue queue 'get url
:then (lambda (body) (message "%s" body))))))
#+end_src
You may also clear a queue with ~plz-clear~, which cancels any active or queued requests and calls their ~:else~ functions. And ~plz-length~ returns the number of a queue's active and queued requests.
** Tips
:PROPERTIES:
:TOC: :ignore (this)
:END:
+ You can customize settings in the =plz= group, but this can only be used to adjust a few defaults. It's not intended that changing or binding global variables be necessary for normal operation.
* Changelog
:PROPERTIES:
:TOC: :depth 0
:END:
** 0.7
*Changes*
+ A new error signal, ~plz-error~, is defined. The existing signals, ~plz-curl-error~ and ~plz-http-error~, inherit from it, so handling ~plz-error~ catches both.
*NOTE:* The existing signals, ~plz-curl-error~ and ~plz-http-error~, are hereby deprecated, and they will be removed in v0.8. Applications should be updated while using v0.7 to only expect ~plz-error~.
*Fixes*
+ Significant improvement in reliability by implementing failsafes and workarounds for Emacs's process-handling code. (See [[https://github.com/alphapapa/plz.el/issues/3][#3]].)
+ STDERR output from curl processes is not included in response bodies (which sometimes happened, depending on Emacs's internal race conditions). (Fixes [[https://github.com/alphapapa/plz.el/issues/23][#23]].)
+ Use ~with-local-quit~ for synchronous requests (preventing Emacs from complaining sometimes). (Fixes [[https://github.com/alphapapa/plz.el/issues/26][#26]].)
+ Various fixes for ~:as 'buffer~ result type: decode body when appropriate; unset multibyte for binary; narrow to body; don't kill buffer prematurely.
+ When clearing a queue, don't try to kill finished processes.
*Internal*
+ Response processing now happens outside the process sentinel, so any errors (e.g. in user callbacks) are not signaled from inside the sentinel. (This avoids the 2-second pause Emacs imposes in such cases.)
+ Tests run against a local instance of [[https://github.com/postmanlabs/httpbin][httpbin]] (since the ~httpbin.org~ server is often overloaded).
+ No buffer-local variables are defined anymore; process properties are used instead.
** 0.6
*Additions*
+ Function ~plz~'s ~:body~ argument now accepts a list like ~(file FILENAME)~ to upload a file from disk (by passing the filename to curl, rather than reading its content into Emacs and sending it to curl through the pipe).
*Fixes*
+ Function ~plz~'s docstring now mentions that the ~:body~ argument may also be a buffer (an intentional feature that was accidentally undocumented).
+ Handle HTTP 3xx redirects when using ~:as 'response~.
** 0.5.4
*Fixes*
+ Only run queue's ~finally~ function after queue is empty. (New features should not be designed and released on a Friday.)
** 0.5.3
*Fixes*
+ Move new slot in ~plz-queue~ struct to end to prevent invalid byte-compiler expansions for already-compiled applications (which would require them to be recompiled after upgrading ~plz~).
** 0.5.2
*Fixes*
+ When clearing a queue, only call ~plz-queue~'s ~finally~ function when specified.
** 0.5.1
*Fixes*
+ Only call ~plz-queue~'s ~finally~ function when specified. (Thanks to [[https://github.com/redchops][Dan Oriani]] for reporting.)
** 0.5
*Additions*
+ Struct ~plz-queue~'s ~finally~ slot, a function called when the queue is finished.
** 0.4
*Additions*
+ Support for HTTP ~HEAD~ requests. (Thanks to [[https://ushin.org/][USHIN, Inc.]] for sponsoring.)
*Changes*
+ Allow sending ~POST~ and ~PUT~ requests without bodies. ([[https://github.com/alphapapa/plz.el/issues/16][#16]]. Thanks to [[https://github.com/josephmturner][Joseph Turner]] for reporting. Thanks to [[https://ushin.org/][USHIN, Inc.]] for sponsoring.)
*Fixes*
+ All 2xx HTTP status codes are considered successful. ([[https://github.com/alphapapa/plz.el/issues/17][#17]]. Thanks to [[https://github.com/josephmturner][Joseph Turner]] for reporting. Thanks to [[https://ushin.org/][USHIN, Inc.]] for sponsoring.)
+ Errors are signaled with error data correctly.
*Internal*
+ Test suite explicitly tests with both HTTP/1.1 and HTTP/2.
+ Test suite also tests with Emacs versions 27.2, 28.1, and 28.2.
** 0.3
*Additions*
+ Handle HTTP proxy headers from Curl. ([[https://github.com/alphapapa/plz.el/issues/2][#2]]. Thanks to [[https://github.com/alanthird][Alan Third]] and [[https://github.com/sawyerzheng][Sawyer Zheng]] for reporting.)
*Fixes*
+ Replaced words not in Ispell's default dictionaries (so ~checkdoc~ linting succeeds).
** 0.2.1
*Fixes*
+ Handle when Curl process is interrupted.
** 0.2
*Added*
+ Simple request queueing.
** 0.1
Initial release.
* Credits
+ Thanks to [[https://github.com/skeeto][Chris Wellons]], author of the [[https://github.com/skeeto/elfeed][Elfeed]] feed reader and the popular blog [[https://nullprogram.com/][null program]], for his invaluable advice, review, and encouragement.
* Development
Bug reports, feature requests, suggestions — /oh my/!
Note that ~plz~ is a young library, and its only client so far is [[https://github.com/alphapapa/ement.el][Ement.el]]. There are a variety of HTTP and ~curl~ features it does not yet support, since they have not been needed by the author. Patches are welcome, as long as they include passing tests.
** Copyright assignment
This package is part of [[https://www.gnu.org/software/emacs/][GNU Emacs]], being distributed in [[https://elpa.gnu.org/][GNU ELPA]]. Contributions to this project must follow GNU guidelines, which means that, as with other parts of Emacs, patches of more than a few lines must be accompanied by having assigned copyright for the contribution to the FSF. Contributors who wish to do so may contact [[mailto:emacs-devel@gnu.org][emacs-devel@gnu.org]] to request the assignment form.
* License
:PROPERTIES:
:TOC: :ignore (this)
:END:
GPLv3
* COMMENT Export setup :noexport:
:PROPERTIES:
:TOC: :ignore (this descendants)
:END:
# Copied from org-super-agenda's readme, in which much was borrowed from Org's =org-manual.org=.
#+OPTIONS: broken-links:t *:t
** Info export options
#+TEXINFO_DIR_CATEGORY: Emacs
#+TEXINFO_DIR_TITLE: Plz: (plz)
#+TEXINFO_DIR_DESC: HTTP library using Curl as a backend
# NOTE: We could use these, but that causes a pointless error, "org-compile-file: File "..README.info" wasn't produced...", so we just rename the files in the after-save-hook instead.
# #+TEXINFO_FILENAME: plz.info
# #+EXPORT_FILE_NAME: plz.texi
** File-local variables
# NOTE: Setting org-comment-string buffer-locally is a nasty hack to work around GitHub's org-ruby's HTML rendering, which does not respect noexport tags. The only way to hide this tree from its output is to use the COMMENT keyword, but that prevents Org from processing the export options declared in it. So since these file-local variables don't affect org-ruby, wet set org-comment-string to an unused keyword, which prevents Org from deleting this tree from the export buffer, which allows it to find the export options in it. And since org-export does respect the noexport tag, the tree is excluded from the info page.
# Local Variables:
# eval: (require 'org-make-toc)
# after-save-hook: (lambda nil (when (and (require 'ox-texinfo nil t) (org-texinfo-export-to-info)) (delete-file "README.texi") (rename-file "README.info" "plz.info" t)))
# before-save-hook: org-make-toc
# org-export-with-properties: ()
# org-export-with-title: t
# org-export-initial-scope: buffer
# org-comment-string: "NOTCOMMENT"
# End:
━━━━━━━━
PLZ.EL
━━━━━━━━
[file:http://elpa.gnu.org/packages/plz.svg]
`plz' is an HTTP library for Emacs. It uses `curl' as a backend, which
avoids some of the issues with using Emacs's built-in `url' library. It
supports both synchronous and asynchronous requests. Its API is
intended to be simple, natural, and expressive. Its code is intended to
be simple and well-organized. Every feature is tested against
[httpbin].
[file:http://elpa.gnu.org/packages/plz.svg]
<http://elpa.gnu.org/packages/plz.html>
[httpbin] <https://httpbin.org/>
1 Installation
══════════════
1.1 GNU ELPA
────────────
`plz' is available in [GNU ELPA]. It may be installed in Emacs using
the `package-install' command.
[GNU ELPA] <http://elpa.gnu.org/packages/plz.html>
1.2 Manual
──────────
`plz' has no dependencies other than Emacs and `curl'. It's known to
work on Emacs 26.3 or later. To install it manually, simply place
`plz.el' in your `load-path' and `(require 'plz)'.
2 Usage
═══════
The main public function is `plz', which sends an HTTP request and
returns either the result of the specified type (for a synchronous
request), or the `curl' process object (for asynchronous requests).
For asynchronous requests, callback, error-handling, and finalizer
functions may be specified, as well as various other options.
2.1 Examples
────────────
Synchronously `GET' a URL and return the response body as a decoded
string (here, raw JSON):
┌────
│ (plz 'get "https://httpbin.org/user-agent")
└────
┌────
│ "{\n \"user-agent\": \"curl/7.35.0\"\n}\n"
└────
Synchronously `GET' a URL that returns a JSON object, and parse and
return it as an alist:
┌────
│ (plz 'get "https://httpbin.org/get" :as #'json-read)
└────
┌────
│ ((args)
│ (headers
│ (Accept . "*/*")
│ (Accept-Encoding . "deflate, gzip")
│ (Host . "httpbin.org")
│ (User-Agent . "curl/7.35.0"))
│ (url . "https://httpbin.org/get"))
└────
Asynchronously `POST' a JSON object in the request body, then parse a
JSON object from the response body, and call a function with the
result:
┌────
│ (plz 'post "https://httpbin.org/post"
│ :headers '(("Content-Type" . "application/json"))
│ :body (json-encode '(("key" . "value")))
│ :as #'json-read
│ :then (lambda (alist)
│ (message "Result: %s" (alist-get 'data alist))))
└────
┌────
│ Result: {"key":"value"}
└────
Synchronously download a JPEG file, then create an Emacs image object
from the data:
┌────
│ (let ((jpeg-data (plz 'get "https://httpbin.org/image/jpeg" :as 'binary)))
│ (create-image jpeg-data nil 'data))
└────
┌────
│ (image :type jpeg :data ""ÿØÿà^@^PJFIF...")
└────
2.2 Functions
─────────────
`plz'
/(method url &key headers body else finally noquery (as 'string)
(then 'sync) (body-type 'text) (decode t decode-s)
(connect-timeout plz-connect-timeout) (timeout plz-timeout))/
Request `METHOD' from `URL' with curl. Return the curl process
object or, for a synchronous request, the selected result.
`HEADERS' may be an alist of extra headers to send with the
request.
`BODY' may be a string, a buffer, or a list like `(file
FILENAME)' to upload a file from disk.
`BODY-TYPE' may be `text' to send `BODY' as text, or `binary' to
send it as binary.
`AS' selects the kind of result to pass to the callback function
`THEN', or the kind of result to return for synchronous
requests. It may be:
• `buffer' to pass the response buffer, which will be narrowed
to the response body and decoded according to `DECODE'.
• `binary' to pass the response body as an un-decoded string.
• `string' to pass the response body as a decoded string.
• `response' to pass a `plz-response' structure.
• `file' to pass a temporary filename to which the response body
has been saved without decoding.
• `(file ~FILENAME)' to pass `FILENAME' after having saved the
response body to it without decoding. `FILENAME' must be a
non-existent file; if it exists, it will not be overwritten,
and an error will be signaled.
• A function, which is called in the response buffer with it
narrowed to the response body (suitable for,
e.g. `json-read').
If `DECODE' is non-nil, the response body is decoded
automatically. For binary content, it should be nil. When `AS'
is `binary', `DECODE' is automatically set to nil.
`THEN' is a callback function, whose sole argument is selected
above with `AS'; if the request fails and no `ELSE' function is
given (see below), the argument will be a `plz-error' structure
describing the error. Or `THEN' may be `sync' to make a
synchronous request, in which case the result is returned
directly from this function.
`ELSE' is an optional callback function called when the request
fails (i.e. if curl fails, or if the `HTTP' response has a
non-2xx status code). It is called with one argument, a
`plz-error' structure. If `ELSE' is nil, a `plz-curl-error' or
`plz-http-error' is signaled when the request fails, with a
`plz-error' structure as the error data. For synchronous
requests, this argument is ignored.
`NOTE': In v0.8 of `plz', only one error will be signaled:
`plz-error'. The existing errors, `plz-curl-error' and
`plz-http-error', inherit from `plz-error' to allow applications
to update their code while using v0.7 (i.e. any `condition-case'
forms should now handle only `plz-error', not the other two).
`FINALLY' is an optional function called without argument after
`THEN' or `ELSE', as appropriate. For synchronous requests,
this argument is ignored.
`CONNECT-TIMEOUT' and `TIMEOUT' are a number of seconds that
limit how long it takes to connect to a host and to receive a
response from a host, respectively.
`NOQUERY' is passed to `make-process', which see.
2.3 Queueing
────────────
`plz' provides a simple system for queueing HTTP requests. First,
make a `plz-queue' struct by calling `make-plz-queue'. Then call
`plz-queue' with the struct as the first argument, and the rest of the
arguments being the same as those passed to `plz'. Then call
`plz-run' to run the queued requests.
All of the queue-related functions return the queue as their value,
making them easy to use. For example:
┌────
│ (defvar my-queue (make-plz-queue :limit 2))
│
│ (plz-run
│ (plz-queue my-queue
│ 'get "https://httpbin.org/get?foo=0"
│ :then (lambda (body) (message "%s" body))))
└────
Or:
┌────
│ (let ((queue (make-plz-queue :limit 2
│ :finally (lambda ()
│ (message "Queue empty."))))
│ (urls '("https://httpbin.org/get?foo=0"
│ "https://httpbin.org/get?foo=1")))
│ (plz-run
│ (dolist (url urls queue)
│ (plz-queue queue 'get url
│ :then (lambda (body) (message "%s" body))))))
└────
You may also clear a queue with `plz-clear', which cancels any active
or queued requests and calls their `:else' functions. And
`plz-length' returns the number of a queue's active and queued
requests.
2.4 Tips
────────
⁃ You can customize settings in the `plz' group, but this can only be
used to adjust a few defaults. It's not intended that changing or
binding global variables be necessary for normal operation.
3 Changelog
═══════════
3.1 0.7
───────
*Changes*
⁃ A new error signal, `plz-error', is defined. The existing signals,
`plz-curl-error' and `plz-http-error', inherit from it, so handling
`plz-error' catches both.
*NOTE:* The existing signals, `plz-curl-error' and `plz-http-error',
are hereby deprecated, and they will be removed in v0.8.
Applications should be updated while using v0.7 to only expect
`plz-error'.
*Fixes*
⁃ Significant improvement in reliability by implementing failsafes and
workarounds for Emacs's process-handling code. (See [#3].)
⁃ STDERR output from curl processes is not included in response bodies
(which sometimes happened, depending on Emacs's internal race
conditions). (Fixes [#23].)
⁃ Use `with-local-quit' for synchronous requests (preventing Emacs
from complaining sometimes). (Fixes [#26].)
⁃ Various fixes for `:as 'buffer' result type: decode body when
appropriate; unset multibyte for binary; narrow to body; don't kill
buffer prematurely.
⁃ When clearing a queue, don't try to kill finished processes.
*Internal*
⁃ Response processing now happens outside the process sentinel, so any
errors (e.g. in user callbacks) are not signaled from inside the
sentinel. (This avoids the 2-second pause Emacs imposes in such
cases.)
⁃ Tests run against a local instance of [httpbin] (since the
`httpbin.org' server is often overloaded).
⁃ No buffer-local variables are defined anymore; process properties
are used instead.
[#3] <https://github.com/alphapapa/plz.el/issues/3>
[#23] <https://github.com/alphapapa/plz.el/issues/23>
[#26] <https://github.com/alphapapa/plz.el/issues/26>
[httpbin] <https://github.com/postmanlabs/httpbin>
3.2 0.6
───────
*Additions*
⁃ Function `plz''s `:body' argument now accepts a list like `(file
FILENAME)' to upload a file from disk (by passing the filename to
curl, rather than reading its content into Emacs and sending it to
curl through the pipe).
*Fixes*
⁃ Function `plz''s docstring now mentions that the `:body' argument
may also be a buffer (an intentional feature that was accidentally
undocumented).
⁃ Handle HTTP 3xx redirects when using `:as 'response'.
3.3 0.5.4
─────────
*Fixes*
⁃ Only run queue's `finally' function after queue is empty. (New
features should not be designed and released on a Friday.)
3.4 0.5.3
─────────
*Fixes*
⁃ Move new slot in `plz-queue' struct to end to prevent invalid
byte-compiler expansions for already-compiled applications (which
would require them to be recompiled after upgrading `plz').
3.5 0.5.2
─────────
*Fixes*
⁃ When clearing a queue, only call `plz-queue''s `finally' function
when specified.
3.6 0.5.1
─────────
*Fixes*
⁃ Only call `plz-queue''s `finally' function when specified. (Thanks
to [Dan Oriani] for reporting.)
[Dan Oriani] <https://github.com/redchops>
3.7 0.5
───────
*Additions*
⁃ Struct `plz-queue''s `finally' slot, a function called when the
queue is finished.
3.8 0.4
───────
*Additions*
⁃ Support for HTTP `HEAD' requests. (Thanks to [USHIN, Inc.] for
sponsoring.)
*Changes*
⁃ Allow sending `POST' and `PUT' requests without bodies. ([#16].
Thanks to [Joseph Turner] for reporting. Thanks to [USHIN, Inc.]
for sponsoring.)
*Fixes*
⁃ All 2xx HTTP status codes are considered successful. ([#17].
Thanks to [Joseph Turner] for reporting. Thanks to [USHIN, Inc.]
for sponsoring.)
⁃ Errors are signaled with error data correctly.
*Internal*
⁃ Test suite explicitly tests with both HTTP/1.1 and HTTP/2.
⁃ Test suite also tests with Emacs versions 27.2, 28.1, and 28.2.
[USHIN, Inc.] <https://ushin.org/>
[#16] <https://github.com/alphapapa/plz.el/issues/16>
[Joseph Turner] <https://github.com/josephmturner>
[#17] <https://github.com/alphapapa/plz.el/issues/17>
3.9 0.3
───────
*Additions*
⁃ Handle HTTP proxy headers from Curl. ([#2]. Thanks to [Alan Third]
and [Sawyer Zheng] for reporting.)
*Fixes*
⁃ Replaced words not in Ispell's default dictionaries (so `checkdoc'
linting succeeds).
[#2] <https://github.com/alphapapa/plz.el/issues/2>
[Alan Third] <https://github.com/alanthird>
[Sawyer Zheng] <https://github.com/sawyerzheng>
3.10 0.2.1
──────────
*Fixes*
⁃ Handle when Curl process is interrupted.
3.11 0.2
────────
*Added*
⁃ Simple request queueing.
3.12 0.1
────────
Initial release.
4 Credits
═════════
⁃ Thanks to [Chris Wellons], author of the [Elfeed] feed reader and
the popular blog [null program], for his invaluable advice, review,
and encouragement.
[Chris Wellons] <https://github.com/skeeto>
[Elfeed] <https://github.com/skeeto/elfeed>
[null program] <https://nullprogram.com/>
5 Development
═════════════
Bug reports, feature requests, suggestions — /oh my/!
Note that `plz' is a young library, and its only client so far is
[Ement.el]. There are a variety of HTTP and `curl' features it does
not yet support, since they have not been needed by the author.
Patches are welcome, as long as they include passing tests.
[Ement.el] <https://github.com/alphapapa/ement.el>
5.1 Copyright assignment
────────────────────────
This package is part of [GNU Emacs], being distributed in [GNU ELPA].
Contributions to this project must follow GNU guidelines, which means
that, as with other parts of Emacs, patches of more than a few lines
must be accompanied by having assigned copyright for the contribution
to the FSF. Contributors who wish to do so may contact
[emacs-devel@gnu.org] to request the assignment form.
[GNU Emacs] <https://www.gnu.org/software/emacs/>
[GNU ELPA] <https://elpa.gnu.org/>
[emacs-devel@gnu.org] <mailto:emacs-devel@gnu.org>
6 License
═════════
GPLv3
# * test.yml --- Test Emacs packages using makem.sh on GitHub Actions
# URL: https://github.com/alphapapa/makem.sh
# Version: 0.6-pre
# * Commentary:
# Based on Steve Purcell's examples at
# <https://github.com/purcell/setup-emacs/blob/master/.github/workflows/test.yml>,
# <https://github.com/purcell/package-lint/blob/master/.github/workflows/test.yml>.
# * License:
# 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/>.
# * Code:
name: "CI"
on:
pull_request:
push:
# Comment out this section to enable testing of all branches.
# branches:
# - master
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
emacs_version:
- 26.3
- 27.1
- 27.2
- 28.1
- 28.2
- snapshot
steps:
- uses: purcell/setup-emacs@master
with:
version: ${{ matrix.emacs_version }}
- uses: actions/checkout@v2
- name: Install Ispell
run: |
sudo apt-get install ispell
- name: Initialize sandbox
run: |
SANDBOX_DIR=$(mktemp -d) || exit 1
echo "SANDBOX_DIR=$SANDBOX_DIR" >> $GITHUB_ENV
./makem.sh -vv --sandbox=$SANDBOX_DIR --install-deps --install-linters
- name: Run httpbin with Docker
run: docker run -d -p 80:80 -P kennethreitz/httpbin
# The "all" rule is not used, because it treats compilation warnings
# as failures, so linting and testing are run as separate steps.
- name: Lint
# NOTE: Uncomment this line to treat lint failures as passing so
# the job doesn't show failure. (Enabled for now because
# Emacs 29 indents some cl- forms differently, which
# causes lint-indent to fail, and what matters most is
# that the tests pass.)
continue-on-error: true
run: ./makem.sh -vv --sandbox=$SANDBOX_DIR lint
- name: Test
if: always() # Run test even if linting fails.
run: ./makem.sh -vv --sandbox=$SANDBOX_DIR test
# Local Variables:
# eval: (outline-minor-mode)
# End:
This is mastodon.info, produced by makeinfo version 6.8 from
mastodon.texi.
INFO-DIR-SECTION Emacs
START-INFO-DIR-ENTRY
* Mastodon: (mastodon). Client for Mastodon on ActivityPub networks.
END-INFO-DIR-ENTRY
File: mastodon.info, Node: Top, Next: README, Up: (dir)
* Menu:
* README::
— The Detailed Node Listing —
README
* Installation::
* Usage::
* Dependencies::
* Network compatibility::
* Contributing::
* Supporting ‘mastodon.el’: Supporting mastodonel.
* Contributors::
Installation
* ELPA::
* MELPA::
* Repo::
* Emoji::
* Discover::
Usage
* Logging in to your instance::
* Timelines::
* Composing toots::
* Other commands and account settings::
* Customization::
* Commands and variables index::
* Alternative timeline layout::
* Live-updating timelines mastodon-async-mode::
* Translating toots::
* Bookmarks and ‘mastodon.el’: Bookmarks and mastodonel.
Contributing
* Bug reports::
* Fixes and features::
* Coding style::
File: mastodon.info, Node: README, Prev: Top, Up: Top
1 README
********
‘mastodon.el’ is an Emacs client for the AcitivityPub social networks
that implement the Mastodon API. For info see joinmastodon.org
(https://joinmastodon.org/).
* Menu:
* Installation::
* Usage::
* Dependencies::
* Network compatibility::
* Contributing::
* Supporting ‘mastodon.el’: Supporting mastodonel.
* Contributors::
File: mastodon.info, Node: Installation, Next: Usage, Up: README
1.1 Installation
================
You can install ‘mastodon.el’ from ELPA, MELPA, or directly from this
repo. It is also available as a GUIX package.
* Menu:
* ELPA::
* MELPA::
* Repo::
* Emoji::
* Discover::
File: mastodon.info, Node: ELPA, Next: MELPA, Up: Installation
1.1.1 ELPA
----------
You should be able to directly install with:
‘M-x package-refresh-contents RET’
‘M-x package-install RET mastodon RET’
File: mastodon.info, Node: MELPA, Next: Repo, Prev: ELPA, Up: Installation
1.1.2 MELPA
-----------
Add ‘MELPA’ to your archives:
(require 'package)
(add-to-list 'package-archives
'("melpa" . "http://melpa.org/packages/") t)
Update and install:
‘M-x package-refresh-contents RET’
‘M-x package-install RET mastodon RET’
File: mastodon.info, Node: Repo, Next: Emoji, Prev: MELPA, Up: Installation
1.1.3 Repo
----------
Clone this repository and add the lisp directory to your load path.
Then, require it and go.
(add-to-list 'load-path "/path/to/mastodon.el/lisp")
(require 'mastodon)
Or, with ‘use-package’:
(use-package mastodon
:ensure t)
The minimum Emacs version is now 27.1. But if you are running an
older version it shouldn’t be very hard to get it working.
File: mastodon.info, Node: Emoji, Next: Discover, Prev: Repo, Up: Installation
1.1.4 Emoji
-----------
‘mastodon-mode’ will enable Emojify
(https://github.com/iqbalansari/emacs-emojify) if it is loaded in your
Emacs environment, so there’s no need to write your own hook anymore.
‘emojify-mode’ is not required.
File: mastodon.info, Node: Discover, Prev: Emoji, Up: Installation
1.1.5 Discover
--------------
‘mastodon-mode’ can provide a context menu for its keybindings if
Discover (https://github.com/mickeynp/discover.el) is installed. It is
not required.
if you have Discover, add the following to your Emacs init
configuration:
(require 'mastodon-discover)
(with-eval-after-load 'mastodon (mastodon-discover))
Or, with ‘use-package’:
(use-package mastodon
:ensure t
:config
(mastodon-discover))
File: mastodon.info, Node: Usage, Next: Dependencies, Prev: Installation, Up: README
1.2 Usage
=========
* Menu:
* Logging in to your instance::
* Timelines::
* Composing toots::
* Other commands and account settings::
* Customization::
* Commands and variables index::
* Alternative timeline layout::
* Live-updating timelines mastodon-async-mode::
* Translating toots::
* Bookmarks and ‘mastodon.el’: Bookmarks and mastodonel.
File: mastodon.info, Node: Logging in to your instance, Next: Timelines, Up: Usage
1.2.1 Logging in to your instance
---------------------------------
You need to set 2 variables in your init file to get started:
1. ‘mastodon-instance-url’
2. ‘mastodon-active-user’
(see their doc strings for details). For example If you want to post
toots as "example_user@social.instance.org", then put this in your init
file:
(setq mastodon-instance-url "https://social.instance.org"
mastodon-active-user "example_user")
Then *restart* Emacs and run ‘M-x mastodon’. Make sure you are
connected to internet before you do this. If you have multiple mastodon
accounts you can activate one at a time by changing those two variables
and restarting Emacs.
If you were using mastodon.el before 2FA was implemented and the
above steps do not work, delete the old file specified by
‘mastodon-client--token-file’ and restart Emacs and follow the steps
again.
File: mastodon.info, Node: Timelines, Next: Composing toots, Prev: Logging in to your instance, Up: Usage
1.2.2 Timelines
---------------
‘M-x mastodon’
Opens a ‘*mastodon-home*’ buffer in the major mode and displays
toots. If your credentials are not yet saved, you will be prompted for
email and password. The app registration process will take place if
your ‘mastodon-token-file’ does not contain ‘:client_id’ and
‘:client_secret’.
1. Keybindings
Key Action
-----------------------------------------------------------------------------------------------------------
*Help*
‘?’ Show discover menu of all bindings, if ‘discover’ is available
*Timeline actions*
‘n’ Go to next item (toot, notification, user)
‘p’ Go to previous item (toot, notification, user)
‘M-n=/=<tab>’ Go to the next interesting thing that has an action
‘M-p=/=<S-tab>’ Go to the previous interesting thing that has an action
‘F’ Open federated timeline (1 prefix arg: hide-replies, 2 prefix args: media only)
‘H’ Open home timeline (1 prefix arg: hide-replies)
‘L’ Open local timeline (1 prefix arg: hide-replies, 2 prefix args: media only)
‘N’ Open notifications timeline
‘@’ Open mentions-only notifications timeline
‘u’ Update current timeline
‘T’ Open thread for toot at point
‘#’ Prompt for tag and open its timeline
‘A’ Open author profile of toot at point
‘P’ Open profile of user attached to toot at point
‘O’ View own profile
‘U’ update your profile bio note
‘;’ view instance description for toot at point
‘:’ view followed tags and load a tag timeline
‘C-:’ view timeline of all followed tags
‘,’ view favouriters of toot at point
‘.’ view boosters of toot at point
‘/’ switch between mastodon buffers
‘Z’ report user/toot at point to instances moderators
*Other views*
‘s’ search (posts, users, tags) (NB: only posts you have interacted with)
‘I’, ‘c’, ‘d’ view, create, and delete filters
‘R’, ‘a’, ‘j’ view/accept/reject follow requests
‘G’ view follow suggestions
‘V’ view your favourited toots
‘K’ view bookmarked toots
‘X’ view/edit/create/delete lists
‘S’ view your scheduled toots
*Toot actions*
‘t’ Compose a new toot
‘c’ Toggle content warning content
‘b’ Boost toot under ‘point’
‘f’ Favourite toot under ‘point’
‘k’ toggle bookmark of toot at point
‘r’ Reply to toot under ‘point’
‘v’ Vote on poll at point
‘C’ copy url of toot at point
‘C-RET’ play video/gif at point (requires ‘mpv’)
‘e’ edit your toot at point
‘E’ view edits of toot at point
‘i’ (un)pin your toot at point
‘d’ delete your toot at point, and reload current timeline
‘D’ delete and redraft toot at point, preserving reply/CW/visibility
(‘S-C-’) ‘W’, ‘M’, ‘B’ (un)follow, (un)mute, (un)block author of toot at point
*Profile view*
‘C-c C-c’ cycle between statuses, statuses without boosts, followers, and following
‘mastodon-profile--account-account-to-list’ (see lists view)
*Notifications view*
‘a’, ‘j’ accept/reject follow request
‘C-k’ clear notification at point
see ‘mastodon-notifications--get-*’ functions for filtered views
*Quitting*
‘q’ Quit mastodon buffer, leave window open
‘Q’ Quit mastodon buffer and kill window
‘C-M-q’ Quit and kill all mastodon buffers
2. Toot byline legend
Marker Meaning
--------------------------------------------
‘(🔁)’ (or I boosted this toot
‘(B)’)
‘(⭐)’ (or I favourited this toot
‘(F)’)
‘(🔖)’ (or I bookmarked this toot
(‘K’))
File: mastodon.info, Node: Composing toots, Next: Other commands and account settings, Prev: Timelines, Up: Usage
1.2.3 Composing toots
---------------------
‘M-x mastodon-toot’ (or ‘t’ from a mastodon.el buffer) opens a new
buffer/window in ‘text-mode’ and ‘mastodon-toot’ minor mode. Enter the
contents of your toot here. ‘C-c C-c’ sends the toot. ‘C-c C-k’
cancels. Both actions kill the buffer and window. Further keybindings
are displayed in the buffer, and in the following subsection.
Replies preserve visibility status/content warnings, and include
boosters by default.
Server’s max toot length, and attachment previews, are shown.
You can download and use your instance’s custom emoji
(‘mastodon-toot--download-custom-emoji’,
‘mastodon-toot--enable-custom-emoji’).
The compose buffer uses ‘text-mode’ so any configuration you have for
that mode will be enabled. If any of your existing config conflicts
with ‘mastodon-toot’, you can disable it in the
‘mastodon-toot-mode-hook’. For example, the default value of that hook
is as follows:
(add-hook 'mastodon-toot-mode-hook
(lambda ()
(auto-fill-mode -1)))
1. Keybindings
Key Action
-------------------------------------------------
‘C-c C-c’ Send toot
‘C-c C-k’ Cancel toot
‘C-c C-w’ Add content warning
‘C-c C-v’ Change toot visibility
‘C-c C-n’ Add sensitive media/nsfw flag
‘C-c C-a’ Upload attachment(s)
‘C-c !’ Remove all attachments
‘C-c C-e’ Add emoji (if ‘emojify’ installed)
‘C-c C-p’ Create a poll
‘C-c C-l’ Set toot language
2. Autocompletion of mentions and tags
Autocompletion of mentions and tags is provided by
‘completion-at-point-functions’ (capf) backends.
‘mastodon-toot--enable-completion’ is enabled by default. If you
want to enable ‘company-mode’ in the toot compose buffer, set
‘mastodon-toot--use-company-for-completion’ to ‘t’. (‘mastodon.el’
used to run its own native company backends, but these have been
removed in favour of capfs.)
If you don’t run ‘company’ and want immediate, keyless completion,
you’ll need to have another completion engine running that handles
capfs. A common combination is ‘consult’ and ‘corfu’.
3. Draft toots
• Compose buffer text is saved as you type, kept in
‘mastodon-toot-current-toot-text’.
• ‘mastodon-toot--save-draft’: save the current toot as a draft.
• ‘mastodon-toot--open-draft-toot’: Open a compose buffer and
insert one of your draft toots.
• ‘mastodon-toot--delete-draft-toot’: Delete a draft toot.
• ‘mastodon-toot--delete-all-drafts’: Delete all your drafts.
File: mastodon.info, Node: Other commands and account settings, Next: Customization, Prev: Composing toots, Up: Usage
1.2.4 Other commands and account settings:
------------------------------------------
In addition to ‘mastodon’, the following three functions are autoloaded
and should work without first loading ‘mastodon.el’:
• ‘mastodon-toot’: Compose new toot
• ‘mastodon-notifications-get’: View all notifications
• ‘mastodon-url-lookup’: Attempt to load a URL in ‘mastodon.el’. URL
may be at point or provided in the minibuffer.
• ‘mastodon-tl--view-instance-description’: View information about
the instance that the author of the toot at point is on.
• ‘mastodon-tl--view-own-instance’: View information about your own
instance.
• ‘mastodon-search--trending-tags’: View a list of trending hashtags
on your instance.
• ‘mastodon-search--trending-statuses’: View a list of trending
statuses on your instance.
• ‘mastodon-tl--add-toot-account-at-point-to-list’: Add the account
of the toot at point to a list.
• ‘mastodon-tl--dm-user’: Send a direct message to one of the users
at point.
• ‘mastodon-profile--add-private-note-to-account’: Add a private note
to another user’s account.
• ‘mastodon-profile--view-account-private-note’: View a private note
on a user’s account.
• ‘mastodon-profile--show-familiar-followers’: Show a list of
“familiar followers” for a given account. Familiar followers are
accounts that you follow, and that follow the account.
• ‘mastodon-tl--follow-tag’: Follow a tag (works like following a
user)
• ‘mastodon-tl--unfollow-tag’: Unfollow a tag
• ‘mastodon-tl--list-followed-tags’: View a list of tags you’re
following.
• ‘mastodon-tl--followed-tags-timeline’: View a timeline of all your
followed tags.
• ‘mastodon-tl--some-followed-tags-timleine’: View a timeline of
multiple tags, from your followed tags or any other.
• ‘mastodon-switch-to-buffer’: switch between mastodon buffers.
• ‘mastodon-profile--update-display-name’: Update the display name
for your account.
• ‘mastodon-profile--update-user-profile-note’: Update your bio note.
• ‘mastodon-profile--update-meta-fields’: Update your metadata
fields.
• ‘mastodon-profile--set-default-toot-visibility’: Set the default
visibility for your toots.
• ‘mastodon-profile--account-locked-toggle’: Toggle the locked status
of your account. Locked accounts have to manually approve follow
requests.
• ‘mastodon-profile--account-discoverable-toggle’: Toggle the
discoverable status of your account. Non-discoverable accounts are
not listed in the profile directory.
• ‘mastodon-profile--account-bot-toggle’: Toggle whether your account
is flagged as a bot.
• ‘mastodon-profile--account-sensitive-toggle’: Toggle whether your
posts are marked as sensitive (nsfw) by default.
File: mastodon.info, Node: Customization, Next: Commands and variables index, Prev: Other commands and account settings, Up: Usage
1.2.5 Customization
-------------------
See ‘M-x customize-group RET mastodon’ to view all customize options.
• Timeline options:
• Use proportional fonts
• Default number of posts displayed
• Timestamp format
• Relative timestamps
• Display user avatars
• Avatar image height
• Enable image caching
• Hide replies in timelines
• Show toot stats in byline
• Compose options:
• Completion style for mentions and tags
• Enable custom emoji
• Display toot being replied to
• Set default reply visibility
File: mastodon.info, Node: Commands and variables index, Next: Alternative timeline layout, Prev: Customization, Up: Usage
1.2.6 Commands and variables index
----------------------------------
An index of all user-facing commands and custom variables is available
here: mastodon-index.org (mastodon-index.org).
File: mastodon.info, Node: Alternative timeline layout, Next: Live-updating timelines mastodon-async-mode, Prev: Commands and variables index, Up: Usage
1.2.7 Alternative timeline layout
---------------------------------
The incomparable Nicholas Rougier has written an alternative timeline
layout for ‘mastodon.el’.
The repo is at mastodon-alt
(https://github.com/rougier/mastodon-alt).
File: mastodon.info, Node: Live-updating timelines mastodon-async-mode, Next: Translating toots, Prev: Alternative timeline layout, Up: Usage
1.2.8 Live-updating timelines: ‘mastodon-async-mode’
----------------------------------------------------
(code taken from mastodon-future
(https://github.com/alexjgriffith/mastodon-future.el).)
Works for federated, local, and home timelines and for notifications.
It’s a little touchy, one thing to avoid is trying to load a timeline
more than once at a time. It can go off the rails a bit, but it’s still
pretty cool. The current maintainer of ‘mastodon.el’ is unable to debug
or improve this feature.
To enable, it, add ‘(require 'mastodon-async)’ to your ‘init.el’.
Then you can view a timeline with one of the commands that begin with
‘mastodon-async--stream-’.
File: mastodon.info, Node: Translating toots, Next: Bookmarks and mastodonel, Prev: Live-updating timelines mastodon-async-mode, Up: Usage
1.2.9 Translating toots
-----------------------
You can translate toots with ‘mastodon-toot--translate-toot-text’ (‘a’
in a timeline). At the moment this requires lingva.el
(https://codeberg.org/martianh/lingva.el), a little interface I wrote to
lingva.ml (https://lingva.ml), to be installed to work.
You could easily modify the simple function to use your Emacs
translator of choice (‘libretrans.el’ , ‘google-translate’, ‘babel’,
‘go-translate’, etc.), you just need to fetch the toot’s content with
‘(mastodon-tl--content toot)’ and pass it to your translator function as
its text argument. Here’s what ‘mastodon-toot--translate-toot-text’
looks like:
(defun mastodon-toot--translate-toot-text ()
"Translate text of toot at point.
Uses `lingva.el'."
(interactive)
(let* ((toot (mastodon-tl--property 'item-json)))
(if toot
(lingva-translate nil (mastodon-tl--content toot))
(message "No toot to translate?"))))
File: mastodon.info, Node: Bookmarks and mastodonel, Prev: Translating toots, Up: Usage
1.2.10 Bookmarks and ‘mastodon.el’
----------------------------------
‘mastodon.el’ doesn’t currently implement its own bookmark record and
handler, which means that emacs bookmarks will not work as is. Until we
implement them, you can get bookmarks going immediately by using
bookmark+.el
(https://github.com/emacsmirror/emacswiki.org/blob/master/bookmark%2b.el).
File: mastodon.info, Node: Dependencies, Next: Network compatibility, Prev: Usage, Up: README
1.3 Dependencies
================
Hard dependencies (should all install with ‘mastodon.el’):
• ‘request’ (for uploading attachments), emacs-request
(https://github.com/tkf/emacs-request)
• ‘persist’ for storing some settings across sessions
Optional dependencies (install yourself, ‘mastodon.el’ can use them):
• ‘emojify’ for inserting and viewing emojis
• ‘mpv’ and ‘mpv.el’ for viewing videos and gifs
• ‘lingva.el’ for translating toots
File: mastodon.info, Node: Network compatibility, Next: Contributing, Prev: Dependencies, Up: README
1.4 Network compatibility
=========================
‘mastodon.el’ should work with ActivityPub servers that implement the
Mastodon API.
Apart from Mastodon itself, it is currently known to work with:
• Pleroma (pleroma.social (https://pleroma.social/))
• Akkoma (akkoma.social (https://akkoma.social/))
• Gotosocial (gotosocial.org (https://gotosocial.org/))
It does not support the non-Mastodon API servers Misskey (misskey.io
(https://misskey.io/)), Firefish (joinfirefish.org
(https://joinfirefish.org/), formerly Calkey) and Friendica, but it
should fully support displaying and interacting with posts and users on
those platforms.
If you attempt to use ‘mastodon.el’ with a server and run into
problems, feel free to open an issue.
File: mastodon.info, Node: Contributing, Next: Supporting mastodonel, Prev: Network compatibility, Up: README
1.5 Contributing
================
PRs, issues, feature requests, and general feedback are very welcome!
* Menu:
* Bug reports::
* Fixes and features::
* Coding style::
File: mastodon.info, Node: Bug reports, Next: Fixes and features, Up: Contributing
1.5.1 Bug reports
-----------------
1. ‘mastodon.el’ has bugs, as well as lots of room for improvement.
2. I receive very little feedback, so if I don’t run into the bug it
often doesn’t get fixed.
3. If you run into something that seems broken, first try running
‘mastodon.el’ in emacs with no init file (i.e. ‘emacs -q’
(instructions and code for doing this are here
(https://codeberg.org/martianh/mastodon.el/issues/300)) to see if
it also happens independently of your own config (it probably
does).
4. Enable debug on error (‘toggle-debug-on-error’), make the bug
happen again, and copy the backtrace that appears.
5. Open an issue here and explain what is going on. Provide your
emacs version and what kind of server your account is on.
File: mastodon.info, Node: Fixes and features, Next: Coding style, Prev: Bug reports, Up: Contributing
1.5.2 Fixes and features
------------------------
1. Create an issue (https://codeberg.org/martianh/mastodon.el/issues)
detailing what you’d like to do.
2. Fork the repository and create a branch off of ‘develop’.
3. Run the tests and ensure that your code doesn’t break any of them.
4. Create a pull request referencing the issue created in step 1.
File: mastodon.info, Node: Coding style, Prev: Fixes and features, Up: Contributing
1.5.3 Coding style
------------------
• This library uses an unconvential double dash (‘--’) between file
namespaces and function names, which contradicts normal Elisp
style. This needs to be respected until the whole library is
changed.
• Use ‘aggressive-indent-mode’ or similar to keep your code indented.
• Single spaces end sentences in docstrings.
• There’s no need for a blank line after the first docstring line
(one is added automatically when documentation is displayed).
File: mastodon.info, Node: Supporting mastodonel, Next: Contributors, Prev: Contributing, Up: README
1.6 Supporting ‘mastodon.el’
============================
If you’d like to support continued development of ‘mastodon.el’, I
accept donations via paypal: paypal.me/martianh
(https://paypal.me/martianh). If you would prefer a different payment
method, please write to me at <martianhiatus [at] riseup [dot] net> and
I can provide IBAN or other bank account details.
I don’t have a tech worker’s income, so even a small tip would help
out.
File: mastodon.info, Node: Contributors, Prev: Supporting mastodonel, Up: README
1.7 Contributors
================
‘mastodon.el’ is the work of a number of people.
Some significant contributors are:
• <https://github.com/jdenen> [original author]
• <http://atomized.org>
• <https://alexjgriffith.itch.io>
• <https://github.com/hdurer>
• <https://codeberg.org/Red_Starfish>
Tag Table:
Node: Top210
Node: README962
Node: Installation1378
Node: ELPA1667
Node: MELPA1895
Node: Repo2275
Node: Emoji2768
Node: Discover3099
Node: Usage3651
Node: Logging in to your instance4094
Node: Timelines5091
Ref: Keybindings5566
Ref: Toot byline legend10139
Node: Composing toots10448
Ref: Keybindings (1)11687
Ref: Autocompletion of mentions and tags12205
Ref: Draft toots12918
Node: Other commands and account settings13389
Node: Customization16547
Node: Commands and variables index17334
Node: Alternative timeline layout17654
Node: Live-updating timelines mastodon-async-mode18059
Node: Translating toots18911
Node: Bookmarks and mastodonel20093
Node: Dependencies20565
Node: Network compatibility21175
Node: Contributing22057
Node: Bug reports22346
Node: Fixes and features23252
Node: Coding style23735
Node: Supporting mastodonel24359
Node: Contributors24926
End Tag Table
Local Variables:
coding: utf-8
End:
;;; mastodon.el --- Client for fediverse services using the Mastodon API -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Copyright (C) 2021 Abhiseck Paira <abhiseckpaira@disroot.org>
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.12
;; Package-Requires: ((emacs "27.1") (request "0.3.0") (persist "0.4"))
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon.el is a client for fediverse services that implement the Mastodon
;; API. See <https://github.com/mastodon/mastodon>.
;; See the readme file at https://codeberg.org/martianh/mastodon.el for set up
;; and usage details.
;;; Code:
(require 'cl-lib) ; for `cl-some' call in mastodon
(eval-when-compile (require 'subr-x))
(require 'mastodon-http)
(require 'mastodon-toot)
(require 'mastodon-search)
(require 'url)
(require 'thingatpt)
(require 'shr)
(declare-function discover-add-context-menu "discover")
(declare-function emojify-mode "emojify")
(declare-function request "request")
(autoload 'mastodon-auth--get-account-name "mastodon-auth")
(autoload 'mastodon-auth--user-acct "mastodon-auth")
(autoload 'mastodon-discover "mastodon-discover")
(autoload 'mastodon-notifications--follow-request-accept "mastodon-notifications")
(autoload 'mastodon-notifications--follow-request-reject "mastodon-notifications")
(autoload 'mastodon-notifications--get-mentions "mastodon-notifications")
(autoload 'mastodon-notifications--timeline "mastodon-notifications")
(autoload 'mastodon-profile--fetch-server-account-settings "mastodon-profile")
(autoload 'mastodon-profile--get-toot-author "mastodon-profile")
(autoload 'mastodon-profile--make-author-buffer "mastodon-profile")
(autoload 'mastodon-profile--my-profile "mastodon-profile")
(autoload 'mastodon-profile--show-user "mastodon-profile")
(autoload 'mastodon-profile--update-user-profile-note "mastodon-profile")
(autoload 'mastodon-profile--view-bookmarks "mastodon-profile")
(autoload 'mastodon-profile--view-favourites "mastodon-profile")
(autoload 'mastodon-tl--block-user "mastodon-tl")
(autoload 'mastodon-tl--follow-user "mastodon-tl")
(autoload 'mastodon-tl--followed-tags-timeline "mastodon-tl")
(autoload 'mastodon-tl--get-buffer-type "mastodon-tl")
(autoload 'mastodon-tl--get-federated-timeline "mastodon-tl")
(autoload 'mastodon-tl--get-home-timeline "mastodon-tl")
(autoload 'mastodon-tl--get-local-timeline "mastodon-tl")
(autoload 'mastodon-tl--get-tag-timeline "mastodon-tl")
(autoload 'mastodon-tl--goto-next-item "mastodon-tl")
(autoload 'mastodon-tl--goto-prev-item "mastodon-tl")
(autoload 'mastodon-tl--init-sync "mastodon-tl")
(autoload 'mastodon-tl--list-followed-tags "mastodon-tl")
(autoload 'mastodon-tl--mute-user "mastodon-tl")
(autoload 'mastodon-tl--next-tab-item "mastodon-tl")
(autoload 'mastodon-tl--poll-vote "mastodon-http")
(autoload 'mastodon-tl--previous-tab-item "mastodon-tl")
(autoload 'mastodon-tl--thread "mastodon-tl")
(autoload 'mastodon-tl--toggle-spoiler-text-in-toot "mastodon-tl")
(autoload 'mastodon-tl--unblock-user "mastodon-tl")
(autoload 'mastodon-tl--unfollow-user "mastodon-tl")
(autoload 'mastodon-tl--unmute-user "mastodon-tl")
(autoload 'mastodon-tl--report-to-mods "mastodon-tl")
(autoload 'mastodon-tl--update "mastodon-tl")
(autoload 'mastodon-toot--edit-toot-at-point "mastodon-toot")
(when (require 'lingva nil :no-error)
(autoload 'mastodon-toot--translate-toot-text "mastodon-toot"))
(autoload 'mastodon-toot--view-toot-history "mastodon-tl")
(autoload 'mastodon-views--view-follow-suggestions "mastodon-views")
(autoload 'mastodon-views--view-filters "mastodon-views")
(autoload 'mastodon-views--view-follow-requests "mastodon-views")
(autoload 'mastodon-views--view-instance-description "mastodon-views")
(autoload 'mastodon-views--view-lists "mastodon-views")
(autoload 'mastodon-views--view-scheduled-toots "mastodon-views")
(autoload 'special-mode "simple")
(defvar mastodon-tl--highlight-current-toot)
(defvar mastodon-notifications--map)
(defgroup mastodon nil
"Interface with Mastodon."
:prefix "mastodon-"
:group 'external)
(defcustom mastodon-instance-url "https://mastodon.social"
"Base URL for the Mastodon instance you want to be active.
For example, if your mastodon username is
\"example_user@social.instance.org\", and you want this account
to be active, the value of this variable should be
\"https://social.instance.org\".
Also for completeness, the value of `mastodon-active-user' should
be \"example_user\".
After setting these variables you should restart Emacs for these
changes to take effect."
:type 'string)
(defcustom mastodon-active-user nil
"Username of the active user.
For example, if your mastodon username is
\"example_user@social.instance.org\", and you want this account
to be active, the value of this variable should be
\"example_user\".
Also for completeness, the value of `mastodon-instance-url'
should be \"https://social.instance.org\".
After setting these variables you should restart Emacs for these
changes to take effect."
:type 'string)
(defcustom mastodon-toot-timestamp-format "%F %T"
"Format to use for timestamps.
For valid formatting options see `format-time-string`.
The default value \"%F %T\" prints ISO8601-style YYYY-mm-dd HH:MM:SS.
Use. e.g. \"%c\" for your locale's date and time format."
:type 'string)
(defvar mastodon-mode-map
(let ((map (make-sparse-keymap)))
;; navigation inside a timeline
(define-key map (kbd "n") #'mastodon-tl--goto-next-item)
(define-key map (kbd "p") #'mastodon-tl--goto-prev-item)
(define-key map (kbd "M-n") #'mastodon-tl--next-tab-item)
(define-key map (kbd "M-p") #'mastodon-tl--previous-tab-item)
(define-key map [?\t] #'mastodon-tl--next-tab-item)
(define-key map [backtab] #'mastodon-tl--previous-tab-item)
(define-key map [?\S-\t] #'mastodon-tl--previous-tab-item)
(define-key map [?\M-\t] #'mastodon-tl--previous-tab-item)
(define-key map (kbd "l") #'recenter-top-bottom)
;; navigation between timelines
(define-key map (kbd "#") #'mastodon-tl--get-tag-timeline)
(define-key map (kbd "\"") #'mastodon-tl--list-followed-tags)
(define-key map (kbd "'") #'mastodon-tl--followed-tags-timeline)
(define-key map (kbd "A") #'mastodon-profile--get-toot-author)
(define-key map (kbd "F") #'mastodon-tl--get-federated-timeline)
(define-key map (kbd "H") #'mastodon-tl--get-home-timeline)
(define-key map (kbd "L") #'mastodon-tl--get-local-timeline)
(define-key map (kbd "N") #'mastodon-notifications-get)
(define-key map (kbd "@") #'mastodon-notifications--get-mentions)
(define-key map (kbd "P") #'mastodon-profile--show-user)
(define-key map (kbd "s") #'mastodon-search--query)
(define-key map (kbd "/") #'mastodon-switch-to-buffer)
;; quitting mastodon
(define-key map (kbd "q") #'kill-current-buffer)
(define-key map (kbd "Q") #'kill-buffer-and-window)
(define-key map (kbd "M-C-q") #'mastodon-kill-all-buffers)
;; toot actions
(define-key map (kbd "c") #'mastodon-tl--toggle-spoiler-text-in-toot)
(define-key map (kbd "b") #'mastodon-toot--toggle-boost)
(define-key map (kbd "f") #'mastodon-toot--toggle-favourite)
(define-key map (kbd "k") #'mastodon-toot--toggle-bookmark)
(define-key map (kbd "r") #'mastodon-toot--reply)
(define-key map (kbd "C") #'mastodon-toot--copy-toot-url)
(define-key map (kbd "v") #'mastodon-tl--poll-vote)
(define-key map (kbd "E") #'mastodon-toot--view-toot-edits)
(define-key map (kbd "T") #'mastodon-tl--thread)
(define-key map (kbd "m") #'mastodon-tl--dm-user)
(when (require 'lingva nil :no-error)
(define-key map (kbd "a") #'mastodon-toot--translate-toot-text))
(define-key map (kbd ",") #'mastodon-toot--list-toot-favouriters)
(define-key map (kbd ".") #'mastodon-toot--list-toot-boosters)
(define-key map (kbd ";") #'mastodon-views--view-instance-description)
;; override special mode binding
(define-key map (kbd "g") #'undefined)
(define-key map (kbd "g") #'mastodon-tl--update)
;; this is now duplicated by 'g', cd remove/use for else:
(define-key map (kbd "u") #'mastodon-tl--update)
;; own toot actions:
(define-key map (kbd "t") #'mastodon-toot)
(define-key map (kbd "d") #'mastodon-toot--delete-toot)
(define-key map (kbd "D") #'mastodon-toot--delete-and-redraft-toot)
(define-key map (kbd "i") #'mastodon-toot--pin-toot-toggle)
(define-key map (kbd "e") #'mastodon-toot--edit-toot-at-point)
;; user actions
(define-key map (kbd "W") #'mastodon-tl--follow-user)
(define-key map (kbd "C-S-W") #'mastodon-tl--unfollow-user)
(define-key map (kbd "B") #'mastodon-tl--block-user)
(define-key map (kbd "C-S-B") #'mastodon-tl--unblock-user)
(define-key map (kbd "M") #'mastodon-tl--mute-user)
(define-key map (kbd "C-S-M") #'mastodon-tl--unmute-user)
(define-key map (kbd "Z") #'mastodon-tl--report-to-mods)
;; own profile
(define-key map (kbd "O") #'mastodon-profile--my-profile)
(define-key map (kbd "U") #'mastodon-profile--update-user-profile-note)
(define-key map (kbd "V") #'mastodon-profile--view-favourites)
(define-key map (kbd "K") #'mastodon-profile--view-bookmarks)
;; minor views
(define-key map (kbd "R") #'mastodon-views--view-follow-requests)
(define-key map (kbd "S") #'mastodon-views--view-scheduled-toots)
(define-key map (kbd "I") #'mastodon-views--view-filters)
(define-key map (kbd "G") #'mastodon-views--view-follow-suggestions)
(define-key map (kbd "X") #'mastodon-views--view-lists)
(define-key map (kbd "SPC") #'mastodon-tl--scroll-up-command)
map)
"Keymap for `mastodon-mode'.")
(defcustom mastodon-mode-hook nil
"Hook run when entering Mastodon mode."
:type 'hook
:options '(provide-discover-context-menu))
(defface mastodon-handle-face
'((t :inherit default))
"Face used for user handles in bylines.")
(defface mastodon-display-name-face
'((t :inherit warning))
"Face used for user display names.")
(defface mastodon-boosted-face
'((t :inherit success :weight bold))
"Face to indicate that a toot is boosted.")
(defface mastodon-boost-fave-face
'((t :inherit success))
"Face to indicate that you have boosted or favourited a toot.")
(defface mastodon-cw-face
'((t :inherit success))
"Face used for content warning.")
(defface mastodon-toot-docs-face
`((t :inherit font-lock-comment-face))
"Face used for documentation in toot compose buffer.
If `mastodon-tl--enable-proportional-fonts' is changed,
mastodon.el needs to be re-loaded for this to be correctly set.")
(defface mastodon-toot-docs-reply-text-face
`((t :inherit font-lock-comment-face
:family ,(face-attribute 'variable-pitch :family)))
"Face used for reply text in toot compose buffer.
See `mastodon-toot-display-orig-in-reply-buffer'.")
(defface mastodon-cursor-highlight-face
`((t :inherit highlight :extend t))
"Face for `mastodon-tl--highlight-current-toot'.")
;;;###autoload
(defun mastodon ()
"Connect Mastodon client to `mastodon-instance-url' instance."
(interactive)
(let* ((tls (list "home"
"local"
"federated"
(concat (mastodon-auth--user-acct) "-statuses") ; own profile
"favourites"
"search"))
(buffer (or (cl-some (lambda (el)
(get-buffer (concat "*mastodon-" el "*")))
tls) ; return first buff that exists
(cl-some (lambda (x)
(when
(string-prefix-p "*mastodon-" (buffer-name x))
(get-buffer x)))
(buffer-list))))) ; catch any other masto buffer
(mastodon-return-credential-account :force)
(if buffer
(switch-to-buffer buffer)
(mastodon-tl--get-home-timeline)
(message "Loading Mastodon account %s on %s..."
(mastodon-auth--user-acct)
mastodon-instance-url))))
(defvar mastodon-profile-credential-account nil)
(defun mastodon-return-credential-account (&optional force)
"Return the CredentialAccount entity.
Either from `mastodon-profile-credential-account' or from the
server.
FORCE means to fetch from the server and update
`mastodon-profile-credential-account'."
(let ((req '(mastodon-http--get-json
(mastodon-http--api "accounts/verify_credentials")
nil :silent)))
(if force
(setq mastodon-profile-credential-account
(eval req))
(or mastodon-profile-credential-account
(setq mastodon-profile-credential-account
(eval req))))))
;;;###autoload
(defun mastodon-toot (&optional user reply-to-id reply-json)
"Update instance with new toot. Content is captured in a new buffer.
If USER is non-nil, insert after @ symbol to begin new toot.
If REPLY-TO-ID is non-nil, attach new toot to a conversation.
If REPLY-JSON is the json of the toot being replied to."
(interactive)
(mastodon-toot--compose-buffer user reply-to-id reply-json))
;;;###autoload
(defun mastodon-notifications-get (&optional type buffer-name force)
"Display NOTIFICATIONS in buffer.
Optionally only print notifications of type TYPE, a string.
BUFFER-NAME is added to \"*mastodon-\" to create the buffer name.
FORCE means do not try to update an existing buffer, but fetch
from the server and load anew."
(interactive)
(let ((buffer (if buffer-name
(concat "*mastodon-" buffer-name "*")
"*mastodon-notifications*")))
(if (and (not force)
(get-buffer buffer))
(progn (switch-to-buffer buffer)
(mastodon-tl--update))
(message "Loading your notifications...")
(mastodon-tl--init-sync (or buffer-name "notifications")
"notifications"
'mastodon-notifications--timeline
type)
(with-current-buffer buffer
(use-local-map mastodon-notifications--map)))))
;; URL lookup: should be available even if `mastodon.el' not loaded:
;;;###autoload
(defun mastodon-url-lookup (&optional query-url)
"If a URL resembles a mastodon link, try to load in `mastodon.el'.
Does a WebFinger lookup.
URL can be arg QUERY-URL, or URL at point, or provided by the user.
If a status or account is found, load it in `mastodon.el', if
not, just browse the URL in the normal fashion."
(interactive)
(let* ((query (or query-url
(thing-at-point-url-at-point)
(mastodon-tl--property 'shr-url :no-move)
(read-string "Lookup URL: "))))
(if (not (mastodon--fedi-url-p query))
;; (shr-browse-url query) ; doesn't work (keep our shr keymap)
(browse-url query)
(message "Performing lookup...")
(let* ((url (format "%s/api/v2/search" mastodon-instance-url))
(params `(("q" . ,query)
("resolve" . "t"))) ; webfinger
(response (mastodon-http--get-json url params :silent)))
(cond ((not (seq-empty-p
(alist-get 'statuses response)))
(let* ((statuses (assoc 'statuses response))
(status (seq-first (cdr statuses)))
(status-id (alist-get 'id status)))
(mastodon-tl--thread status-id)))
((not (seq-empty-p
(alist-get 'accounts response)))
(let* ((accounts (assoc 'accounts response))
(account (seq-first (cdr accounts))))
(mastodon-profile--make-author-buffer account)))
(t
(browse-url query)))))))
(defun mastodon--fedi-url-p (query)
"Check if QUERY resembles a fediverse URL."
;; calqued off https://github.com/tuskyapp/Tusky/blob/c8fc2418b8f5458a817bba221d025b822225e130/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
;; thx to Conny Duck!
(let* ((uri-parsed (url-generic-parse-url query))
(query (url-filename uri-parsed)))
(save-match-data
(or (string-match "^/@[^/]+$" query)
(string-match "^/@[^/]+/[[:digit:]]+$" query)
(string-match "^/user[s]?/@?[[:alnum:]]+$" query) ; @: pleroma or soapbox
(string-match "^/notice/[[:alnum:]]+$" query)
(string-match "^/objects/[-a-f0-9]+$" query)
(string-match "^/notes/[a-z0-9]+$" query)
(string-match "^/display/[-a-f0-9]+$" query)
(string-match "^/profile/[[:alpha:]]+$" query)
(string-match "^/p/[[:alpha:]]+/[[:digit:]]+$" query)
(string-match "^/[[:alpha:]]+$" query)
(string-match "^/u/[[:alpha:]]+$" query)
(string-match "^/c/[[:alnum:]]+$" query)
(string-match "^/post/[[:digit:]]+$" query)
(string-match "^/comment/[[:digit:]]+$" query) ; lemmy
(string-match "^/user[s]?/[[:alnum:]]+/statuses/[[:digit:]]+$" query) ; hometown
(string-match "^/notes/[[:alnum:]]+$" query))))) ; misskey post
(defun mastodon-live-buffers ()
"Return a list of open mastodon buffers.
Calls `mastodon-tl--get-buffer-type', which see."
(cl-loop for x in (buffer-list)
when (with-current-buffer x (mastodon-tl--get-buffer-type))
collect (get-buffer x)))
(defun mastodon-buffer-p (&optional buffer)
"Non-nil if BUFFER or `current-buffer' is a mastodon one."
(let ((buf (or buffer (current-buffer))))
(member buf (mastodon-live-buffers))))
(defun mastodon-kill-all-buffers ()
"Kill any and all open mastodon buffers, hopefully."
(interactive)
(let ((mastodon-buffers (mastodon-live-buffers)))
(cl-loop for x in mastodon-buffers
do (kill-buffer x))))
(defun mastodon-switch-to-buffer ()
"Switch to a live mastodon buffer."
(interactive)
(let* ((bufs (mastodon-live-buffers))
(buf-names (mapcar #'buffer-name bufs))
(choice (completing-read "Switch to mastodon buffer: "
buf-names)))
(switch-to-buffer choice)))
(defun mastodon-mode-hook-fun ()
"Function to add to `mastodon-mode-hook'."
(when (require 'emojify nil :noerror)
(emojify-mode t)
(when mastodon-toot--enable-custom-instance-emoji
(mastodon-toot--enable-custom-emoji)))
(mastodon-profile--fetch-server-account-settings)
(when mastodon-tl--highlight-current-toot
(cursor-face-highlight-mode))) ; 29.1
;;;###autoload
(add-hook 'mastodon-mode-hook #'mastodon-mode-hook-fun)
(define-derived-mode mastodon-mode special-mode "Mastodon"
"Major mode for Mastodon, the federated microblogging network."
(read-only-mode 1))
(provide 'mastodon)
;;; mastodon.el ends here
;;; mastodon-views.el --- Minor views functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-views.el provides minor views functions.
;; These are currently lists, follow suggestions, filters, scheduled toots,
;; follow requests, and instance descriptions.
;; It doesn't include favourites, bookmarks, preferences, trending tags, followed tags, toot edits,
;;; Code:
(require 'cl-lib)
(require 'mastodon-http)
(eval-when-compile
(require 'mastodon-tl))
(defvar mastodon-mode-map)
(defvar mastodon-tl--horiz-bar)
(defvar mastodon-tl--timeline-posts-count)
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-tl--init "mastodon-tl")
(autoload 'mastodon-tl--init-sync "mastodon-tl")
(autoload 'mastodon-tl--field "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
(autoload 'mastodon-tl--set-face "mastodon-tl")
(autoload 'mastodon-tl--buffer-type-eq "mastodon-tl")
(autoload 'mastodon-tl--profile-buffer-p "mastodon-tl")
(autoload 'mastodon-tl--goto-first-item "mastodon-tl")
(autoload 'mastodon-tl--do-if-item "mastodon-tl")
(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl")
(autoload 'mastodon-tl--render-text "mastodon-tl")
(autoload 'mastodon-notifications--follow-request-accept "mastodon-notifications")
(autoload 'mastodon-notifications--follow-request-reject "mastodon-notifications")
(autoload 'mastodon-auth--get-account-id "mastodon-auth")
(autoload 'mastodon-toot--iso-to-human "mastodon-toot")
(autoload 'mastodon-toot--schedule-toot "mastodon-toot")
(autoload 'mastodon-toot--compose-buffer "mastodon-toot")
(autoload 'mastodon-toot--set-toot-properties "mastodon-toot")
(autoload 'mastodon-search--propertize-user "mastodon-search")
(autoload 'mastodon-search--insert-users-propertized "mastodon-search")
(autoload 'mastodon-tl--map-alist "mastodon-tl")
(autoload 'mastodon-tl--map-alist-vals-to-alist "mastodon-tl")
;;; KEYMAPS
;; we copy `mastodon-mode-map', as then all timeline functions are
;; available. this is helpful because if a minor view is the only buffer left
;; open, calling `mastodon' will switch to it, but then we will be unable to
;; switch to timlines without closing the minor view.
;; copying the mode map however means we need to avoid/unbind/override any
;; functions that might cause interfere with the minor view.
;; this is not redundant, as while the buffer -init function calls
;; `mastodon-mode', it gets overridden in some but not all cases.
(defvar mastodon-views-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-mode-map)
map)
"Base keymap for minor mastodon views.")
(defvar mastodon-views--view-filters-keymap
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
(define-key map (kbd "d") #'mastodon-views--delete-filter)
(define-key map (kbd "c") #'mastodon-views--create-filter)
(define-key map (kbd "g") #'mastodon-views--view-filters)
map)
"Keymap for viewing filters.")
(defvar mastodon-views--follow-suggestions-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
(define-key map (kbd "g") #'mastodon-views--view-follow-suggestions)
map)
"Keymap for viewing follow suggestions.")
(defvar mastodon-views--view-lists-keymap
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
(define-key map (kbd "D") #'mastodon-views--delete-list)
(define-key map (kbd "C") #'mastodon-views--create-list)
(define-key map (kbd "A") #'mastodon-views--add-account-to-list)
(define-key map (kbd "R") #'mastodon-views--remove-account-from-list)
(define-key map (kbd "E") #'mastodon-views--edit-list)
(define-key map (kbd "g") #'mastodon-views--view-lists)
map)
"Keymap for viewing lists.")
(defvar mastodon-views--list-name-keymap
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'mastodon-views--view-timeline-list-at-point)
(define-key map (kbd "d") #'mastodon-views--delete-list-at-point)
(define-key map (kbd "a") #'mastodon-views--add-account-to-list-at-point)
(define-key map (kbd "r") #'mastodon-views--remove-account-from-list-at-point)
(define-key map (kbd "e") #'mastodon-views--edit-list-at-point)
map)
"Keymap for when point is on list name.")
(defvar mastodon-views--scheduled-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
(define-key map (kbd "r") #'mastodon-views--reschedule-toot)
(define-key map (kbd "c") #'mastodon-views--cancel-scheduled-toot)
(define-key map (kbd "e") #'mastodon-views--edit-scheduled-as-new)
(define-key map (kbd "RET") #'mastodon-views--edit-scheduled-as-new)
map)
"Keymap for when point is on a scheduled toot.")
(defvar mastodon-views--view-follow-requests-keymap
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
;; make reject binding match the binding in notifs view
;; 'r' is then reserved for replying, even tho it is not avail
;; in foll-reqs view
(define-key map (kbd "j") #'mastodon-notifications--follow-request-reject)
(define-key map (kbd "a") #'mastodon-notifications--follow-request-accept)
(define-key map (kbd "g") #'mastodon-views--view-follow-requests)
map)
"Keymap for viewing follow requests.")
;;; GENERAL FUNCTION
(defun mastodon-views--minor-view (view-name insert-fun data)
"Load a minor view named VIEW-NAME.
BINDINGS-STRING is a string explaining the view's local bindings.
INSERT-FUN is the function to call to insert the view's elements.
DATA is the argument to insert-fun, usually JSON returned in a
request.
This function is used as the update-function to
`mastodon-tl--init-sync', which initializes a buffer for us and
provides the JSON data."
;; FIXME: this is not an update function as it inserts a heading and
;; possible bindings string
;; either it should go in init-sync, or possibly in each view function
;; but either way, this function does almost nothing for us.
;; could we call init-sync in here pehaps?
;; (mastodon-search--insert-heading view-name)
;; (when bindings-string
;; (insert (mastodon-tl--set-face (concat "[" bindings-string "]\n\n")
;; 'font-lock-comment-face)))
(if (seq-empty-p data)
(insert (propertize
(format "Looks like you have no %s for now." view-name)
'face 'font-lock-comment-face
'byline t
'item-type 'no-item ; for nav
'item-id "0")) ; so point can move here when no item
(funcall insert-fun data)
(goto-char (point-min)))
;; (when data
;; FIXME: this seems to trigger a new request, but ideally would run.
;; (mastodon-tl--goto-next-item))
)
;;; LISTS
(defun mastodon-views--view-lists ()
"Show the user's lists in a new buffer."
(interactive)
(mastodon-tl--init-sync "lists" "lists"
'mastodon-views--insert-lists
nil nil nil
"your lists"
"C - create a list\n D - delete a list\
\n A/R - add/remove account from a list\
\n E - edit a list\n n/p - go to next/prev item")
(with-current-buffer "*mastodon-lists*"
(use-local-map mastodon-views--view-lists-keymap)))
(defun mastodon-views--insert-lists (json)
"Insert the user's lists from JSON."
(mastodon-views--minor-view
"lists"
#'mastodon-views--print-list-set
json))
(defun mastodon-views--print-list-set (lists)
"Print each account plus a separator for each list in LISTS."
(mapc (lambda (x)
(mastodon-views--print-list-accounts x)
(insert (propertize (concat " " mastodon-tl--horiz-bar "\n\n")
'face 'success)))
lists))
(defun mastodon-views--print-list-accounts (list)
"Insert the accounts in list named LIST, an alist."
(let-alist list
(let* ((accounts (mastodon-views--accounts-in-list .id)))
(insert
(propertize .title
'byline t ; so we nav here
'item-id "0" ; so we nav here
'item-type 'list
'help-echo "RET: view list timeline, d: delete this list, \
a: add account to this list, r: remove account from this list"
'list t
'face 'link
'keymap mastodon-views--list-name-keymap
'list-name .title
'list-id .id)
(propertize (format " [replies: %s, exclusive %s]"
.replies_policy
(when (eq t .exclusive) "true"))
'face 'font-lock-comment-face)
(propertize "\n\n"
'list t
'keymap mastodon-views--list-name-keymap
'list-name .title
'list-id .id)
(propertize
(mapconcat #'mastodon-search--propertize-user accounts
" ")
'list t
'keymap mastodon-views--list-name-keymap
'list-name .title
'list-id .id)))))
(defun mastodon-views--get-users-lists ()
"Get the list of the user's lists from the server."
(let ((url (mastodon-http--api "lists")))
(mastodon-http--get-json url)))
(defun mastodon-views--get-lists-names ()
"Return a list of the user's lists' names."
(let ((lists (mastodon-views--get-users-lists)))
(mastodon-tl--map-alist 'title lists)))
(defun mastodon-views--get-list-by-name (name)
"Return the list data for list with NAME."
(let* ((lists (mastodon-views--get-users-lists)))
(cl-loop for list in lists
if (string= (alist-get 'title list) name)
return list)))
(defun mastodon-views--get-list-id (name)
"Return id for list with NAME."
(let ((list (mastodon-views--get-list-by-name name)))
(alist-get 'id list)))
(defun mastodon-views--get-list-name (id)
"Return name of list with ID."
(let* ((url (mastodon-http--api (format "lists/%s" id)))
(response (mastodon-http--get-json url)))
(alist-get 'title response)))
(defun mastodon-views--edit-list-at-point ()
"Edit list at point."
(interactive)
(let ((id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--edit-list id)))
(defun mastodon-views--edit-list (&optional id)
"Prompt for a list and edit the name and replies policy.
If ID is provided, use that list."
(interactive)
(let* ((list-names (unless id (mastodon-views--get-lists-names)))
(name-old (if id
(mastodon-tl--property 'list-name :no-move)
(completing-read "Edit list: " list-names)))
(id (or id (mastodon-views--get-list-id name-old)))
(name-choice (read-string "List name: " name-old))
(replies-policy (completing-read "Replies policy: " ; give this a proper name
'("followed" "list" "none")
nil t nil nil "list"))
(exclusive (if (y-or-n-p "Exclude items from home timeline? ")
"true"
"false"))
(url (mastodon-http--api (format "lists/%s" id)))
(response (mastodon-http--put url
`(("title" . ,name-choice)
("replies_policy" . ,replies-policy)
("exclusive" . ,exclusive)))))
(mastodon-http--triage response
(lambda (_)
(with-current-buffer response
(let* ((json (mastodon-http--process-json))
(name-new (alist-get 'title json)))
(message "list %s edited to %s!" name-old name-new)))
(when (mastodon-tl--buffer-type-eq 'lists)
(mastodon-views--view-lists))))))
(defun mastodon-views--view-timeline-list-at-point ()
"View timeline of list at point."
(interactive)
(let ((list-id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--view-list-timeline list-id)))
(defun mastodon-views--view-list-timeline (&optional id)
"Prompt for a list and view its timeline.
If ID is provided, use that list."
(interactive)
(let* ((list-names (unless id (mastodon-views--get-lists-names)))
(list-name (unless id (completing-read "View list: " list-names)))
(id (or id (mastodon-views--get-list-id list-name)))
(endpoint (format "timelines/list/%s" id))
(name (mastodon-views--get-list-name id))
(buffer-name (format "list-%s" name)))
(mastodon-tl--init buffer-name endpoint
'mastodon-tl--timeline
nil
`(("limit" . ,mastodon-tl--timeline-posts-count)))))
(defun mastodon-views--create-list ()
"Create a new list.
Prompt for name and replies policy."
(interactive)
(let* ((title (read-string "New list name: "))
(replies-policy (completing-read "Replies policy: " ; give this a proper name
'("followed" "list" "none")
nil t nil nil "list")) ; default
(exclusive (when (y-or-n-p "Exclude items from home timeline? ")
"true"))
(response (mastodon-http--post (mastodon-http--api "lists")
`(("title" . ,title)
("replies_policy" . ,replies-policy)
("exclusive" . ,exclusive)))))
(mastodon-views--list-action-triage
response "list %s created!" title)))
(defun mastodon-views--delete-list-at-point ()
"Delete list at point."
(interactive)
(let ((id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--delete-list id)))
(defun mastodon-views--delete-list (&optional id)
"Prompt for a list and delete it.
If ID is provided, delete that list."
(interactive)
(let* ((list-names (unless id (mastodon-views--get-lists-names)))
(name (if id
(mastodon-views--get-list-name id)
(completing-read "Delete list: " list-names)))
(id (or id (mastodon-views--get-list-id name)))
(url (mastodon-http--api (format "lists/%s" id))))
(when (y-or-n-p (format "Delete list %s?" name))
(let ((response (mastodon-http--delete url)))
(mastodon-views--list-action-triage
response "list %s deleted!" name)))))
(defun mastodon-views--get-users-followings ()
"Return the list of followers of the logged in account."
(let* ((id (mastodon-auth--get-account-id))
(url (mastodon-http--api (format "accounts/%s/following" id))))
(mastodon-http--get-json url '(("limit" . "80"))))) ; max 80 accounts
(defun mastodon-views--add-account-to-list-at-point ()
"Prompt for account and add to list at point."
(interactive)
(let ((id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--add-account-to-list id)))
(defun mastodon-views--add-account-to-list (&optional id account-id handle)
"Prompt for a list and for an account, add account to list.
If ID is provided, use that list.
If ACCOUNT-ID and HANDLE are provided use them rather than prompting."
(interactive)
(let* ((list-prompt (if handle
(format "Add %s to list: " handle)
"Add account to list: "))
(list-name (if id
(mastodon-tl--property 'list-name :no-move)
(completing-read list-prompt
(mastodon-views--get-lists-names) nil t)))
(list-id (or id (mastodon-views--get-list-id list-name)))
(followings (mastodon-views--get-users-followings))
(handles (mastodon-tl--map-alist-vals-to-alist 'acct 'id followings))
(account (or handle (completing-read "Account to add: "
handles nil t)))
(account-id (or account-id (alist-get account handles)))
(url (mastodon-http--api (format "lists/%s/accounts" list-id)))
(response (mastodon-http--post url `(("account_ids[]" . ,account-id)))))
(mastodon-views--list-action-triage
response "%s added to list %s!" account list-name)))
(defun mastodon-views--add-toot-account-at-point-to-list ()
"Prompt for a list, and add the account of the toot at point to it."
(interactive)
(let* ((toot (mastodon-tl--property 'item-json))
(account (mastodon-tl--field 'account toot))
(account-id (mastodon-tl--field 'id account))
(handle (mastodon-tl--field 'acct account)))
(mastodon-views--add-account-to-list nil account-id handle)))
(defun mastodon-views--remove-account-from-list-at-point ()
"Prompt for account and remove from list at point."
(interactive)
(let ((id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--remove-account-from-list id)))
(defun mastodon-views--remove-account-from-list (&optional id)
"Prompt for a list, select an account and remove from list.
If ID is provided, use that list."
(interactive)
(let* ((list-name (if id
(mastodon-tl--property 'list-name :no-move)
(completing-read "Remove account from list: "
(mastodon-views--get-lists-names) nil t)))
(list-id (or id (mastodon-views--get-list-id list-name)))
(accounts (mastodon-views--accounts-in-list list-id))
(handles (mastodon-tl--map-alist-vals-to-alist 'acct 'id accounts))
(account (completing-read "Account to remove: "
handles nil t))
(account-id (alist-get account handles))
(url (mastodon-http--api (format "lists/%s/accounts" list-id)))
(args (mastodon-http--build-array-params-alist "account_ids[]" `(,account-id)))
(response (mastodon-http--delete url args)))
(mastodon-views--list-action-triage
response "%s removed from list %s!" account list-name)))
(defun mastodon-views--list-action-triage (response &rest args)
"Call `mastodon-http--triage' on RESPONSE and call message on ARGS."
(mastodon-http--triage response
(lambda (_)
(when (mastodon-tl--buffer-type-eq 'lists)
(mastodon-views--view-lists))
(apply #'message args))))
(defun mastodon-views--accounts-in-list (list-id)
"Return the JSON of the accounts in list with LIST-ID."
(let* ((url (mastodon-http--api (format "lists/%s/accounts" list-id))))
(mastodon-http--get-json url)))
;;; FOLLOW REQUESTS
(defun mastodon-views--insert-follow-requests (json)
"Insert the user's current follow requests.
JSON is the data returned by the server."
(mastodon-views--minor-view
"follow requests"
#'mastodon-views--insert-users-propertized-note
json))
(defun mastodon-views--view-follow-requests ()
"Open a new buffer displaying the user's follow requests."
(interactive)
(mastodon-tl--init-sync "follow-requests"
"follow_requests"
'mastodon-views--insert-follow-requests
nil
'(("limit" . "40")) ; server max is 80
:headers
"follow requests"
"a/j - accept/reject request at point\n\
n/p - go to next/prev request")
(mastodon-tl--goto-first-item)
(with-current-buffer "*mastodon-follow-requests*"
(use-local-map mastodon-views--view-follow-requests-keymap)))
;;; SCHEDULED TOOTS
(defun mastodon-views--view-scheduled-toots ()
"Show the user's scheduled toots in a new buffer."
(interactive)
(mastodon-tl--init-sync "scheduled-toots"
"scheduled_statuses"
'mastodon-views--insert-scheduled-toots
nil nil nil
"your scheduled toots"
"n/p - prev/next\n r - reschedule\n\
e/RET - edit toot\n c - cancel")
(with-current-buffer "*mastodon-scheduled-toots*"
(use-local-map mastodon-views--scheduled-map)))
(defun mastodon-views--insert-scheduled-toots (json)
"Insert the user's scheduled toots, from JSON."
(mastodon-views--minor-view
"scheduled toots"
#'mastodon-views--insert-scheduled-toots-list
json))
(defun mastodon-views--insert-scheduled-toots-list (scheduleds)
"Insert scheduled toots in SCHEDULEDS."
(mapc #'mastodon-views--insert-scheduled-toot scheduleds))
(defun mastodon-views--insert-scheduled-toot (toot)
"Insert scheduled TOOT into the buffer."
(let-alist toot
(insert
(propertize (concat .params.text
" | "
(mastodon-toot--iso-to-human .scheduled_at))
'byline t ; so we nav here
'item-id "0" ; so we nav here
'face 'font-lock-comment-face
'keymap mastodon-views--scheduled-map
'scheduled-json toot
'id .id)
"\n")))
(defun mastodon-views--get-scheduled-toots (&optional id)
"Get the user's currently scheduled toots.
If ID, just return that toot."
(let* ((endpoint (if id
(format "scheduled_statuses/%s" id)
"scheduled_statuses"))
(url (mastodon-http--api endpoint)))
(mastodon-http--get-json url)))
(defun mastodon-views--reschedule-toot ()
"Reschedule the scheduled toot at point."
(interactive)
(let ((id (mastodon-tl--property 'id :no-move)))
(if (null id)
(message "no scheduled toot at point?")
(mastodon-toot--schedule-toot :reschedule))))
(defun mastodon-views--copy-scheduled-toot-text ()
"Copy the text of the scheduled toot at point."
(interactive)
(let* ((toot (mastodon-tl--property 'toot :no-move))
(params (alist-get 'params toot))
(text (alist-get 'text params)))
(kill-new text)))
(defun mastodon-views--cancel-scheduled-toot (&optional id no-confirm)
"Cancel the scheduled toot at point.
ID is that of the scheduled toot to cancel.
NO-CONFIRM means there is no ask or message, there is only do."
(interactive)
(let ((id (or id (mastodon-tl--property 'id :no-move))))
(if (null id)
(message "no scheduled toot at point?")
(when (or no-confirm
(y-or-n-p "Cancel scheduled toot?"))
(let* ((url (mastodon-http--api (format "scheduled_statuses/%s" id)))
(response (mastodon-http--delete url)))
(mastodon-http--triage response
(lambda (_)
(mastodon-views--view-scheduled-toots)
(unless no-confirm
(message "Toot cancelled!")))))))))
(defun mastodon-views--edit-scheduled-as-new ()
"Edit scheduled status as new toot."
(interactive)
(let ((id (mastodon-tl--property 'id :no-move)))
(if (null id)
(message "no scheduled toot at point?")
(let* ((toot (mastodon-tl--property 'scheduled-json :no-move))
(scheduled (alist-get 'scheduled_at toot)))
(let-alist (alist-get 'params toot)
;; (poll (alist-get 'poll params))
;; (media (alist-get 'media_attachments toot)))
(mastodon-toot--compose-buffer)
(goto-char (point-max))
(insert .text)
;; adopt properties from scheduled toot:
(mastodon-toot--set-toot-properties
.in_reply_to_id .visibility .spoiler_text .language scheduled id))))))
;;; FILTERS
(defun mastodon-views--view-filters ()
"View the user's filters in a new buffer."
(interactive)
(mastodon-tl--init-sync "filters" "filters"
'mastodon-views--insert-filters
nil nil nil
"current filters"
"c - create filter\n d - delete filter at point\n\
n/p - go to next/prev filter")
(with-current-buffer "*mastodon-filters*"
(use-local-map mastodon-views--view-filters-keymap)))
(defun mastodon-views--insert-filters (json)
"Insert the user's current filters.
JSON is what is returned by by the server."
(mastodon-views--minor-view
"filters"
#'mastodon-views--insert-filter-string-set
json))
(defun mastodon-views--insert-filter-string-set (json)
"Insert a filter string plus a blank line.
JSON is the filters data."
(mapc #'mastodon-views--insert-filter-string json))
(defun mastodon-views--insert-filter-string (filter)
"Insert a single FILTER."
(let* ((phrase (alist-get 'phrase filter))
(contexts (alist-get 'context filter))
(id (alist-get 'id filter))
(filter-string (concat "- \"" phrase "\" filtered in: "
(mapconcat #'identity contexts ", "))))
(insert
(propertize filter-string
'item-id id ;for goto-next-filter compat
'phrase phrase
'byline t) ;for goto-next-filter compat
"\n\n")))
(defun mastodon-views--create-filter ()
"Create a filter for a word.
Prompt for a context, must be a list containting at least one of \"home\",
\"notifications\", \"public\", \"thread\"."
(interactive)
(let* ((url (mastodon-http--api "filters"))
(word (read-string
(format "Word(s) to filter (%s): " (or (current-word) ""))
nil nil (or (current-word) "")))
(contexts
(if (string-empty-p word)
(user-error "You must select at least one word for a filter")
(completing-read-multiple
"Contexts to filter [TAB for options]: "
'("home" "notifications" "public" "thread")
nil t)))
(contexts-processed
(if (equal nil contexts)
(user-error "You must select at least one context for a filter")
(mapcar (lambda (x)
(cons "context[]" x))
contexts)))
(response (mastodon-http--post url (push
`("phrase" . ,word)
contexts-processed))))
(mastodon-http--triage response
(lambda (_)
(message "Filter created for %s!" word)
(when (mastodon-tl--buffer-type-eq 'filters)
(mastodon-views--view-filters))))))
(defun mastodon-views--delete-filter ()
"Delete filter at point."
(interactive)
(let* ((filter-id (mastodon-tl--property 'item-id :no-move))
(phrase (mastodon-tl--property 'phrase :no-move))
(url (mastodon-http--api (format "filters/%s" filter-id))))
(if (null phrase)
(user-error "No filter at point?")
(when (y-or-n-p (format "Delete filter %s? " phrase))
(let ((response (mastodon-http--delete url)))
(mastodon-http--triage
response (lambda (_)
(mastodon-views--view-filters)
(message "Filter for \"%s\" deleted!" phrase))))))))
;;; FOLLOW SUGGESTIONS
;; No pagination: max 80 results
(defun mastodon-views--view-follow-suggestions ()
"Display a buffer of suggested accounts to follow."
(interactive)
(mastodon-tl--init-sync "follow-suggestions"
"suggestions"
'mastodon-views--insert-follow-suggestions
nil
'(("limit" . "80")) ; server max
nil
"suggested accounts")
(with-current-buffer "*mastodon-follow-suggestions*"
(use-local-map mastodon-views--follow-suggestions-map)))
(defun mastodon-views--insert-follow-suggestions (json)
"Insert follow suggestions into buffer.
JSON is the data returned by the server."
(mastodon-views--minor-view
"suggested accounts"
#'mastodon-views--insert-users-propertized-note
json))
(defun mastodon-views--insert-users-propertized-note (json)
"Insert users list into the buffer, including profile note.
JSON is the users list data."
(mastodon-search--insert-users-propertized json :note))
;;; INSTANCES
(defun mastodon-views--view-own-instance (&optional brief)
"View details of your own instance.
BRIEF means show fewer details."
(interactive)
(mastodon-views--view-instance-description :user brief))
(defun mastodon-views--view-own-instance-brief ()
"View brief details of your own instance."
(interactive)
(mastodon-views--view-instance-description :user :brief))
(defun mastodon-views--view-instance-description-brief ()
"View brief details of the instance the current post's author is on."
(interactive)
(mastodon-views--view-instance-description nil :brief))
(defun mastodon-views--get-instance-url (url username &optional instance)
"Return an instance base url from a user account URL.
USERNAME is the name to cull.
If INSTANCE is given, use that."
(cond (instance
(concat "https://" instance))
;; pleroma URL is https://instance.com/users/username
((string-suffix-p "users/" (url-basepath url))
(string-remove-suffix "/users/"
(url-basepath url)))
;; friendica is https://instance.com/profile/user
((string-suffix-p "profile/" (url-basepath url))
(string-remove-suffix "/profile/"
(url-basepath url)))
;; mastodon is https://instance.com/@user
(t
(string-remove-suffix (concat "/@" username)
url))))
(defun mastodon-views--view-instance-description
(&optional user brief instance misskey)
"View the details of the instance the current post's author is on.
USER means to show the instance details for the logged in user.
BRIEF means to show fewer details.
INSTANCE is an instance domain name.
MISSKEY means the instance is a Misskey or derived server."
(interactive)
(if user
(let ((response (mastodon-http--get-json
(mastodon-http--api "instance") nil nil :vector)))
(mastodon-views--instance-response-fun response brief instance))
(mastodon-tl--do-if-item
(let* ((toot (if (mastodon-tl--profile-buffer-p)
;; we may be on profile description itself:
(or (mastodon-tl--property 'profile-json)
;; or on profile account listings, or just toots:
(mastodon-tl--property 'item-json))
;; normal timeline/account listing:
(mastodon-tl--property 'item-json)))
(reblog (alist-get 'reblog toot))
(account (or (alist-get 'account reblog)
(alist-get 'account toot)
toot)) ; else `toot' is already an account listing.
;; we may be at toots/boosts/users in a profile buffer.
;; profile-json is a defacto test for if point is on the profile
;; details at the top of a profile buffer.
(profile-note-p (and (mastodon-tl--profile-buffer-p)
;; only call this in profile buffers:
(mastodon-tl--property 'profile-json)))
(url (if profile-note-p
(alist-get 'url toot) ; profile description
(alist-get 'url account)))
(username (if profile-note-p
(alist-get 'username toot) ;; profile
(alist-get 'username account)))
(instance (mastodon-views--get-instance-url url username instance)))
(if misskey
(let* ((params `(("detail" . ,(or brief t))))
(headers '(("Content-Type" . "application/json")))
(url (concat instance "/api/meta"))
(response
(with-current-buffer (mastodon-http--post url params headers t :json)
(mastodon-http--process-response))))
(mastodon-views--instance-response-fun response brief instance :misskey))
(let ((response (mastodon-http--get-json
(concat instance "/api/v1/instance") nil nil :vector)))
;; if non-misskey attempt errors, try misskey instance:
;; akkoma i guess should not error here.
(if (eq 'error (caar response))
(mastodon-views--instance-desc-misskey)
(mastodon-views--instance-response-fun response brief instance))))))))
(defun mastodon-views--instance-desc-misskey (&optional user brief instance)
"Show instance description for a misskey/firefish server.
USER, BRIEF, and INSTANCE are all for
`mastodon-views--view-instance-description', which see."
(interactive)
(mastodon-views--view-instance-description user brief instance :miskey))
(defun mastodon-views--instance-response-fun (response brief instance
&optional misskey)
"Display instance description RESPONSE in a new buffer.
BRIEF means to show fewer details.
INSTANCE is the instance were are working with.
MISSKEY means the instance is a Misskey or derived server."
(when response
(let* ((domain (url-file-nondirectory instance))
(buf (get-buffer-create
(format "*mastodon-instance-%s*" domain))))
(with-mastodon-buffer buf #'special-mode :other-window
(if misskey
(mastodon-views--insert-json response)
(condition-case nil
(progn
(when brief
(setq response
(list (assoc 'uri response)
(assoc 'title response)
(assoc 'short_description response)
(assoc 'email response)
(cons 'contact_account
(list
(assoc 'username
(assoc 'contact_account response))))
(assoc 'rules response)
(assoc 'stats response))))
(mastodon-views--print-json-keys response)
(mastodon-tl--set-buffer-spec (buffer-name buf) "instance" nil)
(goto-char (point-min)))
(error ; just insert the raw response:
(mastodon-views--insert-json response))))))))
(defun mastodon-views--insert-json (response)
"Insert raw JSON RESPONSE in current buffer."
(let ((inhibit-read-only t))
(erase-buffer)
(insert (prin1-to-string response))
(pp-buffer)
(goto-char (point-min))))
(defun mastodon-views--format-key (el pad)
"Format a key of element EL, a cons, with PAD padding."
(format (concat "%-"
(number-to-string pad)
"s: ")
(propertize (prin1-to-string (car el))
'face '(:underline t))))
(defun mastodon-views--print-json-keys (response &optional ind)
"Print the JSON keys and values in RESPONSE.
IND is the optional indentation level to print at."
(let* ((cars (mapcar (lambda (x) (symbol-name (car x)))
response))
(pad (1+ (cl-reduce #'max (mapcar #'length cars)))))
(while response
(let ((el (pop response)))
(cond
((and (vectorp (cdr el)) ; vector of alists (fields, instance rules):
(not (seq-empty-p (cdr el)))
(consp (seq-elt (cdr el) 0)))
(insert (mastodon-views--format-key el pad)
"\n\n")
(seq-do #'mastodon-views--print-instance-rules-or-fields (cdr el))
(insert "\n"))
((and (vectorp (cdr el)) ; vector of strings (media types):
(not (seq-empty-p (cdr el)))
(< 1 (seq-length (cdr el)))
(stringp (seq-elt (cdr el) 0)))
(when ind (indent-to ind))
(insert (mastodon-views--format-key el pad)
"\n"
(seq-mapcat
(lambda (x) (concat x ", "))
(cdr el) 'string)
"\n\n"))
((consp (cdr el)) ; basic nesting:
(when ind (indent-to ind))
(insert (mastodon-views--format-key el pad)
"\n\n")
(mastodon-views--print-json-keys
(cdr el) (if ind (+ ind 4) 4)))
(t ; basic handling of raw booleans:
(let ((val (cond ((equal (cdr el) :json-false)
"no")
((equal (cdr el) 't)
"yes")
(t
(cdr el)))))
(when ind (indent-to ind))
(insert (mastodon-views--format-key el pad)
" "
(mastodon-views--newline-if-long (cdr el))
;; only send strings to --render-text (for hyperlinks):
(mastodon-tl--render-text
(if (stringp val) val (prin1-to-string val)))
"\n"))))))))
(defun mastodon-views--print-instance-rules-or-fields (alist)
"Print ALIST of instance rules or contact account or emoji fields."
(let-alist alist
(let ((key (or .id .name .shortcode))
(value (or .text .value .url)))
(indent-to 4)
(insert (format "%-5s: "
(propertize key 'face '(:underline t)))
(mastodon-views--newline-if-long value)
(format "%s" (mastodon-tl--render-text
value))
"\n"))))
(defun mastodon-views--newline-if-long (el)
"Return a newline string if the cdr of EL is over 50 characters long."
(let ((rend (if (stringp el) (mastodon-tl--render-text el) el)))
(if (and (sequencep rend)
(< 50 (length rend)))
"\n"
"")))
(provide 'mastodon-views)
;;; mastodon-views.el ends here
;;; mastodon-toot.el --- Minor mode for sending Mastodon toots -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-toot.el supports POSTing status data to Mastodon.
;;; Code:
(eval-when-compile (require 'subr-x))
(require 'emojify nil :noerror)
(declare-function emojify-insert-emoji "emojify")
(declare-function emojify-set-emoji-data "emojify")
(defvar emojify-emojis-dir)
(defvar emojify-user-emojis)
(require 'cl-lib)
(require 'persist)
(require 'mastodon-iso)
(require 'facemenu)
(require 'text-property-search)
(eval-when-compile
(require 'mastodon-tl))
(defvar mastodon-instance-url)
(defvar mastodon-tl--buffer-spec)
(defvar mastodon-tl--enable-proportional-fonts)
(defvar mastodon-profile-account-settings)
(autoload 'iso8601-parse "iso8601")
(autoload 'mastodon-auth--user-acct "mastodon-auth")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--build-array-params-alist "mastodon-http")
(autoload 'mastodon-http--delete "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--get-json-async "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(autoload 'mastodon-http--post-media-attachment "mastodon-http")
(autoload 'mastodon-http--process-json "mastodon-http")
(autoload 'mastodon-http--put "mastodon-http")
(autoload 'mastodon-http--read-file-as-string "mastodon-http")
(autoload 'mastodon-http--triage "mastodon-http")
(autoload 'mastodon-profile--fetch-server-account-settings "mastodon-profile")
(autoload 'mastodon-profile--fetch-server-account-settings-maybe "mastodon-profile")
(autoload 'mastodon-profile--get-source-pref "mastodon-profile")
(autoload 'mastodon-profile--show-user "mastodon-profile")
(autoload 'mastodon-profile--update-preference "mastodon-profile")
(autoload 'mastodon-search--search-accounts-query "mastodon-search")
(autoload 'mastodon-search--search-tags-query "mastodon-search")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--buffer-type-eq "mastodon-tl")
(autoload 'mastodon-tl--clean-tabs-and-nl "mastodon-tl")
(autoload 'mastodon-tl--do-if-item-strict "mastodon-tl")
(autoload 'mastodon-tl--field "mastodon-tl")
(autoload 'mastodon-tl--find-property-range "mastodon-tl")
(autoload 'mastodon-tl--find-property-range "mastodon-tl")
(autoload 'mastodon-tl--goto-next-item "mastodon-tl")
(autoload 'mastodon-tl--map-alist "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
(autoload 'mastodon-tl--reload-timeline-or-profile "mastodon-tl")
(autoload 'mastodon-tl--render-text "mastodon-tl")
(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl")
(autoload 'mastodon-tl--symbol "mastodon-tl")
(autoload 'mastodon-tl--item-id "mastodon-tl")
(autoload 'mastodon-toot "mastodon")
(autoload 'mastodon-views--cancel-scheduled-toot "mastodon-views")
(autoload 'mastodon-views--view-scheduled-toots "mastodon-views")
(autoload 'org-read-date "org")
(autoload 'mastodon-tl--toot-or-base "mastodon-tl")
;; for mastodon-toot--translate-toot-text
(autoload 'mastodon-tl--content "mastodon-tl")
(when (require 'lingva nil :no-error)
(declare-function lingva-translate "lingva"))
(defgroup mastodon-toot nil
"Tooting in Mastodon."
:prefix "mastodon-toot-"
:group 'mastodon)
(defcustom mastodon-toot--default-media-directory "~/"
"The default directory when prompting for a media file to upload."
:type 'string)
(defcustom mastodon-toot--attachment-height 80
"Height of the attached images preview in the toot draft buffer."
:type 'integer)
(defcustom mastodon-toot--enable-completion t
"Whether to enable completion of mentions and hashtags.
Used for completion in toot compose buffer."
:type 'boolean)
(defcustom mastodon-toot--use-company-for-completion nil
"Whether to enable company for completion.
When non-nil, `company-mode' is enabled in the toot compose
buffer, and mastodon completion backends are added to
`company-capf'.
You need to install company yourself to use this."
:type 'boolean)
(defcustom mastodon-toot--completion-style-for-mentions "all"
"The company completion style to use for mentions."
:type '(choice
(const :tag "off" nil)
(const :tag "following only" "following")
(const :tag "all users" "all")))
(defcustom mastodon-toot-display-orig-in-reply-buffer nil
"Display a copy of the toot replied to in the compose buffer."
:type 'boolean)
(defcustom mastodon-toot-orig-in-reply-length 191
;; three lines of divider width: (- (* 3 67) (length " Reply to: "))
"Length to crop toot replied to in the compose buffer to."
:type 'integer)
(defcustom mastodon-toot--default-reply-visibility "public"
"Default visibility settings when replying.
If the original toot visibility is different we use the more restricted one."
:type '(choice
(const :tag "public" "public")
(const :tag "unlisted" "unlisted")
(const :tag "followers only" "private")
(const :tag "direct" "direct")))
(defcustom mastodon-toot--enable-custom-instance-emoji nil
"Whether to enable your instance's custom emoji by default."
:type 'boolean)
(defcustom mastodon-toot--proportional-fonts-compose nil
"Nonnil to enable using proportional fonts in the compose buffer.
By default fixed width fonts are used."
:type '(boolean :tag "Enable using proportional rather than fixed \
width fonts"))
(defvar-local mastodon-toot--content-warning nil
"A flag whether the toot should be marked with a content warning.")
(defvar-local mastodon-toot--content-warning-from-reply-or-redraft nil
"The content warning of the toot being replied to.")
(defvar-local mastodon-toot--content-nsfw nil
"A flag indicating whether the toot should be marked as NSFW.")
(defvar mastodon-toot-visibility-list
'(direct private unlisted public)
"A list of the available toot visibility settings.")
(defvar-local mastodon-toot--visibility nil
"A string indicating the visibility of the toot being composed.
Valid values are \"direct\", \"private\" (followers-only),
\"unlisted\", and \"public\".
This is determined by the account setting on the server. To
change the setting on the server, see
`mastodon-toot--set-default-visibility'.")
(defvar-local mastodon-toot--media-attachments nil
"A list of the media attachments of the toot being composed.")
(defvar-local mastodon-toot--media-attachment-ids nil
"A list of any media attachment ids of the toot being composed.")
(defvar-local mastodon-toot-poll nil
"A list of poll options for the toot being composed.")
(defvar-local mastodon-toot--language nil
"The language of the toot being composed, in ISO 639 (two-letter).")
(defvar-local mastodon-toot--scheduled-for nil
"An ISO 8601 timestamp that specifying when the post should be published.
Should be at least 5 minutes into the future.")
(defvar-local mastodon-toot--scheduled-id nil
"The id of the scheduled post that we are now editing.")
(defvar-local mastodon-toot--reply-to-id nil
"Buffer-local variable to hold the id of the toot being replied to.")
(defvar-local mastodon-toot--edit-item-id nil
"The id of the toot being edited.")
(defvar-local mastodon-toot-previous-window-config nil
"A list of window configuration prior to composing a toot.
Takes its form from `window-configuration-to-register'.")
(defvar mastodon-toot--max-toot-chars nil
"The maximum allowed characters count for a single toot.")
(defvar-local mastodon-toot-completions nil
"The data of completion candidates for the current completion at point.")
(defvar mastodon-toot-current-toot-text nil
"The text of the toot being composed.")
(persist-defvar mastodon-toot-draft-toots-list nil
"A list of toots that have been saved as drafts.
For the moment we just put all composed toots in here, as we want
to also capture toots that are \"sent\" but that don't successfully
send.")
;;; REGEXES
(defvar mastodon-toot-handle-regex
(rx (| (any ?\( "\n" "\t "" ") bol) ; preceding things
(group-n 2 (+ ?@ (* (any ?- ?_ ?. "A-Z" "a-z" "0-9" ))) ; handle
(? ?@ (* (not (any "\n" "\t" " "))))) ; optional domain
(| "'" word-boundary))) ; boundary or possessive
(defvar mastodon-toot-tag-regex
(rx (| (any ?\( "\n" "\t" " ") bol)
(group-n 2 ?# (+ (any "A-Z" "a-z" "0-9")))
(| "'" word-boundary))) ; boundary or possessive
(defvar mastodon-toot-url-regex
;; adapted from ffap-url-regexp
(concat
"\\(?2:\\(news\\(post\\)?:\\|mailto:\\|file:\\|\\(ftp\\|https?\\|telnet\\|gopher\\|www\\|wais\\)://\\)" ; uri prefix
"[^ \n\t]*\\)" ; any old thing, that is, i.e. we allow invalid/unwise chars
;; "[ .,:;!?]\\b"))
"\\>")) ; boundary end
;;; MODE MAP
(defvar mastodon-toot-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'mastodon-toot--send)
(define-key map (kbd "C-c C-k") #'mastodon-toot--cancel)
(define-key map (kbd "C-c C-w") #'mastodon-toot--toggle-warning)
(define-key map (kbd "C-c C-n") #'mastodon-toot--toggle-nsfw)
(define-key map (kbd "C-c C-v") #'mastodon-toot--change-visibility)
(when (require 'emojify nil :noerror)
(define-key map (kbd "C-c C-e") #'mastodon-toot--insert-emoji))
(define-key map (kbd "C-c C-a") #'mastodon-toot--attach-media)
(define-key map (kbd "C-c !") #'mastodon-toot--clear-all-attachments)
(define-key map (kbd "C-c C-p") #'mastodon-toot--create-poll)
(define-key map (kbd "C-c C-l") #'mastodon-toot--set-toot-language)
(define-key map (kbd "C-c C-s") #'mastodon-toot--schedule-toot)
map)
"Keymap for `mastodon-toot'.")
(defun mastodon-toot--set-default-visibility ()
"Set the default visibility for toots on the server."
(interactive)
(let ((vis (completing-read "Set default visibility to:"
mastodon-toot-visibility-list
nil t)))
(mastodon-profile--update-preference "privacy" vis :source)))
(defun mastodon-toot--get-max-toot-chars (&optional no-toot)
"Fetch max_toot_chars from `mastodon-instance-url' asynchronously.
NO-TOOT means we are not calling from a toot buffer."
(mastodon-http--get-json-async
(mastodon-http--api "instance")
nil
'mastodon-toot--get-max-toot-chars-callback no-toot))
(defun mastodon-toot--get-max-toot-chars-callback (json-response
&optional no-toot)
"Set max_toot_chars returned in JSON-RESPONSE and display in new toot buffer.
NO-TOOT means we are not calling from a toot buffer."
(let ((max-chars
(or (alist-get 'max_toot_chars json-response)
(alist-get 'max_characters ; some servers have this instead
(alist-get 'statuses
(alist-get 'configuration
json-response))))))
(setq mastodon-toot--max-toot-chars max-chars)
(unless no-toot
(with-current-buffer "*new toot*"
(mastodon-toot--update-status-fields)))))
(defun mastodon-toot--action-success (marker byline-region remove)
"Insert/remove the text MARKER with `success' face in byline.
BYLINE-REGION is a cons of start and end pos of the byline to be
modified.
Remove MARKER if REMOVE is non-nil, otherwise add it."
(let ((inhibit-read-only t)
(bol (car byline-region))
(eol (cdr byline-region))
(at-byline-p (eq (mastodon-tl--property 'byline :no-move) t)))
(save-excursion
(when remove
(goto-char bol)
(beginning-of-line) ;; The marker is not part of the byline
(if (search-forward (format "(%s) " marker) eol t)
(replace-match "")
(message "Oops: could not find marker '(%s)'" marker)))
(unless remove
(goto-char bol)
(insert
(propertize
(format "(%s) "
(propertize marker
'face 'success))
'cursor-face 'mastodon-cursor-highlight-face))))
(when at-byline-p
;; leave point after the marker:
(unless remove
;; if point is inside the byline, back up first so
;; we don't move to the following toot:
(beginning-of-line)
(forward-line -1)
(mastodon-tl--goto-next-item)))))
(defun mastodon-toot--action (action callback)
"Take ACTION on toot at point, then execute CALLBACK.
Makes a POST request to the server. Used for favouriting,
boosting, or bookmarking toots."
(let* ((id (mastodon-tl--property 'base-item-id))
(url (mastodon-http--api
(concat "statuses/" (mastodon-tl--as-string id) "/" action)))
(response (mastodon-http--post url)))
(mastodon-http--triage response callback)))
(defun mastodon-toot--toggle-boost-or-favourite (type)
"Toggle boost or favourite of toot at `point'.
TYPE is a symbol, either `favourite' or `boost.'"
(mastodon-tl--do-if-item-strict
(let* ((boost-p (equal type 'boost))
;; (has-id (mastodon-tl--property 'base-item-id))
(byline-region ;(when has-id
(mastodon-tl--find-property-range 'byline (point)))
(id (when byline-region
(mastodon-tl--as-string (mastodon-tl--property 'base-item-id))))
(boosted (when byline-region
(get-text-property (car byline-region) 'boosted-p)))
(faved (when byline-region
(get-text-property (car byline-region) 'favourited-p)))
(action (if boost-p
(if boosted "unreblog" "reblog")
(if faved "unfavourite" "favourite")))
(msg (if boosted "unboosted" "boosted"))
(action-string (if boost-p "boost" "favourite"))
(remove (if boost-p (when boosted t) (when faved t)))
(item-json (mastodon-tl--property 'item-json))
(toot-type (alist-get 'type item-json))
(visibility (mastodon-tl--field 'visibility item-json)))
(if byline-region
(if (and (or (equal visibility "direct")
(equal visibility "private"))
boost-p)
(message "You cant boost posts with visibility: %s" visibility)
(cond ;; actually there's nothing wrong with faving/boosting own toots!
;;((mastodon-toot--own-toot-p (mastodon-tl--property 'item-json))
;;(error "You can't %s your own toots" action-string))
;; & nothing wrong with faving/boosting own toots from notifs:
;; this boosts/faves the base toot, not the notif status
((and (equal "reblog" toot-type)
(not (mastodon-tl--buffer-type-eq 'notifications)))
(user-error "You can't %s boosts" action-string))
((and (equal "favourite" toot-type)
(not (mastodon-tl--buffer-type-eq 'notifications)))
(user-error "You can't %s favourites" action-string))
((and (equal "private" visibility)
(equal type 'boost))
(user-error "You can't boost private toots"))
(t
(mastodon-toot--action
action
(lambda (_)
(let ((inhibit-read-only t))
(add-text-properties (car byline-region)
(cdr byline-region)
(if boost-p
(list 'boosted-p (not boosted))
(list 'favourited-p (not faved))))
(mastodon-toot--update-stats-on-action type remove)
(mastodon-toot--action-success (if boost-p
(mastodon-tl--symbol 'boost)
(mastodon-tl--symbol 'favourite))
byline-region remove))
(message (format "%s #%s" (if boost-p msg action) id)))))))
(message (format "Nothing to %s here?!?" action-string))))))
(defun mastodon-toot--inc-or-dec (count subtract)
"If SUBTRACT, decrement COUNT, else increment."
(if subtract
(1- count)
(1+ count)))
(defun mastodon-toot--update-stats-on-action (action &optional subtract)
"Increment the toot stats display upon ACTION.
ACTION is a symbol, either `favourite' or `boost'.
SUBTRACT means we are un-favouriting or unboosting, so we decrement."
(let* ((count-prop (if (eq action 'favourite)
'favourites-count
'boosts-count))
(count-prop-range (mastodon-tl--find-property-range count-prop (point)))
(count (get-text-property (car count-prop-range) count-prop))
(inhibit-read-only 1))
;; TODO another way to implement this would be to async fetch counts again
;; and re-display from count-properties
(add-text-properties (car count-prop-range)
(cdr count-prop-range)
(list 'display
(number-to-string
(mastodon-toot--inc-or-dec count subtract))
;; update the count prop
;; we rely on this for any subsequent actions:
count-prop
(mastodon-toot--inc-or-dec count subtract)))))
(defun mastodon-toot--toggle-boost ()
"Boost/unboost toot at `point'."
(interactive)
(mastodon-toot--toggle-boost-or-favourite 'boost))
(defun mastodon-toot--toggle-favourite ()
"Favourite/unfavourite toot at `point'."
(interactive)
(mastodon-toot--toggle-boost-or-favourite 'favourite))
;; TODO maybe refactor into boost/fave fun
(defun mastodon-toot--toggle-bookmark ()
"Bookmark or unbookmark toot at point."
(interactive)
(mastodon-tl--do-if-item-strict
(let* ((id (mastodon-tl--property 'base-item-id))
(bookmarked-p (mastodon-tl--property 'bookmarked-p))
(byline-region (when id
(mastodon-tl--find-property-range 'byline (point))))
(action (if bookmarked-p "unbookmark" "bookmark"))
(bookmark-str (mastodon-tl--symbol 'bookmark))
(message (if bookmarked-p
"Bookmark removed!"
"Toot bookmarked!"))
(remove (when bookmarked-p t)))
(if byline-region
(mastodon-toot--action
action
(lambda (_)
(let ((inhibit-read-only t))
(add-text-properties (car byline-region)
(cdr byline-region)
(list 'bookmarked-p (not bookmarked-p))))
(mastodon-toot--action-success bookmark-str
byline-region remove)
(message (format "%s #%s" message id))))
(message (format "Nothing to %s here?!?" action))))))
(defun mastodon-toot--list-toot-boosters ()
"List the boosters of toot at point."
(interactive)
(mastodon-toot--list-toot-boosters-or-favers))
(defun mastodon-toot--list-toot-favouriters ()
"List the favouriters of toot at point."
(interactive)
(mastodon-toot--list-toot-boosters-or-favers :favourite))
(defun mastodon-toot--list-toot-boosters-or-favers (&optional favourite)
"List the favouriters or boosters of toot at point.
With FAVOURITE, list favouriters, else list boosters."
(mastodon-tl--do-if-item-strict
(let* ((base-toot (mastodon-tl--property 'base-item-id))
(endpoint (if favourite "favourited_by" "reblogged_by"))
(url (mastodon-http--api (format "statuses/%s/%s" base-toot endpoint)))
(params '(("limit" . "80")))
(json (mastodon-http--get-json url params)))
(if (eq (caar json) 'error)
(user-error "%s (Status does not exist or is private)" (alist-get 'error json))
(let ((handles (mastodon-tl--map-alist 'acct json))
(type-string (if favourite "Favouriters" "Boosters")))
(if (not handles)
(user-error "Looks like this toot has no %s" type-string)
(let ((choice (completing-read
(format "%s (enter to view profile): " type-string)
handles
nil
t)))
(mastodon-profile--show-user choice))))))))
(defun mastodon-toot--copy-toot-url ()
"Copy URL of toot at point.
If the toot is a fave/boost notification, copy the URL of the
base toot."
(interactive)
(let* ((url (mastodon-toot--toot-url)))
(kill-new url)
(message "Toot URL copied to the clipboard.")))
(defun mastodon-toot--toot-url ()
"Return the URL of the base toot at point."
(let* ((toot (or (mastodon-tl--property 'base-toot)
(mastodon-tl--property 'item-json))))
(if (mastodon-tl--field 'reblog toot)
(alist-get 'url (alist-get 'reblog toot))
(alist-get 'url toot))))
(defun mastodon-toot--copy-toot-text ()
"Copy text of toot at point.
If the toot is a fave/boost notification, copy the text of the
base toot."
(interactive)
(let* ((toot (or (mastodon-tl--property 'base-toot)
(mastodon-tl--property 'item-json))))
(kill-new (mastodon-tl--content toot))
(message "Toot content copied to the clipboard.")))
(defun mastodon-toot--translate-toot-text ()
"Translate text of toot at point.
Uses `lingva.el'."
(interactive)
(if (not (require 'lingva nil :no-error))
(message "Looks like you need to install lingva.el first.")
(if mastodon-tl--buffer-spec
(if-let ((toot (mastodon-tl--property 'item-json)))
(lingva-translate nil
(mastodon-tl--content toot)
(when mastodon-tl--enable-proportional-fonts
t))
(message "No toot to translate?"))
(message "No mastodon buffer?"))))
(defun mastodon-toot--own-toot-p (toot)
"Check if TOOT is user's own, for deleting, editing, or pinning it."
;; this check needs to allow acting on own toots displayed as boosts, so we
;; call `mastodon-tl--toot-or-base'.
(let ((json (mastodon-tl--toot-or-base toot)))
(equal (alist-get 'acct (alist-get 'account json))
(mastodon-auth--user-acct))))
(defun mastodon-toot--pin-toot-toggle ()
"Pin or unpin user's toot at point."
(interactive)
(let* ((toot (or (mastodon-tl--property 'base-toot) ;fave/boost notifs
(mastodon-tl--property 'item-json)))
(pinnable-p (mastodon-toot--own-toot-p toot))
(pinned-p (equal (alist-get 'pinned toot) t))
(action (if pinned-p "unpin" "pin"))
(msg (if pinned-p "unpinned" "pinned"))
(msg-y-or-n (if pinned-p "Unpin" "Pin")))
(if (not pinnable-p)
(message "You can only pin your own toots.")
(when (y-or-n-p (format "%s this toot? " msg-y-or-n))
(mastodon-toot--action action
(lambda (_)
(when mastodon-tl--buffer-spec
(mastodon-tl--reload-timeline-or-profile))
(message "Toot %s!" msg)))))))
;;; DELETE, DRAFT, REDRAFT
(defun mastodon-toot--delete-toot ()
"Delete user's toot at point synchronously."
(interactive)
(mastodon-toot--delete-and-redraft-toot t))
;; TODO: handle media/poll for redrafting toots
(defun mastodon-toot--delete-and-redraft-toot (&optional no-redraft)
"Delete and redraft user's toot at point synchronously.
NO-REDRAFT means delete toot only."
(interactive)
(let* ((toot (or (mastodon-tl--property 'base-toot) ;fave/boost notifs
(mastodon-tl--property 'item-json)))
(id (mastodon-tl--as-string (mastodon-tl--item-id toot)))
(url (mastodon-http--api (format "statuses/%s" id)))
(toot-cw (alist-get 'spoiler_text toot))
(toot-visibility (alist-get 'visibility toot))
(reply-id (alist-get 'in_reply_to_id toot))
(pos (point)))
(if (not (mastodon-toot--own-toot-p toot))
(message "You can only delete (and redraft) your own toots.")
(when (y-or-n-p (if no-redraft
(format "Delete this toot? ")
(format "Delete and redraft this toot? ")))
(let* ((response (mastodon-http--delete url)))
(mastodon-http--triage
response
(lambda (_)
(if no-redraft
(progn
(when mastodon-tl--buffer-spec
(mastodon-tl--reload-timeline-or-profile pos))
(message "Toot deleted!"))
(mastodon-toot--redraft response
reply-id
toot-visibility
toot-cw)))))))))
(defun mastodon-toot--set-cw (&optional cw)
"Set content warning to CW if it is non-nil."
(unless (or (null cw) ; cw is nil for `mastodon-tl--dm-user'
(string-empty-p cw))
(setq mastodon-toot--content-warning t)
(setq mastodon-toot--content-warning-from-reply-or-redraft cw)))
;;; REDRAFT
(defun mastodon-toot--redraft (response &optional reply-id toot-visibility toot-cw)
"Opens a new toot compose buffer using values from RESPONSE buffer.
REPLY-ID, TOOT-VISIBILITY, and TOOT-CW of deleted toot are preseved."
(with-current-buffer response
(let* ((json-response (mastodon-http--process-json))
(content (alist-get 'text json-response)))
(mastodon-toot--compose-buffer)
(goto-char (point-max))
(insert content)
;; adopt reply-to-id, visibility and CW from deleted toot:
(mastodon-toot--set-toot-properties
reply-id toot-visibility toot-cw
;; TODO set new lang/scheduled props here
nil))))
(defun mastodon-toot--set-toot-properties
(reply-id visibility cw lang &optional scheduled scheduled-id)
"Set the toot properties for the current redrafted or edited toot.
REPLY-ID, VISIBILITY, CW, SCHEDULED, and LANG are the properties to set."
(when reply-id
(setq mastodon-toot--reply-to-id reply-id))
(setq mastodon-toot--visibility visibility)
(setq mastodon-toot--scheduled-for scheduled)
(setq mastodon-toot--scheduled-id scheduled-id)
(when (not (string-empty-p lang))
(setq mastodon-toot--language lang))
(mastodon-toot--set-cw cw)
(mastodon-toot--update-status-fields))
(defun mastodon-toot--kill (&optional cancel)
"Kill `mastodon-toot-mode' buffer and window.
CANCEL means the toot was not sent, so we save the toot text as a draft."
(let ((prev-window-config mastodon-toot-previous-window-config))
(unless (eq mastodon-toot-current-toot-text nil)
(when cancel
(cl-pushnew mastodon-toot-current-toot-text
mastodon-toot-draft-toots-list :test 'equal)))
;; prevent some weird bug when cancelling a non-empty toot:
(delete #'mastodon-toot--save-toot-text after-change-functions)
(kill-buffer-and-window)
(mastodon-toot--restore-previous-window-config prev-window-config)))
(defun mastodon-toot--cancel ()
"Kill new-toot buffer/window. Does not POST content to Mastodon.
If toot is not empty, prompt to save text as a draft."
(interactive)
(if (mastodon-toot--empty-p)
(mastodon-toot--kill)
(when (y-or-n-p "Save draft toot?")
(mastodon-toot--save-draft))
(mastodon-toot--kill)))
(defun mastodon-toot--save-draft ()
"Save the current compose toot text as a draft.
Pushes `mastodon-toot-current-toot-text' to
`mastodon-toot-draft-toots-list'."
(interactive)
(unless (eq mastodon-toot-current-toot-text nil)
(cl-pushnew mastodon-toot-current-toot-text
mastodon-toot-draft-toots-list :test 'equal)
(message "Draft saved!")))
(defun mastodon-toot--empty-p (&optional text-only)
"Return t if toot has no text, attachments, or polls.
TEXT-ONLY means don't check for attachments or polls."
(and (if text-only
t
(and (not mastodon-toot--media-attachments)
(not mastodon-toot-poll)))
(string-empty-p (mastodon-tl--clean-tabs-and-nl
(mastodon-toot--remove-docs)))))
;;; EMOJIS
(defalias 'mastodon-toot--insert-emoji
#'emojify-insert-emoji
"Prompt to insert an emoji.")
(defun mastodon-toot--emoji-dir ()
"Return the file path for the mastodon custom emojis directory."
(concat (expand-file-name emojify-emojis-dir)
"/mastodon-custom-emojis/"))
(defun mastodon-toot--download-custom-emoji ()
"Download `mastodon-instance-url's custom emoji.
Emoji images are stored in a subdir of `emojify-emojis-dir'.
To use the downloaded emoji, run `mastodon-toot--enable-custom-emoji'."
(interactive)
(let* ((url (mastodon-http--api "custom_emojis"))
(custom-emoji (mastodon-http--get-json url))
(mastodon-custom-emoji-dir (mastodon-toot--emoji-dir)))
(if (not (file-directory-p emojify-emojis-dir))
(message "Looks like you need to set up emojify first.")
(unless (file-directory-p mastodon-custom-emoji-dir)
(make-directory mastodon-custom-emoji-dir nil)) ; no add parent
(mapc (lambda (x)
(let ((url (alist-get 'url x))
(shortcode (alist-get 'shortcode x)))
;; skip anything that contains unexpected characters
(when (and url shortcode
(string-match-p "^[a-zA-Z0-9-_]+$" shortcode)
(string-match-p "^[a-zA-Z]+$" (file-name-extension url)))
(url-copy-file url
(concat mastodon-custom-emoji-dir
shortcode
"."
(file-name-extension url))
t))))
custom-emoji)
(message "Custom emoji for %s downloaded to %s"
mastodon-instance-url
mastodon-custom-emoji-dir))))
(defun mastodon-toot--collect-custom-emoji ()
"Return a list of `mastodon-instance-url's custom emoji.
The list is formatted for `emojify-user-emojis', which see."
(let* ((mastodon-custom-emojis-dir (mastodon-toot--emoji-dir))
(custom-emoji-files (directory-files mastodon-custom-emojis-dir
nil ; not full path
"^[^.]")) ; no dot files
mastodon-emojify-user-emojis)
(mapc (lambda (x)
(push
`(,(concat ":"
(file-name-base x) ":")
. (("name" . ,(file-name-base x))
("image" . ,(concat mastodon-custom-emojis-dir x))
("style" . "github")))
mastodon-emojify-user-emojis))
custom-emoji-files)
(reverse mastodon-emojify-user-emojis)))
(defun mastodon-toot--enable-custom-emoji ()
"Add `mastodon-instance-url's custom emoji to `emojify'.
Custom emoji must first be downloaded with
`mastodon-toot--download-custom-emoji'. Custom emoji are appended
to `emojify-user-emojis', and the emoji data is updated."
(interactive)
(unless (file-exists-p (mastodon-toot--emoji-dir))
(when (y-or-n-p "Looks like you haven't downloaded your
instance's custom emoji yet. Download now? ")
(mastodon-toot--download-custom-emoji)))
(let ((masto-emojis (mastodon-toot--collect-custom-emoji)))
(unless (cl-find (car masto-emojis)
emojify-user-emojis
:test #'equal)
(setq emojify-user-emojis
(append masto-emojis
emojify-user-emojis))
;; if already loaded, reload
(when (featurep 'emojify)
;; we now only do this within the unless test above, as it is extremely
;; slow and runs in `mastodon-mode-hook'.
(emojify-set-emoji-data)))))
(defun mastodon-toot--remove-docs ()
"Get the body of a toot from the current compose buffer."
(let ((header-region (mastodon-tl--find-property-range 'toot-post-header
(point-min))))
(buffer-substring (cdr header-region) (point-max))))
(defun mastodon-toot--build-poll-params ()
"Return an alist of parameters for POSTing a poll status."
(append
(mastodon-http--build-array-params-alist
"poll[options][]"
(plist-get mastodon-toot-poll :options))
`(("poll[expires_in]" . ,(plist-get mastodon-toot-poll :expiry)))
`(("poll[multiple]" . ,(symbol-name (plist-get mastodon-toot-poll :multi))))
`(("poll[hide_totals]" . ,(symbol-name (plist-get mastodon-toot-poll :hide))))))
(defun mastodon-toot--read-cw-string ()
"Read a content warning from the minibuffer."
(when (and (not (mastodon-toot--empty-p))
mastodon-toot--content-warning)
(read-string "Warning: "
mastodon-toot--content-warning-from-reply-or-redraft)))
;;; SEND TOOT FUNCTION
(defun mastodon-toot--send ()
"POST contents of new-toot buffer to Mastodon instance and kill buffer.
If media items have been attached and uploaded with
`mastodon-toot--attach-media', they are attached to the toot.
If `mastodon-toot--edit-item-id' is non-nil, PUT contents to
instance to edit a toot."
(interactive)
(let* ((toot (mastodon-toot--remove-docs))
(scheduled mastodon-toot--scheduled-for)
(scheduled-id mastodon-toot--scheduled-id)
(edit-id mastodon-toot--edit-item-id)
(endpoint (if edit-id ; we are sending an edit:
(mastodon-http--api (format "statuses/%s" edit-id))
(mastodon-http--api "statuses")))
(cw (mastodon-toot--read-cw-string))
(args-no-media (append `(("status" . ,toot)
("in_reply_to_id" . ,mastodon-toot--reply-to-id)
("visibility" . ,mastodon-toot--visibility)
("sensitive" . ,(when mastodon-toot--content-nsfw
(symbol-name t)))
("spoiler_text" . ,cw)
("language" . ,mastodon-toot--language))
;; Pleroma instances can't handle null-valued
;; scheduled_at args, so only add if non-nil
(when scheduled `(("scheduled_at" . ,scheduled)))))
(args-media (when mastodon-toot--media-attachments
(mastodon-http--build-array-params-alist
"media_ids[]"
mastodon-toot--media-attachment-ids)))
(args-poll (when mastodon-toot-poll
(mastodon-toot--build-poll-params)))
;; media || polls:
(args (if mastodon-toot--media-attachments
(append args-media args-no-media)
(if mastodon-toot-poll
(append args-no-media args-poll)
args-no-media)))
(prev-window-config mastodon-toot-previous-window-config))
(cond ((and mastodon-toot--media-attachments
;; make sure we have media args
;; and the same num of ids as attachments
(or (not args-media)
(not (= (length mastodon-toot--media-attachments)
(length mastodon-toot--media-attachment-ids)))))
(message "Something is wrong with your uploads. Wait for them to complete or try again."))
((and mastodon-toot--max-toot-chars
(> (mastodon-toot--count-toot-chars toot cw) mastodon-toot--max-toot-chars))
(message "Looks like your toot (inc. CW) is longer than that maximum allowed length."))
((mastodon-toot--empty-p)
(message "Empty toot. Cowardly refusing to post this."))
(t
(let ((response (if edit-id ; we are sending an edit:
(mastodon-http--put endpoint args)
(mastodon-http--post endpoint args))))
(mastodon-http--triage
response
(lambda (_)
(mastodon-toot--kill)
(if scheduled
(message "Toot scheduled!")
(message "Toot toot!"))
;; cancel scheduled toot if we were editing it:
(when scheduled-id
(mastodon-views--cancel-scheduled-toot
scheduled-id :no-confirm))
(mastodon-toot--restore-previous-window-config prev-window-config)
(when edit-id
(let ((pos (marker-position (cadr prev-window-config))))
(mastodon-tl--reload-timeline-or-profile pos))))))))))
;;; EDITING TOOTS:
(defun mastodon-toot--edit-toot-at-point ()
"Edit the user's toot at point."
(interactive)
(mastodon-tl--do-if-item-strict
(let ((toot (or (mastodon-tl--property 'base-toot) ; fave/boost notifs
(mastodon-tl--property 'item-json))))
(if (not (mastodon-toot--own-toot-p toot))
(message "You can only edit your own toots.")
(let* ((id (mastodon-tl--as-string (mastodon-tl--item-id toot)))
(source (mastodon-toot--get-toot-source id))
(content (alist-get 'text source))
(source-cw (alist-get 'spoiler_text source))
(toot-visibility (alist-get 'visibility toot))
(toot-language (alist-get 'language toot))
(reply-id (alist-get 'in_reply_to_id toot)))
(when (y-or-n-p "Edit this toot? ")
(mastodon-toot--compose-buffer nil reply-id nil content :edit)
(goto-char (point-max))
;; adopt reply-to-id, visibility, CW, and language:
(mastodon-toot--set-toot-properties reply-id toot-visibility
source-cw toot-language)
(mastodon-toot--update-status-fields)
(setq mastodon-toot--edit-item-id id)))))))
(defun mastodon-toot--get-toot-source (id)
"Fetch the source JSON of toot with ID."
(let ((url (mastodon-http--api (format "/statuses/%s/source" id))))
(mastodon-http--get-json url nil :silent)))
(defun mastodon-toot--get-toot-edits (id)
"Return the edit history of toot with ID."
(let* ((url (mastodon-http--api (format "statuses/%s/history" id))))
(mastodon-http--get-json url)))
(defun mastodon-toot--view-toot-edits ()
"View editing history of the toot at point in a popup buffer."
(interactive)
(let ((id (mastodon-tl--property 'base-item-id))
(history (mastodon-tl--property 'edit-history))
(buf "*mastodon-toot-edits*"))
(with-mastodon-buffer buf #'special-mode :other-window
(let ((count 1))
(mapc (lambda (x)
(insert (propertize (if (= count 1)
(format "%s [original]:\n" count)
(format "%s:\n" count))
'face 'font-lock-comment-face)
(mastodon-toot--insert-toot-iter x)
"\n")
(cl-incf count))
history))
(setq-local header-line-format
(propertize
(format "Edits to toot by %s:"
(alist-get 'username
(alist-get 'account (car history))))
'face 'font-lock-comment-face))
(mastodon-tl--set-buffer-spec (buffer-name (current-buffer))
(format "statuses/%s/history" id)
nil))))
(defun mastodon-toot--insert-toot-iter (it)
"Insert iteration IT of toot."
(let ((content (alist-get 'content it)))
;; (account (alist-get 'account it))
;; TODO: handle polls, media
(mastodon-tl--render-text content)))
(defun mastodon-toot--restore-previous-window-config (config)
"Restore the window CONFIG after killing the toot compose buffer.
Buffer-local variable `mastodon-toot-previous-window-config' holds the config."
(set-window-configuration (car config))
(goto-char (cadr config)))
(defun mastodon-toot--mentions-to-string (mentions)
"Apply `mastodon-toot--process-local' function to each mention in MENTIONS.
Remove empty string (self) from result and joins the sequence with whitespace."
(mapconcat (lambda (mention) mention)
(remove "" (mapcar #'mastodon-toot--process-local mentions))
" "))
(defun mastodon-toot--process-local (acct)
"Add domain to local ACCT and replace the curent user name with \"\".
Mastodon requires the full @user@domain, even in the case of local accts.
eg. \"user\" -> \"@user@local.social\" (when local.social is the domain of the
mastodon-instance-url).
eg. \"yourusername\" -> \"\"
eg. \"feduser@fed.social\" -> \"@feduser@fed.social\"."
(cond ((string-match-p "@" acct) (concat "@" acct)) ; federated acct
((string= (mastodon-auth--user-acct) acct) "") ; your acct
(t (concat "@" acct "@" ; local acct
(cadr (split-string mastodon-instance-url "/" t))))))
;;; COMPLETION (TAGS, MENTIONS)
(defun mastodon-toot--mentions (status)
"Extract mentions (not the reply-to author or booster) from STATUS.
The mentioned users look like this:
Local user (including the logged in): `username`.
Federated user: `username@host.co`."
(let* ((boosted (mastodon-tl--field 'reblog status))
(mentions (if boosted
(alist-get 'mentions (alist-get 'reblog status))
(alist-get 'mentions status))))
;; reverse does not work on vectors in 24.5
(mastodon-tl--map-alist 'acct (reverse mentions))))
(defun mastodon-toot--get-bounds (regex)
"Get bounds of tag or handle before point using REGEX."
;; # and @ are not part of any existing thing at point
(save-match-data
(save-excursion
;; match full handle inc. domain, or tag including #
;; (see the regexes for subexp 2)
(when (re-search-backward regex
(save-excursion (forward-whitespace -1)
(point))
:no-error)
(cons (match-beginning 2)
(match-end 2))))))
(defun mastodon-toot--fetch-completion-candidates (start end &optional tags)
"Search for a completion prefix from buffer positions START to END.
Return a list of candidates.
If TAGS, we search for tags, else we search for handles."
;; we can't save the first two-letter search then only filter the
;; resulting list, as max results returned is 40.
(setq mastodon-toot-completions
(if tags
(let ((tags-list (mastodon-search--search-tags-query
(buffer-substring-no-properties start end))))
(cl-loop for tag in tags-list
collect (cons (concat "#" (car tag))
(cdr tag))))
(mastodon-search--search-accounts-query
(buffer-substring-no-properties start end)))))
(defun mastodon-toot--mentions-capf ()
"Build a mentions completion backend for `completion-at-point-functions'."
(let* ((bounds (mastodon-toot--get-bounds mastodon-toot-handle-regex))
(start (car bounds))
(end (cdr bounds)))
(when bounds
(list start
end
(completion-table-dynamic ; only search when necessary
(lambda (_)
;; Interruptible candidate computation, from minad/d mendler, thanks!
(let ((result
(while-no-input
(mastodon-toot--fetch-completion-candidates start end))))
(and (consp result) result))))
:exclusive 'no
:annotation-function
(lambda (cand)
(concat " " (mastodon-toot--mentions-annotation-fun cand)))))))
(defun mastodon-toot--tags-capf ()
"Build a tags completion backend for `completion-at-point-functions'."
(let* ((bounds (mastodon-toot--get-bounds mastodon-toot-tag-regex))
(start (car bounds))
(end (cdr bounds)))
(when bounds
(list start
end
(completion-table-dynamic ; only search when necessary:
(lambda (_)
;; Interruptible candidate computation, from minad/d mendler, thanks!
(let ((result
(while-no-input
(mastodon-toot--fetch-completion-candidates start end :tags))))
(and (consp result) result))))
:exclusive 'no
:annotation-function
(lambda (cand)
(concat " " (mastodon-toot--tags-annotation-fun cand)))))))
(defun mastodon-toot--mentions-annotation-fun (candidate)
"Given a handle completion CANDIDATE, return its annotation string, a username."
(caddr (assoc candidate mastodon-toot-completions)))
(defun mastodon-toot--tags-annotation-fun (candidate)
"Given a tag string CANDIDATE, return an annotation, the tag's URL."
;; TODO: check the list returned here? should be cadr
;; or make it an alist and use cdr
(cadr (assoc candidate mastodon-toot-completions)))
;;; REPLY
(defun mastodon-toot--reply ()
"Reply to toot at `point'.
Customize `mastodon-toot-display-orig-in-reply-buffer' to display
text of the toot being replied to in the compose buffer."
(interactive)
(mastodon-tl--do-if-item-strict
(let* ((toot (mastodon-tl--property 'item-json))
;; no-move arg for base toot: don't try next toot
(base-toot (mastodon-tl--property 'base-toot :no-move)) ; for new notifs handling
(id (mastodon-tl--as-string (mastodon-tl--field 'id (or base-toot toot))))
(account (mastodon-tl--field 'account toot))
(user (alist-get 'acct account))
(mentions (mastodon-toot--mentions (or base-toot toot)))
(boosted (mastodon-tl--field 'reblog (or base-toot toot)))
(booster (when boosted
(alist-get 'acct
(alist-get 'account toot)))))
(mastodon-toot
(when user
(if booster
(if (and (not (equal user booster))
(not (member booster mentions)))
;; different booster, user and mentions:
(mastodon-toot--mentions-to-string (append (list user booster) mentions nil))
;; booster is either user or in mentions:
(if (not (member user mentions))
;; user not already in mentions:
(mastodon-toot--mentions-to-string (append (list user) mentions nil))
;; user already in mentions:
(mastodon-toot--mentions-to-string (copy-sequence mentions))))
;; ELSE no booster:
(if (not (member user mentions))
;; user not in mentions:
(mastodon-toot--mentions-to-string (append (list user) mentions nil))
;; user in mentions already:
(mastodon-toot--mentions-to-string (copy-sequence mentions)))))
id
(or base-toot toot)))))
;;; COMPOSE TOOT SETTINGS
(defun mastodon-toot--toggle-warning ()
"Toggle `mastodon-toot--content-warning'."
(interactive)
(setq mastodon-toot--content-warning
(not mastodon-toot--content-warning))
(mastodon-toot--update-status-fields))
(defun mastodon-toot--toggle-nsfw ()
"Toggle `mastodon-toot--content-nsfw'."
(interactive)
(setq mastodon-toot--content-nsfw
(not mastodon-toot--content-nsfw))
(message "NSFW flag is now %s" (if mastodon-toot--content-nsfw "on" "off"))
(mastodon-toot--update-status-fields))
(defun mastodon-toot--change-visibility ()
"Change the current visibility to the next valid value."
(interactive)
(if (mastodon-tl--buffer-type-eq 'edit-toot)
(message "You can't change visibility when editing toots.")
(setq mastodon-toot--visibility
(cond ((string= mastodon-toot--visibility "public")
"unlisted")
((string= mastodon-toot--visibility "unlisted")
"private")
((string= mastodon-toot--visibility "private")
"direct")
(t
"public")))
(mastodon-toot--update-status-fields)))
(defun mastodon-toot--set-toot-language ()
"Prompt for a language and set `mastodon-toot--language'.
Return its two letter ISO 639 1 code."
(interactive)
(let* ((choice (completing-read "Language for this toot: "
mastodon-iso-639-1)))
(setq mastodon-toot--language
(alist-get choice mastodon-iso-639-1 nil nil 'equal))
(message "Language set to %s" choice)
(mastodon-toot--update-status-fields)))
;;; ATTACHMENTS
(defun mastodon-toot--clear-all-attachments ()
"Remove all attachments from a toot draft."
(interactive)
(setq mastodon-toot--media-attachments nil)
(setq mastodon-toot--media-attachment-ids nil)
(mastodon-toot--refresh-attachments-display)
(mastodon-toot--update-status-fields))
(defun mastodon-toot--attach-media (file description)
"Prompt for an attachment FILE with DESCRIPTION.
A preview is displayed in the new toot buffer, and the file
is uploaded asynchronously using `mastodon-toot--upload-attached-media'.
File is actually attached to the toot upon posting."
(interactive "fFilename: \nsDescription: ")
(when (>= (length mastodon-toot--media-attachments) 4)
;; Only a max. of 4 attachments are allowed, so pop the oldest one.
(pop mastodon-toot--media-attachments))
(if (file-directory-p file)
(message "Looks like you chose a directory not a file.")
(setq mastodon-toot--media-attachments
(nconc mastodon-toot--media-attachments
`(((:contents . ,(mastodon-http--read-file-as-string file))
(:description . ,description)
(:filename . ,file)))))
(mastodon-toot--refresh-attachments-display)
;; upload only most recent attachment:
(mastodon-toot--upload-attached-media (car (last mastodon-toot--media-attachments)))))
(defun mastodon-toot--upload-attached-media (attachment)
"Upload a single ATTACHMENT using `mastodon-http--post-media-attachment'.
The item's id is added to `mastodon-toot--media-attachment-ids',
which is used to attach it to a toot when posting."
(let* ((filename (expand-file-name (alist-get :filename attachment)))
(caption (alist-get :description attachment))
(url (concat mastodon-instance-url "/api/v2/media")))
(message "Uploading %s... (please wait before starting further uploads)"
(file-name-nondirectory filename))
(mastodon-http--post-media-attachment url filename caption)))
(defun mastodon-toot--refresh-attachments-display ()
"Update the display attachment previews in toot draft buffer."
(let ((inhibit-read-only t)
(attachments-region (mastodon-tl--find-property-range
'toot-attachments (point-min)))
(display-specs (mastodon-toot--format-attachments)))
(dotimes (i (- (cdr attachments-region) (car attachments-region)))
(add-text-properties (+ (car attachments-region) i)
(+ (car attachments-region) i 1)
(list 'display (or (nth i display-specs) ""))))))
(defun mastodon-toot--format-attachments ()
"Format the attachment previews for display in toot draft buffer."
(or (let ((counter 0)
(image-options (when (or (image-type-available-p 'imagemagick)
(image-transforms-p))
`(:height ,mastodon-toot--attachment-height))))
(mapcan (lambda (attachment)
(let* ((data (alist-get :contents attachment))
(image (apply #'create-image data
(if (version< emacs-version "27.1")
(when image-options 'imagemagick)
nil) ; inbuilt scaling in 27.1
t image-options))
(description (alist-get :description attachment)))
(setq counter (1+ counter))
(list (format "\n %d: " counter)
image
(format " \"%s\"" description))))
mastodon-toot--media-attachments))
(list "None")))
;;; POLL
(defun mastodon-toot--fetch-max-poll-options (instance)
"Return the maximum number of poll options from JSON data INSTANCE."
(mastodon-toot--fetch-poll-field 'max_options instance))
(defun mastodon-toot--fetch-max-poll-option-chars (instance)
"Return the maximum number of characters a poll option may have.
INSTANCE is JSON."
(if (alist-get 'pleroma instance)
(mastodon-toot--fetch-poll-field 'max_option_chars instance)
(or (mastodon-toot--fetch-poll-field 'max_characters_per_option instance)
50))) ; masto default
(defun mastodon-toot--fetch-poll-field (field instance)
"Return FIELD from the poll settings from JSON data INSTANCE."
(let* ((polls (if (alist-get 'pleroma instance)
(alist-get 'poll_limits instance)
(alist-get 'polls
(alist-get 'configuration instance)))))
(alist-get field polls)))
(defun mastodon-toot--read-poll-options-count (max)
"Read the user's choice of the number of options the poll should have.
MAX is the maximum number set by their instance."
(let ((number (read-number (format "Number of options [2-%s]: " max) 2)))
(if (> number max)
(user-error "You need to choose a number between 2 and %s" max)
number)))
(defun mastodon-toot--create-poll ()
"Prompt for new poll options and return as a list."
(interactive)
(let* ((instance (mastodon-http--get-json (mastodon-http--api "instance")))
(max-options (mastodon-toot--fetch-max-poll-options instance))
(count (mastodon-toot--read-poll-options-count max-options))
(length (mastodon-toot--fetch-max-poll-option-chars instance))
(multiple-p (y-or-n-p "Multiple choice? "))
(options (mastodon-toot--read-poll-options count length))
(hide-totals (y-or-n-p "Hide votes until poll ends? "))
(expiry (mastodon-toot--read-poll-expiry)))
(setq mastodon-toot-poll
`(:options ,options :length ,length :multi ,multiple-p
:hide ,hide-totals :expiry ,expiry))
(message "poll created!")))
(defun mastodon-toot--read-poll-options (count length)
"Read a list of options for poll with COUNT options.
LENGTH is the maximum character length allowed for a poll option."
(let* ((choices (cl-loop for x from 1 to count
collect (read-string
(format "Poll option [%s/%s] [max %s chars]: "
x count length))))
(longest (cl-reduce #'max (mapcar #'length choices))))
(if (> longest length)
(progn
(message "looks like you went over the max length. Try again.")
(sleep-for 2)
(mastodon-toot--read-poll-options count length))
choices)))
(defun mastodon-toot--read-poll-expiry ()
"Prompt for a poll expiry time."
;; API requires this in seconds
(let* ((options (mastodon-toot--poll-expiry-options-alist))
(response (completing-read "poll ends in [or enter seconds]: "
options nil 'confirm)))
(or (alist-get response options nil nil #'equal)
(if (< (string-to-number response) 600)
"600" ;; min 5 mins
response))))
(defun mastodon-toot--poll-expiry-options-alist ()
"Return an alist of expiry options options in seconds."
`(("5 minutes" . ,(number-to-string (* 60 5)))
("30 minutes" . ,(number-to-string (* 60 30)))
("1 hour" . ,(number-to-string (* 60 60)))
("6 hours" . ,(number-to-string (* 60 60 6)))
("1 day" . ,(number-to-string (* 60 60 24)))
("3 days" . ,(number-to-string (* 60 60 24 3)))
("7 days" . ,(number-to-string (* 60 60 24 7)))
("14 days" . ,(number-to-string (* 60 60 24 14)))
("30 days" . ,(number-to-string (* 60 60 24 30)))))
;;; SCHEDULE
(defun mastodon-toot--schedule-toot (&optional reschedule)
"Read a date (+ time) in the minibuffer and schedule the current toot.
With RESCHEDULE, reschedule the scheduled toot at point without editing."
;; original idea by christian tietze, thanks!
;; https://codeberg.org/martianh/mastodon.el/issues/285
(interactive)
(cond ((mastodon-tl--buffer-type-eq 'edit-toot)
(message "You can't schedule toots you're editing."))
((not (or (mastodon-tl--buffer-type-eq 'new-toot)
(mastodon-tl--buffer-type-eq 'scheduled-statuses)))
(message "You can only schedule toots from the compose buffer or scheduled toots view."))
(t
(let* ((id (when reschedule (mastodon-tl--property 'id :no-move)))
(ts (when reschedule
(alist-get 'scheduled_at
(mastodon-tl--property 'scheduled-json :no-move))))
(time-value (org-read-date t t nil "Schedule toot:"
;; default to scheduled timestamp if already set:
(mastodon-toot--iso-to-org
;; we are rescheduling without editing:
(or ts
;; we are maybe editing the scheduled toot:
mastodon-toot--scheduled-for))))
(iso8601-str (format-time-string "%FT%T%z" time-value))
(msg-str (format-time-string "%d-%m-%y at %H:%M[%z]" time-value)))
(if (not reschedule)
(progn
(setq-local mastodon-toot--scheduled-for iso8601-str)
(message (format "Toot scheduled for %s." msg-str)))
(let* ((args `(("scheduled_at" . ,iso8601-str)))
(url (mastodon-http--api (format "scheduled_statuses/%s" id)))
(response (mastodon-http--put url args)))
(mastodon-http--triage response
(lambda (_)
;; reschedule means we are in scheduled toots view:
(mastodon-views--view-scheduled-toots)
(message
(format "Toot rescheduled for %s." msg-str))))))))))
(defun mastodon-toot--iso-to-human (ts)
"Format an ISO8601 timestamp TS to be more human-readable."
(let* ((decoded (iso8601-parse ts))
(encoded (encode-time decoded)))
(format-time-string "%d-%m-%y, %H:%M[%z]" encoded)))
(defun mastodon-toot--iso-to-org (ts)
"Convert ISO8601 timestamp TS to something `org-read-date' can handle."
(when ts (let* ((decoded (iso8601-parse ts)))
(encode-time decoded))))
;;; DISPLAY KEYBINDINGS
(defun mastodon-toot--get-mode-kbinds ()
"Get a list of the keybindings in the mastodon-toot-mode."
(let* ((binds (copy-tree mastodon-toot-mode-map))
(prefix (car (cadr binds)))
(bindings (remove nil (mapcar (lambda (i)
(when (listp i) i))
(cadr binds)))))
(mapcar (lambda (b)
(setf (car b) (vector prefix (car b)))
b)
bindings)))
(defun mastodon-toot--format-kbind-command (cmd)
"Format CMD to be more readable.
e.g. mastodon-toot--send -> Send."
(let* ((str (symbol-name cmd))
(re "--\\(.*\\)$")
(str2 (save-match-data
(string-match re str)
(match-string 1 str))))
(capitalize (replace-regexp-in-string "-" " " str2))))
(defun mastodon-toot--format-kbind (kbind)
"Format a single keybinding, KBIND, for display in documentation."
(let ((key (concat "\\`"
(help-key-description (car kbind) nil)
"'"))
(command (mastodon-toot--format-kbind-command (cdr kbind))))
(substitute-command-keys
(format
(concat (mastodon-toot--comment " ")
"%s"
(mastodon-toot--comment " - %s"))
key command))))
(defun mastodon-toot--comment (str)
"Propertize STR with `mastodon-toot-docs-face'."
(propertize str
'face 'mastodon-toot-docs-face))
(defun mastodon-toot--format-kbinds (kbinds)
"Format a list of keybindings, KBINDS, for display in documentation."
(mapcar #'mastodon-toot--format-kbind kbinds))
(defvar-local mastodon-toot--kbinds-pairs nil
"Contains a list of paired toot compose buffer keybindings for inserting.")
(defun mastodon-toot--formatted-kbinds-pairs (kbinds-list longest)
"Return a list of strings each containing two formatted kbinds.
KBINDS-LIST is the list of formatted bindings to pair.
LONGEST is the length of the longest binding."
(when kbinds-list
(push (concat "\n"
(car kbinds-list)
(make-string (- (1+ longest) (length (car kbinds-list)))
?\ )
(cadr kbinds-list))
mastodon-toot--kbinds-pairs)
(mastodon-toot--formatted-kbinds-pairs (cddr kbinds-list) longest))
(reverse mastodon-toot--kbinds-pairs))
(defun mastodon-toot--formatted-kbinds-longest (kbinds-list)
"Return the length of the longest item in KBINDS-LIST."
(let ((lengths (mapcar #'length kbinds-list)))
(car (sort lengths #'>))))
;;; DISPLAY DOCS
(defun mastodon-toot--make-mode-docs ()
"Create formatted documentation text for the mastodon-toot-mode."
(let* ((kbinds (mastodon-toot--get-mode-kbinds))
(longest-kbind (mastodon-toot--formatted-kbinds-longest
(mastodon-toot--format-kbinds kbinds))))
(concat
(mastodon-toot--comment " Compose a new toot here. The following keybindings are available:")
(mapconcat #'identity
(mastodon-toot--formatted-kbinds-pairs
(mastodon-toot--format-kbinds kbinds)
longest-kbind)
nil))))
(defun mastodon-toot--format-reply-in-compose-string (reply-text)
"Format a REPLY-TEXT for display in compose buffer docs."
(let* ((rendered (mastodon-tl--render-text reply-text))
(no-props (substring-no-properties rendered))
;; FIXME: this replaces \n at end of every post, so we have to trim:
(no-newlines (string-trim
(replace-regexp-in-string "[\n]+" " " no-props)))
(reply-to (concat " Reply to: \"" no-newlines "\""))
(crop (truncate-string-to-width reply-to
mastodon-toot-orig-in-reply-length)))
(if (> (length no-newlines)
(length crop)) ; we cropped:
(concat crop "\n")
(concat reply-to "\n"))))
(defun mastodon-toot--display-docs-and-status-fields (&optional reply-text)
"Insert propertized text with documentation about `mastodon-toot-mode'.
Also includes and the status fields which will get updated based
on the status of NSFW, content warning flags, media attachments, etc.
REPLY-TEXT is the text of the toot being replied to."
(let ((divider
"|=================================================================|"))
(insert
(concat
(mastodon-toot--make-mode-docs) "\n"
(mastodon-toot--comment divider) "\n"
(propertize
(concat
" "
(propertize "Count"
'toot-post-counter t)
" ⋅ "
(propertize "Visibility"
'toot-post-visibility t)
" ⋅ "
(propertize "Language"
'toot-post-language t)
" "
(propertize "Scheduled"
'toot-post-scheduled t)
" "
(propertize "CW"
'toot-post-cw-flag t)
" "
(propertize "NSFW"
'toot-post-nsfw-flag t)
"\n"
" Attachments: "
(propertize "None "
'toot-attachments t)
"\n"
(if reply-text
(propertize
(mastodon-toot--format-reply-in-compose-string reply-text)
'toot-reply t)
"")
divider)
'face 'mastodon-toot-docs-face
'read-only "Edit your message below."
'toot-post-header t))
;; allow us to enter text after read-only header:
(propertize "\n"
'rear-nonsticky t))))
(defun mastodon-toot--most-restrictive-visibility (reply-visibility)
"Return REPLY-VISIBILITY or default visibility, whichever is more restrictive.
The default is given by `mastodon-toot--default-reply-visibility'."
(unless (null reply-visibility)
(let ((less-restrictive (member (intern mastodon-toot--default-reply-visibility)
mastodon-toot-visibility-list)))
(if (member (intern reply-visibility) less-restrictive)
mastodon-toot--default-reply-visibility reply-visibility))))
(defun mastodon-toot--setup-as-reply (reply-to-user reply-to-id reply-json)
"If REPLY-TO-USER is provided, inject their handle into the message.
If REPLY-TO-ID is provided, set `mastodon-toot--reply-to-id'.
REPLY-JSON is the full JSON of the toot being replied to."
(let ((reply-visibility (mastodon-toot--most-restrictive-visibility
(alist-get 'visibility reply-json)))
(reply-cw (alist-get 'spoiler_text reply-json)))
(when reply-to-user
(when (> (length reply-to-user) 0) ; self is "" unforch
(insert (format "%s " reply-to-user)))
(setq mastodon-toot--reply-to-id reply-to-id)
(unless (equal mastodon-toot--visibility reply-visibility)
(setq mastodon-toot--visibility reply-visibility))
(mastodon-toot--set-cw reply-cw))))
(defun mastodon-toot--update-status-fields (&rest _args)
"Update the status fields in the header based on the current state."
(ignore-errors ;; called from after-change-functions so let's not leak errors
(let* ((inhibit-read-only t)
(header-region (mastodon-tl--find-property-range 'toot-post-header
(point-min)))
(count-region (mastodon-tl--find-property-range 'toot-post-counter
(point-min)))
(visibility-region (mastodon-tl--find-property-range
'toot-post-visibility (point-min)))
(nsfw-region (mastodon-tl--find-property-range 'toot-post-nsfw-flag
(point-min)))
(cw-region (mastodon-tl--find-property-range 'toot-post-cw-flag
(point-min)))
(lang-region (mastodon-tl--find-property-range 'toot-post-language
(point-min)))
(scheduled-region (mastodon-tl--find-property-range 'toot-post-scheduled
(point-min)))
(toot-string (buffer-substring-no-properties (cdr header-region)
(point-max))))
(add-text-properties (car count-region) (cdr count-region)
(list 'display
(format "%s/%s chars"
(mastodon-toot--count-toot-chars toot-string)
(number-to-string mastodon-toot--max-toot-chars))))
(add-text-properties (car visibility-region) (cdr visibility-region)
(list 'display
(format "%s"
(if (equal
mastodon-toot--visibility
"private")
"followers-only"
mastodon-toot--visibility))))
(add-text-properties (car lang-region) (cdr lang-region)
(list 'display
(if mastodon-toot--language
(format "Lang: %s ⋅"
mastodon-toot--language)
"")))
(add-text-properties (car scheduled-region) (cdr scheduled-region)
(list 'display
(if mastodon-toot--scheduled-for
(format "Scheduled: %s ⋅"
(mastodon-toot--iso-to-human
mastodon-toot--scheduled-for))
"")))
(add-text-properties (car nsfw-region) (cdr nsfw-region)
(list 'display (if mastodon-toot--content-nsfw
(if mastodon-toot--media-attachments
"NSFW" "NSFW (for attachments only)")
"")
'face 'mastodon-cw-face))
(add-text-properties (car cw-region) (cdr cw-region)
(list 'invisible (not mastodon-toot--content-warning)
'face 'mastodon-cw-face)))))
(defun mastodon-toot--count-toot-chars (toot-string &optional cw)
"Count the characters in TOOT-STRING.
URLs always = 23, and domain names of handles are not counted.
This is how mastodon does it.
CW is the content warning, which contributes to the character count."
(with-temp-buffer
(switch-to-buffer (current-buffer))
(insert toot-string)
(goto-char (point-min))
;; handle URLs
(while (search-forward-regexp mastodon-toot-url-regex nil t)
; "\\w+://[^ \n]*" old regex
(replace-match "xxxxxxxxxxxxxxxxxxxxxxx")) ; 23 x's
;; handle @handles
(goto-char (point-min))
(while (search-forward-regexp mastodon-toot-handle-regex nil t)
(replace-match (match-string 2))) ; replace with handle only
(+ (length cw)
(length (buffer-substring (point-min) (point-max))))))
;;; DRAFTS
(defun mastodon-toot--save-toot-text (&rest _args)
"Save the current toot text in `mastodon-toot-current-toot-text'.
Added to `after-change-functions' in new toot buffers."
(let ((text (mastodon-toot--remove-docs)))
(unless (string-empty-p text)
(setq mastodon-toot-current-toot-text text))))
(defun mastodon-toot--open-draft-toot ()
"Prompt for a draft and compose a toot with it."
(interactive)
(if mastodon-toot-draft-toots-list
(let ((text (completing-read "Select draft toot: "
mastodon-toot-draft-toots-list
nil t)))
(if (mastodon-toot--compose-buffer-p)
(when (and (not (mastodon-toot--empty-p :text-only))
(y-or-n-p "Replace current text with draft?"))
(cl-pushnew mastodon-toot-current-toot-text
mastodon-toot-draft-toots-list)
(goto-char
(cdr (mastodon-tl--find-property-range 'toot-post-header
(point-min))))
(kill-region (point) (point-max))
;; to not save to kill-ring:
;; (delete-region (point) (point-max))
(insert text))
(mastodon-toot--compose-buffer nil nil nil text)))
(unless (mastodon-toot--compose-buffer-p)
(mastodon-toot--compose-buffer))
(message "No drafts available.")))
(defun mastodon-toot--delete-draft-toot ()
"Prompt for a draft toot and delete it."
(interactive)
(if mastodon-toot-draft-toots-list
(let ((draft (completing-read "Select draft to delete: "
mastodon-toot-draft-toots-list
nil t)))
(setq mastodon-toot-draft-toots-list
(cl-delete draft mastodon-toot-draft-toots-list :test #'equal))
(message "Draft deleted!"))
(message "No drafts to delete.")))
(defun mastodon-toot--delete-all-drafts ()
"Delete all drafts."
(interactive)
(setq mastodon-toot-draft-toots-list nil)
(message "All drafts deleted!"))
;;; PROPERTIZE TAGS AND HANDLES
(defun mastodon-toot--propertize-tags-and-handles (&rest _args)
"Propertize tags and handles in toot compose buffer.
Added to `after-change-functions'."
(when (mastodon-toot--compose-buffer-p)
(let ((header-region (mastodon-tl--find-property-range 'toot-post-header
(point-min)))
(face (when mastodon-toot--proportional-fonts-compose
'variable-pitch)))
;; cull any prev props:
;; stops all text after a handle or mention being propertized:
(set-text-properties (cdr header-region) (point-max) `(face ,face))
(mastodon-toot--propertize-item mastodon-toot-tag-regex
'success
(cdr header-region))
(mastodon-toot--propertize-item mastodon-toot-handle-regex
'mastodon-display-name-face
(cdr header-region))
(mastodon-toot--propertize-item mastodon-toot-url-regex
'link
(cdr header-region)))))
(defun mastodon-toot--propertize-item (regex face start)
"Propertize item matching REGEX with FACE starting from START."
(save-excursion
(goto-char start)
(cl-loop while (search-forward-regexp regex nil :noerror)
do (add-text-properties (match-beginning 2)
(match-end 2)
`(face ,face)))))
(defun mastodon-toot--compose-buffer-p ()
"Return t if compose buffer is current."
(or (mastodon-tl--buffer-type-eq 'edit-toot)
(mastodon-tl--buffer-type-eq 'new-toot)))
(defun mastodon-toot--fill-reply-in-compose ()
"Fill reply text in compose buffer to the width of the divider."
(save-excursion
(save-match-data
(let* ((fill-column 67))
(goto-char (point-min))
(when-let ((prop (text-property-search-forward 'toot-reply)))
(fill-region (prop-match-beginning prop)
(point)))))))
;;; COMPOSE BUFFER FUNCTION
(defun mastodon-toot--compose-buffer
(&optional reply-to-user reply-to-id reply-json initial-text edit)
"Create a new buffer to capture text for a new toot.
If REPLY-TO-USER is provided, inject their handle into the message.
If REPLY-TO-ID is provided, set the `mastodon-toot--reply-to-id' var.
REPLY-JSON is the full JSON of the toot being replied to.
INITIAL-TEXT is used by `mastodon-toot-insert-draft-toot' to add
a draft into the buffer.
EDIT means we are editing an existing toot, not composing a new one."
(let* ((buffer-name (if edit "*edit toot*" "*new toot*"))
(buffer-exists (get-buffer buffer-name))
(buffer (or buffer-exists (get-buffer-create buffer-name)))
(inhibit-read-only t)
(reply-text (alist-get 'content
(or (alist-get 'reblog reply-json)
reply-json)))
(previous-window-config (list (current-window-configuration)
(point-marker))))
(switch-to-buffer-other-window buffer)
(text-mode)
(mastodon-toot-mode t)
(setq mastodon-toot--visibility
(or (plist-get mastodon-profile-account-settings 'privacy)
;; use toot visibility setting from the server:
(mastodon-profile--get-source-pref 'privacy)
"public")) ; fallback
(unless buffer-exists
(if mastodon-toot-display-orig-in-reply-buffer
(progn
(mastodon-toot--display-docs-and-status-fields reply-text)
(mastodon-toot--fill-reply-in-compose))
(mastodon-toot--display-docs-and-status-fields))
;; `reply-to-user' (alone) is also used by `mastodon-tl--dm-user', so
;; perhaps we should not always call --setup-as-reply, or make its
;; workings conditional on reply-to-id. currently it only checks for
;; reply-to-user.
(mastodon-toot--setup-as-reply reply-to-user reply-to-id reply-json))
(unless mastodon-toot--max-toot-chars
;; no need to fetch from `mastodon-profile-account-settings' as
;; `mastodon-toot--max-toot-chars' is set when we set it
(mastodon-toot--get-max-toot-chars))
;; set up completion:
(when mastodon-toot--enable-completion
(set (make-local-variable 'completion-at-point-functions)
(add-to-list 'completion-at-point-functions
#'mastodon-toot--mentions-capf))
(add-to-list 'completion-at-point-functions
#'mastodon-toot--tags-capf)
;; company
(when (and mastodon-toot--use-company-for-completion
(require 'company nil :no-error))
(declare-function company-mode-on "company")
(set (make-local-variable 'company-backends)
(add-to-list 'company-backends 'company-capf))
(company-mode-on)))
;; after-change:
(make-local-variable 'after-change-functions)
(cl-pushnew #'mastodon-toot--save-toot-text after-change-functions)
(cl-pushnew #'mastodon-toot--update-status-fields after-change-functions)
(mastodon-toot--update-status-fields)
(cl-pushnew #'mastodon-toot--propertize-tags-and-handles after-change-functions)
(mastodon-toot--propertize-tags-and-handles)
(mastodon-toot--refresh-attachments-display)
;; draft toot text saving:
(setq mastodon-toot-current-toot-text nil)
;; if we set this before changing modes, it gets nuked:
(setq mastodon-toot-previous-window-config previous-window-config)
(when mastodon-toot--proportional-fonts-compose
(facemenu-set-face 'variable-pitch))
(when initial-text
(insert initial-text))))
;; flyspell ignore masto toot regexes:
(defvar flyspell-generic-check-word-predicate)
(defun mastodon-toot-mode-flyspell-verify ()
"A predicate function for `flyspell'.
Only text that is not one of these faces will be spell-checked."
(let ((faces '(mastodon-display-name-face
mastodon-toot-docs-face font-lock-comment-face
success link)))
(unless (eql (point) (point-min))
;; (point) is next char after the word. Must check one char before.
(let ((f (get-text-property (1- (point)) 'face)))
(not (memq f faces))))))
(defun mastodon-toot-mode-hook-fun ()
"Function for code to run in `mastodon-toot-mode-hook'."
;; disable auto-fill-mode:
(auto-fill-mode -1)
;; add flyspell predicate function:
(setq flyspell-generic-check-word-predicate
#'mastodon-toot-mode-flyspell-verify))
(add-hook 'mastodon-toot-mode-hook #'mastodon-toot-mode-hook-fun)
;;;###autoload
(add-hook 'mastodon-toot-mode-hook
#'mastodon-profile--fetch-server-account-settings-maybe)
(define-minor-mode mastodon-toot-mode
"Minor mode to capture Mastodon toots."
:keymap mastodon-toot-mode-map
:global nil)
(provide 'mastodon-toot)
;;; mastodon-toot.el ends here
;;; mastodon-tl.el --- Timeline functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-tl.el provides timeline functions.
;;; Code:
(require 'shr)
(require 'thingatpt) ; for word-at-point
(require 'time-date)
(require 'cl-lib)
(require 'mastodon-iso)
(require 'mpv nil :no-error)
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-notifications-get "mastodon")
(autoload 'mastodon-url-lookup "mastodon")
(autoload 'mastodon-auth--get-account-id "mastodon-auth")
(autoload 'mastodon-auth--get-account-name "mastodon-auth")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--build-array-params-alist "mastodon-http")
(autoload 'mastodon-http--build-params-string "mastodon-http")
(autoload 'mastodon-http--delete "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--get-json-async "mastodon-http")
(autoload 'mastodon-http--get-response-async "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(autoload 'mastodon-http--process-json "mastodon-http")
(autoload 'mastodon-http--put "mastodon-http")
(autoload 'mastodon-http--triage "mastodon-http")
(autoload 'mastodon-media--get-avatar-rendering "mastodon-media")
(autoload 'mastodon-media--get-media-link-rendering "mastodon-media")
(autoload 'mastodon-media--inline-images "mastodon-media")
(autoload 'mastodon-notifications--filter-types-list "mastodon-notifications")
(autoload 'mastodon-notifications--get-mentions "mastodon-notifications")
(autoload 'mastodon-profile--account-from-id "mastodon-profile")
(autoload 'mastodon-profile--extract-users-handles "mastodon-profile")
(autoload 'mastodon-profile--get-preferences-pref "mastodon-profile")
(autoload 'mastodon-profile--get-toot-author "mastodon-profile")
(autoload 'mastodon-profile--lookup-account-in-status "mastodon-profile")
(autoload 'mastodon-profile--make-author-buffer "mastodon-profile")
(autoload 'mastodon-profile--my-profile "mastodon-profile")
(autoload 'mastodon-profile--open-statuses-no-reblogs "mastodon-profile")
(autoload 'mastodon-profile--profile-json "mastodon-profile")
(autoload 'mastodon-profile--search-account-by-handle "mastodon-profile")
(autoload 'mastodon-profile--item-json "mastodon-profile")
(autoload 'mastodon-profile--view-author-profile "mastodon-profile")
(autoload 'mastodon-profile-mode "mastodon-profile")
(autoload 'mastodon-search--get-user-info "mastodon-search")
(autoload 'mastodon-search--insert-users-propertized "mastodon-search")
(autoload 'mastodon-search--propertize-user "mastodon-search")
(autoload 'mastodon-toot--compose-buffer "mastodon-toot")
(autoload 'mastodon-toot--delete-toot "mastodon-toot")
(autoload 'mastodon-toot--get-toot-edits "mastodon-toot")
(autoload 'mastodon-toot--iso-to-human "mastodon-toot")
(autoload 'mastodon-toot--schedule-toot "mastodon-toot")
(autoload 'mastodon-toot--set-toot-properties "mastodon-toot")
(autoload 'mastodon-toot--update-status-fields "mastodon-toot")
(autoload 'mastodon-search--buf-type "mastodon-search")
(autoload 'mastodon-http--api-search "mastodon-http")
(autoload 'mastodon-views--insert-users-propertized-note "mastodon-views") ; for search pagination
(autoload 'mastodon-http--get-response "mastodon-http")
(autoload 'mastodon-search--insert-heading "mastodon-search")
(defvar mastodon-toot--visibility)
(defvar mastodon-toot-mode)
(defvar mastodon-active-user)
(when (require 'mpv nil :no-error)
(declare-function mpv-start "mpv"))
(defvar mastodon-instance-url)
(defvar mastodon-toot-timestamp-format)
(defvar shr-use-fonts) ;; declare it since Emacs24 didn't have this
(defvar mastodon-mode-map)
;;; CUSTOMIZES
(defgroup mastodon-tl nil
"Timelines in Mastodon."
:prefix "mastodon-tl-"
:group 'mastodon)
(defcustom mastodon-tl--enable-relative-timestamps t
"Whether to show relative (to the current time) timestamps.
This will require periodic updates of a timeline buffer to
keep the timestamps current as time progresses."
:type '(boolean :tag "Enable relative timestamps and background updater task"))
(defcustom mastodon-tl--enable-proportional-fonts nil
"Nonnil to enable using proportional fonts when rendering HTML.
By default fixed width fonts are used."
:type '(boolean :tag "Enable using proportional rather than fixed \
width fonts when rendering HTML text"))
(defcustom mastodon-tl--display-caption-not-url-when-no-media t
"Display an image's caption rather than URL.
Only has an effect when `mastodon-tl--display-media-p' is set to
nil."
:type 'boolean)
(defcustom mastodon-tl--show-avatars nil
"Whether to enable display of user avatars in timelines."
:type '(boolean :tag "Whether to display user avatars in timelines"))
(defcustom mastodon-tl--show-stats t
"Whether to show toot stats (faves, boosts, replies counts)."
:type 'boolean)
(defcustom mastodon-tl--symbols
'((reply . ("💬" . "R"))
(boost . ("🔁" . "B"))
(favourite . ("⭐" . "F"))
(bookmark . ("🔖" . "K"))
(media . ("📹" . "[media]"))
(verified . ("" . "V"))
(locked . ("🔒" . "[locked]"))
(private . ("🔒" . "[followers]"))
(direct . ("✉" . "[direct]"))
(edited . ("✍" . "[edited]"))
(replied . ("⬇" . "↓"))
(reply-bar . ("┃" . "|")))
"A set of symbols (and fallback strings) to be used in timeline.
If a symbol does not look right (tofu), it means your
font settings do not support it."
:type '(alist :key-type symbol :value-type string))
(defcustom mastodon-tl-position-after-update nil
"Defines where `point' should be located after a timeline update.
Valid values are:
- nil Top/bottom depending on timeline type
- keep-point Keep original position of point
- last-old-toot The last toot before the new ones"
:type '(choice (const :tag "Top/bottom depending on timeline type" nil)
(const :tag "Keep original position of point" keep-point)
(const :tag "The last toot before the new ones" last-old-toot)))
(defcustom mastodon-tl--timeline-posts-count "20"
"Number of posts to display when loading a timeline.
Must be an integer between 20 and 40 inclusive."
:type '(string))
(defcustom mastodon-tl--hide-replies nil
"Whether to hide replies from the timelines.
Note that you can hide replies on a one-off basis by loading a
timeline with a simple prefix argument, `C-u'."
:type '(boolean :tag "Whether to hide replies from the timelines."))
(defcustom mastodon-tl--highlight-current-toot nil
"Whether to highlight the toot at point. Uses `cursor-face' special property."
:type '(boolean))
(defcustom mastodon-tl--expand-content-warnings 'server
"Whether to expand content warnings by default.
The API returns data about this setting on the server, but no
means to set it, so we roll our own option here to override the
server setting if desired. If you change the server setting and
want it to be respected by mastodon.el, you'll likely need to
either unset `mastodon-profile-acccount-preferences-data' and
re-load mastodon.el, or restart Emacs."
:type '(choice (const :tag "true" t)
(const :tag "false" nil)
(const :tag "follow server setting" server)))
;;; VARIABLES
(defvar-local mastodon-tl--buffer-spec nil
"A unique identifier and functions for each Mastodon buffer.")
(defvar-local mastodon-tl--update-point nil
"When updating a mastodon buffer this is where new toots will be inserted.
If nil `(point-min)' is used instead.")
(defvar-local mastodon-tl--after-update-marker nil
"Marker defining the position of point after the update is done.")
(defvar mastodon-tl--display-media-p t
"A boolean value stating whether to show media in timelines.")
(defvar-local mastodon-tl--timestamp-next-update nil
"The timestamp when the buffer should next be scanned to update the timestamps.")
(defvar-local mastodon-tl--timestamp-update-timer nil
"The timer that, when set will scan the buffer to update the timestamps.")
(defvar mastodon-tl--horiz-bar
(if (char-displayable-p ?―)
(make-string 12 ?―)
(make-string 12 ?-)))
;;; KEYMAPS
(defvar mastodon-tl--link-keymap
(let ((map (make-sparse-keymap)))
(define-key map [return] #'mastodon-tl--do-link-action-at-point)
(define-key map [mouse-2] #'mastodon-tl--do-link-action)
(define-key map [follow-link] 'mouse-face)
map)
"The keymap for link-like things in buffer (except for shr.el generate links).
This will make the region of text act like like a link with mouse
highlighting, mouse click action tabbing to next/previous link
etc.")
(defvar mastodon-tl--shr-map-replacement
(let ((map (make-sparse-keymap)))
(set-keymap-parent map shr-map)
;; Replace the move to next/previous link bindings with our
;; version that knows about more types of links.
(define-key map [remap shr-next-link] #'mastodon-tl--next-tab-item)
(define-key map [remap shr-previous-link] #'mastodon-tl--previous-tab-item)
;; keep new my-profile binding; shr 'O' doesn't work here anyway
(define-key map (kbd "O") #'mastodon-profile--my-profile)
;; remove shr's u binding, as it the maybe-probe-and-copy-url
;; is already bound to w also
(define-key map (kbd "u") #'mastodon-tl--update)
(define-key map [remap shr-browse-url] #'mastodon-url-lookup)
map)
"The keymap to be set for shr.el generated links that are not images.
We need to override the keymap so tabbing will navigate to all
types of mastodon links and not just shr.el-generated ones.")
(defvar mastodon-tl--shr-image-map-replacement
(let ((map (make-sparse-keymap)))
(set-keymap-parent map (if (boundp 'shr-image-map)
shr-image-map
shr-map))
;; Replace the move to next/previous link bindings with our
;; version that knows about more types of links.
(define-key map [remap shr-next-link] #'mastodon-tl--next-tab-item)
(define-key map [remap shr-previous-link] #'mastodon-tl--previous-tab-item)
;; browse-url loads the preview only, we want browse-image
;; on RET to browse full sized image URL
(define-key map [remap shr-browse-url] #'shr-browse-image)
;; remove shr's u binding, as it the maybe-probe-and-copy-url
;; is already bound to w also
(define-key map (kbd "u") #'mastodon-tl--update)
;; keep new my-profile binding; shr 'O' doesn't work here anyway
(define-key map (kbd "O") #'mastodon-profile--my-profile)
(define-key map (kbd "<C-return>") #'mastodon-tl--mpv-play-video-at-point)
(define-key map (kbd "<mouse-2>") #'mastodon-tl--click-image-or-video)
map)
"The keymap to be set for shr.el generated image links.
We need to override the keymap so tabbing will navigate to all
types of mastodon links and not just shr.el-generated ones.")
(defvar mastodon-tl--byline-link-keymap
(when (require 'mpv nil :no-error)
(let ((map (make-sparse-keymap)))
(define-key map (kbd "<C-return>") #'mastodon-tl--mpv-play-video-from-byline)
(define-key map (kbd "RET") #'mastodon-profile--get-toot-author)
map))
"The keymap to be set for the author byline.
It is active where point is placed by `mastodon-tl--goto-next-item.'")
;;; MACROS
(defmacro with-mastodon-buffer (buffer mode-fun other-window &rest body)
"Evaluate BODY in a new or existing buffer called BUFFER.
MODE-FUN is called to set the major mode.
OTHER-WINDOW means call `switch-to-buffer-other-window' rather
than `switch-to-buffer'."
(declare (debug t)
(indent 3))
`(with-current-buffer (get-buffer-create ,buffer)
(let ((inhibit-read-only t))
(erase-buffer)
(funcall ,mode-fun)
(if ,other-window
(switch-to-buffer-other-window ,buffer)
(switch-to-buffer ,buffer))
,@body)))
(defmacro mastodon-tl--do-if-item (&rest body)
"Execute BODY if we have an item at point."
(declare (debug t))
`(if (and (not (mastodon-tl--profile-buffer-p))
(not (mastodon-tl--property 'item-json))) ; includes users but not tags
(message "Looks like there's no item at point?")
,@body))
(defmacro mastodon-tl--do-if-item-strict (&rest body)
"Execute BODY if we have a toot object at point.
Includes boosts, and notifications that display toots."
(declare (debug t))
`(if (not (equal 'toot (mastodon-tl--property 'item-type :no-move)))
(message "Looks like there's no toot at point?")
,@body))
;;; NAV
(defun mastodon-tl--scroll-up-command ()
"Call `scroll-up-command', loading more toots if necessary.
If we hit `point-max', call `mastodon-tl--more' then `scroll-up-command'."
(interactive)
(if (not (equal (point) (point-max)))
(scroll-up-command)
(mastodon-tl--more)
(scroll-up-command)))
(defun mastodon-tl--next-tab-item (&optional previous)
"Move to the next interesting item.
This could be the next toot, link, or image; whichever comes first.
Don't move if nothing else to move to is found, i.e. near the end of the buffer.
This also skips tab items in invisible text, i.e. hidden spoiler text.
PREVIOUS means move to previous item."
(interactive)
(let (next-range
(search-pos (point)))
(while (and (setq next-range
(mastodon-tl--find-next-or-previous-property-range
'mastodon-tab-stop search-pos previous))
(get-text-property (car next-range) 'invisible)
(setq search-pos (if previous
(1- (car next-range))
(1+ (cdr next-range)))))
;; do nothing, all the action is in the while condition
)
(if (null next-range)
(message "Nothing else here.")
(goto-char (car next-range))
(message "%s" (mastodon-tl--property 'help-echo :no-move)))))
(defun mastodon-tl--previous-tab-item ()
"Move to the previous interesting item.
This could be the previous toot, link, or image; whichever comes
first. Don't move if nothing else to move to is found, i.e. near
the start of the buffer. This also skips tab items in invisible
text, i.e. hidden spoiler text."
(interactive)
(mastodon-tl--next-tab-item :previous))
(defun mastodon-tl--goto-item-pos (find-pos refresh &optional pos)
"Search for item with function FIND-POS.
If search returns nil, execute REFRESH function.
Optionally start from POS."
(let* ((npos (or ; toot/user items have byline:
(funcall find-pos
(or pos (point))
;; 'item-type ; breaks nav to last item in a view?
'byline
(current-buffer)))))
(if npos
(if (not (or
;; (get-text-property npos 'item-id) ; toots, users, not tags
(get-text-property npos 'item-type))) ; generic
(mastodon-tl--goto-item-pos find-pos refresh npos)
(goto-char npos)
;; force display of help-echo on moving to a toot byline:
(mastodon-tl--message-help-echo))
;; FIXME: this doesn't really work, as the funcall doesn't return if we
;; run into an endless refresh loop
(condition-case nil
(funcall refresh)
(error "No more items")))))
(defun mastodon-tl--goto-next-item ()
"Jump to next item.
Load more items it no next item."
(interactive)
(mastodon-tl--goto-item-pos 'next-single-property-change
'mastodon-tl--more))
(defun mastodon-tl--goto-prev-item ()
"Jump to previous item.
Update if no previous items"
(interactive)
(mastodon-tl--goto-item-pos 'previous-single-property-change
'mastodon-tl--update))
(defun mastodon-tl--goto-first-item ()
"Jump to first toot or item in buffer.
Used on initializing a timeline or thread."
;; goto-next-item assumes we already have items, and is therefore
;; incompatible with any view where it is possible to have no items.
;; when that is the case the call to goto-toot-pos loops infinitely
(goto-char (point-min))
(mastodon-tl--goto-item-pos 'next-single-property-change
'next-line))
;; (mastodon-tl--goto-next-item))
;;; TIMELINES
(defun mastodon-tl--get-federated-timeline (&optional prefix local)
"Open federated timeline.
If LOCAL, get only local timeline.
With a single PREFIX arg, hide-replies.
With a double PREFIX arg, only show posts with media."
(interactive "p")
(let ((params `(("limit" . ,mastodon-tl--timeline-posts-count))))
;; avoid adding 'nil' to our params alist:
(when (eq prefix 16)
(push '("only_media" . "true") params))
(when local
(push '("local" . "true") params))
(message "Loading federated timeline...")
(mastodon-tl--init (if local "local" "federated")
"timelines/public" 'mastodon-tl--timeline nil
params
(when (eq prefix 4) t))))
(defun mastodon-tl--get-home-timeline (&optional arg)
"Open home timeline.
With a single prefix ARG, hide replies."
(interactive "p")
(message "Loading home timeline...")
(mastodon-tl--init "home" "timelines/home" 'mastodon-tl--timeline nil
`(("limit" . ,mastodon-tl--timeline-posts-count))
(when (eq arg 4) t)))
(defun mastodon-tl--get-local-timeline (&optional prefix)
"Open local timeline.
With a single PREFIX arg, hide-replies.
With a double PREFIX arg, only show posts with media."
(interactive "p")
(message "Loading local timeline...")
(mastodon-tl--get-federated-timeline prefix :local))
(defun mastodon-tl--get-tag-timeline (&optional prefix tag)
"Prompt for tag and opens its timeline.
Optionally load TAG timeline directly.
With a single PREFIX arg, only show posts with media.
With a double PREFIX arg, limit results to your own instance."
(interactive "p")
(let* ((word (or (word-at-point) ""))
(input (or tag (read-string
(format "Load timeline for tag (%s): " word))))
(tag (or tag (if (string-empty-p input) word input))))
(message "Loading timeline for #%s..." tag)
(mastodon-tl--show-tag-timeline prefix tag)))
(defun mastodon-tl--show-tag-timeline (&optional prefix tag)
"Opens a new buffer showing the timeline of posts with hastag TAG.
If TAG is a list, show a timeline for all tags.
With a single PREFIX arg, only show posts with media.
With a double PREFIX arg, limit results to your own instance."
(let ((params `(("limit" . ,mastodon-tl--timeline-posts-count))))
;; avoid adding 'nil' to our params alist:
(when (eq prefix 4)
(push '("only_media" . "true") params))
(when (eq prefix 16)
(push '("local" . "true") params))
(when (listp tag)
(let ((list (mastodon-http--build-array-params-alist "any[]" (cdr tag))))
(while list
(push (pop list) params))))
(mastodon-tl--init
(if (listp tag) "tags-multiple" (concat "tag-" tag))
(concat "timelines/tag/" (if (listp tag) (car tag) tag)) ; must be /tag/:sth
'mastodon-tl--timeline
nil
params)))
;;; BYLINES, etc.
(defun mastodon-tl--message-help-echo ()
"Call message on `help-echo' property at point.
Do so if type of status at poins is not follow_request/follow."
(let ((type (alist-get 'type
(mastodon-tl--property 'item-json :no-move)))
(echo (mastodon-tl--property 'help-echo :no-move)))
(when echo ; not for followers/following in profile
(unless (or (string= type "follow_request")
(string= type "follow")) ; no counts for these
(message "%s" (mastodon-tl--property 'help-echo :no-move))))))
(defun mastodon-tl--byline-author (toot &optional avatar)
"Propertize author of TOOT.
With arg AVATAR, include the account's avatar image."
(let-alist toot
(concat
;; avatar insertion moved up to `mastodon-tl--byline' by default to be
;; outside 'byline propt.
(when (and avatar ; used by `mastodon-profile--format-user'
mastodon-tl--show-avatars
mastodon-tl--display-media-p
(if (version< emacs-version "27.1")
(image-type-available-p 'imagemagick)
(image-transforms-p)))
(mastodon-media--get-avatar-rendering .account.avatar))
(propertize (if (not (string-empty-p .account.display_name))
.account.display_name
.account.username)
'face 'mastodon-display-name-face
;; enable playing of videos when point is on byline:
'attachments (mastodon-tl--get-attachments-for-byline toot)
'keymap mastodon-tl--byline-link-keymap
;; echo faves count when point on post author name:
;; which is where --goto-next-toot puts point.
'help-echo
;; but don't add it to "following"/"follows" on profile views:
;; we don't have a tl--buffer-spec yet:
(unless (or (string-suffix-p "-followers*" (buffer-name))
(string-suffix-p "-following*" (buffer-name)))
(mastodon-tl--format-byline-help-echo toot)))
" ("
(propertize (concat "@" .account.acct)
'face 'mastodon-handle-face
'mouse-face 'highlight
'mastodon-tab-stop 'user-handle
'account .account
'shr-url .account.url
'keymap mastodon-tl--link-keymap
'mastodon-handle (concat "@" .account.acct)
'help-echo (concat "Browse user profile of @" .account.acct))
")")))
(defun mastodon-tl--format-byline-help-echo (toot)
"Format a help-echo for byline of TOOT.
Displays a toot's media types and optionally the binding to play
moving image media from the byline.
Used when point is at the start of a byline, i.e. where
`mastodon-tl--goto-next-item' leaves point."
(let* ((toot-to-count
(or ; simply praying this order works
(alist-get 'status toot) ; notifications timeline
;; fol-req notif, has 'type placed before boosts coz fol-reqs have
;; a (useless) reblog entry:
(when (and (or (mastodon-tl--buffer-type-eq 'notifications)
(mastodon-tl--buffer-type-eq 'mentions))
(alist-get 'type toot))
toot)
(alist-get 'reblog toot) ; boosts
toot)) ; everything else
(fol-req-p (or (string= (alist-get 'type toot-to-count) "follow")
(string= (alist-get 'type toot-to-count) "follow_request"))))
(unless fol-req-p
(let* ((media-types (mastodon-tl--get-media-types toot))
(format-media (when media-types
(format "media: %s"
(mapconcat #'identity media-types " "))))
(format-media-binding (when (and (or (member "video" media-types)
(member "gifv" media-types))
(require 'mpv nil :no-error))
(format " | C-RET to view with mpv"))))
(format "%s" (concat format-media format-media-binding))))))
(defun mastodon-tl--get-media-types (toot)
"Return a list of the media attachment types of the TOOT at point."
(let* ((attachments (mastodon-tl--field 'media_attachments toot)))
(mastodon-tl--map-alist 'type attachments)))
(defun mastodon-tl--get-attachments-for-byline (toot)
"Return a list of attachment URLs and types for TOOT.
The result is added as an attachments property to author-byline."
(let ((media-attachments (mastodon-tl--field 'media_attachments toot)))
(mapcar (lambda (attachment)
(let-alist attachment
(list :url (or .remote_url .url) ; fallback for notifications
:type .type)))
media-attachments)))
(defun mastodon-tl--byline-boosted (toot)
"Add byline for boosted data from TOOT."
(let ((reblog (alist-get 'reblog toot)))
(when reblog
(concat
"\n " (propertize "Boosted" 'face 'mastodon-boosted-face)
" " (mastodon-tl--byline-author reblog)))))
(defun mastodon-tl--format-faved-or-boosted-byline (letter)
"Format the byline marker for a boosted or favourited status.
LETTER is a string, F for favourited, B for boosted, or K for bookmarked."
(let ((help-string (cond ((equal letter "F")
"favourited")
((equal letter "B")
"boosted")
((equal letter (or "🔖" "K"))
"bookmarked"))))
(format "(%s) "
(propertize letter 'face 'mastodon-boost-fave-face
;; emojify breaks this for 🔖:
'help-echo (format "You have %s this status."
help-string)))))
(defun mastodon-tl--byline (toot author-byline action-byline &optional detailed-p)
"Generate byline for TOOT.
AUTHOR-BYLINE is a function for adding the author portion of
the byline that takes one variable.
ACTION-BYLINE is a function for adding an action, such as boosting,
favouriting and following to the byline. It also takes a single function.
By default it is `mastodon-tl--byline-boosted'.
DETAILED-P means display more detailed info. For now
this just means displaying toot client."
(let* ((created-time
;; bosts and faves in notifs view
;; (makes timestamps be for the original toot not the boost/fave):
(or (mastodon-tl--field 'created_at
(mastodon-tl--field 'status toot))
;; all other toots, inc. boosts/faves in timelines:
;; (mastodon-tl--field auto fetches from reblogs if needed):
(mastodon-tl--field 'created_at toot)))
(parsed-time (date-to-time created-time))
(faved (equal 't (mastodon-tl--field 'favourited toot)))
(boosted (equal 't (mastodon-tl--field 'reblogged toot)))
(bookmarked (equal 't (mastodon-tl--field 'bookmarked toot)))
(visibility (mastodon-tl--field 'visibility toot))
(account (alist-get 'account toot))
(avatar-url (alist-get 'avatar account))
(type (alist-get 'type toot))
(edited-time (alist-get 'edited_at toot))
(edited-parsed (when edited-time (date-to-time edited-time))))
(concat
;; Boosted/favourited markers are not technically part of the byline, so
;; we don't propertize them with 'byline t', as per the rest. This
;; ensures that `mastodon-tl--goto-next-item' puts point on
;; author-byline, not before the (F) or (B) marker. Not propertizing like
;; this makes the behaviour of these markers consistent whether they are
;; displayed for an already boosted/favourited toot or as the result of
;; the toot having just been favourited/boosted.
(concat (when boosted
(mastodon-tl--format-faved-or-boosted-byline
(mastodon-tl--symbol 'boost)))
(when faved
(mastodon-tl--format-faved-or-boosted-byline
(mastodon-tl--symbol 'favourite)))
(when bookmarked
(mastodon-tl--format-faved-or-boosted-byline
(mastodon-tl--symbol 'bookmark))))
;; we remove avatars from the byline also, so that they also do not mess
;; with `mastodon-tl--goto-next-item':
(when (and mastodon-tl--show-avatars
mastodon-tl--display-media-p
(if (version< emacs-version "27.1")
(image-type-available-p 'imagemagick)
(image-transforms-p)))
(mastodon-media--get-avatar-rendering avatar-url))
(propertize
(concat
;; we propertize help-echo format faves for author name
;; in `mastodon-tl--byline-author'
(funcall author-byline toot)
;; visibility:
(cond ((equal visibility "direct")
(propertize (concat " " (mastodon-tl--symbol 'direct))
'help-echo visibility))
((equal visibility "private")
(propertize (concat " " (mastodon-tl--symbol 'private))
'help-echo visibility)))
(funcall action-byline toot)
" "
(propertize
(format-time-string mastodon-toot-timestamp-format parsed-time)
'timestamp parsed-time
'display (if mastodon-tl--enable-relative-timestamps
(mastodon-tl--relative-time-description parsed-time)
parsed-time))
(when detailed-p
(let* ((app (alist-get 'application toot))
(app-name (alist-get 'name app))
(app-url (alist-get 'website app)))
(when app
(concat
(propertize " via " 'face 'default)
(propertize app-name
'face 'mastodon-display-name-face
'follow-link t
'mouse-face 'highlight
'mastodon-tab-stop 'shr-url
'shr-url app-url
'help-echo app-url
'keymap mastodon-tl--shr-map-replacement)))))
(if edited-time
(concat
" "
(mastodon-tl--symbol 'edited)
" "
(propertize
(format-time-string mastodon-toot-timestamp-format
edited-parsed)
'face 'font-lock-comment-face
'timestamp edited-parsed
'display (if mastodon-tl--enable-relative-timestamps
(mastodon-tl--relative-time-description edited-parsed)
edited-parsed)))
"")
(propertize (concat "\n " mastodon-tl--horiz-bar)
'face 'default)
(if (and mastodon-tl--show-stats
(not (member type '("follow" "follow_request"))))
(mastodon-tl--toot-stats toot)
"")
"\n")
'favourited-p faved
'boosted-p boosted
'bookmarked-p bookmarked
'edited edited-time
'edit-history (when edited-time
(mastodon-toot--get-toot-edits (alist-get 'id toot)))
'byline t))))
;;; TIMESTAMPS
(defun mastodon-tl--relative-time-details (timestamp &optional current-time)
"Return cons of (DESCRIPTIVE STRING . NEXT-CHANGE) for the TIMESTAMP.
Use the optional CURRENT-TIME as the current time (only used for
reliable testing).
The descriptive string is a human readable version relative to
the current time while the next change timestamp give the first
time that this description will change in the future.
TIMESTAMP is assumed to be in the past."
(let* ((time-difference (time-subtract current-time timestamp))
(seconds-difference (float-time time-difference))
(tmp (mastodon-tl--human-duration (max 0 seconds-difference))))
(cons (concat (car tmp) " ago")
(time-add current-time (cdr tmp)))))
(defun mastodon-tl--relative-time-description (timestamp &optional current-time)
"Return a string with a human readable TIMESTAMP relative to the current time.
Use the optional CURRENT-TIME as the current time (only used for
reliable testing).
E.g. this could return something like \"1 min ago\", \"yesterday\", etc.
TIME-STAMP is assumed to be in the past."
(car (mastodon-tl--relative-time-details timestamp current-time)))
;;; RENDERING HTML, LINKS, HASHTAGS, HANDLES
(defun mastodon-tl--render-text (string &optional toot)
"Return a propertized text rendering the given HTML string STRING.
The contents comes from the given TOOT which is used in parsing
links in the text. If TOOT is nil no parsing occurs."
(when string ; handle rare empty notif server bug
(with-temp-buffer
(insert string)
(let ((shr-use-fonts mastodon-tl--enable-proportional-fonts)
(shr-width (when mastodon-tl--enable-proportional-fonts
(- (window-width) 3))))
(shr-render-region (point-min) (point-max)))
;; Make all links a tab stop recognized by our own logic, make things point
;; to our own logic (e.g. hashtags), and update keymaps where needed:
(when toot
(let (region)
(while (setq region (mastodon-tl--find-property-range
'shr-url (or (cdr region) (point-min))))
(mastodon-tl--process-link toot
(car region) (cdr region)
(get-text-property (car region) 'shr-url)))))
(buffer-string))))
(defun mastodon-tl--process-link (toot start end url)
"Process link URL in TOOT as hashtag, userhandle, or normal link.
START and END are the boundaries of the link in the toot."
(let* (mastodon-tab-stop-type
keymap
(help-echo (get-text-property start 'help-echo))
extra-properties
;; handle calling this on non-toots, e.g. for profiles:
(toot-url (when (proper-list-p toot)
(mastodon-tl--field 'url toot)))
(toot-url (when toot-url (url-generic-parse-url toot-url)))
(toot-instance-url (if toot-url
(concat (url-type toot-url) "://"
(url-host toot-url))
mastodon-instance-url))
(link-str (buffer-substring-no-properties start end))
(maybe-hashtag (mastodon-tl--extract-hashtag-from-url
url toot-instance-url))
(maybe-userhandle
(if (proper-list-p toot) ; fails for profile buffers?
(or (mastodon-tl--userhandle-from-mentions toot link-str)
;; FIXME: if prev always works, cut this:
(mastodon-tl--extract-userhandle-from-url url link-str))
(mastodon-tl--extract-userhandle-from-url url link-str))))
(cond (;; Hashtags:
maybe-hashtag
(setq mastodon-tab-stop-type 'hashtag
keymap mastodon-tl--link-keymap
help-echo (concat "Browse tag #" maybe-hashtag)
extra-properties (list 'mastodon-tag maybe-hashtag)))
(;; User handles:
maybe-userhandle
;; this fails on mentions in profile notes:
(let ((maybe-userid (when (proper-list-p toot)
(mastodon-tl--extract-userid-toot
toot link-str))))
(setq mastodon-tab-stop-type 'user-handle
keymap mastodon-tl--link-keymap
help-echo (concat "Browse user profile of " maybe-userhandle)
extra-properties (append
(list 'mastodon-handle maybe-userhandle)
(when maybe-userid
(list 'account-id maybe-userid))))))
;; Anything else:
(t ; Leave it as a url handled by shr.el.
(setq keymap (if (eq shr-map (get-text-property start 'keymap))
mastodon-tl--shr-map-replacement
mastodon-tl--shr-image-map-replacement)
mastodon-tab-stop-type 'shr-url)))
(add-text-properties start end
(append
(list 'mastodon-tab-stop mastodon-tab-stop-type
'keymap keymap
'help-echo help-echo)
extra-properties))))
(defun mastodon-tl--userhandle-from-mentions (toot link)
"Extract a user handle from mentions in json TOOT.
LINK is maybe the `@handle' to search for."
(mastodon-tl--extract-el-from-mentions 'acct toot link))
(defun mastodon-tl--extract-userid-toot (toot link)
"Extract a user id for an ACCT from mentions in a TOOT.
LINK is maybe the `@handle' to search for."
(mastodon-tl--extract-el-from-mentions 'id toot link))
(defun mastodon-tl--extract-el-from-mentions (el toot link)
"Extract element EL from TOOT mentions that matches LINK.
LINK should be a simple handle string with no domain, i.e. \"@user\".
Return nil if no matching element."
;; Must return nil if nothing found!
(let ((mentions (append (alist-get 'mentions toot) nil)))
(when mentions
(let* ((mention (pop mentions))
(name (substring-no-properties link 1 (length link))) ; cull @
return)
(while mention
(when (string= name (alist-get 'username mention))
(setq return (alist-get el mention)))
(setq mention (pop mentions)))
return))))
(defun mastodon-tl--extract-userhandle-from-url (url buffer-text)
"Return the user hande the URL points to or nil if it is not a profile link.
BUFFER-TEXT is the text covered by the link with URL, for a user profile
this should be of the form <at-sign><user id>, e.g. \"@Gargon\"."
(let* ((parsed-url (url-generic-parse-url url))
(local-p (string=
(url-host (url-generic-parse-url mastodon-instance-url))
(url-host parsed-url))))
(when (and (string= "@" (substring buffer-text 0 1))
;; don't error on domain only url (rare):
(not (string= "" (url-filename parsed-url)))
(string= (downcase buffer-text)
(downcase (substring (url-filename parsed-url) 1))))
(if local-p
buffer-text ; no instance suffix for local mention
(concat buffer-text "@" (url-host parsed-url))))))
(defun mastodon-tl--extract-hashtag-from-url (url instance-url)
"Return the hashtag that URL points to or nil if URL is not a tag link.
INSTANCE-URL is the url of the instance for the toot that the link
came from (tag links always point to a page on the instance publishing
the toot)."
(cond
;; Mastodon type tag link:
((string-prefix-p (concat instance-url "/tags/") url)
(substring url (length (concat instance-url "/tags/"))))
;; Link from some other ostatus site we've encountered:
((string-prefix-p (concat instance-url "/tag/") url)
(substring url (length (concat instance-url "/tag/"))))
;; If nothing matches we assume it is not a hashtag link:
(t nil)))
;;; HYPERLINKS
(defun mastodon-tl--make-link (string link-type)
"Return a propertized version of STRING that will act like link.
LINK-TYPE is the type of link to produce."
(let ((help-text (cond ((eq link-type 'content-warning)
"Toggle hidden text")
(t
(error "Unknown link type %s" link-type)))))
(propertize string
'mastodon-tab-stop link-type
'mouse-face 'highlight
'keymap mastodon-tl--link-keymap
'help-echo help-text)))
(defun mastodon-tl--do-link-action-at-point (position)
"Do the action of the link at POSITION.
Used for hitting RET on a given link."
(interactive "d")
(let ((link-type (get-text-property position 'mastodon-tab-stop)))
(cond ((eq link-type 'content-warning)
(mastodon-tl--toggle-spoiler-text position))
((eq link-type 'hashtag)
(mastodon-tl--show-tag-timeline
nil (get-text-property position 'mastodon-tag)))
;; 'account / 'account-id is not set for mentions, only bylines
((eq link-type 'user-handle)
(let ((account-json (get-text-property position 'account))
(account-id (get-text-property position 'account-id)))
(cond
(account-json
(mastodon-profile--make-author-buffer
account-json))
(account-id
(mastodon-profile--make-author-buffer
(mastodon-profile--account-from-id account-id)))
(t
(let ((account
(mastodon-profile--search-account-by-handle
(get-text-property position 'mastodon-handle))))
;; never call make-author-buffer on nil account:
(if account
(mastodon-profile--make-author-buffer account)
;; optional webfinger lookup:
(if (y-or-n-p
"Search for account returned nothing. Perform URL lookup?")
(mastodon-url-lookup (get-text-property position 'shr-url))
(message "Unable to find account."))))))))
(t
(error "Unknown link type %s" link-type)))))
(defun mastodon-tl--do-link-action (event)
"Do the action of the link at point.
Used for a mouse-click EVENT on a link."
(interactive "e")
(mastodon-tl--do-link-action-at-point (posn-point (event-end event))))
;;; CONTENT WARNINGS
(defun mastodon-tl--has-spoiler (toot)
"Check if the given TOOT has a spoiler text.
Spoiler text should initially be shown only while the main
content should be hidden."
(let ((spoiler (mastodon-tl--field 'spoiler_text toot)))
(and spoiler (> (length spoiler) 0))))
(defun mastodon-tl--toggle-spoiler-text (position)
"Toggle the visibility of the spoiler text at/after POSITION."
(let ((inhibit-read-only t)
(spoiler-text-region (mastodon-tl--find-property-range
'mastodon-content-warning-body position nil)))
(if (not spoiler-text-region)
(message "No spoiler text here")
(add-text-properties (car spoiler-text-region) (cdr spoiler-text-region)
(list 'invisible
(not (get-text-property (car spoiler-text-region)
'invisible)))))))
(defun mastodon-tl--toggle-spoiler-text-in-toot ()
"Toggle the visibility of the spoiler text in the current toot."
(interactive)
(let* ((toot-range (or (mastodon-tl--find-property-range
'item-json (point))
(mastodon-tl--find-property-range
'item-json (point) t)))
(spoiler-range (when toot-range
(mastodon-tl--find-property-range
'mastodon-content-warning-body
(car toot-range)))))
(cond ((null toot-range)
(message "No toot here"))
((or (null spoiler-range)
(> (car spoiler-range) (cdr toot-range)))
(message "No content warning text here"))
(t
(mastodon-tl--toggle-spoiler-text (car spoiler-range))))))
(defun mastodon-tl--clean-tabs-and-nl (string)
"Remove tabs and newlines from STRING."
(replace-regexp-in-string "[\t\n ]*\\'" "" string))
(defun mastodon-tl--spoiler (toot)
"Render TOOT with spoiler message.
This assumes TOOT is a toot with a spoiler message.
The main body gets hidden and only the spoiler text and the
content warning message are displayed. The content warning
message is a link which unhides/hides the main body."
(let* ((spoiler (mastodon-tl--field 'spoiler_text toot))
(string (mastodon-tl--set-face
(mastodon-tl--clean-tabs-and-nl
(mastodon-tl--render-text spoiler toot))
'default))
(message (concat " " mastodon-tl--horiz-bar "\n "
(mastodon-tl--make-link
(concat "CW: " string)
'content-warning)
"\n "
mastodon-tl--horiz-bar "\n"))
(cw (mastodon-tl--set-face message 'mastodon-cw-face)))
(concat
cw
(propertize (mastodon-tl--content toot)
'invisible
(let ((cust mastodon-tl--expand-content-warnings))
(cond ((eq t cust)
nil)
((eq nil cust)
t)
((eq 'server cust)
(unless (eq t
;; If something goes wrong reading prefs,
;; just return nil so CWs show by default.
(condition-case nil
(mastodon-profile--get-preferences-pref
'reading:expand:spoilers)
(error nil)))
t))))
'mastodon-content-warning-body t))))
;;; MEDIA
(defun mastodon-tl--media (toot)
"Retrieve a media attachment link for TOOT if one exists."
(let* ((media-attachments (mastodon-tl--field 'media_attachments toot))
(media-string (mapconcat #'mastodon-tl--media-attachment
media-attachments "")))
(if (not (and mastodon-tl--display-media-p
(string-empty-p media-string)))
(concat "\n" media-string)
"")))
(defun mastodon-tl--media-attachment (media-attachment)
"Return a propertized string for MEDIA-ATTACHMENT."
(let-alist media-attachment
(let ((display-str
(if (and mastodon-tl--display-caption-not-url-when-no-media
.description)
(concat "Media:: " .description)
(concat "Media:: " .preview_url))))
(if mastodon-tl--display-media-p
(mastodon-media--get-media-link-rendering ; placeholder: "[img]"
.preview_url (or .remote_url .url) .type .description) ; 2nd arg for shr-browse-url
;; return URL/caption:
(concat (mastodon-tl--propertize-img-str-or-url
(concat "Media:: " .preview_url) ; string
.preview_url .remote_url .type .description
display-str ; display
'shr-link)
"\n")))))
(defun mastodon-tl--propertize-img-str-or-url
(str media-url full-remote-url type help-echo &optional display face)
"Propertize an media placeholder string \"[img]\" or media URL.
STR is the string to propertize, MEDIA-URL is the preview link,
FULL-REMOTE-URL is the link to the full resolution image on the
server, TYPE is the media type.
HELP-ECHO, DISPLAY, and FACE are the text properties to add."
(propertize str
'media-url media-url
'media-state (when (string= str "[img]") 'needs-loading)
'media-type 'media-link
'mastodon-media-type type
'display display
'face face
'mouse-face 'highlight
'mastodon-tab-stop 'image ; for do-link-action-at-point
'image-url full-remote-url ; for shr-browse-image
'keymap mastodon-tl--shr-image-map-replacement
'help-echo (if (or (string= type "image")
(string= type nil)
(string= type "unknown")) ; handle borked images
help-echo
(concat help-echo "\nC-RET: play " type " with mpv"))))
;; POLLS
(defun mastodon-tl--format-poll-option (option option-counter longest-option)
"Format poll OPTION. OPTION-COUNTER is just a counter.
LONGEST-OPTION is the option whose length determines the formatting."
(format "%s: %s%s%s\n"
option-counter
(propertize (alist-get 'title option)
'face 'success)
(make-string (1+ (- (length longest-option)
(length (alist-get 'title option))))
?\ )
;; TODO: disambiguate no votes from hidden votes
(format "[%s votes]" (or (alist-get 'votes_count option)
"0"))))
(defun mastodon-tl--get-poll (toot)
"If TOOT includes a poll, return it as a formatted string."
(let-alist (mastodon-tl--field 'poll toot) ; toot or reblog
(let* ((option-titles (mastodon-tl--map-alist 'title .options))
(longest-option (car (sort option-titles
(lambda (x y)
(> (length x)
(length y))))))
(option-counter 0))
(concat "\nPoll: \n\n"
(mapconcat (lambda (option)
(setq option-counter (1+ option-counter))
(mastodon-tl--format-poll-option
option option-counter longest-option))
.options
"\n")
"\n"
(propertize
(cond (.voters_count ; sometimes it is nil
(if (= .voters_count 1)
(format "%s person | " .voters_count)
(format "%s people | " .voters_count)))
(.vote_count
(format "%s votes | " .vote_count))
(t
""))
'face 'font-lock-comment-face)
(let ((str (if (eq .expired :json-false)
(if (eq .expires_at nil)
""
(mastodon-tl--format-poll-expiry .expires_at))
"Poll expired.")))
(propertize str 'face 'font-lock-comment-face))
"\n"))))
(defconst mastodon-tl--time-units
'("sec" 60.0 ;Use a float to convert `n' to float.
"min" 60
"hour" 24
"day" 7
"week" 4.345
"month" 12
"year"))
(defun mastodon-tl--format-poll-expiry (timestamp)
"Convert poll expiry TIMESTAMP into a descriptive string."
;; FIXME: Could we document the format of TIMESTAMP here?
(let* ((ts (encode-time (parse-time-string timestamp)))
(seconds (time-to-seconds (time-subtract ts nil))))
;; FIXME: Use the `cdr' to update poll expiry times?
(concat (car (mastodon-tl--human-duration (max 0 seconds))) " left")))
(defun mastodon-tl--human-duration (seconds &optional resolution)
"Return a string describing SECONDS in a more human-friendly way.
The return format is (STRING . RES) where RES is the resolution of
this string, in seconds.
RESOLUTION is the finest resolution, in seconds, to use for the
second part of the output (defaults to 60, so that seconds are only
displayed when the duration is smaller than a minute)."
(cl-assert (>= seconds 0))
(unless resolution (setq resolution 60))
(let* ((units mastodon-tl--time-units)
(n1 seconds) (unit1 (pop units)) (res1 1)
n2 unit2 res2
next)
(while (and units (> (truncate (setq next (/ n1 (car units)))) 0))
(setq unit2 unit1)
(setq res2 res1)
(setq n2 (- n1 (* (car units) (truncate n1 (car units)))))
(setq n1 next)
(setq res1 (truncate (* res1 (car units))))
(pop units)
(setq unit1 (pop units)))
(setq n1 (truncate n1))
(if n2 (setq n2 (truncate n2)))
(cond
((null n2)
(cons (format "%d %s%s" n1 unit1 (if (> n1 1) "s" ""))
(max resolution res1)))
((< (* res2 n2) resolution)
(cons (format "%d %s%s" n1 unit1 (if (> n1 1) "s" ""))
(max resolution res2)))
((< res2 resolution)
(let ((n2 (/ (* resolution (/ (* n2 res2) resolution)) res2)))
(cons (format "%d %s%s, %d %s%s"
n1 unit1 (if (> n1 1) "s" "")
n2 unit2 (if (> n2 1) "s" ""))
resolution)))
(t
(cons (format "%d %s%s, %d %s%s"
n1 unit1 (if (> n1 1) "s" "")
n2 unit2 (if (> n2 1) "s" ""))
(max res2 resolution))))))
(defun mastodon-tl--read-poll-option ()
"Read a poll option to vote on a poll."
(let* ((toot (mastodon-tl--property 'item-json))
(poll (mastodon-tl--field 'poll toot))
(options (mastodon-tl--field 'options poll))
(options-titles (mastodon-tl--map-alist 'title options))
(options-number-seq (number-sequence 1 (length options)))
(options-numbers (mapcar #'number-to-string options-number-seq))
(options-alist (cl-mapcar #'cons options-numbers options-titles))
;; we display both option number and the option title
;; but also store both as cons cell as cdr, as we need it below
(candidates (mapcar (lambda (cell)
(cons (format "%s | %s" (car cell) (cdr cell))
cell))
options-alist)))
(if (null poll)
(message "No poll here.")
(list
;; var "option" = just the cdr, a cons of option number and desc
(cdr (assoc (completing-read "Poll option to vote for: "
candidates
nil t) ; require match
candidates))))))
(defun mastodon-tl--poll-vote (option)
"If there is a poll at point, prompt user for OPTION to vote on it."
(interactive (mastodon-tl--read-poll-option))
(if (null (mastodon-tl--field 'poll (mastodon-tl--property 'item-json)))
(message "No poll here.")
(let* ((toot (mastodon-tl--property 'item-json))
(poll (mastodon-tl--field 'poll toot))
(poll-id (alist-get 'id poll))
(url (mastodon-http--api (format "polls/%s/votes" poll-id)))
;; need to zero-index our option:
(option-as-arg (number-to-string (1- (string-to-number (car option)))))
(arg `(("choices[]" . ,option-as-arg)))
(response (mastodon-http--post url arg)))
(mastodon-http--triage response
(lambda (_)
(message "You voted for option %s: %s!"
(car option) (cdr option)))))))
;; VIDEOS / MPV
(defun mastodon-tl--find-first-video-in-attachments ()
"Return the first media attachment that is a moving image."
(let ((attachments (mastodon-tl--property 'attachments))
vids)
(mapc (lambda (x)
(let ((att-type (plist-get x :type)))
(when (or (string= "video" att-type)
(string= "gifv" att-type))
(push x vids))))
attachments)
(car vids)))
(defun mastodon-tl--mpv-play-video-from-byline ()
"Run `mastodon-tl--mpv-play-video-at-point' on first moving image in post."
(interactive)
(let* ((video (mastodon-tl--find-first-video-in-attachments))
(url (plist-get video :url))
(type (plist-get video :type)))
(mastodon-tl--mpv-play-video-at-point url type)))
(defun mastodon-tl--click-image-or-video (_event)
"Click to play video with `mpv.el'."
(interactive "e")
(if (mastodon-tl--media-video-p)
(mastodon-tl--mpv-play-video-at-point)
(shr-browse-image)))
(defun mastodon-tl--media-video-p (&optional type)
"T if mastodon-media-type prop is \"gifv\" or \"video\".
TYPE is a mastodon media type."
(let ((type (or type (mastodon-tl--property 'mastodon-media-type :no-move))))
(or (equal type "gifv")
(equal type "video"))))
(defun mastodon-tl--mpv-play-video-at-point (&optional url type)
"Play the video or gif at point with an mpv process.
URL and TYPE are provided when called while point is on byline,
in which case play first video or gif from current toot."
(interactive)
(let ((url (or url ; point in byline:
(mastodon-tl--property 'image-url :no-move)))) ; point in toot
;; (type (or type ; in byline
;; point in toot:
;; (mastodon-tl--property 'mastodon-media-type :no-move))))
(if url
(if (mastodon-tl--media-video-p type)
(progn
(message "'q' to kill mpv.")
(mpv-start "--loop" url))
(message "no moving image here?"))
(message "no moving image here?"))))
;;; INSERT TOOTS
(defun mastodon-tl--content (toot)
"Retrieve text content from TOOT.
Runs `mastodon-tl--render-text' and fetches poll or media."
(let* ((content (mastodon-tl--field 'content toot))
(poll-p (mastodon-tl--field 'poll toot)))
(concat (mastodon-tl--render-text content toot)
(when poll-p
(mastodon-tl--get-poll toot))
(mastodon-tl--media toot))))
(defun mastodon-tl--prev-item-id ()
"Return the id of the last toot inserted into the buffer."
(let* ((prev-change
(save-excursion
(previous-single-property-change (point) 'base-toot-id)))
(prev-pos
(when prev-change (1- prev-change))))
(when prev-pos
(get-text-property prev-pos 'base-toot-id))))
(defun mastodon-tl--after-reply-status (reply-to-id)
"T if REPLY-TO-ID is equal to that of the last toot inserted in the bufer."
(let ((prev-id (mastodon-tl--prev-item-id)))
(string= reply-to-id prev-id)))
(defun mastodon-tl--insert-status (toot body author-byline action-byline
&optional id base-toot detailed-p thread)
"Display the content and byline of timeline element TOOT.
BODY will form the section of the toot above the byline.
AUTHOR-BYLINE is an optional function for adding the author
portion of the byline that takes one variable. By default it is
`mastodon-tl--byline-author'.
ACTION-BYLINE is also an optional function for adding an action,
such as boosting favouriting and following to the byline. It also
takes a single function. By default it is
`mastodon-tl--byline-boosted'.
ID is that of the status if it is a notification, which is
attached as a `item-id' property if provided. If the
status is a favourite or boost notification, BASE-TOOT is the
JSON of the toot responded to.
DETAILED-P means display more detailed info. For now
this just means displaying toot client.
THREAD means the status will be displayed in a thread view."
(let* ((start-pos (point))
(reply-to-id (alist-get 'in_reply_to_id toot))
(after-reply-status-p
(when (and thread reply-to-id)
(mastodon-tl--after-reply-status reply-to-id))))
(insert
(propertize
(concat
"\n"
(if (and after-reply-status-p thread)
(concat (mastodon-tl--symbol 'replied)
"\n")
"")
(if (and after-reply-status-p thread)
(let ((bar (mastodon-tl--symbol 'reply-bar)))
(propertize body
'line-prefix bar
'wrap-prefix bar))
body)
" \n"
(mastodon-tl--byline toot author-byline action-byline detailed-p))
'item-type 'toot
'item-id (or id ; notification's own id
(alist-get 'id toot)) ; toot id
'base-item-id (mastodon-tl--item-id
;; if status is a notif, get id from base-toot
;; (-tl--item-id toot) will not work here:
(or base-toot
toot)) ; else normal toot with reblog check
'item-json toot
'base-toot base-toot
'cursor-face 'mastodon-cursor-highlight-face)
"\n")
(when mastodon-tl--display-media-p
(mastodon-media--inline-images start-pos (point)))))
;; from mastodon-alt.el:
(defun mastodon-tl--toot-for-stats (&optional toot)
"Return the TOOT on which we want to extract stats.
If no TOOT is given, the one at point is considered."
(let* ((original-toot (or toot (get-text-property (point) 'item-json)))
(toot (or (alist-get 'status original-toot)
(when (alist-get 'type original-toot)
original-toot)
(alist-get 'reblog original-toot)
original-toot))
(type (alist-get 'type (or toot))))
(unless (member type '("follow" "follow_request"))
toot)))
(defun mastodon-tl--toot-stats (toot)
"Return a right aligned string (using display align-to).
String is filled with TOOT statistics (boosts, favs, replies).
When the TOOT is a reblog (boost), statistics from reblogged
toots are returned.
To disable showing the stats, customize
`mastodon-tl--show-stats'."
(let-alist (mastodon-tl--toot-for-stats toot)
(let* ((faves-prop (propertize (format "%s" .favourites_count)
'favourites-count .favourites_count))
(boosts-prop (propertize (format "%s" .reblogs_count)
'boosts-count .reblogs_count))
(faves (format "%s %s" faves-prop (mastodon-tl--symbol 'favourite)))
(boosts (format "%s %s" boosts-prop (mastodon-tl--symbol 'boost)))
(replies (format "%s %s" .replies_count (mastodon-tl--symbol 'reply)))
(status (concat
(propertize faves
'favourited-p (eq 't .favourited)
'favourites-field t
'help-echo (format "%s favourites" .favourites_count)
'face 'font-lock-comment-face)
(propertize " | " 'face 'font-lock-comment-face)
(propertize boosts
'boosted-p (eq 't .reblogged)
'boosts-field t
'help-echo (format "%s boosts" .reblogs_count)
'face 'font-lock-comment-face)
(propertize " | " 'face 'font-lock-comment-face)
(propertize replies
'replies-field t
'replies-count .replies_count
'help-echo (format "%s replies" .replies_count)
'face 'font-lock-comment-face)))
(status
(concat
(propertize " "
'display
`(space :align-to (- right ,(+ (length status) 7))))
status)))
status)))
(defun mastodon-tl--is-reply (toot)
"Check if the TOOT is a reply to another one (and not boosted)."
(and (null (mastodon-tl--field 'in_reply_to_id toot))
(not (mastodon-tl--field 'rebloged toot))))
(defun mastodon-tl--toot (toot &optional detailed-p thread)
"Format TOOT and insert it into the buffer.
DETAILED-P means display more detailed info. For now
this just means displaying toot client.
THREAD means the status will be displayed in a thread view."
(mastodon-tl--insert-status
toot
(mastodon-tl--clean-tabs-and-nl (if (mastodon-tl--has-spoiler toot)
(mastodon-tl--spoiler toot)
(mastodon-tl--content toot)))
'mastodon-tl--byline-author 'mastodon-tl--byline-boosted
nil nil detailed-p thread))
(defun mastodon-tl--timeline (toots &optional thread)
"Display each toot in TOOTS.
This function removes replies if user required.
THREAD means the status will be displayed in a thread view."
(mapc (lambda (toot)
(mastodon-tl--toot toot nil thread))
;; hack to *not* filter replies on profiles:
(if (eq (mastodon-tl--get-buffer-type) 'profile-statuses)
toots
(if (or ; we were called via --more*:
(mastodon-tl--buffer-property 'hide-replies nil :no-error)
;; loading a tl with a prefix arg:
(mastodon-tl--hide-replies-p current-prefix-arg))
(cl-remove-if-not #'mastodon-tl--is-reply toots)
toots)))
(goto-char (point-min)))
;;; BUFFER SPEC
(defun mastodon-tl--update-function (&optional buffer)
"Get the UPDATE-FUNCTION stored in `mastodon-tl--buffer-spec'.
Optionally get it for BUFFER."
(mastodon-tl--buffer-property 'update-function buffer))
(defun mastodon-tl--endpoint (&optional buffer no-error)
"Get the ENDPOINT stored in `mastodon-tl--buffer-spec'.
Optionally set it for BUFFER.
NO-ERROR means to fail silently."
(mastodon-tl--buffer-property 'endpoint buffer no-error))
(defun mastodon-tl--buffer-name (&optional buffer no-error)
"Get the BUFFER-NAME stored in `mastodon-tl--buffer-spec'.
Optionally get it for BUFFER.
NO-ERROR means to fail silently."
(mastodon-tl--buffer-property 'buffer-name buffer no-error))
(defun mastodon-tl--link-header (&optional buffer)
"Get the LINK HEADER stored in `mastodon-tl--buffer-spec'.
Optionally get it for BUFFER."
(mastodon-tl--buffer-property 'link-header buffer :no-error))
(defun mastodon-tl--update-params (&optional buffer)
"Get the UPDATE PARAMS stored in `mastodon-tl--buffer-spec'.
Optionally get it for BUFFER."
(mastodon-tl--buffer-property 'update-params buffer :no-error))
(defun mastodon-tl--buffer-property (property &optional buffer no-error)
"Get PROPERTY from `mastodon-tl--buffer-spec' in BUFFER or `current-buffer'.
If NO-ERROR is non-nil, do not error when property is empty."
(with-current-buffer (or buffer (current-buffer))
(if no-error
(plist-get mastodon-tl--buffer-spec property)
(or (plist-get mastodon-tl--buffer-spec property)
(error "Mastodon-tl--buffer-spec not defined for buffer %s, prop %s"
(or buffer (current-buffer))
property)))))
(defun mastodon-tl--set-buffer-spec
(buffer endpoint update-fun &optional link-header update-params hide-replies)
"Set `mastodon-tl--buffer-spec' for the current buffer.
BUFFER is buffer name, ENDPOINT is buffer's enpoint,
UPDATE-FUN is its update function.
LINK-HEADER is the http Link header if present.
UPDATE-PARAMS is any http parameters needed for the update function.
HIDE-REPLIES is a flag indicating if replies are hidden in the current buffer."
(setq mastodon-tl--buffer-spec
`(account ,(cons mastodon-active-user
mastodon-instance-url)
buffer-name ,buffer
endpoint ,endpoint
update-function ,update-fun
link-header ,link-header
update-params ,update-params
hide-replies ,hide-replies)))
;;; BUFFERS
(defun mastodon-tl--endpoint-str-= (str &optional type)
"Return T if STR is equal to the current buffer's endpoint.
TYPE may be :prefix or :suffix, in which case, T if STR is a prefix or suffix."
(let ((endpoint-fun (mastodon-tl--endpoint nil :no-error)))
(cond ((eq type :prefix)
(string-prefix-p str endpoint-fun))
((eq type :suffix)
(string-suffix-p str endpoint-fun))
(t
(string= str endpoint-fun)))))
(defun mastodon-tl--get-buffer-type ()
"Return a symbol descriptive of current mastodon buffer type.
Should work in all mastodon buffers.
Note that for many buffers, this requires `mastodon-tl--buffer-spec'
to be set. It is set for almost all buffers, but you still have to
call this function after it is set or use something else."
(let ((buffer-name (mastodon-tl--buffer-name nil :no-error)))
(cond (mastodon-toot-mode
;; composing/editing:
(if (string= "*edit toot*" (buffer-name))
'edit-toot
'new-toot))
;; main timelines:
((mastodon-tl--endpoint-str-= "timelines/home")
'home)
((string= "*mastodon-local*" buffer-name)
'local)
((mastodon-tl--endpoint-str-= "timelines/public")
'federated)
((mastodon-tl--endpoint-str-= "timelines/tag/" :prefix)
'tag-timeline)
((mastodon-tl--endpoint-str-= "timelines/list/" :prefix)
'list-timeline)
;; notifs:
((string-suffix-p "mentions*" buffer-name)
'mentions)
((mastodon-tl--endpoint-str-= "notifications")
'notifications)
;; threads:
((mastodon-tl--endpoint-str-= "context" :suffix)
'thread)
((mastodon-tl--endpoint-str-= "statuses" :prefix)
'single-status)
;; profiles:
((mastodon-tl--profile-buffer-p)
(cond
;; an own profile option is needlessly confusing e.g. for
;; `mastodon-profile--account-view-cycle'
;; profile note:
((string-suffix-p "update-profile*" buffer-name)
'update-profile-note)
;; posts inc. boosts:
((string-suffix-p "no-boosts*" buffer-name)
'profile-statuses-no-boosts)
((mastodon-tl--endpoint-str-= "statuses" :suffix)
'profile-statuses)
;; profile followers
((mastodon-tl--endpoint-str-= "followers" :suffix)
'profile-followers)
;; profile following
((mastodon-tl--endpoint-str-= "following" :suffix)
'profile-following)))
((mastodon-tl--endpoint-str-= "preferences")
'preferences)
;; search
((mastodon-tl--search-buffer-p)
(cond ((equal (mastodon-search--buf-type) "accounts")
'search-accounts)
((equal (mastodon-search--buf-type) "hashtags")
'search-hashtags)
((equal (mastodon-search--buf-type) "statuses")
'search-statuses)))
;; trends
((mastodon-tl--endpoint-str-= "trends/statuses")
'trending-statuses)
((mastodon-tl--endpoint-str-= "trends/tags")
'trending-tags)
((mastodon-tl--endpoint-str-= "trends/links")
'trending-links)
;; User's views:
((mastodon-tl--endpoint-str-= "filters")
'filters)
((mastodon-tl--endpoint-str-= "lists")
'lists)
((mastodon-tl--endpoint-str-= "suggestions")
'follow-suggestions)
((mastodon-tl--endpoint-str-= "favourites")
'favourites)
((mastodon-tl--endpoint-str-= "bookmarks")
'bookmarks)
((mastodon-tl--endpoint-str-= "follow_requests")
'follow-requests)
((mastodon-tl--endpoint-str-= "scheduled_statuses")
'scheduled-statuses)
;; instance description
((mastodon-tl--endpoint-str-= "instance")
'instance-description)
((string= "*mastodon-toot-edits*" buffer-name)
'toot-edits))))
(defun mastodon-tl--buffer-type-eq (type)
"Return t if current buffer type is equal to symbol TYPE."
(eq (mastodon-tl--get-buffer-type) type))
(defun mastodon-tl--profile-buffer-p ()
"Return t if current buffer is a profile buffer of any kind.
This includes the update profile note buffer, but not the preferences one."
(string-prefix-p "accounts" (mastodon-tl--endpoint nil :no-error)))
(defun mastodon-tl--search-buffer-p ()
"T if current buffer is a search buffer."
(string-suffix-p "search" (mastodon-tl--endpoint nil :no-error)))
(defun mastodon-tl--timeline-proper-p ()
"Return non-nil if the current buffer is a \"proper\" timeline.
A proper timeline excludes notifications, threads, profiles, and
other toot buffers that aren't strictly mastodon timelines."
(let ((timeline-buffers
'(home federated local tag-timeline list-timeline profile-statuses)))
(member (mastodon-tl--get-buffer-type) timeline-buffers)))
(defun mastodon-tl--hide-replies-p (&optional prefix)
"Return non-nil if replies should be hidden in the timeline.
We hide replies if user explictly set the
`mastodon-tl--hide-replies' or used PREFIX combination to open a
timeline."
(and (mastodon-tl--timeline-proper-p) ; Only if we are in a proper timeline
(or mastodon-tl--hide-replies ; User configured to hide replies
(equal '(4) prefix)))) ; Timeline called with C-u prefix
;;; UTILITIES
(defun mastodon-tl--map-alist (key alist)
"Return a list of values extracted from ALIST with KEY.
Key is a symbol, as with `alist-get'."
(mapcar (lambda (x)
(alist-get key x))
alist))
(defun mastodon-tl--map-alist-vals-to-alist (key1 key2 alist)
"From ALIST, return an alist consisting of (val1 . val2) elements.
Values are accessed by `alist-get', using KEY1 and KEY2."
(mapcar (lambda (x)
(cons (alist-get key1 x)
(alist-get key2 x)))
alist))
(defun mastodon-tl--symbol (name)
"Return the unicode symbol (as a string) corresponding to NAME.
If symbol is not displayable, an ASCII equivalent is returned. If
NAME is not part of the symbol table, '?' is returned."
(if-let* ((symbol (alist-get name mastodon-tl--symbols)))
(if (char-displayable-p (string-to-char (car symbol)))
(car symbol)
(cdr symbol))
"?"))
(defun mastodon-tl--set-face (string face)
"Return the propertized STRING with the face property set to FACE."
(propertize string 'face face))
(defun mastodon-tl--field (field toot)
"Return FIELD from TOOT.
Return value from boosted content if available."
(or (alist-get field (alist-get 'reblog toot))
(alist-get field toot)))
(defun mastodon-tl--remove-html (toot)
"Remove unrendered tags from TOOT."
(let* ((t1 (replace-regexp-in-string "<\/p>" "\n\n" toot))
(t2 (replace-regexp-in-string "<\/?span>" "" t1)))
(replace-regexp-in-string "<span class=\"h-card\">" "" t2)))
(defun mastodon-tl--property (prop &optional no-move backward)
"Get property PROP for toot at point.
Move forward (down) the timeline unless NO-MOVE is non-nil.
BACKWARD means move backward (up) the timeline."
(if no-move
(get-text-property (point) prop)
(or (get-text-property (point) prop)
(save-excursion
(if backward
(mastodon-tl--goto-prev-item)
(mastodon-tl--goto-next-item))
(get-text-property (point) prop)))))
(defun mastodon-tl--newest-id ()
"Return item-id from the top of the buffer."
(save-excursion
(goto-char (point-min))
(mastodon-tl--property 'item-id)))
(defun mastodon-tl--oldest-id ()
"Return item-id from the bottom of the buffer."
(save-excursion
(goto-char (point-max))
(mastodon-tl--property 'item-id nil :backward)))
(defun mastodon-tl--as-string (numeric)
"Convert NUMERIC to string."
(cond ((numberp numeric)
(number-to-string numeric))
((stringp numeric) numeric)
(t (error "Numeric:%s must be either a string or a number"
numeric))))
(defun mastodon-tl--item-id (json)
"Find approproiate toot id in JSON.
If the toot has been boosted use the id found in the
reblog portion of the toot. Otherwise, use the body of
the toot. This is the same behaviour as the mastodon.social
webapp"
(let-alist json
(if .reblog .reblog.id .id)))
(defun mastodon-tl--toot-or-base (json)
"Return the base toot or just the toot from toot JSON."
(or (alist-get 'reblog json) json))
;;; THREADS
(defun mastodon-tl--single-toot (id)
"View toot at point in separate buffer.
ID is that of the toot to view."
(interactive)
(let* ((buffer (format "*mastodon-toot-%s*" id))
(toot (mastodon-http--get-json
(mastodon-http--api (concat "statuses/" id)))))
(if (equal (caar toot) 'error)
(user-error "Error: %s" (cdar toot))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-tl--set-buffer-spec buffer (format "statuses/%s" id)
#'mastodon-tl--update-toot)
(mastodon-tl--toot toot :detailed-p)
(goto-char (point-min))
(mastodon-tl--goto-next-item)))))
(defun mastodon-tl--update-toot (json)
"Call `mastodon-tl--single-toot' on id found in JSON."
(let ((id (alist-get 'id json)))
(mastodon-tl--single-toot id)))
(defun mastodon-tl--view-whole-thread ()
"From a thread view, view entire thread.
If you load a thread from a toot, only the branches containing
are displayed by default. Call this if you subsequently want to
view all branches of a thread."
(interactive)
(if (not (eq (mastodon-tl--get-buffer-type) 'thread))
(user-error "You need to be viewing a thread to call this")
(goto-char (point-min))
(let ((id (mastodon-tl--property 'base-item-id)))
(mastodon-tl--thread id))))
(defun mastodon-tl--thread (&optional id)
"Open thread buffer for toot at point or with ID."
(interactive)
(let* ((id (or id (mastodon-tl--property 'base-item-id :no-move)))
(type (mastodon-tl--field 'type (mastodon-tl--property 'item-json :no-move))))
(if (or (string= type "follow_request")
(string= type "follow")) ; no can thread these
(user-error "No thread")
(let* ((endpoint (format "statuses/%s/context" id))
(url (mastodon-http--api endpoint))
(buffer (format "*mastodon-thread-%s*" id))
(toot (mastodon-http--get-json ; refetch in case we just faved/boosted:
(mastodon-http--api (concat "statuses/" id))
nil :silent))
(context (mastodon-http--get-json url nil :silent)))
(if (equal (caar toot) 'error)
(user-error "Error: %s" (cdar toot))
(when (member (alist-get 'type toot) '("reblog" "favourite"))
(setq toot (alist-get 'status toot)))
(if (> (+ (length (alist-get 'ancestors context))
(length (alist-get 'descendants context)))
0)
;; if we have a thread:
(with-mastodon-buffer buffer #'mastodon-mode nil
(let ((marker (make-marker)))
(mastodon-tl--set-buffer-spec buffer endpoint
#'mastodon-tl--thread)
(mastodon-tl--timeline (alist-get 'ancestors context) :thread)
(goto-char (point-max))
(move-marker marker (point))
;; print re-fetched toot:
(mastodon-tl--toot toot :detailed-p :thread)
(mastodon-tl--timeline (alist-get 'descendants context)
:thread)
;; put point at the toot:
(goto-char (marker-position marker))
(mastodon-tl--goto-next-item)))
;; else just print the lone toot:
(mastodon-tl--single-toot id)))))))
(defun mastodon-tl--mute-thread ()
"Mute the thread displayed in the current buffer.
Note that you can only (un)mute threads you have posted in."
(interactive)
(mastodon-tl--mute-or-unmute-thread))
(defun mastodon-tl--unmute-thread ()
"Mute the thread displayed in the current buffer.
Note that you can only (un)mute threads you have posted in."
(interactive)
(mastodon-tl--mute-or-unmute-thread :unmute))
(defun mastodon-tl--mute-or-unmute-thread (&optional unmute)
"Mute a thread.
If UNMUTE, unmute it."
(let ((endpoint (mastodon-tl--endpoint))
(mute-str (if unmute "unmute" "mute")))
(when (or (mastodon-tl--buffer-type-eq 'thread)
(mastodon-tl--buffer-type-eq 'notifications))
(let* ((id
(if (mastodon-tl--buffer-type-eq 'notifications)
(get-text-property (point) 'base-item-id)
(save-match-data
(string-match "statuses/\\(?2:[[:digit:]]+\\)/context"
endpoint)
(match-string 2 endpoint))))
(we-posted-p (mastodon-tl--user-in-thread-p id))
(url (mastodon-http--api (format "statuses/%s/%s" id mute-str))))
(if (not we-posted-p)
(message "You can only (un)mute a thread you have posted in.")
(when (y-or-n-p (format "%s this thread? " (capitalize mute-str)))
(let ((response (mastodon-http--post url)))
(mastodon-http--triage response
(lambda (_)
(if unmute
(message "Thread unmuted!")
(message "Thread muted!")))))))))))
(defun mastodon-tl--map-account-id-from-toot (statuses)
"Return a list of the account IDs of the author of each toot in STATUSES."
(mapcar (lambda (status)
(alist-get 'id
(alist-get 'account status)))
statuses))
(defun mastodon-tl--user-in-thread-p (id)
"Return non-nil if the logged-in user has posted to the current thread.
ID is that of the post the context is currently displayed for."
(let* ((context-json (mastodon-http--get-json
(mastodon-http--api (format "statuses/%s/context" id))
nil :silent))
(ancestors (alist-get 'ancestors context-json))
(descendants (alist-get 'descendants context-json))
(a-ids (mastodon-tl--map-account-id-from-toot ancestors))
(d-ids (mastodon-tl--map-account-id-from-toot descendants)))
(or (member (mastodon-auth--get-account-id) a-ids)
(member (mastodon-auth--get-account-id) d-ids))))
;;; FOLLOW/BLOCK/MUTE, ETC
(defun mastodon-tl--follow-user (user-handle &optional notify langs reblogs)
"Query for USER-HANDLE from current status and follow that user.
If NOTIFY is \"true\", enable notifications when that user posts.
If NOTIFY is \"false\", disable notifications when that user posts.
Can be called to toggle NOTIFY on users already being followed.
LANGS is an array parameters alist of languages to filer user's posts by.
REBLOGS is a boolean string like NOTIFY, enabling or disabling
display of the user's boosts in your timeline."
(interactive
(list (mastodon-tl--user-handles-get "follow")))
(mastodon-tl--do-if-item
(mastodon-tl--do-user-action-and-response
user-handle "follow" nil notify langs reblogs)))
;; TODO: make this action "enable/disable notifications"
(defun mastodon-tl--enable-notify-user-posts (user-handle)
"Query for USER-HANDLE and enable notifications when they post."
(interactive
(list (mastodon-tl--user-handles-get "enable")))
(mastodon-tl--do-if-item
(mastodon-tl--follow-user user-handle "true")))
(defun mastodon-tl--disable-notify-user-posts (user-handle)
"Query for USER-HANDLE and disable notifications when they post."
(interactive
(list (mastodon-tl--user-handles-get "disable")))
(mastodon-tl--follow-user user-handle "false"))
(defun mastodon-tl--follow-user-disable-boosts (user-handle)
"Prompt for a USER-HANDLE, and disable display of boosts in home timeline.
If they are also not yet followed, follow them."
(interactive
(list (mastodon-tl--user-handles-get "disable boosts")))
(mastodon-tl--follow-user user-handle nil nil "false"))
(defun mastodon-tl--follow-user-enable-boosts (user-handle)
"Prompt for a USER-HANDLE, and enable display of boosts in home timeline.
If they are also not yet followed, follow them.
You only need to call this if you have previously disabled
display of boosts."
(interactive
(list (mastodon-tl--user-handles-get "enable boosts")))
(mastodon-tl--follow-user user-handle nil nil "true"))
(defun mastodon-tl--filter-user-user-posts-by-language (user-handle)
"Query for USER-HANDLE and enable notifications when they post.
This feature is experimental and for now not easily varified by
the instance API."
(interactive
(list (mastodon-tl--user-handles-get "filter by language")))
(let ((langs (mastodon-tl--read-filter-langs)))
(mastodon-tl--do-if-item
(mastodon-tl--follow-user user-handle nil langs))))
(defun mastodon-tl--read-filter-langs (&optional langs)
"Read language choices and return an alist array parameter.
LANGS is the accumulated array param alist if we re-run recursively."
(let* ((langs-alist langs)
(choice (completing-read "Filter user's posts by language: "
mastodon-iso-639-1)))
(when choice
(setq langs-alist
(push `("languages[]" . ,(alist-get choice mastodon-iso-639-1
nil nil #'string=))
langs-alist))
(if (y-or-n-p "Filter by another language? ")
(mastodon-tl--read-filter-langs langs-alist)
langs-alist))))
(defun mastodon-tl--unfollow-user (user-handle)
"Query for USER-HANDLE from current status and unfollow that user."
(interactive
(list (mastodon-tl--user-handles-get "unfollow")))
(mastodon-tl--do-if-item
(mastodon-tl--do-user-action-and-response user-handle "unfollow" t)))
(defun mastodon-tl--block-user (user-handle)
"Query for USER-HANDLE from current status and block that user."
(interactive
(list (mastodon-tl--user-handles-get "block")))
(mastodon-tl--do-if-item
(mastodon-tl--do-user-action-and-response user-handle "block")))
(defun mastodon-tl--unblock-user (user-handle)
"Query for USER-HANDLE from list of blocked users and unblock that user."
(interactive
(list (mastodon-tl--get-blocks-or-mutes-list "unblock")))
(if (not user-handle)
(message "Looks like you have no blocks to unblock!")
(mastodon-tl--do-user-action-and-response user-handle "unblock" t)))
(defun mastodon-tl--mute-user (user-handle)
"Query for USER-HANDLE from current status and mute that user."
(interactive
(list (mastodon-tl--user-handles-get "mute")))
(mastodon-tl--do-if-item
(mastodon-tl--do-user-action-and-response user-handle "mute")))
(defun mastodon-tl--unmute-user (user-handle)
"Query for USER-HANDLE from list of muted users and unmute that user."
(interactive
(list (mastodon-tl--get-blocks-or-mutes-list "unmute")))
(if (not user-handle)
(message "Looks like you have no mutes to unmute!")
(mastodon-tl--do-user-action-and-response user-handle "unmute" t)))
(defun mastodon-tl--dm-user (user-handle)
"Query for USER-HANDLE from current status and compose a message to that user."
(interactive
(list (mastodon-tl--user-handles-get "message")))
(mastodon-tl--do-if-item
(mastodon-toot--compose-buffer (concat "@" user-handle))
(setq mastodon-toot--visibility "direct")
(mastodon-toot--update-status-fields)))
(defun mastodon-tl--user-handles-get (action)
"Get the list of user-handles for ACTION from the current toot."
(mastodon-tl--do-if-item
(let ((user-handles
(cond ((or ; follow suggests / search / foll requests compat:
(mastodon-tl--buffer-type-eq 'follow-suggestions)
(mastodon-tl--buffer-type-eq 'search)
(mastodon-tl--buffer-type-eq 'follow-requests)
;; profile follows/followers but not statuses:
(mastodon-tl--buffer-type-eq 'profile-followers)
(mastodon-tl--buffer-type-eq 'profile-following))
;; fetch 'item-json:
(list (alist-get 'acct
(mastodon-tl--property 'item-json :no-move))))
;; profile view, point in profile details, poss no toots
;; needed for e.g. gup.pe groups which show no toots publically:
((and (mastodon-tl--profile-buffer-p)
(get-text-property (point) 'profile-json))
(list (alist-get 'acct
(mastodon-profile--profile-json))))
(t
(mastodon-profile--extract-users-handles
(mastodon-profile--item-json))))))
;; return immediately if only 1 handle:
(if (eq 1 (length user-handles))
(car user-handles)
(completing-read (cond ((or ; TODO: make this "enable/disable notifications"
(equal action "disable")
(equal action "enable"))
(format "%s notifications when user posts: " action))
((string-suffix-p "boosts" action)
(format "%s by user: " action))
(t
(format "Handle of user to %s: " action)))
user-handles
nil ; predicate
'confirm)))))
(defun mastodon-tl--get-blocks-or-mutes-list (action)
"Fetch the list of accounts for ACTION from the server.
Action must be either \"unblock\" or \"unmute\"."
(let* ((endpoint (cond ((equal action "unblock")
"blocks")
((equal action "unmute")
"mutes")))
(url (mastodon-http--api endpoint))
(json (mastodon-http--get-json url))
(accts (mastodon-tl--map-alist 'acct json)))
(when accts
(completing-read (format "Handle of user to %s: " action)
accts nil t)))) ; require match
(defun mastodon-tl--do-user-action-and-response
(user-handle action &optional negp notify langs reblogs)
"Do ACTION on user USER-HANDLE.
NEGP is whether the action involves un-doing something.
If NOTIFY is \"true\", enable notifications when that user posts.
If NOTIFY is \"false\", disable notifications when that user posts.
NOTIFY is only non-nil when called by `mastodon-tl--follow-user'.
LANGS is an array parameters alist of languages to filer user's posts by.
REBLOGS is a boolean string like NOTIFY, enabling or disabling
display of the user's boosts in your timeline."
(let* ((account (if negp
;; unmuting/unblocking, handle from mute/block list
(mastodon-profile--search-account-by-handle user-handle)
;; profile view, use 'profile-json as status:
(if (mastodon-tl--profile-buffer-p)
(mastodon-profile--lookup-account-in-status
user-handle (mastodon-profile--profile-json))
;; muting/blocking, select from handles in current status
(mastodon-profile--lookup-account-in-status
user-handle (mastodon-profile--item-json)))))
(user-id (alist-get 'id account))
(name (if (string-empty-p (alist-get 'display_name account))
(alist-get 'username account)
(alist-get 'display_name account)))
(args (cond (notify `(("notify" . ,notify)))
(langs langs)
(reblogs `(("reblogs" . ,reblogs)))
(t nil)))
(url (mastodon-http--api (format "accounts/%s/%s" user-id action))))
(if account
(if (equal action "follow") ; y-or-n for all but follow
(mastodon-tl--do-user-action-function url name user-handle action notify args reblogs)
(when (y-or-n-p (format "%s user %s? " action name))
(mastodon-tl--do-user-action-function url name user-handle action args)))
(message "Cannot find a user with handle %S" user-handle))))
(defun mastodon-tl--do-user-action-function
(url name user-handle action &optional notify args reblogs)
"Post ACTION on user NAME/USER-HANDLE to URL.
NOTIFY is either \"true\" or \"false\", and used when we have been called
by `mastodon-tl--follow-user' to enable or disable notifications.
ARGS is an alist of any parameters to send with the request."
(let ((response (mastodon-http--post url args)))
(mastodon-http--triage
response
(lambda (response)
(let ((json (with-current-buffer response
(mastodon-http--process-json))))
;; TODO: when > if, with failure msg
(cond ((string-equal notify "true")
(when (equal 't (alist-get 'notifying json))
(message "Receiving notifications for user %s (@%s)!"
name user-handle)))
((string-equal notify "false")
(when (equal :json-false (alist-get 'notifying json))
(message "Not receiving notifications for user %s (@%s)!"
name user-handle)))
((string-equal reblogs "true")
(when (equal 't (alist-get 'showing_reblogs json))
(message "Receiving boosts by user %s (@%s)!"
name user-handle)))
((string-equal reblogs "false")
(when (equal :json-false (alist-get 'showing_reblogs json))
(message "Not receiving boosts by user %s (@%s)!"
name user-handle)))
((or (string-equal action "mute")
(string-equal action "unmute"))
(message "User %s (@%s) %sd!" name user-handle action))
((assoc "languages[]" args #'equal)
(message "User %s filtered by language(s): %s" name
(mapconcat #'cdr args " ")))
((and (eq notify nil)
(eq reblogs nil))
(message "User %s (@%s) %sed!" name user-handle action))))))))
;; FOLLOW TAGS
(defun mastodon-tl--get-tags-list ()
"Return the list of tags of the toot at point."
(let* ((toot (or (mastodon-tl--property 'base-toot :no-move) ; fave/boost notifs
(mastodon-tl--property 'item-json :no-move)))
(tags (mastodon-tl--field 'tags toot)))
(mapcar (lambda (x)
(alist-get 'name x))
tags)))
(defun mastodon-tl--follow-tag (&optional tag)
"Prompt for a tag and follow it.
If TAG provided, follow it."
(interactive)
(let* ((tags (unless tag (mastodon-tl--get-tags-list)))
(tag-at-point
(unless tag
(when (eq 'hashtag (get-text-property (point) 'mastodon-tab-stop))
(get-text-property (point) 'mastodon-tag))))
(tag (or tag (completing-read
(format "Tag to follow [%s]: " tag-at-point)
tags nil nil nil nil tag-at-point)))
(url (mastodon-http--api (format "tags/%s/follow" tag)))
(response (mastodon-http--post url)))
(mastodon-http--triage response
(lambda (_)
(message "tag #%s followed!" tag)))))
(defun mastodon-tl--followed-tags ()
"Return JSON of tags followed."
(let ((url (mastodon-http--api (format "followed_tags"))))
(mastodon-http--get-json url)))
(defun mastodon-tl--unfollow-tag (&optional tag)
"Prompt for a followed tag, and unfollow it.
If TAG is provided, unfollow it."
(interactive)
(let* ((followed-tags-json (unless tag (mastodon-tl--followed-tags)))
(tags (unless tag (mastodon-tl--map-alist 'name followed-tags-json)))
(tag (or tag (completing-read "Unfollow tag: " tags)))
(url (mastodon-http--api (format "tags/%s/unfollow" tag)))
(response (mastodon-http--post url)))
(mastodon-http--triage response
(lambda (_)
(message "tag #%s unfollowed!" tag)))))
(defun mastodon-tl--list-followed-tags (&optional prefix)
"List followed tags. View timeline of tag user choses.
PREFIX is sent to `mastodon-tl--get-tag-timeline', which see."
(interactive "p")
(let* ((followed-tags-json (mastodon-tl--followed-tags))
(tags (mastodon-tl--map-alist 'name followed-tags-json))
(tag (completing-read "Tag: " tags nil)))
(if (null tag)
(message "You have to follow some tags first.")
(mastodon-tl--get-tag-timeline prefix tag))))
(defun mastodon-tl--followed-tags-timeline (&optional prefix)
"Open a timeline of all your followed tags.
PREFIX is sent to `mastodon-tl--show-tag-timeline', which see.
Note that the number of tags supported is undocumented, and from
manual testing appears to be limited to a total of four tags."
(interactive "p")
(let* ((followed-tags-json (mastodon-tl--followed-tags))
(tags (mastodon-tl--map-alist 'name followed-tags-json)))
(mastodon-tl--show-tag-timeline prefix tags)))
(defun mastodon-tl--some-followed-tags-timeline (&optional prefix)
"Prompt for some tags, and open a timeline for them.
The suggestions are from followed tags, but any other tags are also allowed.
PREFIX is for `mastodon-tl--show-tag-timeline', which see."
(interactive "p")
(let* ((followed-tags-json (mastodon-tl--followed-tags))
(tags (mastodon-tl--map-alist 'name followed-tags-json))
(selection (completing-read-multiple
"Tags' timelines to view [TAB to view, comma to separate]: "
tags)))
(mastodon-tl--show-tag-timeline prefix selection)))
;;; REPORT TO MODERATORS
(defun mastodon-tl--instance-rules ()
"Return the rules of the user's instance."
(let ((url (mastodon-http--api "instance/rules")))
(mastodon-http--get-json url nil :silent)))
(defun mastodon-tl--report-params (account toot)
"Query user and return report params alist.
ACCOUNT and TOOT are the data to use."
(let* ((account-id (alist-get 'id account))
(comment (read-string "Add comment [optional]: "))
(item-id (when (y-or-n-p "Also report status at point? ")
(mastodon-tl--item-id toot))) ; base toot if poss
(forward-p (when (y-or-n-p "Forward to remote admin? ") "true"))
(rules (when (y-or-n-p "Cite a rule broken? ")
(mastodon-tl--read-rules-ids)))
(cat (unless rules (if (y-or-n-p "Spam? ") "spam" "other"))))
(mastodon-tl--report-build-params account-id comment item-id
forward-p cat rules)))
(defun mastodon-tl--report-build-params
(account-id comment item-id forward-p cat &optional rules)
"Build the parameters alist based on user responses.
ACCOUNT-ID, COMMENT, ITEM-ID, FORWARD-P, CAT, and RULES are all from
`mastodon-tl--report-params', which see."
(let ((params `(("account_id" . ,account-id)
,(when comment
`("comment" . ,comment))
,(when item-id
`("status_ids[]" . ,item-id))
,(when forward-p
`("forward" . ,forward-p))
,(when cat
`("category" . ,cat)))))
(when rules
(let ((alist
(mastodon-http--build-array-params-alist "rule_ids[]" rules)))
(mapc (lambda (x)
(push x params))
alist)))
;; FIXME: the above approach adds nils to your params.
(setq params (delete nil params))
params))
(defun mastodon-tl--report-to-mods ()
"Report the author of the toot at point to your instance moderators.
Optionally report the toot at point, add a comment, cite rules
that have been broken, forward the report to the remove admin,
report the account for spam."
(interactive)
(mastodon-tl--do-if-item
(when (y-or-n-p "Report author of toot at point?")
(let* ((url (mastodon-http--api "reports"))
(toot (mastodon-tl--toot-or-base
(mastodon-tl--property 'item-json :no-move)))
(account (alist-get 'account toot))
(handle (alist-get 'acct account))
(params (mastodon-tl--report-params account toot))
(response (mastodon-http--post url params)))
(mastodon-http--triage response
(lambda (_)
(message "User %s reported!" handle)))))))
(defvar crm-separator)
(defun mastodon-tl--map-rules-alist (rules)
"Convert RULES text and id fields into an alist."
(mapcar (lambda (x)
(let-alist x
(cons .text .id)))
rules))
(defun mastodon-tl--read-rules-ids ()
"Prompt for a list of instance rules and return a list of selected ids."
(let* ((rules (mastodon-tl--instance-rules))
(alist (mastodon-tl--map-rules-alist rules))
(crm-separator (replace-regexp-in-string "," "|" crm-separator))
(choices (completing-read-multiple
"rules [TAB for options, | to separate]: "
alist nil t)))
(mapcar (lambda (x)
(alist-get x alist nil nil #'equal))
choices)))
;;; UPDATING, etc.
(defun mastodon-tl--more-json (endpoint id)
"Return JSON for timeline ENDPOINT before ID."
(let* ((args `(("max_id" . ,(mastodon-tl--as-string id))))
(url (mastodon-http--api endpoint)))
(mastodon-http--get-json url args)))
(defun mastodon-tl--more-json-async
(endpoint id &optional params callback &rest cbargs)
"Return JSON for timeline ENDPOINT before ID.
Then run CALLBACK with arguments CBARGS.
PARAMS is used to send any parameters needed to correctly update
the current view."
(let* ((args `(("max_id" . ,(mastodon-tl--as-string id))))
(args (if params (push (car args) params) args))
(url (if (string-suffix-p "search" endpoint)
(mastodon-http--api-search)
(mastodon-http--api endpoint))))
(apply #'mastodon-http--get-json-async url args callback cbargs)))
(defun mastodon-tl--more-json-async-offset (endpoint &optional params
callback &rest cbargs)
"Return JSON for ENDPOINT, using the \"offset\" query param.
This is used for pagination with endpoints that implement the
\"offset\" parameter, rather than using link-headers or
\"max_id\".
PARAMS are the update parameters, see
`mastodon-tl--update-params'. These (\"limit\" and \"offset\")
must be set in `mastodon-tl--buffer-spec' for pagination to work.
Then run CALLBACK with arguments CBARGS."
(let* ((params (or params
(mastodon-tl--update-params)))
(limit (string-to-number
(alist-get "limit" params nil nil #'equal)))
(offset (number-to-string
(+ limit ; limit + old offset = new offset
(string-to-number
(alist-get "offset" params nil nil #'equal)))))
(url (if (string-suffix-p "search" endpoint)
(mastodon-http--api-search)
(mastodon-http--api endpoint))))
;; increment:
(setf (alist-get "offset" params nil nil #'equal) offset)
(apply #'mastodon-http--get-json-async url params callback cbargs)))
(defun mastodon-tl--updated-json (endpoint id &optional params)
"Return JSON for timeline ENDPOINT since ID.
PARAMS is used to send any parameters needed to correctly update
the current view."
(let* ((args `(("since_id" . ,(mastodon-tl--as-string id))))
(args (if params (push (car args) params) args))
(url (mastodon-http--api endpoint)))
(mastodon-http--get-json url args)))
;; TODO: add this to new posts in some cases, e.g. in thread view.
(defun mastodon-tl--reload-timeline-or-profile (&optional pos)
"Reload the current timeline or profile page.
For use after e.g. deleting a toot.
POS is a number, where point will be placed."
(let ((type (mastodon-tl--get-buffer-type)))
(cond ((eq type 'home)
(mastodon-tl--get-home-timeline))
((eq type 'federated)
(mastodon-tl--get-federated-timeline))
((eq type 'local)
(mastodon-tl--get-local-timeline))
((eq type 'mentions)
(mastodon-notifications--get-mentions))
((eq type 'notifications)
(mastodon-notifications-get nil nil :force))
((eq type 'profile-statuses-no-boosts)
(mastodon-profile--open-statuses-no-reblogs))
((eq type 'profile-statuses)
(mastodon-profile--my-profile))
((eq type 'thread)
(save-match-data
(let ((endpoint (mastodon-tl--endpoint)))
(string-match "statuses/\\(?2:[[:digit:]]+\\)/context" endpoint)
(mastodon-tl--thread (match-string 2 endpoint))))))
;; TODO: sends point to where point was in buffer. This is very rough; we
;; may have removed an item , so the buffer will be smaller, point will
;; end up past where we were, etc.
(when pos
(goto-char pos)
(mastodon-tl--goto-prev-item))))
(defun mastodon-tl--build-link-header-url (str)
"Return a URL from STR, an http Link header."
(let* ((split (split-string str "; "))
(url-base (string-trim (car split) "<" ">"))
(param (cadr split)))
(concat url-base "&" param)))
(defun mastodon-tl--use-link-header-p ()
"Return t if we are in a view needing Link header pagination.
Currently this includes favourites, bookmarks, follow requests,
and profile pages when showing followers or accounts followed."
(or (mastodon-tl--buffer-type-eq 'favourites)
(mastodon-tl--buffer-type-eq 'bookmarks)
(mastodon-tl--buffer-type-eq 'profile-followers)
(mastodon-tl--buffer-type-eq 'profile-following)
(mastodon-tl--buffer-type-eq 'follow-requests)))
(defun mastodon-tl--get-link-header-from-response (headers)
"Get http Link header from list of http HEADERS."
;; pleroma uses "link", so case-insensitive match required:
(when-let ((link-headers (alist-get "Link" headers nil nil #'cl-equalp)))
(split-string link-headers ", ")))
(defun mastodon-tl--more ()
"Append older toots to timeline, asynchronously."
(message "Loading...")
(if (mastodon-tl--use-link-header-p)
;; link-header paginate:
;; can't build a URL with --more-json-async, endpoint/id:
;; ensure we have a "next" type here, otherwise the CAR will be the
;; "prev" type!
(let ((link-header (mastodon-tl--link-header)))
(if (> 2 (length link-header))
(message "No next page")
(let* ((next (car link-header))
;;(prev (cadr (mastodon-tl--link-header)))
(url (mastodon-tl--build-link-header-url next)))
(mastodon-http--get-response-async url nil 'mastodon-tl--more* (current-buffer)
(point) :headers))))
(cond ( ; no paginate
(or (mastodon-tl--buffer-type-eq 'follow-suggestions)
(mastodon-tl--buffer-type-eq 'filters)
(mastodon-tl--buffer-type-eq 'lists))
(message "No more results"))
;; offset paginate (search, trending, user lists, ...?):
((or (string-prefix-p "*mastodon-trending-" (buffer-name))
(mastodon-tl--search-buffer-p))
(mastodon-tl--more-json-async-offset
(mastodon-tl--endpoint)
(mastodon-tl--update-params)
'mastodon-tl--more* (current-buffer) (point)))
(t;; max_id paginate (timelines, items with ids/timestamps):
(mastodon-tl--more-json-async
(mastodon-tl--endpoint)
(mastodon-tl--oldest-id)
(mastodon-tl--update-params)
'mastodon-tl--more* (current-buffer) (point))))))
(defun mastodon-tl--more* (response buffer point-before &optional headers)
"Append older toots to timeline, asynchronously.
Runs the timeline's update function on RESPONSE, in BUFFER.
When done, places point at POINT-BEFORE.
HEADERS is the http headers returned in the response, if any."
(with-current-buffer buffer
(if (not response)
(message "No more results")
(let* ((inhibit-read-only t)
(json (if headers (car response) response))
;; FIXME: max-id pagination works for statuses only, not other
;; search results pages:
(json (if (mastodon-tl--search-buffer-p)
(cond ((equal "statuses" (mastodon-search--buf-type))
(cdr ; avoid repeat of last status
(alist-get 'statuses response)))
((equal "hashtags" (mastodon-search--buf-type))
(alist-get 'hashtags response))
((equal "accounts" (mastodon-search--buf-type))
(alist-get 'accounts response)))
json))
(headers (if headers (cdr response) nil))
(link-header (mastodon-tl--get-link-header-from-response headers)))
(goto-char (point-max))
(if (eq (mastodon-tl--get-buffer-type) 'thread)
;; if thread view, call --thread with parent ID
(progn (goto-char (point-min))
(mastodon-tl--goto-next-item)
(funcall (mastodon-tl--update-function))
(goto-char point-before)
(message "Loaded full thread."))
(if (not json)
(message "No more results.")
(funcall (mastodon-tl--update-function) json)
(goto-char point-before)
;; update buffer spec to new link-header:
;; (other values should just remain as they were)
(when headers
(mastodon-tl--set-buffer-spec (mastodon-tl--buffer-name)
(mastodon-tl--endpoint)
(mastodon-tl--update-function)
link-header))
(message "Loading... done.")))))))
(defun mastodon-tl--find-property-range (property start-point
&optional search-backwards)
"Return nil if no such range is found.
If PROPERTY is set at START-POINT returns a range around
START-POINT otherwise before/after START-POINT.
SEARCH-BACKWARDS determines whether we pick point
before (non-nil) or after (nil)"
(if (get-text-property start-point property)
;; We are within a range, so look backwards for the start:
(cons (previous-single-property-change
(if (equal start-point (point-max)) start-point (1+ start-point))
property nil (point-min))
(next-single-property-change start-point property nil (point-max)))
(if search-backwards
(let* ((end (or (previous-single-property-change
(if (equal start-point (point-max))
start-point (1+ start-point))
property)
;; we may either be just before the range or there
;; is nothing at all
(and (not (equal start-point (point-min)))
(get-text-property (1- start-point) property)
start-point)))
(start (and end (previous-single-property-change
end property nil (point-min)))))
(when end
(cons start end)))
(let* ((start (next-single-property-change start-point property))
(end (and start (next-single-property-change
start property nil (point-max)))))
(when start
(cons start end))))))
(defun mastodon-tl--find-next-or-previous-property-range
(property start-point search-backwards)
"Find (start . end) property range after/before START-POINT.
Does so while PROPERTY is set to a consistent value (different
from the value at START-POINT if that is set).
Return nil if no such range exists.
If SEARCH-BACKWARDS is non-nil it find a region before
START-POINT otherwise after START-POINT."
(if (get-text-property start-point property)
;; We are within a range, we need to start the search from
;; before/after this range:
(let ((current-range (mastodon-tl--find-property-range property start-point)))
(if search-backwards
(unless (equal (car current-range) (point-min))
(mastodon-tl--find-property-range
property (1- (car current-range)) search-backwards))
(unless (equal (cdr current-range) (point-max))
(mastodon-tl--find-property-range
property (1+ (cdr current-range)) search-backwards))))
;; If we are not within a range, we can just defer to
;; mastodon-tl--find-property-range directly.
(mastodon-tl--find-property-range property start-point search-backwards)))
(defun mastodon-tl--consider-timestamp-for-updates (timestamp)
"Take note that TIMESTAMP is used in buffer and ajust timers as needed.
This calculates the next time the text for TIMESTAMP will change
and may adjust existing or future timer runs should that time
before current plans to run the update function.
The adjustment is only made if it is significantly (a few
seconds) before the currently scheduled time. This helps reduce
the number of occasions where we schedule an update only to
schedule the next one on completion to be within a few seconds.
If relative timestamps are disabled (i.e. if
`mastodon-tl--enable-relative-timestamps' is nil), this is a
no-op."
(when mastodon-tl--enable-relative-timestamps
(let ((this-update (cdr (mastodon-tl--relative-time-details timestamp))))
(when (time-less-p this-update
(time-subtract mastodon-tl--timestamp-next-update
(seconds-to-time 10)))
(setq mastodon-tl--timestamp-next-update this-update)
(when mastodon-tl--timestamp-update-timer
;; We need to re-schedule for an earlier time
(cancel-timer mastodon-tl--timestamp-update-timer)
(setq mastodon-tl--timestamp-update-timer
(run-at-time (time-to-seconds (time-subtract this-update
(current-time)))
nil ;; don't repeat
#'mastodon-tl--update-timestamps-callback
(current-buffer) nil)))))))
(defun mastodon-tl--update-timestamps-callback (buffer previous-marker)
"Update the next few timestamp displays in BUFFER.
Start searching for more timestamps from PREVIOUS-MARKER or
from the start if it is nil."
;; only do things if the buffer hasn't been killed in the meantime
(when (and mastodon-tl--enable-relative-timestamps ; just in case
(buffer-live-p buffer))
(save-excursion
(with-current-buffer buffer
(let ((previous-timestamp (if previous-marker
(marker-position previous-marker)
(point-min)))
(iteration 0)
next-timestamp-range)
(if previous-marker
;; a follow-up call to process the next batch of timestamps.
;; Release the marker to not slow things down.
(set-marker previous-marker nil)
;; Otherwise this is a rew run, so let's initialize the next-run time.
(setq mastodon-tl--timestamp-next-update (time-add (current-time)
(seconds-to-time 300))
mastodon-tl--timestamp-update-timer nil))
(while (and (< iteration 5)
(setq next-timestamp-range
(mastodon-tl--find-property-range 'timestamp
previous-timestamp)))
(let* ((start (car next-timestamp-range))
(end (cdr next-timestamp-range))
(timestamp (get-text-property start 'timestamp))
(current-display (get-text-property start 'display))
(new-display (mastodon-tl--relative-time-description timestamp)))
(unless (string= current-display new-display)
(let ((inhibit-read-only t))
(add-text-properties
start end
(list 'display
(mastodon-tl--relative-time-description timestamp)))))
(mastodon-tl--consider-timestamp-for-updates timestamp)
(setq iteration (1+ iteration)
previous-timestamp (1+ (cdr next-timestamp-range)))))
(if next-timestamp-range
;; schedule the next batch from the previous location to
;; start very soon in the future:
(run-at-time 0.1 nil #'mastodon-tl--update-timestamps-callback buffer
(copy-marker previous-timestamp))
;; otherwise we are done for now; schedule a new run for when needed
(setq mastodon-tl--timestamp-update-timer
(run-at-time (time-to-seconds
(time-subtract mastodon-tl--timestamp-next-update
(current-time)))
nil ;; don't repeat
#'mastodon-tl--update-timestamps-callback
buffer nil))))))))
(defun mastodon-tl--set-after-update-marker ()
"Set `mastodon-tl--after-update-marker' to the after-update location.
This location is defined by a non-nil value of
`mastodon-tl-position-after-update'."
(if (not mastodon-tl-position-after-update)
(setq mastodon-tl--after-update-marker nil)
(let ((marker (make-marker)))
(set-marker marker
(cond
((eq 'keep-point mastodon-tl-position-after-update)
(point))
((eq 'last-old-toot mastodon-tl-position-after-update)
(next-single-property-change
(or mastodon-tl--update-point (point-min))
'byline))
(t
(error "Unknown mastodon-tl-position-after-update value %S"
mastodon-tl-position-after-update))))
;; Make the marker advance if text gets inserted there.
(set-marker-insertion-type marker t)
(setq mastodon-tl--after-update-marker marker))))
(defun mastodon-tl--update ()
"Update timeline with new toots."
(interactive)
;; FIXME: actually these buffers should just reload by calling their own
;; load function:
(if (or (mastodon-tl--buffer-type-eq 'trending-statuses)
(mastodon-tl--buffer-type-eq 'trending-tags)
(mastodon-tl--buffer-type-eq 'follow-suggestions)
(mastodon-tl--buffer-type-eq 'lists)
(mastodon-tl--buffer-type-eq 'filters)
(mastodon-tl--search-buffer-p))
(message "update not available in this view.")
;; FIXME: handle update for search and trending buffers
(let* ((endpoint (mastodon-tl--endpoint))
(update-function (mastodon-tl--update-function)))
;; update a thread, without calling `mastodon-tl--updated-json':
(if (mastodon-tl--buffer-type-eq 'thread)
(let ((thread-id (mastodon-tl--property 'item-id)))
(funcall update-function thread-id))
;; update other timelines:
(let* ((id (mastodon-tl--newest-id))
(params (mastodon-tl--update-params))
(json (mastodon-tl--updated-json endpoint id params)))
(if json
(let ((inhibit-read-only t))
(mastodon-tl--set-after-update-marker)
(goto-char (or mastodon-tl--update-point (point-min)))
(funcall update-function json)
(when mastodon-tl--after-update-marker
(goto-char mastodon-tl--after-update-marker)))
(message "nothing to update")))))))
;;; LOADING TIMELINES
(defun mastodon-tl--init (buffer-name endpoint update-function
&optional headers params hide-replies)
"Initialize BUFFER-NAME with timeline targeted by ENDPOINT asynchronously.
UPDATE-FUNCTION is used to recieve more toots.
HEADERS means to also collect the response headers. Used for paginating
favourites and bookmarks.
PARAMS is any parameters to send with the request.
HIDE-REPLIES is a flag indicating if replies are hidden in the current buffer."
(let ((url (mastodon-http--api endpoint))
(buffer (concat "*mastodon-" buffer-name "*")))
(if headers
(mastodon-http--get-response-async
url params 'mastodon-tl--init*
buffer endpoint update-function headers params hide-replies)
(mastodon-http--get-json-async
url params 'mastodon-tl--init*
buffer endpoint update-function nil params hide-replies))))
(defun mastodon-tl--init* (response buffer endpoint update-function
&optional headers update-params hide-replies)
"Initialize BUFFER with timeline targeted by ENDPOINT.
UPDATE-FUNCTION is used to recieve more toots.
RESPONSE is the data returned from the server by
`mastodon-http--process-json', with arg HEADERS a cons cell of
JSON and http headers, without it just the JSON."
(let ((json (if headers (car response) response)))
(if (not json) ; praying this is right here, else try "\n[]"
(message "Looks like nothing returned from endpoint: %s" endpoint)
(let* ((headers (if headers (cdr response) nil))
(link-header (mastodon-tl--get-link-header-from-response headers)))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-tl--set-buffer-spec buffer endpoint update-function
link-header update-params hide-replies)
(mastodon-tl--do-init json update-function))))))
(defun mastodon-tl--init-sync
(buffer-name endpoint update-function
&optional note-type params headers view-name binding-str)
"Initialize BUFFER-NAME with timeline targeted by ENDPOINT.
UPDATE-FUNCTION is used to receive more toots.
Runs synchronously.
Optional arg NOTE-TYPE means only get that type of note.
PARAMS is an alist of any params to include in the request.
HEADERS are any headers to send in the request.
VIEW-NAME is a string, to be used as a heading for the view.
BINDING-STR is a string explaining any bindins in the view."
;; Used by `mastodon-notifications-get' and in views.el
(let* ((exclude-types (when note-type
(mastodon-notifications--filter-types-list note-type)))
(notes-params (when note-type
(mastodon-http--build-array-params-alist
"exclude_types[]" exclude-types)))
(params (append notes-params params))
(url (mastodon-http--api endpoint))
(buffer (concat "*mastodon-" buffer-name "*"))
(response (mastodon-http--get-response url params))
(json (car response))
(headers (when headers (cdr response)))
(link-header (when headers
(mastodon-tl--get-link-header-from-response headers))))
(with-mastodon-buffer buffer #'mastodon-mode nil
;; insert view-name/ heading-str
(when view-name
(mastodon-search--insert-heading view-name))
(when binding-str
(insert (mastodon-tl--set-face (concat "[" binding-str "]\n\n")
'font-lock-comment-face)))
(mastodon-tl--set-buffer-spec buffer endpoint update-function
link-header params)
(mastodon-tl--do-init json update-function)
buffer)))
(defun mastodon-tl--do-init (json update-fun)
"Utility function for `mastodon-tl--init*' and `mastodon-tl--init-sync'.
JSON is the data to call UPDATE-FUN on."
(remove-overlays) ; video overlays
(funcall update-fun json)
(setq
;; Initialize with a minimal interval; we re-scan at least once
;; every 5 minutes to catch any timestamps we may have missed
mastodon-tl--timestamp-next-update (time-add (current-time)
(seconds-to-time 300)))
(setq mastodon-tl--timestamp-update-timer
(when mastodon-tl--enable-relative-timestamps
(run-at-time (time-to-seconds
(time-subtract mastodon-tl--timestamp-next-update
(current-time)))
nil ;; don't repeat
#'mastodon-tl--update-timestamps-callback
(current-buffer)
nil)))
(unless (mastodon-tl--profile-buffer-p)
(mastodon-tl--goto-first-item)))
(provide 'mastodon-tl)
;;; mastodon-tl.el ends here
;;; mastodon-search.el --- Search functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Marty Hiatt
;; Author: Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; A basic search function for mastodon.el
;;; Code:
(require 'json)
(eval-when-compile
(require 'mastodon-tl))
(autoload 'mastodon-auth--access-token "mastodon-auth")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--get-search-json "mastodon-http")
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--render-text "mastodon-tl")
(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl")
(autoload 'mastodon-tl--set-face "mastodon-tl")
(autoload 'mastodon-tl--timeline "mastodon-tl")
(autoload 'mastodon-tl--toot "mastodon-tl")
(autoload 'mastodon-tl--buffer-property "mastodon-tl")
(autoload 'mastodon-http--api-search "mastodon-http")
(defvar mastodon-toot--completion-style-for-mentions)
(defvar mastodon-instance-url)
(defvar mastodon-tl--link-keymap)
(defvar mastodon-tl--horiz-bar)
;; functions for completion of mentions in mastodon-toot
(defun mastodon-search--get-user-info-@ (account)
"Get user handle, display name and account URL from ACCOUNT."
(list (concat "@" (cdr (assoc 'acct account)))
(cdr (assoc 'url account))
(cdr (assoc 'display_name account))))
(defun mastodon-search--search-accounts-query (query)
"Prompt for a search QUERY and return accounts synchronously.
Returns a nested list containing user handle, display name, and URL."
(let* ((url (mastodon-http--api "accounts/search"))
(response
(if (equal mastodon-toot--completion-style-for-mentions "following")
(mastodon-http--get-json
url `(("q" . ,query) ("following" . "true"))
:silent)
(mastodon-http--get-json url `(("q" . ,query)) :silent))))
(mapcar #'mastodon-search--get-user-info-@ response)))
;; functions for tags completion:
(defun mastodon-search--search-tags-query (query)
"Return an alist containing tag strings plus their URLs.
QUERY is the string to search."
(let* ((url (mastodon-http--api-search))
(params `(("q" . ,query) ("type" . "hashtags")))
(response (mastodon-http--get-json url params :silent))
(tags (alist-get 'hashtags response)))
(mapcar #'mastodon-search--get-hashtag-info tags)))
;; trending tags
(defun mastodon-search--trending-tags ()
"Display a list of tags trending on your instance."
(interactive)
(mastodon-search--view-trending "tags"
#'mastodon-search--print-tags))
(defun mastodon-search--trending-statuses ()
"Display a list of statuses trending on your instance."
(interactive)
(mastodon-search--view-trending "statuses"
#'mastodon-tl--timeline))
(defun mastodon-search--view-trending (type print-fun)
"Display a list of tags trending on your instance.
TYPE is a string, either tags, statuses, or links.
PRINT-FUN is the function used to print the data from the response."
(let* ((url (mastodon-http--api
(format "trends/%s" type)))
;; max for statuses = 40, for others = 20
(limit (if (equal type "statuses")
'("limit" . "40")
'("limit" . "20")))
(offset '(("offset" . "0")))
(params (push limit offset))
(data (mastodon-http--get-json url params))
(buffer (get-buffer-create (format "*mastodon-trending-%s*" type))))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-tl--set-buffer-spec (buffer-name buffer)
(format "trends/%s" type)
print-fun nil
params)
(mastodon-search--insert-heading "trending" type)
(funcall print-fun data)
(unless (equal type "statuses")
(goto-char (point-min))))))
;; functions for mastodon search
(defun mastodon-search--insert-heading (heading &optional type)
"Format HEADING as a heading.
Optionally add string TYPE after HEADING."
(insert
(mastodon-tl--set-face (concat "\n " mastodon-tl--horiz-bar "\n "
(upcase heading) " "
(if type (upcase type) "") "\n"
" " mastodon-tl--horiz-bar "\n")
'success)))
(defvar mastodon-search-types
'("statuses" "accounts" "hashtags"))
(defun mastodon-search--query (query
&optional type limit
following account-id offset)
"Prompt for a search QUERY and return accounts, statuses, and hashtags.
TYPE is a member of `mastodon-search-types'.
LIMIT is a number as string, up to 40, with 40 the default.
FOLLOWING means limit to accounts followed, for \"accounts\" type only.
A single prefix arg also sets FOLLOWING to true.
ACCOUNT-ID means limit search to that account, for \"statuses\" type only.
OFFSET is a number as string, means to skip that many results. It
is used for pagination."
;; TODO: handle no results
(interactive "sSearch mastodon for: ")
(let* ((url (mastodon-http--api-search))
(following (when (or following
(equal current-prefix-arg '(4)))
"true"))
(type (or type
(if (equal current-prefix-arg '(4))
"accounts" ; if FOLLOWING, must be "accounts"
(completing-read "Search type: "
mastodon-search-types
nil t))))
(limit (or limit "40"))
(offset (or offset "0"))
(buffer (format "*mastodon-search-%s-%s*" type query))
(params (cl-remove nil
`(("q" . ,query)
,(when type `("type" . ,type))
,(when limit `("limit" . ,limit))
,(when offset `("offset" . ,offset))
,(when following `("following" . ,following))
,(when account-id `("account_id" . ,account-id)))))
(response (mastodon-http--get-json url params))
(accts (when (equal type "accounts")
(alist-get 'accounts response)))
(tags (when (equal type "hashtags")
(alist-get 'hashtags response)))
(statuses (when (equal type "statuses")
(alist-get 'statuses response))))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-search-mode)
(mastodon-search--insert-heading type)
;; user results:
(cond ((equal type "accounts")
(mastodon-search--render-response accts type buffer params
'mastodon-views--insert-users-propertized-note
'mastodon-views--insert-users-propertized-note))
((equal type "hashtags")
(mastodon-search--render-response tags type buffer params
'mastodon-search--print-tags
'mastodon-search--print-tags))
((equal type "statuses")
(mastodon-search--render-response statuses type buffer params
#'mastodon-tl--timeline
#'mastodon-tl--timeline)))
(goto-char (point-min))
(message
(substitute-command-keys
"\\[mastodon-search--query-cycle] to cycle result types.")))))
(defun mastodon-search-insert-no-results (&optional thing)
"Insert a no results message for object THING."
(let ((thing (or thing "nothing")))
(insert
(propertize (format "Looks like search returned no %s." thing)
'face 'font-lock-comment-face))))
(defun mastodon-search--render-response (data type buffer params
insert-fun update-fun)
"Call INSERT-FUN on DATA of result TYPE if non-nil.
BUFFER, PARAMS, and UPDATE-FUN are for `mastodon-tl--buffer-spec'."
(if (not data)
(mastodon-search-insert-no-results type)
(funcall insert-fun data))
;; (mapc #'mastodon-tl--toot data))
(mastodon-tl--set-buffer-spec buffer "search"
update-fun
nil params))
(defun mastodon-search--buf-type ()
"Return search buffer type, a member of `mastodon-search-types'."
;; called in `mastodon-tl--get-buffer-type'
(let* ((spec (mastodon-tl--buffer-property 'update-params)))
(alist-get "type" spec nil nil #'equal)))
(defun mastodon-search--query-cycle ()
"Cycle through search types: accounts, hashtags, and statuses."
(interactive)
(let* ((spec (mastodon-tl--buffer-property 'update-params))
(type (alist-get "type" spec nil nil #'equal))
(query (alist-get "q" spec nil nil #'equal)))
(cond ((equal type "hashtags")
(mastodon-search--query query "accounts"))
((equal type "accounts")
(mastodon-search--query query "statuses"))
((equal type "statuses")
(mastodon-search--query query "hashtags")))))
(defun mastodon-serach--query-accounts-followed (query)
"Run an accounts search QUERY, limited to your followers."
(interactive "sSearch mastodon for: ")
(mastodon-search--query query "accounts" :following))
(defun mastodon-search--insert-users-propertized (json &optional note)
"Insert users list into the buffer.
JSON is the data from the server.
If NOTE is non-nil, include user's profile note. This is also
called by `mastodon-tl--get-follow-suggestions' and
`mastodon-profile--insert-follow-requests'."
(mapc (lambda (acct)
(insert (concat (mastodon-search--propertize-user acct note)
mastodon-tl--horiz-bar
"\n\n")))
json))
(defun mastodon-search--propertize-user (acct &optional note)
"Propertize display string for ACCT, optionally including profile NOTE."
(let* ((user (mastodon-search--get-user-info acct))
(id (alist-get 'id acct)))
(propertize
(concat
(propertize (car user)
'face 'mastodon-display-name-face
'byline t
'item-type 'user
'item-id id) ; for prev/next nav
" : \n : "
(propertize (concat "@" (cadr user))
'face 'mastodon-handle-face
'mouse-face 'highlight
'mastodon-tab-stop 'user-handle
'keymap mastodon-tl--link-keymap
'mastodon-handle (concat "@" (cadr user))
'help-echo (concat "Browse user profile of @" (cadr user)))
" : \n"
(if note
(mastodon-tl--render-text (cadddr user) acct)
"")
"\n")
'item-json acct))) ; for compat w other processing functions
(defun mastodon-search--print-tags (tags)
"Print TAGS data as returned from a \"hashtags\" search query."
(let ((tags-list (mapcar #'mastodon-search--get-hashtag-info tags)))
(mastodon-search--print-tags-list tags-list)))
(defun mastodon-search--print-tags-list (tags-list)
"Insert a propertized list of TAGS-LIST."
(mapc (lambda (el)
(insert
" : "
(propertize (concat "#" (car el))
'face '(:box t)
'mouse-face 'highlight
'mastodon-tag (car el)
'mastodon-tab-stop 'hashtag
'item-type 'tag ; for next/prev nav
'byline t ; for next/prev nav
'help-echo (concat "Browse tag #" (car el))
'keymap mastodon-tl--link-keymap)
" : \n\n"))
tags-list))
(defun mastodon-search--get-user-info (account)
"Get user handle, display name, account URL and profile note from ACCOUNT."
(list (if (not (string-empty-p (alist-get 'display_name account)))
(alist-get 'display_name account)
(alist-get 'username account))
(alist-get 'acct account)
(alist-get 'url account)
(alist-get 'note account)))
(defun mastodon-search--get-hashtag-info (tag)
"Get hashtag name and URL from TAG."
(list (alist-get 'name tag)
(alist-get 'url tag)))
(defun mastodon-search--get-status-info (status)
"Get ID, timestamp, content, and spoiler from STATUS."
(list (alist-get 'id status)
(alist-get 'created_at status)
(alist-get 'spoiler_text status)
(alist-get 'content status)))
(defun mastodon-search--id-from-status (status)
"Fetch the id from a STATUS returned by a search call to the server.
We use this to fetch the complete status from the server."
(alist-get 'id status))
(defun mastodon-search--full-status-from-id (id)
"Fetch the full status with id ID from the server.
This allows us to access the full account etc. details and to
render them properly."
(let* ((url (concat mastodon-instance-url "/api/v1/statuses/" (mastodon-tl--as-string id)))
(json (mastodon-http--get-json url)))
json))
(defvar mastodon-search-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'mastodon-search--query-cycle)
map)
"Keymap for `mastodon-search-mode'.")
(define-minor-mode mastodon-search-mode
"Toggle mastodon search minor mode.
This minor mode is used for mastodon search pages to adds a keybinding."
:init-value nil
:lighter " Search"
:keymap mastodon-search-mode-map
:group 'mastodon
:global nil)
(provide 'mastodon-search)
;;; mastodon-search.el ends here
;;; mastodon-profile.el --- Functions for inspecting Mastodon profiles -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-profile.el generates a stream of users toots.
;; To add
;; - Option to follow
;; - wheather they follow you or not
;; - Show only Media
;;; Code:
(require 'seq)
(require 'cl-lib)
(require 'persist)
(require 'parse-time)
(eval-when-compile
(require 'mastodon-tl))
(autoload 'mastodon-auth--get-account-id "mastodon-auth")
(autoload 'mastodon-auth--get-account-name "mastodon-auth.el")
(autoload 'mastodon-http--api "mastodon-http.el")
(autoload 'mastodon-http--get-json "mastodon-http.el")
(autoload 'mastodon-http--get-json-async "mastodon-http.el")
(autoload 'mastodon-http--get-response "mastodon-http")
(autoload 'mastodon-http--patch "mastodon-http")
(autoload 'mastodon-http--patch-json "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http.el")
(autoload 'mastodon-http--triage "mastodon-http.el")
(autoload 'mastodon-media--get-media-link-rendering "mastodon-media.el")
(autoload 'mastodon-media--inline-images "mastodon-media.el")
(autoload 'mastodon-mode "mastodon.el")
(autoload 'mastodon-notifications--follow-request-accept "mastodon-notifications")
(autoload 'mastodon-notifications--follow-request-reject "mastodon-notifications")
(autoload 'mastodon-search--insert-users-propertized "mastodon-search")
(autoload 'mastodon-tl--as-string "mastodon-tl.el")
(autoload 'mastodon-tl--buffer-type-eq "mastodon tl")
(autoload 'mastodon-tl--byline-author "mastodon-tl.el")
(autoload 'mastodon-tl--find-property-range "mastodon-tl.el")
(autoload 'mastodon-tl--get-link-header-from-response "mastodon-tl")
(autoload 'mastodon-tl--init "mastodon-tl.el")
(autoload 'mastodon-tl--user-handles-get "mastodon-tl")
(autoload 'mastodon-tl--map-alist "mastodon-tl")
(autoload 'mastodon-tl--map-alist-vals-to-alist "mastodon-tl")
(autoload 'mastodon-tl--profile-buffer-p "mastodon tl")
(autoload 'mastodon-tl--property "mastodon-tl.el")
(autoload 'mastodon-tl--render-text "mastodon-tl.el")
(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl")
(autoload 'mastodon-tl--set-face "mastodon-tl.el")
(autoload 'mastodon-tl--symbol "mastodon-tl")
(autoload 'mastodon-tl--timeline "mastodon-tl.el")
(autoload 'mastodon-tl--toot "mastodon-tl")
(autoload 'mastodon-tl--item-id "mastodon-tl")
(autoload 'mastodon-toot--count-toot-chars "mastodon-toot")
(autoload 'mastodon-toot--get-max-toot-chars "mastodon-toot")
(autoload 'mastodon-views--add-account-to-list "mastodon-views")
(autoload 'mastodon-return-credential-account "mastodon")
(autoload 'mastodon-tl--buffer-property "mastodon-tl")
(autoload 'mastodon-search--query "mastodon-search")
(defvar mastodon-tl--horiz-bar)
(defvar mastodon-tl--update-point)
(defvar mastodon-toot--max-toot-chars)
(defvar mastodon-toot--visibility)
(defvar mastodon-toot--content-nsfw)
(defvar mastodon-tl--timeline-posts-count)
(defvar-local mastodon-profile--account nil
"The data for the account being described in the current profile buffer.")
(defvar mastodon-profile-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'mastodon-profile--account-view-cycle)
(define-key map (kbd "C-c C-s") #'mastodon-profile--account-search)
map)
"Keymap for `mastodon-profile-mode'.")
(define-minor-mode mastodon-profile-mode
"Toggle mastodon profile minor mode.
This minor mode is used for mastodon profile pages and adds a couple of
extra keybindings."
:init-value nil
:lighter " Profile"
:keymap mastodon-profile-mode-map
:group 'mastodon
:global nil)
(defvar mastodon-profile-credential-account nil
"Holds the JSON data of the CredentialAccount entity.
It contains details of the current user's account.")
(defvar mastodon-profile-acccount-preferences-data nil
"Holds the JSON data of the current user's preferences.")
(defvar mastodon-profile-update-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'mastodon-profile--user-profile-send-updated)
(define-key map (kbd "C-c C-k") #'mastodon-profile--update-profile-note-cancel)
map)
"Keymap for `mastodon-profile-update-mode'.")
(persist-defvar mastodon-profile-account-settings nil
"An alist of account settings saved from the server.
Other clients can change these settings on the server at any
time, so this list is not the canonical source for settings. It
is updated on entering mastodon mode and on toggle any setting it
contains")
(define-minor-mode mastodon-profile-update-mode
"Minor mode to update Mastodon user profile."
:group 'mastodon-profile
:keymap mastodon-profile-update-mode-map
:global nil)
(defun mastodon-profile--item-json ()
"Get the next item-json."
(mastodon-tl--property 'item-json))
(defun mastodon-profile--make-author-buffer (account &optional no-reblogs)
"Take an ACCOUNT json and insert a user account into a new buffer.
NO-REBLOGS means do not display boosts in statuses."
(mastodon-profile--make-profile-buffer-for
account "statuses" #'mastodon-tl--timeline no-reblogs))
;; TODO: we shd just load all views' data then switch coz this is slow af:
(defun mastodon-profile--account-view-cycle ()
"Cycle through profile view: toots, toot sans boosts, followers, and following."
(interactive)
(cond ((mastodon-tl--buffer-type-eq 'profile-statuses)
(mastodon-profile--open-statuses-no-reblogs))
((mastodon-tl--buffer-type-eq 'profile-statuses-no-boosts)
(mastodon-profile--open-followers))
((mastodon-tl--buffer-type-eq 'profile-followers)
(mastodon-profile--open-following))
((mastodon-tl--buffer-type-eq 'profile-following)
(mastodon-profile--make-author-buffer mastodon-profile--account))))
(defun mastodon-profile--open-statuses-no-reblogs ()
"Open a profile buffer showing statuses without reblogs."
(interactive)
(if mastodon-profile--account
(mastodon-profile--make-author-buffer mastodon-profile--account :no-reblogs)
(user-error "Not in a mastodon profile")))
(defun mastodon-profile--open-following ()
"Open a profile buffer showing the accounts that current profile follows."
(interactive)
(if mastodon-profile--account
(mastodon-profile--make-profile-buffer-for
mastodon-profile--account
"following"
#'mastodon-profile--format-user
nil
:headers)
(user-error "Not in a mastodon profile")))
(defun mastodon-profile--open-followers ()
"Open a profile buffer showing the accounts following the current profile."
(interactive)
(if mastodon-profile--account
(mastodon-profile--make-profile-buffer-for
mastodon-profile--account
"followers"
#'mastodon-profile--format-user
nil
:headers)
(user-error "Not in a mastodon profile")))
(defun mastodon-profile--view-favourites ()
"Open a new buffer displaying the user's favourites."
(interactive)
(message "Loading your favourited toots...")
(mastodon-tl--init "favourites"
"favourites"
'mastodon-tl--timeline
:headers))
(defun mastodon-profile--view-bookmarks ()
"Open a new buffer displaying the user's bookmarks."
(interactive)
(message "Loading your bookmarked toots...")
(mastodon-tl--init "bookmarks"
"bookmarks"
'mastodon-tl--timeline
:headers))
(defun mastodon-profile--add-account-to-list ()
"Add account of current profile buffer to a list."
(interactive)
(when mastodon-profile--account
(let* ((profile mastodon-profile--account)
(id (alist-get 'id profile))
(handle (alist-get 'acct profile)))
(mastodon-views--add-account-to-list nil id handle))))
(defun mastodon-profile--account-search (query)
"Run a statuses search QUERY for the currently viewed account."
(interactive "sSearch account for: ")
(let* ((ep (mastodon-tl--buffer-property 'endpoint))
(id (nth 1 (split-string ep "/"))))
(mastodon-search--query query "statuses" nil nil id)))
;;; ACCOUNT PREFERENCES
(defun mastodon-profile--get-json-value (val)
"Fetch current VAL ue from account."
(let* ((response (mastodon-return-credential-account)))
(if (eq (alist-get val response) :json-false)
nil
(alist-get val response))))
(defun mastodon-profile--get-source-values ()
"Return the \"source\" preferences from the server."
(mastodon-profile--get-json-value 'source))
(defun mastodon-profile--get-source-value (pref)
"Return account PREF erence from the \"source\" section on the server."
(let ((source (mastodon-profile--get-source-values)))
(if (eq (alist-get pref source) :json-false)
nil
(alist-get pref source))))
(defun mastodon-profile--update-user-profile-note ()
"Fetch user's profile note and display for editing."
(interactive)
(let* ((json (mastodon-return-credential-account))
(source (alist-get 'source json))
(note (alist-get 'note source))
(buffer (get-buffer-create "*mastodon-update-profile*"))
(inhibit-read-only t)
(msg-str (substitute-command-keys
"Edit your profile note. \\`C-c C-c' to send, \\`C-c C-k' to cancel.")))
(switch-to-buffer-other-window buffer)
(text-mode)
(mastodon-tl--set-buffer-spec (buffer-name buffer) "accounts/verify_credentials" nil)
(setq-local header-line-format msg-str)
(mastodon-profile-update-mode t)
(insert (propertize (concat (propertize "0"
'note-counter t
'display nil)
"/500 characters")
'read-only t
'face 'font-lock-comment-face
'note-header t)
"\n")
(make-local-variable 'after-change-functions)
(cl-pushnew #'mastodon-profile--update-note-count after-change-functions)
(let ((start-point (point)))
(insert note)
(goto-char start-point))
(delete-trailing-whitespace) ; remove all ^M's
(message msg-str)))
(defun mastodon-profile--update-note-count (&rest _args)
"Display the character count of the profile note buffer."
(let* ((inhibit-read-only t)
(header-region (mastodon-tl--find-property-range 'note-header
(point-min)))
(count-region (mastodon-tl--find-property-range 'note-counter
(point-min)))
(count (number-to-string (mastodon-toot--count-toot-chars
(buffer-substring-no-properties
(cdr header-region) (point-max))))))
(add-text-properties (car count-region) (cdr count-region)
(list 'display count))))
(defun mastodon-profile--update-profile-note-cancel ()
"Cancel updating user profile and kill buffer and window."
(interactive)
(when (y-or-n-p "Cancel updating your profile note?")
(kill-buffer-and-window)))
(defun mastodon-profile--note-remove-header ()
"Get the body of a toot from the current compose buffer."
(let ((header-region (mastodon-tl--find-property-range 'note-header
(point-min))))
(buffer-substring (cdr header-region) (point-max))))
(defun mastodon-profile--user-profile-send-updated ()
"Send PATCH request with the updated profile note.
Ask for confirmation if length > 500 characters."
(interactive)
(let* ((note (mastodon-profile--note-remove-header))
(url (mastodon-http--api "accounts/update_credentials")))
(if (> (mastodon-toot--count-toot-chars note) 500)
(when (y-or-n-p "Note is over mastodon's max for profile notes (500). Proceed?")
(kill-buffer-and-window)
(mastodon-profile--user-profile-send-updated-do url note))
(kill-buffer-and-window)
(mastodon-profile--user-profile-send-updated-do url note))))
(defun mastodon-profile--user-profile-send-updated-do (url note)
"Send PATCH request with the updated profile NOTE to URL."
(let ((response (mastodon-http--patch url `(("note" . ,note)))))
(mastodon-http--triage response
(lambda (_) (message "Profile note updated!")))))
(defun mastodon-profile--update-preference (pref val &optional source)
"Update account PREF erence to setting VAL.
Both args are strings.
SOURCE means that the preference is in the `source' part of the account JSON."
(let* ((url (mastodon-http--api "accounts/update_credentials"))
(pref-formatted (if source (concat "source[" pref "]") pref))
(response (mastodon-http--patch url `((,pref-formatted . ,val)))))
(mastodon-http--triage response
(lambda (_)
(mastodon-profile--fetch-server-account-settings)
(message "Account setting %s updated to %s!" pref val)))))
(defun mastodon-profile--get-pref (pref)
"Return PREF from `mastodon-profile-account-settings'."
(plist-get mastodon-profile-account-settings pref))
(defun mastodon-profile--update-preference-plist (pref val)
"Set local account preference plist preference PREF to VAL.
This is done after changing the setting on the server."
(setq mastodon-profile-account-settings
(plist-put mastodon-profile-account-settings pref val)))
;; used in toot.el
(defun mastodon-profile--fetch-server-account-settings-maybe ()
"Fetch account settings from the server.
Only do so if `mastodon-profile-account-settings' is nil."
(mastodon-profile--fetch-server-account-settings :no-force))
(defun mastodon-profile--fetch-server-account-settings (&optional no-force)
"Fetch basic account settings from the server.
Store the values in `mastodon-profile-account-settings'.
Run in `mastodon-mode-hook'.
If NO-FORCE, only fetch if `mastodon-profile-account-settings' is nil."
(unless (and no-force mastodon-profile-account-settings)
(let ((keys '(locked discoverable display_name bot))
(source-keys '(privacy sensitive language)))
(mapc (lambda (k)
(mastodon-profile--update-preference-plist
k (mastodon-profile--get-json-value k)))
keys)
(mapc (lambda (sk)
(mastodon-profile--update-preference-plist
sk (mastodon-profile--get-source-value sk)))
source-keys)
;; hack for max toot chars:
(mastodon-toot--get-max-toot-chars :no-toot)
(mastodon-profile--update-preference-plist 'max_toot_chars
mastodon-toot--max-toot-chars)
;; TODO: remove now redundant vars, replace with fetchers from the plist
(setq mastodon-toot--visibility (mastodon-profile--get-pref 'privacy)
mastodon-toot--content-nsfw (mastodon-profile--get-pref 'sensitive))
mastodon-profile-account-settings)))
(defun mastodon-profile--account-locked-toggle ()
"Toggle the locked status of your account.
Locked means follow requests have to be approved."
(interactive)
(mastodon-profile--toggle-account-key 'locked))
(defun mastodon-profile--account-discoverable-toggle ()
"Toggle the discoverable status of your account.
Discoverable means the account is listed in the server directory."
(interactive)
(mastodon-profile--toggle-account-key 'discoverable))
(defun mastodon-profile--account-bot-toggle ()
"Toggle the bot status of your account."
(interactive)
(mastodon-profile--toggle-account-key 'bot))
(defun mastodon-profile--account-sensitive-toggle ()
"Toggle the sensitive status of your account.
When enabled, statuses are marked as sensitive by default."
(interactive)
(mastodon-profile--toggle-account-key 'sensitive :source))
(defun mastodon-profile--toggle-account-key (key &optional source)
"Toggle the boolean account setting KEY.
SOURCE means the setting is located under \"source\" in the account JSON.
Current settings are fetched from the server."
(let* ((val (if source
(mastodon-profile--get-source-value key)
(mastodon-profile--get-json-value key)))
(prompt (format "Account setting %s is %s. Toggle?" key val)))
(when (y-or-n-p prompt)
(mastodon-profile--update-preference (symbol-name key)
(if val "false" "true")
source))))
(defun mastodon-profile--edit-string-value (key)
"Edit the string for account preference KEY."
(let* ((val (mastodon-profile--get-json-value key))
(new-val (read-string (format "Edit account setting %s: " key)
val)))
(mastodon-profile--update-preference (symbol-name key) new-val)))
(defun mastodon-profile--update-display-name ()
"Update display name for your account."
(interactive)
(mastodon-profile--edit-string-value 'display_name))
(defun mastodon-profile--make-meta-fields-params (fields)
"Construct a parameter query string from metadata alist FIELDS.
Returns an alist."
(let ((keys (cl-loop for count from 1 to 5
collect (cons (format "fields_attributes[%s][name]" count)
(format "fields_attributes[%s][value]" count)))))
(cl-loop for a-pair in keys
for b-pair in fields
append (list (cons (car a-pair) (car b-pair))
(cons (cdr a-pair) (cdr b-pair))))))
(defun mastodon-profile--update-meta-fields ()
"Prompt for new metadata fields information and PATCH the server."
(interactive)
(let* ((url (mastodon-http--api "accounts/update_credentials"))
(fields-updated (mastodon-profile--update-meta-fields-alist))
(params (mastodon-profile--make-meta-fields-params fields-updated))
(response (mastodon-http--patch url params)))
(mastodon-http--triage response
(lambda (_)
(mastodon-profile--fetch-server-account-settings)
(message "Metadata fields updated to %s!"
fields-updated)))))
(defun mastodon-profile--update-meta-fields-alist ()
"Prompt for new metadata fields information.
Returns the results as an alist."
(let ((fields-old (mastodon-profile--fields-get
nil
;; we must fetch the plaintext version:
(mastodon-profile--get-source-value 'fields))))
;; offer empty fields if user currently has less than four filled:
(while (< (length fields-old) 4)
(setq fields-old (append fields-old '(("" . "")))))
(let* ((f-str "Metadata %s [%s/4] (max. 255 chars): ")
(alist
(cl-loop for f in fields-old
for x from 1 to 5
collect
(cons (read-string (format f-str "key" x) (car f))
(read-string (format f-str "value" x) (cdr f))))))
(mapcar (lambda (x)
(cons (mastodon-profile--limit-to-255 (car x))
(mastodon-profile--limit-to-255 (cdr x))))
alist))))
(defun mastodon-profile--limit-to-255 (x)
"Limit string X to 255 chars max."
(if (> (length x) 255) (substring x 0 255) x))
;; used in tl.el
(defun mastodon-profile--get-preferences-pref (pref)
"Fetch PREF from the endpoint \"/preferences\".
If `mastodon-profile-acccount-preferences-data' is set, fetch
from that instead.
The endpoint only holds a few preferences. For others, see
`mastodon-profile--update-preference' and its endpoint,
\"/accounts/update_credentials.\""
(alist-get pref
(or mastodon-profile-acccount-preferences-data
(setq mastodon-profile-acccount-preferences-data
(mastodon-http--get-json
(mastodon-http--api "preferences"))))))
(defun mastodon-profile--view-preferences ()
"View user preferences in another window."
(interactive)
(let* ((url (mastodon-http--api "preferences"))
(response (mastodon-http--get-json url))
(buf (get-buffer-create "*mastodon-preferences*")))
(with-mastodon-buffer buf #'special-mode :other-window
(mastodon-tl--set-buffer-spec (buffer-name buf) "preferences" nil)
(while response
(let ((el (pop response)))
(insert (format "%-30s %s"
(prin1-to-string (car el))
(prin1-to-string (cdr el)))
"\n\n")))
(goto-char (point-min)))))
;;; PROFILE VIEW DETAILS
(defun mastodon-profile--relationships-get (id)
"Fetch info about logged-in user's relationship to user with id ID."
(let* ((their-id id)
(args `(("id[]" . ,their-id)))
(url (mastodon-http--api "accounts/relationships")))
(car (mastodon-http--get-json url args)))) ; API takes array, just get 1st
(defun mastodon-profile--fields-get (&optional account fields)
"Fetch the fields vector (aka profile metadata) from profile of ACCOUNT.
Returns an alist.
FIELDS means provide a fields vector fetched by other means."
(let ((fields (or fields (alist-get 'fields account))))
(when fields
(mastodon-tl--map-alist-vals-to-alist 'name 'value fields))))
(defun mastodon-profile--fields-insert (fields)
"Format and insert field pairs (a.k.a profile metadata) in FIELDS."
(let* ((car-fields (mapcar #'car fields))
(left-width (cl-reduce #'max (mapcar #'length car-fields))))
(mapconcat (lambda (field)
(mastodon-tl--render-text
(concat
(format "_ %s " (car field))
(make-string (- (+ 1 left-width) (length (car field))) ?_)
(format " :: %s" (cdr field)))
field)) ; hack to make links tabstops
fields "")))
(defun mastodon-profile--get-statuses-pinned (account)
"Fetch the pinned toots for ACCOUNT."
(let* ((id (alist-get 'id account))
(args `(("pinned" . "true")))
(url (mastodon-http--api (format "accounts/%s/statuses" id))))
(mastodon-http--get-json url args)))
(defun mastodon-profile--insert-statuses-pinned (pinned-statuses)
"Insert each of the PINNED-STATUSES for a given account."
(mapc (lambda (pinned-status)
(insert (mastodon-tl--set-face " :pinned: " 'success))
(mastodon-tl--toot pinned-status))
pinned-statuses))
(defun mastodon-profile--follows-p (list)
"T if you have any relationship with the accounts in LIST."
(let (result)
(dolist (x list result)
(when (not (equal :json-false x))
(setq result x)))))
(defun mastodon-profile--make-profile-buffer-for
(account endpoint-type update-function &optional no-reblogs headers)
"Display profile of ACCOUNT, using ENDPOINT-TYPE and UPDATE-FUNCTION.
NO-REBLOGS means do not display boosts in statuses.
HEADERS means also fetch link headers for pagination."
(let-alist account
(let* ((args `(("limit" . ,mastodon-tl--timeline-posts-count)))
(args (if no-reblogs (push '("exclude_reblogs" . "t") args) args))
(endpoint (format "accounts/%s/%s" .id endpoint-type))
(url (mastodon-http--api endpoint))
(buffer (concat "*mastodon-" .acct "-"
(if no-reblogs
(concat endpoint-type "-no-boosts")
endpoint-type)
"*"))
(response (if headers
(mastodon-http--get-response url args)
(mastodon-http--get-json url args)))
(json (if headers (car response) response))
(link-header (when headers
(mastodon-tl--get-link-header-from-response
(cdr response))))
(fields (mastodon-profile--fields-get account))
(pinned (mastodon-profile--get-statuses-pinned account))
(relationships (mastodon-profile--relationships-get .id)))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-profile-mode)
(remove-overlays)
(setq mastodon-profile--account account)
(mastodon-tl--set-buffer-spec buffer endpoint
update-function link-header
args)
(let* ((inhibit-read-only t)
(is-statuses (string= endpoint-type "statuses"))
(is-followers (string= endpoint-type "followers"))
(is-following (string= endpoint-type "following"))
(endpoint-name (cond
(is-statuses (if no-reblogs
" TOOTS (no boosts)"
" TOOTS "))
(is-followers " FOLLOWERS ")
(is-following " FOLLOWING "))))
(insert
(propertize
(concat
"\n"
(mastodon-profile--image-from-account account 'avatar_static)
(mastodon-profile--image-from-account account 'header_static)
"\n"
(propertize .display_name 'face 'mastodon-display-name-face)
"\n"
(propertize (concat "@" .acct) 'face 'default)
(if (equal .locked t)
(concat " " (mastodon-tl--symbol 'locked))
"")
"\n " mastodon-tl--horiz-bar "\n"
;; profile note:
(mastodon-tl--render-text .note account) ; account = tab-stops in profile
;; meta fields:
(if fields
(concat "\n" (mastodon-tl--set-face
(mastodon-profile--fields-insert fields)
'success))
"")
"\n"
;; Joined date:
(propertize
(mastodon-profile--format-joined-date-string .created_at)
'face 'success)
"\n\n")
'profile-json account)
;; insert counts
(mastodon-tl--set-face
(concat " " mastodon-tl--horiz-bar "\n"
" TOOTS: " (mastodon-tl--as-string .statuses_count) " | "
"FOLLOWERS: " (mastodon-tl--as-string .followers_count) " | "
"FOLLOWING: " (mastodon-tl--as-string .following_count) "\n"
" " mastodon-tl--horiz-bar "\n\n")
'success)
;; insert relationship (follows)
(let-alist relationships
(let ((followsp (mastodon-profile--follows-p
(list .requested_by .following .followed_by))))
(if followsp
(mastodon-tl--set-face
(concat (when (equal .following 't)
" | FOLLOWED BY YOU")
(when (equal .followed_by 't)
" | FOLLOWS YOU")
(when (equal .requested_by 't)
" | REQUESTED TO FOLLOW YOU")
"\n\n")
'success)
""))) ; for insert call
;; insert endpoint
(mastodon-tl--set-face (concat " " mastodon-tl--horiz-bar "\n"
endpoint-name "\n"
" " mastodon-tl--horiz-bar "\n")
'success))
(setq mastodon-tl--update-point (point))
(mastodon-media--inline-images (point-min) (point))
;; insert pinned toots first
(when (and pinned (equal endpoint-type "statuses"))
(mastodon-profile--insert-statuses-pinned pinned)
(setq mastodon-tl--update-point (point))) ; updates after pinned toots
(funcall update-function json)))
(goto-char (point-min))
(message
(substitute-command-keys
;; "\\[mastodon-profile--account-view-cycle]" ; not always bound?
"\\`C-c C-c' to cycle profile views: toots, followers, following.
\\`C-c C-s' to search user's toots.")))))
(defun mastodon-profile--format-joined-date-string (joined)
"Format a human-readable Joined string from timestamp JOINED.
JOINED is the `created_at' field in profile account JSON, and of
the format \"2000-01-31T00:00:00.000Z\"."
(format-time-string "Joined: %d %B %Y"
(parse-iso8601-time-string joined)))
(defun mastodon-profile--get-toot-author ()
"Open profile of author of toot under point.
If toot is a boost, opens the profile of the booster."
(interactive)
(mastodon-profile--make-author-buffer
(alist-get 'account (mastodon-profile--item-json))))
(defun mastodon-profile--image-from-account (account img-type)
"Return a avatar image from ACCOUNT.
IMG-TYPE is the JSON key from the account data."
(let ((img (alist-get img-type account)))
(unless (equal img "/avatars/original/missing.png")
(mastodon-media--get-media-link-rendering img))))
(defun mastodon-profile--show-user (user-handle)
"Query for USER-HANDLE from current status and show that user's profile."
(interactive
(list
(if (and (not (mastodon-tl--profile-buffer-p))
(not (mastodon-tl--property 'item-json :no-move)))
(message "Looks like there's no toot or user at point?")
(let ((user-handles (mastodon-profile--extract-users-handles
(mastodon-profile--item-json))))
(completing-read "View profile of user [choose or enter any handle]: "
user-handles
nil ; predicate
'confirm)))))
(if (not (or ; own profile has no need for item-json test:
(equal user-handle (mastodon-auth--get-account-name))
(mastodon-tl--profile-buffer-p)
(mastodon-tl--property 'item-json :no-move)))
(message "Looks like there's no toot or user at point?")
(let ((account (mastodon-profile--lookup-account-in-status
user-handle (mastodon-profile--item-json))))
(if account
(progn
(message "Loading profile of user %s..." user-handle)
(mastodon-profile--make-author-buffer account))
(message "Cannot find a user with handle %S" user-handle)))))
(defun mastodon-profile--my-profile ()
"Show the profile of the currently signed in user."
(interactive)
(message "Loading your profile...")
(mastodon-profile--show-user (mastodon-auth--get-account-name)))
(defun mastodon-profile--format-user (tootv)
"Convert TOOTV into author-bylines and insert.
Also insert their profile note.
Used to view a user's followers and those they're following."
(let ((inhibit-read-only t))
(unless (seq-empty-p tootv)
(mapc
(lambda (toot)
(let ((start-pos (point)))
(insert "\n"
(propertize
(mastodon-tl--byline-author `((account . ,toot)) :avatar)
'byline 't
'item-id (alist-get 'id toot)
'base-item-id (mastodon-tl--item-id toot)
'item-json toot))
(mastodon-media--inline-images start-pos (point))
(insert "\n"
(propertize
(mastodon-tl--render-text (alist-get 'note toot) nil)
'item-json toot)
"\n")))
tootv))))
(defun mastodon-profile--search-account-by-handle (handle)
"Return an account based on a user's HANDLE.
If the handle does not match a search return then retun NIL."
(let* ((handle (if (string= "@" (substring handle 0 1))
(substring handle 1 (length handle))
handle))
(args `(("q" . ,handle)))
(matching-account (seq-remove
(lambda (x)
(not (string= (alist-get 'acct x) handle)))
(mastodon-http--get-json
(mastodon-http--api "accounts/search")
args))))
(when (equal 1 (length matching-account))
(elt matching-account 0))))
(defun mastodon-profile--account-from-id (user-id)
"Request an account object relating to a USER-ID from Mastodon."
(mastodon-http--get-json
(mastodon-http--api (format "accounts/%s" user-id))))
(defun mastodon-profile--extract-users-handles (status)
"Return all user handles found in STATUS.
These include the author, author of reblogged entries and any user mentioned."
(when status
(let ((this-account (or (alist-get 'account status) ; status is a toot
status)) ; status is a user listing
(mentions (or (alist-get 'mentions (alist-get 'status status))
(alist-get 'mentions status)))
(reblog (or (alist-get 'reblog (alist-get 'status status))
(alist-get 'reblog status))))
(seq-filter #'stringp
(seq-uniq
(seq-concatenate
'list
(list (alist-get 'acct this-account))
(mastodon-profile--extract-users-handles reblog)
(mastodon-tl--map-alist 'acct mentions)))))))
(defun mastodon-profile--lookup-account-in-status (handle status)
"Return account for HANDLE using hints in STATUS if possible."
(let* ((this-account (alist-get 'account status))
(reblog-account (alist-get 'account (alist-get 'reblog status)))
(mention-id (seq-some
(lambda (mention)
(when (string= handle (alist-get 'acct mention))
(alist-get 'id mention)))
(alist-get 'mentions status))))
(cond ((string= handle (alist-get 'acct this-account))
this-account)
((string= handle (alist-get 'acct reblog-account))
reblog-account)
(mention-id
(mastodon-profile--account-from-id mention-id))
(t
(mastodon-profile--search-account-by-handle handle)))))
(defun mastodon-profile--remove-user-from-followers (&optional id)
"Remove a user from your followers.
Optionally provide the ID of the account to remove."
(interactive)
(let* ((account (unless id (mastodon-tl--property 'item-json :no-move)))
(id (or id (alist-get 'id account)))
(handle (if account
(alist-get 'acct account)
(let ((account (mastodon-profile--account-from-id id)))
(alist-get 'acct account))))
(url (mastodon-http--api
(format "accounts/%s/remove_from_followers" id))))
(when (y-or-n-p (format "Remove follower %s? " handle))
(let ((response (mastodon-http--post url)))
(mastodon-http--triage response
(lambda (_)
(message "Follower %s removed!" handle)))))))
(defun mastodon-profile--remove-from-followers-at-point ()
"Prompt for a user in the item at point and remove from followers."
(interactive)
(let* ((handles (mastodon-profile--extract-users-handles
(mastodon-profile--item-json)))
(handle (completing-read "Remove from followers: " handles nil))
(account (mastodon-profile--lookup-account-in-status
handle (mastodon-profile--item-json)))
(id (alist-get 'id account)))
(mastodon-profile--remove-user-from-followers id)))
(defun mastodon-profile--remove-from-followers-list ()
"Select a user from your followers and remove from followers.
Currently limited to 100 handles. If not found, try
`mastodon-search--query'."
(interactive)
(let* ((endpoint (format "accounts/%s/followers"
(mastodon-auth--get-account-id)))
(url (mastodon-http--api endpoint))
(response (mastodon-http--get-json url `(("limit" . "100"))))
(handles (mastodon-tl--map-alist-vals-to-alist 'acct 'id response))
(choice (completing-read "Remove from followers: " handles))
(id (alist-get choice handles)))
(mastodon-profile--remove-user-from-followers id)))
(defun mastodon-profile--add-private-note-to-account ()
"Add a private note to an account.
Can be called from a profile page or normal timeline.
Send an empty note to clear an existing one."
(interactive)
(mastodon-profile--add-or-view-private-note
'mastodon-profile--post-private-note-to-account
"add a note to"))
(defun mastodon-profile--post-private-note-to-account (id handle note-old)
"POST a private note onto an account ID with user HANDLE on the server.
NOTE-OLD is the text of any existing note."
(let* ((note (read-string (format "Add private note to account %s: " handle)
note-old))
(params `(("comment" . ,note)))
(url (mastodon-http--api (format "accounts/%s/note" id)))
(response (mastodon-http--post url params)))
(mastodon-http--triage response
(lambda (_)
(message "Private note on %s added!" handle)))))
(defun mastodon-profile--view-account-private-note ()
"Display the private note about a user."
(interactive)
(mastodon-profile--add-or-view-private-note
'mastodon-profile--display-private-note
"view private note of"
:view))
(defun mastodon-profile--display-private-note (note)
"Display private NOTE in a temporary buffer."
(with-output-to-temp-buffer "*mastodon-profile-private-note*"
(let ((inhibit-read-only t))
(princ note))))
(defun mastodon-profile--profile-json ()
"Return the profile-json property if we are in a profile buffer."
(when (mastodon-tl--profile-buffer-p)
(save-excursion
(goto-char (point-min))
(or (mastodon-tl--property 'profile-json :no-move)
(error "No profile data found")))))
(defun mastodon-profile--add-or-view-private-note (action-fun &optional message view)
"Add or view a private note for an account.
ACTION-FUN does the adding or viewing, MESSAGE is a prompt for
`mastodon-tl--user-handles-get', VIEW is a flag."
(let* ((profile-json (mastodon-profile--profile-json))
(handle (if (mastodon-tl--profile-buffer-p)
(alist-get 'acct profile-json)
(mastodon-tl--user-handles-get message)))
(account (if (mastodon-tl--profile-buffer-p)
profile-json
(mastodon-profile--search-account-by-handle handle)))
(id (alist-get 'id account))
(relationships (mastodon-profile--relationships-get id))
(note (alist-get 'note relationships)))
(if view
(if (string-empty-p note)
(message "No private note for %s" handle)
(funcall action-fun note))
(funcall action-fun id handle note))))
(defun mastodon-profile--show-familiar-followers ()
"Show a list of familiar followers.
Familiar followers are accounts that you follow, and that follow
the given account."
(interactive)
(let* ((profile-json (mastodon-profile--profile-json))
(handle
(if (mastodon-tl--profile-buffer-p)
(alist-get 'acct profile-json)
(mastodon-tl--user-handles-get "show familiar followers of")))
(account (if (mastodon-tl--profile-buffer-p)
profile-json
(mastodon-profile--search-account-by-handle handle)))
(id (alist-get 'id account)))
(mastodon-profile--get-familiar-followers id)))
(defun mastodon-profile--get-familiar-followers (id)
"Return JSON data of familiar followers for account ID."
;; the server handles multiple IDs, but we just handle one.
(let* ((params `(("id" . ,id)))
(url (mastodon-http--api "accounts/familiar_followers"))
(json (mastodon-http--get-json url params))
(accounts (alist-get 'accounts (car json))) ; first id
(handles (mastodon-tl--map-alist 'acct accounts)))
(if (null handles)
(message "Looks like there are no familiar followers for this account")
(let ((choice (completing-read "Show profile of user: " handles)))
(mastodon-profile--show-user choice)))))
(provide 'mastodon-profile)
;;; mastodon-profile.el ends here
(define-package "mastodon" "20231030.1922" "Client for fediverse services using the Mastodon API"
'((emacs "27.1")
(request "0.3.0")
(persist "0.4"))
:commit "a8c80d25b7790746a439ae6c2deea3dc6bcac710" :authors
'(("Johnson Denen" . "johnson.denen@gmail.com")
("Marty Hiatt" . "martianhiatus@riseup.net"))
:maintainers
'(("Marty Hiatt" . "martianhiatus@riseup.net"))
:maintainer
'("Marty Hiatt" . "martianhiatus@riseup.net")
:url "https://codeberg.org/martianh/mastodon.el")
;; Local Variables:
;; no-byte-compile: t
;; End:
;;; mastodon-notifications.el --- Notification functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-notification.el provides notification functions for Mastodon.
;;; Code:
(require 'mastodon)
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--get-params-async-json "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(autoload 'mastodon-http--triage "mastodon-http")
(autoload 'mastodon-media--inline-images "mastodon-media")
(autoload 'mastodon-tl--byline "mastodon-tl")
(autoload 'mastodon-tl--byline-author "mastodon-tl")
(autoload 'mastodon-tl--clean-tabs-and-nl "mastodon-tl")
(autoload 'mastodon-tl--content "mastodon-tl")
(autoload 'mastodon-tl--field "mastodon-tl")
(autoload 'mastodon-tl--find-property-range "mastodon-tl")
(autoload 'mastodon-tl--has-spoiler "mastodon-tl")
(autoload 'mastodon-tl--init "mastodon-tl")
(autoload 'mastodon-tl--insert-status "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
(autoload 'mastodon-tl--reload-timeline-or-profile "mastodon-tl")
(autoload 'mastodon-tl--spoiler "mastodon-tl")
(autoload 'mastodon-tl--item-id "mastodon-tl")
(autoload 'mastodon-tl--update "mastodon-tl")
(autoload 'mastodon-views--view-follow-requests "mastodon-views")
(defvar mastodon-tl--buffer-spec)
(defvar mastodon-tl--display-media-p)
(defvar mastodon-notifications--types-alist
'(("follow" . mastodon-notifications--follow)
("favourite" . mastodon-notifications--favourite)
("reblog" . mastodon-notifications--reblog)
("mention" . mastodon-notifications--mention)
("poll" . mastodon-notifications--poll)
("follow_request" . mastodon-notifications--follow-request)
("status" . mastodon-notifications--status)
("update" . mastodon-notifications--edit))
"Alist of notification types and their corresponding function.")
(defvar mastodon-notifications--response-alist
'(("Followed" . "you")
("Favourited" . "your status from")
("Boosted" . "your status from")
("Mentioned" . "you")
("Posted a poll" . "that has now ended")
("Requested to follow" . "you")
("Posted" . "a post")
("Edited" . "a post from"))
"Alist of subjects for notification types.")
(defvar mastodon-notifications--map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-mode-map)
(define-key map (kbd "a") #'mastodon-notifications--follow-request-accept)
(define-key map (kbd "j") #'mastodon-notifications--follow-request-reject)
(define-key map (kbd "C-k") #'mastodon-notifications--clear-current)
map)
"Keymap for viewing notifications.")
(defun mastodon-notifications--byline-concat (message)
"Add byline for TOOT with MESSAGE."
(concat " " (propertize message 'face 'highlight)
" " (cdr (assoc message mastodon-notifications--response-alist))))
(defun mastodon-notifications--follow-request-process (&optional reject)
"Process the follow request at point.
With no argument, the request is accepted. Argument REJECT means
reject the request. Can be called in notifications view or in
follow-requests view."
(if (not (mastodon-tl--find-property-range 'item-json (point)))
(message "No follow request at point?")
(let* ((item-json (mastodon-tl--property 'item-json))
(f-reqs-view-p (string= "follow_requests"
(plist-get mastodon-tl--buffer-spec 'endpoint)))
(f-req-p (or (string= "follow_request" (alist-get 'type item-json)) ;notifs
f-reqs-view-p)))
(if (not f-req-p)
(message "No follow request at point?")
(let-alist (or (alist-get 'account item-json) ;notifs
item-json) ;f-reqs
(if .id
(let ((response
(mastodon-http--post
(concat
(mastodon-http--api "follow_requests")
(format "/%s/%s" .id (if reject "reject" "authorize"))))))
(mastodon-http--triage response
(lambda (_)
(if f-reqs-view-p
(mastodon-views--view-follow-requests)
(mastodon-tl--reload-timeline-or-profile))
(message "Follow request of %s (@%s) %s!"
.username .acct (if reject
"rejected"
"accepted")))))
(message "No account result at point?")))))))
(defun mastodon-notifications--follow-request-accept ()
"Accept a follow request.
Can be called in notifications view or in follow-requests view."
(interactive)
(mastodon-notifications--follow-request-process))
(defun mastodon-notifications--follow-request-reject ()
"Reject a follow request.
Can be called in notifications view or in follow-requests view."
(interactive)
(mastodon-notifications--follow-request-process :reject))
(defun mastodon-notifications--mention (note)
"Format for a `mention' NOTE."
(mastodon-notifications--format-note note 'mention))
(defun mastodon-notifications--follow (note)
"Format for a `follow' NOTE."
(mastodon-notifications--format-note note 'follow))
(defun mastodon-notifications--follow-request (note)
"Format for a `follow-request' NOTE."
(mastodon-notifications--format-note note 'follow-request))
(defun mastodon-notifications--favourite (note)
"Format for a `favourite' NOTE."
(mastodon-notifications--format-note note 'favourite))
(defun mastodon-notifications--reblog (note)
"Format for a `boost' NOTE."
(mastodon-notifications--format-note note 'boost))
(defun mastodon-notifications--status (note)
"Format for a `status' NOTE.
Status notifications are given when
`mastodon-tl--enable-notify-user-posts' has been set."
(mastodon-notifications--format-note note 'status))
(defun mastodon-notifications--poll (note)
"Format for a `poll' NOTE."
(mastodon-notifications--format-note note 'poll))
(defun mastodon-notifications--edit (note)
"Format for an `edit' NOTE."
(mastodon-notifications--format-note note 'edit))
(defun mastodon-notifications--format-note (note type)
"Format for a NOTE of TYPE."
(let ((id (alist-get 'id note))
(status (mastodon-tl--field 'status note))
(follower (alist-get 'username (alist-get 'account note))))
(mastodon-notifications--insert-status
;; toot
(cond ((or (equal type 'follow)
(equal type 'follow-request))
;; Using reblog with an empty id will mark this as something
;; non-boostable/non-favable.
(cons '(reblog (id . nil)) note))
;; reblogs/faves use 'note' to process their own json
;; not the toot's. this ensures following etc. work on such notifs
((or (equal type 'favourite)
(equal type 'boost))
note)
(t
status))
;; body
(if (or (equal type 'follow)
(equal type 'follow-request))
(propertize (if (equal type 'follow)
"Congratulations, you have a new follower!"
(format "You have a follow request from... %s"
follower))
'face 'default)
(mastodon-tl--clean-tabs-and-nl
(if (mastodon-tl--has-spoiler status)
(mastodon-tl--spoiler status)
(mastodon-tl--content status))))
;; author-byline
(if (or (equal type 'follow)
(equal type 'follow-request)
(equal type 'mention))
'mastodon-tl--byline-author
(lambda (_status)
(mastodon-tl--byline-author note)))
;; action-byline
(lambda (_status)
(mastodon-notifications--byline-concat
(cond ((equal type 'boost)
"Boosted")
((equal type 'favourite)
"Favourited")
((equal type 'follow-request)
"Requested to follow")
((equal type 'follow)
"Followed")
((equal type 'mention)
"Mentioned")
((equal type 'status)
"Posted")
((equal type 'poll)
"Posted a poll")
((equal type 'edit)
"Edited"))))
id
;; base toot
(when (or (equal type 'favourite)
(equal type 'boost))
status))))
(defun mastodon-notifications--insert-status
(toot body author-byline action-byline id &optional base-toot)
"Display the content and byline of timeline element TOOT.
BODY will form the section of the toot above the byline.
AUTHOR-BYLINE is an optional function for adding the author
portion of the byline that takes one variable. By default it is
`mastodon-tl--byline-author'.
ACTION-BYLINE is also an optional function for adding an action,
such as boosting favouriting and following to the byline. It also
takes a single function. By default it is
`mastodon-tl--byline-boosted'.
ID is the notification's own id, which is attached as a property.
If the status is a favourite or a boost, BASE-TOOT is the JSON
of the toot responded to."
(when toot ; handle rare blank notif server bug
(mastodon-tl--insert-status toot body author-byline action-byline id base-toot)))
(defun mastodon-notifications--by-type (note)
"Filters NOTE for those listed in `mastodon-notifications--types-alist'."
(let* ((type (mastodon-tl--field 'type note))
(fun (cdr (assoc type mastodon-notifications--types-alist)))
(start-pos (point)))
(when fun
(funcall fun note)
(when mastodon-tl--display-media-p
(mastodon-media--inline-images start-pos (point))))))
(defun mastodon-notifications--timeline (json)
"Format JSON in Emacs buffer."
(if (seq-empty-p json)
(message "Looks like you have no (more) notifications for the moment.")
(mapc #'mastodon-notifications--by-type json)
(goto-char (point-min))))
(defun mastodon-notifications--get-mentions ()
"Display mention notifications in buffer."
(interactive)
(mastodon-notifications-get "mention" "mentions"))
(defun mastodon-notifications--get-favourites ()
"Display favourite notifications in buffer."
(interactive)
(mastodon-notifications-get "favourite" "favourites"))
(defun mastodon-notifications--get-boosts ()
"Display boost notifications in buffer."
(interactive)
(mastodon-notifications-get "reblog" "boosts"))
(defun mastodon-notifications--get-polls ()
"Display poll notifications in buffer."
(interactive)
(mastodon-notifications-get "poll" "polls"))
(defun mastodon-notifications--get-statuses ()
"Display status notifications in buffer.
Status notifications are created when you call
`mastodon-tl--enable-notify-user-posts'."
(interactive)
(mastodon-notifications-get "status" "statuses"))
(defun mastodon-notifications--filter-types-list (type)
"Return a list of notification types with TYPE removed."
(let ((types (mapcar #'car mastodon-notifications--types-alist)))
(remove type types)))
(defun mastodon-notifications--clear-all ()
"Clear all notifications."
(interactive)
(when (y-or-n-p "Clear all notifications?")
(let ((response
(mastodon-http--post (mastodon-http--api "notifications/clear"))))
(mastodon-http--triage
response (lambda (_)
(when mastodon-tl--buffer-spec
(mastodon-tl--reload-timeline-or-profile))
(message "All notifications cleared!"))))))
(defun mastodon-notifications--clear-current ()
"Dismiss the notification at point."
(interactive)
(let* ((id (or (mastodon-tl--property 'item-id)
(mastodon-tl--field 'id
(mastodon-tl--property 'item-json))))
(response
(mastodon-http--post (mastodon-http--api
(format "notifications/%s/dismiss" id)))))
(mastodon-http--triage
response (lambda (_)
(when mastodon-tl--buffer-spec
(mastodon-tl--reload-timeline-or-profile))
(message "Notification dismissed!")))))
(provide 'mastodon-notifications)
;;; mastodon-notifications.el ends here
;;; mastodon-media.el --- Functions for inlining Mastodon media -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-media.el provides functions for inlining media.
;; Known bug gnutls -12 when trying to access images on some systems.
;; It looks like their may be a version mismatch between the encryption
;; required by the server and client.
;;; Code:
(require 'url-cache)
(autoload 'mastodon-tl--propertize-img-str-or-url "mastodon-tl")
(defvar url-show-status)
(defvar mastodon-tl--shr-image-map-replacement)
(defgroup mastodon-media nil
"Inline Mastadon media."
:prefix "mastodon-media-"
:group 'mastodon)
(defcustom mastodon-media--avatar-height 20
"Height of the user avatar images (if shown)."
:type 'integer)
(defcustom mastodon-media--preview-max-height 250
"Max height of any media attachment preview to be shown in timelines."
:type 'integer)
(defcustom mastodon-media--enable-image-caching nil
"Whether images should be cached."
:type 'boolean)
(defvar mastodon-media--generic-avatar-data
(base64-decode-string
"iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA
B3RJTUUH4QUIFCg2lVD1hwAAABZ0RVh0Q29tbWVudABHZW5lcmljIGF2YXRhcsyCnMsAAAcGSURB
VHja7dzdT1J/HAfwcw7EQzMKW0pGRMK4qdRZbdrs6aIRbt506V1b/AV1U2td9l9UXnmhW6vgwuko
SbcOD/a0RB4CCRCRg0AIR4Hz8LvgN2cKCMI5wOH7uXBuugO+eH8+fM/3HIFpmoZAVVYIIABYAAtg
ASyABbAAAcACWAALYAEsgAUIABbAAlgAC2ABLEAAsAAWwAJYAAtgAQKAxUjxm+R50DRN0zRFUf+8
kggCwzAMwwDrfyOSJGmattlsdrvd5XLlcrndnyoUir6+vpGRkZMnT/J4vIarwY26MaTAZLVap6en
fT7f9vY2QRA7Ozv/vJJ8vkgk4vP5XV1dWq1Wq9VKpdIGkjUGi6IoFEWnp6ddLlcymSRJsvzv83g8
kUikUCi0Wq1Opzt16lS7YBEE8ebNG6PRiGHYoUwHyW7cuPHo0SOlUsl9LIIgXrx4Ybfb//79e7Qj
CIXC3t7ex48fX7lyhctYBSkURTOZTC3H4fF4SqXy6dOnLHuxh0VR1PPnz2uX2uv17Nmzy5cvc21R
StP0q1ev7HZ7XaQgCCJJ0u/3T0xMBINBrmGhKGo0Go88p0p5Wa1Wg8GQSqW4g0XT9NTUFIZhdT9y
Npudn59nLVwIO7FyuVxVrRIqr1AoZDab2QkXG1hTU1PJZJKhg5MkOT8/HwqFuIBF07TP52MoVrvh
YqLHG4BlsVi2t7cZfQiSJB0OBwudyDiWzWYjCILpR1lZWeECltPp3LeXwEQFg8FoNNryWPl8noVp
ws6jgG1lgAWwuI914cIFPp/xnX6ZTCYSiVoeq7+/n4U/Q61Wy+Xylse6desWC8kaGBiQSCQtjyWR
SGQyGY/HY+4hpFJpV1cXRwa8TqdjtBOHh4fVajVHsLRarVKpZChcUqn07t27LPQgS1gSiUSn04nF
4rofGYbh4eHhgYEBTq2ztFrtyMhI3ZtRo9GMjY2xEyv2sCQSiV6vV6lUdWzGzs7O8fHxwcFBDq7g
5XL5kydPent76+LV2dmp1+vv37/P5gqe7SvSDofj5cuXteydwjAslUr1ev2DBw9YPt1pwL0ODodj
YmLCYrEcYZ8LhmGNRjM+Ps5yphqGBUFQKBQyGo0mk2l1dTWfz5MkSVFUPp8/+GSEQiEMw8eOHYNh
uLu7e2hoaGxsjM05tbfYvpkNx/FQKBSJRCAI6unpwTBsbW0tmUwWbtc6mCMEQSAIOn78+Llz586f
P9/T05PL5QKBgEKh4GyyCkZfvnwJhULhcHhzczOTyRRuYMtms/l8PpPJZDKZnZ2dvc9HIBCIxeIT
J04Uvil87ejoOH36tEwm02g0V69evXjxIkewCkZer/fr16+/f/+OxWKlrvQQBEEQxL7dYQRBhEJh
0fNwBEHEYrFMJlOpVP39/RqNhgU1prAKTDMzMy6XKxqNJhIJptY+CHLmzBmZTHbp0qXbt2+rVKpW
wtplWl5eDofDTF803Bs0tVrNKFmdsXAcn52dnZ2dDQaD7DAVJRsdHb1z507dT93rhoXj+MrKytzc
3NLSEnNNVyHZ2bNnr127NjQ0NDg4WEey+mDhOP7u3bu5ubkyI5z9iMnl8nv37o2OjgoEgmbBisVi
r1+/ttlsjQ1UmYg9fPiwo6OjwVg4jn///v3Dhw/Ly8vNEKiiXhKJpK+vT6fT1d6S/FqkUBSdnJz0
+/1QsxZFUclkEkXReDxOkuT169dr8TpisnAcN5lMb9++ZfP+11pKIBAUdgpv3rx55BGGtIMUBEG5
XM7tdhsMhoWFhb3/S8UsVitK1curaqzV1dX379+3nNQ+r42NjSPsPlaH5fP5mnyiV+Ll9XonJyfD
4XC1XkhVDTgzM/Pz50+oxSubzX779u3z58/VLneQyqUMBsOnT5+acz1V7XoiHo9//PjRZDKl0+n6
Y3k8HrPZ3Gxr9Fq81tfXl5aWAoFA5cO+IqxIJFLYSIA4VARBuN3uxcXFyoc9v5IGNJvNVquVAw14
sBktFkt3d7dUKq3k5BGpJFYLCwucacCizZhIJCoJF3JorBYXF//8+QNxtAiCKFwiqKRvkEPnOoqi
HGvAfeFKJBIVTnqkfKx+/PjBsbleKlwej6cmLI/H43A4OByr3XClUimn03louMphra2teb1eqA0q
m836fL6tra0jYkUiEb/fz8k3waLhikQiXq+3/NtiSayNjY1fv35BbVP5fN7pdG5tbR0Fy+12c360
Hxzz5a8KI6V6EMMwzo/2fZ2YTqej0WgqlSoVLqRUDwYCAajNiqKoYDBYphOLY8ViscItVG1VJEmu
r6+XeU8sjhWPxzc3N9sNiyAIDMOqS1YbDqwKx1YRrFQqxc7HJDRnpdPpUuEqgoVhWL0+i6hFz6tL
ja3iM4u1zw1qwhlfJihI0bfCNhxYe4NSqg3/A862hQAbrdtHAAAAAElFTkSuQmCC")
"The PNG data for a generic 100x100 avatar.")
(defvar mastodon-media--generic-broken-image-data
(base64-decode-string
"iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA
B3RJTUUH4QUIFQUVFt+0LQAAABZ0RVh0Q29tbWVudABHZW5lcmljIGF2YXRhcsyCnMsAAAdoSURB
VHja7d1NSFRrAIfx//iB6ZDSMJYVkWEk0ceYFUkkhhQlEUhEg0FlC1eBoRTUwlbRok0TgRQURZAE
FgpjJmFajpK4kggxpXHRQEGWUJZizpy7uPfC5eKiV+dD5zw/mN05jrxnnjnfcxyWZVkCMKc0SXI4
HIwEMIcUhgAgEIBAAAIBCAQgEIBAAAIBCAQgEAAEAhAIQCAAgQAEAhAIQCAAgQA2kBaNP8Jt7ViM
onErOWsQgEAAAgEIBCAQgEAAAgEIBCAQgEAAEAhAIACBAAQCEAhAIACBAAQCEAhAIAAIBCAQgEAA
AgEIBCAQgEAAAgEIBACBAAQCEAhAIACBAAQCEAhAIACBAAQCgEAAAgEIBCAQgECAxSyNIYitz58/
a3BwUIODgxoZGVEoFFIoFNK3b980NTWlX79+SZIyMzOVlZWlVatWae3atSooKJDH49HOnTvl8XiU
ksJ3WSI4LMuyHA7Hgv6IZVmM5D8mJyf1/PlzdXZ2qrOzU8FgcMF/0+126+DBg6qqqlJFRYXS0vhe
+6MP9wI/1wQSJeFwWH6/X01NTWpra9PU1FTM3isvL0/nz5/XuXPntHz5ciqIcSCy/v50L+hlV+Pj
49a1a9esdevWLXgMTV8ul8u6c+eOFYlELMwtKmNNIOa+fv1qXbp0yXI6nXEP4/+v0tJS6+PHj9RA
IIk3PT1tXb161crOzk54GP995ebmWt3d3RRBIInj9/utgoKCRRXGf18ZGRmW3++niigHwk56PHf4
Yiw9PV0dHR0qLy9nD52jWAQylxUrVmhgYEAbN24kkCgsM84+JZmJiQmdPn1akUiEweBE4eL/NsrN
zVVZWZlKSkpUWFioTZs2yeVyKTs7W7Ozs5qYmNDExITev3+v/v5+9fX1qb+/f8FjevPmTdXW1rIG
IZDFN9gbNmyQ1+uV1+uVx+MxXlAjIyNqbGzU3bt39fPnz3n9vytXrlQwGJTT6SQQThQm/ohIamqq
VVlZaXV1dUXtPT98+GCVlZXNe7n4fD6OYnGYN7GDnZ6ebtXU1FhjY2Mxed9IJGLV19fPa7kUFRUR
CIEkZrAdDod15syZmIXxf7W1tfNaNqOjowSygBdHseZh7969GhgY0IMHD5Sfnx+X97xx44Z2795t
PF93dzcLjMO88TvHcP/+ffX19WnXrl3xXVApKbp9+7bxfSFv3rxhwRFI7B07dkxDQ0Oqrq5O2P9Q
XFysffv2Gc0zOjrKwiOQ2Hv69Kny8vIS/n8cP37caPqxsTEWHoHYa//HxPfv3xk0ArGP1atXG03/
7z3vIBBbyM3NNZo+KyuLQSMQ+5icnDSaPicnh0EjEPsYHh42mp7L3gnEVnp6eoymLyoqYtAIxD4e
PXpkNP3+/fsZtAXgcvclpL29XUeOHPnj6Z1Op8bHx7Vs2TJ7fri5o9A+ZmZmdPHiRaN5vF6vbeNg
E8tmGhoaNDQ0ZPTteeHCBQaOQJLfkydPdP36daN5Tp48qc2bNzN47IMkt9evX+vw4cOanp7+43ly
cnI0PDy8KK4dYx8EMRMIBHT06FGjOCTJ5/PZPg42sZJce3u7Dh06pB8/fhjNV11dndBL8tnEYhMr
5lpaWuT1evX792+j+YqLixUIBLj+ik2s5NXc3KwTJ04Yx5Gfn69nz54RB5tYyaupqUlVVVWanZ01
ms/tdqujo4P9DgJJXg8fPtSpU6cUDoeN43j58qUKCwsZRAJJTvfu3dPZs2eNf0/X7Xarq6tL27dv
ZxAJJDn5fD7V1NQYx7FmzRq9evVK27ZtYxAJJDk1NDSorq7O+ChgQUGBent7tWXLFgYxxniecILU
1dXJ5/MZz7d161a9ePHC+N50sAZZMq5cuTKvOEpKStTT00McccSJwji7devWvJ7bceDAAbW2ttr6
cQbGH26eD7K0BAIBlZeXG5/nqKioUEtLizIyMhhEAklOX758kcfj0adPn4zXHG1tbcSRoEDYB4mT
y5cvG8exZ88etba2Egf7IMnt7du32rFjh9G5jvz8fA0MDBj/UBxYgyw5jY2NRnGkpqaqubmZOBYB
AomxmZkZPX782Gie+vr6uD9/BGxiJURvb69KS0v/ePrMzEyFQiG5XC4Gj02s5BcIBIymr6ysJA42
sezj3bt3RtObPv8DBLKkBYNBo+m5r4NAbCUUChlNv379egaNQOzD9FdJ2P8gEFsxfQQaFyMuLhzm
jfUAG45tOBw2fhY6ojP2rEGWwiqdONjEAggEIBCAQAACAUAgAIEA0cIPx8UYJ1FZgwAEAhAIAAIB
CAQgEIBAAAIBFiNOFMaY6V1tnFhkDQIQCEAgAIEABAKAQAACAQgEIBCAQAACAQgEIBCAQABIXO4e
c1y+zhoEIBCAQAAQCEAgAIEABAIQCEAgAIEABAIQCEAgAAgEIBCAQAACAQgEIBCAQAACAQgEAIEA
BAIQCEAgAIEABAIsJVH58WqHw8FIgjUIQCAACAQgEIBAAAIBCAQgEIBAAAIBCAQgEAAEAhAIQCBA
fKRJkmVZjAQwh78A6vCRWJE8K+8AAAAASUVORK5CYII=")
"The PNG data for a generic 200x200 \"broken image\" view.")
(defun mastodon-media--process-image-response
(status-plist marker image-options region-length url)
"Callback function processing the url retrieve response for URL.
STATUS-PLIST is the usual plist of status events as per `url-retrieve'.
IMAGE-OPTIONS are the precomputed options to apply to the image.
MARKER is the marker to where the response should be visible.
REGION-LENGTH is the length of the region that should be replaced
with the image."
(when (marker-buffer marker) ; if buffer hasn't been killed
(let ((url-buffer (current-buffer))
(is-error-response-p (eq :error (car status-plist))))
(let* ((data (unless is-error-response-p
(goto-char (point-min))
(search-forward "\n\n")
(buffer-substring (point) (point-max))))
(image (when data
(apply #'create-image data
(if (version< emacs-version "27.1")
(when image-options 'imagemagick)
nil) ; inbuilt scaling in 27.1
t image-options))))
(when mastodon-media--enable-image-caching
(unless (url-is-cached url) ; cache if not already cached
(url-store-in-cache url-buffer)))
(with-current-buffer (marker-buffer marker)
;; Save narrowing in our buffer
(let ((inhibit-read-only t))
(save-restriction
(widen)
(put-text-property marker
(+ marker region-length) 'media-state 'loaded)
(when image
;; We only set the image to display if we could load
;; it; we already have set a default image when we
;; added the tag.
(put-text-property marker (+ marker region-length)
'display image))
;; We are done with the marker; release it:
(set-marker marker nil)))
(kill-buffer url-buffer))))))
(defun mastodon-media--load-image-from-url (url media-type start region-length)
"Take a URL and MEDIA-TYPE and load the image asynchronously.
MEDIA-TYPE is a symbol and either `avatar' or `media-link'.
START is the position where we start loading the image.
REGION-LENGTH is the range from start to propertize."
(let ((image-options (when (or (image-type-available-p 'imagemagick)
(image-transforms-p)) ; inbuilt scaling in 27.1
(cond
((eq media-type 'avatar)
`(:height ,mastodon-media--avatar-height))
((eq media-type 'media-link)
`(:max-height ,mastodon-media--preview-max-height))))))
(let ((buffer (current-buffer))
(marker (copy-marker start))
(url-show-status nil)) ; stop url.el from spamming us about connecting
(condition-case nil
;; catch any errors in url-retrieve so as to not abort
;; whatever called us
(if (and mastodon-media--enable-image-caching
(url-is-cached url))
;; if image url is cached, decompress and use it
(with-current-buffer (url-fetch-from-cache url)
(set-buffer-multibyte nil)
(goto-char (point-min))
(zlib-decompress-region
(goto-char (search-forward "\n\n")) (point-max))
(mastodon-media--process-image-response
nil marker image-options region-length url))
;; else fetch as usual and process-image-response will cache it
(url-retrieve url #'mastodon-media--process-image-response
(list marker image-options region-length url)))
(error (with-current-buffer buffer
;; TODO: Consider adding retries
(put-text-property marker
(+ marker region-length)
'media-state
'loading-failed)
:loading-failed))))))
(defun mastodon-media--select-next-media-line (end-pos)
"Find coordinates of the next media to load before END-POS.
Returns the list of (`start' . `end', `media-symbol') points of
that line and string found or nil no more media links were
found."
(let ((next-pos (point)))
(while
(and
(setq next-pos (next-single-property-change next-pos 'media-state))
(or (not (eq 'needs-loading (get-text-property next-pos 'media-state)))
(null (get-text-property next-pos 'media-url))
(null (get-text-property next-pos 'media-type))))
;; do nothing - the loop will proceed
)
(when (and next-pos (< next-pos end-pos))
(let ((media-type (get-text-property next-pos 'media-type)))
(cond
((eq media-type 'avatar) ; avatars are one character
(list next-pos (+ next-pos 1) 'avatar))
((eq media-type 'media-link) ; media links are 5 characters: [img]
(list next-pos (+ next-pos 5) 'media-link)))))))
(defun mastodon-media--valid-link-p (link)
"Check if LINK is valid.
Checks to make sure the missing string has not been returned."
(and link
(> (length link) 8)
(or (string= "http://" (substring link 0 7))
(string= "https://" (substring link 0 8)))))
(defun mastodon-media--inline-images (search-start search-end)
"Find all `Media_Links:' in the range from SEARCH-START to SEARCH-END.
Replace them with the referenced image."
(save-excursion
(goto-char search-start)
(let (line-details)
(while (setq line-details
(mastodon-media--select-next-media-line search-end))
(let* ((start (car line-details))
(end (cadr line-details))
(media-type (cadr (cdr line-details)))
(type (get-text-property start 'mastodon-media-type))
(image-url (get-text-property start 'media-url)))
(if (not (mastodon-media--valid-link-p image-url))
;; mark it at least as not needing loading any more
(put-text-property start end 'media-state 'invalid-url)
;; proceed to load this image asynchronously
(put-text-property start end 'media-state 'loading)
(mastodon-media--load-image-from-url
image-url media-type start (- end start))
(when (or (equal type "gifv")
(equal type "video"))
(mastodon-media--moving-image-overlay start end))))))))
;; (defvar-local mastodon-media--overlays nil
;; "Holds a list of overlays in the buffer.")
(defun mastodon-media--moving-image-overlay (start end)
"Add play symbol overlay to moving image media items."
(let ((ov (make-overlay start end)))
(overlay-put
ov
'after-string
(propertize ""
'help-echo "Video"
'face
'((:height 3.5 :inherit font-lock-comment-face))))))
;; (cl-pushnew ov mastodon-media--overlays)))
(defun mastodon-media--get-avatar-rendering (avatar-url)
"Return the string to be written that renders the avatar at AVATAR-URL."
;; We use just an empty space as the textual representation.
;; This is what a user will see on a non-graphical display
;; where not showing an avatar at all is preferable.
(let ((image-options (when (or (image-type-available-p 'imagemagick)
(image-transforms-p)) ; inbuilt scaling in 27.1
`(:height ,mastodon-media--avatar-height))))
(concat
(propertize " "
'media-url avatar-url
'media-state 'needs-loading
'media-type 'avatar
'display (apply #'create-image mastodon-media--generic-avatar-data
(if (version< emacs-version "27.1")
(when image-options 'imagemagick)
nil) ; inbuilt scaling in 27.1
t image-options))
" ")))
(defun mastodon-media--get-media-link-rendering
(media-url &optional full-remote-url type caption)
"Return the string to be written that renders the image at MEDIA-URL.
FULL-REMOTE-URL is used for `shr-browse-image'.
TYPE is the attachment's type field on the server.
CAPTION is the image caption if provided."
(let* ((help-echo-base
"RET/i: load full image (prefix: copy URL), +/-: zoom,\
r: rotate, o: save preview")
(help-echo (if caption
(concat help-echo-base
"\n\"" caption "\"")
help-echo-base)))
(concat
(mastodon-tl--propertize-img-str-or-url
"[img]" media-url full-remote-url type help-echo
(create-image mastodon-media--generic-broken-image-data nil t))
" ")))
(provide 'mastodon-media)
;;; mastodon-media.el ends here
;;; mastodon-iso.el --- ISO language code lists for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2022 Marty Hiatt
;; Author: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;;; Code:
;; via
;; https://github.com/VyrCossont/mastodon/blob/0836f4a656d5486784cadfd7d0cd717bb67ede4c/app/helpers/languages_helper.rb
;; and
;; https://github.com/Shinmera/language-codes/blob/master/data/iso-639-3.lisp
(defvar mastodon-iso-639-1
'(("Abkhazian" . "ab")
("Afar" . "aa")
("Afrikaans" . "af")
("Akan" . "ak")
("Albanian" . "sq")
("Amharic" . "am")
("Arabic" . "ar")
("Aragonese" . "an")
("Armenian" . "hy")
("Assamese" . "as")
("Avaric" . "av")
("Avestan" . "ae")
("Aymara" . "ay")
("Azerbaijani" . "az")
("Bambara" . "bm")
("Bashkir" . "ba")
("Basque" . "eu")
("Belarusian" . "be")
("Bengali" . "bn")
("Bihari languages" . "bh")
("Bislama" . "bi")
("Bosnian" . "bs")
("Breton" . "br")
("Bulgarian" . "bg")
("Burmese" . "my")
("Central Khmer" . "km")
("Chamorro" . "ch")
("Chechen" . "ce")
("Chinese" . "zh")
("Chuvash" . "cv")
("Cornish" . "kw")
("Corsican" . "co")
("Cree" . "cr")
("Croatian" . "hr")
("Czech" . "cs")
("Danish" . "da")
("Dzongkha" . "dz")
("English" . "en")
("Esperanto" . "eo")
("Estonian" . "et")
("Ewe" . "ee")
("Faroese" . "fo")
("Fijian" . "fj")
("Finnish" . "fi")
("Dutch" . "nl")
("French" . "fr")
("Fulah" . "ff")
("Galician" . "gl")
("Ganda" . "lg")
("Georgian" . "ka")
("German" . "de")
("Greek" . "el")
("Guarani" . "gn")
("Gujarati" . "gu")
("Haitian" . "ht")
("Hausa" . "ha")
("Hebrew" . "he")
("Herero" . "hz")
("Hindi" . "hi")
("Hiri Motu" . "ho")
("Hungarian" . "hu")
("Icelandic" . "is")
("Ido" . "io")
("Igbo" . "ig")
("Indonesian" . "id")
("Interlingua" . "ia")
("Inuktitut" . "iu")
("Inupiaq" . "ik")
("Irish" . "ga")
("Italian" . "it")
("Japanese" . "ja")
("Japanese" . "jp")
("Javanese" . "jv")
("Kalaallisut" . "kl")
("Kannada" . "kn")
("Kanuri" . "kr")
("Kashmiri" . "ks")
("Kazakh" . "kk")
("Kikuyu" . "ki")
("Kinyarwanda" . "rw")
("Komi" . "kv")
("Kongo" . "kg")
("Korean" . "ko")
("Kurdish" . "ku")
("Kuanyama" . "kj")
("Kirghiz" . "ky")
("Lao" . "lo")
("Latin" . "la")
("Latvian" . "lv")
("Limburgan" . "li")
("Lingala" . "ln")
("Lithuanian" . "lt")
("Luba-Katanga" . "lu")
("Luxembourgish" . "lb")
("Macedonian" . "mk")
("Malagasy" . "mg")
("Malay" . "ms")
("Malayalam" . "ml")
("Divehi" . "dv")
("Maltese" . "mt")
("Manx" . "gv")
("Maori" . "mi")
("Marathi" . "mr")
("Marshallese" . "mh")
("Mongolian" . "mn")
("Nauru" . "na")
("Navajo" . "nv")
("Ndonga" . "ng")
("Nepali" . "ne")
("Ndebele, North" . "nd")
("Northern Sami" . "se")
("Norwegian" . "no")
("Bokmål, Norwegian" . "nb")
("Chichewa" . "ny")
("Norwegian Nynorsk" . "nn")
("Interlingue" . "ie")
("Occitan" . "oc")
("Ojibwa" . "oj")
("Church Slavic" . "cu")
("Oriya" . "or")
("Oromo" . "om")
("Ossetian" . "os")
("Pali" . "pi")
("Persian" . "fa")
("Polish" . "pl")
("Portuguese" . "pt")
("Panjabi" . "pa")
("Pushto" . "ps")
("Quechua" . "qu")
("Romanian" . "ro")
("Romansh" . "rm")
("Rundi" . "rn")
("Russian" . "ru")
("Samoan" . "sm")
("Sango" . "sg")
("Sanskrit" . "sa")
("Sardinian" . "sc")
("Gaelic" . "gd")
("Serbian" . "sr")
("Shona" . "sn")
("Sichuan Yi" . "ii")
("Sindhi" . "sd")
("Sinhala" . "si")
("Slovak" . "sk")
("Slovenian" . "sl")
("Somali" . "so")
("Sotho, Southern" . "st")
("Ndebele, South" . "nr")
("Spanish" . "es")
("Sundanese" . "su")
("Swahili" . "sw")
("Swati" . "ss")
("Swedish" . "sv")
("Tagalog" . "tl")
("Tahitian" . "ty")
("Tajik" . "tg")
("Tamil" . "ta")
("Tatar" . "tt")
("Telugu" . "te")
("Thai" . "th")
("Tibetan" . "bo")
("Tigrinya" . "ti")
("Tonga (Tonga Islands)" . "to")
("Tsonga" . "ts")
("Tswana" . "tn")
("Turkish" . "tr")
("Turkmen" . "tk")
("Twi" . "tw")
("Ukrainian" . "uk")
("Urdu" . "ur")
("Uighur" . "ug")
("Uzbek" . "uz")
("Catalan" . "ca")
("Venda" . "ve")
("Vietnamese" . "vi")
("Volapük" . "vo")
("Walloon" . "wa")
("Welsh" . "cy")
("Western Frisian" . "fy")
("Wolof" . "wo")
("Xhosa" . "xh")
("Yiddish" . "yi")
("Yoruba" . "yo")
("Zhuang" . "za")
("Zulu" . "zu")))
;; web UI doesn't respect these for now
(defvar mastodon-iso-639-regional
'(("es-AR" "Español (Argentina)")
("es-MX" "Español (México)")
("pt-BR" "Português (Brasil)")
("pt-PT" "Português (Portugal)")
("sr-Latn" "Srpski (latinica)")
("zh-CN" "简体中文")
("zh-HK" "繁體中文(香港)")
("zh-TW" "繁體中文(臺灣)")))
(defvar mastodon-iso-639-3
'(("ast" "Asturian" "Asturianu")
("ckb" "Sorani (Kurdish)" "سۆرانی")
("jbo" "Lojban" "la .lojban.")
("kab" "Kabyle" "Taqbaylit")
("kmr" "Kurmanji (Kurdish)" "Kurmancî")
("ldn" "Láadan" "Láadan")
("lfn" "Lingua Franca Nova" "lingua franca nova")
("tok" "Toki Pona" "toki pona")
("zba" "Balaibalan" "باليبلن")
("zgh" "Standard Moroccan Tamazight" "ⵜⴰⵎⴰⵣⵉⵖⵜ")))
(provide 'mastodon-iso)
;;; mastodon-iso.el ends here
;;; mastodon-inspect.el --- Client for Mastodon -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; Some tools to help inspect / debug mastodon.el
;;; Code:
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--get-search-json "mastodon-http")
(autoload 'mastodon-media--inline-images "mastodon-media")
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
(autoload 'mastodon-tl--toot "mastodon-tl")
(defvar mastodon-instance-url)
(defgroup mastodon-inspect nil
"Tools to help inspect toots."
:prefix "mastodon-inspect-"
:group 'external)
(defun mastodon-inspect--dump-json-in-buffer (name json)
"Buffer NAME is opened and JSON in printed into it."
(switch-to-buffer-other-window name)
(erase-buffer)
(let ((print-level nil)
(print-length nil))
(insert (pp json t)))
(goto-char (point-min))
(emacs-lisp-mode)
(message "success"))
(defun mastodon-inspect--toot ()
"Find next toot and dump its meta data into new buffer."
(interactive)
(mastodon-inspect--dump-json-in-buffer
(concat "*mastodon-inspect-toot-"
(mastodon-tl--as-string (mastodon-tl--property 'item-id))
"*")
(mastodon-tl--property 'item-json)))
(defun mastodon-inspect--download-single-toot (item-id)
"Download the toot/status represented by ITEM-ID."
(mastodon-http--get-json
(mastodon-http--api (concat "statuses/" item-id))))
(defun mastodon-inspect--view-single-toot (item-id)
"View the toot/status represented by ITEM-ID."
(interactive "s Toot ID: ")
(let ((buffer (get-buffer-create (concat "*mastodon-status-" item-id "*"))))
(with-current-buffer buffer
(let ((toot (mastodon-inspect--download-single-toot item-id )))
(mastodon-tl--toot toot)
(goto-char (point-min))
(while (search-forward "\n\n\n | " nil t)
(replace-match "\n | "))
(mastodon-media--inline-images (point-min) (point-max))))
(switch-to-buffer-other-window buffer)
(mastodon-mode)))
(defun mastodon-inspect--view-single-toot-source (item-id)
"View the ess source of a toot/status represented by ITEM-ID."
(interactive "s Toot ID: ")
(mastodon-inspect--dump-json-in-buffer
(concat "*mastodon-status-raw-" item-id "*")
(mastodon-inspect--download-single-toot item-id)))
(defvar mastodon-inspect--search-query-accounts-result)
(defvar mastodon-inspect--single-account-json)
(defvar mastodon-inspect--search-query-full-result)
(defvar mastodon-inspect--search-result-tags)
(defun mastodon-inspect--get-search-result (query)
"Inspect function for a search result for QUERY."
(interactive)
(setq mastodon-inspect--search-query-full-result
(append ; convert vector to list
(mastodon-http--get-search-json
(format "%s/api/v2/search" mastodon-instance-url)
query)
nil))
(setq mastodon-inspect--search-result-tags
(append (cdr
(caddr mastodon-inspect--search-query-full-result))
nil)))
(defun mastodon-inspect--get-search-account (query)
"Return JSON for a single account after search QUERY."
(interactive)
(setq mastodon-inspect--search-query-accounts-result
(append ; convert vector to list
(mastodon-http--get-search-json
(format "%s/api/v1/accounts/search" mastodon-instance-url)
query)
nil))
(setq mastodon-inspect--single-account-json
(car mastodon-inspect--search-query-accounts-result)))
(provide 'mastodon-inspect)
;;; mastodon-inspect.el ends here
;;; mastodon-http.el --- HTTP request/response functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-http.el provides HTTP request/response functions.
;;; Code:
(require 'json)
(require 'request) ; for attachments upload
(require 'url)
(require 'shr)
(defvar mastodon-instance-url)
(defvar mastodon-toot--media-attachment-ids)
(defvar mastodon-toot--media-attachment-filenames)
(autoload 'mastodon-auth--access-token "mastodon-auth")
(autoload 'mastodon-toot--update-status-fields "mastodon-toot")
(defvar mastodon-http--api-version "v1")
(defconst mastodon-http--timeout 15
"HTTP request timeout, in seconds. Has no effect on Emacs < 26.1.")
(defun mastodon-http--api (endpoint)
"Return Mastodon API URL for ENDPOINT."
(concat mastodon-instance-url "/api/"
mastodon-http--api-version "/" endpoint))
(defun mastodon-http--api-search ()
"Return Mastodon API url for the /search endpoint (v2)."
(format "%s/api/v2/search" mastodon-instance-url))
(defun mastodon-http--response ()
"Capture response buffer content as string."
(with-current-buffer (current-buffer)
(buffer-substring-no-properties (point-min) (point-max))))
(defun mastodon-http--response-body (pattern)
"Return substring matching PATTERN from `mastodon-http--response'."
(let ((resp (mastodon-http--response)))
(string-match pattern resp)
(match-string 0 resp)))
(defun mastodon-http--status ()
"Return HTTP Response Status Code from `mastodon-http--response'."
(let* ((status-line (mastodon-http--response-body "^HTTP/1.*$")))
(string-match "[0-9][0-9][0-9]" status-line)
(match-string 0 status-line)))
(defun mastodon-http--url-retrieve-synchronously (url &optional silent)
"Retrieve URL asynchronously.
This is a thin abstraction over the system
`url-retrieve-synchronously'. Depending on which version of this
is available we will call it with or without a timeout.
SILENT means don't message."
(if (< (cdr (func-arity 'url-retrieve-synchronously)) 4)
(url-retrieve-synchronously url)
(url-retrieve-synchronously url (or silent nil) nil mastodon-http--timeout)))
(defun mastodon-http--triage (response success)
"Determine if RESPONSE was successful.
Call SUCCESS if successful. Message status and JSON error from
RESPONSE if unsuccessful."
(let ((status (with-current-buffer response
(mastodon-http--status))))
(if (string-prefix-p "2" status)
(funcall success response)
(if (string-prefix-p "404" status)
(message "Error %s: page not found" status)
(let ((json-response (with-current-buffer response
(mastodon-http--process-json))))
(message "Error %s: %s" status (alist-get 'error json-response)))))))
(defun mastodon-http--read-file-as-string (filename)
"Read a file FILENAME as a string. Used to generate image preview."
(with-temp-buffer
(insert-file-contents filename)
(string-to-unibyte (buffer-string))))
(defmacro mastodon-http--authorized-request (method body &optional unauthenticated-p)
"Make a METHOD type request using BODY, with Mastodon authorization.
Unless UNAUTHENTICATED-P is non-nil."
(declare (debug 'body)
(indent 1))
`(let ((url-request-method ,method)
(url-request-extra-headers
(unless ,unauthenticated-p
(list (cons "Authorization"
(concat "Bearer " (mastodon-auth--access-token)))))))
,body))
(defun mastodon-http--build-params-string (params)
"Build a request parameters string from parameters alist PARAMS."
;; (url-build-query-string args nil))
;; url-build-query-string adds 'nil' for empty params so lets stick with our
;; own:
(mapconcat (lambda (p)
(when (cdr p) ; only when value
(concat (url-hexify-string (car p))
"=" (url-hexify-string (cdr p)))))
params "&"))
(defun mastodon-http--build-array-params-alist (param-str array)
"Return parameters alist using PARAM-STR and ARRAY param values.
Used for API form data parameters that take an array."
(cl-loop for x in array
collect (cons param-str x)))
(defun mastodon-http--post (url
&optional params headers unauthenticated-p json)
"POST synchronously to URL, optionally with PARAMS and HEADERS.
Authorization header is included by default unless
UNAUTHENTICATED-P is non-nil.If JSON, encode PARAMS as JSON for
the request data."
(mastodon-http--authorized-request "POST"
(let* ((url-request-data
(when params
(if json
(json-encode params)
(mastodon-http--build-params-string params))))
(url-request-extra-headers
(append url-request-extra-headers ; auth set in macro
(unless (assoc "Content-Type" headers) ; pleroma compat:
'(("Content-Type" . "application/x-www-form-urlencoded")))
headers)))
(with-temp-buffer
(mastodon-http--url-retrieve-synchronously url)))
unauthenticated-p))
(defun mastodon-http--concat-params-to-url (url params)
"Build a query string with PARAMS and concat to URL."
(if params
(concat url "?"
(mastodon-http--build-params-string params))
url))
(defun mastodon-http--get (url &optional params silent)
"Make synchronous GET request to URL.
PARAMS is an alist of any extra parameters to send with the request.
SILENT means don't message."
(mastodon-http--authorized-request "GET"
;; url-request-data doesn't seem to work with GET requests?:
(let ((url (mastodon-http--concat-params-to-url url params)))
(mastodon-http--url-retrieve-synchronously url silent))))
(defun mastodon-http--get-response (url &optional params no-headers silent vector)
"Make synchronous GET request to URL. Return JSON and response headers.
PARAMS is an alist of any extra parameters to send with the request.
SILENT means don't message.
NO-HEADERS means don't collect http response headers.
VECTOR means return json arrays as vectors."
(with-current-buffer (mastodon-http--get url params silent)
(mastodon-http--process-response no-headers vector)))
(defun mastodon-http--get-json (url &optional params silent vector)
"Return only JSON data from URL request.
PARAMS is an alist of any extra parameters to send with the request.
SILENT means don't message.
VECTOR means return json arrays as vectors."
(car (mastodon-http--get-response url params :no-headers silent vector)))
(defun mastodon-http--process-json ()
"Return only JSON data from async URL request.
Callback to `mastodon-http--get-json-async', usually
`mastodon-tl--init*', is run on the result."
(car (mastodon-http--process-response :no-headers)))
(defun mastodon-http--render-html-err (string)
"Render STRING as HTML in a temp buffer.
STRING should be a HTML for a 404 errror."
(with-temp-buffer
(insert string)
(shr-render-buffer (current-buffer))
(view-mode))) ; for 'q' to kill buffer and window
;; (error ""))) ; stop subsequent processing
(defun mastodon-http--process-response (&optional no-headers vector)
"Process http response.
Return a cons of JSON list and http response headers.
If NO-HEADERS is non-nil, just return the JSON.
VECTOR means return json arrays as vectors.
Callback to `mastodon-http--get-response-async', usually
`mastodon-tl--init*', is run on the result."
;; view raw response:
;; (switch-to-buffer (current-buffer))
(let ((headers (unless no-headers
(mastodon-http--process-headers))))
(goto-char (point-min))
(re-search-forward "^$" nil 'move)
(let ((json-array-type (if vector 'vector 'list))
(json-string (decode-coding-string
(buffer-substring-no-properties (point) (point-max))
'utf-8)))
(kill-buffer)
(cond ((or (string-empty-p json-string) (null json-string))
nil)
;; if we get html, just render it and error:
;; ideally we should handle the status code in here rather than
;; this crappy hack?
((string-prefix-p "\n<" json-string) ; html hack
(mastodon-http--render-html-err json-string))
;; if no json or html, maybe we have a plain string error message
;; (misskey does this, but there are probably better ways to do
;; this):
((not (or (string-prefix-p "\n{" json-string)
(string-prefix-p "\n[" json-string)))
(error "%s" json-string))
(t
`(,(json-read-from-string json-string) . ,headers))))))
(defun mastodon-http--process-headers ()
"Return an alist of http response headers."
(switch-to-buffer (current-buffer))
(goto-char (point-min))
(let* ((head-str (buffer-substring-no-properties
(point-min)
(re-search-forward "^$" nil 'move)))
(head-list (split-string head-str "\n")))
(mapcar (lambda (x)
(let ((list (split-string x ": ")))
(cons (car list) (cadr list))))
head-list)))
(defun mastodon-http--delete (url &optional params)
"Make DELETE request to URL.
PARAMS is an alist of any extra parameters to send with the request."
;; url-request-data only works with POST requests?
(let ((url (mastodon-http--concat-params-to-url url params)))
(mastodon-http--authorized-request "DELETE"
(with-temp-buffer
(mastodon-http--url-retrieve-synchronously url)))))
(defun mastodon-http--put (url &optional params headers)
"Make PUT request to URL.
PARAMS is an alist of any extra parameters to send with the request.
HEADERS is an alist of any extra headers to send with the request."
(mastodon-http--authorized-request "PUT"
(let ((url-request-data
(when params (mastodon-http--build-params-string params)))
(url-request-extra-headers
(append url-request-extra-headers ; auth set in macro
(unless (assoc "Content-Type" headers) ; pleroma compat:
'(("Content-Type" . "application/x-www-form-urlencoded")))
headers)))
(with-temp-buffer (mastodon-http--url-retrieve-synchronously url)))))
;; profile update functions
(defun mastodon-http--patch-json (url &optional params)
"Make synchronous PATCH request to URL. Return JSON response.
Optionally specify the PARAMS to send."
(with-current-buffer (mastodon-http--patch url params)
(mastodon-http--process-json)))
(defun mastodon-http--patch (base-url &optional params)
"Make synchronous PATCH request to BASE-URL.
Optionally specify the PARAMS to send."
(mastodon-http--authorized-request "PATCH"
(let ((url (mastodon-http--concat-params-to-url base-url params)))
(mastodon-http--url-retrieve-synchronously url))))
;; Asynchronous functions
(defun mastodon-http--get-async (url &optional params callback &rest cbargs)
"Make GET request to URL.
Pass response buffer to CALLBACK function with args CBARGS.
PARAMS is an alist of any extra parameters to send with the request."
(let ((url (mastodon-http--concat-params-to-url url params)))
(mastodon-http--authorized-request "GET"
(url-retrieve url callback cbargs))))
(defun mastodon-http--get-response-async (url &optional params callback &rest cbargs)
"Make GET request to URL. Call CALLBACK with http response and CBARGS.
PARAMS is an alist of any extra parameters to send with the request."
(mastodon-http--get-async
url
params
(lambda (status)
(when status ; for flakey servers
(apply callback (mastodon-http--process-response) cbargs)))))
(defun mastodon-http--get-json-async (url &optional params callback &rest cbargs)
"Make GET request to URL. Call CALLBACK with json-list and CBARGS.
PARAMS is an alist of any extra parameters to send with the request."
(mastodon-http--get-async
url
params
(lambda (status)
(when status ;; only when we actually get sth?
(apply callback (mastodon-http--process-json) cbargs)))))
(defun mastodon-http--post-async (url params _headers &optional callback &rest cbargs)
"POST asynchronously to URL with PARAMS and HEADERS.
Then run function CALLBACK with arguements CBARGS.
Authorization header is included by default unless UNAUTHENTICED-P is non-nil."
(mastodon-http--authorized-request "POST"
(let (;(request-timeout 5) ; this is from request.el no url.el!
(url-request-data (when params
(mastodon-http--build-params-string params))))
(with-temp-buffer
(url-retrieve url callback cbargs)))))
;; TODO: test for curl first?
(defun mastodon-http--post-media-attachment (url filename caption)
"Make POST request to upload FILENAME with CAPTION to the server's media URL.
The upload is asynchronous. On succeeding,
`mastodon-toot--media-attachment-ids' is set to the id(s) of the
item uploaded, and `mastodon-toot--update-status-fields' is run."
(let* ((file (file-name-nondirectory filename))
(request-backend 'curl)
(cb (cl-function
(lambda (&key data &allow-other-keys)
(when data
(push (alist-get 'id data)
mastodon-toot--media-attachment-ids) ; add ID to list
(message (alist-get 'id data))
(message "Uploading %s... (done)" file)
(mastodon-toot--update-status-fields))))))
(request
url
:type "POST"
:params `(("description" . ,caption))
:files `(("file" . (,file :file ,filename
:mime-type "multipart/form-data")))
:parser 'json-read
:headers `(("Authorization" . ,(concat "Bearer "
(mastodon-auth--access-token))))
:sync nil
:success (apply-partially cb)
:error (cl-function
(lambda (&key error-thrown &allow-other-keys)
(cond
;; handle curl errors first (eg 26, can't read file/path)
;; because the '=' test below fails for them
;; they have the form (error . error message 24)
((not (proper-list-p error-thrown)) ; not dotted list
(message "Got error: %s. Shit went south." (cdr error-thrown)))
;; handle mastodon api errors
;; they have the form (error http 401)
((= (car (last error-thrown)) 401)
(message "Got error: %s Unauthorized: The access token is invalid"
error-thrown))
((= (car (last error-thrown)) 422)
(message "Got error: %s Unprocessable entity: file or file\
type is unsupported or invalid"
error-thrown))
(t
(message "Got error: %s Shit went south"
error-thrown))))))))
(provide 'mastodon-http)
;;; mastodon-http.el ends here
;;; mastodon-discover.el --- Use Mastodon.el with discover.el -*- lexical-binding: t -*-
;; Copyright (C) 2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; This adds optional functionality that can be used if the dicover package
;; is present.
;;
;; See the README file for how to use this.
;;; Code:
(declare-function discover-add-context-menu "discover")
(defun mastodon-discover ()
"Plug Mastodon functionality into `discover'."
(interactive)
(when (require 'discover nil :noerror)
(discover-add-context-menu
:bind "?"
:mode 'mastodon-mode
:mode-hook 'mastodon-mode-hook
:context-menu
'(mastodon
(description "Mastodon feed viewer")
(actions
("Toots"
("A" "View profile of author" mastodon-profile--get-toot-author)
("b" "Boost" mastodon-toot--boost)
("f" "Favourite" mastodon-toot--favourite)
("c" "Toggle hidden text (CW)" mastodon-tl--toggle-spoiler-text-in-toot)
("k" "Bookmark toot" mastodon-toot--toggle-bookmark)
("v" "Vote on poll" mastodon-tl--poll-vote)
("n" "Next" mastodon-tl--goto-next-item)
("p" "Prev" mastodon-tl--goto-prev-item)
("TAB" "Next link item" mastodon-tl--next-tab-item)
("S-TAB" "Prev link item" mastodon-tl--previous-tab-item)
;; NB: (when (require 'mpv etc. calls don't work here
("C-RET" "Play media" mastodon-tl--mpv-play-video-at-point)
("t" "New toot" mastodon-toot)
("r" "Reply" mastodon-toot--reply)
("C" "Copy toot URL" mastodon-toot--copy-toot-url)
("d" "Delete (your) toot" mastodon-toot--delete-toot)
("D" "Delete and redraft (your) toot" mastodon-toot--delete-toot)
("e" "Edit (your) toot" mastodon-toot--edit-toot-at-point)
("E" "View edits of (your) toot" mastodon-toot--view-toot-edits)
("i" "Pin/Unpin (your) toot" mastodon-toot--pin-toot-toggle)
("P" "View user profile" mastodon-profile--show-user)
("a" "Translate toot at point" mastodon-toot--translate-toot-text)
("T" "View thread" mastodon-tl--thread)
("v" "Vote on poll" mastodon-tl--poll-vote)
("," "View toot's favouriters" mastodon-toot--list-toot-favouriters)
("." "View toot's boosters" mastodon-toot--list-toot-boosters)
("/" "Switch buffers" mastodon-switch-to-buffer))
("Views"
("h/?" "View mode help/keybindings" describe-mode)
("#" "Tag search" mastodon-tl--get-tag-timeline)
("\"" "List followed tags" mastodon-tl--list-followed-tags)
("'" "Followed tags timeline" mastodon-tl--followed-tags-timeline)
("F" "Federated" mastodon-tl--get-federated-timeline)
("H" "Home" mastodon-tl--get-home-timeline)
("L" "Local" mastodon-tl--get-local-timeline)
("N" "Notifications" mastodon-notifications-get)
("@" "Notifications with mentions" mastodon-notifications--get-mentions)
("g/u" "Update timeline" mastodon-tl--update)
("s" "Search" mastodon-search--query)
("O" "Jump to your profile" mastodon-profile--my-profile)
("U" "Update your profile note" mastodon-profile--update-user-profile-note)
("K" "View bookmarks" mastodon-profile--view-bookmarks)
("V" "View favourites" mastodon-profile--view-favourites)
("R" "View follow requests" mastodon-profile--view-follow-requests)
("G" "View follow suggestions" mastodon-tl--get-follow-suggestions)
("I" "View filters" mastodon-tl--view-filters)
("X" "View lists" mastodon-tl--view-lists)
("S" "View scheduled toots" mastodon-tl--view-scheduled-toots)
(";" "View instance description" mastodon-tl--view-instance-description))
("Users"
("W" "Follow" mastodon-tl--follow-user)
("C-S-W" "Unfollow" mastodon-tl--unfollow-user)
("M" "Mute" mastodon-tl--mute-user)
("C-S-M" "Unmute" mastodon-tl--unmute-user)
("B" "Block" mastodon-tl--block-user)
("C-S-B" "Unblock" mastodon-tl--unblock-user))
("Images"
;; RET errors here also :/
("<return>/i" "Load full image in browser" 'shr-browse-image)
("r" "rotate" 'image-rotate)
("+" "zoom in" 'image-increase-size)
("-" "zoom out" 'image-decrease-size)
("u" "copy URL" 'shr-maybe-probe-and-copy-url))
("Profile view"
("C-c C-c" "Cycle profile views" mastodon-profile--account-view-cycle))
("Quit"
("q" "Quit mastodon and bury buffer." kill-this-buffer)
("Q" "Quit mastodon buffer and kill window." kill-buffer-and-window)
("M-C-q" "Quit mastodon and kill all buffers." mastodon-kill-all-buffers)))))))
(provide 'mastodon-discover)
;;; mastodon-discover.el ends here
;;; mastodon-client.el --- Client functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2021 Abhiseck Paira <abhiseckpaira@disroot.org>
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-client.el supports registering the Emacs client with your Mastodon instance.
;;; Code:
(require 'plstore)
(require 'json)
(require 'url)
(defvar mastodon-instance-url)
(defvar mastodon-active-user)
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(defcustom mastodon-client--token-file (concat user-emacs-directory "mastodon.plstore")
"File path where Mastodon access tokens are stored."
:group 'mastodon
:type 'file)
(defvar mastodon-client--client-details-alist nil
"An alist of Client id and secrets keyed by the instance url.")
(defvar mastodon-client--active-user-details-plist nil
"A plist of active user details.")
(defvar mastodon-client-scopes "read write follow"
"Scopes to pass to oauth during registration.")
(defvar mastodon-client-website "https://codeberg.org/martianh/mastodon.el"
"Website of mastodon.el.")
(defvar mastodon-client-redirect-uri "urn:ietf:wg:oauth:2.0:oob"
"Redirect_uri as required by oauth.")
(defun mastodon-client--register ()
"POST client to Mastodon."
(mastodon-http--post (mastodon-http--api "apps")
`(("client_name" . "mastodon.el")
("redirect_uris" . ,mastodon-client-redirect-uri)
("scopes" . ,mastodon-client-scopes)
("website" . ,mastodon-client-website))
nil
:unauthenticated))
(defun mastodon-client--fetch ()
"Return JSON from `mastodon-client--register' call."
(with-current-buffer (mastodon-client--register)
(goto-char (point-min))
(re-search-forward "^$" nil 'move)
(let ((json-object-type 'plist)
(json-key-type 'keyword)
(json-array-type 'vector)
(json-string (buffer-substring-no-properties (point) (point-max))))
(json-read-from-string json-string))))
(defun mastodon-client--token-file ()
"Return `mastodon-client--token-file'."
mastodon-client--token-file)
(defun mastodon-client--store ()
"Store client_id and client_secret in `mastodon-client--token-file'.
Make `mastodon-client--fetch' call to determine client values."
(let ((plstore (plstore-open (mastodon-client--token-file)))
(client (mastodon-client--fetch))
;; alexgriffith reported seeing ellipses in the saved output
;; which indicate some output truncating. Nothing in `plstore-save'
;; seems to ensure this cannot happen so let's do that ourselves:
(print-length nil)
(print-level nil))
(plstore-put plstore (concat "mastodon-" mastodon-instance-url) client nil)
(plstore-save plstore)
(plstore-close plstore)
client))
(defun mastodon-client--remove-key-from-plstore (plstore)
"Remove KEY from PLSTORE."
(cdr plstore))
;; Actually it returns a plist with client-details if such details are
;; already stored in mastodon.plstore
(defun mastodon-client--read ()
"Retrieve client_id and client_secret from `mastodon-client--token-file'."
(let* ((plstore (plstore-open (mastodon-client--token-file)))
(mastodon (plstore-get plstore (concat "mastodon-" mastodon-instance-url))))
(mastodon-client--remove-key-from-plstore mastodon)))
(defun mastodon-client--general-read (key)
"Retrieve the plstore item keyed by KEY.
Return plist without the KEY."
(let* ((plstore (plstore-open (mastodon-client--token-file)))
(plstore-item (plstore-get plstore key)))
(mastodon-client--remove-key-from-plstore plstore-item)))
(defun mastodon-client--make-user-details-plist ()
"Make a plist with current user details. Return it."
`(:username ,(mastodon-client--form-user-from-vars)
:instance ,mastodon-instance-url
:client_id ,(plist-get (mastodon-client) :client_id)
:client_secret ,(plist-get (mastodon-client) :client_secret)))
(defun mastodon-client--store-access-token (token)
"Save TOKEN as :access_token in plstore of the current user.
Return the plist after the operation."
(let* ((user-details (mastodon-client--make-user-details-plist))
(plstore (plstore-open (mastodon-client--token-file)))
(username (plist-get user-details :username))
(plstore-value (setq user-details
(plist-put user-details :access_token token)))
(print-length nil)
(print-level nil))
(plstore-put plstore (concat "user-" username) plstore-value nil)
(plstore-save plstore)
(plstore-close plstore)
plstore-value))
(defun mastodon-client--make-user-active (user-details)
"USER-DETAILS is a plist consisting of user details."
(let ((plstore (plstore-open (mastodon-client--token-file)))
(print-length nil)
(print-level nil))
(plstore-put plstore "active-user" user-details nil)
(plstore-save plstore)
(plstore-close plstore)))
(defun mastodon-client--form-user-from-vars ()
"Create a username from user variable. Return that username.
Username in the form user@instance.com is formed from the
variables `mastodon-instance-url' and `mastodon-active-user'."
(concat mastodon-active-user
"@"
(url-host (url-generic-parse-url mastodon-instance-url))))
(defun mastodon-client--make-current-user-active ()
"Make the user specified by user variables active user.
Return the details (plist)."
(let ((username (mastodon-client--form-user-from-vars))
user-plist)
(when (setq user-plist
(mastodon-client--general-read (concat "user-" username)))
(mastodon-client--make-user-active user-plist))
user-plist))
(defun mastodon-client--current-user-active-p ()
"Return user-details if the current user is active.
Otherwise return nil."
(let ((username (mastodon-client--form-user-from-vars))
(user-details (mastodon-client--general-read "active-user")))
(when (and user-details
(equal (plist-get user-details :username) username))
user-details)))
(defun mastodon-client--active-user ()
"Return the details of the currently active user.
Details is a plist."
(let ((active-user-details mastodon-client--active-user-details-plist))
(unless active-user-details
(setq active-user-details
(or (mastodon-client--current-user-active-p)
(mastodon-client--make-current-user-active)))
(setq mastodon-client--active-user-details-plist
active-user-details))
active-user-details))
(defun mastodon-client ()
"Return variable client secrets to use for `mastodon-instance-url'.
Read plist from `mastodon-client--token-file' if variable is nil.
Fetch and store plist if `mastodon-client--read' returns nil."
(let ((client-details
(cdr (assoc mastodon-instance-url mastodon-client--client-details-alist))))
(unless client-details
(setq client-details
(or (mastodon-client--read)
(mastodon-client--store)))
(push (cons mastodon-instance-url client-details)
mastodon-client--client-details-alist))
client-details))
(provide 'mastodon-client)
;;; mastodon-client.el ends here
;;; mastodon-autoloads.el --- automatically extracted autoloads (do not edit) -*- lexical-binding: t -*-
;; Generated by the `loaddefs-generate' function.
;; This file is part of GNU Emacs.
;;; Code:
(add-to-list 'load-path (or (and load-file-name (directory-file-name (file-name-directory load-file-name))) (car load-path)))
;;; Generated autoloads from mastodon.el
(autoload 'mastodon "mastodon" "\
Connect Mastodon client to `mastodon-instance-url' instance." t)
(autoload 'mastodon-toot "mastodon" "\
Update instance with new toot. Content is captured in a new buffer.
If USER is non-nil, insert after @ symbol to begin new toot.
If REPLY-TO-ID is non-nil, attach new toot to a conversation.
If REPLY-JSON is the json of the toot being replied to.
(fn &optional USER REPLY-TO-ID REPLY-JSON)" t)
(autoload 'mastodon-notifications-get "mastodon" "\
Display NOTIFICATIONS in buffer.
Optionally only print notifications of type TYPE, a string.
BUFFER-NAME is added to \"*mastodon-\" to create the buffer name.
FORCE means do not try to update an existing buffer, but fetch
from the server and load anew.
(fn &optional TYPE BUFFER-NAME FORCE)" t)
(autoload 'mastodon-url-lookup "mastodon" "\
If a URL resembles a mastodon link, try to load in `mastodon.el'.
Does a WebFinger lookup.
URL can be arg QUERY-URL, or URL at point, or provided by the user.
If a status or account is found, load it in `mastodon.el', if
not, just browse the URL in the normal fashion.
(fn &optional QUERY-URL)" t)
(add-hook 'mastodon-mode-hook #'mastodon-mode-hook-fun)
(register-definition-prefixes "mastodon" '("mastodon-"))
;;; Generated autoloads from mastodon-async.el
(autoload 'mastodon-async-mode "mastodon-async" "\
Async Mastodon.
This is a minor mode. If called interactively, toggle the
`Mastodon-Async mode' mode. If the prefix argument is positive,
enable the mode, and if it is zero or negative, disable the mode.
If called from Lisp, toggle the mode if ARG is `toggle'. Enable
the mode if ARG is nil, omitted, or is a positive number.
Disable the mode if ARG is a negative number.
To check whether the minor mode is enabled in the current buffer,
evaluate `mastodon-async-mode'.
The mode's hook is called both when the mode is enabled and when
it is disabled.
(fn &optional ARG)" t)
(register-definition-prefixes "mastodon-async" '("mastodon-async--"))
;;; Generated autoloads from mastodon-auth.el
(register-definition-prefixes "mastodon-auth" '("mastodon-auth-"))
;;; Generated autoloads from mastodon-client.el
(register-definition-prefixes "mastodon-client" '("mastodon-client"))
;;; Generated autoloads from mastodon-discover.el
(register-definition-prefixes "mastodon-discover" '("mastodon-discover"))
;;; Generated autoloads from mastodon-http.el
(register-definition-prefixes "mastodon-http" '("mastodon-http--"))
;;; Generated autoloads from mastodon-inspect.el
(register-definition-prefixes "mastodon-inspect" '("mastodon-inspect--"))
;;; Generated autoloads from mastodon-iso.el
(register-definition-prefixes "mastodon-iso" '("mastodon-iso-639-"))
;;; Generated autoloads from mastodon-media.el
(register-definition-prefixes "mastodon-media" '("mastodon-media--"))
;;; Generated autoloads from mastodon-notifications.el
(register-definition-prefixes "mastodon-notifications" '("mastodon-notifications--"))
;;; Generated autoloads from mastodon-profile.el
(register-definition-prefixes "mastodon-profile" '("mastodon-profile-"))
;;; Generated autoloads from mastodon-search.el
(register-definition-prefixes "mastodon-search" '("mastodon-se"))
;;; Generated autoloads from mastodon-tl.el
(register-definition-prefixes "mastodon-tl" '("mastodon-tl-" "with-mastodon-buffer"))
;;; Generated autoloads from mastodon-toot.el
(add-hook 'mastodon-toot-mode-hook #'mastodon-profile--fetch-server-account-settings-maybe)
(register-definition-prefixes "mastodon-toot" '("mastodon-toot-"))
;;; Generated autoloads from mastodon-views.el
(register-definition-prefixes "mastodon-views" '("mastodon-views-"))
;;; End of scraped data
(provide 'mastodon-autoloads)
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; no-native-compile: t
;; coding: utf-8-emacs-unix
;; End:
;;; mastodon-autoloads.el ends here
;;; mastodon-auth.el --- Auth functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2021 Abhiseck Paira <abhiseckpaira@disroot.org>
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-auth.el supports authorizing and authenticating with Mastodon.
;;; Code:
(require 'plstore)
(require 'auth-source)
(require 'json)
(eval-when-compile (require 'subr-x)) ; for if-let
(autoload 'mastodon-client "mastodon-client")
(autoload 'mastodon-client--active-user "mastodon-client")
(autoload 'mastodon-client--form-user-from-vars "mastodon-client")
(autoload 'mastodon-client--make-user-active "mastodon-client")
(autoload 'mastodon-client--store-access-token "mastodon-client")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--concat-params-to-url "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(autoload 'mastodon-return-credential-account "mastodon")
(defvar mastodon-instance-url)
(defvar mastodon-client-scopes)
(defvar mastodon-client-redirect-uri)
(defvar mastodon-active-user)
(defgroup mastodon-auth nil
"Authenticate with Mastodon."
:prefix "mastodon-auth-"
:group 'mastodon)
(defvar mastodon-auth-source-file nil
"This variable is obsolete.
This variable currently serves no purpose and will be removed in
the future.")
(defvar mastodon-auth--token-alist nil
"Alist of User access tokens keyed by instance url.")
(defvar mastodon-auth--acct-alist nil
"Alist of account accts (name@domain) keyed by instance url.")
(defvar mastodon-auth--user-unaware
" ** MASTODON.EL - NOTICE **
It appears that you are not aware of the recent developments in
mastodon.el. In short we now require that you also set the
variable `mastodon-active-user' in your init file in addition to
`mastodon-instance-url'.
Please see its documentation to understand what value it accepts
by running M-x describe-variable on it or visiting our web page:
https://codeberg.org/martianh/mastodon.el
We apologize for the inconvenience.
")
(defun mastodon-auth--get-browser-login-url ()
"Return properly formed browser login url."
(mastodon-http--concat-params-to-url
(concat mastodon-instance-url "/oauth/authorize/")
`(("response_type" . "code")
("redirect_uri" . ,mastodon-client-redirect-uri)
("scope" . ,mastodon-client-scopes)
("client_id" . ,(plist-get (mastodon-client) :client_id)))))
(defvar mastodon-auth--explanation
(format
"
1. A URL has been copied to your clipboard. Open this URL in a
javascript capable browser and your browser will take you to your
Mastodon instance's login page.
2. Login to your account (%s) and authorize \"mastodon.el\".
3. After authorization you will be presented an authorization
code. Copy this code and paste it in the minibuffer prompt."
(mastodon-client--form-user-from-vars)))
(defun mastodon-auth--show-notice (notice buffer-name &optional ask)
"Display NOTICE to user.
NOTICE is displayed in vertical split occupying 50% of total
width. The buffer name of the buffer being displayed in the
window is BUFFER-NAME.
When optional argument ASK is given which should be a string, use
ASK as the minibuffer prompt. Return whatever user types in
response to the prompt.
When ASK is absent return nil."
(let ((buffer (get-buffer-create buffer-name))
(inhibit-read-only t)
ask-value window)
(set-buffer buffer)
(erase-buffer)
(insert notice)
(fill-region (point-min) (point-max))
(read-only-mode)
(setq window (select-window
(split-window (frame-root-window) nil 'left)
t))
(switch-to-buffer buffer t)
(when ask
(setq ask-value (read-string ask))
(kill-buffer buffer)
(delete-window window))
ask-value))
(defun mastodon-auth--request-authorization-code ()
"Ask authorization code and return it."
(let ((url (mastodon-auth--get-browser-login-url))
(select-enable-clipboard t)
authorization-code)
(kill-new url)
(message "%s" url)
(setq authorization-code
(mastodon-auth--show-notice mastodon-auth--explanation
"*mastodon-notice*"
"Authorization Code: "))
authorization-code))
(defun mastodon-auth--generate-token ()
"Generate access_token for the user. Return response buffer."
(let ((authorization-code (mastodon-auth--request-authorization-code)))
(mastodon-http--post
(concat mastodon-instance-url "/oauth/token")
`(("grant_type" . "authorization_code")
("client_secret" . ,(plist-get (mastodon-client) :client_secret))
("client_id" . ,(plist-get (mastodon-client) :client_id))
("code" . ,authorization-code)
("redirect_uri" . ,mastodon-client-redirect-uri))
nil
:unauthenticated)))
(defun mastodon-auth--get-token ()
"Make a request to generate an auth token and return JSON response."
(with-current-buffer (mastodon-auth--generate-token)
(goto-char (point-min))
(re-search-forward "^$" nil 'move)
(let ((json-object-type 'plist)
(json-key-type 'keyword)
(json-array-type 'vector)
(json-string (buffer-substring-no-properties (point) (point-max))))
(json-read-from-string json-string))))
(defun mastodon-auth--access-token ()
"Return the access token to use with `mastodon-instance-url'.
Generate/save token if none known yet."
(cond (mastodon-auth--token-alist
;; user variables are known and initialised.
(alist-get mastodon-instance-url mastodon-auth--token-alist))
((plist-get (mastodon-client--active-user) :access_token)
;; user variables need to be read from plstore.
(push (cons mastodon-instance-url
(plist-get (mastodon-client--active-user) :access_token))
mastodon-auth--token-alist)
(alist-get mastodon-instance-url mastodon-auth--token-alist))
((null mastodon-active-user)
;; user not aware of 2FA-related changes and has not set
;; `mastodon-active-user'. Make user aware and error out.
(mastodon-auth--show-notice mastodon-auth--user-unaware
"*mastodon-notice*")
(error "Variables not set properly"))
(t
;; user access-token needs to fetched from the server and
;; stored and variables initialised.
(mastodon-auth--handle-token-response (mastodon-auth--get-token)))))
(defun mastodon-auth--handle-token-response (response)
"Add token RESPONSE to `mastodon-auth--token-alist'.
The token is returned by `mastodon-auth--get-token'.
Handle any errors from the server."
(pcase response
((and (let token (plist-get response :access_token))
(guard token))
(mastodon-client--make-user-active
(mastodon-client--store-access-token token))
(cdar (push (cons mastodon-instance-url token)
mastodon-auth--token-alist)))
(`(:error ,class :error_description ,error)
(error "Mastodon-auth--access-token: %s: %s" class error))
(_ (error "Unknown response from mastodon-auth--get-token!"))))
(defun mastodon-auth--get-account-name ()
"Request user credentials and return an account name."
(alist-get 'acct
(mastodon-return-credential-account)))
(defun mastodon-auth--get-account-id ()
"Request user credentials and return an account name."
(alist-get 'id
(mastodon-return-credential-account)))
(defun mastodon-auth--user-acct ()
"Return a mastodon user acct name."
(or (cdr (assoc mastodon-instance-url mastodon-auth--acct-alist))
(let ((acct (mastodon-auth--get-account-name)))
(push (cons mastodon-instance-url acct) mastodon-auth--acct-alist)
acct)))
(provide 'mastodon-auth)
;;; mastodon-auth.el ends here
;;; mastodon-async.el --- Async streaming functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017 Alex J. Griffith
;; Author: Alex J. Griffith <griffitaj@gmail.com>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Package-Requires: ((emacs "27.1"))
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el 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.
;; mastodon.el 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 mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; Rework sync code so it does not mess up the async-buffer
;;; Code:
(require 'mastodon-tl)
(require 'json)
(require 'url-http)
(defvar url-http-end-of-headers)
(autoload 'mastodon-auth--access-token "mastodon-auth")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-notifications--timeline "mastodon-notifications")
(autoload 'mastodon-tl--timeline "mastodon-tl")
(defgroup mastodon-async nil
"An async module for mastodon streams."
:prefix "mastodon-async-"
:group 'external)
;;;###autoload
(define-minor-mode mastodon-async-mode
"Async Mastodon."
:lighter " MasA")
(defvar mastodon-instance-url)
(defvar mastodon-tl--enable-relative-timestamps)
(defvar mastodon-tl--display-media-p)
(defvar mastodon-tl--buffer-spec)
(defvar-local mastodon-async--queue "" ;;"*mastodon-async-queue*"
"The intermediate queue buffer name.")
(defvar-local mastodon-async--buffer "" ;;"*mastodon-async-buffer*"
"User facing output buffer name.")
(defvar-local mastodon-async--http-buffer "" ;;""
"Buffer variable bound to http output.")
(defun mastodon-async--display-http ()
"Display the async HTTP input buffer."
(display-buffer mastodon-async--http-buffer))
(defun mastodon-async--display-buffer ()
"Display the async user facing buffer."
(interactive)
(display-buffer mastodon-async--buffer))
(defun mastodon-async--display-queue ()
"Display the async queue buffer."
(display-buffer mastodon-async--queue))
(defun mastodon-async--stop-http ()
"Stop the http processs and close the async and http buffer."
(interactive)
(let ((inhibit-read-only t))
(stop-process (get-buffer-process mastodon-async--http-buffer))
(delete-process (get-buffer-process mastodon-async--http-buffer))
(kill-buffer mastodon-async--http-buffer)
(setq mastodon-async--http-buffer "")
(when (not (equal "" mastodon-async--queue)) ; error handle on kill async buffer
(kill-buffer mastodon-async--queue))))
(defun mastodon-async--stream-notifications ()
"Open a stream of user notifications."
(interactive)
(mastodon-async--mastodon
"user"
"home"
"notifications"
'mastodon-async--process-queue-string-notifications))
(defun mastodon-async--stream-home ()
"Open a stream of the home timeline."
(interactive)
(mastodon-async--mastodon
"user"
"home"
"home"
'mastodon-async--process-queue-string))
(defun mastodon-async--stream-federated ()
"Open a stream of Federated."
(interactive)
(mastodon-async--mastodon
"public"
"public"
"federated"
'mastodon-async--process-queue-string))
(defun mastodon-async--stream-local ()
"Open a stream of Local."
(interactive)
;; Need to add another layer of filtering for this to work
;; apparently it the local flag does not work
(mastodon-async--mastodon
"public"
"public?local=true"
"local"
'mastodon-async--process-queue-local-string))
(defun mastodon-async--mastodon (endpoint timeline name filter)
"Make sure that the previous async process has been closed.
Then start an async stream at ENDPOINT filtering toots
using FILTER.
TIMELINE is a specific target, such as federated or home.
NAME is the center portion of the buffer name for
*mastodon-async-buffer and *mastodon-async-queue."
(ignore timeline) ;; TODO: figure out what this is meant to be used for
(let ((buffer (mastodon-async--start-process
endpoint filter name)))
(with-current-buffer buffer
(mastodon-async--display-buffer)
(goto-char (point-max))
(goto-char 1))))
(defun mastodon-async--get (url callback)
"An async GET request to URL with CALLBACK."
(let ((url-request-method "GET")
(url-request-extra-headers
`(("Authorization" .
,(concat
"Bearer "
(mastodon-auth--access-token))))))
(url-retrieve url callback)))
(defun mastodon-async--set-http-buffer (buffer http-buffer)
"Initialize for BUFFER a local variable `mastodon-async--http-buffer'.
HTTP-BUFFER is the initializing value. Use this funcion if HTTP-BUFFER
is not known when `mastodon-async--setup-buffer' is called."
(with-current-buffer (get-buffer-create buffer)
(setq mastodon-async--http-buffer http-buffer)))
(defun mastodon-async--set-local-variables (buffer
http-buffer
buffer-name
queue-name)
"Set local variables for BUFFER, HTTP-BUFFER, BUFFER-NAME, and QUEUE-NAME."
(with-current-buffer (get-buffer-create buffer)
(let ((value mastodon-instance-url))
(make-local-variable 'mastodon-instance-url)
(setq-local mastodon-instance-url value))
(setq mastodon-async--http-buffer http-buffer)
(setq mastodon-async--buffer buffer-name)
(setq mastodon-async--queue queue-name)))
(defun mastodon-async--setup-http (http-buffer name)
"Add local variables to HTTP-BUFFER.
NAME is used to generate the display buffer and the queue."
(let ((queue-name (concat " *mastodon-async-queue-" name "-"
mastodon-instance-url "*"))
(buffer-name (concat "*mastodon-async-display-" name "-"
mastodon-instance-url "*")))
(mastodon-async--set-local-variables http-buffer http-buffer
buffer-name queue-name)))
(defun mastodon-async--setup-queue (http-buffer name)
"Set up HTTP-BUFFER buffer for the async queue.
NAME is used to generate the display buffer and the queue."
(let ((queue-name (concat " *mastodon-async-queue-" name "-"
mastodon-instance-url "*"))
(buffer-name(concat "*mastodon-async-display-" name "-"
mastodon-instance-url "*")))
(mastodon-async--set-local-variables queue-name http-buffer
buffer-name queue-name)
queue-name))
(defun mastodon-async--setup-buffer (http-buffer name endpoint)
"Set up the buffer timeline like `mastodon-tl--init'.
HTTP-BUFFER the name of the http-buffer, if unknown, set to...
NAME is the name of the stream for the buffer name.
ENDPOINT is the endpoint for the stream and timeline."
(let ((queue-name (concat " *mastodon-async-queue-" name "-"
mastodon-instance-url "*"))
(buffer-name (concat "*mastodon-async-display-" name "-"
mastodon-instance-url "*"))
;; if user stream, we need "timelines/home" not "timelines/user"
;; if notifs, we need "notifications" not "timelines/notifications"
(endpoint (cond
((equal name "notifications") "notifications")
((equal name "home") "timelines/home")
(t (format "timelines/%s" endpoint)))))
(mastodon-async--set-local-variables buffer-name http-buffer
buffer-name queue-name)
;; Similar to timeline init.
(with-current-buffer (get-buffer-create buffer-name)
(setq inhibit-read-only t) ; for home timeline?
(make-local-variable 'mastodon-tl--enable-relative-timestamps)
(make-local-variable 'mastodon-tl--display-media-p)
(message (mastodon-http--api endpoint))
(if (equal name "notifications")
(mastodon-notifications--timeline
(mastodon-http--get-json
(mastodon-http--api "notifications")))
(mastodon-tl--timeline (mastodon-http--get-json
(mastodon-http--api endpoint))))
(mastodon-mode)
(mastodon-tl--set-buffer-spec buffer-name
endpoint
(if (equal name "notifications")
'mastodon-notifications--timeline
'mastodon-tl--timeline))
(setq-local mastodon-tl--enable-relative-timestamps nil)
(setq-local mastodon-tl--display-media-p t)
(current-buffer))))
(defun mastodon-async--start-process (endpoint filter &optional name)
"Start an async mastodon stream at ENDPOINT.
Filter the toots using FILTER.
NAME is used for the queue and display buffer."
(let* ((stream (concat "streaming/" endpoint))
(async-queue (mastodon-async--setup-queue "" (or name stream)))
(async-buffer (mastodon-async--setup-buffer "" (or name stream) endpoint))
(http-buffer (mastodon-async--get
(mastodon-http--api stream)
(lambda (status)
(ignore status)
(message "HTTP SOURCE CLOSED")))))
(mastodon-async--setup-http http-buffer (or name stream))
(mastodon-async--set-http-buffer async-buffer http-buffer)
(mastodon-async--set-http-buffer async-queue http-buffer)
(set-process-filter (get-buffer-process http-buffer)
(mastodon-async--http-hook filter))
http-buffer))
(defun mastodon-async--http-hook (filter)
"Return a lambda with a custom FILTER for processing toots."
(let ((filter filter))
(lambda (proc data)
(with-current-buffer (process-buffer proc)
(let* ((string
(mastodon-async--stream-filter
(mastodon-async--http-layer proc data)))
(queue-string (mastodon-async--cycle-queue string)))
(when queue-string
(mastodon-async--output-toot
(funcall filter queue-string))))))))
(defun mastodon-async--process-queue-string (string)
"Parse the output STRING of the queue buffer, returning only update events."
(let ((split-strings (split-string string "\n" t)))
(when split-strings ; do nothing if we get nothing; just postpones the error
(let ((event-type (replace-regexp-in-string
"^event: " ""
(car split-strings)))
(data (replace-regexp-in-string
"^data: " "" (cadr split-strings))))
(when (equal "update" event-type)
;; in some casses the data is not fully formed
;; for now return nil if malformed using `ignore-errors'
(ignore-errors (json-read-from-string data)))))))
(defun mastodon-async--process-queue-string-notifications (string)
"Parse the output STRING of the queue buffer, returning only notification events."
;; NB notification events in streams include follow requests
(let* ((split-strings (split-string string "\n" t))
(event-type (replace-regexp-in-string
"^event: " ""
(car split-strings)))
(data (replace-regexp-in-string
"^data: " "" (cadr split-strings))))
(when (equal "notification" event-type)
;; in some casses the data is not fully formed
;; for now return nil if malformed using `ignore-errors'
(ignore-errors (json-read-from-string data)))))
(defun mastodon-async--process-queue-local-string (string)
"Use STRING to limit the public endpoint to displaying local steams only."
(let ((json (mastodon-async--process-queue-string string)))
(when json
(when (mastodon-async--account-local-p json)
json))))
(defun mastodon-async--account-local-p (json)
"Test JSON to see if account is local."
(not (string-match-p
"@"
(alist-get 'acct (alist-get 'account json)))))
(defun mastodon-async--output-toot (toot)
"Process TOOT and prepend it to the async user-facing buffer."
(if (not (bufferp (get-buffer mastodon-async--buffer)))
(mastodon-async--stop-http)
(when toot
(with-current-buffer mastodon-async--buffer
(let* ((inhibit-read-only t)
(old-max (point-max))
(previous (point))
(mastodon-tl--enable-relative-timestamps t)
(mastodon-tl--display-media-p t))
(goto-char (point-min))
(if (equal (buffer-name)
(concat "*mastodon-async-display-notifications-"
mastodon-instance-url "*"))
(mastodon-notifications--timeline (list toot))
(mastodon-tl--timeline (list toot)))
(if (equal previous 1)
(goto-char 1)
(goto-char (+ previous (- (point-max) old-max)))))))))
(defun mastodon-async--cycle-queue (string)
"Append the most recent STRING from http buffer to queue buffer.
Then determine if a full message has been recived. If so return it.
Full messages are seperated by two newlines"
(with-current-buffer mastodon-async--queue
(goto-char (max-char))
(insert (decode-coding-string string 'utf-8))
(goto-char 0)
(let ((next (re-search-forward "\n\n" nil t)))
(when next
(let ((return-string (buffer-substring 1 next))
(inhibit-read-only t))
(delete-region 1 next)
return-string)))))
(defun mastodon-async--http-layer (proc data)
"Passes PROC and DATA to ‘url-http-generic-filter’.
It then processes its output."
(with-current-buffer (process-buffer proc)
(let ((start (max 1 (- (point-max) 2))))
(url-http-generic-filter proc data)
(when (> url-http-end-of-headers start)
(setq start url-http-end-of-headers))
(let ((end (- (point-max) 2)))
(buffer-substring start end)))))
(defun mastodon-async--stream-filter (string)
"Remove comments from STRING."
(replace-regexp-in-string "^:.*\n" "" string))
(provide 'mastodon-async)
;;; mastodon-async.el ends here
This is the file .../info/dir, which contains the
topmost node of the Info hierarchy, called (dir)Top.
The first time you invoke Info you start off looking at this node.
File: dir, Node: Top This is the top of the INFO tree
This (the Directory node) gives a menu of major topics.
Typing "q" exits, "H" lists all Info commands, "d" returns here,
"h" gives a primer for first-timers,
"mEmacs<Return>" visits the Emacs manual, etc.
In Emacs, you can click mouse button 2 on a menu item or cross reference
to select it.
* Menu:
Emacs
* Mastodon: (mastodon). Client for Mastodon on ActivityPub networks.
Good signature from 066DAFCB81E42C40 GNU ELPA Signing Agent (2019) <elpasign@elpa.gnu.org> (trust undefined) created at 2023-09-15T05:05:06-0400 using RSA
;;; ement-tests.el --- Tests for Ement.el -*- lexical-binding: t; -*-
;; Copyright (C) 2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; 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:
;;
;;; Code:
(require 'ert)
(require 'map)
(require 'ement-lib)
;;;; Tests
(ert-deftest ement--format-body-mentions ()
(let ((room (make-ement-room
:members (map-into
`(("@foo:matrix.org" . ,(make-ement-user :id "@foo:matrix.org"
:displayname "foo"))
("@bar:matrix.org" . ,(make-ement-user :id "@bar:matrix.org"
:displayname "bar")))
'(hash-table :test equal)))))
(should (equal (ement--format-body-mentions "@foo: hi" room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>: hi"))
(should (equal (ement--format-body-mentions "@foo:matrix.org: hi" room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>: hi"))
(should (equal (ement--format-body-mentions "foo: hi" room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>: hi"))
(should (equal (ement--format-body-mentions "@foo and @bar:matrix.org: hi" room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a> and <a href=\"https://matrix.to/#/@bar:matrix.org\">bar</a>: hi"))
(should (equal (ement--format-body-mentions "foo: how about you and @bar ..." room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>: how about you and <a href=\"https://matrix.to/#/@bar:matrix.org\">bar</a> ..."))
(should (equal (ement--format-body-mentions "Hello, @foo:matrix.org." room)
"Hello, <a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>."))
(should (equal (ement--format-body-mentions "Hello, @foo:matrix.org, how are you?" room)
"Hello, <a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>, how are you?"))))
(provide 'ement-tests)
;;; ement-tests.el ends here
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xml:space="preserve"
id="svg4768"
viewBox="0.171 0.201 512 512"
height="48"
width="48"
version="1.0"
inkscape:version="0.48.4 r9939"
sodipodi:docname="logo2.svg"
inkscape:export-filename="/home/me/src/emacs/ement.el/images/logo-128px.png"
inkscape:export-xdpi="240"
inkscape:export-ydpi="240"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1173"
id="namedview72"
showgrid="false"
inkscape:zoom="4.9166667"
inkscape:cx="-8.3796227"
inkscape:cy="-20.646658"
inkscape:window-x="1920"
inkscape:window-y="6"
inkscape:window-maximized="1"
inkscape:current-layer="svg4768" /><metadata
id="metadata70"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><!-- Gnu Emacs Icon
Copyright (C) 2008-2017 Free Software Foundation, Inc.
Author: Nicolas Petton <nicolas@petton.fr>
This file is part of GNU Emacs.
GNU Emacs 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.
GNU Emacs 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 GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
--><!-- Created with Inkscape (http://www.inkscape.org/) --><defs
id="defs4770"><linearGradient
id="linearGradient3961"><stop
id="stop3963"
offset="0"
style="stop-color:#ffffff;stop-opacity:1;" /><stop
id="stop3965"
offset="1"
style="stop-color:#0dbd8b;stop-opacity:1;" /></linearGradient><linearGradient
id="linearGradient3874"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1;"
id="stop3876" /><stop
id="stop3882"
style="stop-color:#000000;stop-opacity:1;"
offset="1" /></linearGradient><linearGradient
id="linearGradient3866"><stop
style="stop-color:#00ff18;stop-opacity:1;"
offset="0"
id="stop3868" /><stop
style="stop-color:#000000;stop-opacity:1;"
offset="1"
id="stop3870" /></linearGradient><linearGradient
id="linearGradient3817"><stop
id="stop3819"
style="stop-color:#00ff0a;stop-opacity:1;"
offset="0" /><stop
id="stop3823"
style="stop-color:#000000;stop-opacity:0.99215686;"
offset="1" /></linearGradient><linearGradient
id="linearGradient4292"><stop
id="stop4294"
offset="0"
style="stop-color:#411f5d;stop-opacity:1" /><stop
id="stop4296"
offset="1"
style="stop-color:#5b2a85;stop-opacity:1" /></linearGradient><linearGradient
id="linearGradient4284"><stop
offset="0"
style="stop-color:#8381c5;stop-opacity:1"
id="stop4286" /><stop
id="stop4290"
style="stop-color:#7e55b3;stop-opacity:0.99607843"
offset="0.56639391" /><stop
offset="1"
style="stop-color:#a52ecb;stop-opacity:0.99215686"
id="stop4288" /></linearGradient><linearGradient
id="linearGradient4898"><stop
id="stop4278"
style="stop-color:#bab8db;stop-opacity:1"
offset="0" /><stop
id="stop4280"
style="stop-color:#5955a9;stop-opacity:0.99159664"
offset="1" /></linearGradient><linearGradient
id="linearGradient3294"><stop
offset="0"
style="stop-color:#6376e6;stop-opacity:1"
id="stop3296" /><stop
offset="0.50094414"
style="stop-color:#222989;stop-opacity:1"
id="stop3302" /><stop
offset="1"
style="stop-color:#00003d;stop-opacity:1"
id="stop3298" /></linearGradient><linearGradient
id="linearGradient3284"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3286" /><stop
offset="0.84845906"
style="stop-color:#000000;stop-opacity:0.49803922"
id="stop3292" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3288" /></linearGradient><linearGradient
id="linearGradient3274"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3276" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3278" /></linearGradient><linearGradient
id="linearGradient3262"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3264" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3266" /></linearGradient><linearGradient
id="linearGradient3242"><stop
offset="0"
style="stop-color:#282828;stop-opacity:1"
id="stop3244" /><stop
offset="0.39253417"
style="stop-color:#808080;stop-opacity:1"
id="stop3252" /><stop
offset="1"
style="stop-color:#d9d9d9;stop-opacity:1"
id="stop3246" /></linearGradient><linearGradient
id="linearGradient3202"><stop
offset="0"
style="stop-color:#2b2b2b;stop-opacity:1"
id="stop3204" /><stop
offset="0.5"
style="stop-color:#828383;stop-opacity:1"
id="stop3250" /><stop
offset="1"
style="stop-color:#dadbdb;stop-opacity:1"
id="stop3206" /></linearGradient><linearGradient
id="linearGradient4966"><stop
offset="0"
style="stop-color:#b6b3d8;stop-opacity:1"
id="stop4968" /><stop
offset="1"
style="stop-color:#b6b3d8;stop-opacity:0"
id="stop4970" /></linearGradient><linearGradient
id="linearGradient4938"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop4940" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop4942" /></linearGradient><linearGradient
id="linearGradient4282"><stop
offset="0"
style="stop-color:#bab8db;stop-opacity:1"
id="stop4900" /><stop
offset="1"
style="stop-color:#5955a9;stop-opacity:0.99159664"
id="stop4902" /></linearGradient><linearGradient
id="linearGradient4876"><stop
offset="0"
style="stop-color:#d3d2e8;stop-opacity:1"
id="stop4878" /><stop
offset="1"
style="stop-color:#5955a9;stop-opacity:0.99159664"
id="stop4880" /></linearGradient><radialGradient
gradientTransform="matrix(0.6817439,0,0,0.5905355,-3.8523706,-28.935273)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4898"
id="radialGradient4892"
fy="-108.96888"
fx="20.951529"
r="266.76535"
cy="-108.96888"
cx="20.951529" /><radialGradient
gradientTransform="matrix(1,0,0,0.1854103,0,383.88493)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4938"
id="radialGradient4944"
fy="471.26172"
fx="233.8876"
r="170.49393"
cy="471.26172"
cx="233.8876" /><radialGradient
gradientTransform="matrix(1,0,0,0.9121621,0,32.654948)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4966"
id="radialGradient4972"
fy="371.76376"
fx="299.70135"
r="76.696358"
cy="371.76376"
cx="299.70135" /><radialGradient
gradientTransform="matrix(0.414705,0.3300575,-0.5059004,0.6356454,346.95314,49.479585)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3202"
id="radialGradient3210"
fy="390.45248"
fx="289.44067"
r="17.67668"
cy="390.45248"
cx="289.44067" /><radialGradient
gradientTransform="matrix(0.414705,0.3300575,-0.5059004,0.6356454,448.41009,-65.398074)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3202"
id="radialGradient3238"
fy="382.14804"
fx="283.50717"
r="17.67668"
cy="382.14804"
cx="283.50717" /><radialGradient
gradientTransform="matrix(-6.5565014e-2,-5.9721765e-2,1.6871024,-1.8521705,171.90774,540.51473)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3242"
id="radialGradient3248"
fy="181.18982"
fx="418.45551"
r="63.068935"
cy="181.18982"
cx="418.45551" /><radialGradient
gradientTransform="matrix(0.4055116,-3.3440123e-2,0.1034174,4.3988695,177.23251,-1191.6649)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3262"
id="radialGradient3268"
fy="357.33591"
fx="354.51709"
r="33.712105"
cy="357.33591"
cx="354.51709" /><radialGradient
gradientTransform="matrix(-0.1339874,-0.1146812,0.3079048,-0.3597394,444.23592,395.03849)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3274"
id="radialGradient3280"
fy="223.55537"
fx="510.58469"
r="132.28336"
cy="223.55537"
cx="510.58469" /><radialGradient
gradientTransform="matrix(-1.2497569,1.3798305,-9.6289463e-2,-7.2974479e-2,674.3826,-70.590682)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3284"
id="radialGradient3290"
fy="-158.17821"
fx="284.4671"
r="110.2972"
cy="-158.17821"
cx="284.4671" /><radialGradient
gradientTransform="matrix(-0.1008165,-8.0872321e-2,1.0745309,-1.3395252,13.843287,784.79288)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3294"
id="radialGradient3300"
fy="356.62274"
fx="425.51019"
r="143.34167"
cy="356.62274"
cx="425.51019" /><filter
height="1.088351"
y="-0.044175496"
width="1.0892536"
x="-0.044626798"
id="filter4350"
style="color-interpolation-filters:sRGB"><feGaussianBlur
id="feGaussianBlur4352"
stdDeviation="8.7848425" /></filter><linearGradient
y2="300.73987"
x2="236.61363"
y1="-161.8512"
x1="-122.20192"
spreadMethod="pad"
gradientTransform="matrix(0.87385837,0,0,0.82818057,246.00762,250.28138)"
gradientUnits="userSpaceOnUse"
id="linearGradient4245"
xlink:href="#linearGradient3817" /><linearGradient
y2="66.018341"
x2="173.94518"
y1="396.6066"
x1="447.80933"
gradientTransform="matrix(0.98684959,0,0,0.98684959,3.0344187,2.5250397)"
gradientUnits="userSpaceOnUse"
id="linearGradient4247"
xlink:href="#linearGradient4292" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3961"
id="radialGradient3959"
cx="1136.9111"
cy="38.175797"
fx="1136.9111"
fy="38.175797"
r="233.11514"
gradientTransform="matrix(1,0,0,1.010216,-880.74005,217.63519)"
gradientUnits="userSpaceOnUse" /><filter
inkscape:collect="always"
id="filter3894"><feGaussianBlur
inkscape:collect="always"
stdDeviation="7.6798908"
id="feGaussianBlur3896" /></filter><filter
color-interpolation-filters="sRGB"
inkscape:collect="always"
id="filter3894-3"><feGaussianBlur
inkscape:collect="always"
stdDeviation="7.6798908"
id="feGaussianBlur3896-1" /></filter></defs><rect
style="fill:none;display:none"
id="rect4772"
y="0.20100001"
x="0.171"
height="512"
width="512" /><g
style="display:none"
id="g4788"><g
style="display:inline"
id="g4790" /></g><g
style="display:none"
id="g4806"><g
style="display:inline"
id="g4808"><path
style="fill:#050505;display:none"
id="path4810"
d="M 349.098,256.651 C 348.833,256.397 386.735,284.256 388.519,281.663 C 394.881,272.411 470.565,188.526 473.303,165.427 C 473.545,163.424 472.787,161.331 472.787,161.331 C 472.787,161.331 471.597,161.187 466.462,157.017 C 463.77,154.825 460.979,152.436 460.979,152.436 C 444.925,153.434 403.094,193.995 349.917,256.004" /></g></g><path
d="m 438.46612,112.92247 c 80.3259,102.29207 63.35739,249.67467 -37.9002,329.18824 C 299.30833,521.62427 152.10573,503.15872 71.779825,400.86665 -8.5460891,298.57456 8.4225064,151.19191 109.6801,71.678342 210.93769,-7.8352228 358.14021,10.630379 438.46612,112.92247 z"
id="path4235"
style="fill:#0dbd8b;fill-opacity:1;stroke:#000000;stroke-width:13.33333302;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -862.34934,-104.26336 c 0,-16.20664 13.138,-29.34464 29.34471,-29.34464 108.04422,0 195.63103,87.586807 195.63103,195.631095 0,16.206636 -13.138,29.344642 -29.34464,29.344642 -16.20664,0 -29.34464,-13.138006 -29.34464,-29.344642 0,-75.631014 -61.3108,-136.941754 -136.94175,-136.941754 -16.20671,0 -29.34471,-13.138067 -29.34471,-29.344701 z"
id="path4" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -725.40758,326.12504 c 0,16.20664 -13.13801,29.34464 -29.34464,29.34464 -108.04429,0 -195.63112,-87.58681 -195.63112,-195.63103 0,-16.20664 13.138,-29.34471 29.3447,-29.34471 16.2066,0 29.3447,13.13807 29.3447,29.34471 0,75.63095 61.3107,136.94175 136.94172,136.94175 16.20663,0 29.34464,13.138 29.34464,29.34464 z"
id="path6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -1009.0726,179.40172 c -16.2067,0 -29.3447,-13.13801 -29.3447,-29.34464 0,-108.044293 87.58676,-195.631101 195.6311,-195.631101 16.20663,0 29.34464,13.138002 29.34464,29.344639 0,16.20663681 -13.13801,29.344702 -29.34464,29.344702 -75.63104,0 -136.94174,61.310741 -136.94174,136.94176 0,16.20663 -13.1381,29.34464 -29.34466,29.34464 z"
id="path8" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -578.68426,42.459959 c 16.20664,0 29.34464,13.138002 29.34464,29.344639 0,108.044292 -87.58681,195.631102 -195.63103,195.631102 -16.20664,0 -29.34471,-13.138 -29.34471,-29.34464 0,-16.20664 13.13807,-29.34464 29.34471,-29.34464 75.63095,0 136.94175,-61.3108 136.94175,-136.941822 0,-16.206637 13.138,-29.344639 29.34464,-29.344639 z"
id="path10" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -258.96103,188.38006 c -5.92272,0 -10.72401,-4.80129 -10.72401,-10.72404 0,-39.48479 32.00863,-71.49343 71.49346,-71.49343 5.92272,0 10.72401,4.80129 10.72401,10.72401 0,5.92273 -4.80129,10.72401 -10.72401,10.72401 -27.63938,0 -50.04542,22.40606 -50.04542,50.04541 0,5.92275 -4.80131,10.72404 -10.72403,10.72404 z"
id="path4-3" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -187.33996,241.78363 c 0,5.92274 -4.80129,10.72402 -10.72401,10.72402 -39.48482,0 -71.49346,-32.00861 -71.49346,-71.49346 0,-5.92272 4.80129,-10.72401 10.72401,-10.72401 5.92273,0 10.72403,4.80129 10.72403,10.72401 0,27.63939 22.40604,50.04541 50.04542,50.04541 5.92272,0 10.72401,4.80133 10.72401,10.72402 z"
id="path8-7" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -258.74453,313.27152 c -5.92272,0 -10.72401,-4.80129 -10.72401,-10.72401 0,-39.48483 32.00863,-71.49347 71.49344,-71.49347 5.92272,0 10.72403,4.80129 10.72403,10.72401 0,5.92273 -4.80131,10.72402 -10.72403,10.72402 -27.63937,0 -50.04542,22.40605 -50.04542,50.04544 0,5.92272 -4.80129,10.72401 -10.72401,10.72401 z"
id="path10-5" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -187.12346,367.23573 c 0,5.92272 -4.80128,10.724 -10.72401,10.724 -39.48482,0 -71.49346,-32.00863 -71.49346,-71.49343 0,-5.92272 4.80129,-10.72403 10.72403,-10.72403 5.92271,0 10.72404,4.80131 10.72404,10.72403 0,27.63936 22.40601,50.04542 50.04539,50.04542 5.92273,0 10.72401,4.80128 10.72401,10.72401 z"
id="path6-6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 629.26349,134.49819 c -5.17912,5.17913 -13.57608,5.17911 -18.75524,-3e-5 -34.52746,-34.527469 -34.52746,-90.507338 4e-5,-125.0348349 5.17912,-5.1791201 13.57609,-5.1791215 18.75521,2.1e-6 5.17912,5.1791198 5.1791,13.5760858 -10e-6,18.7551968 -24.16926,24.16926 -24.16922,63.355176 0,87.524396 5.17914,5.17914 5.17912,13.57614 0,18.75527 z"
id="path4-3-3" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.41281462;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 740.87146,587.17776 c -10.37726,0 -18.78964,-8.41245 -18.78964,-18.78967 0,-69.18171 56.08261,-125.26432 125.26429,-125.26432 10.37724,0 18.78967,8.41239 18.78967,18.78963 0,10.37726 -8.41243,18.78964 -18.78967,18.78964 -48.42717,0 -87.68502,39.25785 -87.68502,87.68505 0,10.37722 -8.41238,18.78967 -18.78963,18.78967 z"
id="path10-5-6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 630.7519,243.88245 c -5.17914,5.17911 -13.5761,5.17911 -18.75523,0 -34.52748,-34.52751 -34.5275,-90.50737 0,-125.03487 5.17912,-5.1791 13.57607,-5.17908 18.75518,3e-5 5.17917,5.17907 5.17918,13.57606 5e-5,18.75519 -24.16926,24.16926 -24.16924,63.35515 2e-5,87.5244 5.17912,5.17912 5.17907,13.57615 -2e-5,18.75518 z"
id="path8-7-5" /><g
id="g3921"
transform="matrix(1.2623093,0,0,1.2623093,-22.620675,-167.67864)"><path
id="path4-9"
d="m -567.88395,525.90207 c 0,-8.09524 6.56246,-14.6577 14.65773,-14.6577 53.96826,0 97.71801,43.74975 97.71801,97.71804 0,8.09524 -6.56246,14.65769 -14.65769,14.65769 -8.09525,0 -14.6577,-6.56245 -14.6577,-14.65769 0,-37.7778 -30.62484,-68.40262 -68.40262,-68.40262 -8.09527,0 -14.65773,-6.56248 -14.65773,-14.65772 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path6-1"
d="m -499.48133,740.88175 c 0,8.09524 -6.56245,14.65768 -14.65769,14.65768 -53.96829,0 -97.71805,-43.74974 -97.71805,-97.718 0,-8.09524 6.56246,-14.65772 14.65772,-14.65772 8.09523,0 14.65773,6.56248 14.65773,14.65772 0,37.77778 30.62479,68.40262 68.4026,68.40262 8.09524,0 14.65769,6.56245 14.65769,14.6577 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path8-2"
d="m -641.17244,667.59321 c -8.09527,0 -14.65773,-6.56245 -14.65773,-14.65769 0,-53.96829 43.74973,-97.71804 97.71804,-97.71804 8.09524,0 14.6577,6.56246 14.6577,14.6577 0,8.09524 -6.56246,14.65772 -14.6577,14.65772 -37.77782,0 -68.40261,30.62481 -68.40261,68.40262 0,8.09524 -6.5625,14.65769 -14.6577,14.65769 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path10-7"
d="m -426.19279,599.19059 c 8.09524,0 14.65769,6.56246 14.65769,14.65769 0,53.9683 -43.74975,97.71805 -97.71801,97.71805 -8.09524,0 -14.65772,-6.56246 -14.65772,-14.65769 0,-8.09525 6.56248,-14.6577 14.65772,-14.6577 37.77778,0 68.40262,-30.62484 68.40262,-68.40266 0,-8.09523 6.56245,-14.65769 14.6577,-14.65769 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /></g><path
d="m 132.03775,284.66555 c 0,0 8.69652,10.02068 21.79702,20.99902 5.30536,4.44597 25.63519,21.13925 42.51032,31.94399 0,0 20.48551,13.26291 32.83983,18.96361 12.92683,5.96487 21.0213,8.15057 27.52118,6.27789 0.75681,-0.95865 4.61173,-3.1871 2.08279,-9.37015 -6.46553,-15.80783 -20.22633,-27.87882 -45.24183,-53.97806 -27.3683,-29.31592 -32.05255,-43.48826 -33.2541,-52.33151 -0.94007,-8.69226 12.20528,-16.994 42.2872,-5.80372 15.42502,5.36496 65.45708,36.83202 65.45708,36.83202 -6.94024,-20.50721 -20.6055,-58.02276 -23.29074,-65.85827 -2.35514,-6.87215 -6.3377,-17.65607 -3.82376,-23.37085 2.13538,-5.76446 11.63777,-3.06713 16.01947,-0.63418 14.13893,7.80833 31.59768,21.33241 45.91754,35.21052 7.19793,6.97589 8.80422,7.67024 8.80422,7.67024 11.40065,8.15653 24.5282,7.90359 32.44151,-5.37384 8.40733,-13.22288 2.06338,-32.27651 -12.20417,-42.14412 -13.43576,-9.29229 -48.78214,-28.72642 -48.78214,-28.72642 34.14941,33.56336 39.25098,39.7951 38.24337,45.86431 -0.59509,3.58428 -5.6341,4.27584 -16.13816,-3.27885 -11.43561,-8.22465 -33.80314,-26.72533 -33.80314,-26.72533 -20.767,-18.43865 -34.65312,-32.16894 -47.56281,-27.5421 -8.43403,3.02279 -7.90747,13.678 -8.62701,19.29852 -1.16084,21.87146 7.58578,43.04002 12.63994,57.24178 1.90166,5.3435 9.28085,19.22246 9.28085,19.22246 -29.51684,-32.95272 -61.63954,-45.89324 -85.89923,-48.0673 -28.77557,-1.12189 -38.64387,21.97216 -18.16598,62.74587 12.09509,24.0826 18.24712,35.87235 47.65908,60.42484 16.74943,15.03619 18.74503,18.05128 17.59938,19.51384 -1.61292,2.05912 -24.04912,-11.25556 -30.33696,-14.72806 -15.99635,-8.83409 -55.77276,-34.14832 -55.97075,-34.27615 z"
id="path4237-0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;filter:url(#filter3894)"
inkscape:connector-curvature="0"
transform="matrix(-1.3538609,0,0,-1.3538609,693.74978,1158.395)" /><path
d="m 132.03775,284.66555 c 0,0 8.69652,10.02068 21.79702,20.99902 5.30536,4.44597 25.63519,21.13925 42.51032,31.94399 0,0 20.48551,13.26291 32.83983,18.96361 12.92683,5.96487 21.0213,8.15057 27.52118,6.27789 0.75681,-0.95865 4.61173,-3.1871 2.08279,-9.37015 -6.46553,-15.80783 -20.22633,-27.87882 -45.24183,-53.97806 -27.3683,-29.31592 -32.05255,-43.48826 -33.2541,-52.33151 -0.94007,-8.69226 12.20528,-16.994 42.2872,-5.80372 15.42502,5.36496 65.45708,36.83202 65.45708,36.83202 -6.94024,-20.50721 -20.6055,-58.02276 -23.29074,-65.85827 -2.35514,-6.87215 -6.3377,-17.65607 -3.82376,-23.37085 2.13538,-5.76446 11.63777,-3.06713 16.01947,-0.63418 14.13893,7.80833 31.59768,21.33241 45.91754,35.21052 7.19793,6.97589 8.80422,7.67024 8.80422,7.67024 11.40065,8.15653 24.5282,7.90359 32.44151,-5.37384 8.40733,-13.22288 2.06338,-32.27651 -12.20417,-42.14412 -13.43576,-9.29229 -48.78214,-28.72642 -48.78214,-28.72642 34.14941,33.56336 39.25098,39.7951 38.24337,45.86431 -0.59509,3.58428 -5.6341,4.27584 -16.13816,-3.27885 -11.43561,-8.22465 -33.80314,-26.72533 -33.80314,-26.72533 -20.767,-18.43865 -34.65312,-32.16894 -47.56281,-27.5421 -8.43403,3.02279 -7.90747,13.678 -8.62701,19.29852 -1.16084,21.87146 7.58578,43.04002 12.63994,57.24178 1.90166,5.3435 9.28085,19.22246 9.28085,19.22246 -29.51684,-32.95272 -61.63954,-45.89324 -85.89923,-48.0673 -28.77557,-1.12189 -38.64387,21.97216 -18.16598,62.74587 12.09509,24.0826 18.24712,35.87235 47.65908,60.42484 16.74943,15.03619 18.74503,18.05128 17.59938,19.51384 -1.61292,2.05912 -24.04912,-11.25556 -30.33696,-14.72806 -15.99635,-8.83409 -55.77276,-34.14832 -55.97075,-34.27615 z"
id="path4237-0-2"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;filter:url(#filter3894-3)"
inkscape:connector-curvature="0"
transform="matrix(1.3538609,0,0,1.3538609,-52.89981,465.82444)" /><path
d="m -269.87161,757.91183 c 0,0 13.23506,0.93627 30.26137,-0.56431 6.89523,-0.60768 33.07453,-3.17912 52.64715,-7.47152 0,0 23.86373,-5.10715 36.63057,-9.81197 13.35843,-4.92285 20.62761,-9.10098 23.89954,-15.02127 -0.14269,-1.21302 1.00739,-5.51461 -5.15294,-8.09846 -15.74964,-6.60595 -34.01548,-5.41108 -70.15906,-6.1774 -40.0818,-1.37718 -53.41542,-8.08627 -60.51817,-13.48977 -6.81108,-5.48163 -3.38613,-20.64701 25.79772,-34.00541 14.70074,-7.11354 72.32932,-20.24098 72.32932,-20.24098 -19.40827,-9.59329 -55.59858,-26.458 -63.03787,-30.09979 -6.52468,-3.19401 -16.96616,-8.00329 -19.2295,-13.82188 -2.56614,-5.58603 6.06036,-10.39793 10.87905,-11.77591 15.51905,-4.47641 37.42722,-7.25864 57.3662,-7.571 10.0224,-0.15701 11.64921,-0.80185 11.64921,-0.80185 13.82901,-2.29394 22.93273,-11.75538 19.13973,-26.73949 -3.40511,-15.29487 -21.36391,-24.28197 -38.43005,-21.17074 -16.07116,2.92988 -54.80683,14.18154 -54.80683,14.18154 47.88016,-0.4144 55.89402,0.38474 59.47311,5.38881 2.11368,2.95526 -0.96045,7.00739 -13.7299,9.09291 -13.90189,2.27049 -42.80009,5.00476 -42.80009,5.00476 -27.72258,1.6464 -47.25033,1.75659 -53.10719,14.15679 -3.82632,8.10119 4.08038,15.26323 7.5459,19.74633 14.64462,16.28629 35.79785,25.06993 49.41383,31.53826 5.12311,2.43375 20.15489,7.02978 20.15489,7.02978 -44.17265,-2.42953 -76.03716,11.13432 -94.72864,26.75121 -21.14069,19.55411 -11.78868,42.86201 31.52274,57.21332 25.58149,8.47645 38.26825,12.46292 76.42687,9.02676 22.47583,-1.21144 26.01893,-0.49052 26.24301,1.35373 0.31548,2.59652 -24.96418,9.04641 -31.86578,11.03716 -17.55777,5.06447 -63.5838,15.29078 -63.81419,15.34039 z"
id="path4237"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 741.14237,231.14899 c 5.17911,5.17911 5.17911,13.5761 0,18.75521 -34.5275,34.52749 -90.50738,34.5275 -125.03484,5e-5 -5.17911,-5.17912 -5.17915,-13.57613 -10e-6,-18.75527 5.17911,-5.17911 13.57615,-5.1791 18.75525,1e-5 24.16922,24.16921 63.35516,24.16927 87.5244,2e-5 5.17913,-5.17912 13.57606,-5.17915 18.7552,-2e-5 z"
id="path6-6-2" /><text
xml:space="preserve"
style="font-size:237.56724548px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;font-family:Hack;-inkscape-font-specification:Hack"
x="1116.8435"
y="417.50568"
id="text3927"
sodipodi:linespacing="125%"
transform="scale(0.97330289,1.0274294)"><tspan
sodipodi:role="line"
id="tspan3929"
x="1116.8435"
y="417.50568"
style="font-size:237.56724548px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;font-family:Hack;-inkscape-font-specification:Hack">l</tspan></text>
<text
xml:space="preserve"
style="font-size:138.19949341px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;font-family:Hack;-inkscape-font-specification:Hack"
x="-359.01129"
y="525.40228"
id="text3931"
sodipodi:linespacing="125%"><tspan
sodipodi:role="line"
id="tspan3933"
x="-359.01129"
y="525.40228">.</tspan></text>
<path
d="m 434.66331,265.76624 c 0,0 -5.87624,-16.97489 -16.80346,-37.37302 -4.42522,-8.26075 -21.55552,-39.48393 -37.31315,-61.56659 0,0 -19.05507,-26.99135 -31.70512,-40.38178 -13.23615,-14.01104 -22.30529,-20.83761 -31.41651,-21.76094 -1.43417,0.82308 -7.40128,1.68002 -7.34029,10.72393 0.15581,23.12199 11.36194,45.22591 29.63861,90.62981 19.61161,50.63174 18.35361,70.8008 15.40574,82.51822 -3.2003,11.39589 -23.90241,15.20102 -56.06034,-14.02326 -16.67759,-14.51651 -63.68239,-79.27534 -63.68239,-79.27534 -1.61653,29.26616 -3.35632,83.29359 -3.93191,94.49265 -0.50478,9.82218 -0.93646,25.37992 -6.97594,31.29347 -5.58886,6.1668 -16.16918,-2.01234 -20.4485,-7.27809 -13.82996,-16.93838 -28.95018,-42.73217 -39.94787,-67.38856 -5.52802,-12.39366 -7.19635,-14.07581 -7.19635,-14.07581 -10.21371,-15.99561 -26.83628,-22.29501 -43.47232,-9.60048 -17.229294,12.37696 -18.862353,39.5161 -5.90882,59.10686 12.19836,18.44856 46.81573,60.68509 46.81573,60.68509 -25.9912,-59.38689 -29.26021,-69.78878 -24.93481,-76.90697 2.55449,-4.20376 9.23472,-2.5327 18.62521,12.25475 10.2233,16.09882 29.00297,50.6202 29.00297,50.6202 16.79982,33.63665 27.327,57.88869 45.8806,58.58235 12.12126,0.45314 16.83063,-13.20078 20.56787,-19.9004 12.4834,-26.89688 12.16354,-57.90459 12.97156,-78.29709 0.30404,-7.67281 -1.9721,-28.83169 -1.9721,-28.83169 20.47806,56.28447 54.31805,88.73667 83.70504,103.69706 35.59166,15.91462 59.63247,-8.12927 54.4544,-69.68472 -3.05843,-36.35714 -4.84572,-54.27229 -29.42634,-99.9488 -13.46673,-27.33625 -14.45442,-32.13071 -12.27766,-33.39096 3.06463,-1.7743 24.54469,26.26536 30.69511,33.79814 15.64679,19.16356 52.86658,71.02164 53.05093,71.28206 z"
id="path4237-0-1"
style="fill:#351d52;fill-opacity:0.45098039;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.88823676;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
d="m 76.374471,245.63739 c 0,0 5.87624,16.97489 16.803461,37.37302 4.425216,8.26075 21.555518,39.48394 37.313168,61.56662 0,0 19.05505,26.99133 31.70509,40.38174 13.23617,14.01101 22.30526,20.83764 31.41647,21.76092 1.43424,-0.82309 7.40129,-1.68002 7.34026,-10.72394 -0.15576,-23.12194 -11.36187,-45.22583 -29.63856,-90.62974 -19.61159,-50.63172 -18.35358,-70.8008 -15.40573,-82.51823 3.2003,-11.39589 23.90241,-15.20102 56.06034,14.02327 16.67758,14.51651 63.68232,79.27531 63.68232,79.27531 1.61655,-29.26616 3.35639,-83.29357 3.93193,-94.49265 0.50479,-9.82216 0.9365,-25.37989 6.97597,-31.29345 5.58885,-6.16681 16.16917,2.01233 20.44848,7.27808 13.82999,16.9384 28.95021,42.73217 39.94789,67.38858 5.52802,12.39365 7.19636,14.0758 7.19636,14.0758 10.21372,15.99561 26.83623,22.29498 43.47234,9.60047 17.22922,-12.37696 18.86233,-39.5161 5.90877,-59.10686 -12.19832,-18.44853 -46.81572,-60.68508 -46.81572,-60.68508 25.99117,59.38686 29.26015,69.78874 24.93481,76.90696 -2.55451,4.20376 -9.23472,2.53271 -18.62518,-12.25473 -10.22337,-16.09885 -29.00301,-50.62021 -29.00301,-50.62021 -16.7998,-33.63664 -27.32697,-57.88867 -45.88056,-58.58233 -12.12127,-0.45315 -16.83064,13.20079 -20.56788,19.90042 -12.48341,26.89685 -12.16354,57.90456 -12.97159,78.29705 -0.30402,7.67282 1.97212,28.8317 1.97212,28.8317 -20.47807,-56.28447 -54.31805,-88.73667 -83.70503,-103.69705 -35.59167,-15.91461 -59.63247,8.12927 -54.45439,69.68472 3.05842,36.35714 4.84571,54.2723 29.42632,99.94879 13.46673,27.33625 14.45444,32.1307 12.27766,33.39096 -3.06461,1.7743 -24.54468,-26.26535 -30.69511,-33.79814 C 113.7787,297.75584 76.558885,245.89777 76.374542,245.63733 z"
id="path4237-0-2-0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.88823676;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><image
y="-123.97097"
x="1859.6594"
id="image4081"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAABHNCSVQICAgIfAhkiAAAIABJREFU eJzdvXmQXNd5H/o7d+99m559xzIASIIAQYgixU0LFTt0qFgynKfYjpO8OHlJlWzHieOXvFTJSdlV SSpxUlHFjhK9smP52Y4p+UVSJEsWaYqLxBUECBDLAIPBYGYwW/f0vtz1nPzRfbtvd9/u6Z7pAYf5 qmb63rOf833f73xnvQT/exBp8+x8b3bvRKzpt9ndze9DSb00ykEiN4aTxudz1ecLBBgnwEL1fcgR d7X6PO5g5iYDDlffVxlwmgGLDDjvFAo3QfhQCsSHSQCaNdnB8HNcndF5Lh4PcNHoOtnaMrhQKEjS 6RSh1MsBQCBASS5HXesdDHIsn+cYIQVGCMc4TqCEcIznx2kicY0BIq0IR5w6hMIpEB86YfgwCEAz wwlwhgBZDlA5wODm5sLc1pbBWZbOU6rwjFmcxxPlFMXkKC1wPnHMH+WHYmExFIPpCXEQgoSjnGUJ hOdNRihhJkXZ5PR8mWTTql7cXszNJznOsHw+nqqqQMtlzSJEoBxXojwvWSl+nCKRp0CAVgTieYoP oTAcVAFo1nauzvQQH48HONNc5m2GK0pIkKSMMMU9MBIUp096Zfm4xEnHeCZOgOPHCUOkMXnWxBVW YxOz/xOmU8rWGOhdk+q3VGpcU63tKwvW+SuUclo6nTNDIdEqFlVreNhjzs9nKHDcchGGAy0IB00A XLT9HAe8ziN+mI+Yy7xpSgKlmiDLvDgZnBkYku77mJ8LPCZB/CjH8bOdk2eO/w63ZuY3+TtdCLGK JjPfUa3y6xkz9drl1Fvv64JX59SsyfO62UYYqKMAB0oQDooANDH+DFfR9rwQjQZ401QFyxJFWebF +4JnD8Wl6Z+SRM8nBcbPMYDrLgsXrW/56cx8pz8DAMYAwtKmZbyetzLfuG689kKyJJXDUt6QJK+5 VAybSCxYwKPWQRWEgyAAzr6dAwI8cI2PRHyCxyOIuh4QR/lYbCL04HN+3vc5gUinAdax3LwsQQn7 IQU9EP0eiF4ZgkcGLwngZRGcKII4U2AAtSxYmlH902GUNOjFMvRCCVq2CC1XAGMOMWGtwkIJUgYt fTtPt//kWvGV97JZqvO8YYyM+Iz5+aJ5EAXhgxQAB+PBAU/zQIqfnubEZLIghsM+aVY6/eCwNPl/ CZz8aQLI7RIS/R74hiLwDUbgiYcgeKTuS8EcD269NaugAKMU5XQepa00CpsplFNZUMuqRWhGCkro zbKW+4Nr+qt/lNeMvKZl9TaC8IHaCB+EADjhngMO80BeiER8gmFwUjjsk07Ij5wNi6NfEDnp427a TjgO/tEY/KMx+IYiELxtZaMzseq/dtM7Vea3+jMwi6K0nUVxaxvplQ0YJdUlAYARa7Ng5r6yWHzj qymjnK0LwohRtREstBqM94zutQA4NL5i3EWjiqgonMRxHvmY7/GPRfn4F0SIj7tF9kSDCM4MITgx CF4Wd18KN613ujdAfbN/axwGhlIyjfTyGvJ3E7BMs9GmYAAF2y6bxf/3pvnu760lV1KCYOlZJaxj s2QCCxY+IDS4VwLQpPVPC/E4BE1bkEwpIB+TT04dChz9osQpP9YckRM4hA+NITw7DCno23tJuoT8 BnenJHQQGACgpoXc2hYSC3eg5vJoTsxidCurp3/zQu6FP9V1aKJI9XQ6YgCzZlO3cE+E4F4IgHNI xwOLQiSSFg2Dk+KC6D0x9OzfCAuRXwNDA3c5gUd4dgTRY5O99emdaA+Q7x6nnRBV/hUSKWxeW0Ap nW0eP8Bi1ltrxuI/v5xffF/QN7WcfFhHAibwAxP3UAj2WwAckH+YR3xcDGoLkiQNyWciDz0Wk8Z/ gzDhmDMCLwmIHB1H5Mg4eEnoTyl2A/ldxmkM63B3/BS2trF5YxGlVLqxXITpRav45cuZt/9jppzI VtBA14EhEzjfPFrYF9pPAbCZzwNP83Nz6+Ldu5YiiiHPE/FP/mJQCP5DBtIwhg9ODGLozGHwcp80 HuiKYW21viF+Y5zW9FvjMOc/BuQ2E1h7/zqMstowbqDMurVZvvMPLm5fuiyKRTWb1XTgSeNeGIj8 fiSKmtaf4QFeiEQKcjpteY4NnBo/G37qKx7e+9fgGIlLAS9GHzuO2PFJcEKfirSjBlf+9QfyGwWC tYkj+32ITo6DMYZyJluLQgiJBsXwZ4e8w4mckLzBmAg9xAElAUCqu/rukvotAFXGg6v092kxFCor uhDzPBb7xCcmPUf+gOf4GuRzPMHAA7MY/egxSAFv/0rRJcP2C/I7IQjhOARiUQQHB1DO5mBqWjVL Ikmc8pcGhfHhtJl6g5Y3rJkZnm1vPwBgad+6gH4KQJOx94oYDJaVYDDqfTz8qS9EhcF/Swj8dmDJ 78H4kw8iOBkHIX3sifoO+Z0RBM0/XSKIKMuIjI+CGgaK2VzNnyfCAyPK+KeIILycLOWLsrzNyuWT bL+EoF8C0MT8H0iBQFQJBGK+R/w//kUfH/hFkPqcfWBsAONPnoTkV/qUPQ4k5Dfm3yqQhBAE4gPw BoPIJ5NglFbduXhIjD7LQf7RRn4l7ffnoKo+ALm+C0G/BIBDjfkJKRCgcswXDjwSfObfKrz35+xA hBDEH5zF4Okj4IQu13C6oS4YxprDuml9g39/IL9tWo44ss+H8OgwSuksDE0DAwMBCQTF0LMRJfjO tpnakmXG9kMI+iEA9swej3hCGg2vKR5uJPJI7OO/I3Oe5+xAnMBj4okHEJoeRj8Rv7MGo3+QDyAQ EBEb8CI+5IPPL4EXOGiqVWFwTwjSmj/Pi4iMjkIt5qEVi3ZIr8IFP+MXotdT5ubyfgjBXllRZ/7Q K2KgFFFCynDkYwNPf1Ug4kftQLwsYeLJk1Ci/g5J9UhdQu5uIV8SeYxOBDAxFcT4ZBBjk0F4vK3z EppqYvl2FtcvJ3H53U2YBm3IvzF9h7tL/gwMjDKsvH8F6fW7dW8CPaOl/u6V4hvfM81yOZ2e1Po1 YbQXAagzH6+IgUBEGeCJ/yPD574scPJfsgOJPgUTT56EFLy3Vn6vkC9JHKZmQpg5FMHUbAhDwz4Q rrfmKRUNvPrCHbz1ympt6bhj/g7GO8vEGMPGzRvYWlqqRmNggJqy7v7su5tvvCbLfDmVGtf7IQS7 FQDHJM8ZMRjcVARB8X588PP/WuGUv24HkgNeTHz8QQieXa7WuVGfIJ/nCMYng5g5FMb0oTDGJgLg emR4O1pdyuL537uCfFbbscwNzK/+cuAYGMP67dtYX7heLxQh2xv67Z+6Xb5xNRzWyvPzlu5YSKLY Be2mxo7p3XFxenpANk3mO+v58V/1ioFfsgMJXhlTnzgN0dcnS78PkB+JKpg5FMbMoQgOHY1Alvdr HgxIJcr4/f90AbmM2lT++jNz6SZs5ttuqwvXydbS7VogE3R1SZ3/yaX0jTsez3g5kYjr1RlD52pi 17QbAahO9DwtRCLLsqbJ3k9NPPdzAT70r1Bdu+clAZOfOA051IfVO6B7yG9qZFGsaPnsoQiOHIsi PtjHbqgL2ljN4yv//h1Qs3nk4C7EHCOs2Y2B4c6VyyS1vuqQdXplvvTq59byhWQ+n1aBcR0475w2 7pp6FYBavz83d0nOZGTP/f6zDw3Js//D3rHDCRzGn3oQ3oFQj0m3oU6QX/VzYz7hgPiAF7xAGuO0 ESLFI4LnAUniIYk8OIFAUQT4/BLCUQVjkwEEgr13Zd//xgJe/4vlriC/bf0YxcLF8ySXStY8dEv7 2mX9tV/O59OlbJaqwKqBXdgDvQiAvW+PB1alQCCiDEcHB894nvk2z3EzlRAEYx+7D4GxgR6SbUNd aH3NqcW/czfRLq0GM8HFbpiaCePZc0cx0AOS5LMa/sMXfwRKqbvWN0F+o3/d3aAWbp1/kxTzmVqQ rJ77x28Vf/iHsqWVHCMDCz0IQC+dYBX6eTEUEuVg0O95OPBjvyU6hnuxE9OIHB7tIck21AXDujH0 XJnc4t+B+U3pZ9Mqbl5L4pEnJ7quiqwIuLOQRiap1h07QH5DIRxuPDj4o3GkN+8Si1b2Ikqc+HiA k1/cKG4lQ6EgLZXOUuBqT11At9NxVe0/zEejiihJAflB6ZlzMuTP2AE88RAG7p/qJe9WqoEXa8+w nTZtuKKBg7sNwlV3Zw1O7REkuItuYGI63JA/B465Mt9ZCJf8FVnG5LEHa4UmIN4Baew/jSvDEU1b kIBFAbVZ2e7QvRsEcEB/UoxEPMqU9PDcuH/8dwEiAgAvi5h8+kHw4h42cHQB+aw5rJvWN/jvDfKd cbxeAR99agLPnpsD3+M0dj6j4fp7CQDdQ36jf71NFK8XpqGTUq6ynExAYn5pwLdlZl6R5TRV1QcZ sNT1iKAbjlUNvwA/PT0i5nIFZXrw8K8zRrxAZVV/+Ozc3sb6fYf8dhrcGscN8v1BCeGIgnDUg/HJ AKYOhTE44tv1qiXhnIzvwHy3Mjvc7eYZmz3Oipk0KRUqW81kIv78Id+hr1/ZfvfteBxWInGOAs+7 qVEL7SQAtdO3c3OXxNVVXX588DM/JnLiJ+wAodnR3Rt9OzKM1V930WBuWk8I4PNLCIUVhCJKldFy 9bfiJvRzoQpAuWh2bYM0+rvXnycEU8dPsWvvvELAGBghfEwY/BeiSD+n64tm0+TQngWAA17n19ch DkXGglFP7J/XCiKJiD8ws0MSbahPkC8rArweAbIiQJZ5KAoPRam+Kzz8fgnBkAx/QEIgKMEXkPo2 49ct3bmZcXASjvLvjGCsxb/y4vX4MDg+g82VRQAMPOEffiD4xLnz5Vf+YG7OY87Pn7OqKEDQQQg6 CYDD8IPIcaJ8yvuJX+TATdkBBk/O7m5/fkcNRgvkK4qAyakgBoe9GBjwIhrzVJjpE3vuj+815dIq Ft5Pdt9NVd1bUa8VQUYmjrB0Yo3oahkAEJQi//ekOvW91bWNdWDRRoG9CkCIN4yieDR2YlIR/L9g eyqxAEKzwx2iu1APkM9zBPefjOPM2RGMjvl7Xpg5CMQYw3efv8lM3V4hrP3r2E210/rmNhN4DmPT J9jt6+crQwpGB8a8c19IWPlfF8WskUqdM3dCgXYC4ND+rMCpIWlKOf73aufzCMHwQ3PoaWG/B8gf GfHjc//HMUQifdwxdI+JWgzf/uN5Nn+xYv33aui1vLRBkNjAEJLhGHKZyiyhLMg/MyQHf3s5V1wF Fu3VwrYC0A4/HdoviOODQ0Me3vPTtmdwIt7b2n6t8jswnwEzs2H8rV84+aFm/p2FNL7yb95mF3+4 Vqlnh7G93Sa1YHC42+Fa0KCxHcenjzPbXieM+Ualmb9tWUSKRrNC5QhexcutrG4I4LT8+ZUVTjqk nPo7cJzciR7rciasS8vcrpcgcnjus0cOfL/uJGoxlEsGttYKWFnMsesXt7C5Uqh47tHQ63bk4/MF EAzHkMskwBggEO/PjwYHvrxVuq0Br/PoMCJo1wXULP+x+GxY4b0/Y0f1j0ShRALtW6SpcL2M7Q8d iuxqpq0fpGsWsmkVhZyOfFar/OU0FHMGDN2CYVgoFw2YJoVpUJSLBgyNgpmOCnbJsL1Afqt/5WF4 7BDLphMEADhCQlPK4Z/NWqn/ODdnGtURgWtX0CwA9WtZ4gneKt0Rj8uP/RwYCdsBoiem0JF2LHDl X6uVC4TC+898Q7ewtpJHcquExEYRyY0iEpsl5Dut27sg2E4reO3Saj+30UHrW9JvigMgFIzBHwij kM8AYJB47//Jcfx/3djIa0DCQKW7t9BEbbqABDftywi6yEle0XPOztQTD3Ve5u3B0HOT8HSq3D7t XVK5ZGD5dhbLi1ksL2awvpIHpc3lc2/8tuv2e5zO3Svkt2vf4bEjbOH6WxUUABk8Kp765IXij74B XONRYX63CJDndL0kHPc9+SDPhMO2Z+RQh5W+XUB+c+UXFzLI53QEgns/G7i1XsTLf76EG1e2YVlW G4FszB8Nr22YzwjbC8P6BflON6vqHooMQJY90LQyGABFln8yEuG/I4o+IZ0+bAILLd2A09pyGH8F PpMJiGEy+rlaQIGD323Klzkr71Yh1pn5rO5uGRTf/dZCax490g9fWsZ//ffnce1SApbZjvmsJf+2 kM92v4LXDyu/I/OdCTOCyMAIqpfaQCLyp0Q2FLMsna/cqdg6Emg2twmQ4DY2ysJg0JJlUfnLtkdg PN56cLMdJDkK7JSPun/7Brv+fhLvvrXeXM6u6dI7G3jxfy7Csmh7DWsD3wyssk+vSSBaIL8TfLso hHv9W/NvJ5ANylX1t1BlflP+sYGJeizGpElh9lmPJypUruJpuJepWrc6VT2u8ZYlCUcCTz9NGDdo e4amm2b9nJVso2FOgW5piQ4N9t1v3cLqnRx2QzeubsOtwXbK3w3ya1q/K4ZVn1o0uFP9m9OHa/va kO+Wv6L44fUFaw4eQfmMKBIxElnmgcMt4+umSYJz3NxcmKNKSAhxvpr2C14Z3sHGTQ2dC7ybTRv1 R8ug+O+/fwVbG0X0SlOzobYC2S7/dpDfGUE6M8yp6PX84ZJ/U1otAtOYfiPkN+dfafvYwHgtQcL4 jyg0NFjpBsbt09s1akKAC0TTPHxYyggCLz9iewRGByrTvm0Y5iywUz7q/js3WHOFSgUdX/0v7/Us BGceHcOhuahL/s1lqjDeFfLZDpDf7OZofLtK3eTfWSEa828H+W4KGYkM15wZY9ywOPYopQpfHQ0A ji7A6cABXsGyOPlo+MxsTBj+FTuH2IlJyAFfQyaNBUZnrW8ucK2SbunUHQyd4uqlBCamgwiFu5sa JhzB/aeHoOsWVpdybcvcbmxP3OrnVo8Gf9YSzOnefZs44jjcXRnfIS2eF7G9vUos0wDAwDE+s8zu vmhMyAa2P0GBq7VDJLYAVDd8TguRCC8fVR56TublZxiqO35OH62c5u2p8tV/HeK0rbzjx9ApLl/Y QjAkY3isu/UHwhEcOhbF+HQIGyt5lApGY7pdHMhoqUdD+R2erDVYZ4Fpp0TuCtFW65vjNLmXSjlS KlW2jfE8fFm28FUha+mqumkBqRpuOCzDc/zc3JqYKHqU44G5v8cR8TgAyGE/YnPjbaW1N62HO/Nd 4jDHP2YxzF9JopDXMXM4DJ7vbq0gOuDBmY+NIRCUsblWhKoarg3Wy+5cV+Z3Vb8OabUZ27ecL2yX lkv+zKJIp9dJxZtESgx/lCtvZjXNtIBcbV3AFgAO0HlKLTGgaJ5Z5aFfJ4QLAEBoYhD+4ahLhfp7 pVpDkDYNtr6Sx9X3EhibDCLY5bQxIQSjk0GcfWIMgZCCxEYRaskE8CGGfNf2bcxfFCRsbN4mVS4R kXFX1ktbV3S9bALF2h23DgQ4yUtSQh73Hx0ZlGZ+1U4pdmwKsvP+nrabFjoUro+7c8tFAxff2kB6 u4yp2TBEqbujDRxXEYRHnhrH6FQQ6YTKChnn4U1nXjtDbmv9e2WYu0LsCvLdujReQCp1l5imbgdZ u1u6/ZIRPmygtFQ7RuZAgJgQizFpRnjoIa8Y+mk7s8H7Z8BLYi2jtlrvWmD3wu0E+d002OZaAZfe 3oCsCBga7X7HECEEsUEvTj82SoYn/CSb1pBL73yKt4H5XdWvQ1r7APmt+TPkC0lSVitL0xxopqis /Q9Byxrl8ict2xDkUTtIQAWvNyyPK8c/qfDKp8AqN1oNPngYpDp93Jb5eygwaxenoULuCKJrJm5c SeLS2xsQJR7DY/6ut24TAgwM+XD6sVEyeyxCSiUD25vFjvm31r+D1jfEd4njcO8H5LvlXyrlSL6Q QoV3BJvGrf9mGKzBEKwjQPys4GUFedZ77LMikc4AlZu8oofHGvjTtpIN/u4M2w3kd4MgarkiCNff S8AfkhEb9PW0Wy0UVXD/w0PkyP0DRC2b2F4vtTR+A/N3xbBWhbBq1WtX/94UojF/Al3XkM5WDEFw XGCbpX5HVQuqpk2Z9uGRqgCc4+cmbgmMUWVKeeBvEQizAIN3IITgxFAPEt6uwK1xGpjfE4K0z79Q 0HHl/CauvZeA4hEQH+7tMEcgLOPEmSFy4uFBYhgWEncL9sSmI/92AtmhLh1W8Nzr15RWTwpRMel4 BlBqYWt72T4ezZmm9Y20WtzS9Q3THglUBeA+jtJVyTCgHA6c/ocEiAKVGUDfYLQpw14Z1lhg1i5O Q4W6QxDns3Nip5jTce1iAu+/tcV4AWRw1A+O714QvH4Jcw/GyclHRwilwNZKobqHoDPkftCQb39a ga++ckTA2tZCbQqXUu41VVqbz+ejJrBuoSoAHKDzw8MBkRBNnlJO/hoBkQAgNDkETyS4A+S5V9Kt wKzx3y4lvDH/Tps21JKBhfe38fZLq9jeKkGUeRKOebruHhSviCMPDJCHnhojsiJg406ufgmUs1wH BPJ5AFzVnzACnuOxnrhFKKscTbeo9W7S3D5fLK4b9lCwKgAnecbWRI8n5p+STvwjVOeKw9Mj9Sng ngrcWEmGJvfdQP4OWu9kfPPY3jIpNlcKuPzmBi6/uQ61bMLjE4mvy40nksxj6miEnH5yjIiygM3l fFUQDhbkAxXGk9oOYWAjeYeYllEtA714J3vzh0Y0qqO4SQGw2o4gxigZwHSkkmIlNU4U2ldypwK3 aP1eKtmaf7vpXJ4niAx64fWK8PhEeP0ivD4Jsqc6TqyGvfl+EhsreRy+LwZvoDtB8PolPPXcLPno M5N4+y+W8eafL7NiTt+xfruD/B0UAgBYRevtV8LqjK+6QBQkqHoRDAw8WMTr9RIvjZIEzgA4DwEA ARYIY34Skj1+OzUGVI5774FhDZDvWklHWm0r2RjHqfXBiIKJ6RAGR3yIj/gwOOon0QHPvp8ikj0C Hn92Fo88M0XefXkVr//ZHZbbduxn3I3WO9276lIdWo/6Si2p+tlCIPACajsxCednTCGx2DpJJCrr Kg17Ai2TyBDredYs6J6ksoPWd1XJ9nEkhcf04TBmj8UwOxfBwFCfLqHaJYkSj0eemcLDH58gL/3p Anv9O0tgtFLYe2Xo1bS+FqTOfMIAQuozpYRjLQc5GwRAEgWxni8D4bguGLa/kE8IwczRME6eHcHx U/Gup373m9KJEhavbGN1IcuWrqaQTdYRoF/TufVnVmNuJ8h3RYLaNzkYGCMtfV2DAFBmh2aNofZp Orelko6fcNSDR54axwMPD8Hr7+MXRHZBlDKs38nhznwaKzczbPl6GuWCXg+wG8jvSSGatL4D5BNH eMLsnR8VT4603gjTIADEMA04MYDRNhVqLPDeIL8x/YEhLx7/1DTuf3jonp/jrxeJYf1OHotXk1ie z7CVGxloZdMZwPFc+TkokA97DFB1o4zWkqQUDqmtUIMAlJlVC8AYwExrxwKzxn+7hvxASMYznzmM +x4a7O8HJLqkfEbDrStJ3L6aZovvJ1HMtWp4Ow0+GJDv7scYrWdJTFcBYMBhRsgCI0QvVcpWiUIt 2pZhjU67h3yOEHzk6Qk8/eMzkPbx6tZmMnQLd26kcftamt26nMTWaqG1zPViumu90/2AQH7lt/5u WjZyMTBwZUJUlkqNMCAPwIEAhHDsLjYzE+xErWyWZm+l2h/IHxzx4bN/4z4MjvbxGvkOpJZM3Li0 hfmLSbbwXhKG7kS43hDsQEG+I61mJDAtrZY2Y1a6VCqxoj/FgEsAHAIQiURZNrtaZB6mERAZACzd 2DfIf/CREfzlnzq671Y9pQy3rmzjwqtr7OZ7W7AsN+Z0j2CWM44z7L5DfmPf3gL5jk8sOwXDMDVH ZjTj9wdYcbOec7ULWGXZLGWEcBTANoBRgMEoN56Y7QfkE0LwE39tDqcf7cONoh3IMinefe0uXv/u MsskS61l3QXDDgTkV9/rz+4zgIQBlFnQqb3ZhcEwjWSxWGJAqBayigCnGSEXmNfrYybMOyKEUQZA K5b6CvmEEHzmrx/HybM93i3UA1HK8N7ra3j1W0sss13uAnI7lNnhfs8hv4mZdbdOkN/oV9IqG1xs m85k6i1CBAps1mpdFYBFFo/zbGVFp9Rj3Ga88CgA6IVSF1rfTSUrP48/M7WvzF9byuGbv3uVba0V dgfTbSG/nQZ3SKtbyN8Vo9tDvvO9qOedayYsLWwuElJgwGEGrNa6AADnMT9/kvp8eWpSc1GqdkRa odiZ+T1M545OBPH0j+/yTsEdyLIYXvuzJfbqtxbv7bp9D2l1o/WVZ2C3kN8cv6Tm6+UgLFEyU7lo dIDmcqsNXQCr/ImMEJ6WoN3yVr/vaBkmTF2DIEq7qCQa/D/92cP7skijlk38ye9cYkvXUh3z70Xr gQ9ubN8ZCXpDiZKerS3mWLBuc5yf5nIFCpxmwAIDGs4GbjKOU60sSzQc0NfyJZcKORqmE/Or/oOj PkzOhtFvKmQ1/P6/O19lvqMwTfl3ZJgL5Ft2Wi31a0qrwb+NQjjTsiGfVQy91v6bNGl543s75hPm 3kUU9VytMJZlLamqTuNxngGLtRLWVwqg0Gh0gKZS76+A0GLFkaGUyjQ1mJuGtW8wBoaj9/fhAxJN ZFkMz3/5Mtu4k99BILtnWDcnbztCfluFaN20URGC7plZ92sNb78TVsmLMAKLmsir9Q9PU7DrPC9a 8/MZCpyvld4hAKdZLlegZoAalmWdt9eQi8l02wZDhwZznrwdHOn/RM/L31xkKzcyOzAMLgxjrgzr 9uRto387hXDmX4F8Vyu/hgROP9LE6B5Ropp1upwErU4DMwAlI/t6qaRbgGjvaWNARQCq0Z9nKX6c plK6VYL2uh2klMqAWo4ZM1cJb2yw5n16/V7UKeZ1vPn9O23zPxiQ79D6GqNbmVlHglat3xHyWRuU AJAqbhBHFVKL5cXrHFeilRFAvXYOBABDIk9DIdEq0tSP7Iak1IKayfUE+c0NVvt+Xp/o2vnNyjSu q0B2z7D9hXx0Dfmoam5byG+j9fY6gJtgbZc3awUzif5mIEANnpcsIN6qUNPLAAAUkElEQVRwYaTz mC0DArRYVK3lwnuXwFjOrlJhO10L0Q3kO/05cGx1MesMvWfKZ3XmzjC4MIy1MKyXyxYa/Vnb+u8V 8sFsrXeBfNT9635o8kMtLZOZyJW3a4U0TfONVEq3Uqm81fwhiSYBiFOelyxLLOsGMd62Q+YTyXol XRpsp/t15t9LolRoWYncNcWGvPV9DjsyrNGt0/06tYcuEWTXkN+Gmfa7K+SjM+Q7USJZ2AB1fEg0 z3KvcZxsVvt/Z60augAAz7NUKm/pumyWdPUl26uUyUIrlbqG/Ob7dSyD4uVvL/UNBU48PISJI+F6 hl0y7MBAfoOh1ywM7Q29TpDvRIm7+VsOo4suJ9mNm7GYYgFKy33BTQgABoi0XE6Z68bFbzLQWued ubveELLXK9XOv3wXi9e20Q8SBA4/+ysPkUeemQLnunG1Mf8PDvK7Gdu7z+W3Qwk3yK+FB4Fhatgq rtYKqlv6Nw1DMWS5bFUmgBoFwLkWW23JQc7r5XhLYtaoMHWaI8IhANBVFbHJCRCQVq3HTpctVB5u XtrG0QdixNflPvxOxPMcDj8wQI49PETUkont9WJtR64z3w/qQIYrhMNd6+0Vf1eUQJvwtjCgUbCW 84vYLK4Q20jKYfufbRXSa2trfh14wURTF9C8GE+AFFFViXg8Pj4sDBKF9z7LwGCZJgKxGETZcTNH O61v8K+7mwbF1Xe2MH0sQgJ9uhjaF5Rw/OEhcvqpcRKISMTUKXLbFeDalwMZVeY0H8Oq/PbOzAYI RyMz7fdWwXIgQZNgXdl+i5TNYrXI9L35/KUvRSJeLZPJ60CqpQtw241BgLNEENa5vFVeH1Omfh6E KBUPhmA83tA4vd6vY+gU77+5AX9YJsMTXVw73yXJioCJwxGcenKMfOTTk2RyLkxiwz5IMg9mMpRL 5u603uluM99mTruJmmq07piJ2nuvKFEzDKtlKxl5XN0+XzOQNaZ/+W5+5c1EYlgDiAmst3xi3u22 cArE6fDwurmyohfViPodBZ7PAwyZtQ0Mzh6CKMt7ui7d0Cx88/eussWrKXz63BHiD/X3mniPT8TR U4M4emqwZgzpqoXkegG5lIrctopS3mC57TJKBQO6akIrGCgXDOhlE9Ri0FUThulAy11s1ao/t/bn 7VCi5ttund9FUOzu4EbmErG7Z0KovmXc+QbPG0blM3KrDRyql6KVqlfGnZECgaLnvtipB8blmRcZ YxwDEJ+awtjRub5dqaZ4BDz5V2bIw09PQBAP7pdCGAPUUmWPJGGVjSf2VnGtZILRimBbBoWuWihl dZSyOgopnWVWS9heKyK/paIzo+vvNc12EyyX7qRsFvH95a8TRisobzHz+bdL87+k0GwxlTqtAs+3 9P/V1FqIAOCAw0IkQhWfL+B/SPnkVwRO+jEA4HgOxx5/gkmitKt+lTX411+CYQUfe3aanPrY6IE5 /dNvKqQ0rF5J4/a72+zma1vQSpYrM5s1u+7WHgkuJd8ki9lrABgICE2oW88k+VuX1tYCJeC8jvpn YxqoXUsTYIaoqkAoLXF+yb8WECKfBxhhjIHjORIIO6+O6wz5zcGc7rabpppYeC+Jd3+wArVsIRL3 EMW7i28SHmCSPAIGpvw48tFBcuavTpHwiELWrmZhqqwLQ8/FlqgKj27pOJ94lTBWWbMxYXxnjSz+ XqmkqqqqG27Gn00dVG0dwHEyOkq5jJXcHhanz9ofjdTyeUTGx8ETHi3M3wHyOwsMg6FRLN9I480X lrF0PQ1qURKOe/63QwWOJxg6FMT4/RFy5c/X20I+aQP5zuHg9fS7JFneAFBp9ZSW+uWN3MZKLufT AK9t/PUkANX8YsjIa4TmCRf2RDZ8XPCnAcCiFqhpkNBAfNeQ30lg7NdssowbF7bwxneXsHwjDbVs kEBYgezZw1fKDxgFBhRce3EDWt5oC/n1cX8rSpSMHM4nXiGsMjsDC8ZL62TxP1uWVlbVYQN43b4T 0JV2UKt1oHiW+HwZUuQzW8P81GlCuBkAKBVyCETjkOx5gR4hvyWQm41qp8WA9GYZC+8l8cZ37+Dm xQQKeZ14fCJ8H9BXxvpFxbSOd/90BZZKd4B8wA0l3t76ASkYOTAABMzKYPsXC1ZiOZHI64DiOvRz UicBqArjEtSQRPyEJwKTLgX5yOdBiAgwlAs5MjBsf0Nwd5Df7WSMfb8OA0MureH2lRTefmEF7764 go07eRSyKmEUUD4E3xO2yTIovvHrl1l6uTJx0y3k236rhSXczF4itsqZVP9v6+z6H5ZKpqqqig5c qd0I2q4MO+3UqI0IolHIHBfynY08+k8V4vmCHWB87j4WHxnrC+S3i9PLdC4PguiQF6EhD4JhGcGY B6LMEUnmwfEcZA9fG76ZOoWpUxiqxcoFA2rBQClX+dWLJkyDwihb0MsmmMlqzcVzBLExH577RyfJ 4PTuJrOym2V8+zeusM0blS+j9DoCoJTi+3e/RopmAQADYyy5TK99IpEtruXzvjJw3qg23Z4EAHDM CwSDm8qoND0wF/nI9znCTzIG8IKAuYcfY7Ki9A3yne77eSBjt7tzx46G8dCzE+S+p0Z6PslsaBYu fmsVb//RHaYVzJbhnLMsNSRwEYYLyR+RxfzVWroFWvzHS+r8/xeNmsX5+ZMa8Lz99fC2zAc6fz3c JlZJaNYcGSkYK6t3s3FP5osxJfa7AINlGli6epHMnfoIA+H2R+v3mFb92Y7T3f06FbdK04/MBDH3 +BA58fgIwkMe9EpqwcCVP1/DO19bZaXqWkWniZ0WJHAIylpxBbfz1xxVs84vFC4+T6mpzc9Ts/qV 0I6aX6tfl+WvosDTwvR0RjFN5jslP/ElgVN+0g4wNDmD0ekjDguwVrq6w73S+pa86loPdDed6/GK mDk9gJlTMXLo9AACsd19zHrtWhbvf3eNXX9pE5ZWt8c6M7qxLDXmM6BsFfH9u18nBtXtUKU023ru ZmrhUsGXL2HzSaPdrJ8bdSsApPrHA2fEQKDoiUYHB09JZ79FQA6xaqkP3f8wC4Vj9Vg9GnrAB3cg gyMchqeDmD0zgNlTMTI+F+npdtFalpRh9f0Mbr2ZZDdf20J+s3H6t5PWd4L8CjspXtr4nySlbdXy s6HfsrRSKgUNWLA/Gb8j84HuugA4EqPArDk6eknPZDLbSax+ISaN/f8ERAYD7sxfInOnHmOyJNej 7QbyexCYvZy8HRj1YebBAUzdFyXT90fh2eU+hexmGXcuprByOc3uvJ1COWd0NObqjG7u/zv5ARdT bzUw32Dmn6yzW3+s63ktm6UGsLqj0ddMvYp4rSuIRJZljZe9j4ae+Dt+LvAv7TwVrw9HTj7CREHs mmH36uTtwIgfkyeimDwRITMPxBCI7g7WM+slrF7NYO16ji2/m0JmveTK6G5W8HaCfDvNW7nruJB6 zcEv69bdwvxPrOvFRD6fVoFxHTi/7wJgdwUcMC6GQpwSCES8Jz1P/GcR0k9Uyw5fMIwj951lvPOa uXsM+QLHYWgygKn7ohifi5Cp41H4d7EJpZw3sLmYw9ZiHus3cmz1cgaldLX/bepO3Ji5F8i3BWS1 uIQ3ki/WoIswFLfZxmdvpW9fGBvj1V6s/mbqvZOrCcEZHliVAoGIEg77wqfkJ75KCPeonX8wEsfs 8YcYD3JPIF+WOYwdCWNyLobxIyEycSwKxdv9lLFaNJBeLyG9XkJyuci2buexdSuPfFLripndjd97 6Q4q4ZPaBl7d/A6xmH04h5kFs/h3l4wbf1bp98d14Ad2v99x1s+NdiMAdjwOOMfH4wlJFNeUGDc8 MqWc+hpH+KM2Y2LDY5ieva/eXH2E/HBMweSxKMYOh8jEXAQj06GORptaMCqbQRIqcikVpbTOMptl pFaLSK+XUMrqOzBz90u1zvjdQj5hBBk9iR9sfrtm8QNgBVr81dvlO39oWelSOj2pOZjfE/TbtFsB AGqfmjkszM3xUiYje8aEqdkxz+zXCbhRoLI4ER0YxuzhkwyE2/P9OgJHMDgRwNGHhxCIyA1SRS0G rWTCKFusXDSgFS2UchoKCQ3ZpApTq39FvJfLFnpl5m5RohkJEtoGfrj1PSfzUbbK/3oVN7+Uz6dL 2SxVgVUTXcz2daK9CIDDHnhaiEZXJU2zPMfCD5wY8c5+jTDUvjQRCEUxe+whxtn31vYB8rux8t32 3aENMzvNAHbPzL2hhB3+bmkZbyZfJBarX05pUOMPl9i1f1Jhflzrdqp3J+rTIvsSK5dPskCgwDaL G5mgEvyhlwQ+DcJ8AKBrZRQySRKODYNzXF7cbyvfObHTjvm72Z1bN9TahG+DEu2sfLcNnXbqtws3 8db2S4TafT4AE8ZX1/I3/5+0mijlcroOnDKrX/3aE/OBvgkA4BSCtdzqtkf0vOQRAp/kQEIAoBsq suktEgwNQODt8XZnyG9wqz3bcdpvze7ETPu91925XTOzqSwNVn5TfKdgMcrwfvYdcjn9JnEcfGRl U/ut68Xzv1mwssVcztCAJ43dWvxu1OdtNnUhKKCQMQzjeyE58jhA4gBgmjq2E3eJrHjhqX+aYBeQ /0EdyCAukO+CEg1pN6FEU3wCQLNU/Cj5fXKneLNWdQJCNVr+4gpu/E65nC/vB/OBvgsAYAvB+HiG rmxvFyhT/ywsDXyEEG4UABizkNneILqhkWAwBuI8nugqEE6/D/pARiNk1/1cUKKp/3eDfABIltfx 8ta3Sdao3+ZBCNGyNPdL67jyx/l8vlyB/f4zH9gXAQCAJba9HWEzMzzLGXk1icK3ohiI8IQ7abdB qZhDLrNF/MEoRN6x+bOtDXBwD2Q447dCvjtKUEZxNXsB76RfISY1alWmBMs5mvmbK+qtv5Bluby5 6deBoAG8YI/z+8b8Ss36T47RwWEeGBeDwQUpGIx5JskDz4Xl6L8ijNmfIgMhHOKD4xgdPcZ4zjlK 2NnQgwsz0YGZdvHarfO3hu/vdK6d9nZ5A++kXyU5I9PQcBboiyvlhV8pkfKGrhe0dFrX+zHU60T7 vNU2xYCzTNMMGo0yulG+u8Bbxvd8QvgMIWQIqMwWFIsZbG/fJZLsgUf2oztDz0YCONxIk9buAiV6 MPR6hXyDariYep1cSL9ONKrWWokAlk7Lv7VovvPPLE5Lh0KWuro6rgOX9jTJ0w3tBwI0p80BZzhg U4hGFVHXIUcioeBx8aF/KnDKz4PRhvnaSHgQoyPHmEfx7/m6dNfwNkq0tQ3Q4/i9fXdgp0WZiVu5 67iWe5fotPG6HEas61k9+2vz+sbbopFQs0pYx2bJrBzn2l/mV0u/79TQJcTj46KmLUimNCTPeUdO DApjv8kT4ZFKUHsARBAODmBseI4FPeF6Qdsws+7XKzP3dzqXWhZuFeYxn3uPqFaxoVEYWFk19d9e K1z77ZRVzosi1SuQ/6hVNfYY9pn5lRrcGyL1v3M8sChEImnRMDhJFKnn/tATPxPg/P+EEVbhdtUG ICAIBQcxPniEBX2RJiRo7Q7aocS9ns41LAO3CtdxM/teA9TbZDHre1vq5r9YppklQd/UZPmwnkjA 3Ou8/m7oXgmAM68qGjwtIA4hpC+KkhSQQ2JsZFqe+2WB8D9NQGoL9TYmeJUg4pFJNhgegyJU9uTt z3RuY/xuUQKUYUNdw0rhJrlbugOL1S17R13ez9HSv9tit75PaVlTVaqnUqpR1XqnlX9PmA/cWwFw 5ulAg9f5uTmfsLZGJFn2SQNibHREGv37Hk75PCXwNkZlIIQg5B/EUHiKDQSGIJDKENK9/99L374z SjAAOS2N5eICWSksQLXKVc418o8S60JJL3/pLi69YFk+zePh9KXyuoHNQHNf3xp5n+mDEABnvlU0 OMcBi0I0mhUMQxBl2SeFpcHhSWnwF0Te83OEoeGLpqg+ERD4PRFEvHGE/UMs6omBI3zH4eBeIb9o 5LFVXkeivEaS5XVotP69wOYSWsx6q0xLX7pLL79sWT5NVTNGVpo1UIH7ZiPvnjLepg9KAJz5O/4O 80CItwVBUXgxKnpCQ+LcTyiC97Mc4z/KwOrbjGo/lQeO8Ah6IvDLIQTkKPPKAXgFHxTB0zBk7GYF z2QmVL2AnJFHQc8gp22TtJ5AyXRcoW9HcRaGsE2dGd8q0NTz22zrSiZT1AcG/MbSUtgEYFYuany+ eVLnA2E+8MELgE1NglBBhLm5Ar+xURYUJSyKIhHj/MBkiBv/nMCkv8oTcgioM79OrYzhCAeJVyAK CmROAk8qI0+Bk0CZCcoYGLOgWxo0qsOwytAtrSFN1pBoYx4MtMyY8UJB17++jdsvb5m8FpbyRrls Gum03wSi1kFjvE0HRQBschGEBIc4hIi5zJumJFCqCbLMi1PS8UMeIfiIIooPC0R8CgyDbt1EIzXe auMe0iVOg1flAgYG67oG40eapr+aMu++YXiyhXTaMnheN4eHPeb8vN8CshYQsm/nPlCMt+mgCYBN thAA9Z1HHBDi4/EAZ1aFweuVeEnShHyeE+dCR++T4PuYyIn38+COMsIfIszxsWTWjBVtILwhRMWN AglQOm8yY94w1bdydPVHm0Ymw/OyWS5rFs/rJs9L1atYjzu1vbl/PzCMt+mgCoBNTmPRgQoXCKBy QJSLRku8Zek8pV7O65V4yxJ5SrO8zxeS4+bAKA/vpCx6xsFYnIcQ4TiEwEjl/npC/ABUMNNkBAYF y4LStAlkDKhrFrFWMjR7R6V3MzwvWKmUbhHCU45TLZ6XrMFBkVY0PUCrTG+n6QeO8TYddAGwibj8 2gJBgAQHLBAMGdxcOMxtbRkcpSZHqZcLBCjJ5ShhzOK8Xi8pFinxer3umRCVlUol5vcHWLFYYoQI lONKNBKJslyuQFP8OEUiTysMX2WVmzdbzuEdeKY76cMiAE4iLs9NAnGBAOMEWCDAEAFWCTAExKNk Lrrets6p1AhLJFIM2AQwziqfV7O/sHWaVT61ct7J4A8l0530YRSAZnITCPvZxe9M9XW2qe6LDDhv vzgZ6cbcds8fOvpfMzvdLlvhNv4AAAAASUVORK5CYII= "
height="1365.3334"
width="1365.3334" /></svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xml:space="preserve"
id="svg4768"
viewBox="0.171 0.201 512 512"
height="48"
width="48"
version="1.0"
inkscape:version="0.48.4 r9939"
sodipodi:docname="logo.svg"
inkscape:export-filename="/home/me/src/emacs/ement.el/images/logo64.png"
inkscape:export-xdpi="120"
inkscape:export-ydpi="120"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1173"
id="namedview72"
showgrid="false"
inkscape:zoom="6.9532167"
inkscape:cx="63.22113"
inkscape:cy="9.8428958"
inkscape:window-x="1920"
inkscape:window-y="6"
inkscape:window-maximized="1"
inkscape:current-layer="svg4768" /><metadata
id="metadata70"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><!-- Gnu Emacs Icon
Copyright (C) 2008-2017 Free Software Foundation, Inc.
Author: Nicolas Petton <nicolas@petton.fr>
This file is part of GNU Emacs.
GNU Emacs 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.
GNU Emacs 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 GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
--><!-- Created with Inkscape (http://www.inkscape.org/) --><defs
id="defs4770"><linearGradient
id="linearGradient3961"><stop
id="stop3963"
offset="0"
style="stop-color:#ffffff;stop-opacity:1;" /><stop
id="stop3965"
offset="1"
style="stop-color:#0dbd8b;stop-opacity:1;" /></linearGradient><linearGradient
id="linearGradient3874"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1;"
id="stop3876" /><stop
id="stop3882"
style="stop-color:#000000;stop-opacity:1;"
offset="1" /></linearGradient><linearGradient
id="linearGradient3866"><stop
style="stop-color:#00ff18;stop-opacity:1;"
offset="0"
id="stop3868" /><stop
style="stop-color:#000000;stop-opacity:1;"
offset="1"
id="stop3870" /></linearGradient><linearGradient
id="linearGradient3817"><stop
id="stop3819"
style="stop-color:#00ff0a;stop-opacity:1;"
offset="0" /><stop
id="stop3823"
style="stop-color:#000000;stop-opacity:0.99215686;"
offset="1" /></linearGradient><linearGradient
id="linearGradient4292"><stop
id="stop4294"
offset="0"
style="stop-color:#411f5d;stop-opacity:1" /><stop
id="stop4296"
offset="1"
style="stop-color:#5b2a85;stop-opacity:1" /></linearGradient><linearGradient
id="linearGradient4284"><stop
offset="0"
style="stop-color:#8381c5;stop-opacity:1"
id="stop4286" /><stop
id="stop4290"
style="stop-color:#7e55b3;stop-opacity:0.99607843"
offset="0.56639391" /><stop
offset="1"
style="stop-color:#a52ecb;stop-opacity:0.99215686"
id="stop4288" /></linearGradient><linearGradient
id="linearGradient4898"><stop
id="stop4278"
style="stop-color:#bab8db;stop-opacity:1"
offset="0" /><stop
id="stop4280"
style="stop-color:#5955a9;stop-opacity:0.99159664"
offset="1" /></linearGradient><linearGradient
id="linearGradient3294"><stop
offset="0"
style="stop-color:#6376e6;stop-opacity:1"
id="stop3296" /><stop
offset="0.50094414"
style="stop-color:#222989;stop-opacity:1"
id="stop3302" /><stop
offset="1"
style="stop-color:#00003d;stop-opacity:1"
id="stop3298" /></linearGradient><linearGradient
id="linearGradient3284"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3286" /><stop
offset="0.84845906"
style="stop-color:#000000;stop-opacity:0.49803922"
id="stop3292" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3288" /></linearGradient><linearGradient
id="linearGradient3274"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3276" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3278" /></linearGradient><linearGradient
id="linearGradient3262"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3264" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3266" /></linearGradient><linearGradient
id="linearGradient3242"><stop
offset="0"
style="stop-color:#282828;stop-opacity:1"
id="stop3244" /><stop
offset="0.39253417"
style="stop-color:#808080;stop-opacity:1"
id="stop3252" /><stop
offset="1"
style="stop-color:#d9d9d9;stop-opacity:1"
id="stop3246" /></linearGradient><linearGradient
id="linearGradient3202"><stop
offset="0"
style="stop-color:#2b2b2b;stop-opacity:1"
id="stop3204" /><stop
offset="0.5"
style="stop-color:#828383;stop-opacity:1"
id="stop3250" /><stop
offset="1"
style="stop-color:#dadbdb;stop-opacity:1"
id="stop3206" /></linearGradient><linearGradient
id="linearGradient4966"><stop
offset="0"
style="stop-color:#b6b3d8;stop-opacity:1"
id="stop4968" /><stop
offset="1"
style="stop-color:#b6b3d8;stop-opacity:0"
id="stop4970" /></linearGradient><linearGradient
id="linearGradient4938"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop4940" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop4942" /></linearGradient><linearGradient
id="linearGradient4282"><stop
offset="0"
style="stop-color:#bab8db;stop-opacity:1"
id="stop4900" /><stop
offset="1"
style="stop-color:#5955a9;stop-opacity:0.99159664"
id="stop4902" /></linearGradient><linearGradient
id="linearGradient4876"><stop
offset="0"
style="stop-color:#d3d2e8;stop-opacity:1"
id="stop4878" /><stop
offset="1"
style="stop-color:#5955a9;stop-opacity:0.99159664"
id="stop4880" /></linearGradient><radialGradient
gradientTransform="matrix(0.6817439,0,0,0.5905355,-3.8523706,-28.935273)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4898"
id="radialGradient4892"
fy="-108.96888"
fx="20.951529"
r="266.76535"
cy="-108.96888"
cx="20.951529" /><radialGradient
gradientTransform="matrix(1,0,0,0.1854103,0,383.88493)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4938"
id="radialGradient4944"
fy="471.26172"
fx="233.8876"
r="170.49393"
cy="471.26172"
cx="233.8876" /><radialGradient
gradientTransform="matrix(1,0,0,0.9121621,0,32.654948)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4966"
id="radialGradient4972"
fy="371.76376"
fx="299.70135"
r="76.696358"
cy="371.76376"
cx="299.70135" /><radialGradient
gradientTransform="matrix(0.414705,0.3300575,-0.5059004,0.6356454,346.95314,49.479585)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3202"
id="radialGradient3210"
fy="390.45248"
fx="289.44067"
r="17.67668"
cy="390.45248"
cx="289.44067" /><radialGradient
gradientTransform="matrix(0.414705,0.3300575,-0.5059004,0.6356454,448.41009,-65.398074)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3202"
id="radialGradient3238"
fy="382.14804"
fx="283.50717"
r="17.67668"
cy="382.14804"
cx="283.50717" /><radialGradient
gradientTransform="matrix(-6.5565014e-2,-5.9721765e-2,1.6871024,-1.8521705,171.90774,540.51473)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3242"
id="radialGradient3248"
fy="181.18982"
fx="418.45551"
r="63.068935"
cy="181.18982"
cx="418.45551" /><radialGradient
gradientTransform="matrix(0.4055116,-3.3440123e-2,0.1034174,4.3988695,177.23251,-1191.6649)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3262"
id="radialGradient3268"
fy="357.33591"
fx="354.51709"
r="33.712105"
cy="357.33591"
cx="354.51709" /><radialGradient
gradientTransform="matrix(-0.1339874,-0.1146812,0.3079048,-0.3597394,444.23592,395.03849)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3274"
id="radialGradient3280"
fy="223.55537"
fx="510.58469"
r="132.28336"
cy="223.55537"
cx="510.58469" /><radialGradient
gradientTransform="matrix(-1.2497569,1.3798305,-9.6289463e-2,-7.2974479e-2,674.3826,-70.590682)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3284"
id="radialGradient3290"
fy="-158.17821"
fx="284.4671"
r="110.2972"
cy="-158.17821"
cx="284.4671" /><radialGradient
gradientTransform="matrix(-0.1008165,-8.0872321e-2,1.0745309,-1.3395252,13.843287,784.79288)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3294"
id="radialGradient3300"
fy="356.62274"
fx="425.51019"
r="143.34167"
cy="356.62274"
cx="425.51019" /><filter
height="1.088351"
y="-0.044175496"
width="1.0892536"
x="-0.044626798"
id="filter4350"
style="color-interpolation-filters:sRGB"><feGaussianBlur
id="feGaussianBlur4352"
stdDeviation="8.7848425" /></filter><linearGradient
y2="300.73987"
x2="236.61363"
y1="-161.8512"
x1="-122.20192"
spreadMethod="pad"
gradientTransform="matrix(0.87385837,0,0,0.82818057,246.00762,250.28138)"
gradientUnits="userSpaceOnUse"
id="linearGradient4245"
xlink:href="#linearGradient3817" /><linearGradient
y2="66.018341"
x2="173.94518"
y1="396.6066"
x1="447.80933"
gradientTransform="matrix(0.98684959,0,0,0.98684959,3.0344187,2.5250397)"
gradientUnits="userSpaceOnUse"
id="linearGradient4247"
xlink:href="#linearGradient4292" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3961"
id="radialGradient3959"
cx="1136.9111"
cy="38.175797"
fx="1136.9111"
fy="38.175797"
r="233.11514"
gradientTransform="matrix(1,0,0,1.010216,-880.74005,217.63519)"
gradientUnits="userSpaceOnUse" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3961"
id="radialGradient3973"
cx="255.12297"
cy="256.89456"
fx="255.12297"
fy="256.89456"
r="239.78181"
gradientTransform="matrix(1,0,0,1.009932,0,-2.5514676)"
gradientUnits="userSpaceOnUse" /></defs><rect
style="fill:none;display:none"
id="rect4772"
y="0.20100001"
x="0.171"
height="512"
width="512" /><g
style="display:none"
id="g4788"><g
style="display:inline"
id="g4790" /></g><g
style="display:none"
id="g4806"><g
style="display:inline"
id="g4808"><path
style="fill:#050505;display:none"
id="path4810"
d="M 349.098,256.651 C 348.833,256.397 386.735,284.256 388.519,281.663 C 394.881,272.411 470.565,188.526 473.303,165.427 C 473.545,163.424 472.787,161.331 472.787,161.331 C 472.787,161.331 471.597,161.187 466.462,157.017 C 463.77,154.825 460.979,152.436 460.979,152.436 C 444.925,153.434 403.094,193.995 349.917,256.004" /></g></g><path
d="m 488.23812,256.89456 c 0,130.06121 -104.3692,235.49665 -233.1151,235.49665 -128.7459,0 -233.115201,-105.43544 -233.115201,-235.49665 0,-130.06123 104.369301,-235.49666 233.115201,-235.49666 128.7459,0 233.1151,105.43543 233.1151,235.49666 z"
id="path4235"
style="fill:url(#radialGradient3973);fill-opacity:1.0;stroke:#000000;stroke-width:13.33333301999999954;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -862.34934,-104.26336 c 0,-16.20664 13.138,-29.34464 29.34471,-29.34464 108.04422,0 195.63103,87.586807 195.63103,195.631095 0,16.206636 -13.138,29.344642 -29.34464,29.344642 -16.20664,0 -29.34464,-13.138006 -29.34464,-29.344642 0,-75.631014 -61.3108,-136.941754 -136.94175,-136.941754 -16.20671,0 -29.34471,-13.138067 -29.34471,-29.344701 z"
id="path4" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -725.40758,326.12504 c 0,16.20664 -13.13801,29.34464 -29.34464,29.34464 -108.04429,0 -195.63112,-87.58681 -195.63112,-195.63103 0,-16.20664 13.138,-29.34471 29.3447,-29.34471 16.2066,0 29.3447,13.13807 29.3447,29.34471 0,75.63095 61.3107,136.94175 136.94172,136.94175 16.20663,0 29.34464,13.138 29.34464,29.34464 z"
id="path6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -1009.0726,179.40172 c -16.2067,0 -29.3447,-13.13801 -29.3447,-29.34464 0,-108.044293 87.58676,-195.631101 195.6311,-195.631101 16.20663,0 29.34464,13.138002 29.34464,29.344639 0,16.20663681 -13.13801,29.344702 -29.34464,29.344702 -75.63104,0 -136.94174,61.310741 -136.94174,136.94176 0,16.20663 -13.1381,29.34464 -29.34466,29.34464 z"
id="path8" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -578.68426,42.459959 c 16.20664,0 29.34464,13.138002 29.34464,29.344639 0,108.044292 -87.58681,195.631102 -195.63103,195.631102 -16.20664,0 -29.34471,-13.138 -29.34471,-29.34464 0,-16.20664 13.13807,-29.34464 29.34471,-29.34464 75.63095,0 136.94175,-61.3108 136.94175,-136.941822 0,-16.206637 13.138,-29.344639 29.34464,-29.344639 z"
id="path10" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -258.96103,188.38006 c -5.92272,0 -10.72401,-4.80129 -10.72401,-10.72404 0,-39.48479 32.00863,-71.49343 71.49346,-71.49343 5.92272,0 10.72401,4.80129 10.72401,10.72401 0,5.92273 -4.80129,10.72401 -10.72401,10.72401 -27.63938,0 -50.04542,22.40606 -50.04542,50.04541 0,5.92275 -4.80131,10.72404 -10.72403,10.72404 z"
id="path4-3" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -187.33996,241.78363 c 0,5.92274 -4.80129,10.72402 -10.72401,10.72402 -39.48482,0 -71.49346,-32.00861 -71.49346,-71.49346 0,-5.92272 4.80129,-10.72401 10.72401,-10.72401 5.92273,0 10.72403,4.80129 10.72403,10.72401 0,27.63939 22.40604,50.04541 50.04542,50.04541 5.92272,0 10.72401,4.80133 10.72401,10.72402 z"
id="path8-7" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -258.74453,313.27152 c -5.92272,0 -10.72401,-4.80129 -10.72401,-10.72401 0,-39.48483 32.00863,-71.49347 71.49344,-71.49347 5.92272,0 10.72403,4.80129 10.72403,10.72401 0,5.92273 -4.80131,10.72402 -10.72403,10.72402 -27.63937,0 -50.04542,22.40605 -50.04542,50.04544 0,5.92272 -4.80129,10.72401 -10.72401,10.72401 z"
id="path10-5" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -187.12346,367.23573 c 0,5.92272 -4.80128,10.724 -10.72401,10.724 -39.48482,0 -71.49346,-32.00863 -71.49346,-71.49343 0,-5.92272 4.80129,-10.72403 10.72403,-10.72403 5.92271,0 10.72404,4.80131 10.72404,10.72403 0,27.63936 22.40601,50.04542 50.04539,50.04542 5.92273,0 10.72401,4.80128 10.72401,10.72401 z"
id="path6-6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 629.26349,134.49819 c -5.17912,5.17913 -13.57608,5.17911 -18.75524,-3e-5 -34.52746,-34.527469 -34.52746,-90.507338 4e-5,-125.0348349 5.17912,-5.1791201 13.57609,-5.1791215 18.75521,2.1e-6 5.17912,5.1791198 5.1791,13.5760858 -10e-6,18.7551968 -24.16926,24.16926 -24.16922,63.355176 0,87.524396 5.17914,5.17914 5.17912,13.57614 0,18.75527 z"
id="path4-3-3" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.41281462;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 740.87146,587.17776 c -10.37726,0 -18.78964,-8.41245 -18.78964,-18.78967 0,-69.18171 56.08261,-125.26432 125.26429,-125.26432 10.37724,0 18.78967,8.41239 18.78967,18.78963 0,10.37726 -8.41243,18.78964 -18.78967,18.78964 -48.42717,0 -87.68502,39.25785 -87.68502,87.68505 0,10.37722 -8.41238,18.78967 -18.78963,18.78967 z"
id="path10-5-6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 630.7519,243.88245 c -5.17914,5.17911 -13.5761,5.17911 -18.75523,0 -34.52748,-34.52751 -34.5275,-90.50737 0,-125.03487 5.17912,-5.1791 13.57607,-5.17908 18.75518,3e-5 5.17917,5.17907 5.17918,13.57606 5e-5,18.75519 -24.16926,24.16926 -24.16924,63.35515 2e-5,87.5244 5.17912,5.17912 5.17907,13.57615 -2e-5,18.75518 z"
id="path8-7-5" /><g
id="g3921"
transform="matrix(1.2623093,0,0,1.2623093,-22.620675,-167.67864)"><path
id="path4-9"
d="m -567.88395,525.90207 c 0,-8.09524 6.56246,-14.6577 14.65773,-14.6577 53.96826,0 97.71801,43.74975 97.71801,97.71804 0,8.09524 -6.56246,14.65769 -14.65769,14.65769 -8.09525,0 -14.6577,-6.56245 -14.6577,-14.65769 0,-37.7778 -30.62484,-68.40262 -68.40262,-68.40262 -8.09527,0 -14.65773,-6.56248 -14.65773,-14.65772 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path6-1"
d="m -499.48133,740.88175 c 0,8.09524 -6.56245,14.65768 -14.65769,14.65768 -53.96829,0 -97.71805,-43.74974 -97.71805,-97.718 0,-8.09524 6.56246,-14.65772 14.65772,-14.65772 8.09523,0 14.65773,6.56248 14.65773,14.65772 0,37.77778 30.62479,68.40262 68.4026,68.40262 8.09524,0 14.65769,6.56245 14.65769,14.6577 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path8-2"
d="m -641.17244,667.59321 c -8.09527,0 -14.65773,-6.56245 -14.65773,-14.65769 0,-53.96829 43.74973,-97.71804 97.71804,-97.71804 8.09524,0 14.6577,6.56246 14.6577,14.6577 0,8.09524 -6.56246,14.65772 -14.6577,14.65772 -37.77782,0 -68.40261,30.62481 -68.40261,68.40262 0,8.09524 -6.5625,14.65769 -14.6577,14.65769 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path10-7"
d="m -426.19279,599.19059 c 8.09524,0 14.65769,6.56246 14.65769,14.65769 0,53.9683 -43.74975,97.71805 -97.71801,97.71805 -8.09524,0 -14.65772,-6.56246 -14.65772,-14.65769 0,-8.09525 6.56248,-14.6577 14.65772,-14.6577 37.77778,0 68.40262,-30.62484 68.40262,-68.40266 0,-8.09523 6.56245,-14.65769 14.6577,-14.65769 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /></g><path
d="m 168.87017,369.7941 c 0,0 13.23506,0.93627 30.26137,-0.56431 6.89523,-0.60768 33.07453,-3.17912 52.64715,-7.47152 0,0 23.86373,-5.10715 36.63057,-9.81197 13.35843,-4.92285 20.62761,-9.10098 23.89954,-15.02127 -0.14269,-1.21302 1.00739,-5.51461 -5.15294,-8.09846 -15.74964,-6.60595 -34.01548,-5.41108 -70.15906,-6.1774 -40.0818,-1.37718 -53.41542,-8.08627 -60.51817,-13.48977 -6.81108,-5.48163 -3.38613,-20.64701 25.79772,-34.00541 14.70074,-7.11354 72.32932,-20.24098 72.32932,-20.24098 -19.40827,-9.59329 -55.59858,-26.458 -63.03787,-30.09979 -6.52468,-3.19401 -16.96616,-8.00329 -19.2295,-13.82188 -2.56614,-5.58603 6.06036,-10.39793 10.87905,-11.77591 15.51905,-4.47641 37.42722,-7.25864 57.3662,-7.571 10.0224,-0.15701 11.64921,-0.80185 11.64921,-0.80185 13.82901,-2.29394 22.93273,-11.75538 19.13973,-26.73949 -3.40511,-15.29487 -21.36391,-24.28197 -38.43005,-21.17074 -16.07116,2.92988 -54.80683,14.18154 -54.80683,14.18154 47.88016,-0.4144 55.89402,0.38474 59.47311,5.38881 2.11368,2.95526 -0.96045,7.00739 -13.7299,9.09291 -13.90189,2.27049 -42.80009,5.00476 -42.80009,5.00476 -27.72258,1.6464 -47.25033,1.75659 -53.10719,14.15679 -3.82632,8.10119 4.08038,15.26323 7.5459,19.74633 14.64462,16.28629 35.79785,25.06993 49.41383,31.53826 5.12311,2.43375 20.15489,7.02978 20.15489,7.02978 -44.17265,-2.42953 -76.03716,11.13432 -94.72864,26.75121 -21.14069,19.55411 -11.78868,42.86201 31.52274,57.21332 25.58149,8.47645 38.26825,12.46292 76.42687,9.02676 22.47583,-1.21144 26.01893,-0.49052 26.24301,1.35373 0.31548,2.59652 -24.96418,9.04641 -31.86578,11.03716 -17.55777,5.06447 -63.5838,15.29078 -63.81419,15.34039 z"
id="path4237"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-opacity:1;stroke-width:2.13333333000000014;stroke-miterlimit:4;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 741.14237,231.14899 c 5.17911,5.17911 5.17911,13.5761 0,18.75521 -34.5275,34.52749 -90.50738,34.5275 -125.03484,5e-5 -5.17911,-5.17912 -5.17915,-13.57613 -10e-6,-18.75527 5.17911,-5.17911 13.57615,-5.1791 18.75525,1e-5 24.16922,24.16921 63.35516,24.16927 87.5244,2e-5 5.17913,-5.17912 13.57606,-5.17915 18.7552,-2e-5 z"
id="path6-6-2" /><text
xml:space="preserve"
style="font-size:237.56724547999996844px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#0dbd8b;font-family:Hack;-inkscape-font-specification:Hack;stroke-opacity:1;stroke-width:2.13333333000000014;stroke-miterlimit:4;stroke-dasharray:none"
x="295.67422"
y="342.85031"
id="text3927"
sodipodi:linespacing="125%"
transform="scale(0.97330289,1.0274294)"><tspan
sodipodi:role="line"
id="tspan3929"
x="295.67422"
y="342.85031"
style="font-size:237.56724547999996844px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;font-family:Hack;-inkscape-font-specification:Hack;stroke:#0dbd8b;stroke-opacity:1;stroke-width:2.13333333000000014;stroke-miterlimit:4;stroke-dasharray:none">l</tspan></text>
<text
xml:space="preserve"
style="font-size:138.19949341000000231px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#0dbd8b;font-family:Hack;-inkscape-font-specification:Hack;stroke-opacity:1;stroke-width:2.13333333000000014;stroke-miterlimit:4;stroke-dasharray:none"
x="76.662376"
y="367.39389"
id="text3931"
sodipodi:linespacing="125%"><tspan
sodipodi:role="line"
id="tspan3933"
x="76.662376"
y="367.39389">.</tspan></text>
</svg>
This is docClp2QW.info, produced by makeinfo version 6.8 from
ement.texi.
INFO-DIR-SECTION Emacs
START-INFO-DIR-ENTRY
* Ement: (ement). Matrix client for Emacs.
END-INFO-DIR-ENTRY
File: docClp2QW.info, Node: Top, Next: Installation, Up: (dir)
Ement.el
********
https://elpa.gnu.org/packages/ement.svg
(https://elpa.gnu.org/packages/ement.html)
Ement.el is a Matrix client for Emacs. It aims to be simple, fast,
featureful, and reliable.
Feel free to join us in the chat room:
https://img.shields.io/matrix/ement.el:matrix.org.svg?label=%23ement.el:matrix.org
(https://matrix.to/#/#ement.el:matrix.org)
* Menu:
* Installation::
* Usage::
* Changelog::
* Development::
* License::
— The Detailed Node Listing —
Installation
* GNU ELPA::
* GNU Guix::
* Debian, Ubuntu: Debian Ubuntu.
* Nix::
* Other distributions::
* Git master::
* Manual::
Usage
* Bindings::
* Tips::
* Encrypted room support through Pantalaimon::
Changelog
* 0.12: 012.
* 0.11: 011.
* 0.10: 010.
* 0.9.3: 093.
* 0.9.2: 092.
* 0.9.1: 091.
* 0.9: 09.
* 0.8.3: 083.
* 0.8.2: 082.
* 0.8.1: 081.
* 0.8: 08.
* 0.7: 07.
* 0.6: 06.
* 0.5.2: 052.
* 0.5.1: 051.
* 0.5: 05.
* 0.4.1: 041.
* 0.4: 04.
* 0.3.1: 031.
* 0.3: 03.
* 0.2.1: 021.
* 0.2: 02.
* 0.1.4: 014.
* 0.1.3: 013.
* 0.1.2: 012 (1).
* 0.1.1: 011 (1).
* 0.1: 01.
Development
* Copyright Assignment::
* Matrix spec in Org format::
* Rationale::
File: docClp2QW.info, Node: Installation, Next: Usage, Prev: Top, Up: Top
1 Installation
**************
* Menu:
* GNU ELPA::
* GNU Guix::
* Debian, Ubuntu: Debian Ubuntu.
* Nix::
* Other distributions::
* Git master::
* Manual::
File: docClp2QW.info, Node: GNU ELPA, Next: GNU Guix, Up: Installation
GNU ELPA
========
Ement.el is published in GNU ELPA (http://elpa.gnu.org/) as ement
(https://elpa.gnu.org/packages/ement.html), so it may be installed in
Emacs with the command ‘M-x package-install RET ement RET’. This is the
recommended way to install Ement.el, as it will install the current
stable release.
The latest development build may be installed from ELPA-devel
(https://elpa.gnu.org/devel/ement.html) or from Git (see below).
File: docClp2QW.info, Node: GNU Guix, Next: Debian Ubuntu, Prev: GNU ELPA, Up: Installation
GNU Guix
========
Ement.el is available in GNU Guix (https://guix.gnu.org/) as emacs-ement
(https://packages.guix.gnu.org/packages/emacs-ement/).
File: docClp2QW.info, Node: Debian Ubuntu, Next: Nix, Prev: GNU Guix, Up: Installation
Debian, Ubuntu
==============
Ement.el is available in Debian as elpa-ement
(https://packages.debian.org/elpa-ement) and in Ubuntu as elpa-ement
(https://packages.ubuntu.com/search?suite=default§ion=all&arch=any&keywords=elpa-ement&searchon=names).
File: docClp2QW.info, Node: Nix, Next: Other distributions, Prev: Debian Ubuntu, Up: Installation
Nix
===
Ement.el is available in NixOS (https://nixos.org/) as
emacsPackages.ement
(https://search.nixos.org/packages?channel=23.05&show=emacsPackages.ement&from=0&size=50&sort=relevance&type=packages&query=ement).
File: docClp2QW.info, Node: Other distributions, Next: Git master, Prev: Nix, Up: Installation
Other distributions
===================
Ement.el is also available in some other distributions. See Repology
(https://repology.org/project/emacs:ement/related) for details.
File: docClp2QW.info, Node: Git master, Next: Manual, Prev: Other distributions, Up: Installation
Git master
==========
The ‘master’ branch of the Git repository is intended to be usable at
all times; only minor bugs are expected to be found in it before a new
stable release is made.
To install, it is recommended to use quelpa-use-package
(https://github.com/quelpa/quelpa-use-package), like this (using this
helpful command
(https://github.com/alphapapa/unpackaged.el#upgrade-a-quelpa-use-package-forms-package)
for upgrading versions):
;; Install and load `quelpa-use-package'.
(package-install 'quelpa-use-package)
(require 'quelpa-use-package)
;; Install Ement.
(use-package ement
:quelpa (ement :fetcher github :repo "alphapapa/ement.el"))
One might also use systems like Elpaca
(https://github.com/progfolio/elpaca) or Straight
(https://github.com/radian-software/straight.el) (which is also used by
DOOM (https://github.com/doomemacs/doomemacs)), but the author cannot
offer support for them.
File: docClp2QW.info, Node: Manual, Prev: Git master, Up: Installation
Manual
======
Ement.el is intended to be installed with Emacs’s package system, which
will ensure that the required autoloads are generated, etc. If you
choose to install it manually, you’re on your own.
File: docClp2QW.info, Node: Usage, Next: Changelog, Prev: Installation, Up: Top
2 Usage
*******
• • •
1. Call command ‘ement-connect’ to connect. Multiple sessions are
supported, so you may call the command again to connect to another
account.
2. Wait for initial sync to complete (which can take a few
moments–initial sync JSON requests can be large).
3. Use these commands (room-related commands may be called with
universal prefix to prompt for the room):
• ‘ement-list-rooms’ to view the list of joined rooms.
• ‘ement-view-room’ to view a room’s buffer, selected with
completion.
• ‘ement-create-room’ to create a new room.
• ‘ement-create-space’ to create a space.
• ‘ement-invite-user’ to invite a user to a room.
• ‘ement-join-room’ to join a room.
• ‘ement-leave-room’ to leave a room.
• ‘ement-forget-room’ to forget a room.
• ‘ement-tag-room’ to toggle a tag on a room (including
favorite/low-priority status).
• ‘ement-list-members’ to list members in a room.
• ‘ement-send-direct-message’ to send a direct message to a user
(in an existing direct room, or creating a new one
automatically).
• ‘ement-room-edit-message’ to edit a message at point.
• ‘ement-room-send-file’ to send a file.
• ‘ement-room-send-image’ to send an image.
• ‘ement-room-set-topic’ to set a room’s topic.
• ‘ement-room-occur’ to search in a room’s known events.
• ‘ement-room-override-name’ to override a room’s display name.
• ‘ement-ignore-user’ to ignore a user (or with interactive
prefix, un-ignore).
• ‘ement-room-set-message-format’ to set a room’s message format
buffer-locally.
• ‘ement-room-toggle-space’ to toggle a room’s membership in a
space (a way to group rooms in Matrix).
• ‘ement-directory’ to view a room directory.
• ‘ement-directory-search’ to search a room directory.
4. Use these special buffers to see events from multiple rooms (you
can also reply to messages from these buffers!):
• See all new events that mention you in the ‘*Ement Mentions*’
buffer.
• See all new events in rooms that have open buffers in the
‘*Ement Notifications*’ buffer.
* Menu:
* Bindings::
* Tips::
* Encrypted room support through Pantalaimon::
File: docClp2QW.info, Node: Bindings, Next: Tips, Up: Usage
Bindings
========
These bindings are common to all of the following buffer types:
• Switch to a room buffer: ‘M-g M-r’
• Switch to the room list buffer: ‘M-g M-l’
• Switch to the mentions buffer: ‘M-g M-m’
• Switch to the notifications buffer: ‘M-g M-n’
* Menu:
* Room buffers::
* Room list buffer::
* Directory buffers::
* Mentions/notifications buffers::
File: docClp2QW.info, Node: Room buffers, Next: Room list buffer, Up: Bindings
Room buffers
------------
• Show command menu: ‘?’
*Movement*
• Next event: ‘n’
• Previous event: ‘p’
• Scroll up and mark read: ‘SPC’
• Scroll down: ‘S-SPC’
• Jump to fully-read marker: ‘M-g M-p’
• Move read markers to point: ‘m’
• Load older messages: at top of buffer, scroll contents up (i.e.
‘S-SPC’, ‘M-v’ or ‘mwheel-scroll’)
*Switching*
• List rooms: ‘M-g M-l’
• Switch to other room: ‘M-g M-r’
• Switch to mentions buffer: ‘M-g M-m’
• Switch to notifications buffer: ‘M-g M-n’
• Quit window: ‘q’
*Messages*
• Write message: ‘RET’
• Write reply to event at point (when region is active, only quote
marked text) : ‘S-RET’
• Compose message in buffer: ‘M-RET’ (while writing in minibuffer:
‘C-c ')’ (Use command ‘ement-room-compose-org’ to activate Org mode
in the compose buffer.)
• Edit message: ‘<insert>’
• Delete message: ‘C-k’
• Send reaction to event at point, or send same reaction at point: ‘s
r’
• Send emote: ‘s e’
• Send file: ‘s f’
• Send image: ‘s i’
• View event source: ‘v’
• Complete members and rooms at point: ‘C-M-i’ (standard
‘completion-at-point’ command). (Type an ‘@’ prefix for a member
mention, a ‘#’ prefix for a room alias, or a ‘!’ prefix for a room
ID.)
*Images*
• Toggle scale of image (between fit-to-window and thumbnail):
‘mouse-1’
• Show image in new buffer at full size: ‘double-mouse-1’
*Users*
• Send direct message: ‘u RET’
• Invite user: ‘u i’
• Ignore user: ‘u I’
*Room*
• Occur search in room: ‘M-s o’
• List members: ‘r m’
• Set topic: ‘r t’
• Set message format: ‘r f’
• Set notification rules: ‘r n’
• Override display name: ‘r N’
• Tag/untag room: ‘r T’
*Room membership*
• Create room: ‘R c’
• Join room: ‘R j’
• Leave room: ‘R l’
• Forget room: ‘R F’
• Toggle room’s spaces: ‘R s’
*Other*
• Sync new messages (not necessary if auto sync is enabled; with
prefix to force new sync): ‘g’
File: docClp2QW.info, Node: Room list buffer, Next: Directory buffers, Prev: Room buffers, Up: Bindings
Room list buffer
----------------
• Show buffer of room at point: ‘RET’
• Show buffer of next unread room: ‘SPC’
• Move between room names: ‘TAB’ / ‘<backtab>’
• Kill room’s buffer: ‘k’
• Toggle room’s membership in a space: ‘s’
File: docClp2QW.info, Node: Directory buffers, Next: Mentions/notifications buffers, Prev: Room list buffer, Up: Bindings
Directory buffers
-----------------
• View/join a room: ‘RET’ / ‘mouse-1’
• Load next batch of rooms: ‘+’
File: docClp2QW.info, Node: Mentions/notifications buffers, Prev: Directory buffers, Up: Bindings
Mentions/notifications buffers
------------------------------
• Move between events: ‘TAB’ / ‘<backtab>’
• Go to event at point in its room buffer: ‘RET’
• Write reply to event at point (shows the event in its room while
writing) : ‘S-RET’
File: docClp2QW.info, Node: Tips, Next: Encrypted room support through Pantalaimon, Prev: Bindings, Up: Usage
Tips
====
• Desktop notifications are enabled by default for events that
mention the local user. They can also be shown for all events in
rooms with open buffers.
• Send messages in Org mode format by customizing the option
‘ement-room-send-message-filter’ (which enables Org format by
default), or by calling ‘ement-room-compose-org’ in a compose
buffer (which enables it for a single message). Then Org-formatted
messages are automatically converted and sent as HTML-formatted
messages (with the Org syntax as the plain-text fallback). You can
send syntax such as:
• Bold, italic, underline, strikethrough
• Links
• Tables
• Source blocks (including results with ‘:exports both’)
• Footnotes (okay, that might be pushing it, but you can!)
• And, generally, anything that Org can export to HTML
• Starting in the room list buffer, by pressing ‘SPC’ repeatedly, you
can cycle through and read all rooms with unread buffers. (If a
room doesn’t have a buffer, it will not be included.)
• Room buffers and the room-list buffer can be bookmarked in Emacs,
i.e. using ‘C-x r m’. This is especially useful with Burly
(https://github.com/alphapapa/burly.el): you can arrange an Emacs
frame with several room buffers displayed at once, use
‘burly-bookmark-windows’ to bookmark the layout, and then you can
restore that layout and all of the room buffers by opening the
bookmark, rather than having to manually arrange them every time
you start Emacs or change the window configuration.
• Images and other files can be uploaded to rooms using
drag-and-drop.
• Mention members by typing a ‘@’ followed by their displayname or
Matrix ID. (Members’ names and rooms’ aliases/IDs may be completed
with ‘completion-at-point’ commands.)
• You can customize settings in the ‘ement’ group.
• *Note:* ‘setq’ should not be used for certain options, because
it will not call the associated setter function. Users who
have an aversion to the customization system may experience
problems.
* Menu:
* Displaying symbols and emojis::
File: docClp2QW.info, Node: Displaying symbols and emojis, Up: Tips
Displaying symbols and emojis
-----------------------------
Emacs may not display certain symbols and emojis well by default. Based
on this question and answer
(https://emacs.stackexchange.com/questions/62049/override-the-default-font-for-emoji-characters),
you may find that the simplest way to fix this is to install an
appropriate font, like Noto Emoji
(https://www.google.com/get/noto/#emoji-zsye), and then use this Elisp
code:
(setf use-default-font-for-symbols nil)
(set-fontset-font t 'unicode "Noto Emoji" nil 'append)
File: docClp2QW.info, Node: Encrypted room support through Pantalaimon, Prev: Tips, Up: Usage
Encrypted room support through Pantalaimon
==========================================
Ement.el doesn’t support encrypted rooms natively, but it can be used
transparently with the E2EE-aware reverse proxy daemon Pantalaimon
(https://github.com/matrix-org/pantalaimon/). After configuring it
according to its documentation, call ‘ement-connect’ with the
appropriate hostname and port, like:
(ement-connect :uri-prefix "http://localhost:8009")
File: docClp2QW.info, Node: Changelog, Next: Development, Prev: Usage, Up: Top
3 Changelog
***********
* Menu:
* 0.12: 012.
* 0.11: 011.
* 0.10: 010.
* 0.9.3: 093.
* 0.9.2: 092.
* 0.9.1: 091.
* 0.9: 09.
* 0.8.3: 083.
* 0.8.2: 082.
* 0.8.1: 081.
* 0.8: 08.
* 0.7: 07.
* 0.6: 06.
* 0.5.2: 052.
* 0.5.1: 051.
* 0.5: 05.
* 0.4.1: 041.
* 0.4: 04.
* 0.3.1: 031.
* 0.3: 03.
* 0.2.1: 021.
* 0.2: 02.
* 0.1.4: 014.
* 0.1.3: 013.
* 0.1.2: 012 (1).
* 0.1.1: 011 (1).
* 0.1: 01.
File: docClp2QW.info, Node: 012, Next: 011, Up: Changelog
0.12
====
*Additions*
• Command ‘ement-notifications’ shows recent notifications, similar
to the pane in the Element client. (This new command fetches
recent notifications from the server and allows scrolling up to
retrieve older ones. Newly received notifications, as configured
in the ‘ement-notify’ options, are displayed in the same buffer.
This functionality will be consolidated in the future.)
• Face ‘ement-room-quote’, applied to quoted parts of replies.
*Changes*
• Commands ‘ement-room-goto-next’ and ‘ement-room-goto-prev’ work
more usefully at the end of a room buffer. (Now pressing ‘n’ on
the last event moves point to the end of the buffer so it will
scroll automatically for new messages, and then pressing ‘p’ skips
over any read marker to the last event.)
• Room buffer bindings:
• ‘ement-room-goto-next’ and ‘ement-room-goto-prev’ are bound to
‘n’ and ‘p’, respectively.
• ‘ement-room-goto-fully-read-marker’ is bound to ‘M-g M-p’ (the
mnemonic being "go to previously read").
• The quoted part of a reply now omits the face applied to the rest
of the message, helping to distinguish them.
• Commands that read a string from the minibuffer in ‘ement-room’
buffers and ‘ement-connect’ user ID prompts use separate history
list variables.
• Use Emacs’s Jansson-based JSON-parsing functions when available.
(This results in a 3-5x speed improvement for parsing JSON
responses, which can be significant for large initial sync
responses. Thanks to Ryan Rix (https://github.com/rrix/) for
discovering this!)
*Fixes*
• File event formatter assumed that file size metadata would be
present (a malformed, e.g. spam, event might not have it).
• Send correct file size when sending files/images.
• Underscores are no longer interpreted as denoting subscripts when
sending messages in Org format. (Thanks to Phil Sainty
(https://github.com/phil-s).)
• Add workaround for ‘savehist-mode’’s serializing of the
‘command-history’ variable’s arguments. (For ‘ement-’ commands,
that may include large data structures, like ‘ement-session’
structs, which should never be serialized or reused, and
‘savehist’’s doing so could cause noticeable delays for users who
enabled it). (See #216
(https://github.com/alphapapa/ement.el/issues/216). Thanks to Phil
Sainty (https://github.com/phil-s) and other users who helped to
discover this problem.)
File: docClp2QW.info, Node: 011, Next: 010, Prev: 012, Up: Changelog
0.11
====
*Additions*
• Commands ‘ement-room-image-show’ and ‘ement-room-image-scale’
(bound to ‘RET’ and ‘M-RET’ when point is at an image) view and
scale images. (Thanks to Steven Allen
(https://github.com/Stebalien) for these and other image-related
improvements.)
• Command ‘ement-room-image-show-mouse’ is used to show an image with
the mouse.
*Changes*
• Enable ‘image-mode’ when showing images in a new buffer. (Thanks
to Steven Allen (https://github.com/Stebalien).)
• Command ‘ement-room-image-show’ is not used for mouse events.
• Show useful message in SSO login page.
*Fixes*
• Allow editing of already-edited events.
• Push rules’ actions may be listed in any order. (Fixes
compatibility with v1.7 of the spec
(https://spec.matrix.org/v1.7/client-server-api/#actions). Thanks
to Steven Allen (https://github.com/Stebalien).)
• Call external browser for SSO login page. (JavaScript is usually
required, which EWW doesn’t support, and loading the page twice
seems to change state on the server that causes the SSO login to
fail, so it’s best to load the page in the external browser
directly).
• Clean up SSO server process after two minutes in case SSO login
fails.
• Don’t stop syncing if an error is signaled while sending a
notification.
• Command ‘ement-room-list-next-unread’ could enter an infinite loop.
(Thanks to Visuwesh (https://github.com/vizs) and
‘@mrtnmrtn:matrix.org’.)
• Events in notifications buffer could appear out-of-order. (#191
(https://github.com/alphapapa/ement.el/issues/191). Thanks to Phil
Sainty (https://github.com/phil-s).)
*Internal*
• The ‘ement-read-receipt-idle-timer’ could be duplicated when using
multiple sessions. (#196
(https://github.com/alphapapa/ement.el/issues/196). Thanks to Phil
Sainty (https://github.com/phil-s).)
File: docClp2QW.info, Node: 010, Next: 093, Prev: 011, Up: Changelog
0.10
====
*Security Fixes*
• When uploading a GPG-encrypted file (i.e. one whose filename ends
in ‘.gpg’), if the recipient’s private key or the symmetric
encryption key were cached by Emacs (or a configured agent, like
‘gpg-agent’), Emacs would automatically decrypt the file while
reading its contents and then upload the decrypted contents. (This
happened because the function ‘insert-file-contents’ was used,
which does many things automatically, some of which are not even
mentioned in its docstring; refer to its entry in the Elisp Info
manual for details. The fix is to use
‘insert-file-contents-literally’ instead.) Thanks to
‘@welkinsl:matrix.org’ for reporting.
*Additions*
• Support for Single Sign-On (SSO) authentication. (#24
(https://github.com/alphapapa/ement.el/issues/24). Thanks to
Jeffrey Stoffers (https://github.com/Necronian) for development,
and to Phil Sainty (https://github.com/phil-s), Jakub Kadlčík
(https://github.com/FrostyX), and Juanjo Presa
(https://github.com/oneingan) for testing.)
• Bind ‘m’ in room buffers to ‘ement-room-mark-read’ (which moves
read markers to point).
*Changes*
• Activating a space in the room list uses ‘ement-view-space’ (which
shows a directory of rooms in the space) instead of
‘ement-view-room’ (which shows events in the space, which is
generally not useful).
• Command ‘ement-view-room’, when used for a space, shows a footer
explaining that the buffer is showing a space rather than a normal
room, with a button to call ‘ement-view-space’ for it (which lists
rooms in the space).
• Command ‘ement-describe-room’ shows whether a room is a space or a
normal room.
• Command ‘ement-view-space’ shows the space’s name and alias.
• Command ‘ement-room-scroll-up-mark-read’ moves the fully read
marker to the top of the window (when the marker’s position is
within the range of known events), rather than only moving it when
at the end of the buffer. (This eases the process of gradually
reading a long backlog of messages.)
• Improve readme export settings.
*Fixes*
• Extra indentation of some membership events. (Thanks to Steven
Allen (https://github.com/Stebalien).)
• Customization group for faces.
• Don’t reinitialize ‘ement-room-list-mode’ when room list buffer is
refreshed. (#146
(https://github.com/alphapapa/ement.el/issues/146). Thanks to Ted
Reed (https://github.com/treed) for reporting.)
• Don’t fetch old events when scrolling to the bottom of a room
buffer (only when scrolling to the top). (Thanks to Steven Allen
(https://github.com/Stebalien).)
• Minor improvements to auto-detection of homeserver URIs. (See #24
(https://github.com/alphapapa/ement.el/issues/24#issuecomment-1569518713).
Thanks to Phil Sainty (https://github.com/phil-s).)
• Uploading of certain filetypes (e.g. Emacs would decompress some
archives before uploading). Thanks to ‘@welkinsl:matrix.org’ for
reporting.
• Messages edited multiple times sometimes weren’t correctly
replaced.
File: docClp2QW.info, Node: 093, Next: 092, Prev: 010, Up: Changelog
0.9.3
=====
*Fixes*
• Another attempt at restoring position in room list when refreshing.
• Command ‘ement-room-list-next-unread’.
File: docClp2QW.info, Node: 092, Next: 091, Prev: 093, Up: Changelog
0.9.2
=====
*Fixes*
• Restore position in room list when refreshing.
• Completion in minibuffer.
File: docClp2QW.info, Node: 091, Next: 09, Prev: 092, Up: Changelog
0.9.1
=====
*Fixes*
• Error in ‘ement-room-list’ command upon initial sync.
File: docClp2QW.info, Node: 09, Next: 083, Prev: 091, Up: Changelog
0.9
===
*Additions*
• Option ‘ement-room-timestamp-header-align’ controls how timestamp
headers are aligned in room buffers.
• Option ‘ement-room-view-hook’ runs functions when ‘ement-room-view’
is called. (By default, it refreshes the room list buffer.)
• In the room list, middle-clicking a room which has a buffer closes
its buffer.
• Basic support for video events. (Thanks to Arto Jantunen
(https://github.com/viiru-).)
*Changes*
• Using new option ‘ement-room-timestamp-header-align’, timestamp
headers default to right-aligned. (With default settings, this
keeps them near message timestamps and makes for a cleaner
appearance.)
*Fixes*
• Recognition of certain MXID or displayname forms in outgoing
messages when linkifying (aka "pilling") them.
• Unreadable room avatar images no longer cause errors. (Fixes #147
(https://github.com/alphapapa/ement.el/issues/147). Thanks to
@jgarte (https://github.com/jgarte) for reporting.)
• Don’t error in ‘ement-room-list’ when no rooms are joined. (Fixes
#123 (https://github.com/alphapapa/ement.el/issues/123). Thanks to
@Kabouik (https://github.com/Kabouik) and Omar Antolín Camarena
(https://github.com/oantolin) for reporting.)
• Enable member/room completion in compose buffers. (Fixes #115
(https://github.com/alphapapa/ement.el/issues/115). Thanks to
Thanks to Justus Piater (https://github.com/piater) and Caleb Chase
(https://github.com/chasecaleb) for reporting.)
File: docClp2QW.info, Node: 083, Next: 082, Prev: 09, Up: Changelog
0.8.3
=====
*Fixes*
• Avoid use of ‘pcase’’s ‘(map :KEYWORD)’ form. (This can cause a
broken installation on older versions of Emacs that have an older
version of the ‘map’ library loaded, such as Emacs 27.2 included in
Debian 11. Since there’s no way to force Emacs to actually load
the version of ‘map’ required by this package before installing it
(which would naturally happen upon restarting Emacs), we can only
avoid using such forms while these versions of Emacs are widely
used.)
File: docClp2QW.info, Node: 082, Next: 081, Prev: 083, Up: Changelog
0.8.2
=====
*Fixes*
• Deduplicate grouped membership events.
File: docClp2QW.info, Node: 081, Next: 08, Prev: 082, Up: Changelog
0.8.1
=====
Added missing changelog entry (of course).
File: docClp2QW.info, Node: 08, Next: 07, Prev: 081, Up: Changelog
0.8
===
*Additions*
• Command ‘ement-create-space’ creates a new space.
• Command ‘ement-room-toggle-space’ toggles a room’s membership in a
space (a way to group rooms in Matrix).
• Visibility of sections in the room list is saved across sessions.
• Command ‘ement-room-list-kill-buffer’ kills a room’s buffer from
the room list.
• Set ‘device_id’ and ‘initial_device_display_name’ upon login (e.g.
‘Ement.el: username@hostname’). (#134
(https://github.com/alphapapa/ement.el/issues/134). Thanks to Arto
Jantunen (https://github.com/viiru-) for reporting.)
*Changes*
• Room-related commands may be called interactively with a universal
prefix to prompt for the room/session (allowing to send events or
change settings in rooms other than the current one).
• Command ‘ement-room-list’ reuses an existing window showing the
room list when possible. (#131
(https://github.com/alphapapa/ement.el/issues/131). Thanks to Jeff
Bowman (https://github.com/jeffbowman) for suggesting.)
• Command ‘ement-tag-room’ toggles tags (rather than adding by
default and removing when called with a prefix).
• Default room grouping now groups "spaced" rooms separately.
*Fixes*
• Message format filter works properly when writing replies.
• Improve insertion of sender name headers when using the "Elemental"
message format.
• Prompts in commands ‘ement-leave-room’ and ‘ement-forget-room’.
File: docClp2QW.info, Node: 07, Next: 06, Prev: 08, Up: Changelog
0.7
===
*Additions*
• Command ‘ement-room-override-name’ sets a local override for a
room’s display name. (Especially helpful for 1:1 rooms and bridged
rooms. See MSC3015
(https://github.com/matrix-org/matrix-spec-proposals/pull/3015#issuecomment-1451017296).)
*Changes*
• Improve display of room tombstones (displayed at top and bottom of
buffer, and new room ID is linked to join).
• Use descriptive prompts in ‘ement-leave-room’ and
‘ement-forget-room’ commands.
*Fixes*
• Command ‘ement-view-space’ when called from a room buffer. (Thanks
to Richard Brežák (https://github.com/MagicRB) for reporting.)
• Don’t call ‘display-buffer’ when reverting room list buffer.
(Fixes #121 (https://github.com/alphapapa/ement.el/issues/121).
Thanks to mekeor (https://github.com/mekeor) for reporting.)
• Retry sync for network timeouts. (Accidentally broken in v0.6.)
*Internal*
• Function ‘ement-put-account-data’ accepts ‘:room’ argument to put
on a room’s account data.
File: docClp2QW.info, Node: 06, Next: 052, Prev: 07, Up: Changelog
0.6
===
*Additions*
• Command ‘ement-view-space’ to view a space’s rooms in a directory
buffer.
*Changes*
• Improve ‘ement-describe-room’ command (formatting, bindings).
*Fixes*
• Retry sync for HTTP 502 "Bad Gateway" errors.
• Formatting of unban events.
• Update password authentication according to newer Matrix spec.
(Fixes compatibility with Conduit servers. #66
(https://github.com/alphapapa/ement.el/issues/66). Thanks to
Travis Peacock (https://github.com/tpeacock19), Arto Jantunen
(https://github.com/viiru-), and Stephen D
(https://github.com/scd31).)
• Image scaling issues. (Thanks to Visuwesh
(https://github.com/vizs).)
File: docClp2QW.info, Node: 052, Next: 051, Prev: 06, Up: Changelog
0.5.2
=====
*Fixes*
• Apply ‘ement-initial-sync-timeout’ properly (important for when the
homeserver is slow to respond).
File: docClp2QW.info, Node: 051, Next: 05, Prev: 052, Up: Changelog
0.5.1
=====
*Fixes*
• Autoload ‘ement-directory’ commands.
• Faces in ‘ement-directory’ listings.
File: docClp2QW.info, Node: 05, Next: 041, Prev: 051, Up: Changelog
0.5
===
*Additions*
• Present "joined-and-left" and "rejoined-and-left" membership event
pairs as such.
• Process and show rooms’ canonical alias events.
*Changes*
• The taxy.el (https://github.com/alphapapa/taxy.el)-based room list,
with programmable, smart grouping, is now the default
‘ement-room-list’. (The old, ‘tabulated-list-mode’-based room list
is available as ‘ement-tabulated-room-list’.)
• When selecting a room to view with completion, don’t offer spaces.
• When selecting a room with completion, empty aliases and topics are
omitted instead of being displayed as nil.
*Fixes*
• Use of send-message filter when replying.
• Replies may be written in compose buffers.
File: docClp2QW.info, Node: 041, Next: 04, Prev: 05, Up: Changelog
0.4.1
=====
*Fixes*
• Don’t show "curl process interrupted" message when updating a read
marker’s position again.
File: docClp2QW.info, Node: 04, Next: 031, Prev: 041, Up: Changelog
0.4
===
*Additions*
• Option ‘ement-room-unread-only-counts-notifications’, now enabled
by default, causes rooms’ unread status to be determined only by
their notification counts (which are set by the server and depend
on rooms’ notification settings).
• Command ‘ement-room-set-notification-state’ sets a room’s
notification state (imitating Element’s user-friendly presets).
• Room buffers’ Transient menus show the room’s notification state
(imitating Element’s user-friendly presets).
• Command ‘ement-set-display-name’ sets the user’s global
displayname.
• Command ‘ement-room-set-display-name’ sets the user’s displayname
in a room (which is also now displayed in the room’s Transient
menu).
• Column ‘Notifications’ in the ‘ement-taxy-room-list’ buffer shows
rooms’ notification state.
• Option ‘ement-interrupted-sync-hook’ allows customization of how
sync interruptions are handled. (Now, by default, a warning is
displayed instead of merely a message.)
*Changes*
• When a room’s read receipt is updated, the room’s buffer is also
marked as unmodified. (In concert with the new option, this makes
rooms’ unread status more intuitive.)
*Fixes*
• Binding of command ‘ement-forget-room’ in room buffers.
• Highlighting of ‘@room’ mentions.
File: docClp2QW.info, Node: 031, Next: 03, Prev: 04, Up: Changelog
0.3.1
=====
*Fixes*
• Room unread status (when the last event in a room is sent by the
local user, the room is considered read).
File: docClp2QW.info, Node: 03, Next: 021, Prev: 031, Up: Changelog
0.3
===
*Additions*
• Command ‘ement-directory’ shows a server’s room directory.
• Command ‘ement-directory-search’ searches a server’s room
directory.
• Command ‘ement-directory-next’ fetches the next batch of rooms in a
directory.
• Command ‘ement-leave-room’ accepts a ‘FORCE-P’ argument
(interactively, with prefix) to leave a room without prompting.
• Command ‘ement-forget-room’ accepts a ‘FORCE-P’ argument
(interactively, with prefix) to also leave the room, and to forget
it without prompting.
• Option ‘ement-notify-mark-frame-urgent-predicates’ marks the frame
as urgent when (by default) a message mentions the local user or
"@room" and the message’s room has an open buffer.
*Changes*
• Minor improvements to date/time headers.
*Fixes*
• Command ‘ement-describe-room’ for rooms without topics.
• Improve insertion of old messages around existing timestamp
headers.
• Reduce D-Bus notification system check timeout to 2 seconds (from
the default of 25).
• Compatibility with Emacs 27.
File: docClp2QW.info, Node: 021, Next: 02, Prev: 03, Up: Changelog
0.2.1
=====
*Fixes*
• Info manual export filename.
File: docClp2QW.info, Node: 02, Next: 014, Prev: 021, Up: Changelog
0.2
===
*Changes*
• Read receipts are re-enabled. (They’re now implemented with a
global idle timer rather than ‘window-scroll-functions’, which
sometimes caused a strange race condition that could cause Emacs to
become unresponsive or crash.)
• When determining whether a room is considered unread, non-message
events like membership changes, reactions, etc. are ignored. This
fixes a bug that caused certain rooms that had no message events
(like some bridged rooms) to appear as unread when they shouldn’t
have. But it’s unclear whether this is always preferable (e.g.
one might want a member leaving a room to cause it to be marked
unread), so this is classified as a change rather than simply a
fix, and more improvements may be made to this in the future.
(Fixes #97 (https://github.com/alphapapa/ement.el/issues/97).
Thanks to Julien Roy (https://github.com/MrRoy) for reporting and
testing.)
• The ‘ement-taxy-room-list’ view no longer automatically refreshes
the list if the region is active in the buffer. (This allows the
user to operate on multiple rooms without the contents of the
buffer changing before completing the process.)
*Fixes*
• Links to only rooms (as opposed to links to events in rooms) may be
activated to join them.
• Read receipts mark the last completely visible event (rather than
one that’s only partially displayed).
• Prevent error when a room avatar image fails to load.
File: docClp2QW.info, Node: 014, Next: 013, Prev: 02, Up: Changelog
0.1.4
=====
*Fixed*
• Info manual directory headers.
File: docClp2QW.info, Node: 013, Next: 012 (1), Prev: 014, Up: Changelog
0.1.3
=====
*Fixed*
• Temporarily disable sending of read receipts due to an unusual bug
that could cause Emacs to become unresponsive. (The feature will
be re-enabled in a future release.)
File: docClp2QW.info, Node: 012 (1), Next: 011 (1), Prev: 013, Up: Changelog
0.1.2
=====
*Fixed*
• Function ‘ement-room-sync’ correctly updates room-list buffers.
(Thanks to Visuwesh (https://github.com/vizs).)
• Only send D-Bus notifications when supported. (Fixes #83
(https://github.com/alphapapa/ement.el/issues/83). Thanks to
Tassilo Horn (https://github.com/tsdh).)
File: docClp2QW.info, Node: 011 (1), Next: 01, Prev: 012 (1), Up: Changelog
0.1.1
=====
*Fixed*
• Function ‘ement-room-scroll-up-mark-read’ selects the correct room
window.
• Option ‘ement-room-list-avatars’ defaults to what function
‘display-images-p’ returns.
File: docClp2QW.info, Node: 01, Prev: 011 (1), Up: Changelog
0.1
===
After almost two years of development, the first tagged release.
Submitted to GNU ELPA.
File: docClp2QW.info, Node: Development, Next: License, Prev: Changelog, Up: Top
4 Development
*************
Bug reports, feature requests, suggestions — _oh my_!
* Menu:
* Copyright Assignment::
* Matrix spec in Org format::
* Rationale::
File: docClp2QW.info, Node: Copyright Assignment, Next: Matrix spec in Org format, Up: Development
Copyright Assignment
====================
Ement.el is published in GNU ELPA and is considered part of GNU Emacs.
Therefore, cumulative contributions of more than 15 lines of code
require that the author assign copyright of such contributions to the
FSF. Authors who are interested in doing so may contact assign@gnu.org
<assign@gnu.org> to request the appropriate form.
File: docClp2QW.info, Node: Matrix spec in Org format, Next: Rationale, Prev: Copyright Assignment, Up: Development
Matrix spec in Org format
=========================
An Org-formatted version of the Matrix spec is available in the
meta/spec (https://github.com/alphapapa/ement.el/tree/meta/spec) branch.
File: docClp2QW.info, Node: Rationale, Prev: Matrix spec in Org format, Up: Development
Rationale
=========
_This section is preserved for posterity. As it says, Ement.el has long
since surpassed ‘matrix-client’, which should no longer be used._
Why write a new Emacs Matrix client when there is already
matrix-client.el (https://github.com/alphapapa/matrix-client.el), by the
same author, no less? A few reasons:
• ‘matrix-client’ uses an older version of the Matrix spec, r0.3.0,
with a few elements of r0.4.0 grafted in. Bringing it up to date
with the current version of the spec, r0.6.1, would be more work
than to begin with the current version. Ement.el targets r0.6.1
from the beginning.
• ‘matrix-client’ does not use Matrix’s lazy-loading feature (which
was added to the specification later), so initial sync requests can
take a long time for the server to process and can be large
(sometimes tens of megabytes of JSON for the client to process!).
Ement.el uses lazy-loading, which significantly improves
performance.
• ‘matrix-client’ automatically makes buffers for every room a user
has joined, even if the user doesn’t currently want to watch a
room. Ement.el opens room buffers on-demand, improving performance
by not having to insert events into buffers for rooms the user
isn’t watching.
• ‘matrix-client’ was developed without the intention of publishing
it to, e.g. MELPA or ELPA. It has several dependencies, and its
code does not always install or compile cleanly due to
macro-expansion issues (apparently depending on the user’s Emacs
config). Ement.el is designed to have minimal dependencies outside
of Emacs (currently only one, ‘plz’, which could be imported into
the project), and every file is linted and compiles cleanly using
makem.sh (https://github.com/alphapapa/makem.sh).
• ‘matrix-client’ uses EIEIO, probably unnecessarily, since few, if
any, of the benefits of EIEIO are realized in it. Ement.el uses
structs instead.
• ‘matrix-client’ uses bespoke code for inserting messages into
buffers, which works pretty well, but has a few minor bugs which
are difficult to track down. Ement.el uses Emacs’s built-in (and
perhaps little-known) ‘ewoc’ library, which makes it much simpler
and more reliable to insert and update messages in buffers, and
enables the development of advanced UI features more easily.
• ‘matrix-client’ was, to a certain extent, designed to imitate other
messaging apps. The result is, at least when used with the
‘matrix-client-frame’ command, fairly pleasing to use, but isn’t
especially "Emacsy." Ement.el is intended to better fit into
Emacs’s paradigms.
• ‘matrix-client’’s long name makes for long symbol names, which
makes for tedious, verbose code. ‘ement’ is easy to type and makes
for concise, readable code.
• The author has learned much since writing ‘matrix-client’ and hopes
to write simpler, more readable, more maintainable code in
Ement.el. It’s hoped that this will enable others to contribute
more easily.
Note that, while ‘matrix-client’ remains usable, and probably will
for some time to come, Ement.el has now surpassed it in every way. The
only reason to choose ‘matrix-client’ instead is if one is using an
older version of Emacs that isn’t supported by Ement.el.
File: docClp2QW.info, Node: License, Prev: Development, Up: Top
5 License
*********
GPLv3
Tag Table:
Node: Top188
Node: Installation1406
Node: GNU ELPA1645
Node: GNU Guix2170
Node: Debian Ubuntu2417
Node: Nix2766
Node: Other distributions3088
Node: Git master3366
Node: Manual4423
Node: Usage4711
Node: Bindings7347
Node: Room buffers7811
Node: Room list buffer10239
Node: Directory buffers10635
Node: Mentions/notifications buffers10894
Node: Tips11276
Node: Displaying symbols and emojis13694
Node: Encrypted room support through Pantalaimon14309
Node: Changelog14865
Node: 01215342
Node: 01118088
Node: 01020187
Node: 09323575
Node: 09223798
Node: 09123983
Node: 0924145
Node: 08325813
Node: 08226441
Node: 08126586
Node: 0826718
Node: 0728344
Node: 0629520
Node: 05230317
Node: 05130530
Node: 0530723
Node: 04131565
Node: 0431769
Node: 03133281
Node: 0333496
Node: 02134720
Node: 0234852
Node: 01436481
Node: 01336616
Node: 012 (1)36905
Node: 011 (1)37317
Node: 0137620
Node: Development37785
Node: Copyright Assignment38038
Node: Matrix spec in Org format38516
Node: Rationale38830
Node: License42414
End Tag Table
Local Variables:
coding: utf-8
End:
;;; ement.el --- Matrix client -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; URL: https://github.com/alphapapa/ement.el
;; Version: 0.12
;; Package-Requires: ((emacs "27.1") (map "2.1") (persist "0.5") (plz "0.6") (taxy "0.10") (taxy-magit-section "0.12.1") (svg-lib "0.2.5") (transient "0.3.7"))
;; Keywords: comm
;; 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:
;; Another Matrix client! This one is written from scratch and is
;; intended to be more "Emacsy," more suitable for MELPA, etc. Also
;; it has a shorter, perhaps catchier name, that is a mildly clever
;; play on the name of the official Matrix client and the Emacs Lisp
;; filename extension (oops, I explained the joke), which makes for
;; much shorter symbol names.
;; This file implements the core client library. Functions that may be called in multiple
;; files belong in `ement-lib'.
;;; Code:
;;;; Debugging
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (require 'warnings)
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
;;;; Requirements
;; Built in.
(require 'cl-lib)
(require 'dns)
(require 'files)
(require 'map)
;; This package.
(require 'ement-lib)
(require 'ement-room)
(require 'ement-notifications)
(require 'ement-notify)
;;;; Variables
(defvar ement-sessions nil
"Alist of active `ement-session' sessions, keyed by MXID.")
(defvar ement-syncs nil
"Alist of outstanding sync processes for each session.")
(defvar ement-users (make-hash-table :test #'equal)
;; NOTE: When changing the ement-user struct, it's necessary to
;; reset this table to clear old-type structs.
"Hash table storing user structs keyed on user ID.")
(defvar ement-progress-reporter nil
"Used to report progress while processing sync events.")
(defvar ement-progress-value nil
"Used to report progress while processing sync events.")
(defvar ement-sync-callback-hook
'(ement--update-room-buffers ement--auto-sync ement-tabulated-room-list-auto-update
ement-room-list-auto-update)
"Hook run after `ement--sync-callback'.
Hooks are called with one argument, the session that was
synced.")
(defvar ement-event-hook
'(ement-notify ement--process-event ement--put-event)
"Hook called for events.
Each function is called with three arguments: the event, the
room, and the session. This hook isn't intended to be modified
by users; ones who do so should know what they're doing.")
(defvar ement-default-sync-filter
'((room (state (lazy_load_members . t))
(timeline (lazy_load_members . t))))
"Default filter for sync requests.")
(defvar ement-images-queue (make-plz-queue :limit 5)
"`plz' HTTP request queue for image requests.")
(defvar ement-read-receipt-idle-timer nil
"Idle timer used to update read receipts.")
(defvar ement-connect-user-id-history nil
"History list of user IDs entered into `ement-connect'.")
;; From other files.
(defvar ement-room-avatar-max-width)
(defvar ement-room-avatar-max-height)
;;;; Customization
(defgroup ement nil
"Options for Ement, the Matrix client."
:group 'comm)
(defcustom ement-save-sessions nil
"Save session to disk.
Writes the session file when Emacs is killed."
:type 'boolean
:set (lambda (option value)
(set-default option value)
(if value
(add-hook 'kill-emacs-hook #'ement--kill-emacs-hook)
(remove-hook 'kill-emacs-hook #'ement--kill-emacs-hook))))
(defcustom ement-sessions-file "~/.cache/ement.el"
;; FIXME: Expand correct XDG cache directory (new in Emacs 27).
"Save username and access token to this file."
:type 'file)
(defcustom ement-auto-sync t
"Automatically sync again after syncing."
:type 'boolean)
(defcustom ement-after-initial-sync-hook
'(ement-room-list--after-initial-sync ement-view-initial-rooms ement--link-children ement--run-idle-timer)
"Hook run after initial sync.
Run with one argument, the session synced."
:type 'hook)
(defcustom ement-initial-sync-timeout 40
"Timeout in seconds for initial sync requests.
For accounts in many rooms, the Matrix server may take some time
to prepare the initial sync response, and increasing this timeout
might be necessary."
:type 'integer)
(defcustom ement-auto-view-rooms nil
"Rooms to view after initial sync.
Alist mapping user IDs to a list of room aliases/IDs to open buffers for."
:type '(alist :key-type (string :tag "Local user ID")
:value-type (repeat (string :tag "Room alias/ID"))))
(defcustom ement-disconnect-hook '(ement-kill-buffers ement--stop-idle-timer)
;; FIXME: Put private functions in a private hook.
"Functions called when disconnecting.
That is, when calling command `ement-disconnect'. Functions are
called with no arguments."
:type 'hook)
(defcustom ement-view-room-display-buffer-action '(display-buffer-same-window)
"Display buffer action to use when opening room buffers.
See function `display-buffer' and info node `(elisp) Buffer
Display Action Functions'."
:type 'function)
(defcustom ement-auto-view-room-display-buffer-action '(display-buffer-no-window)
"Display buffer action to use when automatically opening room buffers.
That is, rooms listed in `ement-auto-view-rooms', which see. See
function `display-buffer' and info node `(elisp) Buffer Display
Action Functions'."
:type 'function)
(defcustom ement-interrupted-sync-hook '(ement-interrupted-sync-warning)
"Functions to call when syncing of a session is interrupted.
Only called when `ement-auto-sync' is non-nil. Functions are
called with one argument, the session whose sync was interrupted.
This hook allows the user to customize how sync interruptions are
handled (e.g. how to be notified)."
:type 'hook
:options '(ement-interrupted-sync-message ement-interrupted-sync-warning))
(defcustom ement-sso-server-port 4567
"TCP port used for local HTTP server for SSO logins.
It shouldn't usually be necessary to change this."
:type 'integer)
;;;; Commands
;;;###autoload
(cl-defun ement-connect (&key user-id password uri-prefix session)
"Connect to Matrix with USER-ID and PASSWORD, or using SESSION.
Interactively, with prefix, ignore a saved session and log in
again; otherwise, use a saved session if `ement-save-sessions' is
enabled and a saved session is available, or prompt to log in if
not enabled or available.
If USERID or PASSWORD are not specified, the user will be
prompted for them.
If URI-PREFIX is specified, it should be the prefix of the
server's API URI, including protocol, hostname, and optionally
the port, e.g.
\"https://matrix-client.matrix.org\"
\"http://localhost:8080\""
(interactive (if current-prefix-arg
;; Force new session.
(list :user-id (read-string "User ID: " nil 'ement-connect-user-id-history))
;; Use known session.
(unless ement-sessions
;; Read sessions from disk.
(condition-case err
(setf ement-sessions (ement--read-sessions))
(error (display-warning 'ement (format "Unable to read session data from disk (%s). Prompting to log in again."
(error-message-string err))))))
(cl-case (length ement-sessions)
(0 (list :user-id (read-string "User ID: " nil 'ement-connect-user-id-history)))
(1 (list :session (cdar ement-sessions)))
(otherwise (list :session (ement-complete-session))))))
(let (sso-server-process)
(cl-labels ((new-session ()
(unless (string-match (rx bos "@" (group (1+ (not (any ":")))) ; Username
":" (group (optional (1+ (not (any blank)))))) ; Server name
user-id)
(user-error "Invalid user ID format: use @USERNAME:SERVER"))
(let* ((username (match-string 1 user-id))
(server-name (match-string 2 user-id))
(uri-prefix (or uri-prefix (ement--hostname-uri server-name)))
(user (make-ement-user :id user-id :username username))
(server (make-ement-server :name server-name :uri-prefix uri-prefix))
(transaction-id (ement--initial-transaction-id))
(initial-device-display-name (format "Ement.el: %s@%s"
;; Just to be extra careful:
(or user-login-name "[unknown user-login-name]")
(or (system-name) "[unknown system-name]")))
(device-id (secure-hash 'sha256 initial-device-display-name)))
(make-ement-session :user user :server server :transaction-id transaction-id
:device-id device-id :initial-device-display-name initial-device-display-name
:events (make-hash-table :test #'equal))))
(password-login ()
(pcase-let* (((cl-struct ement-session user device-id initial-device-display-name) session)
((cl-struct ement-user id) user)
(data (ement-alist "type" "m.login.password"
"identifier"
(ement-alist "type" "m.id.user"
"user" id)
"password" (or password
(read-passwd (format "Password for %s: " id)))
"device_id" device-id
"initial_device_display_name" initial-device-display-name)))
;; TODO: Clear password in callback (if we decide to hold on to it for retrying login timeouts).
(ement-api session "login" :method 'post :data (json-encode data)
:then (apply-partially #'ement--login-callback session))
(ement-message "Logging in with password...")))
(sso-filter (process string)
;; NOTE: This is technically wrong, because it's not guaranteed that the
;; string will be a complete request--it could just be a chunk. But in
;; practice, if this works, it's much simpler than setting up process log
;; functions and per-client buffers for this throwaway, pretend HTTP server.
(when (string-match (rx "GET /?loginToken=" (group (0+ nonl)) " " (0+ nonl)) string)
(unwind-protect
(pcase-let* ((token (match-string 1 string))
((cl-struct ement-session user device-id initial-device-display-name)
session)
((cl-struct ement-user id) user)
(data (ement-alist
"type" "m.login.token"
"identifier" (ement-alist "type" "m.id.user"
"user" id)
"token" token
"device_id" device-id
"initial_device_display_name" initial-device-display-name)))
(ement-api session "login" :method 'post
:data (json-encode data)
:then (apply-partially #'ement--login-callback session))
(process-send-string process "HTTP/1.0 202 Accepted
Content-Type: text/plain; charset=utf-8
Ement: SSO login accepted; session token received. Connecting to Matrix server. (You may close this page.)")
(process-send-eof process))
(delete-process sso-server-process)
(delete-process process))))
(sso-login ()
(setf sso-server-process
(make-network-process
:name "ement-sso" :family 'ipv4 :host 'local :service ement-sso-server-port
:filter #'sso-filter :server t :noquery t))
;; Kill server after 2 minutes in case of problems.
(run-at-time 120 nil (lambda ()
(when (process-live-p sso-server-process)
(delete-process sso-server-process))))
(let ((url (concat (ement-server-uri-prefix (ement-session-server session))
"/_matrix/client/r0/login/sso/redirect?redirectUrl=http://localhost:"
(number-to-string ement-sso-server-port))))
(funcall browse-url-secondary-browser-function url)
(message "Browsing to single sign-on page <%s>..." url)))
(flows-callback (data)
(let ((flows (cl-loop for flow across (map-elt data 'flows)
for type = (map-elt flow 'type)
when (member type '("m.login.password" "m.login.sso"))
collect type)))
(pcase (length flows)
(0 (error "Ement: No supported login flows: Server:%S Supported flows:%S"
(ement-server-uri-prefix (ement-session-server session))
(map-elt data 'flows)))
(1 (pcase (car flows)
("m.login.password" (password-login))
("m.login.sso" (sso-login))
(_ (error "Ement: Unsupported login flow: %s Server:%S Supported flows:%S"
(car flows) (ement-server-uri-prefix (ement-session-server session))
(map-elt data 'flows)))))
(_ (pcase (completing-read "Select authentication method: "
(cl-loop for flow in flows
collect (string-trim-left flow (rx "m.login."))))
("password" (password-login))
("sso" (sso-login))
(else (error "Ement: Unsupported login flow:%S Server:%S Supported flows:%S"
else (ement-server-uri-prefix (ement-session-server session))
(map-elt data 'flows)))))))))
(if session
;; Start syncing given session.
(let ((user-id (ement-user-id (ement-session-user session))))
;; HACK: If session is already in ement-sessions, this replaces it. I think that's okay...
(setf (alist-get user-id ement-sessions nil nil #'equal) session)
(ement--sync session :timeout ement-initial-sync-timeout))
;; Start password login flow. Prompt for user ID and password
;; if not given (i.e. if not called interactively.)
(unless user-id
(setf user-id (read-string "User ID: " nil 'ement-connect-user-id-history)))
(setf session (new-session))
(when (ement-api session "login" :then #'flows-callback)
(message "Ement: Checking server's login flows..."))))))
(defun ement-disconnect (sessions)
"Disconnect from SESSIONS.
Interactively, with prefix, disconnect from all sessions. If
`ement-auto-sync' is enabled, stop syncing, and clear the session
data. When enabled, write the session to disk. Any existing
room buffers are left alive and can be read, but other commands
in them won't work."
(interactive (list (if current-prefix-arg
(mapcar #'cdr ement-sessions)
(list (ement-complete-session)))))
(when ement-save-sessions
;; Write sessions before we remove them from the variable.
(ement--write-sessions ement-sessions))
(dolist (session sessions)
(let ((user-id (ement-user-id (ement-session-user session))))
(when-let ((process (map-elt ement-syncs session)))
(ignore-errors
(delete-process process)))
;; NOTE: I'd like to use `map-elt' here, but not until
;; <https://debbugs.gnu.org/cgi/bugreport.cgi?bug=47368> is fixed, I guess.
(setf (alist-get session ement-syncs nil nil #'equal) nil
(alist-get user-id ement-sessions nil 'remove #'equal) nil)))
(unless ement-sessions
;; HACK: If no sessions remain, clear the users table. It might be best
;; to store a per-session users table, but this is probably good enough.
(clrhash ement-users))
(run-hooks 'ement-disconnect-hook)
(message "Ement: Disconnected (%s)"
(string-join (cl-loop for session in sessions
collect (ement-user-id (ement-session-user session)))
", ")))
(defun ement-kill-buffers ()
"Kill all Ement buffers.
Useful in, e.g. `ement-disconnect-hook', which see."
(interactive)
(dolist (buffer (buffer-list))
(when (string-prefix-p "ement-" (symbol-name (buffer-local-value 'major-mode buffer)))
(kill-buffer buffer))))
(defun ement--login-callback (session data)
"Record DATA from logging in to SESSION and do initial sync."
(pcase-let* (((cl-struct ement-session (user (cl-struct ement-user (id user-id)))) session)
((map ('access_token token) ('device_id device-id)) data))
(setf (ement-session-token session) token
(ement-session-device-id session) device-id
(alist-get user-id ement-sessions nil nil #'equal) session)
(ement--sync session :timeout ement-initial-sync-timeout)))
;;;; Functions
(defun ement-interrupted-sync-warning (session)
"Display a warning that syncing of SESSION was interrupted."
(display-warning
'ement
(format
(substitute-command-keys
"\\<ement-room-mode-map>Syncing of session <%s> was interrupted. Use command `ement-room-sync' in a room buffer to retry.")
(ement-user-id (ement-session-user session)))
:error))
(defun ement-interrupted-sync-message (session)
"Display a message that syncing of SESSION was interrupted."
(message
(substitute-command-keys
"\\<ement-room-mode-map>Syncing of session <%s> was interrupted. Use command `ement-room-sync' in a room buffer to retry.")
(ement-user-id (ement-session-user session))))
(defun ement--run-idle-timer (&rest _ignore)
"Run idle timer that updates read receipts.
To be called from `ement-after-initial-sync-hook'. Timer is
stored in `ement-read-receipt-idle-timer'."
(unless (timerp ement-read-receipt-idle-timer)
(setf ement-read-receipt-idle-timer (run-with-idle-timer 3 t #'ement-room-read-receipt-idle-timer))))
(defun ement--stop-idle-timer (&rest _ignore)
"Stop idle timer stored in `ement-read-receipt-idle-timer'.
To be called from `ement-disconnect-hook'."
(unless ement-sessions
(when (timerp ement-read-receipt-idle-timer)
(cancel-timer ement-read-receipt-idle-timer)
(setf ement-read-receipt-idle-timer nil))))
(defun ement-view-initial-rooms (session)
"View rooms for SESSION configured in `ement-auto-view-rooms'."
(when-let (rooms (alist-get (ement-user-id (ement-session-user session))
ement-auto-view-rooms nil nil #'equal))
(dolist (alias/id rooms)
(when-let (room (cl-find-if (lambda (room)
(or (equal alias/id (ement-room-canonical-alias room))
(equal alias/id (ement-room-id room))))
(ement-session-rooms session)))
(let ((ement-view-room-display-buffer-action ement-auto-view-room-display-buffer-action))
(ement-view-room room session))))))
(defun ement--initial-transaction-id ()
"Return an initial transaction ID for a new session."
;; We generate a somewhat-random initial transaction ID to avoid potential conflicts in
;; case, e.g. using Pantalaimon causes a transaction ID conflict. See
;; <https://github.com/alphapapa/ement.el/issues/36>.
(cl-parse-integer
(secure-hash 'sha256 (prin1-to-string (list (current-time) (system-name))))
:end 8 :radix 16))
(defsubst ement--sync-messages-p (session)
"Return non-nil if sync-related messages should be shown for SESSION."
;; For now, this seems like the best way.
(or (not (ement-session-has-synced-p session))
(not ement-auto-sync)))
(defun ement--hostname-uri (hostname)
"Return the \".well-known\" URI for server HOSTNAME.
If no URI is found, prompt the user for the hostname."
;; FIXME: When fail-prompting, a URI should be returned, not just a hostname.
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#id178> ("4.1 Well-known URI")
(cl-labels ((fail-prompt ()
(let ((input (read-string "Auto-discovery of server's well-known URI failed. Input server hostname, or leave blank to use server name: ")))
(pcase input
("" hostname)
(_ input))))
(parse (string)
(if-let* ((object (ignore-errors (json-read-from-string string)))
(url (map-nested-elt object '(m.homeserver base_url)))
((string-match-p
(rx bos "http" (optional "s") "://" (1+ nonl))
url)))
url
;; Parsing error: FAIL_PROMPT.
(fail-prompt))))
(condition-case err
(let ((response (plz 'get (concat "https://" hostname "/.well-known/matrix/client")
:as 'response :then 'sync)))
(if (plz-response-p response)
(pcase (plz-response-status response)
(200 (parse (plz-response-body response)))
(404 (fail-prompt))
(_ (warn "Ement: `plz' request for .well-known URI returned unexpected code: %s"
(plz-response-status response))
(fail-prompt)))
(warn "Ement: `plz' request for .well-known URI did not return a `plz' response")
(fail-prompt)))
(error (warn "Ement: `plz' request for .well-known URI signaled an error: %S" err)
(fail-prompt)))))
(cl-defun ement--sync (session &key force quiet
(timeout 40) ;; Give the server an extra 10 seconds.
(filter ement-default-sync-filter))
"Send sync request for SESSION.
If SESSION has a `next-batch' token, it's used. If FORCE, first
delete any outstanding sync processes. If QUIET, don't show a
message about syncing this time. Cancel request after TIMEOUT
seconds.
FILTER may be an alist representing a raw event filter (i.e. not
a filter ID). When unspecified, the value of
`ement-default-sync-filter' is used. The filter is encoded with
`json-encode'. To use no filter, specify FILTER as nil."
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#id257>.
;; TODO: Filtering: <https://matrix.org/docs/spec/client_server/r0.6.1#filtering>.
;; TODO: Use a filter ID for default filter.
;; TODO: Optionally, automatically sync again when HTTP request fails.
;; TODO: Ensure that the process in (map-elt ement-syncs session) is live.
(when (map-elt ement-syncs session)
(if force
(condition-case err
(delete-process (map-elt ement-syncs session))
;; Ensure the only error is the expected one from deleting the process.
(ement-api-error (cl-assert (equal "curl process killed" (plz-error-message (cl-third err))))
(message "Ement: Forcing new sync")))
(user-error "Ement: Already syncing this session")))
(pcase-let* (((cl-struct ement-session next-batch) session)
(params (remove
nil (list (list "full_state" (if next-batch "false" "true"))
(when filter
;; TODO: Document filter arg.
(list "filter" (json-encode filter)))
(when next-batch
(list "since" next-batch))
(when next-batch
(list "timeout" "30000")))))
(sync-start-time (time-to-seconds))
;; FIXME: Auto-sync again in error handler.
(process (ement-api session "sync" :params params
:timeout timeout
:then (apply-partially #'ement--sync-callback session)
:else (lambda (plz-error)
(setf (map-elt ement-syncs session) nil)
;; TODO: plz probably needs nicer error handling.
;; Ideally we would use `condition-case', but since the
;; error is signaled in `plz--sentinel'...
(pcase-let (((cl-struct plz-error curl-error response) plz-error)
(reason))
(cond ((when response
(pcase (plz-response-status response)
((or 429 502) (setf reason "failed")))))
((pcase curl-error
(`(28 . ,_) (setf reason "timed out")))))
(if reason
(if (not ement-auto-sync)
(run-hook-with-args 'ement-interrupted-sync-hook session)
(message "Ement: Sync %s (%s). Syncing again..."
reason (ement-user-id (ement-session-user session)))
;; Set QUIET to allow the just-printed message to remain visible.
(ement--sync session :timeout timeout :quiet t))
;; Unrecognized errors:
(pcase curl-error
(`(,code . ,message)
(signal 'ement-api-error (list (format "Ement: Network error: %s: %s" code message)
plz-error)))
(_ (signal 'ement-api-error (list "Ement: Unrecognized network error" plz-error)))))))
:json-read-fn (lambda ()
"Print a message, then call `ement--json-parse-buffer'."
(when (ement--sync-messages-p session)
(message "Ement: Response arrived after %.2f seconds. Reading %s JSON response..."
(- (time-to-seconds) sync-start-time)
(file-size-human-readable (buffer-size))))
(let ((start-time (time-to-seconds)))
(prog1 (ement--json-parse-buffer)
(when (ement--sync-messages-p session)
(message "Ement: Reading JSON took %.2f seconds"
(- (time-to-seconds) start-time)))))))))
(when process
(setf (map-elt ement-syncs session) process)
(when (and (not quiet) (ement--sync-messages-p session))
(ement-message "Sync request sent. Waiting for response...")))))
(defun ement--sync-callback (session data)
"Process sync DATA for SESSION.
Runs `ement-sync-callback-hook' with SESSION."
;; Remove the sync first. We already have the data from it, and the
;; process has exited, so it's safe to run another one.
(setf (map-elt ement-syncs session) nil)
(pcase-let* (((map rooms ('next_batch next-batch) ('account_data (map ('events account-data-events))))
data)
((map ('join joined-rooms) ('invite invited-rooms) ('leave left-rooms)) rooms)
(num-events (+
;; HACK: In `ement--push-joined-room-events', we do something
;; with each event 3 times, so we multiply this by 3.
;; FIXME: That calculation doesn't seem to be quite right, because
;; the progress reporter never seems to hit 100% before it's done.
(* 3 (cl-loop for (_id . room) in joined-rooms
sum (length (map-nested-elt room '(state events)))
sum (length (map-nested-elt room '(timeline events)))))
(cl-loop for (_id . room) in invited-rooms
sum (length (map-nested-elt room '(invite_state events)))))))
;; Append account data events.
;; TODO: Since only one event of each type is allowed in account data (the spec
;; doesn't seem to make this clear, but see
;; <https://github.com/matrix-org/matrix-js-sdk/blob/d0b964837f2820940bd93e718a2450b5f528bffc/src/store/memory.ts#L292>),
;; we should store account-data events in a hash table or alist rather than just a
;; list of events.
(cl-callf2 append (cl-coerce account-data-events 'list) (ement-session-account-data session))
;; Process invited and joined rooms.
(ement-with-progress-reporter (:when (ement--sync-messages-p session)
:reporter ("Ement: Reading events..." 0 num-events))
;; Left rooms.
(mapc (apply-partially #'ement--push-left-room-events session) left-rooms)
;; Invited rooms.
(mapc (apply-partially #'ement--push-invite-room-events session) invited-rooms)
;; Joined rooms.
(mapc (apply-partially #'ement--push-joined-room-events session) joined-rooms))
;; TODO: Process "left" rooms (remove room structs, etc).
;; NOTE: We update the next-batch token before updating any room buffers. This means
;; that any errors in updating room buffers (like for unexpected event formats that
;; expose a bug) could cause events to not appear in the buffer, but the user could
;; still dismiss the error and start syncing again, and the client could remain
;; usable. Updating the token after doing everything would be preferable in some
;; ways, but it would mean that an event that exposes a bug would be processed again
;; on every sync, causing the same error each time. It would seem preferable to
;; maintain at least some usability rather than to keep repeating a broken behavior.
(setf (ement-session-next-batch session) next-batch)
;; Run hooks which update buffers, etc.
(run-hook-with-args 'ement-sync-callback-hook session)
;; Show sync message if appropriate, and run after-initial-sync-hook.
(when (ement--sync-messages-p session)
(message (concat "Ement: Sync done."
(unless (ement-session-has-synced-p session)
(run-hook-with-args 'ement-after-initial-sync-hook session)
;; Show tip after initial sync.
(setf (ement-session-has-synced-p session) t)
" Use commands `ement-list-rooms' or `ement-view-room' to view a room."))))))
(defun ement--push-invite-room-events (session invited-room)
"Push events for INVITED-ROOM into that room in SESSION."
;; TODO: Make ement-session-rooms a hash-table.
(ement--push-joined-room-events session invited-room 'invite))
(defun ement--auto-sync (session)
"If `ement-auto-sync' is non-nil, sync SESSION again."
(when ement-auto-sync
(ement--sync session)))
(defun ement--update-room-buffers (session)
"Insert new events into SESSION's rooms which have buffers.
To be called in `ement-sync-callback-hook'."
;; TODO: Move this to ement-room.el, probably.
;; For now, we primitively iterate over the buffer list to find ones
;; whose mode is `ement-room-mode'.
(let* ((buffers (cl-loop for room in (ement-session-rooms session)
for buffer = (map-elt (ement-room-local room) 'buffer)
when (buffer-live-p buffer)
collect buffer)))
(dolist (buffer buffers)
(with-current-buffer buffer
(save-window-excursion
;; NOTE: When the buffer has a window, it must be the selected one
;; while calling event-insertion functions. I don't know if this is
;; due to a bug in EWOC or if I just misunderstand something, but
;; without doing this, events may be inserted at the wrong place.
(when-let ((buffer-window (get-buffer-window buffer)))
(select-window buffer-window))
(cl-assert ement-room)
(when (ement-room-ephemeral ement-room)
;; Ephemeral events.
(ement-room--process-events (ement-room-ephemeral ement-room))
(setf (ement-room-ephemeral ement-room) nil))
(when-let ((new-events (alist-get 'new-events (ement-room-local ement-room))))
;; HACK: Process these events in reverse order, so that later events (like reactions)
;; which refer to earlier events can find them. (Not sure if still necessary.)
(ement-room--process-events (reverse new-events))
(setf (alist-get 'new-events (ement-room-local ement-room)) nil))
(when-let ((new-events (alist-get 'new-account-data-events (ement-room-local ement-room))))
;; Account data events. Do this last so, e.g. read markers can refer to message events we've seen.
(ement-room--process-events new-events)
(setf (alist-get 'new-account-data-events (ement-room-local ement-room)) nil)))))))
(cl-defun ement--push-joined-room-events (session joined-room &optional (status 'join))
"Push events for JOINED-ROOM into that room in SESSION.
Also used for left rooms, in which case STATUS should be set to
`leave'."
(pcase-let* ((`(,id . ,event-types) joined-room)
(id (symbol-name id)) ; Really important that the ID is a STRING!
;; TODO: Make ement-session-rooms a hash-table.
(room (or (cl-find-if (lambda (room)
(equal id (ement-room-id room)))
(ement-session-rooms session))
(car (push (make-ement-room :id id) (ement-session-rooms session)))))
((map summary state ephemeral timeline
('invite_state (map ('events invite-state-events)))
('account_data (map ('events account-data-events)))
('unread_notifications unread-notifications))
event-types)
(latest-timestamp))
(setf (ement-room-status room) status
(ement-room-unread-notifications room) unread-notifications)
;; NOTE: The idea is that, assuming that events in the sync reponse are in
;; chronological order, we push them to the lists in the room slots in that order,
;; leaving the head of each list as the most recent event of that type. That means
;; that, e.g. the room state events may be searched in order to find, e.g. the most
;; recent room name event. However, chronological order is not guaranteed, e.g. after
;; loading older messages (the "retro" function; this behavior is in development).
;; MAYBE: Use queue.el to store the events in a DLL, so they could
;; be accessed from either end. Could be useful.
;; Push the StrippedState events to the room's invite-state. (These events have no
;; timestamp data.) We also run the event hook, because for invited rooms, the
;; invite-state events include room name, topic, etc.
(cl-loop for event across-ref invite-state-events do
(setf event (ement--make-event event))
(push event (ement-room-invite-state room))
(run-hook-with-args 'ement-event-hook event room session))
;; Save room summary.
(dolist (parameter '(m.heroes m.joined_member_count m.invited_member_count))
(when (alist-get parameter summary)
;; These fields are only included when they change.
(setf (alist-get parameter (ement-room-summary room)) (alist-get parameter summary))))
;; Update account data. According to the spec, only one of each event type is
;; supposed to be present in a room's account data, so we store them as an alist keyed
;; on their type. (NOTE: We don't currently make them into event structs, but maybe
;; we should in the future.)
(cl-loop for event across account-data-events
for type = (alist-get 'type event)
do (setf (alist-get type (ement-room-account-data room) nil nil #'equal) event))
;; But we also need to track just the new events so we can process those in a room
;; buffer (and for some reason, we do make them into structs here, but I don't
;; remember why). FIXME: Unify this.
(cl-callf2 append (mapcar #'ement--make-event account-data-events)
(alist-get 'new-account-data-events (ement-room-local room)))
;; Save state and timeline events.
(cl-macrolet ((push-events (type accessor)
;; Push new events of TYPE to room's slot of ACCESSOR, and return the latest timestamp pushed.
`(let ((ts 0))
;; NOTE: We replace each event in the vector with the
;; struct, which is used when calling hooks later.
(cl-loop for event across-ref (alist-get 'events ,type)
do (setf event (ement--make-event event))
do (push event (,accessor room))
(when (ement--sync-messages-p session)
(ement-progress-update))
(when (> (ement-event-origin-server-ts event) ts)
(setf ts (ement-event-origin-server-ts event))))
;; One would think that one should use `maximizing' here, but, completely
;; inexplicably, it sometimes returns nil, even when every single value it's comparing
;; is a number. It's absolutely bizarre, but I have to do the equivalent manually.
ts)))
;; FIXME: This is a bit convoluted and hacky now. Refactor it.
(setf latest-timestamp
(max (push-events state ement-room-state)
(push-events timeline ement-room-timeline)))
;; NOTE: We also append the new events to the new-events list in the room's local
;; slot, which is used by `ement--update-room-buffers' to insert only new events.
;; FIXME: Does this also need to be done for invite-state events?
(cl-callf2 append (cl-coerce (alist-get 'events timeline) 'list)
(alist-get 'new-events (ement-room-local room)))
;; Update room's latest-timestamp slot.
(when (> latest-timestamp (or (ement-room-latest-ts room) 0))
(setf (ement-room-latest-ts room) latest-timestamp))
(unless (ement-session-has-synced-p session)
;; Only set this token on initial sync, otherwise it would
;; overwrite earlier tokens from loading earlier messages.
(setf (ement-room-prev-batch room) (alist-get 'prev_batch timeline))))
;; Run event hook for state and timeline events.
(cl-loop for event across (alist-get 'events state)
do (run-hook-with-args 'ement-event-hook event room session)
(when (ement--sync-messages-p session)
(ement-progress-update)))
(cl-loop for event across (alist-get 'events timeline)
do (run-hook-with-args 'ement-event-hook event room session)
(when (ement--sync-messages-p session)
(ement-progress-update)))
;; Ephemeral events (do this after state and timeline hooks, so those events will be
;; in the hash tables).
(cl-loop for event across (alist-get 'events ephemeral)
for event-struct = (ement--make-event event)
do (push event-struct (ement-room-ephemeral room))
(ement--process-event event-struct room session))
(when (ement-session-has-synced-p session)
;; NOTE: We don't fill gaps in "limited" requests on initial
;; sync, only in subsequent syncs, e.g. after the system has
;; slept and awakened.
;; NOTE: When not limited, the read value is `:json-false', so
;; we must explicitly compare to t.
(when (eq t (alist-get 'limited timeline))
;; Timeline was limited: start filling gap. We start the
;; gap-filling, retrieving up to the session's current
;; next-batch token (this function is not called when retrieving
;; older messages, so the session's next-batch token is only
;; evaluated once, when this chain begins, and then that token
;; is passed to repeated calls to `ement-room-retro-to-token'
;; until the gap is filled).
(ement-room-retro-to-token room session (alist-get 'prev_batch timeline)
(ement-session-next-batch session))))))
(defun ement--push-left-room-events (session left-room)
"Push events for LEFT-ROOM into that room in SESSION."
(ement--push-joined-room-events session left-room 'leave))
(defun ement--make-event (event)
"Return `ement-event' struct for raw EVENT list.
Adds sender to `ement-users' when necessary."
(pcase-let* (((map content type unsigned redacts
('event_id id) ('origin_server_ts ts)
('sender sender-id) ('state_key state-key))
event)
(sender (or (gethash sender-id ement-users)
(puthash sender-id (make-ement-user :id sender-id)
ement-users))))
;; MAYBE: Handle other keys in the event, such as "room_id" in "invite" events.
(make-ement-event :id id :sender sender :type type :content content :state-key state-key
:origin-server-ts ts :unsigned unsigned
;; Since very few events will be redactions and have this key, we
;; record it in the local slot alist rather than as another slot on
;; the struct.
:local (when redacts
(ement-alist 'redacts redacts)))))
(defun ement--put-event (event _room session)
"Put EVENT on SESSION's events table."
(puthash (ement-event-id event) event (ement-session-events session)))
;; FIXME: These functions probably need to compare timestamps to
;; ensure that older events that are inserted at the head of the
;; events lists aren't used instead of newer ones.
;; TODO: These two functions should be folded into event handlers.
;;;;; Reading/writing sessions
(defun ement--read-sessions ()
"Return saved sessions alist read from disk.
Returns nil if unable to read `ement-sessions-file'."
(cl-labels ((plist-to-session (plist)
(pcase-let* (((map (:user user-data) (:server server-data)
(:token token) (:transaction-id transaction-id))
plist)
(user (apply #'make-ement-user user-data))
(server (apply #'make-ement-server server-data))
(session (make-ement-session :user user :server server
:token token :transaction-id transaction-id)))
(setf (ement-session-events session) (make-hash-table :test #'equal))
session)))
(when (file-exists-p ement-sessions-file)
(pcase-let* ((read-circle t)
(sessions (with-temp-buffer
(insert-file-contents ement-sessions-file)
(read (current-buffer)))))
(prog1
(cl-loop for (id . plist) in sessions
collect (cons id (plist-to-session plist)))
(message "Ement: Read sessions."))))))
(defun ement--write-sessions (sessions-alist)
"Write SESSIONS-ALIST to disk."
;; We only record the slots we need. We record them as a plist
;; so that changes to the struct definition don't matter.
;; NOTE: If we ever persist more session data (like room data, so we
;; could avoid doing an initial sync next time), we should limit the
;; amount of session data saved (e.g. room history could grow
;; forever on-disk, which probably isn't what we want).
;; NOTE: This writes all current sessions, even if there are multiple active ones and only one
;; is being disconnected. That's probably okay, but it might be something to keep in mind.
(cl-labels ((session-plist (session)
(pcase-let* (((cl-struct ement-session user server token transaction-id) session)
((cl-struct ement-user (id user-id) username) user)
((cl-struct ement-server (name server-name) uri-prefix) server))
(list :user (list :id user-id
:username username)
:server (list :name server-name
:uri-prefix uri-prefix)
:token token
:transaction-id transaction-id))))
(message "Ement: Writing sessions...")
(with-temp-file ement-sessions-file
(pcase-let* ((print-level nil)
(print-length nil)
;; Very important to use `print-circle', although it doesn't
;; solve everything. Writing/reading Lisp data can be tricky...
(print-circle t)
(sessions-alist-plist (cl-loop for (id . session) in sessions-alist
collect (cons id (session-plist session)))))
(prin1 sessions-alist-plist (current-buffer))))
;; Ensure permissions are safe.
(chmod ement-sessions-file #o600)))
(defun ement--kill-emacs-hook ()
"Function to be added to `kill-emacs-hook'.
Writes Ement session to disk when enabled."
(ignore-errors
;; To avoid interfering with Emacs' exit, We must be careful that
;; this function handles errors, so just ignore any.
(when (and ement-save-sessions
ement-sessions)
(ement--write-sessions ement-sessions))))
;;;;; Event handlers
(defvar ement-event-handlers nil
"Alist mapping event types to functions which process an event of each type.
Each function is called with three arguments: the event, the
room, and the session. These handlers are run regardless of
whether a room has a live buffer.")
(defun ement--process-event (event room session)
"Process EVENT for ROOM in SESSION.
Uses handlers defined in `ement-event-handlers'. If no handler
is defined for EVENT's type, does nothing and returns nil. Any
errors signaled during processing are demoted in order to prevent
unexpected errors from arresting event processing and syncing."
(when-let ((handler (alist-get (ement-event-type event) ement-event-handlers nil nil #'equal)))
;; We demote any errors that happen while processing events, because it's possible for
;; events to be malformed in unexpected ways, and that could cause an error, which
;; would stop processing of other events and prevent further syncing. See,
;; e.g. <https://github.com/alphapapa/ement.el/pull/61>.
(with-demoted-errors "Ement (ement--process-event): Error processing event: %S"
(funcall handler event room session))))
(defmacro ement-defevent (type &rest body)
"Define an event handling function for events of TYPE, a string.
Around the BODY, the variable `event' is bound to the event being
processed, `room' to the room struct in which the event occurred,
and `session' to the session. Adds function to
`ement-event-handlers', which see."
(declare (indent defun))
`(setf (alist-get ,type ement-event-handlers nil nil #'string=)
(lambda (event room session)
,(concat "`ement-' handler function for " type " events.")
,@body)))
;; I love how Lisp macros make it so easy and concise to define these
;; event handlers!
(ement-defevent "m.room.avatar"
(when ement-room-avatars
;; If room avatars are disabled, we don't download avatars at all. This
;; means that, if a user has them disabled and then reenables them, they will
;; likely need to reconnect to cause them to be displayed in most rooms.
(if-let ((url (alist-get 'url (ement-event-content event))))
(plz-run
(plz-queue ement-images-queue
'get (ement--mxc-to-url url session) :as 'binary :noquery t
:then (lambda (data)
(when ement-room-avatars
;; MAYBE: Store the raw image data instead of using create-image here.
(let ((image (create-image data nil 'data-p
:ascent 'center
:max-width ement-room-avatar-max-width
:max-height ement-room-avatar-max-height)))
(if (not image)
(progn
(display-warning 'ement (format "Room avatar seems unreadable: ROOM-ID:%S AVATAR-URL:%S"
(ement-room-id room) (ement--mxc-to-url url session)))
(setf (ement-room-avatar room) nil
(alist-get 'room-list-avatar (ement-room-local room)) nil))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(setf (image-property image :type) 'imagemagick))
;; We set the room-avatar slot to a propertized string that
;; displays as the image. This seems the most convenient thing to
;; do. We also unset the cached room-list-avatar so it can be
;; remade.
(setf (ement-room-avatar room) (propertize " " 'display image)
(alist-get 'room-list-avatar (ement-room-local room)) nil)))))))
;; Unset avatar.
(setf (ement-room-avatar room) nil
(alist-get 'room-list-avatar (ement-room-local room)) nil))))
(ement-defevent "m.room.create"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map type))) event))
(when type
(setf (ement-room-type room) type))))
(ement-defevent "m.room.member"
"Put/update member on `ement-users' and room's members table."
(ignore session)
(pcase-let* (((cl-struct ement-room members) room)
((cl-struct ement-event state-key
(content (map displayname membership
('avatar_url avatar-url))))
event)
(user (or (gethash state-key ement-users)
(puthash state-key
(make-ement-user :id state-key :avatar-url avatar-url
;; NOTE: The spec doesn't seem to say whether the
;; displayname in the member event applies only to the
;; room or is for the user generally, so we'll save it
;; in the struct anyway.
:displayname displayname)
ement-users))))
(pcase membership
("join"
(puthash state-key user members)
(puthash user displayname (ement-room-displaynames room)))
(_ (remhash state-key members)
(remhash user (ement-room-displaynames room))))))
(ement-defevent "m.room.name"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map name))) event))
(when name
;; Recalculate room name and cache in slot.
(setf (ement-room-display-name room) (ement--room-display-name room)))))
(ement-defevent "m.room.topic"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map topic))) event))
(when topic
(setf (ement-room-topic room) topic))))
(ement-defevent "m.receipt"
(ignore session)
(pcase-let (((cl-struct ement-event content) event)
((cl-struct ement-room (receipts room-receipts)) room))
(cl-loop for (event-id . receipts) in content
do (cl-loop for (user-id . receipt) in (alist-get 'm.read receipts)
;; Users may not have been "seen" yet, so although we'd
;; prefer to key on the user struct, we key on the user ID.
;; Same for events, unfortunately.
;; NOTE: The JSON map keys are converted to symbols by `json-read'.
;; MAYBE: (Should we keep them that way? It would use less memory, I guess.)
do (puthash (symbol-name user-id)
(cons (symbol-name event-id) (alist-get 'ts receipt))
room-receipts)))))
(ement-defevent "m.space.child"
;; SPEC: v1.2/11.35.
(pcase-let* ((space-room room)
((cl-struct ement-session rooms) session)
((cl-struct ement-room (id parent-room-id)) space-room)
((cl-struct ement-event (state-key child-room-id) (content (map via))) event)
(child-room (cl-find child-room-id rooms :key #'ement-room-id :test #'equal)))
(if via
;; Child being declared: add it.
(progn
(cl-pushnew child-room-id (alist-get 'children (ement-room-local space-room)) :test #'equal)
(when child-room
;; The user is also in the child room: link the parent space-room in it.
;; FIXME: On initial sync, if the child room hasn't been processed yet, this will fail.
(cl-pushnew parent-room-id (alist-get 'parents (ement-room-local child-room)) :test #'equal)))
;; Child being disowned: remove it.
(setf (alist-get 'children (ement-room-local space-room))
(delete child-room-id (alist-get 'children (ement-room-local space-room))))
(when child-room
;; The user is also in the child room: unlink the parent space-room in it.
(setf (alist-get 'parents (ement-room-local child-room))
(delete parent-room-id (alist-get 'parents (ement-room-local child-room))))))))
(ement-defevent "m.room.canonical_alias"
(ignore session)
(pcase-let (((cl-struct ement-event (content (map alias))) event))
(setf (ement-room-canonical-alias room) alias)))
(defun ement--link-children (session)
"Link child rooms in SESSION.
To be called after initial sync."
;; On initial sync, when processing m.space.child events, the child rooms may not have
;; been processed yet, so we link them again here.
(pcase-let (((cl-struct ement-session rooms) session))
(dolist (room rooms)
(pcase-let (((cl-struct ement-room (id parent-id) (local (map children))) room))
(when children
(dolist (child-id children)
(when-let ((child-room (cl-find child-id rooms :key #'ement-room-id :test #'equal)))
(cl-pushnew parent-id (alist-get 'parents (ement-room-local child-room)) :test #'equal))))))))
;;;;; Savehist compatibility
;; See <https://github.com/alphapapa/ement.el/issues/216>.
(defvar savehist-save-hook)
(with-eval-after-load 'savehist
;; TODO: Consider using a symbol property on our commands and checking that rather than
;; symbol names; would avoid consing.
(defun ement--savehist-save-hook ()
"Remove all `ement-' commands from `command-history'.
Because when `savehist' saves `command-history', it includes the
interactive arguments passed to the command, which in our case
includes large data structures that should never be persisted!"
(setf command-history
(cl-remove-if (pcase-lambda (`(,command . ,_))
(string-match-p (rx bos "ement-") (symbol-name command)))
command-history)))
(cl-pushnew 'ement--savehist-save-hook savehist-save-hook))
;;;; Footer
(provide 'ement)
;;; ement.el ends here
;;; ement-tabulated-room-list.el --- Ement tabulated room list buffer -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;; This library implements a room list buffer with `tabulated-list-mode'.
;; NOTE: It doesn't appear that there is a way to get the number of
;; members in a room other than by retrieving the list of members and
;; counting them. For a large room (e.g. the Spacemacs Gitter room or
;; #debian:matrix.org), that means thousands of users, none of the
;; details of which we care about. So it seems impractical to know
;; the number of members when using lazy-loading. So I guess we just
;; won't show the number of members.
;; TODO: (Or maybe there is, see m.joined_member_count).
;; NOTE: The tabulated-list API is awkward here. When the
;; `tabulated-list-format' is changed, we have to make the change in 4
;; or 5 other places, and if one forgets to, bugs with non-obvious
;; causes happen. I think library using EIEIO or structs would be
;; very helpful.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'tabulated-list)
(require 'ement)
;;;; Variables
(declare-function ement-notify-switch-to-mentions-buffer "ement-notify")
(declare-function ement-notify-switch-to-notifications-buffer "ement-notify")
(defvar ement-tabulated-room-list-mode-map
(let ((map (make-sparse-keymap)))
;; (define-key map (kbd "g") #'tabulated-list-revert)
;; (define-key map (kbd "q") #'bury-buffer)
(define-key map (kbd "SPC") #'ement-tabulated-room-list-next-unread)
(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 "S") #'tabulated-list-sort)
map))
(defvar ement-tabulated-room-list-timestamp-colors nil
"List of colors used for timestamps.
Set automatically when `ement-tabulated-room-list-mode' is activated.")
(defvar ement-sessions)
;;;; Customization
(defgroup ement-tabulated-room-list nil
"Options for the room list buffer."
:group 'ement)
(defcustom ement-tabulated-room-list-auto-update t
"Automatically update the room list buffer."
:type 'boolean)
(defcustom ement-tabulated-room-list-avatars (display-images-p)
"Show room avatars in the room list."
:type 'boolean)
(defcustom ement-tabulated-room-list-simplify-timestamps t
"Only show the largest unit of time in a timestamp.
For example, \"1h54m3s\" becomes \"1h\"."
:type 'boolean)
;;;;; Faces
(defface ement-tabulated-room-list-name
'((t (:inherit font-lock-function-name-face button)))
"Non-direct rooms.")
(defface ement-tabulated-room-list-direct
;; In case `font-lock-constant-face' is bold, we set the weight to normal, so it can be
;; made bold for unread rooms only.
'((t (:weight normal :inherit (font-lock-constant-face ement-tabulated-room-list-name))))
"Direct rooms.")
(defface ement-tabulated-room-list-invited
'((t (:inherit italic ement-tabulated-room-list-name)))
"Invited rooms.")
(defface ement-tabulated-room-list-left
'((t (:strike-through t :inherit ement-tabulated-room-list-name)))
"Left rooms.")
(defface ement-tabulated-room-list-unread
'((t (:inherit bold ement-tabulated-room-list-name)))
"Unread rooms.")
(defface ement-tabulated-room-list-favourite '((t (:inherit (font-lock-doc-face ement-tabulated-room-list-name))))
"Favourite rooms.")
(defface ement-tabulated-room-list-low-priority '((t (:inherit (font-lock-comment-face ement-tabulated-room-list-name))))
"Low-priority rooms.")
(defface ement-tabulated-room-list-recent
'((t (:inherit font-lock-warning-face)))
"Latest timestamp of recently updated rooms.
The foreground color is used to generate a gradient of colors
from recent to non-recent for rooms updated in the past 24
hours but at least one hour ago.")
(defface ement-tabulated-room-list-very-recent
'((t (:inherit error)))
"Latest timestamp of very recently updated rooms.
The foreground color is used to generate a gradient of colors
from recent to non-recent for rooms updated in the past hour.")
;;;; Bookmark support
;; Especially useful with Burly: <https://github.com/alphapapa/burly.el>
(require 'bookmark)
(defun ement-tabulated-room-list-bookmark-make-record ()
"Return a bookmark record for the `ement-tabulated-room-list' buffer."
(pcase-let* (((cl-struct ement-session user) ement-session)
((cl-struct ement-user (id session-id)) user))
;; MAYBE: Support bookmarking specific events in a room.
(list (concat "Ement room list (" session-id ")")
(cons 'session-id session-id)
(cons 'handler #'ement-tabulated-room-list-bookmark-handler))))
(defun ement-tabulated-room-list-bookmark-handler (bookmark)
"Show Ement room list buffer for BOOKMARK."
(pcase-let* (((map session-id) bookmark))
(unless (alist-get session-id ement-sessions nil nil #'equal)
;; MAYBE: Automatically connect.
(user-error "Session %s not connected: call `ement-connect' first" session-id))
(ement-tabulated-room-list)))
;;;; Commands
(defun ement-tabulated-room-list-next-unread ()
"Show next unread room."
(interactive)
(unless (button-at (point))
(call-interactively #'forward-button))
(unless (cl-loop with starting-line = (line-number-at-pos)
if (equal "U" (elt (tabulated-list-get-entry) 0))
do (progn
(goto-char (button-end (button-at (point))))
(push-button (1- (point)))
(cl-return t))
else do (call-interactively #'forward-button)
while (> (line-number-at-pos) starting-line))
;; No more unread rooms.
(message "No more unread rooms")))
;;;###autoload
(defun ement-tabulated-room-list (&rest _ignore)
"Show buffer listing joined rooms.
Calls `pop-to-buffer-same-window'. Interactively, with prefix,
call `pop-to-buffer'."
(interactive)
(with-current-buffer (get-buffer-create "*Ement Rooms*")
(ement-tabulated-room-list-mode)
(setq-local bookmark-make-record-function #'ement-tabulated-room-list-bookmark-make-record)
;; FIXME: There must be a better way to handle this.
(funcall (if current-prefix-arg
#'pop-to-buffer #'pop-to-buffer-same-window)
(current-buffer))))
(defun ement-tabulated-room-list--timestamp-colors ()
"Return a vector of generated latest-timestamp colors for rooms.
Used in `ement-tabulated-room-list' and `ement-room-list'."
(if (or (equal "unspecified-fg" (face-foreground 'default nil 'default))
(equal "unspecified-bg" (face-background 'default nil 'default)))
;; NOTE: On a TTY, the default face's foreground and background colors may be the
;; special values "unspecified-fg"/"unspecified-bg", in which case we can't generate
;; gradients, so we just return a vector of "unspecified-fg". See
;; <https://debbugs.gnu.org/cgi/bugreport.cgi?bug=55623>.
(make-vector 134 "unspecified-fg")
(cl-coerce
(append (mapcar
;; One face per 10-minute period, from "recent" to 1-hour.
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'ement-tabulated-room-list-very-recent
nil 'default))
(color-name-to-rgb (face-foreground 'ement-tabulated-room-list-recent
nil 'default))
6))
(mapcar
;; One face per hour, from "recent" to default.
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'ement-tabulated-room-list-recent
nil 'default))
(color-name-to-rgb (face-foreground 'default nil 'default))
24))
(mapcar
;; One face per week for the last year (actually we
;; generate colors for the past two years' worth so
;; that the face for one-year-ago is halfway to
;; invisible, and we don't use colors past that point).
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'default nil 'default))
(color-name-to-rgb (face-background 'default nil 'default))
104)))
'vector)))
(define-derived-mode ement-tabulated-room-list-mode tabulated-list-mode
"Ement-Tabulated-Room-List"
:group 'ement
(setf tabulated-list-format (vector
'("U" 1 t)
'(#("P" 0 1 (help-echo "Priority (favorite/low)")) 1 t)
'("B" 1 t)
;; '("U" 1 t)
'("d" 1 t) ; Direct
(list (propertize "🐱"
'help-echo "Avatar")
4 t) ; Avatar
'("Name" 25 t) '("Topic" 35 t)
(list "Latest"
(if ement-tabulated-room-list-simplify-timestamps
6 20)
#'ement-tabulated-room-list-latest<
:right-align t)
'("Members" 7 ement-tabulated-room-list-members<)
;; '("P" 1 t) '("Tags" 15 t)
'("Session" 15 t))
tabulated-list-sort-key '("Latest" . t)
ement-tabulated-room-list-timestamp-colors (ement-tabulated-room-list--timestamp-colors))
(add-hook 'tabulated-list-revert-hook #'ement-tabulated-room-list--set-entries nil 'local)
(tabulated-list-init-header)
(ement-tabulated-room-list--set-entries)
(tabulated-list-revert))
(defun ement-tabulated-room-list-action (event)
"Show buffer for room at EVENT or point."
(interactive "e")
(mouse-set-point event)
(pcase-let* ((room (tabulated-list-get-id))
(`[,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name ,_topic ,_latest ,_members ,user-id]
(tabulated-list-get-entry))
(session (alist-get user-id ement-sessions nil nil #'equal)))
(ement-view-room room session)))
;;;; Functions
;;;###autoload
(defun ement-tabulated-room-list-auto-update (_session)
"Automatically update the room list buffer.
Does so when variable `ement-tabulated-room-list-auto-update' is non-nil.
To be called in `ement-sync-callback-hook'."
(when (and ement-tabulated-room-list-auto-update
(buffer-live-p (get-buffer "*Ement Rooms*")))
(with-current-buffer (get-buffer "*Ement Rooms*")
(revert-buffer))))
(defun ement-tabulated-room-list--set-entries ()
"Set `tabulated-list-entries'."
;; Reset avatar size in case default font size has changed.
;; TODO: After implementing avatars.
;; (customize-set-variable 'ement-room-avatar-in-buffer-name-size ement-room-avatar-in-buffer-name-size)
;; NOTE: From Emacs docs:
;; This buffer-local variable specifies the entries displayed in the
;; Tabulated List buffer. Its value should be either a list, or a
;; function.
;;
;; If the value is a list, each list element corresponds to one entry,
;; and should have the form ‘(ID CONTENTS)’, where
;;
;; • ID is either ‘nil’, or a Lisp object that identifies the
;; entry. If the latter, the cursor stays on the same entry when
;; re-sorting entries. Comparison is done with ‘equal’.
;;
;; • CONTENTS is a vector with the same number of elements as
;; ‘tabulated-list-format’. Each vector element is either a
;; string, which is inserted into the buffer as-is, or a list
;; ‘(LABEL . PROPERTIES)’, which means to insert a text button by
;; calling ‘insert-text-button’ with LABEL and PROPERTIES as
;; arguments (*note Making Buttons::).
;;
;; There should be no newlines in any of these strings.
(let ((entries (cl-loop for (_id . session) in ement-sessions
append (mapcar (lambda (room)
(ement-tabulated-room-list--entry session room))
(ement-session-rooms session)))))
(setf tabulated-list-entries
;; Pre-sort by latest event so that, when the list is sorted by other columns,
;; the rooms will be secondarily sorted by latest event.
(cl-sort entries #'> :key (lambda (entry)
;; In case a room has no latest event (not sure if
;; this may obscure a bug, but this has happened, so
;; we need to handle it), we fall back to 0.
(or (ement-room-latest-ts (car entry)) 0))))))
(defun ement-tabulated-room-list--entry (session room)
"Return entry for ROOM in SESSION for `tabulated-list-entries'."
(pcase-let* (((cl-struct ement-room id canonical-alias display-name avatar topic latest-ts summary
(local (map buffer room-list-avatar)))
room)
((map ('m.joined_member_count member-count)) summary)
(e-alias (or canonical-alias
(setf (ement-room-canonical-alias room)
(ement--room-alias room))
id))
;; FIXME: Figure out how to track unread status cleanly.
(e-unread (if (and buffer (buffer-modified-p buffer))
(propertize "U" 'help-echo "Unread") ""))
(e-buffer (if buffer (propertize "B" 'help-echo "Room has buffer") ""))
(e-avatar (if (and ement-tabulated-room-list-avatars avatar)
(or room-list-avatar
(if-let* ((avatar-image (get-text-property 0 'display avatar))
(new-avatar-string (propertize " " 'display
(ement--resize-image avatar-image
nil (frame-char-height)))))
(progn
;; alist-get doesn't seem to return the new value when used with setf?
(setf (alist-get 'room-list-avatar (ement-room-local room))
new-avatar-string)
new-avatar-string)
;; If a room avatar image fails to download or decode
;; and ends up nil, we return the empty string.
(ement-debug "nil avatar for room: " (ement-room-display-name room) (ement-room-canonical-alias room))
""))
;; Room avatars disabled.
""))
;; We have to copy the list, otherwise using `setf' on it
;; later causes its value to be mutated for every entry.
(name-face (cl-copy-list '(:inherit (ement-tabulated-room-list-name))))
(e-name (list (propertize (or display-name
(ement--room-display-name room))
;; HACK: Apply face here, otherwise tabulated-list overrides it.
'face name-face
'help-echo e-alias)
'action #'ement-tabulated-room-list-action))
(e-topic (if topic
;; Remove newlines from topic. Yes, this can happen.
(replace-regexp-in-string "\n" "" topic t t)
""))
(formatted-timestamp (if latest-ts
(ement--human-format-duration (- (time-convert nil 'integer) (/ latest-ts 1000))
t)
""))
(latest-face (when latest-ts
(let* ((difference-seconds (- (float-time) (/ latest-ts 1000)) )
(n (cl-typecase difference-seconds
((number 0 3599) ;; 1 hour to 1 day: 24 1-hour periods.
(truncate (/ difference-seconds 600)))
((number 3600 86400) ;; 1 day
(+ 6 (truncate (/ difference-seconds 3600))))
(otherwise ;; Difference in weeks.
(min (/ (length ement-tabulated-room-list-timestamp-colors) 2)
(+ 24 (truncate (/ difference-seconds 86400 7))))))))
(list :foreground (elt ement-tabulated-room-list-timestamp-colors n)))))
(e-latest (or (when formatted-timestamp
(propertize formatted-timestamp
'value latest-ts
'face latest-face))
;; Invited rooms don't have a latest-ts.
""))
(e-session (propertize (ement-user-id (ement-session-user session))
'value session))
;; ((e-tags favorite-p low-priority-p) (ement-tabulated-room-list--tags room))
(e-direct-p (if (ement--room-direct-p room session)
(propertize "d" 'help-echo "Direct room")
""))
(e-priority (cond ((ement--room-favourite-p room) "F")
((ement--room-low-priority-p room) "l")
(" ")))
(e-members (if member-count (number-to-string member-count) "")))
(when ement-tabulated-room-list-simplify-timestamps
(setf e-latest (replace-regexp-in-string
(rx bos (1+ digit) (1+ alpha) (group (1+ (1+ digit) (1+ alpha))))
"" e-latest t t 1)))
;; Add face modifiers.
(when (and buffer (buffer-modified-p buffer))
;; For some reason, `push' doesn't work with `map-elt'.
(setf (map-elt name-face :inherit)
(cons 'ement-tabulated-room-list-unread (map-elt name-face :inherit))))
(when (ement--room-direct-p room session)
(setf (map-elt name-face :inherit)
(cons 'ement-tabulated-room-list-direct (map-elt name-face :inherit))))
(when (ement--room-favourite-p room)
(push 'ement-tabulated-room-list-favourite (map-elt name-face :inherit)))
(when (ement--room-low-priority-p room)
(push 'ement-tabulated-room-list-low-priority (map-elt name-face :inherit)))
(pcase (ement-room-type room)
('invite
(setf e-topic (concat (propertize "[invited]"
'face 'ement-tabulated-room-list-invited)
" " e-topic)
(map-elt name-face :inherit) (cons 'ement-tabulated-room-list-invited
(map-elt name-face :inherit))))
('leave
(setf e-topic (concat (propertize "[left]"
'face 'ement-tabulated-room-list-left)
" " e-topic)
(map-elt name-face :inherit) (cons (map-elt name-face :inherit)
'ement-tabulated-room-list-left))))
(list room (vector e-unread e-priority e-buffer e-direct-p
e-avatar e-name e-topic e-latest e-members
;; e-tags
e-session
;; e-avatar
))))
;; TODO: Define sorters with a macro? This gets repetitive and hard to update.
(defun ement-tabulated-room-list-members< (a b)
"Return non-nil if entry A has fewer members than room B.
A and B should be entries from `tabulated-list-mode'."
(pcase-let* ((`(,_room [,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name-for-list ,_topic ,_latest ,a-members ,_session]) a)
(`(,_room [,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name-for-list ,_topic ,_latest ,b-members ,_session]) b))
(when (and a-members b-members)
;; Invited rooms may have no member count (I think).
(< (string-to-number a-members) (string-to-number b-members)))))
(defun ement-tabulated-room-list-latest< (a b)
"Return non-nil if entry A has fewer members than room B.
A and B should be entries from `tabulated-list-mode'."
(pcase-let* ((`(,_room-a [,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name-for-list ,_topic ,a-latest ,_a-members ,_session]) a)
(`(,_room-b [,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name-for-list ,_topic ,b-latest ,_b-members ,_session]) b)
(a-latest (get-text-property 0 'value a-latest))
(b-latest (get-text-property 0 'value b-latest)))
(cond ((and a-latest b-latest)
(< a-latest b-latest))
(b-latest
;; Invited rooms have no latest timestamp, and we want to sort them first.
nil)
(t t))))
;;;; Footer
(provide 'ement-tabulated-room-list)
;;; ement-tabulated-room-list.el ends here
;;; ement-structs.el --- Ement structs -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;;
;;; Code:
;;;; Debugging
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
;;;; Requirements
(require 'cl-lib)
;;;; Structs
(cl-defstruct ement-user
id displayname account-data
(color nil :documentation "Color in which to display user's name.")
(message-color nil :documentation "Color in which to display user's messages.")
(username nil
;; NOTE: Not exactly according to spec, I guess, but useful for now.
:documentation "Username part of user's Matrix ID.")
(avatar-url nil :documentation "MXC URL to user's avatar.")
(avatar nil :documentation "One-space string with avatar image in display property."))
(cl-defstruct ement-event
id sender content origin-server-ts type unsigned state-key
receipts
;; The local slot is an alist used by the local client only.
local)
(cl-defstruct ement-server
name uri-prefix)
(cl-defstruct ement-session
user server token transaction-id rooms next-batch
device-id initial-device-display-name has-synced-p
account-data
;; Hash table of all seen events, keyed on event ID.
events)
(cl-defstruct ement-room
id display-name prev-batch
summary state timeline ephemeral account-data unread-notifications
latest-ts topic canonical-alias avatar status type invite-state
(members (make-hash-table :test #'equal) :documentation "Hash table mapping joined user IDs to user structs.")
;; The local slot is an alist used by the local client only.
local
(receipts (make-hash-table :test #'equal))
(displaynames (make-hash-table) :documentation "Hash table mapping users to their displayname in this room."))
;;;; Variables
;;;; Customization
;;;; Commands
;;;; Functions
;;;; Footer
(provide 'ement-structs)
;;; ement-structs.el ends here
;;; ement-room.el --- Ement room buffers -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;; This library implements buffers displaying events in a room.
;; 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.
;;; Code:
;;;; Debugging
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
;;;; Requirements
(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)
;;;; 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."
;; Like the room timeline slot, events are sorted latest-first. We also deduplicate
;; them , because it seems that we can end up with multiple copies of a membership event
;; (e.g. when loading old messages).
(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)
;;;; Variables
(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)
;; Movement
(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)
;; Switching
(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)
;; Messages
(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)
;; Users
(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)
;; Room
(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)
;; Room membership
(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)
;; Other
(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))
;; NOTE: The confusing differences between what /sync and /messages
;; expect. See <https://github.com/matrix-org/matrix-doc/issues/706>.
"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.")
;; Variables from other files.
(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)
;; Defined in Emacs 28.1: silence byte-compilation warning in earlier versions.
(defvar browse-url-handlers)
;;;; Customization
(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)
;;;;; Faces
(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
;; TODO(30.1): Remove when not supporting Emacs 27 anymore.
(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.")
;;;;; Options
(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
;; TODO: Show in new screenshots.
'(: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]"))
;; Also set help-echo in case the topic is too wide to fit.
'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)
;; Set variable-value pairs, locally if LOCAL is non-nil.
`(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
;; Try to set the margin widths smartly.
("%B%r%R%t" ;; "Elemental"
(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" ;; "IRC-style using margins"
(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" ;; "IRC-style without margins"
(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)
;; NOTE: The following two variables may seem redundant, but one is an
;; option that the user may override, while the other is set
;; automatically.
ement-room-sender-headers
(if (string-match-p (or "%S" "%s") value)
;; If "%S" or "%s" isn't found, assume it's to be shown in headers.
nil t)
ement-room-sender-in-headers
(if (string-match-p (rx (or "%S" "%s")) value)
;; If "%S" or "%s" isn't found, assume it's to be shown in headers.
nil t))
(message "Ement: When using custom message format, setting margin widths may be necessary")))
(unless ement-room-sender-in-headers
;; HACK: Disable overline on sender face.
(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))
;; Only display when a session is connected (not sure why `bound-and-true-p'
;; is required to avoid compilation warnings).
(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)
;; This file must be loaded before calling the setter to define the
;; `ement-room-user' face used in it.
: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"
;; FIXME: In Emacs 27+, maybe use :extend t instead of adding a newline.
"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
;; Activate in compose buffer by default.
(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."
;; FIXME: When we keep a hash table of members in a room, make this
;; smarter.
: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."
;; Prot would almost approve of this default. :) I would go all the way
;; to 7, but 6 already significantly dilutes the colors in some cases.
: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)
;;;; Macros
(defmacro ement-room-with-highlighted-event-at (position &rest body)
"Highlight event at POSITION while evaluating BODY."
;; MAYBE: Accept a marker for POSITION.
(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)
;; NOTE: It doesn't seem possible to get the end position of
;; a node, so if there is no next node, we use point-max.
;; But this might break if we were to use an EWOC footer.
(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
;; In case there are any stray ones (e.g. a user typing in
;; more than room at once, which is possible but unlikely).
(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))
;; Cancel typing notifications after sending a message. (The
;; spec doesn't say whether this is needed, but it seems to be.)
(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)))
;;;;; Event formatting
;; NOTE: When adding specs, also add them to docstring
;; for `ement-room-message-format-spec'.
(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))
;; FIXME(v0.12): The quote-end may be detected in the wrong position when, e.g. a link is
;; in the middle of the quoted part. We need to search backward from the end to find
;; where the quote face finally ends.
(ement-room-define-event-formatter ?b
"Plain-text body content."
;; NOTE: `save-match-data' is required around calls to `ement-room--format-message-body'.
(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."
;; Probably only useful for debugging, so might remove later.
(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)))))
;; HACK: This will probably only be used in the notifications buffers, anyway.
(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))
;; NOTE: In ?s and ?S, we add nearly-invisible ASCII unit-separator characters ("")
;; to prevent, e.g. `dabbrev-expand' from expanding display names with body text.
(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))
;; NOTE: When called from an `ement-notify' function, ROOM may have no buffer. In
;; that case, just use the current buffer (which should be a temp buffer used to
;; format the event).
(with-current-buffer (or buffer (current-buffer))
(when ement-room-sender-in-left-margin
;; Sender in left margin: truncate/pad appropriately.
(setf sender
(if (< (string-width sender) ement-room-left-margin-width)
;; Using :align-to or :width space display properties doesn't
;; seem to have any effect in the margin, so we make a string.
(concat (make-string (- ement-room-left-margin-width (string-width sender))
? )
sender)
;; String wider than margin: truncate it.
(ement-room--concat-property
(truncate-string-to-width sender ement-room-left-margin-width nil nil "…")
'help-echo (concat sender " "))))))
;; NOTE: I'd like to add a help-echo function to display the sender ID, but the Emacs
;; manual says that there is currently no way to make text in the margins mouse-sensitive.
;; So `ement--format-user' returns a string propertized with `help-echo' as a string.
(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 ;; Timestamps are in milliseconds.
(/ (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) ;; Unused for now, but keeping for consistency.
;; This used to be a macro in --format-message, which is probably better for
;; performance, but using a function is clearer, and avoids premature optimization.
(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."
;; This only looks for a member name at the beginning of the string. It would be neat to add
;; colors to every member mentioned in a message, but that would probably not perform well.
;; NOTE: This function may be called by `ement-notify' functions even when the room has
;; no buffer, and this function is designed to use events in a room buffer to more
;; quickly find the data it needs, so, for now, if the room has no buffer, we return
;; STRING unchanged.
(pcase-let (((cl-struct ement-room (local (map buffer))) room))
(if (buffer-live-p buffer)
(save-match-data
;; This function may be called from a chain of others that use the match data, so
;; rather than depending on all of them to save the match data, we do it here.
;; FIXME: Member names containing spaces aren't matched. Can this even be fixed reasonably?
(when (string-match (rx bos (group (1+ (not blank))) (or ":" ",") (1+ blank)) string)
(when-let* ((member-name (match-string 1 string))
;; HACK: Since we don't currently keep a list of all
;; members in a room, we look to see if this displayname
;; has any mentions in the room so far.
(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)
;; NOTE: I don't know why, but sometimes the regexp
;; search ends on a non-event line, like a timestamp
;; header, so for now we just try to handle that case.
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))))
;; Room has no buffer: return STRING as-is.
string)))
;;;; Bookmark support
;; Especially useful with Burly: <https://github.com/alphapapa/burly.el>
(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))
;; MAYBE: Support bookmarking specific events in a room.
(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
;; MAYBE: Automatically connect.
(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)
;; HACK: Put point at the end of the room buffer. This seems unusually difficult,
;; apparently because the bookmark library itself moves point after jumping to a
;; bookmark. My attempts at setting the buffer's and window's points after calling
;; `ement-view-room' have had no effect. `bookmark-after-jump-hook' sounds ideal, but
;; it does not seem to actually get run, so we use a timer that runs immediately after
;; `bookmark-jump' returns.
(run-at-time nil nil (lambda ()
(goto-char (point-max))))))
;;;; Commands
(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)
;; `json-encode' wants an empty hash table to represent an empty map. And
;; apparently there's no way to DELETE account-data events, so we have to re-PUT
;; it with empty content.
(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))))
;; Flush notify-background-color colors.
(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)))
;; NOTE: The notifications buffer can't be refreshed because each event is from a
;; different room, and the `ement-room' variable is unset in the buffer.
;; (when-let (buffer (get-buffer "*Ement Notifications*"))
;; (with-current-buffer buffer
;; (ewoc-refresh ement-ewoc)))
)
(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
;; Compare with current buffer's room.
(and room-id (equal room-id (ement-room-id ement-room)))
(and room-alias (equal room-alias (ement-room-canonical-alias ement-room)))
;; Compare with other rooms on session.
(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
;; Found room in current session: view it and find the event.
(ement-view-room room ement-session)
(when event-id
(ement-room-find-event event-id)))
;; Room not joined: offer to join it or load link in browser.
(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))
;; Found event in timeline: it should be in the EWOC, so go to it.
(goto-event event-id)
;; Event not found in timeline: try to retro-load it.
(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
;; TODO: Add an ELSE argument to `ement-room-retro-to' and use it to give
;; a useful error here.
: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)
;; Return non-nil for nodes that should stay.
(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."
;; TODO: Support URLs to remote files.
(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)))))
;; NOTE: The typing notification won't be quite right, because it'll be canceled while waiting
;; for the file to upload. It would be awkward to handle that, so this will do for now.
(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)))
;; TODO: Image height/width (maybe not easy to get in Emacs).
(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."
;; TODO: Support URLs to remote files.
(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
;; According to tulir in #matrix-dev:matrix.org, ": is not
;; allowed in the localpart, all other valid unicode is
;; allowed. (user ids and room ids are the same over
;; federation). it's mostly a lack of validation in
;; synapse (arbitrary unicode isn't intentionally allowed,
;; but it's not disallowed either)". See
;; <https://matrix.to/#/!jxlRxnrZCsjpjDubDX:matrix.org/$Cnb53UQdYnGFizM49Aje_Xs0BxVdt-be7Dnm7_k-0ho>.
(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)
;; NOTE: This generates a symbol and sets its function value to a lambda
;; which removes the symbol from the hook, removing itself from the hook.
;; TODO: When requiring Emacs 27, use `letrec'.
(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)))
;; In case the join event is not in this next sync
;; response, make sure the room is found before removing
;; the function and joining the room.
(remove-hook 'ement-sync-callback-hook then-fn-symbol)
;; FIXME: Probably need to unintern the 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))
;; Point is actually on the last event, but it doesn't appear to be: move point to
;; the beginning of that event.
(ewoc-goto-node ement-ewoc (ement-room--ewoc-last-matching ement-ewoc #'ement-event-p))
;; Go to previous event.
(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))
;; Already at end of buffer: signal error.
(user-error "End of events")
;; Go to end-of-buffer so new messages will auto-scroll.
(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))))
;; TODO: Unify these retro-loading functions.
(cl-defun ement-room-retro
(room session number &key buffer
(then (apply-partially #'ement-room-retro-callback room session)))
;; FIXME: Naming things is hard.
"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))))
;; We use a timeout of 30, because sometimes the server can take a while to
;; respond, especially if loading, e.g. hundreds or thousands of events.
(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
;; Ensure the event hasn't already been retrieved.
(not (gethash event-id (ement-session-events session))))
(let* ((total-retrieved 0)
;; TODO: Use letrec someday.
(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)
;; FIXME: Probably need to unintern the symbol.
(when then
(funcall then)))
;; FIXME: What if it hits the beginning of the timeline?
(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."
;; NOTE: We don't set `ement-room-retro-loading' since the room may
;; not have a buffer. This could theoretically allow a user to
;; overlap manual scrollback-induced loading of old messages with
;; this gap-filling loading, but that shouldn't matter, and probably
;; would be very rare, anyway.
(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))
;; HACK: Comparing the END and TO tokens ought to
;; work for determining whether we are done
;; filling, but it isn't (maybe the server isn't
;; returning the TO token as END when there are no
;; more events), so instead we'll check the length
;; of the chunk.
(unless (< (length chunk) batch-size)
;; More pages remain to be loaded.
(let ((remaining-limit (- limit batch-size)))
(if (not (> remaining-limit 0))
;; FIXME: This leaves a gap if it's larger than 1,000 events.
;; Probably, the limit should be configurable, but it would be good
;; to find some way to remember the gap and fill it if the user
;; scrolls to it later (although that might be very awkward to do).
(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))))
;; FIXME: Remove this message after further testing.
(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))))))))
;; FIXME: Remove this message after further testing.
(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))))))
;; NOTE: `declare-function' doesn't recognize cl-defun forms, so this declaration doesn't work.
(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)
;; NOTE: This assumes that the selected window is the buffer's window. For now
;; this is almost surely the case, but in the future, we might let the function
;; send messages to other rooms more easily, so this assumption might not hold.
(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)))
;; Point is on last event: advance it to eob so that when the event is received
;; back, the window will scroll. (This might not always be desirable, because
;; the user might have point on that event for a reason, but I think in most
;; cases, it will be what's expected and most helpful.)
(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)) ;; Data is added when calling back.
;; NOTE: This assumes that the selected window is the buffer's window. For now
;; this is almost surely the case, but in the future, we might let the function
;; send messages to other rooms more easily, so this assumption might not hold.
(when window
(with-selected-window window
(when (>= (window-point) (ewoc-location (ewoc-nth ement-ewoc -1)))
;; Point is on last event: advance it to eob so that when the event is received
;; back, the window will scroll. (This might not always be desirable, because
;; the user might have point on that event for a reason, but I think in most
;; cases, it will be what's expected and most helpful.)
(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)
;; Move read markers.
(when-let ((buffer (alist-get 'buffer (ement-room-local room))))
(with-current-buffer buffer
;; NOTE: The new event may not exist in the buffer yet, so
;; we just have to use the last one.
;; FIXME: When we add local echo, this can be fixed.
(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
;; Editing an already-edited event: get the original event.
(setf event (gethash id events)))
;; Remove any leading asterisk from the plain-text body.
(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)))))
;; Prepend the asterisk after the filter may have modified the content. Note that the
;; "m.new_content" body does not get the leading asterisk, only the "content" body,
;; which is intended as a fallback.
(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))
;; HACK: This isn't really an error, but is there a cleaner way to cancel?
(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)))
;; SPEC: MSC2677 <https://github.com/matrix-org/matrix-doc/pull/2677>
;; HACK: We could simplify this by storing the key in a text property...
(ement-room-with-highlighted-event-at position
(pcase-let* ((event (or (ewoc-data (ewoc-locate ement-ewoc position))
(user-error "No event at point")))
;; NOTE: Sadly, `face-at-point' doesn't work here because, e.g. if
;; hl-line-mode is enabled, it only returns the hl-line face.
((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)
;; Point is in a reaction button but after the key.
(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))
;; Already sent this reaction: redact it.
(ement-redact reaction-event room session)
;; Send reaction.
(ement-room-send-reaction key (point)))))
(defun ement-room-reaction-button-action (button)
"Push reaction BUTTON at point."
;; TODO: Toggle reactions off with redactions (not in spec yet, but Element does it).
(save-excursion
(goto-char (button-start button))
(call-interactively #'ement-room-toggle-reaction)))
(defun ement-room-toggle-space (room space session)
;; Naming things is hard, but this seems the best balance between concision, ambiguity,
;; and consistency. The docstring is always there. (Or there's the sci-fi angle:
;; "spacing" a room...)
"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)))
;; TODO: Use different face for spaces the room is already in.
(`(,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
;; FIXME: Finish and use the routing function.
;; (ement--room-routing room)
routing-server)))
('remove (make-hash-table)))))
(ement-put-state space "m.space.child" child-id data session
:then (lambda (response-data)
;; It appears that the server doesn't send the new event in the next sync (at
;; least, not to the client that put the state), so we must simulate receiving it.
(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))))))
;;;; Functions
(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))
;; FIXME: This doesn't seem to work as desired, e.g. when
;; `ement-view-room-display-buffer-action' is set to `display-buffer-no-window'; I
;; guess because `pop-to-buffer' selects a window.
(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."
;; This function is necessary because the hook is called with the room argument, which
;; `ement-room-list-auto-update' doesn't need.
(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))
;; We do 3 things for chunk events, so we count them 3 times when
;; reporting progress. (We also may receive some state events for
;; these chunk events, but we don't bother to include them in the
;; count, and we don't report progress for them, because they are
;; likely very few compared to the number of timeline events, which is
;; what the user is interested in (e.g. when loading 1000 earlier
;; messages in #emacs:matrix.org, only 31 state events were received).
(progress-max-value (* 3 num-events)))
;; NOTE: Put the newly retrieved events at the end of the slots, because they should be
;; older events. But reverse them first, because we're using "dir=b", which the
;; spec says causes the events to be returned in reverse-chronological order, and we
;; want to process them oldest-first (important because a membership event having a
;; user's displayname should be older than a message event sent by the user).
;; NOTE: The events in `chunk' and `state' are vectors, so we
;; convert them to a list before appending.
(ement-debug num-events progress-max-value)
(setf chunk (nreverse chunk)
state (nreverse state))
;; FIXME: Like `ement--push-joined-room-events', this should probably run the `ement-event-hook' on the newly seen events.
;; Append state events.
(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))
;; Append timeline events (in the "chunk").
(cl-loop for event across-ref chunk
do (setf event (ement--make-event event))
;; HACK: Put events on events table. See FIXME above about using the event hook.
(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
;; Insert events into the room's buffer.
(with-current-buffer buffer
(save-window-excursion
;; NOTE: See note in `ement--update-room-buffers'.
(when-let ((buffer-window (get-buffer-window buffer)))
(select-window buffer-window))
;; FIXME: Use retro-loading in event handlers, or in --handle-events, anyway.
(ement-room--process-events chunk)
(when set-prev-batch
;; This feels a little hacky, but maybe not too bad.
(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))
;; HACK: See below.
(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
;; NOTE: When inserting some events, seemingly only replies, if a different buffer's
;; window is selected, and this buffer's window-point is at the bottom, the formatted
;; events may be inserted into the wrong place in the buffer, even though they are
;; inserted into the EWOC at the right place. We work around this by selecting the
;; buffer's window while inserting events, if it has one. (I don't know if this is a bug
;; in EWOC or in this file somewhere. But this has been particularly nasty to debug.)
(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)))
;; Since events can be received in any order, we have to check the whole buffer
;; for where to insert new timestamp headers. (Avoiding that would require
;; getting a list of newly inserted nodes and checking each one instead of every
;; node in the buffer. Doing that now would probably be premature optimization,
;; though it will likely be necessary if users keep buffers open for busy rooms
;; for a long time, as the time to do this in each buffer will increase with the
;; number of events. At least we only do it once per batch of events.)
(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))
;; TODO: Experiment with this.
(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)
;; We don't really care about the response, I think.
: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
;; TODO: Use EWOC header/footer for, e.g. typing messages.
ement-ewoc (ewoc-create #'ement-room--pp-thing))
;; Set the URL handler. Note that `browse-url-handlers' was added in 28.1;
;; prior to that `browse-url-browser-function' served double-duty.
;; TODO: Remove compat code when requiring Emacs >=28.
(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))
;; Track buffer in room's slot.
(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)
;; Set initial header and footer. (Do this before processing events, which
;; might cause the header/footer to be changed (e.g. a tombstone event).
(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)
;; Set header and footer for an invited 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)
;; Kill the room buffer so it can be recreated after joining
;; (which will cleanly update the room's name, footer, etc).
(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)
;; Kill the room buffer so it can be recreated after joining
;; (which will cleanly update the room's name, footer, etc).
(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
;; Clear new-events, because those only matter when a buffer is already open.
(alist-get 'new-events (ement-room-local room)) nil
;; Set the new buffer in the room's local alist so that it
;; can be used by event-inserting functions before this
;; function returns, e.g. `ement-room--add-member-face'.
(alist-get 'buffer (ement-room-local room)) new-buffer)
;; We don't use `ement-room--insert-events' to avoid extra
;; calls to `ement-room--insert-ts-headers'.
;; NOTE: We handle the events in chronological order (i.e. the reverse of the
;; stored order, which is latest-first), because some logic depends on this
;; (e.g. processing a message-edit event before the edited event would mean the
;; edited event would not yet be in the 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)))))
;; Return the buffer!
new-buffer)))
(defun ement-room--event-data (id)
"Return event struct for event ID in current buffer."
;; Search from bottom, most likely to be faster.
(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))
;;;;; Imenu
(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))))))
;;;;; Occur
(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)
;; TODO: Insert date header before first event.
(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)))
;;;;; Events
;; Functions to handle types of events.
;; NOTE: At the moment, this only handles "m.typing" ephemeral events. Message
;; events are handled elsewhere. A better framework should be designed...
;; TODO: Define other handlers this way.
;; MAYBE: Should we intern these functions? That means every event
;; handled has to concat and intern. Should we use lambdas in an
;; alist or hash-table instead? For now let's use an alist.
(defvar ement-users)
(defvar ement-room-event-fns nil
"Alist mapping event types to functions which process events in room buffers.")
;; NOTE: While transitioning to the defevent-based handler system, we
;; define both a handle-events and handle-event function that do the
;; same thing.
;; TODO: Tidy this up.
;; NOTE: --handle-events and --handle-event need to be called in the room
;; buffer's window, when it has one. This is absolutely necessary,
;; otherwise the events may be inserted at the wrong place. (I'm not
;; sure if this is a bug in EWOC or in my code, but doing this fixes it.)
(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."
;; FIXME: Calling `ement-room--insert-ts-headers' is convenient, but it
;; may also be called in functions that call this function, which may
;; result in it being called multiple times for a single set of events.
(cl-loop for event being the elements of events ;; EVENTS may be a list or array.
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)))
;; We demote any errors that happen while processing events, because it's possible for
;; events to be malformed in unexpected ways, and that could cause an error, which
;; would stop processing of other events and prevent further syncing. See,
;; e.g. <https://github.com/alphapapa/ement.el/pull/61>.
(with-demoted-errors "Ement (ement-room--process-event): Error processing event: %S"
(funcall handler event))))
;;;;;; Event handlers
(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))
;; TODO: Handle other rel_types?
(pcase rel-type
("m.annotation"
;; Look for related event in timeline.
(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)))
;; Found related event: add reaction to local slot and invalidate node.
(progn
;; Every time a room buffer is made, these reaction events are processed again, so we use pushnew to
;; avoid duplicates. (In the future, as event-processing is refactored, this may not be necessary.)
(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)))
;; No known related event: discard.
;; TODO: Is this the correct thing to do?
(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
;; TODO: Include alt_aliases, maybe.
;; TODO: Include old alias when it is being replaced.
(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"
;; We handle redaction events here rather than an `ement-defevent' handler. This way we
;; do less work for events in rooms that the user isn't looking at, at the cost of doing
;; a bit more work when a room's buffer is prepared.
(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))
;; Record the redaction in the redacted event's local slot.
(cl-pushnew event (alist-get 'redacted-by (ement-event-local redacted-event)))
(pcase rel-type
("m.annotation"
;; Redacted annotation/reaction. NOTE: Since we link annotations in a -room
;; event handler (rather than in a non-room handler), we also unlink redacted
;; ones here.
(when-let (annotated-event (cl-find related-id timeline
:key #'ement-event-id :test #'equal))
;; Remove it from the related event's local slot.
(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))
;; Invalidate the related event's node.
(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)))))
;; Invalidate the redacted event's 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))
;; Event replaces existing event: find and replace it in buffer if possible, otherwise insert it.
(or (ement-room--replace-event event)
(progn
(ement-debug "Unable to replace event ID: inserting instead." replaces-event-id)
(ement-room--insert-event event)))
;; New event.
(if replaced-by-id
(ement-debug "Event replaced: not inserting." replaced-by-id)
;; Not replaced: insert it.
(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)
;; NOTE: We assume that no more typing events will be received,
;; which would replace the footer.
(ement-room--insert-event event)
(ewoc-set-hf ement-ewoc banner banner)))
;;;;; Read markers
;; Marking rooms as read and showing lines where marks are.
(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
;; The window's end has been scrolled to or past the position of the
;; receipt marker.
(and read-receipt-node
(>= (window-end nil t) (ewoc-location read-receipt-node)))
;; The read receipt is outside of retrieved events.
(not read-receipt-node))
(let* ((event-node (when window-end-node
;; It seems like `window-end-node' shouldn't ever be nil,
;; but just in case...
(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)))
;; The entire event is not visible: use the previous event. (NOTE: This
;; isn't quite perfect, because apparently `window-end' considers a position
;; visible if even one pixel of its line is visible. This will have to be
;; good enough for now.)
;; FIXME: Workaround that an entire line's height need not be displayed for it to be considered so.
(setf event-node (ement-room--ewoc-next-matching ement-ewoc event-node
#'ement-event-p #'ewoc-prev)))
(setf event (ewoc-data event-node))
;; Mark the buffer as not modified so that will not contribute to its being
;; considered unread. NOTE: This will mean that any room buffer displayed in
;; a window will have its buffer marked unmodified when this function is
;; called. This is probably for the best.
(set-buffer-modified-p nil)
(unless (alist-get event ement-room-read-receipt-request)
;; No existing request for this event: cancel any outstanding request and
;; send a new one.
(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))
;; I like using `point' as a GV, and I object to its being obsoleted (and said so
;; on emacs-devel).
(setf (point) fully-read-pos (window-start) fully-read-pos))
;; Unlike the fully-read marker, there doesn't seem to be a
;; simple way to get the user's read-receipt marker. So if
;; we haven't seen either marker in the retrieved events, we
;; go back to the fully-read marker.
(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))))
;; Fully-read account-data event is known.
(if (gethash fully-read-event-id (ement-session-events ement-session))
;; The fully-read event (i.e. the message event that was read, not the
;; account-data event) is already retrieved, but the marker is not present in
;; the buffer (this shouldn't happen, but somehow, it can): Reset the marker,
;; which should work around the problem.
(ement-room-mark-read ement-room ement-session
:fully-read-event (gethash fully-read-event-id (ement-session-events ement-session)))
;; Fully-read event not retrieved: search for it in room history.
(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
;; HACK: Should probably call this function elsewhere, in a hook or something.
(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)
;; The node is at the end of the buffer: use the last event in the timeline
;; instead of the last node in the EWOC, because the last event in the timeline
;; might not be the last event in the EWOC (e.g. a reaction to an earlier 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)
;; Sending only a read receipt, which uses a different endpoint
;; than when setting the fully-read marker or both.
(ement-room-send-receipt room session read-event)
;; Setting the fully-read marker, and maybe the "m.read" one too.
(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))
;; NOTE: See similar code in `ement-room-update-read-receipt'.
(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"
;; Ignore this, because it happens when we
;; update a read marker before the previous
;; update request is completed.
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))))
;; NOTE: Ideally we would do this before sending the new request, but to make
;; the code much simpler, we do it afterward.
(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)))
;; No other type is yet specified.
(_ #'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
;; If the event hasn't been inserted into the buffer yet,
;; this might be nil. That shouldn't happen, but...
(ewoc-enter-after ement-ewoc event-node symbol)))))))
(when-let ((buffer (alist-get 'buffer (ement-room-local room))))
;; MAYBE: Error if no buffer? Or does it matter?
(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))))
;; NOTE: Return nil so that, in the event this function is called manually with `eval-expression',
;; it does not cause an error due to the return value being an EWOC node, which is a structure too
;; big and/or circular to print. (This was one of those bugs that only happens WHEN debugging.)
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
;; At the bottom of the buffer: mark read and show next unread room.
(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))))
;; Rooms buffer already displayed: select its window and move to next unread room.
(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))))
;; Rooms buffer not displayed: bury this room buffer, which should usually
;; result in another room buffer or the rooms list buffer being displayed.
(bury-buffer))
(when (member major-mode '(ement-tabulated-room-list-mode ement-room-list-mode))
;; Back in the room-list buffer: revert it.
(revert-buffer)))
;; Not at the bottom of the buffer: scroll.
(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))))
;; Move fully-read marker to top of window.
(ement-room-mark-read ement-room ement-session :fully-read-event (ewoc-data event-node)))))
;;;;; EWOC
(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))
;; Intended to be like `ewoc-collect', but returning as soon as a match is found.
(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."
;; Intended to be like `ewoc-collect', but working with the full node instead of just the node's data.
(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)
;; HACK: Trying to work around a bug in case the
;; room doesn't seem to have any events yet.
(point-max)))
(node-b (or start-node (ewoc-nth ewoc 0)))
node-a)
;; On the first loop iteration, node-a is set to the first matching
;; node after node-b; then it's set to the first node after 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))))
;; NOTE: Matrix timestamps are in milliseconds.
(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))
;; Different date: bind format to print date.
(let ((ement-room-timestamp-header-format ement-room-timestamp-header-with-date-format))
;; Insert the date-only header.
(setf node-a (ewoc-enter-after ewoc node-a (list 'ts b-ts)))))
(with-silent-modifications
;; Avoid marking a buffer as modified just because we inserted a ts
;; header (this function may be called after other events which shouldn't
;; cause it to be marked modified, like moving the read markers).
(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)))
;; TODO: Use this in appropriate places.
"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
;; Just in case...
(ewoc-prev ewoc event-node))))
(while (and event-node
;; I don't like looking up the location of these nodes on every loop
;; iteration, but it seems like the only reliable way to determine
;; whether we've reached the end node. However, when this function is
;; called for short batches of events (or even a single event, like when
;; called from `ement-room--insert-event'), the overhead should be
;; minimal.
(<= (ewoc-location event-node) (ewoc-location end-node)))
(when (message-event-p (ewoc-data event-node))
(if (not prev-node)
;; No previous node and event is a message: insert header.
(insert-sender-before event-node)
;; Previous node exists.
(when (read-marker-p (ewoc-data prev-node))
;; Previous node is a read marker: we want to act as if they don't exist, so
;; we set `prev-node' to the non-marker node before it.
(setf prev-node (ement-room--ewoc-next-matching ewoc prev-node
(lambda (data)
(not (read-marker-p data)))
#'ewoc-prev)))
(when prev-node
;; A previous node still exists: maybe we need to add a header.
(cl-typecase (ewoc-data prev-node)
(ement-event
;; Previous node is an 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)))))
;; Previous node is a message event with a different sender: insert
;; header.
(insert-sender-before event-node)))
((or ement-user ement-room-membership-events)
;; Previous node is a user or coalesced membership events: do not insert
;; header.
nil)
(t
;; Previous node is not an event and not a read marker: insert header.
(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)
;; Not sure whether to use earliest or latest ts; let's try this for now.
(ement-room-membership-events-earliest-ts data))
(`(ts ,ts)
;; Matrix server timestamps are in ms, so we must convert back.
(* 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)
;; HACK: Insert after any read markers.
(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"
;; NOTE: `format-event' is only for debugging, and it
;; doesn't handle user headers, so commenting it out or now.
;; (format-event (ewoc-data event-node-before))
;; NOTE: And it's *Very Bad* to pass the raw node data
;; to `ement-debug', because it makes event insertion
;; *Very Slow*. So we just comment that out for now.
;; (ewoc-data event-node-before)
)
(ewoc-enter-after ewoc event-node-before event)))
(when ement-room-coalesce-events
;; Try to coalesce events.
;; TODO: Move this to a separate function and call it from where this function is called.
(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))
;; Return 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
;; TODO: Record old events in new event's local data, and make it accessible when inspecting the new event.
(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))))))))))
;;;;; Formatting
(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."
;; TODO: Use handlers to insert so e.g. membership events can be inserted silently.
;; TODO: Use `cl-defmethod' and define methods for each of these THING types. (I've
;; benchmarked thoroughly and found no difference in performance between using
;; `cl-defmethod' and using a `defun' with `pcase', so as long as the `cl-defmethod'
;; specializer is sufficient, I see no reason not to use it.)
(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)) ;; Insert a date header.
(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)
;; HACK: Rather than using another variable, compare the format strings to
;; determine whether the date is changing: if so, add a newline before the header.
(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)
;; "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--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)
;; TODO: Define these with a macro, like the defevent and format-spec ones.
("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"
;; Handled by defevent-based handler.
"")
("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."
;; TODO: Like other events, pop to a buffer showing the raw reaction events when a key is pressed.
(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)
;; NOTE: If the reaction key string is a Unicode character composed
;; with, e.g. "VARIATION SELECTOR-16", `string-to-char' ignores the
;; composed modifier/variation-selector and just returns the first
;; character of the string. This should be fine, since it's just
;; for the tooltip.
(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."
;; Bind this locally so formatters can modify it for this call.
(let ((ement-room--format-message-margin-p)
(left-margin-width ement-room-left-margin-width)
(right-margin-width ement-room-right-margin-width))
;; Copied from `format-spec'.
(with-temp-buffer
;; Pretend this is a room buffer.
(setf ement-session session
ement-room room)
;; HACK: Setting these buffer-locally in a temp buffer is ugly.
(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) ?%)
;; Quoted percent sign.
(delete-char 1))
((looking-at "\\([-0-9.]*\\)\\([a-zA-Z]\\)")
;; Valid format spec.
(let* ((num (match-string 1))
(spec (string-to-char (match-string 2)))
(_
;; We delete the specifier now, because the formatter may change the
;; match data, and we already have what we need.
(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)))))
;; Pad result to desired length.
(text (format (concat "%" num "s") val)))
(insert text)))
(t
;; Signal an error on bogus format strings.
(error "ement-room--format-message: Invalid format string: %S" format))))
;; Propertize margin text.
(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)
;; We apply the prefix to the entire event as `wrap-prefix', and to just the
;; body as `line-prefix'.
(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)))))
;; It would be preferable to not have to allocate a string to
;; calculate the display width, but I don't know of another way.
(put-text-property (point-min) (point)
'display `((margin left-margin)
,(buffer-substring (point-min) (point))))
(save-excursion
(goto-char (point-min))
;; Insert a string with a display specification that causes it to be displayed in the
;; left margin as a space that displays with the width of the difference between the
;; left margin's width and the display width of the text in the left margin (whew).
;; This is complicated, but it seems to work (minus a possible Emacs/Gtk bug that
;; sometimes causes the space to have a little "junk" displayed in it at times, but
;; that's not our fault). (And this is another example of how well-documented Emacs
;; is: this was only possible by carefully reading the Elisp manual.)
(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))))
;; Relocate its text to the beginning so it won't be
;; displayed at the last line of wrapped messages.
(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 the string so as not to add face properties to the one in the struct.
(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
;; TODO: Face for m.notices.
((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
;; HACK: Once I got an error when body was nil, so let's avoid that.
(setf body (ement-room--linkify-urls body)))
;; HACK: Ensure body isn't nil (e.g. redacted messages can have empty bodies).
(unless body
(setf body (copy-sequence
;; Yes, copying this string is necessary here too, otherwise a single
;; string will be used across every call to this function, whose face
;; properties will be added to every time in other functions, which will
;; make a very big mess of face properties if a room's buffer is opened
;; and closed a few times.
(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)
;; Message is an edit.
(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
;; NOTE: We workaround `shr`'s not indenting the blockquote properly (it
;; doesn't seem to compensate for the margin). I don't know exactly how
;; `shr-tag-blockquote' and `shr-mark-fill' and `shr-fill-line' and
;; `shr-indentation' work together, but through trial-and-error, this
;; seems to work. It even seems to work properly when a window is
;; resized (i.e. the wrapping is adjusted automatically by redisplay
;; rather than requiring the message to be re-rendered to HTML).
(let ((shr-use-fonts ement-room-shr-use-fonts)
(old-fn (symbol-function 'shr-tag-blockquote))) ;; Bind to a var to avoid unknown-function linting errors.
(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 " "))
;; NOTE: We use our own gv, `ement-text-property'; very convenient.
(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)))
;; FIXME: `ement--user-displayname-in' may not be returning the right result for the
;; local user, so test the displayname slot too. (But even that may be nil sometimes?
;; Something needs to be fixed...)
;; HACK: So we use the username slot, which was created just for this, for now.
(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."
;; Is there an existing Emacs function to do this? I couldn't find one.
;; Yes, maybe: `goto-address-mode'. TODO: Try goto-address-mode.
(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)))
;; NOTE: This function is not useful when displaynames are shown in the margin, because
;; margins are not mouse-interactive in Emacs, therefore the help-echo function is called
;; with the string and the position in the string, which leaves the buffer position
;; unknown. So we have to set the help-echo to a string rather than a function. But the
;; function may be useful in the future, so leaving it commented for now.
;; (defun ement-room--user-help-echo (window _object pos)
;; "Return user ID string for POS in WINDOW.
;; For use as a `help-echo' function on `ement-user' headings."
;; (let ((data (with-selected-window window
;; (ewoc-data (ewoc-locate ement-ewoc pos)))))
;; (cl-typecase data
;; (ement-event (ement-user-id (ement-event-sender data)))
;; (ement-user (ement-user-id data)))))
(defun ement-room--user-color (user)
"Return a color in which to display USER's messages."
(cl-labels ((relative-luminance (rgb)
;; Copy of `modus-themes-wcag-formula', an elegant
;; implementation by Protesilaos Stavrou. Also see
;; <https://en.wikipedia.org/wiki/Relative_luminance> and
;; <https://www.w3.org/TR/WCAG20/#relativeluminancedef>.
(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)
;; Copy of `modus-themes-contrast'; see above.
(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)
;; Avoid infinite loop in case of weirdness
;; by returning color as a fallback.
finally return (or new-color color)))))
(let* ((id (ement-user-id user))
(id-hash (float (+ (abs (sxhash id)) ement-room-prism-color-adjustment)))
;; TODO: Wrap-around the value to get the color I want.
(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))))))
;;;;; Compose buffer
;; Compose messages in a separate buffer, like `org-edit-special'.
(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)
;; TODO: Make mode configurable.
(when body
(insert body))
;; FIXME: Inexplicably, this doesn't do anything, so we comment it out for now.
;; (add-function :override (local 'org-mode)
;; ;; HACK: Since `org-mode' kills buffer-local variables we need, we add
;; ;; buffer-local advice to prevent that from happening in case a user enables it.
;; (lambda (&rest _ignore)
;; (message "Use `ement-room-compose-org' to activate Org in this buffer")))
;; NOTE: Surprisingly, we don't run this hook in `ement-room-init-compose-buffer',
;; because if a function in that hook calls the init function (like
;; `ement-room-compose-org' does), it makes `run-hooks' recursive. As long as this
;; is the only function that makes the compose buffer, and as long as none of the
;; hooks do anything that activating `org-mode' nullifies, this should be okay...
(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)
;; TODO: When requiring Emacs 27, use `letrec'.
;; HACK: I can't seem to find a better way to do this, to exit the minibuffer without exiting this command too.
(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) ; Capture this value from the minibuffer.
(send-message-filter ement-room-send-message-filter)
(replying-to-event ement-room-replying-to-event)
(compose-fn (lambda ()
;; HACK: Since exiting the minibuffer restores the previous window configuration,
;; we have to do some magic to get the new compose buffer to appear.
;; TODO: Use letrec with Emacs 27.
(remove-hook 'minibuffer-exit-hook compose-fn-symbol)
;; FIXME: Probably need to unintern the symbol.
(ement-room-compose-message ement-room ement-session :body body)
;; FIXME: This doesn't propagate the send-message-filter to the minibuffer.
(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)
;; FIXME: Probably need to unintern the 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 minibuffer's input method, otherwise subsequent
;; minibuffers will have it, too.
(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)
;; Putting it in the kill ring seems like the best thing to do, to ensure
;; it doesn't get lost if the user exits the minibuffer before sending.
(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) ; Bind around read-string call.
(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'."
;; Using a macro for this seems awkward but necessary.
(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))
;; FIXME: Compose with local map?
(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)))))
;;;;; Widgets
(require 'widget)
(define-widget 'ement-room-membership 'item
"Widget for membership events."
;; FIXME: This makes it hard to add a timestamp according to the buffer's message format spec.
;; NOTE: The widget needs something before and after "%v" to correctly apply the
;; `ement-room-membership' face. We could use a zero-width space, but that won't work on
;; a TTY. So we use a regular space but replace it with nothing with a display spec.
: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."
;; SPEC: Section 9.3.4: "m.room.member".
(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)
;; For "non-empty-string". Needed because the displayname can be
;; an empty string, but apparently is never null. (Note that the
;; argument should be a variable, never any other form, to avoid
;; multiple evaluation.)
`(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)))))))))
;; NOTE: Widgets are only currently used for single membership events, not grouped ones.
(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)
;; Remove apparent duplicates between join/rejoin 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 ", ")))
"; "))))))))
;;;;; Images
;; Downloading and displaying images in messages, room/user avatars, etc.
(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))
;; Image scaling commands set :max-height and friends to nil so use the
;; impossible dummy value -1. See <https://github.com/alphapapa/ement.el/issues/39>.
(new-height (if (= window-height (or (image-property image :max-height) -1))
(/ window-height 10)
window-height)))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(setf (image-property image :type) 'imagemagick))
;; Set :scale to nil since image scaling commands might have changed it.
(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)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(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)
;; HACK: Get the room's buffer from the variable (the current buffer
;; will be a temp formatting buffer when this is called, but it still
;; inherits the `ement-room' variable from the room buffer, thankfully).
((cl-struct ement-room local) ement-room)
((map buffer) local)
;; TODO: Thumbnail support.
((map ('url mxc) info ;; ('thumbnail_url thumbnail-url)
) 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)))
;; (thumbnail-url (ement--mxc-to-url thumbnail-url ement-session))
)
(if (and ement-room-images image)
;; Images enabled and image downloaded: create image and
;; return it in a string.
(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)
;; Calculate max image display size.
(cond (ement-room-image-initial-height
;; Use configured value.
(setf max-height (truncate
;; Emacs doesn't like floats as the max-height.
(* (window-body-height buffer-window t)
ement-room-image-initial-height))
max-width (window-body-width buffer-window t)))
(buffer-window
;; Buffer displayed: use window size.
(setf max-height (window-body-height buffer-window t)
max-width (window-body-width buffer-window t)))
(t
;; Buffer not displayed: use frame size.
(setf max-height (frame-pixel-height)
max-width (frame-pixel-width))))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(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))))
;; Image not downloaded: insert URL as button, and download if enabled.
(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)
;; Images enabled and URL present: download it.
(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)
;; This shouldn't happen, but very rarely, it can. I haven't figured out why
;; yet, so checking whether a node is found rather than blindly calling
;; `ewoc-invalidate' prevents an error from aborting event processing.
(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."
;; TODO: Insert thumbnail images when enabled.
(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."
;; TODO: Insert thumbnail images when enabled.
(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)))))
;;;;; Org format sending
;; Some of these declarations may need updating as Org changes.
(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"))
;; Calling `org-mode' seems to wipe out local variables.
(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)
;; The CONTENT alist has string keys before being sent.
(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)
;; Riot's syntax coloring doesn't support "elisp", but "lisp" works.
("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>"
;; Build caption.
(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))))))
;; Contents.
(format "<pre><code class=\"src language-%s\"%s>%s</code></pre>"
lang label code))))))
;;;;; Completion
;; Completing member and room names.
(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
;; The manual seems to show the FUN ignoring any
;; arguments, but the `completion-table-dynamic' docstring
;; seems to say that it should use the argument.
(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
;; The manual seems to show the FUN ignoring any
;; arguments, but the `completion-table-dynamic' docstring
;; seems to say that it should use the argument.
(lambda (_ignore)
(ement-room--room-aliases-and-ids)))))
(when beg
(list beg end collection-fn :exclusive 'no))))
;; TODO: Use `cl-pushnew' in these two functions instead of `delete-dups'.
(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'."
;; For now, we just collect a list of members from events we've seen.
;; TODO: In the future, we may maintain a per-room table of members, which
;; would be more suitable for completing names according to the spec.
(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)
;; HACK: Members table empty: update list and use known events
;; for now.
(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))))))
;;;;; Transient
(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)))))])
;;;; Footer
(provide 'ement-room)
;;; ement-room.el ends here
;;; ement-room-list.el --- List Ement rooms -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;; This library implements a room list view using `taxy' and `taxy-magit-section' for
;; dynamic, programmable grouping.
;;; Code:
(require 'button)
(require 'rx)
(require 'persist)
(require 'svg-lib)
(require 'taxy)
(require 'taxy-magit-section)
(require 'ement-lib)
(defgroup ement-room-list nil
"Group Ement rooms with Taxy."
:group 'ement)
;;;; Mouse commands
;; Since mouse-activated commands must handle mouse events, we define a simple macro to
;; wrap a command into a mouse-event-accepting one.
(defmacro ement-room-list-define-mouse-command (command)
"Define a command that calls COMMAND interactively with point at mouse event.
COMMAND should be a form that evaluates to a function symbol; if
a symbol, it should be unquoted.."
(let ((docstring (format "Call command `%s' interactively with point at EVENT." command))
(name (intern (format "ement-room-list-mouse-%s" command))))
`(defun ,name (event)
,docstring
(interactive "e")
(mouse-set-point event)
(call-interactively #',command))))
;;;; Variables
(declare-function ement-room-toggle-space "ement-room")
(defvar ement-room-list-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'ement-room-list-RET)
(define-key map (kbd "SPC") #'ement-room-list-next-unread)
(define-key map [tab] #'ement-room-list-section-toggle)
(define-key map [mouse-1] (ement-room-list-define-mouse-command ement-room-list-RET))
(define-key map [mouse-2] (ement-room-list-define-mouse-command ement-room-list-kill-buffer))
(define-key map (kbd "k") #'ement-room-list-kill-buffer)
(define-key map (kbd "s") #'ement-room-toggle-space)
map)
"Keymap for `ement-room-list' buffers.
See also `ement-room-list-button-map'.")
(defvar ement-room-list-button-map
;; This map is needed because some columns are propertized as buttons, which override
;; the main keymap.
;; TODO: Is it possible to adjust the button properties to obviate this map?
(let ((map (make-sparse-keymap)))
(define-key map [mouse-1] (ement-room-list-define-mouse-command ement-room-list-RET))
(define-key map [mouse-2] (ement-room-list-define-mouse-command ement-room-list-kill-buffer))
map)
"Keymap for buttonized text in `ement-room-list' buffers.")
(defvar ement-room-list-timestamp-colors nil
"List of colors used for timestamps.
Set automatically when `ement-room-list-mode' is activated.")
(defvar ement-room)
(defvar ement-session)
(defvar ement-sessions)
(defvar ement-room-prism-minimum-contrast)
;;;;; Persistent variables
(persist-defvar ement-room-list-visibility-cache nil
"Applied to `magit-section-visibility-cache', which see.")
;;;; Customization
(defcustom ement-room-list-auto-update t
"Automatically update the taxy-based room list buffer."
:type 'boolean)
(defcustom ement-room-list-avatars (display-images-p)
"Show room avatars in the room list."
:type 'boolean)
;;;;; Faces
(defface ement-room-list-direct
;; In case `font-lock-constant-face' is bold, we set the weight to normal, so it can be
;; made bold for unread rooms only.
'((t (:weight normal :inherit (font-lock-constant-face ement-room-list-name))))
"Direct rooms.")
(defface ement-room-list-favourite '((t (:inherit (font-lock-doc-face ement-room-list-name))))
"Favourite rooms.")
(defface ement-room-list-invited
'((t (:inherit italic ement-room-list-name)))
"Invited rooms.")
(defface ement-room-list-left
'((t (:strike-through t :inherit ement-room-list-name)))
"Left rooms.")
(defface ement-room-list-low-priority '((t (:inherit (font-lock-comment-face ement-room-list-name))))
"Low-priority rooms.")
(defface ement-room-list-name
'((t (:inherit font-lock-function-name-face button)))
"Non-direct rooms.")
(defface ement-room-list-space '((t (:inherit (font-lock-regexp-grouping-backslash ement-room-list-name))))
"Space rooms."
:group 'ement-room-list)
(defface ement-room-list-unread
'((t (:inherit bold ement-room-list-name)))
"Unread rooms.")
(defface ement-room-list-recent '((t (:inherit font-lock-warning-face)))
"Latest timestamp of recently updated rooms.
The foreground color is used to generate a gradient of colors
from recent to non-recent for rooms updated in the past 24
hours but at least one hour ago.")
(defface ement-room-list-very-recent '((t (:inherit error)))
"Latest timestamp of very recently updated rooms.
The foreground color is used to generate a gradient of colors
from recent to non-recent for rooms updated in the past hour.")
;;;; Keys
;; Since some of these keys need access to the session, and room
;; structs don't include the session, we use a two-element vector in
;; which the session is the second element.
(eval-and-compile
(taxy-define-key-definer ement-room-list-define-key
ement-room-list-keys "ement-room-list-key" "FIXME: Docstring."))
(ement-room-list-define-key membership (&key name status)
;; FIXME: Docstring: status should be a symbol of either `invite', `join', `leave'.
(cl-labels ((format-membership (membership)
(pcase membership
('join "Joined")
('invite "Invited")
('leave "[Left]"))))
(pcase-let ((`[,(cl-struct ement-room (status membership)) ,_session] item))
(if status
(when (equal status membership)
(or name (format-membership membership)))
(format-membership membership)))))
(ement-room-list-define-key alias (&key name regexp)
(pcase-let ((`[,(cl-struct ement-room canonical-alias) ,_session] item))
(when canonical-alias
(when (string-match-p regexp canonical-alias)
name))))
(ement-room-list-define-key buffer ()
(pcase-let ((`[,(cl-struct ement-room (local (map buffer))) ,_session] item))
(when buffer
#("Buffers" 0 7 (help-echo "Rooms with open buffers")))))
(ement-room-list-define-key direct ()
(pcase-let ((`[,room ,session] item))
(when (ement--room-direct-p room session)
"Direct")))
(ement-room-list-define-key people ()
(pcase-let ((`[,room ,session] item))
(when (ement--room-direct-p room session)
(propertize "People" 'face 'ement-room-list-direct))))
(ement-room-list-define-key space (&key name id)
(pcase-let* ((`[,room ,session] item)
((cl-struct ement-session rooms) session)
((cl-struct ement-room type (local (map parents))) room))
(cl-labels ((format-space (id)
(let* ((parent-room (cl-find id rooms :key #'ement-room-id :test #'equal))
(space-name (if parent-room
(ement-room-display-name parent-room)
id)))
(concat "Space: " space-name))))
(when-let ((key (if id
;; ID specified.
(cond ((or (member id parents)
(equal id (ement-room-id room)))
;; Room is in specified space.
(or name (format-space id)))
((and (equal type "m.space")
(equal id (ement-room-id room)))
;; Room is a specified space.
(or name (concat "Space: " (ement-room-display-name room)))))
;; ID not specified.
(pcase (length parents)
(0 nil)
(1
;; TODO: Make the rooms list a hash table to avoid this lookup.
(format-space (car parents)))
(_
;; TODO: How to handle this better? (though it should be very rare)
(string-join (mapcar #'format-space parents) ", "))))))
(propertize key 'face 'ement-room-list-space)))))
(ement-room-list-define-key space-p ()
"Groups rooms that are themselves spaces."
(pcase-let* ((`[,room ,_session] item)
((cl-struct ement-room type) room))
(when (equal "m.space" type)
"Spaces")))
(ement-room-list-define-key name (&key name regexp)
(pcase-let* ((`[,room ,_session] item)
(display-name (ement--room-display-name room)))
(when display-name
(when (string-match-p regexp display-name)
(or name regexp)))))
(ement-room-list-define-key latest (&key name newer-than older-than)
(pcase-let* ((`[,room ,_session] item)
((cl-struct ement-room latest-ts) room)
(age))
(when latest-ts
(setf age (- (time-convert nil 'integer) (/ latest-ts 1000)))
(cond (newer-than
(when (<= age newer-than)
(or name (format "Newer than %s seconds" newer-than))))
(older-than
(when (>= age older-than)
(or name (format "Older than %s seconds" newer-than))))
(t
;; Default to rooms with traffic in the last day.
(if (<= age 86400)
"Last 24 hours"
"Older than 24 hours"))))))
(ement-room-list-define-key freshness
(&key (intervals '((86400 . "Past 24h")
(604800 . "Past week")
(2419200 . "Past month")
(31536000 . "Past year"))))
(pcase-let* ((`[,room ,_session] item)
((cl-struct ement-room latest-ts) room)
(age))
(when latest-ts
(setf age (- (time-convert nil 'integer) (/ latest-ts 1000)))
(or (alist-get age intervals nil nil #'>)
"Older than a year"))))
(ement-room-list-define-key session (&optional user-id)
(pcase-let ((`[,_room ,(cl-struct ement-session
(user (cl-struct ement-user id)))]
item))
(pcase user-id
(`nil id)
(_ (when (equal user-id id)
user-id)))))
(ement-room-list-define-key topic (&key name regexp)
(pcase-let ((`[,(cl-struct ement-room topic) ,_session] item))
(when (and topic (string-match-p regexp topic))
name)))
(ement-room-list-define-key unread ()
(pcase-let ((`[,room ,session] item))
(when (ement--room-unread-p room session)
"Unread")))
(ement-room-list-define-key favourite ()
:then #'identity
(pcase-let ((`[,room ,_session] item))
(when (ement--room-favourite-p room)
(propertize "Favourite" 'face 'ement-room-list-favourite))))
(ement-room-list-define-key low-priority ()
:then #'identity
(pcase-let ((`[,room ,_session] item))
(when (ement--room-low-priority-p room)
"Low-priority")))
(defcustom ement-room-list-default-keys
'(;; First, group all invitations (this group will appear first since the rooms are
;; already sorted first).
((membership :status 'invite))
;; Group all left rooms (this group will appear last, because the rooms are already
;; sorted last).
((membership :status 'leave))
;; Group all favorite rooms, which are already sorted first.
(favourite)
;; Group all low-priority rooms, which are already sorted last, and within that group,
;; group them by their space, if any.
(low-priority space)
;; Group other rooms which are opened in a buffer.
(buffer)
;; Group other rooms which are unread.
(unread)
;; Group other rooms which are in a space by freshness, then by space.
((and :name "Spaced"
:keys ((not space-p)
space))
freshness space)
;; Group spaces themselves by their parent space (since space headers can't also be
;; items, we have to handle them separately; a bit of a hack, but not too bad).
((and :name "Spaces" :keys (space-p))
space)
;; Group rooms which aren't in spaces by their freshness.
((and :name "Unspaced"
:keys ((not space)
(not people)))
freshness)
;; Group direct rooms by freshness.
(people freshness))
"Default keys."
:type 'sexp)
;;;; Columns
(eval-and-compile
(taxy-magit-section-define-column-definer "ement-room-list"))
(ement-room-list-define-column #("🐱" 0 1 (help-echo "Avatar")) (:align 'right)
(pcase-let* ((`[,room ,_session] item)
((cl-struct ement-room avatar display-name
(local (map room-list-avatar)))
room))
(if ement-room-list-avatars
(or room-list-avatar
(let ((new-avatar
(if avatar
;; NOTE: We resize every avatar to be suitable for this buffer, rather than using
;; the one cached in the room's struct. If the buffer's faces change height, this
;; will need refreshing, but it should be worth it to avoid resizing the images on
;; every update.
(propertize " " 'display
(ement--resize-image (get-text-property 0 'display avatar)
nil (frame-char-height)))
;; Room has no avatar: make one.
(let* ((string (or display-name (ement--room-display-name room)))
(ement-room-prism-minimum-contrast 1)
(color (ement--prism-color string :contrast-with "white")))
(when (string-match (rx bos (or "#" "!" "@")) string)
(setf string (substring string 1)))
(propertize " " 'display (svg-lib-tag (substring string 0 1) nil
:background color :foreground "white"
:stroke 0))))))
(setf (alist-get 'room-list-avatar (ement-room-local room)) new-avatar)))
;; Avatars disabled: use a two-space string.
" ")))
(ement-room-list-define-column "Name" (:max-width 25)
(pcase-let* ((`[,room ,session] item)
((cl-struct ement-room type) room)
(display-name (ement--room-display-name room))
(face))
(or (when display-name
;; TODO: Use code from ement-room-list and put in a dedicated function.
(setf face (cl-copy-list '(:inherit (ement-room-list-name))))
;; In concert with the "Unread" column, this is roughly equivalent to the
;; "red/gray/bold/idle" states listed in <https://github.com/matrix-org/matrix-react-sdk/blob/b0af163002e8252d99b6d7075c83aadd91866735/docs/room-list-store.md#list-ordering-algorithm-importance>.
(when (ement--room-unread-p room session)
;; For some reason, `push' doesn't work with `map-elt'...or does it?
(push 'ement-room-list-unread (map-elt face :inherit)))
(when (equal "m.space" type)
(push 'ement-room-list-space (map-elt face :inherit)))
(when (ement--room-direct-p room session)
(push 'ement-room-list-direct (map-elt face :inherit)))
(when (ement--room-favourite-p room)
(push 'ement-room-list-favourite (map-elt face :inherit)))
(when (ement--room-low-priority-p room)
(push 'ement-room-list-low-priority (map-elt face :inherit)))
(pcase (ement-room-status room)
('invite
(push 'ement-room-list-invited (map-elt face :inherit)))
('leave
(push 'ement-room-list-left (map-elt face :inherit))))
(propertize display-name
'face face
'mouse-face 'highlight
'keymap ement-room-list-button-map))
"")))
(ement-room-list-define-column #("Unread" 0 6 (help-echo "Unread events (Notifications:Highlights)")) (:align 'right)
(pcase-let* ((`[,(cl-struct ement-room unread-notifications) ,_session] item)
((map notification_count highlight_count) unread-notifications))
(if (or (not unread-notifications)
(and (equal 0 notification_count)
(equal 0 highlight_count)))
""
(concat (propertize (number-to-string notification_count)
'face (if (zerop highlight_count)
'default
'ement-room-mention))
":"
(propertize (number-to-string highlight_count)
'face 'highlight)))))
(ement-room-list-define-column "Latest" ()
(pcase-let ((`[,(cl-struct ement-room latest-ts) ,_session] item))
(if latest-ts
(let* ((difference-seconds (- (float-time) (/ latest-ts 1000)))
(n (cl-typecase difference-seconds
((number 0 3599) ;; <1 hour: 10-minute periods.
(truncate (/ difference-seconds 600)))
((number 3600 86400) ;; 1 hour to 1 day: 24 1-hour periods.
(+ 6 (truncate (/ difference-seconds 3600))))
(otherwise ;; Difference in weeks.
(min (/ (length ement-room-list-timestamp-colors) 2)
(+ 24 (truncate (/ difference-seconds 86400 7)))))))
(face (list :foreground (elt ement-room-list-timestamp-colors n)))
(formatted-ts (ement--human-format-duration difference-seconds 'abbreviate)))
(string-match (rx (1+ digit) (repeat 1 alpha)) formatted-ts)
(propertize (match-string 0 formatted-ts) 'face face
'help-echo formatted-ts))
"")))
(ement-room-list-define-column "Topic" (:max-width 35)
(pcase-let ((`[,(cl-struct ement-room topic status) ,_session] item))
;; FIXME: Can the status and type unified, or is this inherent to the spec?
(when topic
(setf topic (replace-regexp-in-string "\n" " " topic 'fixedcase 'literal)))
(pcase status
('invite (concat (propertize "[invited]"
'face 'ement-room-list-invited)
" " topic))
('leave (concat (propertize "[left]"
'face 'ement-room-list-left)
" " topic))
(_ (or topic "")))))
(ement-room-list-define-column "Members" (:align 'right)
(pcase-let ((`[,(cl-struct ement-room
(summary (map ('m.joined_member_count member-count))))
,_session]
item))
(if member-count
(number-to-string member-count)
"")))
(ement-room-list-define-column #("Notifications" 0 5 (help-echo "Notification state")) ()
(pcase-let* ((`[,room ,session] item))
(pcase (ement-room-notification-state room session)
('nil "default")
('all-loud "all (loud)")
('all "all")
('mentions-and-keywords "mentions")
('none "none"))))
(ement-room-list-define-column #("B" 0 1 (help-echo "Buffer exists for room")) ()
(pcase-let ((`[,(cl-struct ement-room (local (map buffer))) ,_session] item))
(if buffer
#("B" 0 1 (help-echo "Buffer exists for room"))
" ")))
(ement-room-list-define-column "Session" ()
(pcase-let ((`[,_room ,(cl-struct ement-session (user (cl-struct ement-user id)))] item))
id))
(unless ement-room-list-columns
;; TODO: Automate this or document it
(setq-default ement-room-list-columns
(get 'ement-room-list-columns 'standard-value)))
;;;; Bookmark support
;; Especially useful with Burly: <https://github.com/alphapapa/burly.el>
(require 'bookmark)
(defun ement-room-list-bookmark-make-record ()
"Return a bookmark record for the `ement-room-list' buffer."
(list "*Ement Room List*"
(cons 'handler #'ement-room-list-bookmark-handler)))
(defun ement-room-list-bookmark-handler (bookmark)
"Show `ement-room-list' room list buffer for BOOKMARK."
(pcase-let* ((`(,_bookmark-name . ,_) bookmark))
(unless ement-sessions
;; MAYBE: Automatically connect.
(user-error "No sessions connected: call `ement-connect' first"))
(ement-room-list)))
;;;; Commands
(defun ement-room-list-section-toggle ()
"Toggle the section at point."
;; HACK: For some reason, when a section's body is hidden, then the buffer is refreshed,
;; and then the section's body is shown again, the body is empty--but then, refreshing
;; the buffer shows its body. So we work around that by refreshing the buffer when a
;; section is toggled. In a way, it makes sense to do this anyway, so the user has the
;; most up-to-date information in the buffer. This hack also works around a minor
;; visual bug that sometimes causes room avatars to be displayed in a section heading
;; when a section is hidden.
(interactive)
(ignore-errors
;; Ignore an error in case point is past the top-level section.
(cl-typecase (aref (oref (magit-current-section) value) 0)
(ement-room
;; HACK: Don't hide rooms themselves (they end up permanently hidden).
nil)
(otherwise
(call-interactively #'magit-section-toggle)
(revert-buffer)))))
;;;###autoload
(defun ement-room-list--after-initial-sync (&rest _ignore)
"Call `ement-room-list', ignoring arguments.
To be called from `ement-after-initial-sync-hook'."
(ement-room-list))
;;;###autoload
(defalias 'ement-list-rooms 'ement-room-list)
;;;###autoload
(cl-defun ement-room-list (&key (buffer-name "*Ement Room List*")
(keys ement-room-list-default-keys)
(display-buffer-action '((display-buffer-reuse-window display-buffer-same-window)))
;; visibility-fn
)
"Show a buffer listing Ement rooms, grouped with Taxy KEYS.
After showing it, its window is selected. The buffer is named
BUFFER-NAME and is shown with DISPLAY-BUFFER-ACTION; or if
DISPLAY-BUFFER-ACTION is nil, the buffer is not displayed."
(interactive)
(let ((window-start 0) (window-point 0)
format-table column-sizes)
(cl-labels (;; (heading-face
;; (depth) (list :inherit (list 'bufler-group (bufler-level-face depth))))
(format-item (item) (gethash item format-table))
;; NOTE: Since these functions take an "item" (which is a [room session]
;; vector), they're prefixed "item-" rather than "room-".
(item-latest-ts (item)
(or (ement-room-latest-ts (elt item 0))
;; Room has no latest timestamp. FIXME: This shouldn't
;; happen, but it can, maybe due to oversights elsewhere.
0))
(item-unread-p (item)
(pcase-let ((`[,room ,session] item))
(ement--room-unread-p room session)))
(item-left-p (item)
(pcase-let ((`[,(cl-struct ement-room status) ,_session] item))
(equal 'leave status)))
(item-buffer-p (item)
(pcase-let ((`[,(cl-struct ement-room (local (map buffer))) ,_session] item))
(buffer-live-p buffer)))
(taxy-unread-p (taxy)
(or (cl-some #'item-unread-p (taxy-items taxy))
(cl-some #'taxy-unread-p (taxy-taxys taxy))))
(item-space-p (item)
(pcase-let ((`[,(cl-struct ement-room type) ,_session] item))
(equal "m.space" type)))
(item-favourite-p (item)
(pcase-let ((`[,room ,_session] item))
(ement--room-favourite-p room)))
(item-low-priority-p (item)
(pcase-let ((`[,room ,_session] item))
(ement--room-low-priority-p room)))
(visible-p (section)
;; This is very confusing and doesn't currently work.
(let ((value (oref section value)))
(if (cl-typecase value
(taxy-magit-section (item-unread-p value))
(ement-room nil))
'show
'hide)))
(item-invited-p (item)
(pcase-let ((`[,(cl-struct ement-room status) ,_session] item))
(equal 'invite status)))
(taxy-latest-ts (taxy)
(apply #'max most-negative-fixnum
(delq nil
(list
(when (taxy-items taxy)
(item-latest-ts (car (taxy-items taxy))))
(when (taxy-taxys taxy)
(cl-loop for sub-taxy in (taxy-taxys taxy)
maximizing (taxy-latest-ts sub-taxy)))))))
(t<nil (a b) (and a (not b)))
(t>nil (a b) (and (not a) b))
(make-fn (&rest args)
(apply #'make-taxy-magit-section
:make #'make-fn
:format-fn #'format-item
:level-indent ement-room-list-level-indent
;; :visibility-fn #'visible-p
;; :heading-indent 2
:item-indent 2
;; :heading-face-fn #'heading-face
args)))
;; (when (get-buffer buffer-name)
;; (kill-buffer buffer-name))
(unless ement-sessions
(error "Ement: Not connected. Use `ement-connect' to connect"))
(if (not (cl-loop for (_id . session) in ement-sessions
thereis (ement-session-rooms session)))
(ement-message "No rooms have been joined")
(with-current-buffer (get-buffer-create buffer-name)
(unless (eq 'ement-room-list-mode major-mode)
(ement-room-list-mode))
(let* ((room-session-vectors
(cl-loop for (_id . session) in ement-sessions
append (cl-loop for room in (ement-session-rooms session)
collect (vector room session))))
(taxy (cl-macrolet ((first-item
(pred) `(lambda (taxy)
(when (taxy-items taxy)
(,pred (car (taxy-items taxy))))))
(name= (name) `(lambda (taxy)
(equal ,name (taxy-name taxy)))))
(thread-last
(make-fn
:name "Ement Rooms"
:take (taxy-make-take-function keys ement-room-list-keys))
(taxy-fill room-session-vectors)
(taxy-sort #'> #'item-latest-ts)
(taxy-sort #'t<nil #'item-invited-p)
(taxy-sort #'t<nil #'item-favourite-p)
(taxy-sort #'t>nil #'item-low-priority-p)
(taxy-sort #'t<nil #'item-unread-p)
(taxy-sort #'t<nil #'item-space-p)
;; Within each taxy, left rooms should be sorted last so that one
;; can never be the first room in the taxy (unless it's the taxy
;; of left rooms), which would cause the taxy to be incorrectly
;; sorted last.
(taxy-sort #'t>nil #'item-left-p)
(taxy-sort* #'string< #'taxy-name)
(taxy-sort* #'> #'taxy-latest-ts)
(taxy-sort* #'t<nil (name= "Buffers"))
(taxy-sort* #'t<nil (first-item item-unread-p))
(taxy-sort* #'t<nil (first-item item-favourite-p))
(taxy-sort* #'t<nil (first-item item-invited-p))
(taxy-sort* #'t>nil (first-item item-space-p))
(taxy-sort* #'t>nil (name= "Low-priority"))
(taxy-sort* #'t>nil (first-item item-left-p)))))
(taxy-magit-section-insert-indent-items nil)
(inhibit-read-only t)
(format-cons (taxy-magit-section-format-items
ement-room-list-columns ement-room-list-column-formatters taxy))
(pos (point))
(section-ident (when (magit-current-section)
(magit-section-ident (magit-current-section)))))
(setf format-table (car format-cons)
column-sizes (cdr format-cons)
header-line-format (taxy-magit-section-format-header
column-sizes ement-room-list-column-formatters))
(when-let ((window (get-buffer-window (current-buffer))))
(setf window-point (window-point window)
window-start (window-start window)))
(when ement-room-list-visibility-cache
(setf magit-section-visibility-cache ement-room-list-visibility-cache))
(add-hook 'kill-buffer-hook #'ement-room-list--cache-visibility nil 'local)
;; Before this point, no changes have been made to the buffer's contents.
(delete-all-overlays)
(erase-buffer)
(save-excursion
(taxy-magit-section-insert taxy :items 'first
;; :blank-between-depth bufler-taxy-blank-between-depth
:initial-depth 0))
(if-let* ((section-ident)
(section (magit-get-section section-ident)))
(goto-char (oref section start))
(goto-char pos))))
(when display-buffer-action
(when-let ((window (display-buffer buffer-name display-buffer-action)))
(select-window window)))
(when-let ((window (get-buffer-window buffer-name)))
(set-window-start window window-start)
(set-window-point window window-point))
;; FIXME: Despite all this code to save and restore point and window point and
;; window start, when I send a message from the minibuffer, or when I abort
;; sending a message from the minibuffer, point is moved to the beginning of the
;; buffer. While the minibuffer is open (and the typing messages are being sent
;; to the server, causing it to repeatedly sync), the point stays in the correct
;; place. I can't find any reason why this happens. It makes no sense. And
;; while trying to debug the problem, somehow Emacs got put into an unbreakable,
;; infinite loop twice; even C-g and SIGUSR2 didn't stop it.
;; NOTE: In order for `bookmark--jump-via' to work properly, the restored buffer
;; must be set as the current buffer, so we have to do this explicitly here.
(set-buffer buffer-name)))))
(cl-defun ement-room-list-side-window (&key (side 'left))
"Show room list in side window on SIDE.
Interactively, with prefix, show on right side; otherwise, on
left."
(interactive (when current-prefix-arg
(list :side 'right)))
(let ((display-buffer-mark-dedicated t))
;; Not sure if binding `display-buffer-mark-dedicated' is still necessary.
(ement-room-list
:display-buffer-action `(display-buffer-in-side-window
(dedicated . t)
(side . ,side)
(window-parameters
(no-delete-other-windows . t))))))
(defun ement-room-list-revert (&optional _ignore-auto _noconfirm)
"Revert current Ement-Room-List buffer."
(interactive)
(with-current-buffer "*Ement Room List*"
;; FIXME: This caching of the visibility only supports the main buffer with the
;; default name, not any special ones with different names.
(setf ement-room-list-visibility-cache magit-section-visibility-cache))
(ement-room-list :display-buffer-action nil))
(defun ement-room-list-kill-buffer (room)
"Kill ROOM's buffer."
(interactive
(ement-with-room-and-session
(ignore ement-session)
(list ement-room)))
(pcase-let (((cl-struct ement-room (local (map buffer))) room)
(kill-buffer-query-functions))
(when (buffer-live-p buffer)
(kill-buffer buffer)
(ement-room-list-revert))))
(declare-function ement-view-room "ement-room")
(defun ement-room-list-RET ()
"View room at point, or cycle section at point."
(declare (function ement-view-space "ement-room"))
(interactive)
(cl-etypecase (oref (magit-current-section) value)
(vector (pcase-let ((`[,room ,session] (oref (magit-current-section) value)))
(if (ement--space-p room)
(ement-view-space room session)
(ement-view-room room session))))
(taxy-magit-section (call-interactively #'ement-room-list-section-toggle))
(null nil)))
(declare-function ement-room-goto-fully-read-marker "ement-room")
(defun ement-room-list-next-unread ()
"Show next unread room."
(interactive)
(when (eobp)
(goto-char (point-min)))
(unless (cl-loop with starting-line = (line-number-at-pos)
for value = (oref (magit-current-section) value)
if (and (vectorp value)
(ement--room-unread-p (elt value 0) (elt value 1)))
do (progn
(ement-view-room (elt value 0) (elt value 1))
(ement-room-goto-fully-read-marker)
(cl-return t))
else do (forward-line 1)
while (and (not (eobp))
(> (line-number-at-pos) starting-line)))
;; No more unread rooms.
(message "No more unread rooms")))
(define-derived-mode ement-room-list-mode magit-section-mode "Ement-Room-List"
:global nil
(setq-local bookmark-make-record-function #'ement-room-list-bookmark-make-record
revert-buffer-function #'ement-room-list-revert
ement-room-list-timestamp-colors (ement-room-list--timestamp-colors)))
;;;; Functions
(defun ement-room-list--cache-visibility ()
"Save visibility cache.
Sets `ement-room-list-visibility-cache' to the value of
`magit-section-visibility-cache'. To be called in
`kill-buffer-hook'."
(ignore-errors
(when magit-section-visibility-cache
(setf ement-room-list-visibility-cache magit-section-visibility-cache))))
;;;###autoload
(defun ement-room-list-auto-update (_session)
"Automatically update the Taxy room list buffer.
+Does so when variable `ement-room-list-auto-update' is non-nil.
+To be called in `ement-sync-callback-hook'."
(when (and ement-room-list-auto-update
(buffer-live-p (get-buffer "*Ement Room List*")))
(with-current-buffer (get-buffer "*Ement Room List*")
(unless (region-active-p)
;; Don't refresh the list if the region is active (e.g. if the user is trying to
;; operate on multiple rooms).
(revert-buffer)))))
(defun ement-room-list--timestamp-colors ()
"Return a vector of generated latest-timestamp colors for rooms.
Used in `ement-tabulated-room-list' and `ement-room-list'."
(if (or (equal "unspecified-fg" (face-foreground 'default nil 'default))
(equal "unspecified-bg" (face-background 'default nil 'default)))
;; NOTE: On a TTY, the default face's foreground and background colors may be the
;; special values "unspecified-fg"/"unspecified-bg", in which case we can't generate
;; gradients, so we just return a vector of "unspecified-fg". See
;; <https://debbugs.gnu.org/cgi/bugreport.cgi?bug=55623>.
(make-vector 134 "unspecified-fg")
(cl-coerce
(append (mapcar
;; One face per 10-minute period, from "recent" to 1-hour.
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'ement-room-list-very-recent
nil 'default))
(color-name-to-rgb (face-foreground 'ement-room-list-recent
nil 'default))
6))
(mapcar
;; One face per hour, from "recent" to default.
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'ement-room-list-recent
nil 'default))
(color-name-to-rgb (face-foreground 'default nil 'default))
24))
(mapcar
;; One face per week for the last year (actually we
;; generate colors for the past two years' worth so
;; that the face for one-year-ago is halfway to
;; invisible, and we don't use colors past that point).
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'default nil 'default))
(color-name-to-rgb (face-background 'default nil 'default))
104)))
'vector)))
;;;; Footer
(provide 'ement-room-list)
;;; ement-room-list.el ends here
;; Generated package description from ement.el -*- no-byte-compile: t -*-
(define-package "ement" "0.12" "Matrix client" '((emacs "27.1") (map "2.1") (persist "0.5") (plz "0.6") (taxy "0.10") (taxy-magit-section "0.12.1") (svg-lib "0.2.5") (transient "0.3.7")) :commit "a4fc3d1ab6df424bc1296b8ca480a8c55c542dc2" :authors '(("Adam Porter" . "adam@alphapapa.net")) :maintainer '("Adam Porter" . "adam@alphapapa.net") :keywords '("comm") :url "https://github.com/alphapapa/ement.el")
;;; ement-notify.el --- Notifications for Ement events -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;; This library implements notifications for Ement events.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'map)
(require 'notifications)
(require 'ement-lib)
(require 'ement-room)
(eval-when-compile
(require 'ement-structs))
;;;; Variables
(declare-function ement-room-list "ement-room-list")
(defvar ement-notify-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "S-<return>") #'ement-notify-reply)
(define-key map (kbd "M-g M-l") #'ement-room-list)
(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)
(make-composed-keymap (list map button-buffer-map) 'view-mode-map))
"Map for Ement notification buffers.")
(defvar ement-notify-dbus-p
(and (featurep 'dbusbind)
(require 'dbus nil :no-error)
(dbus-ignore-errors (dbus-get-unique-name :session))
;; By default, emacs waits up to 25 seconds for a PONG. Realistically, if there's
;; no pong after 2000ms, there's pretty sure no notification service connected or
;; the system's setup has issues.
(dbus-ping :session "org.freedesktop.Notifications" 2000))
"Whether D-Bus notifications are usable.")
;;;; Customization
(defgroup ement-notify nil
"Notification options."
:group 'ement)
(defcustom ement-notify-ignore-predicates
'(ement-notify--event-not-message-p ement-notify--event-from-session-user-p)
"Display notification if none of these return non-nil for an event.
Each predicate is called with three arguments: the event, the
room, and the session (each the respective struct)."
:type '(repeat (choice (function-item ement-notify--event-not-message-p)
(function-item ement-notify--event-from-session-user-p)
(function :tag "Custom predicate"))))
(defcustom ement-notify-log-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-buffer-live-p
ement-notify--room-unread-p)
"Predicates to determine whether to log an event to the notifications buffer.
If one of these returns non-nil for an event, the event is logged."
:type 'hook
:options '(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-buffer-live-p
ement-notify--room-unread-p))
(defcustom ement-notify-mark-frame-urgent-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p)
"Predicates to determine whether to mark a frame as urgent.
If one of these returns non-nil for an event, the frame that most
recently showed the event's room's buffer is marked
urgent. (Only works on X, not other GUI platforms.)"
:type 'hook
:options '(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p))
(defcustom ement-notify-mention-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p)
"Predicates to determine whether to log an event to the mentions buffer.
If one of these returns non-nil for an event, the event is logged."
:type 'hook
:options '(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p))
(defcustom ement-notify-notification-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-buffer-live-p
ement-notify--room-unread-p)
"Predicates to determine whether to send a desktop notification.
If one of these returns non-nil for an event, the notification is sent."
:type 'hook
:options '(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-buffer-live-p
ement-notify--room-unread-p))
(defcustom ement-notify-sound nil
"Sound to play for notifications."
:type '(choice (file :tag "Sound file")
(string :tag "XDG sound name")
(const :tag "Default XDG message sound" "message-new-instant")
(const :tag "Don't play a sound" nil)))
(defcustom ement-notify-limit-room-name-width nil
"Limit the width of room display names in mentions and notifications buffers.
This prevents the margin from being made excessively wide."
:type '(choice (integer :tag "Maximum width")
(const :tag "Unlimited width" nil)))
(defcustom ement-notify-prism-background nil
"Add distinct background color by room to messages in notification buffers.
The color is specific to each room, generated automatically, and
can help distinguish messages by room."
:type 'boolean)
(defcustom ement-notify-room-avatars t
"Show room avatars in the notifications buffers.
This shows room avatars at the left of the window margin in
notification buffers. It's not customizeable beyond that due to
limitations and complexities of displaying strings and images in
margins in Emacs. But it's useful, anyway."
:type 'boolean)
;;;; Commands
(declare-function ement-room-goto-event "ement-room")
(defun ement-notify-button-action (button)
"Show BUTTON's event in its room buffer."
;; TODO: Is `interactive' necessary here?
(interactive)
(let* ((session (button-get button 'session))
(room (button-get button 'room))
(event (button-get button 'event)))
(ement-view-room room session)
(ement-room-goto-event event)))
(defun ement-notify-reply ()
"Send a reply to event at point."
(interactive)
(save-window-excursion
;; Not sure why `call-interactively' doesn't work for `push-button' but oh well.
(push-button)
(call-interactively #'ement-room-write-reply)))
(defun ement-notify-switch-to-notifications-buffer ()
"Switch to \"*Ement Notifications*\" buffer."
(declare (function ement-notifications "ement-notifications"))
(interactive)
(call-interactively #'ement-notifications))
(defvar ement-notifications-mode-map)
(defun ement-notify-switch-to-mentions-buffer ()
"Switch to \"*Ement Mentions*\" buffer."
(declare (function ement-notifications--log-buffer "ement-notifications"))
(interactive)
(switch-to-buffer (ement-notifications--log-buffer :name "*Ement Mentions*"))
;; HACK: Undo remapping of scroll commands which don't apply in this buffer.
(let ((map (copy-keymap ement-notifications-mode-map)))
(define-key map [remap scroll-down-command] nil)
(define-key map [remap mwheel-scroll] nil)
(use-local-map map)))
;;;; Functions
(defun ement-notify (event room session)
"Send notifications for EVENT in ROOM on SESSION.
Sends if all of `ement-notify-ignore-predicates' return nil.
Does not do anything if session hasn't finished initial sync."
(with-demoted-errors "ement-notify: Error: %S"
(when (and (ement-session-has-synced-p session)
(cl-loop for pred in ement-notify-ignore-predicates
never (funcall pred event room session)))
(when (and ement-notify-dbus-p
(run-hook-with-args-until-success 'ement-notify-notification-predicates event room session))
(ement-notify--notifications-notify event room session))
(when (run-hook-with-args-until-success 'ement-notify-log-predicates event room session)
(ement-notify--log-to-buffer event room session))
(when (run-hook-with-args-until-success 'ement-notify-mention-predicates event room session)
(ement-notify--log-to-buffer event room session :buffer-name "*Ement Mentions*"))
(when (run-hook-with-args-until-success 'ement-notify-mark-frame-urgent-predicates event room session)
(ement-notify--mark-frame-urgent event room session)))))
(defun ement-notify--mark-frame-urgent (_event room _session)
"Mark frame showing ROOM's buffer as urgent.
If ROOM has no existing buffer, do nothing."
(declare
;; These silence lint warnings on our GitHub CI runs, which use a build of Emacs
;; without GUI support.
(function dbus-get-unique-name "dbusbind.c")
(function x-change-window-property "xfns.c")
(function x-window-property "xfns.c"))
(cl-labels ((mark-frame-urgent (frame)
(let* ((prop "WM_HINTS")
(hints (cl-coerce
(x-window-property prop frame prop nil nil t)
'list)))
(setf (car hints) (logior (car hints) 256))
(x-change-window-property prop hints nil prop 32 t))))
(when-let* ((buffer (alist-get 'buffer (ement-room-local room)))
(frames (cl-loop for frame in (frame-list)
when (eq 'x (framep frame))
collect frame))
(frame (pcase (length frames)
(1 (car frames))
(_
;; Use the frame that most recently showed ROOM's buffer.
(car (sort frames
(lambda (frame-a frame-b)
(let ((a-pos (cl-position buffer (buffer-list frame-a)))
(b-pos (cl-position buffer (buffer-list frame-b))))
(cond ((and a-pos b-pos)
(< a-pos b-pos))
(a-pos)
(b-pos))))))))))
(mark-frame-urgent frame))))
(defun ement-notify--notifications-notify (event room _session)
"Call `notifications-notify' for EVENT in ROOM on SESSION."
(pcase-let* (((cl-struct ement-event sender content) event)
((cl-struct ement-room avatar (display-name room-displayname)) room)
((map body) content)
(room-name (or room-displayname (ement--room-display-name room)))
(sender-name (ement--user-displayname-in room sender))
(title (format "%s in %s" sender-name room-name)))
;; TODO: Encode HTML entities.
(when (stringp body)
;; If event has no body, it was probably redacted or something, so don't notify.
(truncate-string-to-width body 60)
(notifications-notify :title title :body body
:app-name "Ement.el"
:app-icon (when avatar
(ement-notify--temp-file
(plist-get (cdr (get-text-property 0 'display avatar)) :data)))
:category "im.received"
:timeout 5000
;; FIXME: Using :sound-file seems to do nothing, ever. Maybe a bug in notifications-notify?
:sound-file (when (and ement-notify-sound
(file-name-absolute-p ement-notify-sound))
ement-notify-sound)
:sound-name (when (and ement-notify-sound
(not (file-name-absolute-p ement-notify-sound)))
ement-notify-sound)
;; TODO: Show when action used.
;; :actions '("default" "Show")
;; :on-action #'ement-notify-show
))))
(cl-defun ement-notify--temp-file (content &key (timeout 5))
"Return a filename holding CONTENT, and delete it after TIMEOUT seconds."
(let ((filename (make-temp-file "ement-notify--temp-file-"))
(coding-system-for-write 'no-conversion))
(with-temp-file filename
(insert content))
(run-at-time timeout nil (lambda ()
(delete-file filename)))
filename))
(cl-defun ement-notify--log-to-buffer (event room session &key (buffer-name "*Ement Notifications*"))
"Log EVENT in ROOM on SESSION to \"*Ement Notifications*\" buffer."
(declare (function ement-notifications-log-to-buffer "ement-notifications")
(function make-ement-notification "ement-notifications"))
(pcase-let* (((cl-struct ement-room (id room-id)) room)
(notification (make-ement-notification :room-id room-id :event event)))
(ement-notifications-log-to-buffer session notification :buffer-name buffer-name)))
;;;;; Predicates
(defun ement-notify--event-mentions-session-user-p (event room session)
"Return non-nil if EVENT in ROOM mentions SESSION's user.
If EVENT's sender is SESSION's user, returns nil."
(pcase-let* (((cl-struct ement-session user) session)
((cl-struct ement-event sender) event))
(unless (equal (ement-user-id user) (ement-user-id sender))
(ement-room--event-mentions-user-p event user room))))
(defun ement-notify--room-buffer-live-p (_event room _session)
"Return non-nil if ROOM has a live buffer."
(buffer-live-p (alist-get 'buffer (ement-room-local room))))
(defun ement-notify--room-unread-p (_event room _session)
"Return non-nil if ROOM has unread notifications.
According to the room's notification configuration on the server."
(pcase-let* (((cl-struct ement-room unread-notifications) room)
((map notification_count highlight_count) unread-notifications))
(not (and (equal 0 notification_count)
(equal 0 highlight_count)))))
(defun ement-notify--event-message-p (event _room _session)
"Return non-nil if EVENT is an \"m.room.message\" event."
(equal "m.room.message" (ement-event-type event)))
(defun ement-notify--event-not-message-p (event _room _session)
"Return non-nil if EVENT is not an \"m.room.message\" event."
(not (equal "m.room.message" (ement-event-type event))))
(defun ement-notify--event-from-session-user-p (event _room session)
"Return non-nil if EVENT is sent by SESSION's user."
(equal (ement-user-id (ement-session-user session))
(ement-user-id (ement-event-sender event))))
(defalias 'ement-notify--event-mentions-room-p #'ement--event-mentions-room-p)
;;;; Bookmark support
;; Especially useful with Burly: <https://github.com/alphapapa/burly.el>
(require 'bookmark)
(defun ement-notify-bookmark-make-record ()
"Return a bookmark record for the current `ement-notify' buffer."
(list (buffer-name)
;; It seems silly to have to record the buffer name twice, but the
;; `bookmark-make-record' function seems to override the bookmark name sometimes,
;; which makes the result useless unless we save the buffer name separately.
(cons 'buffer-name (buffer-name))
(cons 'handler #'ement-notify-bookmark-handler)))
(defun ement-notify-bookmark-handler (bookmark)
"Show Ement notifications buffer for BOOKMARK."
(pcase-let ((`(,_bookmark-name . ,(map buffer-name)) bookmark))
(switch-to-buffer (ement-notifications--log-buffer :name buffer-name))))
;;;; Footer
(provide 'ement-notify)
;;; ement-notify.el ends here
;;; ement-notifications.el --- Notifications support -*- lexical-binding: t; -*-
;; Copyright (C) 2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;; This library implements support for Matrix notifications. It differs from
;; `ement-notify', which implements a kind of bespoke notification system for events
;; received via sync requests rather than Matrix's own notifications endpoint. These two
;; libraries currently integrate somewhat, as newly arriving events are handled and
;; notified about by `ement-notify', and old notifications are fetched and listed by
;; `ement-notifications' in the same "*Ement Notifications*" buffer.
;; In the future, these libraries will likely be consolidated and enhanced to more closely
;; follow the Matrix API's and Element client's examples.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'map)
(require 'ement-lib)
(require 'ement-room)
(require 'ement-notify)
;;;; Structs
(cl-defstruct ement-notification
"Represents a Matrix notification."
room-id event readp)
(defun ement-notifications--make (notification)
"Return an `ement-notification' struct for NOTIFICATION.
NOTIFICATION is an alist representing a notification returned
from the \"/notifications\" endpoint. The notification's event
is passed through `ement--make-event'."
(declare (function ement--make-event "ement"))
(pcase-let (((map room_id _actions _ts event read) notification))
(make-ement-notification :room-id room_id :readp read
:event (ement--make-event event))))
;;;; Variables
(declare-function ement-room-list "ement-room-list")
(defvar ement-notifications-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "S-<return>") #'ement-notify-reply)
(define-key map (kbd "M-g M-l") #'ement-room-list)
(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 [remap scroll-down-command] #'ement-notifications-scroll-down-command)
(define-key map [remap mwheel-scroll] #'ement-notifications-mwheel-scroll)
(make-composed-keymap (list map button-buffer-map) 'view-mode-map))
"Map for Ement notification buffers.")
(defvar ement-notifications-hook '(ement-notifications-log-to-buffer)
"Functions called for `ement-notifications' notifications.
Each function is called with two arguments, the session and the
`ement-notification' struct.")
(defvar-local ement-notifications-retro-loading nil
"Non-nil when earlier messages are being loaded.
Used to avoid overlapping requests.")
(defvar-local ement-notifications-metadata nil
"Metadata for `ement-notifications' buffers.")
;; Variables from other files.
(defvar ement-ewoc)
(defvar ement-session)
(defvar ement-notify-prism-background)
(defvar ement-room-message-format-spec)
(defvar ement-room-sender-in-left-margin)
;;;; Commands
;;;###autoload
(cl-defun ement-notifications
(session &key from limit only
(then (apply-partially #'ement-notifications-callback session)) else)
"Show the notifications buffer for SESSION.
FROM may be a \"next_token\" token from a previous request.
LIMIT may be a maximum number of events to return. ONLY may be
the string \"highlight\" to only return notifications that have
the highlight tweak set. THEN and ELSE may be callbacks passed
to `ement-api', which see."
(interactive (list (ement-complete-session)
:only (when current-prefix-arg
"highlight")))
(if-let ((buffer (get-buffer "*Ement Notifications*")))
(switch-to-buffer buffer)
(let ((endpoint "notifications")
(params (remq nil
(list (when from
(list "from" from))
(when limit
(list "limit" (number-to-string limit)))
(when only
(list "only" only))))))
(ement-api session endpoint :params params :then then :else else)
(ement-message "Fetching notifications for <%s>..." (ement-user-id (ement-session-user session))))))
(cl-defun ement-notifications-callback (session data &key (buffer (ement-notifications--log-buffer)))
"Callback for `ement-notifications' on SESSION which receives DATA."
(pcase-let (((map notifications next_token) data))
(with-current-buffer buffer
(setf (map-elt ement-notifications-metadata :next-token) next_token)
(cl-loop for notification across notifications
do (run-hook-with-args 'ement-notifications-hook
session (ement-notifications--make notification)))
;; TODO: Pass start/end nodes to `ement-room--insert-ts-headers' if possible.
(ement-room--insert-ts-headers)
(switch-to-buffer (current-buffer)))))
(defun ement-notifications-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-notifications-retro))))
(defun ement-notifications-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-notifications-retro))))
(cl-defun ement-notifications-retro (session number)
;; FIXME: Naming things is hard.
"Retrieve NUMBER older notifications on SESSION."
;; FIXME: Support multiple sessions.
(interactive (list (ement-complete-session)
(cl-typecase current-prefix-arg
(null 100)
(list (read-number "Number of messages: "))
(number current-prefix-arg))))
(cl-assert (eq 'ement-notifications-mode major-mode))
(cl-assert (map-elt ement-notifications-metadata :next-token) nil
"No more notifications for %s" (ement-user-id (ement-session-user ement-session)))
(let ((buffer (current-buffer)))
(unless ement-notifications-retro-loading
(ement-notifications
session :limit number
:from (map-elt ement-notifications-metadata :next-token)
;; TODO: Use a :finally for resetting `ement-notifications-retro-loading'?
:then (lambda (data)
(unwind-protect
(ement-notifications-callback session data :buffer buffer)
(setf (buffer-local-value 'ement-notifications-retro-loading buffer) nil)))
:else (lambda (plz-error)
(setf (buffer-local-value 'ement-notifications-retro-loading buffer) nil)
(ement-api-error plz-error)))
(ement-message "Loading %s earlier messages..." number)
(setf ement-notifications-retro-loading t))))
;;;; Functions
(cl-defun ement-notifications-log-to-buffer (session notification &key (buffer-name "*Ement Notifications*"))
"Log EVENT in ROOM on SESSION to \"*Ement NOTIFICATIONS*\" buffer."
(with-demoted-errors "ement-notifications-log-to-buffer: %S"
(with-current-buffer (ement-notifications--log-buffer :name buffer-name)
(save-window-excursion
(when-let ((buffer-window (get-buffer-window (current-buffer))))
;; Select the buffer's window to avoid EWOC bug. (See #191.)
(select-window buffer-window))
;; TODO: Use the :readp slot to mark unread events.
(save-mark-and-excursion
(pcase-let* (((cl-struct ement-notification room-id event) notification)
(ement-session session)
(ement-room (or (cl-find room-id (ement-session-rooms session)
:key #'ement-room-id :test #'equal)
(error "ement-notifications-log-to-buffer: Can't find room <%s>; discarding notification" room-id)))
(ement-room-sender-in-left-margin nil)
(ement-room-message-format-spec "%o%O »%W %S> %B%R%t")
(new-node (ement-room--insert-event event))
(inhibit-read-only t)
(start) (end))
(ewoc-goto-node ement-ewoc new-node)
(setf start (point))
(if-let (next-node (ewoc-next ement-ewoc new-node))
(ewoc-goto-node ement-ewoc next-node)
(goto-char (point-max)))
(setf end (- (point) 2))
(add-text-properties start end
(list 'button '(t)
'category 'default-button
'action #'ement-notify-button-action
'session session
'room ement-room
'event event))
;; Remove button face property.
(alter-text-property start end 'face
(lambda (face)
(pcase face
('button nil)
((pred listp) (remq 'button face))
(_ face))))
(when ement-notify-prism-background
(add-face-text-property start end (list :background (ement-notifications--room-background-color ement-room)
:extend t)))))))))
(defun ement-notifications--room-background-color (room)
"Return a background color on which to display ROOM's messages."
(or (alist-get 'notify-background-color (ement-room-local room))
(setf (alist-get 'notify-background-color (ement-room-local room))
(let ((color (color-desaturate-name
(ement--prism-color (ement-room-id room) :contrast-with (face-foreground 'default))
50)))
(if (ement--color-dark-p (color-name-to-rgb (face-background 'default)))
(color-darken-name color 25)
(color-lighten-name color 25))))))
(cl-defun ement-notifications--log-buffer (&key (name "*Ement Notifications*"))
"Return an Ement notifications buffer named NAME."
(or (get-buffer name)
(with-current-buffer (get-buffer-create name)
(ement-notifications-mode)
(current-buffer))))
;;;; Mode
(define-derived-mode ement-notifications-mode ement-room-mode "Ement Notifications"
(setf ement-room-sender-in-left-margin nil
left-margin-width 0
right-margin-width 8)
(setq-local ement-room-message-format-spec "[%o%O] %S> %B%R%t"
bookmark-make-record-function #'ement-notifications-bookmark-make-record))
;;;; Bookmark support
(require 'bookmark)
(defun ement-notifications-bookmark-make-record ()
"Return a bookmark record for the current `ement-notifications' buffer."
(list (buffer-name)
;; It seems silly to have to record the buffer name twice, but the
;; `bookmark-make-record' function seems to override the bookmark name sometimes,
;; which makes the result useless unless we save the buffer name separately.
(cons 'buffer-name (buffer-name))
(cons 'handler #'ement-notifications-bookmark-handler)))
(defun ement-notifications-bookmark-handler (bookmark)
"Show `ement-notifications' buffer for BOOKMARK."
(pcase-let ((`(,_bookmark-name . ,(map buffer-name)) bookmark))
(switch-to-buffer (ement-notifications--log-buffer :name buffer-name))))
;;; Footer
(provide 'ement-notifications)
;;; ement-notifications.el ends here
;;; ement-macros.el --- Ement macros -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;;
;;; Code:
;;;; Requirements
(require 'map)
;;;; Debugging
(require 'warnings)
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
(cl-defmacro ement-debug (&rest args)
"Display a debug warning showing the runtime value of ARGS.
The warning automatically includes the name of the containing
function, and it is only displayed if `warning-minimum-log-level'
is `:debug' at expansion time (otherwise the macro expands to a
call to `ignore' with ARGS and is eliminated by the
byte-compiler). When debugging, the form also returns nil so,
e.g. it may be used in a conditional in place of nil.
Each of ARGS may be a string, which is displayed as-is, or a
symbol, the value of which is displayed prefixed by its name, or
a Lisp form, which is displayed prefixed by its first symbol.
Before the actual ARGS arguments, you can write keyword
arguments, i.e. alternating keywords and values. The following
keywords are supported:
:buffer BUFFER Name of buffer to pass to `display-warning'.
:level LEVEL Level passed to `display-warning', which see.
Default is :debug."
;; TODO: Can we use a compiler macro to handle this more elegantly?
(pcase-let* ((fn-name (when byte-compile-current-buffer
(with-current-buffer byte-compile-current-buffer
;; This is a hack, but a nifty one.
(save-excursion
(beginning-of-defun)
(cl-second (read (current-buffer)))))))
(plist-args (cl-loop while (keywordp (car args))
collect (pop args)
collect (pop args)))
((map (:buffer buffer) (:level level)) plist-args)
(level (or level :debug))
(string (cl-loop for arg in args
concat (pcase arg
((pred stringp) "%S ")
((pred symbolp)
(concat (upcase (symbol-name arg)) ":%S "))
((pred listp)
(concat "(" (upcase (symbol-name (car arg)))
(pcase (length arg)
(1 ")")
(_ "...)"))
":%S "))))))
(if (eq :debug warning-minimum-log-level)
`(let ((fn-name ,(if fn-name
`',fn-name
;; In an interpreted function: use `backtrace-frame' to get the
;; function name (we have to use a little hackery to figure out
;; how far up the frame to look, but this seems to work).
`(cl-loop for frame in (backtrace-frames)
for fn = (cl-second frame)
when (not (or (subrp fn)
(special-form-p fn)
(eq 'backtrace-frames fn)))
return (make-symbol (format "%s [interpreted]" fn))))))
(display-warning fn-name (format ,string ,@args) ,level ,buffer)
nil)
`(ignore ,@args))))
;;;; Macros
(defmacro ement-alist (&rest pairs)
"Expand to an alist of the keys and values in PAIRS."
`(list ,@(cl-loop for (key value) on pairs by #'cddr
collect `(cons ,key ,value))))
;;;;; Anaphoric
;; We could just depend on dash.el and use --first, and anaphora.el (only
;; on MELPA, not ELPA) has aprog1, but in order to reduce dependencies...
(defmacro ement-afirst (form list)
;; Sometimes checkdoc is really annoying. If I use "FORM returns" or
;; "FORM evaluates", it complains, so I can't have a clean linting.
"Return the first element of LIST for which FORM is non-nil.
In FORM, `it' is bound to the element being tested."
(declare (indent 1))
`(cl-loop for it in ,list
;; Avoid the `when' clause's implicit binding of `it'.
do (when ,form
(cl-return it))))
(defmacro ement-aprog1 (first &rest body)
"Like `prog1', but FIRST's value is bound to `it' around BODY."
(declare (indent 1))
`(let ((it ,first))
,@body
it))
(defmacro ement-singly (place-form &rest body)
"If PLACE-FORM is nil, set it non-nil and eval BODY.
BODY should set PLACE-FORM to nil when BODY is eligible to run
again."
(declare (indent defun))
`(unless ,place-form
(setf ,place-form t)
,@body))
;;;;; Progress reporters
;; MAYBE: Submit a `with-progress-reporter' macro to Emacs.
(defalias 'ement-progress-update #'ignore
"By default, this function does nothing. But inside
`ement-with-progress-reporter', it's bound to a function that
updates the current progress reporter.")
(defmacro ement-with-progress-reporter (args &rest body)
"Eval BODY with a progress reporter according to ARGS.
ARGS is a plist of these values:
:when If specified, a form evaluated at runtime to determine
whether to make and update a progress reporter. If not
specified, the reporter is always made and updated.
:reporter A list of arguments passed to
`make-progress-reporter', which see.
Around BODY, the function `ement-progress-update' is set to a
function that calls `progress-reporter-update' on the progress
reporter (or if the :when form evaluates to nil, the function is
set to `ignore'). It optionally takes a VALUE argument, and
without one, it automatically updates the value from the
reporter's min-value to its max-value."
(declare (indent defun))
(pcase-let* ((progress-reporter-sym (gensym))
(progress-value-sym (gensym))
(start-time-sym (gensym))
((map (:when when-form) (:reporter reporter-args)) args)
(`(,_message ,min-value ,_max-value) reporter-args)
(update-fn `(cl-function
(lambda (&optional (value (cl-incf ,progress-value-sym)))
(ement-debug "Updating progress reporter to" value)
(progress-reporter-update ,progress-reporter-sym value)))))
`(let* ((,start-time-sym (current-time))
(,progress-value-sym (or ,min-value 0))
(,progress-reporter-sym ,(if when-form
`(when ,when-form
(make-progress-reporter ,@reporter-args))
`(make-progress-reporter ,@reporter-args))))
;; We use `cl-letf' rather than `cl-labels', because labels expand to lambdas and funcalls,
;; so other functions that call `ement-progress-update' wouldn't call this definition.
(cl-letf (((symbol-function 'ement-progress-update)
,(if when-form
`(if ,when-form
,update-fn
#'ignore)
update-fn)))
,@body
(ement-debug (format "Ement: Progress reporter done (took %.2f seconds)"
(float-time (time-subtract (current-time) ,start-time-sym))))))))
;;;;; Room-related macros
;; Prevent compiler from complaining that `value' is an unknown slot.
(require 'magit-section)
(cl-defmacro ement-with-room-and-session (&rest body)
"Eval BODY with `ement-room' and `ement-session' bound.
If in an `ement-room-list-mode' buffer and `current-prefix-arg'
is nil, use the room and session at point. If in an `ement-room'
buffer and `current-prefix-arg' is nil, use buffer-local value of
`ement-room' and `ement-session'. Otherwise, prompt for them
with `ement-complete-room' or that given with :prompt-form.
BODY may begin with property list arguments, including:
:prompt-form A Lisp form evaluated for the binding of
`ement-room'."
(declare (indent defun))
(pcase-let* ((plist (cl-loop while (keywordp (car body))
append (list (car body) (cadr body))
and do (setf body (cddr body))))
(prompt-form (or (plist-get plist :prompt-form)
'(ement-complete-room :suggest t))))
`(pcase-let* ((`[,list-room ,list-session] (if (eq 'ement-room-list-mode major-mode)
(oref (magit-current-section) value)
[nil nil]))
(ement-room (or list-room ement-room))
(ement-session (or list-session ement-session)))
(when (or current-prefix-arg (not ement-room))
(pcase-let ((`(,room ,session) ,prompt-form))
(setf ement-room room
ement-session session)))
,@body)))
;;;; Variables
;;;; Customization
;;;; Commands
;;;; Functions
;;;; Footer
(provide 'ement-macros)
;;; ement-macros.el ends here
;;; ement-lib.el --- Library of Ement functions -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;; This library provides functions used in other Ement libraries. It exists so they may
;; be required where needed, without causing circular dependencies.
;;; Code:
;;;; Requirements
(eval-when-compile
(require 'eieio)
(require 'ewoc)
(require 'pcase)
(require 'subr-x)
(require 'taxy-magit-section)
(require 'ement-macros))
(require 'cl-lib)
(require 'button)
(require 'color)
(require 'map)
(require 'seq)
(require 'xml)
(require 'ement-api)
(require 'ement-structs)
;;;; Variables
(defvar ement-sessions)
(defvar ement-users)
(defvar ement-ewoc)
(defvar ement-room)
(defvar ement-session)
(defvar ement-room-buffer-name-prefix)
(defvar ement-room-buffer-name-suffix)
(defvar ement-room-leave-kill-buffer)
(defvar ement-room-prism)
(defvar ement-room-prism-color-adjustment)
(defvar ement-room-prism-minimum-contrast)
(defvar ement-room-unread-only-counts-notifications)
;;;; Function declarations
;; Instead of using top-level `declare-function' forms (which can easily become obsolete
;; if not kept with the code that needs them), this allows the use of `(declare (function
;; ...))' forms in each function definition, so that if a function is moved or removed,
;; the `declare-function' goes with it.
;; TODO: Propose this upstream.
(eval-and-compile
(defun ement--byte-run--declare-function (_name _args &rest values)
"Return a `declare-function' form with VALUES.
Allows the use of a form like:
(declare (function FN FILE ...))
inside of a function definition, effectively keeping its
`declare-function' form inside the function definition, ensuring
that stray such forms don't remain if the function is removed."
`(declare-function ,@values))
(cl-pushnew '(function ement--byte-run--declare-function) defun-declarations-alist :test #'equal)
(cl-pushnew '(function ement--byte-run--declare-function) macro-declarations-alist :test #'equal))
;;;; Compatibility
;; These workarounds should be removed when they aren't needed.
(defalias 'ement--json-parse-buffer
;; For non-libjansson builds (those that do have libjansson will see a 4-5x improvement
;; in the time needed to parse JSON responses).
;; TODO: Suggest mentioning in manual and docstrings that `json-read', et al do not use
;; libjansson, while `json-parse-buffer', et al do.
(if (fboundp 'json-parse-buffer)
(lambda ()
(condition-case err
(json-parse-buffer :object-type 'alist :null-object nil :false-object :json-false)
(json-parse-error
(ement-message "`json-parse-buffer' signaled `json-parse-error'; falling back to `json-read'... (%S)"
(error-message-string err))
(goto-char (point-min))
(json-read))))
'json-read))
;;;;; Emacs 28 color features.
;; Copied from Emacs 28. See <https://github.com/alphapapa/ement.el/issues/99>.
;; TODO(future): Remove these workarounds when dropping support for Emacs <28.
(eval-and-compile
(unless (boundp 'color-luminance-dark-limit)
(defconst ement--color-luminance-dark-limit 0.325
"The relative luminance below which a color is considered \"dark.\"
A \"dark\" color in this sense provides better contrast with
white than with black; see `color-dark-p'. This value was
determined experimentally.")))
(defalias 'ement--color-dark-p
(if (fboundp 'color-dark-p)
'color-dark-p
(with-suppressed-warnings ((free-vars ement--color-luminance-dark-limit))
(lambda (rgb)
"Whether RGB is more readable against white than black.
RGB is a 3-element list (R G B), each component in the range [0,1].
This predicate can be used both for determining a suitable (black or white)
contrast colour with RGB as background and as foreground."
(unless (<= 0 (apply #'min rgb) (apply #'max rgb) 1)
(error "RGB components %S not in [0,1]" rgb))
;; Compute the relative luminance after gamma-correcting (assuming sRGB),
;; and compare to a cut-off value determined experimentally.
;; See https://en.wikipedia.org/wiki/Relative_luminance for details.
(let* ((sr (nth 0 rgb))
(sg (nth 1 rgb))
(sb (nth 2 rgb))
;; Gamma-correct the RGB components to linear values.
;; Use the power 2.2 as an approximation to sRGB gamma;
;; it should be good enough for the purpose of this function.
(r (expt sr 2.2))
(g (expt sg 2.2))
(b (expt sb 2.2))
(y (+ (* r 0.2126) (* g 0.7152) (* b 0.0722))))
(< y ement--color-luminance-dark-limit))))))
;;;; Functions
;;;;; Commands
(cl-defun ement-create-room
(session &key name alias topic invite direct-p creation-content
(then (lambda (data)
(message "Created new room: %s" (alist-get 'room_id data))))
(visibility 'private))
"Create new room on SESSION.
Then call function THEN with response data. Optional string
arguments are NAME, ALIAS, and TOPIC. INVITE may be a list of
user IDs to invite. If DIRECT-P, set the \"is_direct\" flag in
the request. CREATION-CONTENT may be an alist of extra keys to
include with the request (see Matrix spec)."
;; TODO: Document other arguments.
;; SPEC: 10.1.1.
(declare (indent defun))
(interactive (list (ement-complete-session)
:name (read-string "New room name: ")
:alias (read-string "New room alias (e.g. \"foo\" for \"#foo:matrix.org\"): ")
:topic (read-string "New room topic: ")
:visibility (completing-read "New room visibility: " '(private public))))
(cl-labels ((given-p (var) (and var (not (string-empty-p var)))))
(pcase-let* ((endpoint "createRoom")
(data (ement-aprog1
(ement-alist "visibility" visibility)
(when (given-p alias)
(push (cons "room_alias_name" alias) it))
(when (given-p name)
(push (cons "name" name) it))
(when (given-p topic)
(push (cons "topic" topic) it))
(when invite
(push (cons "invite" invite) it))
(when direct-p
(push (cons "is_direct" t) it))
(when creation-content
(push (cons "creation_content" creation-content) it)))))
(ement-api session endpoint :method 'post :data (json-encode data)
:then then))))
(cl-defun ement-create-space
(session &key name alias topic
(then (lambda (data)
(message "Created new space: %s" (alist-get 'room_id data))))
(visibility 'private))
"Create new space on SESSION.
Then call function THEN with response data. Optional string
arguments are NAME, ALIAS, and TOPIC."
(declare (indent defun))
(interactive (list (ement-complete-session)
:name (read-string "New space name: ")
:alias (read-string "New space alias (e.g. \"foo\" for \"#foo:matrix.org\"): ")
:topic (read-string "New space topic: ")
:visibility (completing-read "New space visibility: " '(private public))))
(ement-create-room session :name name :alias alias :topic topic :visibility visibility
:creation-content (ement-alist "type" "m.space") :then then))
(defun ement-room-leave (room session &optional force-p)
"Leave ROOM on SESSION.
If FORCE-P, leave without prompting. ROOM may be an `ement-room'
struct, or a room ID or alias string."
;; TODO: Rename `room' argument to `room-or-id'.
(interactive
(ement-with-room-and-session
:prompt-form (ement-complete-room :prompt "Leave room: ")
(list ement-room ement-session)))
(cl-etypecase room
(ement-room)
(string (setf room (ement-afirst (or (equal room (ement-room-canonical-alias it))
(equal room (ement-room-id it)))
(ement-session-rooms session)))))
(when (or force-p (yes-or-no-p (format "Leave room %s? " (ement--format-room room))))
(pcase-let* (((cl-struct ement-room id) room)
(endpoint (format "rooms/%s/leave" (url-hexify-string id))))
(ement-api session endpoint :method 'post :data ""
:then (lambda (_data)
(when ement-room-leave-kill-buffer
;; NOTE: This generates a symbol and sets its function value to a lambda
;; which removes the symbol from the hook, removing itself from the hook.
;; TODO: When requiring Emacs 27, use `letrec'.
(let* ((leave-fn-symbol (gensym (format "ement-leave-%s" room)))
(leave-fn (lambda (_session)
(remove-hook 'ement-sync-callback-hook leave-fn-symbol)
;; FIXME: Probably need to unintern the symbol.
(when-let ((buffer (map-elt (ement-room-local room) 'buffer)))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(setf (symbol-function leave-fn-symbol) leave-fn)
(add-hook 'ement-sync-callback-hook leave-fn-symbol)))
(ement-message "Left room: %s" (ement--format-room room)))
: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
(429 (error "Unable to leave room %s: %s" room error))
(_ (error "Unable to leave room %s: %s %S" room status plz-error)))))))))
(defalias 'ement-leave-room #'ement-room-leave)
(defun ement-forget-room (room session &optional force-p)
"Forget ROOM on SESSION.
If FORCE-P (interactively, with prefix), prompt to leave the room
when necessary, and forget the room without prompting."
(interactive
(ement-with-room-and-session
:prompt-form (ement-complete-room :prompt "Forget room: ")
(list ement-room ement-session current-prefix-arg)))
(pcase-let* (((cl-struct ement-room id display-name status) room)
(endpoint (format "rooms/%s/forget" (url-hexify-string id))))
(pcase status
('join (if (and force-p
(yes-or-no-p (format "Leave and forget room %s? (WARNING: You will not be able to rejoin the room to access its content.) "
(ement--format-room room))))
(progn
;; TODO: Use `letrec'.
(let* ((forget-fn-symbol (gensym (format "ement-forget-%s" room)))
(forget-fn (lambda (_session)
(when (equal 'leave (ement-room-status room))
(remove-hook 'ement-sync-callback-hook forget-fn-symbol)
;; FIXME: Probably need to unintern the symbol.
(ement-forget-room room session 'force)))))
(setf (symbol-function forget-fn-symbol) forget-fn)
(add-hook 'ement-sync-callback-hook forget-fn-symbol))
(ement-leave-room room session 'force))
(user-error "Room %s is joined (must be left before forgetting)"
(ement--format-room room))))
('leave (when (or force-p (yes-or-no-p (format "Forget room \"%s\" (%s)? " display-name id)))
(ement-api session endpoint :method 'post :data ""
:then (lambda (_data)
;; NOTE: The spec does not seem to indicate that the action of forgetting
;; a room is synced to other clients, so it seems that we need to remove
;; the room from the session here.
(setf (ement-session-rooms session)
(cl-remove room (ement-session-rooms session)))
;; TODO: Indicate forgotten in footer in room buffer.
(ement-message "Forgot room: %s." (ement--format-room room)))))))))
(defun ement-ignore-user (user-id session &optional unignore-p)
"Ignore USER-ID on SESSION.
If UNIGNORE-P (interactively, with prefix), un-ignore USER."
(interactive (list (ement-complete-user-id)
(ement-complete-session)
current-prefix-arg))
(pcase-let* (((cl-struct ement-session account-data) session)
;; TODO: Store session account-data events in an alist keyed on type.
((map ('content (map ('ignored_users ignored-users))))
(cl-find "m.ignored_user_list" account-data
:key (lambda (event) (alist-get 'type event)) :test #'equal)))
(if unignore-p
;; Being map keys, the user IDs have been interned by `json-read'.
(setf ignored-users (map-delete ignored-users (intern user-id)))
;; Empty maps are used to list ignored users.
(setf (map-elt ignored-users user-id) nil))
(ement-put-account-data session "m.ignored_user_list" (ement-alist "ignored_users" ignored-users)
:then (lambda (data)
(ement-debug "PUT successful" data)
(message "Ement: User %s %s." user-id (if unignore-p "unignored" "ignored"))))))
(defun ement-invite-user (user-id room session)
"Invite USER-ID to ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
;; SPEC: 10.4.2.1.
(interactive
(ement-with-room-and-session
(list (ement-complete-user-id) ement-room ement-session)))
(pcase-let* ((endpoint (format "rooms/%s/invite"
(url-hexify-string (ement-room-id room))))
(data (ement-alist "user_id" user-id) ))
(ement-api session endpoint :method 'post :data (json-encode data)
;; TODO: Handle error codes.
:then (lambda (_data)
(message "User %s invited to room \"%s\" (%s)" user-id
(ement-room-display-name room)
(ement-room-id room))))))
(defun ement-list-members (room session bufferp)
"Show members of ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. If BUFFERP (interactively, with
prefix), or if there are many members, show in a new buffer;
otherwise show in echo area."
(interactive
(ement-with-room-and-session
(list ement-room ement-session current-prefix-arg)))
(pcase-let* (((cl-struct ement-room members (local (map fetched-members-p))) room)
(list-members
(lambda (&optional _)
(cond ((or bufferp (> (hash-table-count members) 51))
;; Show in buffer.
(let* ((buffer (get-buffer-create (format "*Ement members: %s*" (ement-room-display-name room))))
(members (cl-sort (cl-loop for user being the hash-values of members
for id = (ement-user-id user)
for displayname = (ement--user-displayname-in room user)
collect (cons displayname id))
(lambda (a b) (string-collate-lessp a b nil t)) :key #'car))
(displayname-width (cl-loop for member in members
maximizing (string-width (car member))))
(format-string (format "%%-%ss <%%s>" displayname-width)))
(with-current-buffer buffer
(erase-buffer)
(save-excursion
(dolist (member members)
(insert (format format-string (car member) (cdr member)) "\n"))))
(pop-to-buffer buffer)))
(t
;; Show in echo area.
(message "Members of %s (%s): %s" (ement--room-display-name room)
(hash-table-count members)
(string-join (map-apply (lambda (_id user)
(ement--user-displayname-in room user))
members)
", ")))))))
(if fetched-members-p
(funcall list-members)
(ement--get-joined-members room session
:then list-members))
(message "Listing members of %s..." (ement--format-room room))))
(defun ement-send-direct-message (session user-id message)
"Send a direct MESSAGE to USER-ID on SESSION.
Uses the latest existing direct room with the user, or creates a
new one automatically if necessary."
;; SPEC: 13.23.2.
(interactive
(let* ((session (ement-complete-session))
(user-id (ement-complete-user-id))
(message (read-string "Message: ")))
(list session user-id message)))
(if-let* ((seen-user (gethash user-id ement-users))
(existing-direct-room (ement--direct-room-for-user seen-user session)))
(progn
(ement-send-message existing-direct-room session :body message)
(message "Message sent to %s <%s> in room %S <%s>."
(ement--user-displayname-in existing-direct-room seen-user)
user-id
(ement-room-display-name existing-direct-room) (ement-room-id existing-direct-room)))
;; No existing room for user: make new one.
(message "Creating new room for user %s..." user-id)
(ement-create-room session :direct-p t :invite (list user-id)
:then (lambda (data)
(let* ((room-id (alist-get 'room_id data))
(room (or (cl-find room-id (ement-session-rooms session)
:key #'ement-room-id)
;; New room hasn't synced yet: make a temporary struct.
(make-ement-room :id room-id)))
(direct-rooms-account-data-event-content
;; FIXME: Make account-data a map.
(alist-get 'content (cl-find-if (lambda (event)
(equal "m.direct" (alist-get 'type event)))
(ement-session-account-data session)))))
;; Mark new room as direct: add the room to the account-data event, then
;; put the new account data to the server. (See also:
;; <https://github.com/matrix-org/matrix-react-sdk/blob/919aab053e5b3bdb5a150fd90855ad406c19e4ab/src/Rooms.ts#L91>).
(setf (map-elt direct-rooms-account-data-event-content user-id) (vector room-id))
(ement-put-account-data session "m.direct" direct-rooms-account-data-event-content)
;; Send message to new room.
(ement-send-message room session :body message)
(message "Room \"%s\" created for user %s. Sending message..."
room-id user-id))))))
(defun ement-tag-room (tag room session)
"Toggle TAG for ROOM on SESSION."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Toggle tag (%s): " (ement--format-room ement-room)))
(default-tags
(ement-alist (propertize "Favourite"
'face (when (ement--room-tagged-p "m.favourite" ement-room)
'transient-value))
"m.favourite"
(propertize "Low-priority"
'face (when (ement--room-tagged-p "m.lowpriority" ement-room)
'transient-value))
"m.lowpriority"))
(input (completing-read prompt default-tags))
(tag (alist-get input default-tags (concat "u." input) nil #'string=)))
(list tag ement-room ement-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 "user/%s/rooms/%s/tags/%s"
(url-hexify-string user-id) (url-hexify-string room-id) (url-hexify-string tag)))
(method (if (ement--room-tagged-p tag room) 'delete 'put)))
;; TODO: "order".
;; FIXME: Removing a tag on a left room doesn't seem to work (e.g. to unfavorite a room after leaving it, but not forgetting it).
(ement-api session endpoint :version "v3" :method method :data (pcase method ('put "{}"))
:then (lambda (_)
(ement-message "%s tag %S on %s"
(pcase method
('delete "Removed")
('put "Added"))
tag (ement--format-room room)) ))))
(defun ement-set-display-name (display-name session)
"Set DISPLAY-NAME for user on SESSION.
Sets global displayname."
(interactive
(let* ((session (ement-complete-session))
(display-name (read-string "Set display-name to: " nil nil
(ement-user-displayname (ement-session-user session)))))
(list display-name session)))
(pcase-let* (((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
(endpoint (format "profile/%s/displayname" (url-hexify-string user-id))))
(ement-api session endpoint :method 'put :version "v3"
:data (json-encode (ement-alist "displayname" display-name))
:then (lambda (_data)
(message "Ement: Display name set to %S for <%s>" display-name
(ement-user-id (ement-session-user session)))))))
(defun ement-room-set-display-name (display-name room session)
"Set DISPLAY-NAME for user in ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. Sets the name only in ROOM, not
globally."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Set display-name in %S to: "
(ement--format-room ement-room)))
(display-name (read-string prompt nil nil
(ement-user-displayname (ement-session-user ement-session)))))
(list display-name ement-room ement-session))))
;; NOTE: This does not seem to be documented in the spec, so we imitate the
;; "/myroomnick" command in SlashCommands.tsx from matrix-react-sdk.
(pcase-let* (((cl-struct ement-room state) room)
((cl-struct ement-session user) session)
((cl-struct ement-user id) user)
(member-event (cl-find-if (lambda (event)
(and (equal id (ement-event-state-key event))
(equal "m.room.member" (ement-event-type event))
(equal "join" (alist-get 'membership (ement-event-content event)))))
state)))
(cl-assert member-event)
(setf (alist-get 'displayname (ement-event-content member-event)) display-name)
(ement-put-state room "m.room.member" id (ement-event-content member-event) session
:then (lambda (_data)
(message "Ement: Display name set to %S for <%s> in %S" display-name
(ement-user-id (ement-session-user session))
(ement--format-room room))))))
;;;;;; Describe room
(defvar ement-describe-room-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "q") #'quit-window)
map)
"Keymap for `ement-describe-room-mode' buffers.")
(define-derived-mode ement-describe-room-mode read-only-mode
"Ement-Describe-Room" "Major mode for `ement-describe-room' buffers.")
(defun ement-describe-room (room session)
"Describe ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
(interactive (ement-with-room-and-session (list ement-room ement-session)))
(cl-labels ((heading (string)
(propertize (or string "") 'face 'font-lock-builtin-face))
(id (string)
(propertize (or string "") 'face 'font-lock-constant-face))
(member<
(a b) (string-collate-lessp (car a) (car b) nil t)))
(pcase-let* (((cl-struct ement-room (id room-id) avatar display-name canonical-alias members timeline status topic
(local (map fetched-members-p)))
room)
((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
(inhibit-read-only t))
(if (not fetched-members-p)
;; Members not fetched: fetch them and re-call this command.
(ement--get-joined-members room session
:then (lambda (_) (ement-room-describe room session)))
(with-current-buffer (get-buffer-create (format "*Ement room description: %s*" (or display-name canonical-alias room-id)))
(let ((inhibit-read-only t))
(erase-buffer)
;; We avoid looping twice by doing a bit more work here and
;; returning a cons which we destructure.
(pcase-let* ((`(,member-pairs . ,name-width)
(cl-loop for user being the hash-values of members
for formatted = (ement--format-user user room session)
for id = (format "<%s>" (id (ement-user-id user)))
collect (cons formatted id)
into pairs
maximizing (string-width id) into width
finally return (cons (cl-sort pairs #'member<) width)))
;; We put the MXID first, because users may use Unicode characters
;; in their displayname, which `string-width' does not always
;; return perfect results for, and putting it last prevents
;; alignment problems.
(spec (format "%%-%ss %%s" name-width)))
(save-excursion
(insert "\"" (propertize (or display-name canonical-alias room-id) 'face 'font-lock-doc-face) "\"" " is a "
(propertize (if (ement--space-p room)
"space"
"room")
'face 'font-lock-type-face)
" "
(propertize (pcase status
('invite "invited")
('join "joined")
('leave "left")
(_ (symbol-name status)))
'face 'font-lock-comment-face)
" on session <" (id user-id) ">.\n\n"
(heading "Avatar: ") (or avatar "") "\n\n"
(heading "ID: ") "<" (id room-id) ">" "\n"
(heading "Alias: ") "<" (id canonical-alias) ">" "\n\n"
(heading "Topic: ") (propertize (or topic "[none]") 'face 'font-lock-comment-face) "\n\n"
(heading "Retrieved events: ") (number-to-string (length timeline)) "\n"
(heading " spanning: ")
(format-time-string "%Y-%m-%d %H:%M:%S"
(/ (ement-event-origin-server-ts
(car (cl-sort (copy-sequence timeline) #'< :key #'ement-event-origin-server-ts)))
1000))
(heading " to ")
(format-time-string "%Y-%m-%d %H:%M:%S\n\n"
(/ (ement-event-origin-server-ts
(car (cl-sort (copy-sequence timeline) #'> :key #'ement-event-origin-server-ts)))
1000))
(heading "Members") " (" (number-to-string (hash-table-count members)) "):\n")
(pcase-dolist (`(,formatted . ,id) member-pairs)
(insert " " (format spec id formatted) "\n")))))
(unless (eq major-mode 'ement-describe-room-mode)
;; Without this check, activating the mode again causes a "Cyclic keymap
;; inheritance" error.
(ement-describe-room-mode))
(pop-to-buffer (current-buffer)))))))
(defalias 'ement-room-describe #'ement-describe-room)
;;;;;; Push rules
;; NOTE: Although v1.4 of the spec is available and describes setting the push rules using
;; the "v3" API endpoint, the Element client continues to use the "r0" endpoint, which is
;; slightly different. This implementation will follow Element's initially, because the
;; spec is not simple, and imitating Element's requests will make it easier.
(defun ement-room-notification-state (room session)
"Return notification state for ROOM on SESSION.
Returns one of nil (meaning default rules are used), `all-loud',
`all', `mentions-and-keywords', or `none'."
;; Following the implementation of getRoomNotifsState() in RoomNotifs.ts in matrix-react-sdk.
;; TODO: Guest support (in which case the state should be `all').
;; TODO: Store account data as a hash table of event types.
(let ((push-rules (cl-find-if (lambda (alist)
(equal "m.push_rules" (alist-get 'type alist)))
(ement-session-account-data session))))
(cl-labels ((override-mute-rule-for-room-p (room)
;; Following findOverrideMuteRule() in RoomNotifs.ts.
(when-let ((overrides (map-nested-elt push-rules '(content global override))))
(cl-loop for rule in overrides
when (and (alist-get 'enabled rule)
(rule-for-room-p rule room))
return rule)))
(rule-for-room-p (rule room)
;; Following isRuleForRoom() in RoomNotifs.ts.
(and (/= 1 (length (alist-get 'conditions rule)))
(pcase-let* ((condition (elt (alist-get 'conditions rule) 0))
((map kind key pattern) condition))
(and (equal "event_match" kind)
(equal "room_id" key)
(equal (ement-room-id room) pattern)))))
(mute-rule-p (rule)
(when-let ((actions (alist-get 'actions rule)))
(seq-contains-p actions "dont_notify")))
;; NOTE: Although v1.7 of the spec says that "dont_notify" is
;; obsolete, the latest revision of matrix-react-sdk (released last week
;; as v3.77.1) still works as modeled here.
(tweak-rule-p (type rule)
(when-let ((actions (alist-get 'actions rule)))
(and (seq-contains-p actions "notify")
(seq-contains-p actions `(set_tweak . ,type) 'seq-contains-p)))))
;; If none of these match, nil is returned, meaning that the default rule is used
;; for the room.
(if (override-mute-rule-for-room-p room)
'none
(when-let ((room-rule (cl-find-if (lambda (rule)
(equal (ement-room-id room) (alist-get 'rule_id rule)))
(map-nested-elt push-rules '(content global room)))))
(cond ((not (alist-get 'enabled room-rule))
;; NOTE: According to comment in getRoomNotifsState(), this assumes that
;; the default is to notify for all messages, which "will be 'wrong' for
;; one to one rooms because they will notify loudly for all messages."
'all)
((mute-rule-p room-rule)
;; According to comment, a room-level mute still allows mentions to
;; notify. NOTE: See note above.
'mentions-and-keywords)
((tweak-rule-p "sound" room-rule) 'all-loud)))))))
(defun ement-room-set-notification-state (state room session)
"Set notification STATE for ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. STATE may be nil to set the rules to
default, `all', `mentions-and-keywords', or `none'."
;; This merely attempts to reproduce the behavior of Element's simple notification
;; options. It does not attempt to offer all of the features defined in the spec. And,
;; yes, it is rather awkward, having to sometimes* make multiple requests of different
;; "kinds" to set the rules for a single room, but that is how the API works.
;;
;; * It appears that Element only makes multiple requests of different kinds when
;; strictly necessary, but coding that logic now would seem likely to be a waste of
;; time, given that Element doesn't even use the latest version of the spec yet. So
;; we'll just do the "dumb" thing and always send requests of both "override" and
;; "room" kinds, which appears to Just Work™.
;;
;; TODO: Match rules to these user-friendly notification states for presentation. See
;; <https://github.com/matrix-org/matrix-react-sdk/blob/8c67984f50f985aa481df24778078030efa39001/src/RoomNotifs.ts>.
;; TODO: Support `all-loud' ("all_messages_loud").
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Set notification rules for %s: " (ement--format-room ement-room)))
(available-states (ement-alist "Default" nil
"All messages" 'all
"Mentions and keywords" 'mentions-and-keywords
"None" 'none))
(selected-rule (completing-read prompt (mapcar #'car available-states) nil t))
(state (alist-get selected-rule available-states nil nil #'equal)))
(list state ement-room ement-session))))
(cl-labels ((set-rule (kind rule queue message-fn)
(pcase-let* (((cl-struct ement-room (id room-id)) room)
(rule-id (url-hexify-string room-id))
(endpoint (format "pushrules/global/%s/%s" kind rule-id))
(method (if rule 'put 'delete))
(then (if rule
;; Setting rules requires PUTting the rules, then making a second
;; request to enable them.
(lambda (_data)
(ement-api session (concat endpoint "/enabled") :queue queue :version "r0"
:method 'put :data (json-encode (ement-alist 'enabled t))
:then message-fn))
message-fn)))
(ement-api session endpoint :queue queue :method method :version "r0"
:data (json-encode rule)
:then then
:else (lambda (plz-error)
(pcase-let* (((cl-struct plz-error response) plz-error)
((cl-struct plz-response status) response))
(pcase status
(404 (pcase rule
(`nil
;; Room already had no rules, so none being found is not an
;; error.
nil)
(_ ;; Unexpected error: re-signal.
(ement-api-error plz-error))))
(_ ;; Unexpected error: re-signal.
(ement-api-error plz-error)))))))))
(pcase-let* ((available-states
(ement-alist
nil (ement-alist
"override" nil
"room" nil)
'all (ement-alist
"override" nil
"room" (ement-alist
'actions (vector "notify" (ement-alist
'set_tweak "sound"
'value "default"))))
'mentions-and-keywords (ement-alist
"override" nil
"room" (ement-alist
'actions (vector "dont_notify")))
'none (ement-alist
"override" (ement-alist
'actions (vector "dont_notify")
'conditions (vector (ement-alist
'kind "event_match"
'key "room_id"
'pattern (ement-room-id room))))
"room" nil)))
(kinds-and-rules (alist-get state available-states nil nil #'equal)))
(cl-loop with queue = (make-plz-queue :limit 1)
with total = (1- (length kinds-and-rules))
for count from 0
for message-fn = (if (equal count total)
(lambda (_data)
(message "Set notification rules for room: %s" (ement--format-room room)))
#'ignore)
for (kind . state) in kinds-and-rules
do (set-rule kind state queue message-fn)))))
;;;;; Public functions
;; These functions could reasonably be called by code in other packages.
(cl-defun ement-put-state
(room type key data session
&key (then (lambda (response-data)
(ement-debug "State data put on room" response-data data room session))))
"Put state event of TYPE with KEY and DATA on ROOM on SESSION.
DATA should be an alist, which will become the JSON request
body."
(declare (indent defun))
(pcase-let* ((endpoint (format "rooms/%s/state/%s/%s"
(url-hexify-string (ement-room-id room))
type key)))
(ement-api session endpoint :method 'put :data (json-encode data)
;; TODO: Handle error codes.
:then then)))
(defun ement-message (format-string &rest args)
"Call `message' on FORMAT-STRING prefixed with \"Ement: \"."
;; TODO: Use this function everywhere we use `message'.
(apply #'message (concat "Ement: " format-string) args))
(cl-defun ement-upload (session &key data filename then else
(content-type "application/octet-stream"))
"Upload DATA with FILENAME to content repository on SESSION.
THEN and ELSE are passed to `ement-api', which see."
(declare (indent defun))
(ement-api session "upload" :method 'post :endpoint-category "media"
;; NOTE: Element currently uses "r0" not "v3", so so do we.
:params (when filename
(list (list "filename" filename)))
:content-type content-type :data data :data-type 'binary
:then then :else else))
(cl-defun ement-complete-session (&key (prompt "Session: "))
"Return an Ement session selected with completion."
(cl-etypecase (length ement-sessions)
((integer 1 1) (cdar ement-sessions))
((integer 2 *) (let* ((ids (mapcar #'car ement-sessions))
(selected-id (completing-read prompt ids nil t)))
(alist-get selected-id ement-sessions nil nil #'equal)))
(otherwise (user-error "No active sessions. Call `ement-connect' to log in"))))
(declare-function ewoc-locate "ewoc")
(defun ement-complete-user-id ()
"Return a user-id selected with completion.
Selects from seen users on all sessions. If point is on an
event, suggests the event's sender as initial input. Allows
unseen user IDs to be input as well."
(cl-labels ((format-user (user)
;; FIXME: Per-room displaynames are now stored in room structs
;; rather than user structs, so to be complete, this needs to
;; iterate over all known rooms, looking for the user's
;; displayname in that room.
(format "%s <%s>"
(ement-user-displayname user)
(ement-user-id user))))
(let* ((display-to-id
(cl-loop for key being the hash-keys of ement-users
using (hash-values value)
collect (cons (format-user value) key)))
(user-at-point (when (equal major-mode 'ement-room-mode)
(when-let ((node (ewoc-locate ement-ewoc)))
(when (ement-event-p (ewoc-data node))
(format-user (ement-event-sender (ewoc-data node)))))))
(selected-user (completing-read "User: " (mapcar #'car display-to-id)
nil nil user-at-point)))
(or (alist-get selected-user display-to-id nil nil #'equal)
selected-user))))
(cl-defun ement-put-account-data
(session type data &key room
(then (lambda (received-data)
;; Handle echoed-back account data event (the spec does not explain this,
;; but see <https://github.com/matrix-org/matrix-react-sdk/blob/675b4271e9c6e33be354a93fcd7807253bd27fcd/src/settings/handlers/AccountSettingsHandler.ts#L150>).
;; FIXME: Make session account-data a map instead of a list of events.
(if room
(push received-data (ement-room-account-data room))
(push received-data (ement-session-account-data session)))
;; NOTE: Commenting out this ement-debug form because a bug in Emacs
;; causes this long string to be interpreted as the function's
;; docstring and cause a too-long-docstring warning.
;; (ement-debug "Account data put and received back on session %s: PUT(json-encoded):%S RECEIVED:%S"
;; (ement-user-id (ement-session-user session)) (json-encode data) received-data)
)))
"Put account data of TYPE with DATA on SESSION.
If ROOM, put it on that room's account data. Also handle the
echoed-back event."
(declare (indent defun))
(pcase-let* (((cl-struct ement-session (user (cl-struct ement-user (id user-id)))) session)
(room-part (if room (format "/rooms/%s" (ement-room-id room)) ""))
(endpoint (format "user/%s%s/account_data/%s" (url-hexify-string user-id) room-part type)))
(ement-api session endpoint :method 'put :data (json-encode data)
:then then)))
(defun ement-redact (event room session &optional reason)
"Redact EVENT in ROOM on SESSION, optionally for REASON."
(pcase-let* (((cl-struct ement-event (id event-id)) event)
((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/redact/%s/%s"
room-id event-id (ement--update-transaction-id session)))
(content (ement-alist "reason" reason)))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (lambda (_data)
(message "Event %s redacted." event-id)))))
;;;;; Inline functions
(defsubst ement--user-color (user)
"Return USER's color, setting it if necessary.
USER is an `ement-user' struct."
(or (ement-user-color user)
(setf (ement-user-color user)
(ement--prism-color (ement-user-id user)))))
;;;;; Private functions
;; These functions aren't expected to be called by code in other packages (but if that
;; were necessary, they could be renamed accordingly).
;; (defun ement--room-routing (room)
;; "Return a list of servers to route to ROOM through."
;; ;; See <https://spec.matrix.org/v1.2/appendices/#routing>.
;; ;; FIXME: Ensure highest power level user is at least level 50.
;; ;; FIXME: Ignore servers blocked due to server ACLs.
;; ;; FIXME: Ignore servers which are IP addresses.
;; (cl-labels ((most-powerful-user-in
;; (room))
;; (servers-by-population-in
;; (room))
;; (server-of (user)))
;; (let (first-server-by-power-level)
;; (delete-dups
;; (remq nil
;; (list
;; ;; 1.
;; (or (when-let ((user (most-powerful-user-in room)))
;; (setf first-server-by-power-level t)
;; (server-of user))
;; (car (servers-by-population-in room)))
;; ;; 2.
;; (if first-server-by-power-level
;; (car (servers-by-population-in room))
;; (cl-second (servers-by-population-in room)))
;; ;; 3.
;; (cl-third (servers-by-population-in room))))))))
(defun ement--space-p (room)
"Return non-nil if ROOM is a space."
(equal "m.space" (ement-room-type room)))
(defun ement--room-in-space-p (room space)
"Return non-nil if ROOM is in SPACE on SESSION."
;; We could use `ement---room-spaces', but since that returns rooms by looking them up
;; by ID in the session's rooms list, this is more efficient.
(pcase-let* (((cl-struct ement-room (id parent-id) (local (map children))) space)
((cl-struct ement-room (id child-id) (local (map parents))) room))
(or (member parent-id parents)
(member child-id children))))
(defun ement--room-spaces (room session)
"Return list of ROOM's parent spaces on SESSION."
;; NOTE: This only looks in the room's parents list; it doesn't look in every space's children
;; list. This should be good enough, assuming we add to the lists correctly elsewhere.
(pcase-let* (((cl-struct ement-session rooms) session)
((cl-struct ement-room (local (map parents))) room))
(cl-remove-if-not (lambda (session-room-id)
(member session-room-id parents))
rooms :key #'ement-room-id)))
(cl-defun ement--prism-color (string &key (contrast-with (face-background 'default nil 'default)))
"Return a computed color for STRING.
The color is adjusted to have sufficient contrast with the color
CONTRAST-WITH (by default, the default face's background). The
computed color is useful for user messages, generated room
avatars, etc."
;; TODO: Use this instead of `ement-room--user-color'. (Same algorithm ,just takes a
;; string as argument.)
;; TODO: Try using HSV somehow so we could avoid having so many strings return a
;; nearly-black color.
(cl-labels ((relative-luminance (rgb)
;; Copy of `modus-themes-wcag-formula', an elegant
;; implementation by Protesilaos Stavrou. Also see
;; <https://en.wikipedia.org/wiki/Relative_luminance> and
;; <https://www.w3.org/TR/WCAG20/#relativeluminancedef>.
(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)
;; Copy of `modus-themes-contrast'; see above.
(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)
;; Avoid infinite loop in case of weirdness
;; by returning color as a fallback.
finally return (or new-color color)))))
(let* ((id string)
(id-hash (float (+ (abs (sxhash id)) ement-room-prism-color-adjustment)))
;; TODO: Wrap-around the value to get the color I want.
(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)))
(contrast-with-rgb (color-name-to-rgb contrast-with)))
(when (< (contrast-ratio color-rgb contrast-with-rgb) ement-room-prism-minimum-contrast)
(setf color-rgb (increase-contrast color-rgb contrast-with-rgb ement-room-prism-minimum-contrast
(color-name-to-rgb
;; Ideally we would use the foreground color,
;; but in some themes, like Solarized Dark,
;; the foreground color's contrast is too low
;; to be effective as the value to increase
;; contrast against, so we use white or black.
(pcase contrast-with
((or `nil "unspecified-bg")
;; The `contrast-with' color (i.e. the
;; default background color) is nil. This
;; probably means that we're displaying on
;; a TTY.
(if (fboundp 'frame--current-backround-mode)
;; This function can tell us whether
;; the background color is dark or
;; light, but it was added in Emacs
;; 28.1.
(pcase (frame--current-backround-mode (selected-frame))
('dark "white")
('light "black"))
;; Pre-28.1: Since faces' colors may be
;; "unspecified" on TTY frames, in which
;; case we have nothing to compare with, we
;; assume that the background color of such
;; a frame is black and increase contrast
;; toward white.
"white"))
(_
;; The `contrast-with` color is usable: test it.
(if (ement--color-dark-p (color-name-to-rgb contrast-with))
"white" "black")))))))
(apply #'color-rgb-to-hex (append color-rgb (list 2))))))
(cl-defun ement--format-user (user &optional (room ement-room) (session ement-session))
"Format `ement-user' USER for ROOM on SESSION.
ROOM defaults to the value of `ement-room'."
(let ((face (cond ((equal (ement-user-id (ement-session-user session))
(ement-user-id user))
'ement-room-self)
(ement-room-prism
`(:inherit ement-room-user :foreground ,(or (ement-user-color user)
(setf (ement-user-color user)
(ement--prism-color user)))))
(t 'ement-room-user))))
;; FIXME: If a membership state event has not yet been received, this
;; sets the display name in the room to the user ID, and that prevents
;; the display name from being used if the state event arrives later.
(propertize (ement--user-displayname-in room user)
'face face
'help-echo (ement-user-id user))))
(cl-defun ement--format-body-mentions
(body room &key (template "<a href=\"https://matrix.to/#/%s\">%s</a>"))
"Return string for BODY with mentions in ROOM linkified with TEMPLATE.
TEMPLATE is a format string in which the first \"%s\" is replaced
with the user's MXID and the second with the displayname. A
mention is qualified by an \"@\"-prefixed displayname or
MXID (optionally suffixed with a colon), or a colon-suffixed
displayname, followed by a blank, question mark, comma, or
period, anywhere in the body."
;; Examples:
;; "@foo: hi"
;; "@foo:matrix.org: hi"
;; "foo: hi"
;; "@foo and @bar:matrix.org: hi"
;; "foo: how about you and @bar ..."
(declare (indent defun))
(cl-labels ((members-having-displayname (name members)
;; Iterating over the hash table values isn't as efficient as a hash
;; lookup, but in most rooms it shouldn't be a problem.
(cl-loop for user being the hash-values of members
when (equal name (ement--user-displayname-in room user))
collect user)))
(pcase-let* (((cl-struct ement-room members) room)
(regexp (rx (or bos bow (1+ blank))
(or (seq (group
;; Group 1: full @-prefixed MXID.
"@" (group
;; Group 2: displayname. (NOTE: Does not work
;; with displaynames containing spaces.)
(1+ (seq (optional ".") alnum)))
(optional ":" (1+ (seq (optional ".") alnum))))
(or ":" eow eos (syntax punctuation)))
(seq (group
;; Group 3: MXID username or displayname.
(1+ (not blank)))
":" (1+ blank)))))
(pos 0) (replace-group) (replacement))
(while (setf pos (string-match regexp body pos))
(if (setf replacement
(or (when-let (member (gethash (match-string 1 body) members))
;; Found user ID: use it as replacement.
(setf replace-group 1)
(format template (match-string 1 body)
(ement--xml-escape-string (ement--user-displayname-in room member))))
(when-let* ((name (or (when (match-string 2 body)
(setf replace-group 1)
(match-string 2 body))
(prog1 (match-string 3 body)
(setf replace-group 3))))
(members (members-having-displayname name members))
(member (when (= 1 (length members))
;; If multiple members are found with the same
;; displayname, do nothing.
(car members))))
;; Found displayname: use it and MXID as replacement.
(format template (ement-user-id member)
(ement--xml-escape-string name)))))
(progn
;; Found member: replace and move to end of replacement.
(setf body (replace-match replacement t t body replace-group))
(let ((difference (- (length replacement) (length (match-string 0 body)))))
(setf pos (if (/= 0 difference)
;; Replacement of a different length: adjust POS accordingly.
(+ pos difference)
(match-end 0)))))
;; No replacement: move to end of match.
(setf pos (match-end 0))))))
body)
(defun ement--event-mentions-room-p (event &rest _ignore)
"Return non-nil if EVENT mentions \"@room\"."
(pcase-let (((cl-struct ement-event (content (map body))) event))
(when body
(string-match-p (rx (or space bos) "@room" eow) body))))
(cl-defun ement-complete-room (&key session (predicate #'identity)
(prompt "Room: ") (suggest t))
"Return a (room session) list selected from SESSION with completion.
If SESSION is nil, select from rooms in all of `ement-sessions'.
When SUGGEST, suggest current buffer's room (or a room at point
in a room list buffer) as initial input (i.e. it should be set to
nil when switching from one room buffer to another). PROMPT may
override the default prompt. PREDICATE may be a function to
select which rooms are offered; it is also applied to the
suggested room."
(declare (indent defun))
(pcase-let* ((sessions (if session
(list session)
(mapcar #'cdr ement-sessions)))
(name-to-room-session
(cl-loop for session in sessions
append (cl-loop for room in (ement-session-rooms session)
when (funcall predicate room)
collect (cons (ement--format-room room 'topic)
(list room session)))))
(names (mapcar #'car name-to-room-session))
(selected-name (completing-read
prompt names nil t
(when suggest
(when-let ((suggestion (ement--room-at-point)))
(when (or (not predicate)
(funcall predicate suggestion))
(ement--format-room suggestion 'topic)))))))
(alist-get selected-name name-to-room-session nil nil #'string=)))
(cl-defun ement-send-message (room session
&key body formatted-body replying-to-event filter then)
"Send message to ROOM on SESSION with BODY and FORMATTED-BODY.
THEN may be a function to call after the event is sent
successfully. It is called with keyword arguments for ROOM,
SESSION, CONTENT, and DATA.
REPLYING-TO-EVENT may be an event the message is
in reply to; the message will reference it appropriately.
FILTER may be a function through which to pass the message's
content object before sending (see,
e.g. `ement-room-send-org-filter')."
(declare (indent defun))
(cl-assert (not (string-empty-p body)))
(cl-assert (or (not formatted-body) (not (string-empty-p formatted-body))))
(pcase-let* (((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/send/m.room.message/%s" (url-hexify-string room-id)
(ement--update-transaction-id session)))
(formatted-body (when formatted-body
(ement--format-body-mentions formatted-body room)))
(content (ement-aprog1
(ement-alist "msgtype" "m.text"
"body" body)
(when formatted-body
(push (cons "formatted_body" formatted-body) it)
(push (cons "format" "org.matrix.custom.html") it))))
(then (or then #'ignore)))
(when filter
(setf content (funcall filter content room)))
(when replying-to-event
(setf content (ement--add-reply content replying-to-event room)))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (apply-partially then :room room :session session
;; Data is added when calling back.
:content content :data))))
(defalias 'ement--button-buttonize
;; This isn't nice, but what can you do.
(cond ((version<= "29.1" emacs-version) #'buttonize)
((version<= "28.1" emacs-version) (with-suppressed-warnings ((obsolete button-buttonize))
#'button-buttonize))
((version< emacs-version "28.1")
;; FIXME: This doesn't set the mouse-face to highlight, and it doesn't use the
;; default-button category. Neither does `button-buttonize', of course, but why?
(lambda (string callback &optional data)
"Make STRING into a button and return it.
When clicked, CALLBACK will be called with the DATA as the
function argument. If DATA isn't present (or is nil), the button
itself will be used instead as the function argument."
(propertize string
'face 'button
'button t
'follow-link t
'category t
'button-data data
'keymap button-map
'action callback)))))
(defun ement--add-reply (data replying-to-event room)
"Return DATA adding reply data for REPLYING-TO-EVENT in ROOM.
DATA is an unsent message event's data alist."
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#id351> "13.2.2.6.1 Rich replies"
;; FIXME: Rename DATA.
(pcase-let* (((cl-struct ement-event (id replying-to-event-id)
content (sender replying-to-sender))
replying-to-event)
((cl-struct ement-user (id replying-to-sender-id)) replying-to-sender)
((map ('body replying-to-body) ('formatted_body replying-to-formatted-body)) content)
(replying-to-sender-name (ement--user-displayname-in ement-room replying-to-sender))
(quote-string (format "> <%s> %s\n\n" replying-to-sender-name replying-to-body))
(reply-body (alist-get "body" data nil nil #'string=))
(reply-formatted-body (alist-get "formatted_body" data nil nil #'string=))
(reply-body-with-quote (concat quote-string reply-body))
(reply-formatted-body-with-quote
(format "<mx-reply>
<blockquote>
<a href=\"https://matrix.to/#/%s/%s\">In reply to</a>
<a href=\"https://matrix.to/#/%s\">%s</a>
<br />
%s
</blockquote>
</mx-reply>
%s"
(ement-room-id room) replying-to-event-id replying-to-sender-id replying-to-sender-name
;; TODO: Encode HTML special characters. Not as straightforward in Emacs as one
;; might hope: there's `web-mode-html-entities' and `org-entities'. See also
;; <https://emacs.stackexchange.com/questions/8166/encode-non-html-characters-to-html-equivalent>.
(or replying-to-formatted-body replying-to-body)
(or reply-formatted-body reply-body))))
;; NOTE: map-elt doesn't work with string keys, so we use `alist-get'.
(setf (alist-get "body" data nil nil #'string=) reply-body-with-quote
(alist-get "formatted_body" data nil nil #'string=) reply-formatted-body-with-quote
data (append (ement-alist "m.relates_to"
(ement-alist "m.in_reply_to"
(ement-alist "event_id" replying-to-event-id))
"format" "org.matrix.custom.html")
data))
data))
(defun ement--direct-room-for-user (user session)
"Return last-modified direct room with USER on SESSION, if one exists."
;; Loosely modeled on the Element function findDMForUser in createRoom.ts.
(cl-labels ((membership-event-for-p (event user)
(and (equal "m.room.member" (ement-event-type event))
(equal (ement-user-id user) (ement-event-state-key event))))
(latest-membership-for (user room)
(when-let ((latest-membership-event
(car
(cl-sort
;; I guess we need to check both state and timeline events.
(append (cl-remove-if-not (lambda (event)
(membership-event-for-p event user))
(ement-room-state room))
(cl-remove-if-not (lambda (event)
(membership-event-for-p event user))
(ement-room-timeline room)))
(lambda (a b)
;; Sort latest first so we can use the car.
(> (ement-event-origin-server-ts a)
(ement-event-origin-server-ts b)))))))
(alist-get 'membership (ement-event-content latest-membership-event))))
(latest-event-in (room)
(car
(cl-sort
(append (ement-room-state room)
(ement-room-timeline room))
(lambda (a b)
;; Sort latest first so we can use the car.
(> (ement-event-origin-server-ts a)
(ement-event-origin-server-ts b)))))))
(let* ((direct-rooms (cl-remove-if-not
(lambda (room)
(ement--room-direct-p room session))
(ement-session-rooms session)))
(direct-joined-rooms
;; Ensure that the local user is still in each room.
(cl-remove-if-not
(lambda (room)
(equal "join" (latest-membership-for (ement-session-user session) room)))
direct-rooms))
;; Since we don't currently keep a member list for each room, we look in the room's
;; join events to see if the user has joined or been invited.
(direct-rooms-with-user
(cl-remove-if-not
(lambda (room)
(member (latest-membership-for user room) '("invite" "join")))
direct-joined-rooms)))
(car (cl-sort direct-rooms-with-user
(lambda (a b)
(> (latest-event-in a) (latest-event-in b))))))))
(defun ement--event-replaces-p (a b)
"Return non-nil if event A replaces event B.
That is, if event A replaces B in their
\"m.relates_to\"/\"m.relations\" and \"m.replace\" metadata."
(pcase-let* (((cl-struct ement-event (id a-id) (origin-server-ts a-ts)
(content (map ('m.relates_to
(map ('rel_type a-rel-type)
('event_id a-replaces-event-id))))))
a)
((cl-struct ement-event (id b-id) (origin-server-ts b-ts)
(content (map ('m.relates_to
(map ('rel_type b-rel-type)
('event_id b-replaces-event-id)))
('m.relations
(map ('m.replace
(map ('event_id b-replaced-by-event-id))))))))
b))
(or (equal a-id b-replaced-by-event-id)
(and (equal "m.replace" a-rel-type)
(or (equal a-replaces-event-id b-id)
(and (equal "m.replace" b-rel-type)
(equal a-replaces-event-id b-replaces-event-id)
(>= a-ts b-ts)))))))
(defun ement--events-equal-p (a b)
"Return non-nil if events A and B are essentially equal.
That is, A and B are either the same event (having the same event
ID), or one event replaces the other (in their m.relates_to and
m.replace metadata)."
(or (equal (ement-event-id a) (ement-event-id b))
(ement--event-replaces-p a b)
(ement--event-replaces-p b a)))
(defun ement--format-room (room &optional topic)
"Return ROOM formatted with name, alias, ID, and optionally TOPIC.
Suitable for use in completion, etc."
(if topic
(format "%s%s(<%s>)%s"
(or (ement-room-display-name room)
(setf (ement-room-display-name room)
(ement--room-display-name room)))
(if (ement-room-canonical-alias room)
(format " <%s> " (ement-room-canonical-alias room))
" ")
(ement-room-id room)
(if (ement-room-topic room)
(format ": \"%s\"" (ement-room-topic room))
""))
(format "%s%s(<%s>)"
(or (ement-room-display-name room)
(setf (ement-room-display-name room)
(ement--room-display-name room)))
(if (ement-room-canonical-alias room)
(format " <%s> " (ement-room-canonical-alias room))
" ")
(ement-room-id room))))
(defun ement--members-alist (room)
"Return alist of member displaynames mapped to IDs seen in ROOM."
;; We map displaynames to IDs because `ement-room--format-body-mentions' needs to find
;; MXIDs from displaynames.
(pcase-let* (((cl-struct ement-room timeline) room)
(members-seen (mapcar #'ement-event-sender timeline))
(members-alist))
(dolist (member members-seen)
;; Testing with `benchmark-run-compiled', it appears that using `cl-pushnew' is
;; about 10x faster than using `delete-dups'.
(cl-pushnew (cons (ement--user-displayname-in room member)
(ement-user-id member))
members-alist))
members-alist))
(defun ement--mxc-to-url (uri session)
"Return HTTPS URL for MXC URI accessed through SESSION."
(pcase-let* (((cl-struct ement-session server) session)
((cl-struct ement-server uri-prefix) server)
(server-name) (media-id))
(string-match (rx "mxc://" (group (1+ (not (any "/"))))
"/" (group (1+ anything))) uri)
(setf server-name (match-string 1 uri)
media-id (match-string 2 uri))
(format "%s/_matrix/media/r0/download/%s/%s"
uri-prefix server-name media-id)))
(defun ement--remove-face-property (string value)
"Remove VALUE from STRING's `face' properties.
Used to remove the `button' face from buttons, because that face
can cause undesirable underlining."
(let ((pos 0))
(cl-loop for next-face-change-pos = (next-single-property-change pos 'face string)
for face-at = (get-text-property pos 'face string)
when face-at
do (put-text-property pos (or next-face-change-pos (length string))
'face (cl-typecase face-at
(atom (if (equal value face-at)
nil face-at))
(list (remove value face-at)))
string)
while next-face-change-pos
do (setf pos next-face-change-pos))))
(cl-defun ement--text-property-search-forward (property predicate string &key (start 0))
"Return the position at which PROPERTY in STRING matches PREDICATE.
Return nil if not found. Searches forward from START."
(declare (indent defun))
(cl-loop for pos = start then (next-single-property-change pos property string)
while pos
when (funcall predicate (get-text-property pos property string))
return pos))
(cl-defun ement--text-property-search-backward (property predicate string &key (start 0))
"Return the position at which PROPERTY in STRING matches PREDICATE.
Return nil if not found. Searches backward from START."
(declare (indent defun))
(cl-loop for pos = start then (previous-single-property-change pos property string)
while (and pos (> pos 1))
when (funcall predicate (get-text-property (1- pos) property string))
return pos))
(defun ement--resize-image (image max-width max-height)
"Return a copy of IMAGE set to MAX-WIDTH and MAX-HEIGHT.
IMAGE should be one as created by, e.g. `create-image'."
(declare
;; This silences a lint warning on our GitHub CI runs, which use a build of Emacs
;; without image support.
(function image-property "image"))
;; It would be nice if the image library had some simple functions to do this sort of thing.
(let ((new-image (cl-copy-list image)))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(setf (image-property new-image :type) 'imagemagick))
(setf (image-property new-image :max-width) max-width
(image-property new-image :max-height) max-height)
new-image))
(defun ement--room-alias (room)
"Return latest m.room.canonical_alias event in ROOM."
;; FIXME: This function probably needs to compare timestamps to ensure that older events
;; that are inserted at the head of the events lists aren't used instead of newer ones.
(or (cl-loop for event in (ement-room-timeline room)
when (equal "m.room.canonical_alias" (ement-event-type event))
return (alist-get 'alias (ement-event-content event)))
(cl-loop for event in (ement-room-state room)
when (equal "m.room.canonical_alias" (ement-event-type event))
return (alist-get 'alias (ement-event-content event)))))
(declare-function magit-current-section "magit-section")
(declare-function eieio-oref "eieio-core")
(defun ement--room-at-point ()
"Return room at point.
Works in major-modes `ement-room-mode',
`ement-tabulated-room-list-mode', and `ement-room-list-mode'."
(pcase major-mode
('ement-room-mode ement-room)
('ement-tabulated-room-list-mode (tabulated-list-get-id))
('ement-room-list-mode
(cl-typecase (oref (magit-current-section) value)
(taxy-magit-section nil)
(t (pcase (oref (magit-current-section) value)
(`[,room ,_session] room)))))))
(defun ement--room-direct-p (room session)
"Return non-nil if ROOM on SESSION is a direct chat."
(cl-labels ((content-contains-room-id (content room-id)
(cl-loop for (_user-id . room-ids) in content
;; NOTE: room-ids is a vector.
thereis (seq-contains-p room-ids room-id))))
(pcase-let* (((cl-struct ement-session account-data) session)
((cl-struct ement-room id) room))
(or (cl-loop for event in account-data
when (equal "m.direct" (alist-get 'type event))
thereis (content-contains-room-id (alist-get 'content event) id))
(cl-loop
;; Invited rooms have no account-data yet, and their
;; directness flag is in invite-state events.
for event in (ement-room-invite-state room)
thereis (alist-get 'is_direct (ement-event-content event)))))))
(defun ement--room-display-name (room)
"Return the displayname for ROOM."
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#calculating-the-display-name-for-a-room>.
;; NOTE: The spec seems incomplete, because the algorithm it recommends does not say how
;; or when to use "m.room.member" events for rooms without heroes (e.g. invited rooms).
;; TODO: Add SESSION argument and use it to remove local user from names.
(cl-labels ((latest-event (type content-field)
(or (cl-loop for event in (ement-room-timeline room)
when (and (equal type (ement-event-type event))
(not (string-empty-p (alist-get content-field (ement-event-content event)))))
return (alist-get content-field (ement-event-content event)))
(cl-loop for event in (ement-room-state room)
when (and (equal type (ement-event-type event))
(not (string-empty-p (alist-get content-field (ement-event-content event)))))
return (alist-get content-field (ement-event-content event)))))
(member-events-name ()
(when-let ((member-events (cl-loop for accessor in '(ement-room-timeline ement-room-state ement-room-invite-state)
append (cl-remove-if-not (apply-partially #'equal "m.room.member")
(funcall accessor room)
:key #'ement-event-type))))
(string-join (delete-dups
(mapcar (lambda (event)
(ement--user-displayname-in room (ement-event-sender event)))
member-events))
", ")))
(heroes-name ()
(pcase-let* (((cl-struct ement-room summary) room)
((map ('m.heroes hero-ids) ('m.joined_member_count joined-count)
('m.invited_member_count invited-count))
summary))
;; TODO: Disambiguate hero display names.
(when hero-ids
(cond ((<= (+ joined-count invited-count) 1)
;; Empty room.
(empty-room hero-ids joined-count))
((>= (length hero-ids) (1- (+ joined-count invited-count)))
;; Members == heroes.
(hero-names hero-ids))
((and (< (length hero-ids) (1- (+ joined-count invited-count)))
(> (+ joined-count invited-count) 1))
;; More members than heroes.
(heroes-and-others hero-ids joined-count))))))
(hero-names (heroes)
(string-join (mapcar #'hero-name heroes) ", "))
(hero-name (id)
(if-let ((user (gethash id ement-users)))
(ement--user-displayname-in room user)
id))
(heroes-and-others (heroes joined)
(format "%s, and %s others" (hero-names heroes)
(- joined (length heroes))))
(name-override ()
(when-let ((event (alist-get "org.matrix.msc3015.m.room.name.override"
(ement-room-account-data room)
nil nil #'equal)))
(map-nested-elt event '(content name))))
(empty-room (heroes joined)
(cl-etypecase (length heroes)
((satisfies zerop) "Empty room")
((number 1 5) (format "Empty room (was %s)"
(hero-names heroes)))
(t (format "Empty room (was %s)"
(heroes-and-others heroes joined))))))
(or (name-override)
(latest-event "m.room.name" 'name)
(latest-event "m.room.canonical_alias" 'alias)
(heroes-name)
(member-events-name)
(ement-room-id room))))
(defun ement--room-favourite-p (room)
"Return non-nil if ROOM is tagged as favourite."
(ement--room-tagged-p "m.favourite" room))
(defun ement--room-low-priority-p (room)
"Return non-nil if ROOM is tagged as low-priority."
(ement--room-tagged-p "m.lowpriority" room))
(defun ement--room-tagged-p (tag room)
"Return non-nil if ROOM has TAG."
;; TODO: Use `make-ement-event' on account-data events.
(pcase-let* (((cl-struct ement-room account-data) room)
(tag-event (alist-get "m.tag" account-data nil nil #'equal)))
(when tag-event
(pcase-let (((map ('content (map tags))) tag-event))
(cl-typecase tag
;; Tags are symbols internally, because `json-read' converts map keys to them.
(string (setf tag (intern tag))))
(assoc tag tags)))))
(defun ement--room-unread-p (room session)
"Return non-nil if ROOM is considered unread for SESSION.
The room is unread if it has a modified, live buffer; if it has
non-zero unread notification counts; or if its fully-read marker
is not at the latest known message event."
;; Roughly equivalent to the "red/gray/bold/idle" states listed in
;; <https://github.com/matrix-org/matrix-react-sdk/blob/b0af163002e8252d99b6d7075c83aadd91866735/docs/room-list-store.md#list-ordering-algorithm-importance>.
(pcase-let* (((cl-struct ement-room timeline account-data unread-notifications receipts
(local (map buffer)))
room)
((cl-struct ement-session user) session)
((cl-struct ement-user (id our-id)) user)
((map notification_count highlight_count) unread-notifications)
(fully-read-event-id (map-nested-elt (alist-get "m.fully_read" account-data nil nil #'equal)
'(content event_id))))
;; MAYBE: Ignore whether the buffer is modified. Since we have a better handle on how
;; Matrix does notifications/unreads/highlights, maybe that's not needed, and it would
;; be more consistent to ignore it.
(or (and buffer (buffer-modified-p buffer))
(and unread-notifications
(or (not (zerop notification_count))
(not (zerop highlight_count))))
;; NOTE: This is *WAY* too complicated, but it seems roughly equivalent to doesRoomHaveUnreadMessages() from
;; <https://github.com/matrix-org/matrix-react-sdk/blob/7fa01ffb068f014506041bce5f02df4f17305f02/src/Unread.ts#L52>.
(when (and (not ement-room-unread-only-counts-notifications)
timeline)
;; A room should rarely, if ever, have a nil timeline, but in case it does
;; (which apparently can happen, given user reports), it should not be
;; considered unread.
(cl-labels ((event-counts-toward-unread-p (event)
;; NOTE: We only consider message events, so membership, reaction,
;; etc. events will not mark a room as unread. Ideally, I think
;; that join/leave events should, at least optionally, mark a room
;; as unread (e.g. in a 1:1 room with a friend, if the other user
;; left, one would probably want to know, and marking the room
;; unread would help the user notice), but since membership events
;; have to be processed to understand their meaning, it's not
;; straightforward to know whether one should mark a room unread.
;; FIXME: Use code from `ement-room--format-member-event' to
;; distinguish ones that should count.
(equal "m.room.message" (ement-event-type event))))
(let ((our-read-receipt-event-id (car (gethash our-id receipts)))
(first-counting-event (cl-find-if #'event-counts-toward-unread-p timeline)))
(cond ((equal fully-read-event-id (ement-event-id (car timeline)))
;; The fully-read marker is at the last known event: the room is read.
nil)
((and (not our-read-receipt-event-id)
(when first-counting-event
(and (not (equal fully-read-event-id (ement-event-id first-counting-event)))
(not (equal our-id (ement-user-id (ement-event-sender first-counting-event)))))))
;; The room has no read receipt, and the latest message event is not
;; the event at which our fully-read marker is at, and it is not sent
;; by us: the room is unread. (This is a kind of failsafe to ensure
;; the user doesn't miss any messages, but it's unclear whether this
;; is really correct or best.)
t)
((equal our-id (ement-user-id (ement-event-sender (car timeline))))
;; We sent the last event: the room is read.
nil)
((and first-counting-event
(equal our-id (ement-user-id (ement-event-sender first-counting-event))))
;; We sent the last message event: the room is read.
nil)
((cl-loop for event in timeline
when (event-counts-toward-unread-p event)
return (and (not (equal our-read-receipt-event-id (ement-event-id event)))
(not (equal fully-read-event-id (ement-event-id event)))))
;; The latest message event is not the event at which our
;; read-receipt or fully-read marker are at: the room is unread.
t))))))))
(defun ement--update-transaction-id (session)
"Return SESSION's incremented transaction ID formatted for sending.
Increments ID and appends current timestamp to avoid reuse
problems."
;; TODO: Naming things is hard.
;; In the event that Emacs isn't killed cleanly and the session isn't saved to disk, the
;; transaction ID would get reused the next time the user connects. To avoid that, we
;; append the current time to the ID. (IDs are just strings, and Element does something
;; similar, so this seems reasonable.)
(format "%s-%s"
(cl-incf (ement-session-transaction-id session))
(format-time-string "%s")))
(defun ement--user-displayname-in (room user)
"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>.
;; FIXME: Add step 3 of the spec. For now we skip to step 4.
;; NOTE: Both state and timeline events must be searched. (A helpful user
;; in #matrix-dev:matrix.org, Michael (t3chguy), clarified this for me).
(if-let ((cached-name (gethash user (ement-room-displaynames room))))
cached-name
;; Put timeline events before state events, because IIUC they should be more recent.
(cl-labels ((join-displayname-event-p (event)
(and (eq user (ement-event-sender event))
(equal "m.room.member" (ement-event-type event))
(equal "join" (alist-get 'membership (ement-event-content event)))
(alist-get 'displayname (ement-event-content event)))))
;; FIXME: Should probably sort the relevant events to get the latest one.
(if-let* ((displayname (or (cl-loop for event in (ement-room-timeline room)
when (join-displayname-event-p event)
return (alist-get 'displayname (ement-event-content event)))
(cl-loop for event in (ement-room-state room)
when (join-displayname-event-p event)
return (alist-get 'displayname (ement-event-content event)))))
(calculated-name displayname))
(puthash user calculated-name (ement-room-displaynames room))
;; No membership state event: use pre-calculated displayname or ID.
(or (ement-user-displayname user)
(ement-user-id user))))))
(defun ement--xml-escape-string (string)
"Return STRING having been escaped with `xml-escape-string'.
Before Emacs 28, ignores `xml-invalid-character' errors (and any
invalid characters cause STRING to remain unescaped). After
Emacs 28, uses the NOERROR argument to `xml-escape-string'."
(with-suppressed-warnings ((callargs xml-escape-string))
(condition-case _
(xml-escape-string string 'noerror)
(wrong-number-of-arguments
(condition-case _
(xml-escape-string string)
(xml-invalid-character
;; We still don't want to error on this, so just return the string.
string))))))
(defun ement--mark-room-direct (room session)
"Mark ROOM on SESSION as a direct room.
This may be used to mark rooms as direct which, for whatever
reason (like a bug in your favorite client), were not marked as
such when they were created."
(pcase-let* (((cl-struct ement-room timeline (id room-id)) room)
((cl-struct ement-session (user local-user)) session)
((cl-struct ement-user (id local-user-id)) local-user)
(direct-rooms-account-data-event-content
(alist-get 'content
(cl-find-if (lambda (event)
(equal "m.direct" (alist-get 'type event)))
(ement-session-account-data session))))
(members (delete-dups (mapcar #'ement-event-sender timeline)))
(other-users (cl-remove local-user-id members
:key #'ement-user-id :test #'equal))
((cl-struct ement-user (id other-user-id)) (car other-users))
;; The alist keys are MXIDs as symbols.
(other-user-id (intern other-user-id))
(existing-direct-rooms-for-user (map-elt direct-rooms-account-data-event-content other-user-id)))
(cl-assert (= 1 (length other-users)))
(setf (map-elt direct-rooms-account-data-event-content other-user-id)
(cl-coerce (append existing-direct-rooms-for-user (list room-id))
'vector))
(ement-put-account-data session "m.direct" direct-rooms-account-data-event-content
:then (lambda (_data)
(message "Ement: Room <%s> marked as direct for <%s>." room-id other-user-id)))
(message "Ement: Marking room as direct...")))
(cl-defun ement--get-joined-members (room session &key then else)
"Get joined members in ROOM on SESSION and call THEN with response data.
Or call ELSE with error data if request fails. Also puts members
on `ement-users', updating their displayname and avatar URL
slots, and puts them on ROOM's `members' table."
(declare (indent defun))
(pcase-let* (((cl-struct ement-room id members) room)
(endpoint (format "rooms/%s/joined_members" (url-hexify-string id))))
(ement-api session endpoint
:else else
:then (lambda (data)
(clrhash members)
(mapc (lambda (member)
(pcase-let* ((`(,id-symbol
. ,(map ('avatar_url avatar-url)
('display_name display-name)))
member)
(member-id (symbol-name id-symbol))
(user (or (gethash member-id ement-users)
(puthash member-id (make-ement-user :id member-id)
ement-users))))
(setf (ement-user-displayname user) display-name
(ement-user-avatar-url user) avatar-url)
(puthash member-id user members)))
(alist-get 'joined data))
(setf (alist-get 'fetched-members-p (ement-room-local room)) t)
(when then
;; Finally, call the given callback.
(funcall then data))))
(message "Ement: Getting joined members in %s..." (ement--format-room room))))
(cl-defun ement--human-format-duration (seconds &optional abbreviate)
"Return human-formatted string describing duration SECONDS.
If SECONDS is less than 1, returns \"0 seconds\". If ABBREVIATE
is non-nil, return a shorter version, without spaces. This is a
simple calculation that does not account for leap years, leap
seconds, etc."
;; Copied from `ts-human-format-duration' (same author).
(if (< seconds 1)
(if abbreviate "0s" "0 seconds")
(cl-macrolet ((format> (place)
;; When PLACE is greater than 0, return formatted string using its symbol name.
`(when (> ,place 0)
(format "%d%s%s" ,place
(if abbreviate "" " ")
(if abbreviate
,(substring (symbol-name place) 0 1)
,(symbol-name place)))))
(join-places (&rest places)
;; Return string joining the names and values of PLACES.
`(string-join (delq nil
(list ,@(cl-loop for place in places
collect `(format> ,place))))
(if abbreviate "" ", "))))
(pcase-let ((`(,years ,days ,hours ,minutes ,seconds) (ement--human-duration seconds)))
(join-places years days hours minutes seconds)))))
(defun ement--human-duration (seconds)
"Return list describing duration SECONDS.
List includes years, days, hours, minutes, and seconds. This is
a simple calculation that does not account for leap years, leap
seconds, etc."
;; Copied from `ts-human-format-duration' (same author).
(cl-macrolet ((dividef (place divisor)
;; Divide PLACE by DIVISOR, set PLACE to the remainder, and return the quotient.
`(prog1 (/ ,place ,divisor)
(setf ,place (% ,place ,divisor)))))
(let* ((seconds (floor seconds))
(years (dividef seconds 31536000))
(days (dividef seconds 86400))
(hours (dividef seconds 3600))
(minutes (dividef seconds 60)))
(list years days hours minutes seconds))))
;;; Footer
(provide 'ement-lib)
;;; ement-lib.el ends here
;;; ement-directory.el --- Public room directory support -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;; This library provides support for viewing and searching public room directories on
;; Matrix homeservers.
;; To make rendering the list flexible and useful, we'll use `taxy-magit-section'.
;;; Code:
;;;; Requirements
(require 'ement)
(require 'ement-room-list)
(require 'taxy)
(require 'taxy-magit-section)
;;;; Variables
(defvar ement-directory-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'ement-directory-RET)
(define-key map [mouse-1] #'ement-directory-mouse-1)
(define-key map (kbd "+") #'ement-directory-next)
map))
(defgroup ement-directory nil
"Options for room directories."
:group 'ement)
;;;; Mode
(define-derived-mode ement-directory-mode magit-section-mode "Ement-Directory"
:global nil)
(defvar-local ement-directory-etc nil
"Alist storing information in `ement-directory' buffers.")
;;;;; Keys
(eval-and-compile
(taxy-define-key-definer ement-directory-define-key
ement-directory-keys "ement-directory-key" "FIXME: Docstring."))
;; TODO: Other keys like guest_can_join, world_readable, etc. (Last-updated time would be
;; nice, but the server doesn't include that in the results.)
(ement-directory-define-key joined-p ()
(pcase-let (((map ('room_id id)) item)
((map session) ement-directory-etc))
(when (cl-find id (ement-session-rooms session)
:key #'ement-room-id :test #'equal)
"Joined")))
(ement-directory-define-key size (&key < >)
(pcase-let (((map ('num_joined_members size)) item))
(cond ((and < (< size <))
(format "< %s members" <))
((and > (> size >))
(format "> %s members" >)))))
(ement-directory-define-key space-p ()
"Groups rooms that are themselves spaces."
(pcase-let (((map ('room_type type)) item))
(when (equal "m.space" type)
"Spaces")))
(defcustom ement-directory-default-keys
'((joined-p)
(space-p)
((size :> 10000))
((size :> 1000))
((size :> 100))
((size :> 10))
((size :< 11)))
"Default keys."
:type 'sexp)
;;;; Columns
(defvar-local ement-directory-room-avatar-cache (make-hash-table)
;; Use a buffer-local variable so that the cache is cleared when the buffer is closed.
"Hash table caching room avatars for the `ement-directory' room list.")
(eval-and-compile
(taxy-magit-section-define-column-definer "ement-directory"))
;; TODO: Fetch avatars (with queueing and async updating/insertion?).
(ement-directory-define-column #("✓" 0 1 (help-echo "Joined")) ()
(pcase-let (((map ('room_id id)) item)
((map session) ement-directory-etc))
(if (cl-find id (ement-session-rooms session)
:key #'ement-room-id :test #'equal)
"✓"
" ")))
(ement-directory-define-column "Name" (:max-width 25)
(pcase-let* (((map name ('room_type type)) item)
(face (pcase type
("m.space" 'ement-room-list-space)
(_ 'ement-room-list-name))))
(propertize (or name "[unnamed]")
'face face)))
(ement-directory-define-column "Alias" (:max-width 25)
(pcase-let (((map ('canonical_alias alias)) item))
(or alias "")))
(ement-directory-define-column "Size" ()
(pcase-let (((map ('num_joined_members size)) item))
(number-to-string size)))
(ement-directory-define-column "Topic" (:max-width 50)
(pcase-let (((map topic) item))
(if topic
(replace-regexp-in-string "\n" " | " topic nil t)
"")))
(ement-directory-define-column "ID" ()
(pcase-let (((map ('room_id id)) item))
id))
(unless ement-directory-columns
;; TODO: Automate this or document it
(setq-default ement-directory-columns
'("Name" "Alias" "Size" "Topic" "ID")))
;;;; Commands
;; TODO: Pagination of results.
;;;###autoload
(cl-defun ement-directory (&key server session since (limit 100))
"View the public room directory on SERVER with SESSION.
Show up to LIMIT rooms. Interactively, with prefix, prompt for
server and LIMIT.
SINCE may be a next-batch token."
(interactive (let* ((session (ement-complete-session :prompt "Search on session: "))
(server (if current-prefix-arg
(read-string "Search on server: " nil nil
(ement-server-name (ement-session-server session)))
(ement-server-name (ement-session-server session))))
(args (list :server server :session session)))
(when current-prefix-arg
(cl-callf plist-put args
:limit (read-number "Limit number of rooms: " 100)))
args))
(pcase-let ((revert-function (lambda (&rest _ignore)
(interactive)
(ement-directory :server server :session session :limit limit)))
(endpoint "publicRooms")
(params (list (list "limit" limit))))
(when since
(cl-callf append params (list (list "since" since))))
(ement-api session endpoint :params params
:then (lambda (results)
(pcase-let (((map ('chunk rooms) ('next_batch next-batch)
('total_room_count_estimate remaining))
results))
(ement-directory--view rooms :append-p since
:buffer-name (format "*Ement Directory: %s*" server)
:root-section-name (format "Ement Directory: %s" server)
:init-fn (lambda ()
(setf (alist-get 'server ement-directory-etc) server
(alist-get 'session ement-directory-etc) session
(alist-get 'next-batch ement-directory-etc) next-batch
(alist-get 'limit ement-directory-etc) limit)
(setq-local revert-buffer-function revert-function)
(when remaining
;; FIXME: The server seems to report all of the rooms on
;; the server as remaining even when searching for a
;; specific term like "emacs".
;; TODO: Display this in a more permanent place (like a
;; header or footer).
(message
(substitute-command-keys
"%s rooms remaining (use \\[ement-directory-next] to fetch more)")
remaining)))))))
(ement-message "Listing %s rooms on %s..." limit server)))
;;;###autoload
(cl-defun ement-directory-search (query &key server session since (limit 1000))
"View public rooms on SERVER matching QUERY.
QUERY is a string used to filter results."
(interactive (let* ((session (ement-complete-session :prompt "Search on session: "))
(server (if current-prefix-arg
(read-string "Search on server: " nil nil
(ement-server-name (ement-session-server session)))
(ement-server-name (ement-session-server session))))
(query (read-string (format "Search for rooms on %s matching: " server)))
(args (list query :server server :session session)))
(when current-prefix-arg
(cl-callf plist-put (cdr args)
:limit (read-number "Limit number of rooms: " 1000)))
args))
;; TODO: Handle "include_all_networks" and "third_party_instance_id". See § 10.5.4.
(pcase-let* ((revert-function (lambda (&rest _ignore)
(interactive)
(ement-directory-search query :server server :session session)))
(endpoint "publicRooms")
(data (rassq-delete-all nil
(ement-alist "limit" limit
"filter" (ement-alist "generic_search_term" query)
"since" since))))
(ement-api session endpoint :method 'post :data (json-encode data)
:then (lambda (results)
(pcase-let (((map ('chunk rooms) ('next_batch next-batch)
('total_room_count_estimate remaining))
results))
(ement-directory--view rooms :append-p since
:buffer-name (format "*Ement Directory: \"%s\" on %s*" query server)
:root-section-name (format "Ement Directory: \"%s\" on %s" query server)
:init-fn (lambda ()
(setf (alist-get 'server ement-directory-etc) server
(alist-get 'session ement-directory-etc) session
(alist-get 'next-batch ement-directory-etc) next-batch
(alist-get 'limit ement-directory-etc) limit
(alist-get 'query ement-directory-etc) query)
(setq-local revert-buffer-function revert-function)
(when remaining
(message
(substitute-command-keys
"%s rooms remaining (use \\[ement-directory-next] to fetch more)")
remaining)))))))
(ement-message "Searching for %S on %s..." query server)))
(defun ement-directory-next ()
"Fetch next batch of results in `ement-directory' buffer."
(interactive)
(pcase-let (((map next-batch query limit server session) ement-directory-etc))
(unless next-batch
(user-error "No more results"))
(if query
(ement-directory-search query :server server :session session :limit limit :since next-batch)
(ement-directory :server server :session session :limit limit :since next-batch))))
(defun ement-directory-mouse-1 (event)
"Call `ement-directory-RET' at EVENT."
(interactive "e")
(mouse-set-point event)
(call-interactively #'ement-directory-RET))
(defun ement-directory-RET ()
"View or join room at point, or cycle section at point."
(interactive)
(cl-etypecase (oref (magit-current-section) value)
(null nil)
(list (pcase-let* (((map ('name name) ('room_id room-id)) (oref (magit-current-section) value))
((map session) ement-directory-etc)
(room (cl-find room-id (ement-session-rooms session)
:key #'ement-room-id :test #'equal)))
(if room
(ement-view-room room session)
;; Room not joined: prompt to join. (Don't use the alias in the prompt,
;; because multiple rooms might have the same alias, e.g. when one is
;; upgraded or tombstoned.)
(when (yes-or-no-p (format "Join room \"%s\" <%s>? " name room-id))
(ement-join-room room-id session)))))
(taxy-magit-section (call-interactively #'magit-section-cycle))))
;;;; Functions
(cl-defun ement-directory--view (rooms &key init-fn append-p
(buffer-name "*Ement Directory*")
(root-section-name "Ement Directory")
(keys ement-directory-default-keys)
(display-buffer-action '(display-buffer-same-window)))
"View ROOMS in an `ement-directory-mode' buffer.
ROOMS should be a list of rooms from an API request. Calls
INIT-FN immediately after activating major mode. Sets
BUFFER-NAME and ROOT-SECTION-NAME, and uses
DISPLAY-BUFFER-ACTION. KEYS are a list of `taxy' keys. If
APPEND-P, add ROOMS to buffer rather than replacing existing
contents. To be called by `ement-directory-search'."
(declare (indent defun))
(let (column-sizes window-start)
(cl-labels ((format-item (item)
;; NOTE: We use the buffer-local variable `ement-directory-etc' rather
;; than a closure variable because the taxy-magit-section struct's format
;; table is not stored in it, and we can't reuse closures' variables.
;; (It would be good to store the format table in the taxy-magit-section
;; in the future, to make this cleaner.)
(gethash item (alist-get 'format-table ement-directory-etc)))
;; NOTE: Since these functions take an "item" (which is a [room session]
;; vector), they're prefixed "item-" rather than "room-".
(size (item)
(pcase-let (((map ('num_joined_members size)) item))
size))
(t<nil (a b) (and a (not b)))
(t>nil (a b) (and (not a) b))
(make-fn (&rest args)
(apply #'make-taxy-magit-section
:make #'make-fn
:format-fn #'format-item
;; FIXME: Should we reuse `ement-room-list-level-indent' here?
:level-indent ement-room-list-level-indent
;; :visibility-fn #'visible-p
;; :heading-indent 2
:item-indent 2
;; :heading-face-fn #'heading-face
args)))
(with-current-buffer (get-buffer-create buffer-name)
(unless (eq 'ement-directory-mode major-mode)
;; Don't obliterate buffer-local variables.
(ement-directory-mode))
(when init-fn
(funcall init-fn))
(pcase-let* ((taxy (if append-p
(alist-get 'taxy ement-directory-etc)
(make-fn
:name root-section-name
:take (taxy-make-take-function keys ement-directory-keys))))
(taxy-magit-section-insert-indent-items nil)
(inhibit-read-only t)
(pos (point))
(section-ident (when (magit-current-section)
(magit-section-ident (magit-current-section))))
(format-cons))
(setf taxy (thread-last taxy
(taxy-fill (cl-coerce rooms 'list))
(taxy-sort #'> #'size)
(taxy-sort* #'string> #'taxy-name))
(alist-get 'taxy ement-directory-etc) taxy
format-cons (taxy-magit-section-format-items
ement-directory-columns ement-directory-column-formatters taxy)
(alist-get 'format-table ement-directory-etc) (car format-cons)
column-sizes (cdr format-cons)
header-line-format (taxy-magit-section-format-header
column-sizes ement-directory-column-formatters)
window-start (if (get-buffer-window buffer-name)
(window-start (get-buffer-window buffer-name))
0))
(delete-all-overlays)
(erase-buffer)
(save-excursion
(taxy-magit-section-insert taxy :items 'first
;; :blank-between-depth bufler-taxy-blank-between-depth
:initial-depth 0))
(goto-char pos)
(when (and section-ident (magit-get-section section-ident))
(goto-char (oref (magit-get-section section-ident) start)))))
(display-buffer buffer-name display-buffer-action)
(when (get-buffer-window buffer-name)
(set-window-start (get-buffer-window buffer-name) window-start))
;; NOTE: In order for `bookmark--jump-via' to work properly, the restored buffer
;; must be set as the current buffer, so we have to do this explicitly here.
(set-buffer buffer-name))))
;;;; Spaces
;; Viewing spaces and the rooms in them.
;;;###autoload
(defun ement-view-space (space session)
;; TODO: Use this for spaces instead of `ement-view-room' (or something like that).
;; TODO: Display space's topic in the header or something.
"View child rooms in SPACE on SESSION.
SPACE may be a room ID or an `ement-room' struct."
;; TODO: "from" query parameter.
(interactive (ement-complete-room :predicate #'ement--space-p
:prompt "Space: "))
(pcase-let* ((id (cl-typecase space
(string space)
(ement-room (ement-room-id space))))
(endpoint (format "rooms/%s/hierarchy" id))
(revert-function (lambda (&rest _ignore)
(interactive)
(ement-view-space space session))))
(ement-api session endpoint :version "v1"
:then (lambda (results)
(pcase-let (((map rooms ('next_batch next-batch))
results))
(ement-directory--view rooms ;; :append-p since
;; TODO: Use space's alias where possible.
:buffer-name (format "*Ement Directory: space %s" (ement--format-room space session))
:root-section-name (format "*Ement Directory: rooms in %s %s"
(propertize "space"
'face 'font-lock-type-face)
(ement--format-room space session))
:init-fn (lambda ()
(setf (alist-get 'session ement-directory-etc) session
(alist-get 'next-batch ement-directory-etc) next-batch
;; (alist-get 'limit ement-directory-etc) limit
(alist-get 'space ement-directory-etc) space)
(setq-local revert-buffer-function revert-function)
;; TODO: Handle next batches.
;; (when remaining
;; (message
;; (substitute-command-keys
;; "%s rooms remaining (use \\[ement-directory-next] to fetch more)")
;; remaining))
)))))))
;;;; Footer
(provide 'ement-directory)
;;; ement-directory.el ends here
;;; ement-autoloads.el --- automatically extracted autoloads (do not edit) -*- lexical-binding: t -*-
;; Generated by the `loaddefs-generate' function.
;; This file is part of GNU Emacs.
;;; Code:
(add-to-list 'load-path (or (and load-file-name (directory-file-name (file-name-directory load-file-name))) (car load-path)))
;;; Generated autoloads from ement.el
(autoload 'ement-connect "ement" "\
Connect to Matrix with USER-ID and PASSWORD, or using SESSION.
Interactively, with prefix, ignore a saved session and log in
again; otherwise, use a saved session if `ement-save-sessions' is
enabled and a saved session is available, or prompt to log in if
not enabled or available.
If USERID or PASSWORD are not specified, the user will be
prompted for them.
If URI-PREFIX is specified, it should be the prefix of the
server's API URI, including protocol, hostname, and optionally
the port, e.g.
\"https://matrix-client.matrix.org\"
\"http://localhost:8080\"
(fn &key USER-ID PASSWORD URI-PREFIX SESSION)" t)
(register-definition-prefixes "ement" '("ement-"))
;;; Generated autoloads from ement-api.el
(register-definition-prefixes "ement-api" '("ement-api-error"))
;;; Generated autoloads from ement-directory.el
(autoload 'ement-directory "ement-directory" "\
View the public room directory on SERVER with SESSION.
Show up to LIMIT rooms. Interactively, with prefix, prompt for
server and LIMIT.
SINCE may be a next-batch token.
(fn &key SERVER SESSION SINCE (LIMIT 100))" t)
(autoload 'ement-directory-search "ement-directory" "\
View public rooms on SERVER matching QUERY.
QUERY is a string used to filter results.
(fn QUERY &key SERVER SESSION SINCE (LIMIT 1000))" t)
(autoload 'ement-view-space "ement-directory" "\
View child rooms in SPACE on SESSION.
SPACE may be a room ID or an `ement-room' struct.
(fn SPACE SESSION)" t)
(register-definition-prefixes "ement-directory" '("ement-directory-"))
;;; Generated autoloads from ement-lib.el
(register-definition-prefixes "ement-lib" '("ement-"))
;;; Generated autoloads from ement-macros.el
(register-definition-prefixes "ement-macros" '("ement-"))
;;; Generated autoloads from ement-notifications.el
(autoload 'ement-notifications "ement-notifications" "\
Show the notifications buffer for SESSION.
FROM may be a \"next_token\" token from a previous request.
LIMIT may be a maximum number of events to return. ONLY may be
the string \"highlight\" to only return notifications that have
the highlight tweak set. THEN and ELSE may be callbacks passed
to `ement-api', which see.
(fn SESSION &key FROM LIMIT ONLY (THEN (apply-partially #\\='ement-notifications-callback session)) ELSE)" t)
(register-definition-prefixes "ement-notifications" '("ement-notifications-"))
;;; Generated autoloads from ement-notify.el
(register-definition-prefixes "ement-notify" '("ement-notify"))
;;; Generated autoloads from ement-room.el
(register-definition-prefixes "ement-room" '("ement-"))
;;; Generated autoloads from ement-room-list.el
(autoload 'ement-room-list--after-initial-sync "ement-room-list" "\
Call `ement-room-list', ignoring arguments.
To be called from `ement-after-initial-sync-hook'.
(fn &rest IGNORE)")
(defalias 'ement-list-rooms 'ement-room-list)
(autoload 'ement-room-list "ement-room-list" "\
Show a buffer listing Ement rooms, grouped with Taxy KEYS.
After showing it, its window is selected. The buffer is named
BUFFER-NAME and is shown with DISPLAY-BUFFER-ACTION; or if
DISPLAY-BUFFER-ACTION is nil, the buffer is not displayed.
(fn &key (BUFFER-NAME \"*Ement Room List*\") (KEYS ement-room-list-default-keys) (DISPLAY-BUFFER-ACTION \\='((display-buffer-reuse-window display-buffer-same-window))))" t)
(autoload 'ement-room-list-auto-update "ement-room-list" "\
Automatically update the Taxy room list buffer.
+Does so when variable `ement-room-list-auto-update' is non-nil.
+To be called in `ement-sync-callback-hook'.
(fn SESSION)")
(register-definition-prefixes "ement-room-list" '("ement-room-list-"))
;;; Generated autoloads from ement-tabulated-room-list.el
(autoload 'ement-tabulated-room-list "ement-tabulated-room-list" "\
Show buffer listing joined rooms.
Calls `pop-to-buffer-same-window'. Interactively, with prefix,
call `pop-to-buffer'.
(fn &rest IGNORE)" t)
(autoload 'ement-tabulated-room-list-auto-update "ement-tabulated-room-list" "\
Automatically update the room list buffer.
Does so when variable `ement-tabulated-room-list-auto-update' is non-nil.
To be called in `ement-sync-callback-hook'.
(fn SESSION)")
(register-definition-prefixes "ement-tabulated-room-list" '("ement-tabulated-room-list-"))
;;; End of scraped data
(provide 'ement-autoloads)
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; no-native-compile: t
;; coding: utf-8-emacs-unix
;; End:
;;; ement-autoloads.el ends here
;;; ement-api.el --- Matrix API library -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; 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:
;;
;;; Code:
;;;; Debugging
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
;;;; Requirements
(require 'json)
(require 'url-parse)
(require 'url-util)
(require 'plz)
(require 'ement-macros)
(require 'ement-structs)
;;;; Variables
;;;; Customization
;;;; Commands
;;;; Functions
(cl-defun ement-api (session endpoint
&key then data params queue
(content-type "application/json")
(data-type 'text)
(else #'ement-api-error) (method 'get)
;; FIXME: What's the right term for the URL part after "/_matrix/"?
(endpoint-category "client")
(json-read-fn #'json-read)
;; NOTE: Hard to say what the default timeouts
;; should be. Sometimes the matrix.org homeserver
;; can get slow and respond a minute or two later.
(connect-timeout 10) (timeout 60)
(version "r0"))
"Make API request on SESSION to ENDPOINT.
The request automatically uses SESSION's server, URI prefix, and
access token.
These keyword arguments are passed to `plz', which see: THEN,
DATA (passed as BODY), QUEUE (passed to `plz-queue', which see),
DATA-TYPE (passed as BODY-TYPE), ELSE, METHOD,
JSON-READ-FN (passed as AS), CONNECT-TIMEOUT, TIMEOUT.
Other arguments include PARAMS (used as the URL's query
parameters), ENDPOINT-CATEGORY (added to the endpoint URL), and
VERSION (added to the endpoint URL).
Note that most Matrix requests expect JSON-encoded data, so
usually the DATA argument should be passed through
`json-encode'."
(declare (indent defun))
(pcase-let* (((cl-struct ement-session server token) session)
((cl-struct ement-server uri-prefix) server)
((cl-struct url type host portspec) (url-generic-parse-url uri-prefix))
(path (format "/_matrix/%s/%s/%s" endpoint-category version endpoint))
(query (url-build-query-string params))
(filename (concat path "?" query))
(url (url-recreate-url
(url-parse-make-urlobj type nil nil host portspec filename nil data t)))
(headers (ement-alist "Content-Type" content-type))
(plz-args))
(when token
;; Almost every request will require a token (only a few, like checking login flows, don't),
;; so we simplify the API by using the token automatically when the session has one.
(push (cons "Authorization" (concat "Bearer " token)) headers))
(setf plz-args (list method url :headers headers :body data :body-type data-type
:as json-read-fn :then then :else else
:connect-timeout connect-timeout :timeout timeout :noquery t))
;; Omit `then' from debugging because if it's a partially applied
;; function on the session object, which may be very large, it
;; will take a very long time to print into the warnings buffer.
;; (ement-debug (current-time) method url headers)
(if queue
(plz-run
(apply #'plz-queue queue plz-args))
(apply #'plz plz-args))))
(define-error 'ement-api-error "Ement API error" 'error)
(defun ement-api-error (plz-error)
"Signal an Ement API error for PLZ-ERROR."
;; This feels a little messy, but it seems to be reasonable.
(pcase-let* (((cl-struct plz-error response
(message plz-message) (curl-error `(,curl-exit-code . ,curl-message)))
plz-error)
(status (when (plz-response-p response)
(plz-response-status response)))
(body (when (plz-response-p response)
(plz-response-body response)))
(json-object (when body
(ignore-errors
(json-read-from-string body))))
(error-message (format "%S: %s"
(or curl-exit-code status)
(or (when json-object
(alist-get 'error json-object))
curl-message
plz-message))))
(signal 'ement-api-error (list error-message))))
;;;; Footer
(provide 'ement-api)
;;; ement-api.el ends here
This is the file .../info/dir, which contains the
topmost node of the Info hierarchy, called (dir)Top.
The first time you invoke Info you start off looking at this node.
File: dir, Node: Top This is the top of the INFO tree
This (the Directory node) gives a menu of major topics.
Typing "q" exits, "H" lists all Info commands, "d" returns here,
"h" gives a primer for first-timers,
"mEmacs<Return>" visits the Emacs manual, etc.
In Emacs, you can click mouse button 2 on a menu item or cross reference
to select it.
* Menu:
Emacs
* Ement: (ement). Matrix client for Emacs.
#+TITLE: Ement.el
#+PROPERTY: LOGGING nil
# Export options.
#+OPTIONS: broken-links:t *:t num:1 toc:1
# Info export options.
#+EXPORT_FILE_NAME: ement.texi
#+TEXINFO_DIR_CATEGORY: Emacs
#+TEXINFO_DIR_TITLE: Ement: (ement)
#+TEXINFO_DIR_DESC: Matrix client for Emacs
# Note: This readme works with the org-make-toc <https://github.com/alphapapa/org-make-toc> package, which automatically updates the table of contents.
#+HTML: <img src="images/logo-128px.png" align="right">
# ELPA badge image.
[[https://elpa.gnu.org/packages/ement.html][https://elpa.gnu.org/packages/ement.svg]]
Ement.el is a Matrix client for Emacs. It aims to be simple, fast, featureful, and reliable.
Feel free to join us in the chat room: [[https://matrix.to/#/#ement.el:matrix.org][https://img.shields.io/matrix/ement.el:matrix.org.svg?label=%23ement.el:matrix.org]]
* Contents :noexport:
:PROPERTIES:
:TOC: :include siblings
:END:
:CONTENTS:
- [[#installation][Installation]]
- [[#usage][Usage]]
- [[#bindings][Bindings]]
- [[#tips][Tips]]
- [[#encrypted-room-support-through-pantalaimon][Encrypted room support through Pantalaimon]]
- [[#changelog][Changelog]]
- [[#development][Development]]
:END:
* Screenshots :noexport:
:PROPERTIES:
:ID: d818f690-5f22-4eb0-83e1-4d8ce16c9e5b
:END:
The default formatting style resembles IRC clients, with each message being prefixed by the username (which enables powerful Emacs features, like using Occur to show all messages from or mentioning a user). Alternative, built-in styles include an Element-like one with usernames above groups of messages, as well as a classic, no-margins IRC style. Messages may be optionally displayed with unique colors for each user (with customizeable contrast), making it easier to follow conversations. Timestamp headers are optionally displayed where a certain amount of time passes between events, as well as where the date changes.
[[images/ement-for-twim.png]]
/Two rooms shown in side-by-side buffers, showing inline images, reactions, date/time headings, room avatars, and messages colored by user (using the modus-vivendi Emacs theme)./
[[images/emacs-with-fully-read-line.png]]
/#emacs:libera.chat showing colored text from IRC users, replies with quoted parts, messages colored by user, addressed usernames colored by their user color, highlighted mentions, and the fully-read marker line (using the modus-vivendi Emacs theme)./
[[images/screenshot5.png]]
/Four rooms shown at once, with messages colored by user, in the default Emacs theme./
[[images/screenshot2.png]]
/A room at the top in the "Elemental" display style, with sender names displayed over groups of messages, and only self-messages in an alternate color. The lower window shows an earlier version of the rooms list./
[[images/reactions.png]]
/Reactions displayed as color emojis (may need [[#displaying-symbols-and-emojis][proper Emacs configuration]])./
* Installation
:PROPERTIES:
:TOC: :depth 0
:END:
** GNU ELPA
Ement.el is published in [[http://elpa.gnu.org/][GNU ELPA]] as [[https://elpa.gnu.org/packages/ement.html][ement]], so it may be installed in Emacs with the command ~M-x package-install RET ement RET~. This is the recommended way to install Ement.el, as it will install the current stable release.
The latest development build may be installed from [[https://elpa.gnu.org/devel/ement.html][ELPA-devel]] or from Git (see below).
** GNU Guix
Ement.el is available in [[https://guix.gnu.org/][GNU Guix]] as [[https://packages.guix.gnu.org/packages/emacs-ement/][emacs-ement]].
** Debian, Ubuntu
Ement.el is available in [[https://packages.debian.org/elpa-ement][Debian as elpa-ement]] and in [[https://packages.ubuntu.com/search?suite=default§ion=all&arch=any&keywords=elpa-ement&searchon=names][Ubuntu as elpa-ement]].
** Nix
Ement.el is available in [[https://nixos.org/][NixOS]] as [[https://search.nixos.org/packages?channel=23.05&show=emacsPackages.ement&from=0&size=50&sort=relevance&type=packages&query=ement][emacsPackages.ement]].
** Other distributions
Ement.el is also available in some other distributions. See [[https://repology.org/project/emacs:ement/related][Repology]] for details.
** Git master
The ~master~ branch of the Git repository is intended to be usable at all times; only minor bugs are expected to be found in it before a new stable release is made.
To install, it is recommended to use [[https://github.com/quelpa/quelpa-use-package][quelpa-use-package]], like this (using [[https://github.com/alphapapa/unpackaged.el#upgrade-a-quelpa-use-package-forms-package][this helpful command]] for upgrading versions):
#+BEGIN_SRC elisp
;; Install and load `quelpa-use-package'.
(package-install 'quelpa-use-package)
(require 'quelpa-use-package)
;; Install Ement.
(use-package ement
:quelpa (ement :fetcher github :repo "alphapapa/ement.el"))
#+END_SRC
One might also use systems like [[https://github.com/progfolio/elpaca][Elpaca]] or [[https://github.com/radian-software/straight.el][Straight]] (which is also used by [[https://github.com/doomemacs/doomemacs][DOOM]]), but the author cannot offer support for them.
** Manual
Ement.el is intended to be installed with Emacs's package system, which will ensure that the required autoloads are generated, etc. If you choose to install it manually, you're on your own.
* Usage
:PROPERTIES:
:TOC: :include descendants :depth 1
:END:
:CONTENTS:
- [[#bindings][Bindings]]
- [[#tips][Tips]]
- [[#encrypted-room-support-through-pantalaimon][Encrypted room support through Pantalaimon]]
:END:
1. Call command ~ement-connect~ to connect. Multiple sessions are supported, so you may call the command again to connect to another account.
2. Wait for initial sync to complete (which can take a few moments--initial sync JSON requests can be large).
3. Use these commands (room-related commands may be called with universal prefix to prompt for the room):
- ~ement-list-rooms~ to view the list of joined rooms.
- ~ement-view-room~ to view a room's buffer, selected with completion.
- ~ement-create-room~ to create a new room.
- ~ement-create-space~ to create a space.
- ~ement-invite-user~ to invite a user to a room.
- ~ement-join-room~ to join a room.
- ~ement-leave-room~ to leave a room.
- ~ement-forget-room~ to forget a room.
- ~ement-tag-room~ to toggle a tag on a room (including favorite/low-priority status).
- ~ement-list-members~ to list members in a room.
- ~ement-send-direct-message~ to send a direct message to a user (in an existing direct room, or creating a new one automatically).
- ~ement-room-edit-message~ to edit a message at point.
- ~ement-room-send-file~ to send a file.
- ~ement-room-send-image~ to send an image.
- ~ement-room-set-topic~ to set a room's topic.
- ~ement-room-occur~ to search in a room's known events.
- ~ement-room-override-name~ to override a room's display name.
- ~ement-ignore-user~ to ignore a user (or with interactive prefix, un-ignore).
- ~ement-room-set-message-format~ to set a room's message format buffer-locally.
- ~ement-room-toggle-space~ to toggle a room's membership in a space (a way to group rooms in Matrix).
- ~ement-directory~ to view a room directory.
- ~ement-directory-search~ to search a room directory.
4. Use these special buffers to see events from multiple rooms (you can also reply to messages from these buffers!):
- See all new events that mention you in the =*Ement Mentions*= buffer.
- See all new events in rooms that have open buffers in the =*Ement Notifications*= buffer.
** Bindings
These bindings are common to all of the following buffer types:
+ Switch to a room buffer: ~M-g M-r~
+ Switch to the room list buffer: ~M-g M-l~
+ Switch to the mentions buffer: ~M-g M-m~
+ Switch to the notifications buffer: ~M-g M-n~
*** Room buffers
+ Show command menu: ~?~
[[images/transient.png]]
*Movement*
+ Next event: ~n~
+ Previous event: ~p~
+ Scroll up and mark read: ~SPC~
+ Scroll down: ~S-SPC~
+ Jump to fully-read marker: ~M-g M-p~
+ Move read markers to point: ~m~
+ Load older messages: at top of buffer, scroll contents up (i.e. ~S-SPC~, ~M-v~ or ~mwheel-scroll~)
*Switching*
+ List rooms: ~M-g M-l~
+ Switch to other room: ~M-g M-r~
+ Switch to mentions buffer: ~M-g M-m~
+ Switch to notifications buffer: ~M-g M-n~
+ Quit window: ~q~
*Messages*
+ Write message: ~RET~
+ Write reply to event at point (when region is active, only quote marked text) : ~S-RET~
+ Compose message in buffer: ~M-RET~ (while writing in minibuffer: ~C-c ')~ (Use command ~ement-room-compose-org~ to activate Org mode in the compose buffer.)
+ Edit message: ~<insert>~
+ Delete message: ~C-k~
+ Send reaction to event at point, or send same reaction at point: ~s r~
+ Send emote: ~s e~
+ Send file: ~s f~
+ Send image: ~s i~
+ View event source: ~v~
+ Complete members and rooms at point: ~C-M-i~ (standard ~completion-at-point~ command). (Type an ~@~ prefix for a member mention, a ~#~ prefix for a room alias, or a ~!~ prefix for a room ID.)
*Images*
+ Toggle scale of image (between fit-to-window and thumbnail): ~mouse-1~
+ Show image in new buffer at full size: ~double-mouse-1~
*Users*
+ Send direct message: ~u RET~
+ Invite user: ~u i~
+ Ignore user: ~u I~
*Room*
+ Occur search in room: ~M-s o~
+ List members: ~r m~
+ Set topic: ~r t~
+ Set message format: ~r f~
+ Set notification rules: ~r n~
+ Override display name: ~r N~
+ Tag/untag room: ~r T~
*Room membership*
+ Create room: ~R c~
+ Join room: ~R j~
+ Leave room: ~R l~
+ Forget room: ~R F~
+ Toggle room's spaces: ~R s~
*Other*
+ Sync new messages (not necessary if auto sync is enabled; with prefix to force new sync): ~g~
*** Room list buffer
+ Show buffer of room at point: ~RET~
+ Show buffer of next unread room: ~SPC~
+ Move between room names: ~TAB~ / ~<backtab>~
+ Kill room's buffer: ~k~
+ Toggle room's membership in a space: ~s~
*** Directory buffers
+ View/join a room: ~RET~ / ~mouse-1~
+ Load next batch of rooms: ~+~
*** Mentions/notifications buffers
+ Move between events: ~TAB~ / ~<backtab>~
+ Go to event at point in its room buffer: ~RET~
+ Write reply to event at point (shows the event in its room while writing) : ~S-RET~
** Tips
# TODO: Show sending messages in Org format.
+ Desktop notifications are enabled by default for events that mention the local user. They can also be shown for all events in rooms with open buffers.
+ Send messages in Org mode format by customizing the option ~ement-room-send-message-filter~ (which enables Org format by default), or by calling ~ement-room-compose-org~ in a compose buffer (which enables it for a single message). Then Org-formatted messages are automatically converted and sent as HTML-formatted messages (with the Org syntax as the plain-text fallback). You can send syntax such as:
- Bold, italic, underline, strikethrough
- Links
- Tables
- Source blocks (including results with ~:exports both~)
- Footnotes (okay, that might be pushing it, but you can!)
- And, generally, anything that Org can export to HTML
+ Starting in the room list buffer, by pressing ~SPC~ repeatedly, you can cycle through and read all rooms with unread buffers. (If a room doesn't have a buffer, it will not be included.)
+ Room buffers and the room-list buffer can be bookmarked in Emacs, i.e. using =C-x r m=. This is especially useful with [[https://github.com/alphapapa/burly.el][Burly]]: you can arrange an Emacs frame with several room buffers displayed at once, use =burly-bookmark-windows= to bookmark the layout, and then you can restore that layout and all of the room buffers by opening the bookmark, rather than having to manually arrange them every time you start Emacs or change the window configuration.
+ Images and other files can be uploaded to rooms using drag-and-drop.
+ Mention members by typing a ~@~ followed by their displayname or Matrix ID. (Members' names and rooms' aliases/IDs may be completed with ~completion-at-point~ commands.)
+ You can customize settings in the ~ement~ group.
- *Note:* ~setq~ should not be used for certain options, because it will not call the associated setter function. Users who have an aversion to the customization system may experience problems.
*** Displaying symbols and emojis
Emacs may not display certain symbols and emojis well by default. Based on [[https://emacs.stackexchange.com/questions/62049/override-the-default-font-for-emoji-characters][this question and answer]], you may find that the simplest way to fix this is to install an appropriate font, like [[https://www.google.com/get/noto/#emoji-zsye][Noto Emoji]], and then use this Elisp code:
#+BEGIN_SRC elisp
(setf use-default-font-for-symbols nil)
(set-fontset-font t 'unicode "Noto Emoji" nil 'append)
#+END_SRC
** Encrypted room support through Pantalaimon
Ement.el doesn't support encrypted rooms natively, but it can be used transparently with the E2EE-aware reverse proxy daemon [[https://github.com/matrix-org/pantalaimon/][Pantalaimon]]. After configuring it according to its documentation, call ~ement-connect~ with the appropriate hostname and port, like:
#+BEGIN_SRC elisp
(ement-connect :uri-prefix "http://localhost:8009")
#+END_SRC
* Changelog
:PROPERTIES:
:TOC: :depth 0
:END:
** 0.12
*Additions*
+ Command ~ement-notifications~ shows recent notifications, similar to the pane in the Element client. (This new command fetches recent notifications from the server and allows scrolling up to retrieve older ones. Newly received notifications, as configured in the ~ement-notify~ options, are displayed in the same buffer. This functionality will be consolidated in the future.)
+ Face ~ement-room-quote~, applied to quoted parts of replies.
*Changes*
+ Commands ~ement-room-goto-next~ and ~ement-room-goto-prev~ work more usefully at the end of a room buffer. (Now pressing ~n~ on the last event moves point to the end of the buffer so it will scroll automatically for new messages, and then pressing ~p~ skips over any read marker to the last event.)
+ Room buffer bindings:
+ ~ement-room-goto-next~ and ~ement-room-goto-prev~ are bound to ~n~ and ~p~, respectively.
+ ~ement-room-goto-fully-read-marker~ is bound to ~M-g M-p~ (the mnemonic being "go to previously read").
+ The quoted part of a reply now omits the face applied to the rest of the message, helping to distinguish them.
+ Commands that read a string from the minibuffer in ~ement-room~ buffers and ~ement-connect~ user ID prompts use separate history list variables.
+ Use Emacs's Jansson-based JSON-parsing functions when available. (This results in a 3-5x speed improvement for parsing JSON responses, which can be significant for large initial sync responses. Thanks to [[https://github.com/rrix/][Ryan Rix]] for discovering this!)
*Fixes*
+ File event formatter assumed that file size metadata would be present (a malformed, e.g. spam, event might not have it).
+ Send correct file size when sending files/images.
+ Underscores are no longer interpreted as denoting subscripts when sending messages in Org format. (Thanks to [[https://github.com/phil-s][Phil Sainty]].)
+ Add workaround for ~savehist-mode~'s serializing of the ~command-history~ variable's arguments. (For ~ement-~ commands, that may include large data structures, like ~ement-session~ structs, which should never be serialized or reused, and ~savehist~'s doing so could cause noticeable delays for users who enabled it). (See [[https://github.com/alphapapa/ement.el/issues/216][#216]]. Thanks to [[https://github.com/phil-s][Phil Sainty]] and other users who helped to discover this problem.)
** 0.11
*Additions*
+ Commands ~ement-room-image-show~ and ~ement-room-image-scale~ (bound to ~RET~ and ~M-RET~ when point is at an image) view and scale images. (Thanks to [[https://github.com/Stebalien][Steven Allen]] for these and other image-related improvements.)
+ Command ~ement-room-image-show-mouse~ is used to show an image with the mouse.
*Changes*
+ Enable ~image-mode~ when showing images in a new buffer. (Thanks to [[https://github.com/Stebalien][Steven Allen]].)
+ Command ~ement-room-image-show~ is not used for mouse events.
+ Show useful message in SSO login page.
*Fixes*
+ Allow editing of already-edited events.
+ Push rules' actions may be listed in any order. (Fixes compatibility with [[https://spec.matrix.org/v1.7/client-server-api/#actions][v1.7 of the spec]]. Thanks to [[https://github.com/Stebalien][Steven Allen]].)
+ Call external browser for SSO login page. (JavaScript is usually required, which EWW doesn't support, and loading the page twice seems to change state on the server that causes the SSO login to fail, so it's best to load the page in the external browser directly).
+ Clean up SSO server process after two minutes in case SSO login fails.
+ Don't stop syncing if an error is signaled while sending a notification.
+ Command ~ement-room-list-next-unread~ could enter an infinite loop. (Thanks to [[https://github.com/vizs][Visuwesh]] and ~@mrtnmrtn:matrix.org~.)
+ Events in notifications buffer could appear out-of-order. ([[https://github.com/alphapapa/ement.el/issues/191][#191]]. Thanks to [[https://github.com/phil-s][Phil Sainty]].)
*Internal*
+ The ~ement-read-receipt-idle-timer~ could be duplicated when using multiple sessions. ([[https://github.com/alphapapa/ement.el/issues/196][#196]]. Thanks to [[https://github.com/phil-s][Phil Sainty]].)
** 0.10
*Security Fixes*
+ When uploading a GPG-encrypted file (i.e. one whose filename ends in ~.gpg~), if the recipient's private key or the symmetric encryption key were cached by Emacs (or a configured agent, like ~gpg-agent~), Emacs would automatically decrypt the file while reading its contents and then upload the decrypted contents. (This happened because the function ~insert-file-contents~ was used, which does many things automatically, some of which are not even mentioned in its docstring; refer to its entry in the Elisp Info manual for details. The fix is to use ~insert-file-contents-literally~ instead.) Thanks to ~@welkinsl:matrix.org~ for reporting.
*Additions*
+ Support for Single Sign-On (SSO) authentication. ([[https://github.com/alphapapa/ement.el/issues/24][#24]]. Thanks to [[https://github.com/Necronian][Jeffrey Stoffers]] for development, and to [[https://github.com/phil-s][Phil Sainty]], [[https://github.com/FrostyX][Jakub Kadlčík]], and [[https://github.com/oneingan][Juanjo Presa]] for testing.)
+ Bind ~m~ in room buffers to ~ement-room-mark-read~ (which moves read markers to point).
*Changes*
+ Activating a space in the room list uses ~ement-view-space~ (which shows a directory of rooms in the space) instead of ~ement-view-room~ (which shows events in the space, which is generally not useful).
+ Command ~ement-view-room~, when used for a space, shows a footer explaining that the buffer is showing a space rather than a normal room, with a button to call ~ement-view-space~ for it (which lists rooms in the space).
+ Command ~ement-describe-room~ shows whether a room is a space or a normal room.
+ Command ~ement-view-space~ shows the space's name and alias.
+ Command ~ement-room-scroll-up-mark-read~ moves the fully read marker to the top of the window (when the marker's position is within the range of known events), rather than only moving it when at the end of the buffer. (This eases the process of gradually reading a long backlog of messages.)
+ Improve readme export settings.
*Fixes*
+ Extra indentation of some membership events. (Thanks to [[https://github.com/Stebalien][Steven Allen]].)
+ Customization group for faces.
+ Don't reinitialize ~ement-room-list-mode~ when room list buffer is refreshed. ([[https://github.com/alphapapa/ement.el/issues/146][#146]]. Thanks to [[https://github.com/treed][Ted Reed]] for reporting.)
+ Don't fetch old events when scrolling to the bottom of a room buffer (only when scrolling to the top). (Thanks to [[https://github.com/Stebalien][Steven Allen]].)
+ Minor improvements to auto-detection of homeserver URIs. (See [[https://github.com/alphapapa/ement.el/issues/24#issuecomment-1569518713][#24]]. Thanks to [[https://github.com/phil-s][Phil Sainty]].)
+ Uploading of certain filetypes (e.g. Emacs would decompress some archives before uploading). Thanks to ~@welkinsl:matrix.org~ for reporting.
+ Messages edited multiple times sometimes weren't correctly replaced.
** 0.9.3
*Fixes*
+ Another attempt at restoring position in room list when refreshing.
+ Command ~ement-room-list-next-unread~.
** 0.9.2
*Fixes*
+ Restore position in room list when refreshing.
+ Completion in minibuffer.
** 0.9.1
*Fixes*
+ Error in ~ement-room-list~ command upon initial sync.
** 0.9
*Additions*
+ Option ~ement-room-timestamp-header-align~ controls how timestamp headers are aligned in room buffers.
+ Option ~ement-room-view-hook~ runs functions when ~ement-room-view~ is called. (By default, it refreshes the room list buffer.)
+ In the room list, middle-clicking a room which has a buffer closes its buffer.
+ Basic support for video events. (Thanks to [[https://github.com/viiru-][Arto Jantunen]].)
*Changes*
+ Using new option ~ement-room-timestamp-header-align~, timestamp headers default to right-aligned. (With default settings, this keeps them near message timestamps and makes for a cleaner appearance.)
*Fixes*
+ Recognition of certain MXID or displayname forms in outgoing messages when linkifying (aka "pilling") them.
+ Unreadable room avatar images no longer cause errors. (Fixes [[https://github.com/alphapapa/ement.el/issues/147][#147]]. Thanks to [[https://github.com/jgarte][@jgarte]] for reporting.)
+ Don't error in ~ement-room-list~ when no rooms are joined. (Fixes [[https://github.com/alphapapa/ement.el/issues/123][#123]]. Thanks to [[https://github.com/Kabouik][@Kabouik]] and [[https://github.com/oantolin][Omar Antolín Camarena]] for reporting.)
+ Enable member/room completion in compose buffers. (Fixes [[https://github.com/alphapapa/ement.el/issues/115][#115]]. Thanks to Thanks to [[https://github.com/piater][Justus Piater]] and [[https://github.com/chasecaleb][Caleb Chase]] for reporting.)
** 0.8.3
*Fixes*
+ Avoid use of ~pcase~'s ~(map :KEYWORD)~ form. (This can cause a broken installation on older versions of Emacs that have an older version of the ~map~ library loaded, such as Emacs 27.2 included in Debian 11. Since there's no way to force Emacs to actually load the version of ~map~ required by this package before installing it (which would naturally happen upon restarting Emacs), we can only avoid using such forms while these versions of Emacs are widely used.)
** 0.8.2
*Fixes*
+ Deduplicate grouped membership events.
** 0.8.1
Added missing changelog entry (of course).
** 0.8
*Additions*
+ Command ~ement-create-space~ creates a new space.
+ Command ~ement-room-toggle-space~ toggles a room's membership in a space (a way to group rooms in Matrix).
+ Visibility of sections in the room list is saved across sessions.
+ Command ~ement-room-list-kill-buffer~ kills a room's buffer from the room list.
+ Set ~device_id~ and ~initial_device_display_name~ upon login (e.g. =Ement.el: username@hostname=). ([[https://github.com/alphapapa/ement.el/issues/134][#134]]. Thanks to [[https://github.com/viiru-][Arto Jantunen]] for reporting.)
*Changes*
+ Room-related commands may be called interactively with a universal prefix to prompt for the room/session (allowing to send events or change settings in rooms other than the current one).
+ Command ~ement-room-list~ reuses an existing window showing the room list when possible. ([[https://github.com/alphapapa/ement.el/issues/131][#131]]. Thanks to [[https://github.com/jeffbowman][Jeff Bowman]] for suggesting.)
+ Command ~ement-tag-room~ toggles tags (rather than adding by default and removing when called with a prefix).
+ Default room grouping now groups "spaced" rooms separately.
*Fixes*
+ Message format filter works properly when writing replies.
+ Improve insertion of sender name headers when using the "Elemental" message format.
+ Prompts in commands ~ement-leave-room~ and ~ement-forget-room~.
** 0.7
*Additions*
+ Command ~ement-room-override-name~ sets a local override for a room's display name. (Especially helpful for 1:1 rooms and bridged rooms. See [[https://github.com/matrix-org/matrix-spec-proposals/pull/3015#issuecomment-1451017296][MSC3015]].)
*Changes*
+ Improve display of room tombstones (displayed at top and bottom of buffer, and new room ID is linked to join).
+ Use descriptive prompts in ~ement-leave-room~ and ~ement-forget-room~ commands.
*Fixes*
+ Command ~ement-view-space~ when called from a room buffer. (Thanks to [[https://github.com/MagicRB][Richard Brežák]] for reporting.)
+ Don't call ~display-buffer~ when reverting room list buffer. (Fixes [[https://github.com/alphapapa/ement.el/issues/121][#121]]. Thanks to [[https://github.com/mekeor][mekeor]] for reporting.)
+ Retry sync for network timeouts. (Accidentally broken in v0.6.)
*Internal*
+ Function ~ement-put-account-data~ accepts ~:room~ argument to put on a room's account data.
** 0.6
*Additions*
+ Command ~ement-view-space~ to view a space's rooms in a directory buffer.
*Changes*
+ Improve ~ement-describe-room~ command (formatting, bindings).
*Fixes*
+ Retry sync for HTTP 502 "Bad Gateway" errors.
+ Formatting of unban events.
+ Update password authentication according to newer Matrix spec. (Fixes compatibility with Conduit servers. [[https://github.com/alphapapa/ement.el/issues/66][#66]]. Thanks to [[https://github.com/tpeacock19][Travis Peacock]], [[https://github.com/viiru-][Arto Jantunen]], and [[https://github.com/scd31][Stephen D]].)
+ Image scaling issues. (Thanks to [[https://github.com/vizs][Visuwesh]].)
** 0.5.2
*Fixes*
+ Apply ~ement-initial-sync-timeout~ properly (important for when the homeserver is slow to respond).
** 0.5.1
*Fixes*
+ Autoload ~ement-directory~ commands.
+ Faces in ~ement-directory~ listings.
** 0.5
*Additions*
+ Present "joined-and-left" and "rejoined-and-left" membership event pairs as such.
+ Process and show rooms' canonical alias events.
*Changes*
+ The [[https://github.com/alphapapa/taxy.el][taxy.el]]-based room list, with programmable, smart grouping, is now the default ~ement-room-list~. (The old, ~tabulated-list-mode~-based room list is available as ~ement-tabulated-room-list~.)
+ When selecting a room to view with completion, don't offer spaces.
+ When selecting a room with completion, empty aliases and topics are omitted instead of being displayed as nil.
*Fixes*
+ Use of send-message filter when replying.
+ Replies may be written in compose buffers.
** 0.4.1
*Fixes*
+ Don't show "curl process interrupted" message when updating a read marker's position again.
** 0.4
*Additions*
+ Option ~ement-room-unread-only-counts-notifications~, now enabled by default, causes rooms' unread status to be determined only by their notification counts (which are set by the server and depend on rooms' notification settings).
+ Command ~ement-room-set-notification-state~ sets a room's notification state (imitating Element's user-friendly presets).
+ Room buffers' Transient menus show the room's notification state (imitating Element's user-friendly presets).
+ Command ~ement-set-display-name~ sets the user's global displayname.
+ Command ~ement-room-set-display-name~ sets the user's displayname in a room (which is also now displayed in the room's Transient menu).
+ Column ~Notifications~ in the ~ement-taxy-room-list~ buffer shows rooms' notification state.
+ Option ~ement-interrupted-sync-hook~ allows customization of how sync interruptions are handled. (Now, by default, a warning is displayed instead of merely a message.)
*Changes*
+ When a room's read receipt is updated, the room's buffer is also marked as unmodified. (In concert with the new option, this makes rooms' unread status more intuitive.)
*Fixes*
+ Binding of command ~ement-forget-room~ in room buffers.
+ Highlighting of ~@room~ mentions.
** 0.3.1
*Fixes*
+ Room unread status (when the last event in a room is sent by the local user, the room is considered read).
** 0.3
*Additions*
+ Command ~ement-directory~ shows a server's room directory.
+ Command ~ement-directory-search~ searches a server's room directory.
+ Command ~ement-directory-next~ fetches the next batch of rooms in a directory.
+ Command ~ement-leave-room~ accepts a ~FORCE-P~ argument (interactively, with prefix) to leave a room without prompting.
+ Command ~ement-forget-room~ accepts a ~FORCE-P~ argument (interactively, with prefix) to also leave the room, and to forget it without prompting.
+ Option ~ement-notify-mark-frame-urgent-predicates~ marks the frame as urgent when (by default) a message mentions the local user or "@room" and the message's room has an open buffer.
*Changes*
+ Minor improvements to date/time headers.
*Fixes*
+ Command ~ement-describe-room~ for rooms without topics.
+ Improve insertion of old messages around existing timestamp headers.
+ Reduce D-Bus notification system check timeout to 2 seconds (from the default of 25).
+ Compatibility with Emacs 27.
** 0.2.1
*Fixes*
+ Info manual export filename.
** 0.2
*Changes*
+ Read receipts are re-enabled. (They're now implemented with a global idle timer rather than ~window-scroll-functions~, which sometimes caused a strange race condition that could cause Emacs to become unresponsive or crash.)
+ When determining whether a room is considered unread, non-message events like membership changes, reactions, etc. are ignored. This fixes a bug that caused certain rooms that had no message events (like some bridged rooms) to appear as unread when they shouldn't have. But it's unclear whether this is always preferable (e.g. one might want a member leaving a room to cause it to be marked unread), so this is classified as a change rather than simply a fix, and more improvements may be made to this in the future. (Fixes [[https://github.com/alphapapa/ement.el/issues/97][#97]]. Thanks to [[https://github.com/MrRoy][Julien Roy]] for reporting and testing.)
+ The ~ement-taxy-room-list~ view no longer automatically refreshes the list if the region is active in the buffer. (This allows the user to operate on multiple rooms without the contents of the buffer changing before completing the process.)
*Fixes*
+ Links to only rooms (as opposed to links to events in rooms) may be activated to join them.
+ Read receipts mark the last completely visible event (rather than one that's only partially displayed).
+ Prevent error when a room avatar image fails to load.
** 0.1.4
*Fixed*
+ Info manual directory headers.
** 0.1.3
*Fixed*
# + Read receipt-sending function was called too many times when scrolling.
# + Send read receipts even when the last receipt is outside the range of retrieved events.
+ Temporarily disable sending of read receipts due to an unusual bug that could cause Emacs to become unresponsive. (The feature will be re-enabled in a future release.)
** 0.1.2
*Fixed*
+ Function ~ement-room-sync~ correctly updates room-list buffers. (Thanks to [[https://github.com/vizs][Visuwesh]].)
+ Only send D-Bus notifications when supported. (Fixes [[https://github.com/alphapapa/ement.el/issues/83][#83]]. Thanks to [[https://github.com/tsdh][Tassilo Horn]].)
** 0.1.1
*Fixed*
+ Function ~ement-room-scroll-up-mark-read~ selects the correct room window.
+ Option ~ement-room-list-avatars~ defaults to what function ~display-images-p~ returns.
** 0.1
After almost two years of development, the first tagged release. Submitted to GNU ELPA.
* Development
:PROPERTIES:
:TOC: :include this :ignore descendants
:END:
Bug reports, feature requests, suggestions — /oh my/!
** Copyright Assignment
:PROPERTIES:
:TOC: :ignore (this)
:END:
Ement.el is published in GNU ELPA and is considered part of GNU Emacs. Therefore, cumulative contributions of more than 15 lines of code require that the author assign copyright of such contributions to the FSF. Authors who are interested in doing so may contact [[mailto:assign@gnu.org][assign@gnu.org]] to request the appropriate form.
** Matrix spec in Org format
:PROPERTIES:
:TOC: :ignore (this)
:END:
An Org-formatted version of the Matrix spec is available in the [[https://github.com/alphapapa/ement.el/tree/meta/spec][meta/spec]] branch.
** Rationale
/This section is preserved for posterity. As it says, Ement.el has long since surpassed ~matrix-client~, which should no longer be used./
Why write a new Emacs Matrix client when there is already [[https://github.com/alphapapa/matrix-client.el][matrix-client.el]], by the same author, no less? A few reasons:
- ~matrix-client~ uses an older version of the Matrix spec, r0.3.0, with a few elements of r0.4.0 grafted in. Bringing it up to date with the current version of the spec, r0.6.1, would be more work than to begin with the current version. Ement.el targets r0.6.1 from the beginning.
- ~matrix-client~ does not use Matrix's lazy-loading feature (which was added to the specification later), so initial sync requests can take a long time for the server to process and can be large (sometimes tens of megabytes of JSON for the client to process!). Ement.el uses lazy-loading, which significantly improves performance.
- ~matrix-client~ automatically makes buffers for every room a user has joined, even if the user doesn't currently want to watch a room. Ement.el opens room buffers on-demand, improving performance by not having to insert events into buffers for rooms the user isn't watching.
- ~matrix-client~ was developed without the intention of publishing it to, e.g. MELPA or ELPA. It has several dependencies, and its code does not always install or compile cleanly due to macro-expansion issues (apparently depending on the user's Emacs config). Ement.el is designed to have minimal dependencies outside of Emacs (currently only one, ~plz~, which could be imported into the project), and every file is linted and compiles cleanly using [[https://github.com/alphapapa/makem.sh][makem.sh]].
- ~matrix-client~ uses EIEIO, probably unnecessarily, since few, if any, of the benefits of EIEIO are realized in it. Ement.el uses structs instead.
- ~matrix-client~ uses bespoke code for inserting messages into buffers, which works pretty well, but has a few minor bugs which are difficult to track down. Ement.el uses Emacs's built-in (and perhaps little-known) ~ewoc~ library, which makes it much simpler and more reliable to insert and update messages in buffers, and enables the development of advanced UI features more easily.
- ~matrix-client~ was, to a certain extent, designed to imitate other messaging apps. The result is, at least when used with the ~matrix-client-frame~ command, fairly pleasing to use, but isn't especially "Emacsy." Ement.el is intended to better fit into Emacs's paradigms.
- ~matrix-client~'s long name makes for long symbol names, which makes for tedious, verbose code. ~ement~ is easy to type and makes for concise, readable code.
- The author has learned much since writing ~matrix-client~ and hopes to write simpler, more readable, more maintainable code in Ement.el. It's hoped that this will enable others to contribute more easily.
Note that, while ~matrix-client~ remains usable, and probably will for some time to come, Ement.el has now surpassed it in every way. The only reason to choose ~matrix-client~ instead is if one is using an older version of Emacs that isn't supported by Ement.el.
* License
:PROPERTIES:
:TOC: :ignore (this)
:END:
GPLv3
* COMMENT Config :noexport:
:PROPERTIES:
:TOC: :ignore (this descendants)
:END:
# NOTE: The #+OPTIONS: and other keywords did not take effect when in this section (perhaps due to file size or to changes in Org), so they were moved to the top of the file.
** File-local variables
# Local Variables:
# eval: (require 'org-make-toc)
# before-save-hook: org-make-toc
# org-export-with-properties: ()
# org-export-with-title: t
# End:
━━━━━━━━━━
EMENT.EL
━━━━━━━━━━
Table of Contents
─────────────────
1. Installation
2. Usage
3. Changelog
4. Development
5. License
[https://elpa.gnu.org/packages/ement.svg]
Ement.el is a Matrix client for Emacs. It aims to be simple, fast,
featureful, and reliable.
Feel free to join us in the chat room:
[https://img.shields.io/matrix/ement.el:matrix.org.svg?label=%23ement.el:matrix.org]
[https://elpa.gnu.org/packages/ement.svg]
<https://elpa.gnu.org/packages/ement.html>
[https://img.shields.io/matrix/ement.el:matrix.org.svg?label=%23ement.el:matrix.org]
<https://matrix.to/#/#ement.el:matrix.org>
1 Installation
══════════════
GNU ELPA
────────
Ement.el is published in [GNU ELPA] as [ement], so it may be installed
in Emacs with the command `M-x package-install RET ement RET'. This
is the recommended way to install Ement.el, as it will install the
current stable release.
The latest development build may be installed from [ELPA-devel] or
from Git (see below).
[GNU ELPA] <http://elpa.gnu.org/>
[ement] <https://elpa.gnu.org/packages/ement.html>
[ELPA-devel] <https://elpa.gnu.org/devel/ement.html>
GNU Guix
────────
Ement.el is available in [GNU Guix] as [emacs-ement].
[GNU Guix] <https://guix.gnu.org/>
[emacs-ement] <https://packages.guix.gnu.org/packages/emacs-ement/>
Debian, Ubuntu
──────────────
Ement.el is available in [Debian as elpa-ement] and in [Ubuntu as
elpa-ement].
[Debian as elpa-ement] <https://packages.debian.org/elpa-ement>
[Ubuntu as elpa-ement]
<https://packages.ubuntu.com/search?suite=default§ion=all&arch=any&keywords=elpa-ement&searchon=names>
Nix
───
Ement.el is available in [NixOS] as [emacsPackages.ement].
[NixOS] <https://nixos.org/>
[emacsPackages.ement]
<https://search.nixos.org/packages?channel=23.05&show=emacsPackages.ement&from=0&size=50&sort=relevance&type=packages&query=ement>
Other distributions
───────────────────
Ement.el is also available in some other distributions. See
[Repology] for details.
[Repology] <https://repology.org/project/emacs:ement/related>
Git master
──────────
The `master' branch of the Git repository is intended to be usable at
all times; only minor bugs are expected to be found in it before a new
stable release is made.
To install, it is recommended to use [quelpa-use-package], like this
(using [this helpful command] for upgrading versions):
┌────
│ ;; Install and load `quelpa-use-package'.
│ (package-install 'quelpa-use-package)
│ (require 'quelpa-use-package)
│
│ ;; Install Ement.
│ (use-package ement
│ :quelpa (ement :fetcher github :repo "alphapapa/ement.el"))
└────
One might also use systems like [Elpaca] or [Straight] (which is also
used by [DOOM]), but the author cannot offer support for them.
[quelpa-use-package] <https://github.com/quelpa/quelpa-use-package>
[this helpful command]
<https://github.com/alphapapa/unpackaged.el#upgrade-a-quelpa-use-package-forms-package>
[Elpaca] <https://github.com/progfolio/elpaca>
[Straight] <https://github.com/radian-software/straight.el>
[DOOM] <https://github.com/doomemacs/doomemacs>
Manual
──────
Ement.el is intended to be installed with Emacs's package system,
which will ensure that the required autoloads are generated, etc. If
you choose to install it manually, you're on your own.
2 Usage
═══════
•
•
•
1. Call command `ement-connect' to connect. Multiple sessions are
supported, so you may call the command again to connect to another
account.
2. Wait for initial sync to complete (which can take a few
moments–initial sync JSON requests can be large).
3. Use these commands (room-related commands may be called with
universal prefix to prompt for the room):
• `ement-list-rooms' to view the list of joined rooms.
• `ement-view-room' to view a room's buffer, selected with
completion.
• `ement-create-room' to create a new room.
• `ement-create-space' to create a space.
• `ement-invite-user' to invite a user to a room.
• `ement-join-room' to join a room.
• `ement-leave-room' to leave a room.
• `ement-forget-room' to forget a room.
• `ement-tag-room' to toggle a tag on a room (including
favorite/low-priority status).
• `ement-list-members' to list members in a room.
• `ement-send-direct-message' to send a direct message to a user
(in an existing direct room, or creating a new one
automatically).
• `ement-room-edit-message' to edit a message at point.
• `ement-room-send-file' to send a file.
• `ement-room-send-image' to send an image.
• `ement-room-set-topic' to set a room's topic.
• `ement-room-occur' to search in a room's known events.
• `ement-room-override-name' to override a room's display name.
• `ement-ignore-user' to ignore a user (or with interactive prefix,
un-ignore).
• `ement-room-set-message-format' to set a room's message format
buffer-locally.
• `ement-room-toggle-space' to toggle a room's membership in a
space (a way to group rooms in Matrix).
• `ement-directory' to view a room directory.
• `ement-directory-search' to search a room directory.
4. Use these special buffers to see events from multiple rooms (you
can also reply to messages from these buffers!):
• See all new events that mention you in the `*Ement Mentions*'
buffer.
• See all new events in rooms that have open buffers in the `*Ement
Notifications*' buffer.
Bindings
────────
These bindings are common to all of the following buffer types:
⁃ Switch to a room buffer: `M-g M-r'
⁃ Switch to the room list buffer: `M-g M-l'
⁃ Switch to the mentions buffer: `M-g M-m'
⁃ Switch to the notifications buffer: `M-g M-n'
Room buffers
╌╌╌╌╌╌╌╌╌╌╌╌
⁃ Show command menu: `?'
*Movement*
⁃ Next event: `n'
⁃ Previous event: `p'
⁃ Scroll up and mark read: `SPC'
⁃ Scroll down: `S-SPC'
⁃ Jump to fully-read marker: `M-g M-p'
⁃ Move read markers to point: `m'
⁃ Load older messages: at top of buffer, scroll contents up
(i.e. `S-SPC', `M-v' or `mwheel-scroll')
*Switching*
⁃ List rooms: `M-g M-l'
⁃ Switch to other room: `M-g M-r'
⁃ Switch to mentions buffer: `M-g M-m'
⁃ Switch to notifications buffer: `M-g M-n'
⁃ Quit window: `q'
*Messages*
⁃ Write message: `RET'
⁃ Write reply to event at point (when region is active, only quote
marked text) : `S-RET'
⁃ Compose message in buffer: `M-RET' (while writing in minibuffer:
`C-c ')' (Use command `ement-room-compose-org' to activate Org mode
in the compose buffer.)
⁃ Edit message: `<insert>'
⁃ Delete message: `C-k'
⁃ Send reaction to event at point, or send same reaction at point: `s
r'
⁃ Send emote: `s e'
⁃ Send file: `s f'
⁃ Send image: `s i'
⁃ View event source: `v'
⁃ Complete members and rooms at point: `C-M-i' (standard
`completion-at-point' command). (Type an `@' prefix for a member
mention, a `#' prefix for a room alias, or a `!' prefix for a room
ID.)
*Images*
⁃ Toggle scale of image (between fit-to-window and thumbnail):
`mouse-1'
⁃ Show image in new buffer at full size: `double-mouse-1'
*Users*
⁃ Send direct message: `u RET'
⁃ Invite user: `u i'
⁃ Ignore user: `u I'
*Room*
⁃ Occur search in room: `M-s o'
⁃ List members: `r m'
⁃ Set topic: `r t'
⁃ Set message format: `r f'
⁃ Set notification rules: `r n'
⁃ Override display name: `r N'
⁃ Tag/untag room: `r T'
*Room membership*
⁃ Create room: `R c'
⁃ Join room: `R j'
⁃ Leave room: `R l'
⁃ Forget room: `R F'
⁃ Toggle room's spaces: `R s'
*Other*
⁃ Sync new messages (not necessary if auto sync is enabled; with
prefix to force new sync): `g'
Room list buffer
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
⁃ Show buffer of room at point: `RET'
⁃ Show buffer of next unread room: `SPC'
⁃ Move between room names: `TAB' / `<backtab>'
⁃ Kill room's buffer: `k'
⁃ Toggle room's membership in a space: `s'
Directory buffers
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
⁃ View/join a room: `RET' / `mouse-1'
⁃ Load next batch of rooms: `+'
Mentions/notifications buffers
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
⁃ Move between events: `TAB' / `<backtab>'
⁃ Go to event at point in its room buffer: `RET'
⁃ Write reply to event at point (shows the event in its room while
writing) : `S-RET'
Tips
────
⁃ Desktop notifications are enabled by default for events that mention
the local user. They can also be shown for all events in rooms with
open buffers.
⁃ Send messages in Org mode format by customizing the option
`ement-room-send-message-filter' (which enables Org format by
default), or by calling `ement-room-compose-org' in a compose buffer
(which enables it for a single message). Then Org-formatted
messages are automatically converted and sent as HTML-formatted
messages (with the Org syntax as the plain-text fallback). You can
send syntax such as:
• Bold, italic, underline, strikethrough
• Links
• Tables
• Source blocks (including results with `:exports both')
• Footnotes (okay, that might be pushing it, but you can!)
• And, generally, anything that Org can export to HTML
⁃ Starting in the room list buffer, by pressing `SPC' repeatedly, you
can cycle through and read all rooms with unread buffers. (If a
room doesn't have a buffer, it will not be included.)
⁃ Room buffers and the room-list buffer can be bookmarked in Emacs,
i.e. using `C-x r m'. This is especially useful with [Burly]: you
can arrange an Emacs frame with several room buffers displayed at
once, use `burly-bookmark-windows' to bookmark the layout, and then
you can restore that layout and all of the room buffers by opening
the bookmark, rather than having to manually arrange them every time
you start Emacs or change the window configuration.
⁃ Images and other files can be uploaded to rooms using drag-and-drop.
⁃ Mention members by typing a `@' followed by their displayname or
Matrix ID. (Members' names and rooms' aliases/IDs may be completed
with `completion-at-point' commands.)
⁃ You can customize settings in the `ement' group.
• *Note:* `setq' should not be used for certain options, because it
will not call the associated setter function. Users who have an
aversion to the customization system may experience problems.
[Burly] <https://github.com/alphapapa/burly.el>
Displaying symbols and emojis
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Emacs may not display certain symbols and emojis well by default.
Based on [this question and answer], you may find that the simplest
way to fix this is to install an appropriate font, like [Noto Emoji],
and then use this Elisp code:
┌────
│ (setf use-default-font-for-symbols nil)
│ (set-fontset-font t 'unicode "Noto Emoji" nil 'append)
└────
[this question and answer]
<https://emacs.stackexchange.com/questions/62049/override-the-default-font-for-emoji-characters>
[Noto Emoji] <https://www.google.com/get/noto/#emoji-zsye>
Encrypted room support through Pantalaimon
──────────────────────────────────────────
Ement.el doesn't support encrypted rooms natively, but it can be used
transparently with the E2EE-aware reverse proxy daemon [Pantalaimon].
After configuring it according to its documentation, call
`ement-connect' with the appropriate hostname and port, like:
┌────
│ (ement-connect :uri-prefix "http://localhost:8009")
└────
[Pantalaimon] <https://github.com/matrix-org/pantalaimon/>
3 Changelog
═══════════
0.12
────
*Additions*
⁃ Command `ement-notifications' shows recent notifications, similar to
the pane in the Element client. (This new command fetches recent
notifications from the server and allows scrolling up to retrieve
older ones. Newly received notifications, as configured in the
`ement-notify' options, are displayed in the same buffer. This
functionality will be consolidated in the future.)
⁃ Face `ement-room-quote', applied to quoted parts of replies.
*Changes*
⁃ Commands `ement-room-goto-next' and `ement-room-goto-prev' work more
usefully at the end of a room buffer. (Now pressing `n' on the last
event moves point to the end of the buffer so it will scroll
automatically for new messages, and then pressing `p' skips over any
read marker to the last event.)
⁃ Room buffer bindings:
⁃ `ement-room-goto-next' and `ement-room-goto-prev' are bound to `n'
and `p', respectively.
⁃ `ement-room-goto-fully-read-marker' is bound to `M-g M-p' (the
mnemonic being "go to previously read").
⁃ The quoted part of a reply now omits the face applied to the rest of
the message, helping to distinguish them.
⁃ Commands that read a string from the minibuffer in `ement-room'
buffers and `ement-connect' user ID prompts use separate history
list variables.
⁃ Use Emacs's Jansson-based JSON-parsing functions when available.
(This results in a 3-5x speed improvement for parsing JSON
responses, which can be significant for large initial sync
responses. Thanks to [Ryan Rix] for discovering this!)
*Fixes*
⁃ File event formatter assumed that file size metadata would be
present (a malformed, e.g. spam, event might not have it).
⁃ Send correct file size when sending files/images.
⁃ Underscores are no longer interpreted as denoting subscripts when
sending messages in Org format. (Thanks to [Phil Sainty].)
⁃ Add workaround for `savehist-mode''s serializing of the
`command-history' variable's arguments. (For `ement-' commands,
that may include large data structures, like `ement-session'
structs, which should never be serialized or reused, and
`savehist''s doing so could cause noticeable delays for users who
enabled it). (See [#216]. Thanks to [Phil Sainty] and other users
who helped to discover this problem.)
[Ryan Rix] <https://github.com/rrix/>
[Phil Sainty] <https://github.com/phil-s>
[#216] <https://github.com/alphapapa/ement.el/issues/216>
0.11
────
*Additions*
⁃ Commands `ement-room-image-show' and `ement-room-image-scale' (bound
to `RET' and `M-RET' when point is at an image) view and scale
images. (Thanks to [Steven Allen] for these and other image-related
improvements.)
⁃ Command `ement-room-image-show-mouse' is used to show an image with
the mouse.
*Changes*
⁃ Enable `image-mode' when showing images in a new buffer. (Thanks to
[Steven Allen].)
⁃ Command `ement-room-image-show' is not used for mouse events.
⁃ Show useful message in SSO login page.
*Fixes*
⁃ Allow editing of already-edited events.
⁃ Push rules' actions may be listed in any order. (Fixes
compatibility with [v1.7 of the spec]. Thanks to [Steven Allen].)
⁃ Call external browser for SSO login page. (JavaScript is usually
required, which EWW doesn't support, and loading the page twice
seems to change state on the server that causes the SSO login to
fail, so it's best to load the page in the external browser
directly).
⁃ Clean up SSO server process after two minutes in case SSO login
fails.
⁃ Don't stop syncing if an error is signaled while sending a
notification.
⁃ Command `ement-room-list-next-unread' could enter an infinite loop.
(Thanks to [Visuwesh] and `@mrtnmrtn:matrix.org'.)
⁃ Events in notifications buffer could appear out-of-order. ([#191].
Thanks to [Phil Sainty].)
*Internal*
⁃ The `ement-read-receipt-idle-timer' could be duplicated when using
multiple sessions. ([#196]. Thanks to [Phil Sainty].)
[Steven Allen] <https://github.com/Stebalien>
[v1.7 of the spec]
<https://spec.matrix.org/v1.7/client-server-api/#actions>
[Visuwesh] <https://github.com/vizs>
[#191] <https://github.com/alphapapa/ement.el/issues/191>
[Phil Sainty] <https://github.com/phil-s>
[#196] <https://github.com/alphapapa/ement.el/issues/196>
0.10
────
*Security Fixes*
⁃ When uploading a GPG-encrypted file (i.e. one whose filename ends in
`.gpg'), if the recipient's private key or the symmetric encryption
key were cached by Emacs (or a configured agent, like `gpg-agent'),
Emacs would automatically decrypt the file while reading its
contents and then upload the decrypted contents. (This happened
because the function `insert-file-contents' was used, which does
many things automatically, some of which are not even mentioned in
its docstring; refer to its entry in the Elisp Info manual for
details. The fix is to use `insert-file-contents-literally'
instead.) Thanks to `@welkinsl:matrix.org' for reporting.
*Additions*
⁃ Support for Single Sign-On (SSO) authentication. ([#24]. Thanks to
[Jeffrey Stoffers] for development, and to [Phil Sainty], [Jakub
Kadlčík], and [Juanjo Presa] for testing.)
⁃ Bind `m' in room buffers to `ement-room-mark-read' (which moves read
markers to point).
*Changes*
⁃ Activating a space in the room list uses `ement-view-space' (which
shows a directory of rooms in the space) instead of
`ement-view-room' (which shows events in the space, which is
generally not useful).
⁃ Command `ement-view-room', when used for a space, shows a footer
explaining that the buffer is showing a space rather than a normal
room, with a button to call `ement-view-space' for it (which lists
rooms in the space).
⁃ Command `ement-describe-room' shows whether a room is a space or a
normal room.
⁃ Command `ement-view-space' shows the space's name and alias.
⁃ Command `ement-room-scroll-up-mark-read' moves the fully read marker
to the top of the window (when the marker's position is within the
range of known events), rather than only moving it when at the end
of the buffer. (This eases the process of gradually reading a long
backlog of messages.)
⁃ Improve readme export settings.
*Fixes*
⁃ Extra indentation of some membership events. (Thanks to [Steven
Allen].)
⁃ Customization group for faces.
⁃ Don't reinitialize `ement-room-list-mode' when room list buffer is
refreshed. ([#146]. Thanks to [Ted Reed] for reporting.)
⁃ Don't fetch old events when scrolling to the bottom of a room buffer
(only when scrolling to the top). (Thanks to [Steven Allen].)
⁃ Minor improvements to auto-detection of homeserver URIs. (See
[#24]. Thanks to [Phil Sainty].)
⁃ Uploading of certain filetypes (e.g. Emacs would decompress some
archives before uploading). Thanks to `@welkinsl:matrix.org' for
reporting.
⁃ Messages edited multiple times sometimes weren't correctly replaced.
[#24] <https://github.com/alphapapa/ement.el/issues/24>
[Jeffrey Stoffers] <https://github.com/Necronian>
[Phil Sainty] <https://github.com/phil-s>
[Jakub Kadlčík] <https://github.com/FrostyX>
[Juanjo Presa] <https://github.com/oneingan>
[Steven Allen] <https://github.com/Stebalien>
[#146] <https://github.com/alphapapa/ement.el/issues/146>
[Ted Reed] <https://github.com/treed>
[#24]
<https://github.com/alphapapa/ement.el/issues/24#issuecomment-1569518713>
0.9.3
─────
*Fixes*
⁃ Another attempt at restoring position in room list when refreshing.
⁃ Command `ement-room-list-next-unread'.
0.9.2
─────
*Fixes*
⁃ Restore position in room list when refreshing.
⁃ Completion in minibuffer.
0.9.1
─────
*Fixes*
⁃ Error in `ement-room-list' command upon initial sync.
0.9
───
*Additions*
⁃ Option `ement-room-timestamp-header-align' controls how timestamp
headers are aligned in room buffers.
⁃ Option `ement-room-view-hook' runs functions when `ement-room-view'
is called. (By default, it refreshes the room list buffer.)
⁃ In the room list, middle-clicking a room which has a buffer closes
its buffer.
⁃ Basic support for video events. (Thanks to [Arto Jantunen].)
*Changes*
⁃ Using new option `ement-room-timestamp-header-align', timestamp
headers default to right-aligned. (With default settings, this
keeps them near message timestamps and makes for a cleaner
appearance.)
*Fixes*
⁃ Recognition of certain MXID or displayname forms in outgoing
messages when linkifying (aka "pilling") them.
⁃ Unreadable room avatar images no longer cause errors. (Fixes
[#147]. Thanks to [@jgarte] for reporting.)
⁃ Don't error in `ement-room-list' when no rooms are joined. (Fixes
[#123]. Thanks to [@Kabouik] and [Omar Antolín Camarena] for
reporting.)
⁃ Enable member/room completion in compose buffers. (Fixes [#115].
Thanks to Thanks to [Justus Piater] and [Caleb Chase] for
reporting.)
[Arto Jantunen] <https://github.com/viiru->
[#147] <https://github.com/alphapapa/ement.el/issues/147>
[@jgarte] <https://github.com/jgarte>
[#123] <https://github.com/alphapapa/ement.el/issues/123>
[@Kabouik] <https://github.com/Kabouik>
[Omar Antolín Camarena] <https://github.com/oantolin>
[#115] <https://github.com/alphapapa/ement.el/issues/115>
[Justus Piater] <https://github.com/piater>
[Caleb Chase] <https://github.com/chasecaleb>
0.8.3
─────
*Fixes*
⁃ Avoid use of `pcase''s `(map :KEYWORD)' form. (This can cause a
broken installation on older versions of Emacs that have an older
version of the `map' library loaded, such as Emacs 27.2 included in
Debian 11. Since there's no way to force Emacs to actually load the
version of `map' required by this package before installing it
(which would naturally happen upon restarting Emacs), we can only
avoid using such forms while these versions of Emacs are widely
used.)
0.8.2
─────
*Fixes*
⁃ Deduplicate grouped membership events.
0.8.1
─────
Added missing changelog entry (of course).
0.8
───
*Additions*
⁃ Command `ement-create-space' creates a new space.
⁃ Command `ement-room-toggle-space' toggles a room's membership in a
space (a way to group rooms in Matrix).
⁃ Visibility of sections in the room list is saved across sessions.
⁃ Command `ement-room-list-kill-buffer' kills a room's buffer from the
room list.
⁃ Set `device_id' and `initial_device_display_name' upon login
(e.g. `Ement.el: username@hostname'). ([#134]. Thanks to [Arto
Jantunen] for reporting.)
*Changes*
⁃ Room-related commands may be called interactively with a universal
prefix to prompt for the room/session (allowing to send events or
change settings in rooms other than the current one).
⁃ Command `ement-room-list' reuses an existing window showing the room
list when possible. ([#131]. Thanks to [Jeff Bowman] for
suggesting.)
⁃ Command `ement-tag-room' toggles tags (rather than adding by default
and removing when called with a prefix).
⁃ Default room grouping now groups "spaced" rooms separately.
*Fixes*
⁃ Message format filter works properly when writing replies.
⁃ Improve insertion of sender name headers when using the "Elemental"
message format.
⁃ Prompts in commands `ement-leave-room' and `ement-forget-room'.
[#134] <https://github.com/alphapapa/ement.el/issues/134>
[Arto Jantunen] <https://github.com/viiru->
[#131] <https://github.com/alphapapa/ement.el/issues/131>
[Jeff Bowman] <https://github.com/jeffbowman>
0.7
───
*Additions*
⁃ Command `ement-room-override-name' sets a local override for a
room's display name. (Especially helpful for 1:1 rooms and bridged
rooms. See [MSC3015].)
*Changes*
⁃ Improve display of room tombstones (displayed at top and bottom of
buffer, and new room ID is linked to join).
⁃ Use descriptive prompts in `ement-leave-room' and
`ement-forget-room' commands.
*Fixes*
⁃ Command `ement-view-space' when called from a room buffer. (Thanks
to [Richard Brežák] for reporting.)
⁃ Don't call `display-buffer' when reverting room list buffer. (Fixes
[#121]. Thanks to [mekeor] for reporting.)
⁃ Retry sync for network timeouts. (Accidentally broken in v0.6.)
*Internal*
⁃ Function `ement-put-account-data' accepts `:room' argument to put on
a room's account data.
[MSC3015]
<https://github.com/matrix-org/matrix-spec-proposals/pull/3015#issuecomment-1451017296>
[Richard Brežák] <https://github.com/MagicRB>
[#121] <https://github.com/alphapapa/ement.el/issues/121>
[mekeor] <https://github.com/mekeor>
0.6
───
*Additions*
⁃ Command `ement-view-space' to view a space's rooms in a directory
buffer.
*Changes*
⁃ Improve `ement-describe-room' command (formatting, bindings).
*Fixes*
⁃ Retry sync for HTTP 502 "Bad Gateway" errors.
⁃ Formatting of unban events.
⁃ Update password authentication according to newer Matrix spec.
(Fixes compatibility with Conduit servers. [#66]. Thanks to
[Travis Peacock], [Arto Jantunen], and [Stephen D].)
⁃ Image scaling issues. (Thanks to [Visuwesh].)
[#66] <https://github.com/alphapapa/ement.el/issues/66>
[Travis Peacock] <https://github.com/tpeacock19>
[Arto Jantunen] <https://github.com/viiru->
[Stephen D] <https://github.com/scd31>
[Visuwesh] <https://github.com/vizs>
0.5.2
─────
*Fixes*
⁃ Apply `ement-initial-sync-timeout' properly (important for when the
homeserver is slow to respond).
0.5.1
─────
*Fixes*
⁃ Autoload `ement-directory' commands.
⁃ Faces in `ement-directory' listings.
0.5
───
*Additions*
⁃ Present "joined-and-left" and "rejoined-and-left" membership event
pairs as such.
⁃ Process and show rooms' canonical alias events.
*Changes*
⁃ The [taxy.el]-based room list, with programmable, smart grouping, is
now the default `ement-room-list'. (The old,
`tabulated-list-mode'-based room list is available as
`ement-tabulated-room-list'.)
⁃ When selecting a room to view with completion, don't offer spaces.
⁃ When selecting a room with completion, empty aliases and topics are
omitted instead of being displayed as nil.
*Fixes*
⁃ Use of send-message filter when replying.
⁃ Replies may be written in compose buffers.
[taxy.el] <https://github.com/alphapapa/taxy.el>
0.4.1
─────
*Fixes*
⁃ Don't show "curl process interrupted" message when updating a read
marker's position again.
0.4
───
*Additions*
⁃ Option `ement-room-unread-only-counts-notifications', now enabled by
default, causes rooms' unread status to be determined only by their
notification counts (which are set by the server and depend on
rooms' notification settings).
⁃ Command `ement-room-set-notification-state' sets a room's
notification state (imitating Element's user-friendly presets).
⁃ Room buffers' Transient menus show the room's notification state
(imitating Element's user-friendly presets).
⁃ Command `ement-set-display-name' sets the user's global displayname.
⁃ Command `ement-room-set-display-name' sets the user's displayname in
a room (which is also now displayed in the room's Transient menu).
⁃ Column `Notifications' in the `ement-taxy-room-list' buffer shows
rooms' notification state.
⁃ Option `ement-interrupted-sync-hook' allows customization of how
sync interruptions are handled. (Now, by default, a warning is
displayed instead of merely a message.)
*Changes*
⁃ When a room's read receipt is updated, the room's buffer is also
marked as unmodified. (In concert with the new option, this makes
rooms' unread status more intuitive.)
*Fixes*
⁃ Binding of command `ement-forget-room' in room buffers.
⁃ Highlighting of `@room' mentions.
0.3.1
─────
*Fixes*
⁃ Room unread status (when the last event in a room is sent by the
local user, the room is considered read).
0.3
───
*Additions*
⁃ Command `ement-directory' shows a server's room directory.
⁃ Command `ement-directory-search' searches a server's room directory.
⁃ Command `ement-directory-next' fetches the next batch of rooms in a
directory.
⁃ Command `ement-leave-room' accepts a `FORCE-P' argument
(interactively, with prefix) to leave a room without prompting.
⁃ Command `ement-forget-room' accepts a `FORCE-P' argument
(interactively, with prefix) to also leave the room, and to forget
it without prompting.
⁃ Option `ement-notify-mark-frame-urgent-predicates' marks the frame
as urgent when (by default) a message mentions the local user or
"@room" and the message's room has an open buffer.
*Changes*
⁃ Minor improvements to date/time headers.
*Fixes*
⁃ Command `ement-describe-room' for rooms without topics.
⁃ Improve insertion of old messages around existing timestamp headers.
⁃ Reduce D-Bus notification system check timeout to 2 seconds (from
the default of 25).
⁃ Compatibility with Emacs 27.
0.2.1
─────
*Fixes*
⁃ Info manual export filename.
0.2
───
*Changes*
⁃ Read receipts are re-enabled. (They're now implemented with a
global idle timer rather than `window-scroll-functions', which
sometimes caused a strange race condition that could cause Emacs to
become unresponsive or crash.)
⁃ When determining whether a room is considered unread, non-message
events like membership changes, reactions, etc. are ignored. This
fixes a bug that caused certain rooms that had no message events
(like some bridged rooms) to appear as unread when they shouldn't
have. But it's unclear whether this is always preferable (e.g. one
might want a member leaving a room to cause it to be marked unread),
so this is classified as a change rather than simply a fix, and more
improvements may be made to this in the future. (Fixes [#97].
Thanks to [Julien Roy] for reporting and testing.)
⁃ The `ement-taxy-room-list' view no longer automatically refreshes
the list if the region is active in the buffer. (This allows the
user to operate on multiple rooms without the contents of the buffer
changing before completing the process.)
*Fixes*
⁃ Links to only rooms (as opposed to links to events in rooms) may be
activated to join them.
⁃ Read receipts mark the last completely visible event (rather than
one that's only partially displayed).
⁃ Prevent error when a room avatar image fails to load.
[#97] <https://github.com/alphapapa/ement.el/issues/97>
[Julien Roy] <https://github.com/MrRoy>
0.1.4
─────
*Fixed*
⁃ Info manual directory headers.
0.1.3
─────
*Fixed*
⁃ Temporarily disable sending of read receipts due to an unusual bug
that could cause Emacs to become unresponsive. (The feature will be
re-enabled in a future release.)
0.1.2
─────
*Fixed*
⁃ Function `ement-room-sync' correctly updates room-list buffers.
(Thanks to [Visuwesh].)
⁃ Only send D-Bus notifications when supported. (Fixes [#83]. Thanks
to [Tassilo Horn].)
[Visuwesh] <https://github.com/vizs>
[#83] <https://github.com/alphapapa/ement.el/issues/83>
[Tassilo Horn] <https://github.com/tsdh>
0.1.1
─────
*Fixed*
⁃ Function `ement-room-scroll-up-mark-read' selects the correct room
window.
⁃ Option `ement-room-list-avatars' defaults to what function
`display-images-p' returns.
0.1
───
After almost two years of development, the first tagged release.
Submitted to GNU ELPA.
4 Development
═════════════
Bug reports, feature requests, suggestions — /oh my/!
Copyright Assignment
────────────────────
Ement.el is published in GNU ELPA and is considered part of GNU Emacs.
Therefore, cumulative contributions of more than 15 lines of code
require that the author assign copyright of such contributions to the
FSF. Authors who are interested in doing so may contact
[assign@gnu.org] to request the appropriate form.
[assign@gnu.org] <mailto:assign@gnu.org>
Matrix spec in Org format
─────────────────────────
An Org-formatted version of the Matrix spec is available in the
[meta/spec] branch.
[meta/spec] <https://github.com/alphapapa/ement.el/tree/meta/spec>
Rationale
─────────
/This section is preserved for posterity. As it says, Ement.el has
long since surpassed `matrix-client', which should no longer be used./
Why write a new Emacs Matrix client when there is already
[matrix-client.el], by the same author, no less? A few reasons:
• `matrix-client' uses an older version of the Matrix spec, r0.3.0,
with a few elements of r0.4.0 grafted in. Bringing it up to date
with the current version of the spec, r0.6.1, would be more work
than to begin with the current version. Ement.el targets r0.6.1
from the beginning.
• `matrix-client' does not use Matrix's lazy-loading feature (which
was added to the specification later), so initial sync requests can
take a long time for the server to process and can be large
(sometimes tens of megabytes of JSON for the client to process!).
Ement.el uses lazy-loading, which significantly improves
performance.
• `matrix-client' automatically makes buffers for every room a user
has joined, even if the user doesn't currently want to watch a room.
Ement.el opens room buffers on-demand, improving performance by not
having to insert events into buffers for rooms the user isn't
watching.
• `matrix-client' was developed without the intention of publishing it
to, e.g. MELPA or ELPA. It has several dependencies, and its code
does not always install or compile cleanly due to macro-expansion
issues (apparently depending on the user's Emacs config). Ement.el
is designed to have minimal dependencies outside of Emacs (currently
only one, `plz', which could be imported into the project), and
every file is linted and compiles cleanly using [makem.sh].
• `matrix-client' uses EIEIO, probably unnecessarily, since few, if
any, of the benefits of EIEIO are realized in it. Ement.el uses
structs instead.
• `matrix-client' uses bespoke code for inserting messages into
buffers, which works pretty well, but has a few minor bugs which are
difficult to track down. Ement.el uses Emacs's built-in (and
perhaps little-known) `ewoc' library, which makes it much simpler
and more reliable to insert and update messages in buffers, and
enables the development of advanced UI features more easily.
• `matrix-client' was, to a certain extent, designed to imitate other
messaging apps. The result is, at least when used with the
`matrix-client-frame' command, fairly pleasing to use, but isn't
especially "Emacsy." Ement.el is intended to better fit into
Emacs's paradigms.
• `matrix-client''s long name makes for long symbol names, which makes
for tedious, verbose code. `ement' is easy to type and makes for
concise, readable code.
• The author has learned much since writing `matrix-client' and hopes
to write simpler, more readable, more maintainable code in Ement.el.
It's hoped that this will enable others to contribute more easily.
Note that, while `matrix-client' remains usable, and probably will for
some time to come, Ement.el has now surpassed it in every way. The
only reason to choose `matrix-client' instead is if one is using an
older version of Emacs that isn't supported by Ement.el.
[matrix-client.el] <https://github.com/alphapapa/matrix-client.el>
[makem.sh] <https://github.com/alphapapa/makem.sh>
5 License
═════════
GPLv3
# * test.yml --- Test Emacs packages using makem.sh on GitHub Actions
# URL: https://github.com/alphapapa/makem.sh
# Version: 0.4.2
# * Commentary:
# Based on Steve Purcell's examples at
# <https://github.com/purcell/setup-emacs/blob/master/.github/workflows/test.yml>,
# <https://github.com/purcell/package-lint/blob/master/.github/workflows/test.yml>.
# * License:
# 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/>.
# * Code:
name: "CI"
on:
pull_request:
push:
# Comment out this section to enable testing of all branches.
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
emacs_version:
- 26.3
- 27.1
- 28.2
- 29.1
- snapshot
steps:
- uses: purcell/setup-emacs@master
with:
version: ${{ matrix.emacs_version }}
- uses: actions/checkout@v2
- name: Install Ispell
run: |
sudo apt-get install ispell
- name: Initialize sandbox
run: |
SANDBOX_DIR=$(mktemp -d) || exit 1
echo "SANDBOX_DIR=$SANDBOX_DIR" >> $GITHUB_ENV
./makem.sh -vv --sandbox=$SANDBOX_DIR --install-deps --install-linters
# The "all" rule is not used, because it treats compilation warnings
# as failures, so linting and testing are run as separate steps.
- name: Lint
# NOTE: Uncomment this line to treat lint failures as passing
# so the job doesn't show failure.
# continue-on-error: true
run: ./makem.sh -vv --sandbox=$SANDBOX_DIR lint
- name: Test
if: always() # Run test even if linting fails.
run: ./makem.sh -vv --sandbox=$SANDBOX_DIR test
# Local Variables:
# eval: (outline-minor-mode)
# End:
blank_issues_enabled: true
name: Bug Report
description: File a bug report
labels: ["bug"]
assignees:
- alphapapa
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: os-platform
attributes:
label: OS/platform
description: What operating system or platform are you running Emacs on?
validations:
required: true
- type: textarea
id: emacs-provenance
attributes:
label: Emacs version and provenance
description: What version of Emacs are you using, where did you acquire it, and how did you install it?
validations:
required: true
- type: input
id: emacs-command
attributes:
label: Emacs command
description: By what method did you run Emacs? (i.e. what command did you run?)
validations:
required: true
- type: input
id: emacs-frame
attributes:
label: Emacs frame type
description: Did the problem happen on a GUI or tty Emacs frame?
validations:
required: true
- type: textarea
id: actions
attributes:
label: Actions taken
description: What actions did you take, step-by-step, in order, before the problem was noticed?
validations:
required: true
- type: textarea
id: results
attributes:
label: Results
description: What behavior did you observe that seemed wrong?
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected results
description: What behavior did you expect to observe?
validations:
required: true
- type: textarea
id: backtrace
attributes:
label: Backtrace
description: If an error was signaled, please use `M-x toggle-debug-on-error RET` and cause the error to happen again, then paste the contents of the `*Backtrace*` buffer here.
render: elisp
- type: textarea
id: etc
attributes:
label: Etc.
description: Any other information that seems relevant
.github/
images/
LICENSE
Makefile
makem.sh
NOTES.org
screenshots/
;;; Directory Local Variables
;;; For more information see (info "(emacs) Directory Variables")
((emacs-lisp-mode . ((fill-column . 90)
(indent-tabs-mode . nil))))
Good signature from 066DAFCB81E42C40 GNU ELPA Signing Agent (2019) <elpasign@elpa.gnu.org> (trust undefined) created at 2020-11-24T05:05:01-0500 using RSA
;;; ascii-art-to-unicode.el --- a small artist adjunct -*- lexical-binding: t -*-
;; Copyright (C) 2014-2020 Free Software Foundation, Inc.
;; Author: Thien-Thi Nguyen <ttn@gnu.org>
;; Maintainer: Thien-Thi Nguyen <ttn@gnu.org>
;; Version: 1.13
;; Keywords: ascii, unicode, box-drawing
;; URL: http://www.gnuvola.org/software/aa2u/
;; 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 <http://www.gnu.org/licenses/>.
;;; Commentary:
;; The command ‘aa2u’ converts simple ASCII art line drawings in
;; the {active,accessible} region of the current buffer to Unicode.
;; Command ‘aa2u-rectangle’ is like ‘aa2u’, but works on rectangles.
;;
;; Example use case:
;; - M-x artist-mode RET
;; - C-c C-a r ; artist-select-op-rectangle
;; - (draw two rectangles)
;;
;; +---------------+
;; | |
;; | +-------+--+
;; | | | |
;; | | | |
;; | | | |
;; +-------+-------+ |
;; | |
;; | |
;; | |
;; +----------+
;;
;; - C-c C-c ; artist-mode-off (optional)
;; - C-x n n ; narrow-to-region
;; - M-x aa2u RET
;;
;; ┌───────────────┐
;; │ │
;; │ ┌───────┼──┐
;; │ │ │ │
;; │ │ │ │
;; │ │ │ │
;; └───────┼───────┘ │
;; │ │
;; │ │
;; │ │
;; └──────────┘
;;
;; Much easier on the eyes now!
;;
;; Normally, lines are drawn with the ‘LIGHT’ weight. If you set var
;; ‘aa2u-uniform-weight’ to symbol ‘HEAVY’, you will see, instead:
;;
;; ┏━━━━━━━━━━━━━━━┓
;; ┃ ┃
;; ┃ ┏━━━━━━━╋━━┓
;; ┃ ┃ ┃ ┃
;; ┃ ┃ ┃ ┃
;; ┃ ┃ ┃ ┃
;; ┗━━━━━━━╋━━━━━━━┛ ┃
;; ┃ ┃
;; ┃ ┃
;; ┃ ┃
;; ┗━━━━━━━━━━┛
;;
;; To protect particular ‘|’, ‘-’ or ‘+’ characters from conversion,
;; you can set the property ‘aa2u-text’ on that text with command
;; ‘aa2u-mark-as-text’. A prefix arg clears the property, instead.
;; (You can use ‘describe-text-properties’ to check.) For example:
;;
;; ┌───────────────────┐
;; │ │
;; │ |\/| │
;; │ ‘Oo’ --Oop Ack! │
;; │ ^&-MM. │
;; │ │
;; └─────────┬─────────┘
;; │
;; """""""""
;;
;; Command ‘aa2u-mark-rectangle-as-text’ is similar, for rectangles.
;;
;; Tip: For best results, you should make sure all the tab characaters
;; are converted to spaces. See: ‘untabify’, ‘indent-tabs-mode’.
;;; Code:
(require 'cl-lib)
(require 'pcase)
(autoload 'apply-on-rectangle "rect")
(defvar aa2u-uniform-weight 'LIGHT
"A symbol, one of: ‘LIGHT’, ‘HEAVY’, ‘DOUBLE’.
This specifies the weight of all the lines.")
;;;---------------------------------------------------------------------------
;;; support
(defalias 'aa2u--lookup-char
;; Keep some slack: don't ‘eval-when-compile’ here.
(if (hash-table-p (ucs-names))
;; Emacs 26 and later
#'gethash
;; prior to Emacs 26
(lambda (string alist)
(cdr (assoc-string string alist)))))
(defsubst aa2u--text-p (pos)
(get-text-property pos 'aa2u-text))
(defun aa2u-ucs-bd-uniform-name (&rest components)
"Return the name of the UCS box-drawing char w/ COMPONENTS.
The string begins with \"BOX DRAWINGS\"; followed by the weight
as per variable ‘aa2u-uniform-weight’, followed by COMPONENTS,
a list of one or two symbols from the set:
VERTICAL
HORIZONTAL
DOWN
UP
RIGHT
LEFT
If of length two, the first element in COMPONENTS should be
the \"Y-axis\" (VERTICAL, DOWN, UP). In that case, the returned
string includes \"AND\" between the elements of COMPONENTS.
Lastly, all words are separated by space (U+20)."
(format "BOX DRAWINGS %s %s"
aa2u-uniform-weight
(mapconcat 'symbol-name components
" AND ")))
(defun aa2u-1c (stringifier &rest components)
"Apply STRINGIFIER to COMPONENTS; return the UCS char w/ this name.
The char is a string (of length one), with two properties:
aa2u-stringifier
aa2u-components
Their values are STRINGIFIER and COMPONENTS, respectively."
(let* ((store (ucs-names))
(key (apply stringifier components))
(s (string (if (hash-table-p store)
;; modern: hash table
(gethash key store)
;; classic: alist
(cdr (assoc-string key store))))))
(propertize s
'aa2u-stringifier stringifier
'aa2u-components components)))
(defun aa2u-phase-1 ()
(cl-flet
((gsr (was name)
(goto-char (point-min))
(let ((now (aa2u-1c 'aa2u-ucs-bd-uniform-name name)))
(while (search-forward was nil t)
(unless (aa2u--text-p (match-beginning 0))
(replace-match now t t))))))
(gsr "|" 'VERTICAL)
(gsr "-" 'HORIZONTAL)))
(defun aa2u-replacement (pos)
(let ((cc (- pos (line-beginning-position))))
(cl-flet*
((ok (name pos)
(when (or
;; Infer LIGHTness between "snug" ‘?+’es.
;; |
;; +-----------++--+ +
;; | somewhere ++--+---+-+----+
;; +-+---------+ nowhere |+--+
;; + +---------++
;; | +---|
(eq ?+ (char-after pos))
;; Require properly directional neighborliness.
(memq (cl-case name
((UP DOWN) 'VERTICAL)
((LEFT RIGHT) 'HORIZONTAL))
(get-text-property pos 'aa2u-components)))
name))
(v (name dir) (let ((bol (line-beginning-position dir))
(eol (line-end-position dir)))
(when (< cc (- eol bol))
(ok name (+ bol cc)))))
(h (name dir) (let ((bol (line-beginning-position))
(eol (line-end-position))
(pos (+ pos dir)))
(unless (or (> bol pos)
(<= eol pos))
(ok name pos))))
(two-p (ls) (= 2 (length ls)))
(just (&rest args) (delq nil args)))
(apply 'aa2u-1c
'aa2u-ucs-bd-uniform-name
(just (pcase (just (v 'UP 0)
(v 'DOWN 2))
((pred two-p) 'VERTICAL)
(`(,vc) vc)
(_ nil))
(pcase (just (h 'LEFT -1)
(h 'RIGHT 1))
((pred two-p) 'HORIZONTAL)
(`(,hc) hc)
(_ nil)))))))
(defun aa2u-phase-2 ()
(goto-char (point-min))
(let (changes)
;; (phase 2.1 -- what WOULD change)
;; This is for the benefit of ‘aa2u-replacement ok’, which
;; otherwise (monolithic phase 2) would need to convert the
;; "properly directional neighborliness" impl from a simple
;; ‘memq’ to an ‘intersction’.
(while (search-forward "+" nil t)
(let ((p (point)))
(unless (aa2u--text-p (1- p))
(push (cons p (or (aa2u-replacement (1- p))
"?"))
changes))))
;; (phase 2.2 -- apply changes)
(dolist (ch changes)
(goto-char (car ch))
(delete-char -1)
(insert (cdr ch)))))
(defun aa2u-phase-3 ()
(remove-text-properties (point-min) (point-max)
(list 'aa2u-stringifier nil
'aa2u-components nil)))
;;;---------------------------------------------------------------------------
;;; commands
;;;###autoload
(defun aa2u (beg end &optional interactive)
"Convert simple ASCII art line drawings to Unicode.
Specifically, perform the following replacements:
- (hyphen) BOX DRAWINGS LIGHT HORIZONTAL
| (vertical bar) BOX DRAWINGS LIGHT VERTICAL
+ (plus) (one of)
BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
BOX DRAWINGS LIGHT DOWN AND RIGHT
BOX DRAWINGS LIGHT DOWN AND LEFT
BOX DRAWINGS LIGHT UP AND RIGHT
BOX DRAWINGS LIGHT UP AND LEFT
BOX DRAWINGS LIGHT VERTICAL AND RIGHT
BOX DRAWINGS LIGHT VERTICAL AND LEFT
BOX DRAWINGS LIGHT UP AND HORIZONTAL
BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
BOX DRAWINGS LIGHT UP
BOX DRAWINGS LIGHT DOWN
BOX DRAWINGS LIGHT LEFT
BOX DRAWINGS LIGHT RIGHT
QUESTION MARK
More precisely, hyphen and vertical bar are substituted unconditionally,
first, and plus is substituted with a character depending on its north,
south, east and west neighbors.
NB: Actually, ‘aa2u’ can also use \"HEAVY\" instead of \"LIGHT\",
depending on the value of variable ‘aa2u-uniform-weight’.
This command operates on either the active region,
or the accessible portion otherwise."
(interactive "r\np")
;; This weirdness, along w/ the undocumented "p" in the ‘interactive’
;; form, is to allow ‘M-x aa2u’ (interactive invocation) w/ no region
;; selected to default to the accessible portion (as documented), which
;; was the norm in ascii-art-to-unicode.el prior to 1.5. A bugfix,
;; essentially. This is ugly, unfortunately -- is there a better way?!
(when (and interactive (not (region-active-p)))
(setq beg (point-min)
end (point-max)))
(save-excursion
(save-restriction
(widen)
(narrow-to-region beg end)
(aa2u-phase-1)
(aa2u-phase-2)
(aa2u-phase-3))))
;;;###autoload
(defun aa2u-rectangle (start end)
"Like ‘aa2u’ on the region-rectangle.
When called from a program the rectangle's corners
are START (top left) and END (bottom right)."
(interactive "r")
(let* ((was (delete-extract-rectangle start end))
(now (with-temp-buffer
(insert-rectangle was)
(aa2u (point) (mark))
(extract-rectangle (point-min) (point-max)))))
(goto-char (min start end))
(insert-rectangle now)))
;;;###autoload
(defun aa2u-mark-as-text (start end &optional unmark)
"Set property ‘aa2u-text’ of the text from START to END.
This prevents ‘aa2u’ from misinterpreting \"|\", \"-\" and \"+\"
in that region as lines and intersections to be replaced.
Prefix arg means to remove property ‘aa2u-text’, instead."
(interactive "r\nP")
(funcall (if unmark
'remove-text-properties
'add-text-properties)
start end
'(aa2u-text t)))
;;;###autoload
(defun aa2u-mark-rectangle-as-text (start end &optional unmark)
"Like ‘aa2u-mark-as-text’ on the region-rectangle.
When called from a program the rectangle's corners
are START (top left) and END (bottom right)."
(interactive "r\nP")
(apply-on-rectangle
(lambda (scol ecol unmark)
(let ((p (point)))
(aa2u-mark-as-text (+ p scol) (+ p ecol) unmark)))
start end
unmark))
;;;---------------------------------------------------------------------------
;;; that's it
;;;; ChangeLog:
;; 2020-11-24 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.13
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; [Version]: Bump to "1.13".
;;
;; 2020-11-24 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u maint] Update years in copyright notice; nfc.
;;
;; 2020-11-24 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Handle modern ‘ucs-names’ being a hash table.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el (aa2u-1c): If
;; the ‘ucs-names’ returns a hash table, use ‘gethash’; otherwise, fall
;; back to classic ‘assoc-string’.
;;
;; 2018-04-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.12
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; [Version]: Bump to "1.12".
;;
;; 2018-04-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Mention ‘DOUBLE’ in ‘aa2u-uniform-weight’ docstring.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u-uniform-weight): ...here.
;;
;; 2018-04-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Fix docstring for ‘aa2u-ucs-bd-uniform-name’.
;;
;; Omission from 2014-05-09, "Make weight dynamically customizable".
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u-ucs-bd-uniform-name): Don't mention WEIGHT in docstring.
;;
;; 2018-04-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u maint] Update years in copyright notice; nfc.
;;
;; 2018-04-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Use U+2018, U+2019 instead of U+60, U+27.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; [Commentary]: ...here.
;; (aa2u-uniform-weight, aa2u-ucs-bd-uniform-name)
;; (aa2u, aa2u-rectangle, aa2u-mark-as-text)
;; (aa2u-mark-rectangle-as-text): Likewise, in docstring.
;;
;; 2017-10-04 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u maint] Add Kaushal Modi to THANKS; nfc.
;;
;; 2017-10-03 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.11
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Version]: Bump
;; to "1.11".
;;
;; 2017-10-03 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u slog] Fix botched bifurcation.
;;
;; Bug introduced 2017-10-03, "Handle ‘ucs-names’ that returns a hash
;; table". Culprit: No testing (sigh).
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u--lookup): Delete alias.
;; (aa2u--lookup-char): New alias.
;; (aa2u-1c): Use ‘aa2u--lookup-char’.
;;
;; 2017-10-03 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.10
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Version]: Bump
;; to "1.10".
;;
;; 2017-10-03 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u slog] Handle ‘ucs-names’ that returns a hash table.
;;
;; Reported by Kaushal Modi (bug#28688):
;; http://lists.gnu.org/archive/html/bug-gnu-emacs/2017-10/threads.html
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u--lookup): New alias.
;; (aa2u-1c): Use ‘aa2u--lookup’.
;;
;; 2017-02-04 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u int] Update years in copyright notice; nfc.
;;
;; 2017-02-04 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; Specify copyright update policy in some HACKING files; nfc.
;;
;; 2017-02-04 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; Add some THANKS files; nfc.
;;
;; 2014-05-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.9
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Version]: Bump
;; to "1.9".
;;
;; 2014-05-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Mention TAB infelicity.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Commentary]:
;; ...here.
;;
;; 2014-05-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Update homepage; drop other links.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [URL]: New
;; header.
;; [Commentary]: Remove the HACKING and Tip Jar links.
;;
;; 2014-05-29 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] New command: aa2u-mark-rectangle-as-text
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el: Arrange to
;; autoload "rect" for ‘apply-on-rectangle’.
;; (aa2u-mark-rectangle-as-text): New command, w/ autoload cookie.
;;
;; 2014-05-24 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u maint] Mention TAB infelicity in HACKING; nfc.
;;
;; 2014-05-21 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.8
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Version]: Bump
;; to "1.8".
;;
;; 2014-05-21 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] New command: aa2u-mark-as-text
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u--text-p): New defsubst.
;; (aa2u-phase-1, aa2u-phase-2): If the character in question is
;; ‘aa2u--text-p’, just ignore it.
;; (aa2u-mark-as-text): New command, w/ autoload cookie.
;;
;; 2014-05-21 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u int] Add abstraction: gsr
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u-phase-1 gsr): New internal func.
;;
;; 2014-05-21 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Declare package keywords.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Keywords]: New
;; header.
;;
;; 2014-05-21 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u maint] Add ‘Maintainer’ header per top-level README; nfc.
;;
;; 2014-05-11 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.7
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Version]: Bump
;; to "1.7".
;;
;; 2014-05-11 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] New command: aa2u-rectangle
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u-rectangle): New command.
;;
;; 2014-05-11 Andreas Schwab <schwab@linux-m68k.org>
;;
;; ascii-art-to-unicode.el (aa2u-replacement): Use cl-case instead of case.
;;
;; 2014-05-09 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; fixup! [aa2u] Make weight dynamically customizable.
;;
;; 2014-05-09 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u maint] Update HACKING; nfc.
;;
;; 2014-05-09 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Make weight dynamically customizable.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u-uniform-weight): New defvar.
;; (aa2u-ucs-bd-uniform-name): Don't take arg WEIGHT; instead, consult
;; ‘aa2u-uniform-weight’.
;; (aa2u-phase-1, aa2u-replacement): Update calls to
;; ‘aa2u-ucs-bd-uniform-name’.
;; (aa2u): Mention new var in docstring.
;;
;; 2014-05-09 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u int] Compute vertical/horizontal components separately.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u-replacement ok): Recognize ‘UP’, ‘DOWN’, ‘LEFT’, ‘RIGHT’ instead
;; of ‘n’, ‘s’, ‘w’, ‘e’.
;; (aa2u-replacement two-p): New internal func.
;; (aa2u-replacement just): Likewise.
;; (aa2u-replacement): Don't glom everything for one ‘pcase’; instead,
;; construct args to ‘aa2u-ucs-bd-uniform-name’ by computing vertical and
;; horizontal components separately.
;;
;; 2014-05-09 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u int] Don't use ‘cl-labels’ when ‘cl-flet*’ will do.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el
;; (aa2u-replacement): ...here.
;;
;; 2014-05-09 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u int] Add "Tip Jar" URL in Commentary; nfc.
;;
;; 2014-05-09 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u maint] Extract NEWS and HACKING to separate files; nfc.
;;
;; 2014-05-08 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.6
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Version]: Bump
;; to "1.6".
;;
;; 2014-05-08 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Fix bug: Make ‘M-x aa2u’ operate on accessible portion.
;;
;; Regression introduced 2014-04-03, "Make ‘aa2u’ region-aware".
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el (aa2u): Take
;; optional arg INTERACTIVE; add "p" to ‘interactive’ form; when
;; INTERACTIVE and region is not active, set BEG, END.
;;
;; 2014-04-03 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Release: 1.5
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el [Version]: Bump
;; to "1.5".
;;
;; 2014-04-03 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; [aa2u] Make ‘aa2u’ region-aware.
;;
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el (aa2u): Take
;; args BEG and END; use "r" in ‘interactive’ spec; don't bother w/
;; internal func ‘do-it!’.
;;
;; 2014-01-14 Thien-Thi Nguyen <ttn@gnu.org>
;;
;; New package: ascii-art-to-unicode
;;
;; * packages/ascii-art-to-unicode/: New dir.
;; * packages/ascii-art-to-unicode/ascii-art-to-unicode.el: New file.
;;
(provide 'ascii-art-to-unicode)
;;; ascii-art-to-unicode.el ends here
;;; Generated package description from ascii-art-to-unicode.el -*- no-byte-compile: t -*-
(define-package "ascii-art-to-unicode" "1.13" "a small artist adjunct" 'nil :keywords '("ascii" "unicode" "box-drawing") :authors '(("Thien-Thi Nguyen" . "ttn@gnu.org")) :maintainer '("Thien-Thi Nguyen" . "ttn@gnu.org") :url "http://www.gnuvola.org/software/aa2u/")
;;; ascii-art-to-unicode-autoloads.el --- automatically extracted autoloads (do not edit) -*- lexical-binding: t -*-
;; Generated by the `loaddefs-generate' function.
;; This file is part of GNU Emacs.
;;; Code:
(add-to-list 'load-path (or (and load-file-name (directory-file-name (file-name-directory load-file-name))) (car load-path)))
;;; Generated autoloads from ascii-art-to-unicode.el
(autoload 'aa2u "ascii-art-to-unicode" "\
Convert simple ASCII art line drawings to Unicode.
Specifically, perform the following replacements:
- (hyphen) BOX DRAWINGS LIGHT HORIZONTAL
| (vertical bar) BOX DRAWINGS LIGHT VERTICAL
+ (plus) (one of)
BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
BOX DRAWINGS LIGHT DOWN AND RIGHT
BOX DRAWINGS LIGHT DOWN AND LEFT
BOX DRAWINGS LIGHT UP AND RIGHT
BOX DRAWINGS LIGHT UP AND LEFT
BOX DRAWINGS LIGHT VERTICAL AND RIGHT
BOX DRAWINGS LIGHT VERTICAL AND LEFT
BOX DRAWINGS LIGHT UP AND HORIZONTAL
BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
BOX DRAWINGS LIGHT UP
BOX DRAWINGS LIGHT DOWN
BOX DRAWINGS LIGHT LEFT
BOX DRAWINGS LIGHT RIGHT
QUESTION MARK
More precisely, hyphen and vertical bar are substituted unconditionally,
first, and plus is substituted with a character depending on its north,
south, east and west neighbors.
NB: Actually, ‘aa2u’ can also use \"HEAVY\" instead of \"LIGHT\",
depending on the value of variable ‘aa2u-uniform-weight’.
This command operates on either the active region,
or the accessible portion otherwise.
(fn BEG END &optional INTERACTIVE)" t)
(autoload 'aa2u-rectangle "ascii-art-to-unicode" "\
Like ‘aa2u’ on the region-rectangle.
When called from a program the rectangle's corners
are START (top left) and END (bottom right).
(fn START END)" t)
(autoload 'aa2u-mark-as-text "ascii-art-to-unicode" "\
Set property ‘aa2u-text’ of the text from START to END.
This prevents ‘aa2u’ from misinterpreting \"|\", \"-\" and \"+\"
in that region as lines and intersections to be replaced.
Prefix arg means to remove property ‘aa2u-text’, instead.
(fn START END &optional UNMARK)" t)
(autoload 'aa2u-mark-rectangle-as-text "ascii-art-to-unicode" "\
Like ‘aa2u-mark-as-text’ on the region-rectangle.
When called from a program the rectangle's corners
are START (top left) and END (bottom right).
(fn START END &optional UNMARK)" t)
(register-definition-prefixes "ascii-art-to-unicode" '("aa2u-"))
;;; End of scraped data
(provide 'ascii-art-to-unicode-autoloads)
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; no-native-compile: t
;; coding: utf-8-emacs-unix
;; End:
;;; ascii-art-to-unicode-autoloads.el ends here