aboutsummaryrefslogtreecommitdiff
path: root/lisp/mastodon-toot.el
diff options
context:
space:
mode:
authormousebot <mousebot@riseup.net>2021-12-23 20:24:59 +0100
committermousebot <mousebot@riseup.net>2021-12-23 20:24:59 +0100
commit6c19decad2bdb86d55c96409cd0c96e1c8dd1a32 (patch)
tree59f4191d590d3713c73ac6b2e8a6197097bfbc5a /lisp/mastodon-toot.el
parent0cffc91cfd362190eac9580983cda74248a2d3a0 (diff)
parentab37e43c60edf5f0d591441e8cece61a27dd2a6d (diff)
Merge branch 'main'
Diffstat (limited to 'lisp/mastodon-toot.el')
-rw-r--r--lisp/mastodon-toot.el688
1 files changed, 586 insertions, 102 deletions
diff --git a/lisp/mastodon-toot.el b/lisp/mastodon-toot.el
index e339c4d..ec1ba49 100644
--- a/lisp/mastodon-toot.el
+++ b/lisp/mastodon-toot.el
@@ -2,9 +2,10 @@
;; Copyright (C) 2017-2019 Johnson Denen
;; Author: Johnson Denen <johnson.denen@gmail.com>
-;; Version: 0.9.0
-;; Homepage: https://github.com/jdenen/mastodon.el
-;; Package-Requires: ((emacs "24.4"))
+;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
+;; Version: 0.10.0
+;; Package-Requires: ((emacs "27.1"))
+;; Homepage: https://git.blast.noho.st/mouse/mastodon.el
;; This file is not part of GNU Emacs.
@@ -29,19 +30,43 @@
;;; Code:
-(defvar mastodon-instance-url)
+(when (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)
+
+(when (require 'company nil :noerror)
+ (declare-function company-mode-on "company")
+ (declare-function company-begin-backend "company")
+ (declare-function company-grab-symbol "company")
+ (defvar company-backends))
+
+(defvar mastodon-instance-url)
+(defvar mastodon-tl--buffer-spec)
(autoload 'mastodon-auth--user-acct "mastodon-auth")
(autoload 'mastodon-http--api "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--read-file-as-string "mastodon-http")
(autoload 'mastodon-http--triage "mastodon-http")
+(autoload 'mastodon-search--search-accounts-query "mastodon-search")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--clean-tabs-and-nl "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-toot "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
-(autoload 'mastodon-tl--find-property-range "mastodon-tl")
+(autoload 'mastodon-tl--reload-timeline-or-profile "mastodon-tl")
+(autoload 'mastodon-tl--toot-id "mastodon-tl")
(autoload 'mastodon-toot "mastodon")
(defgroup mastodon-toot nil
@@ -52,41 +77,95 @@
(defcustom mastodon-toot--default-visibility "public"
"The default visibility for new toots.
-Must be one of \"public\", \"unlisted\", \"private\", or \"direct\"."
+Must be one of \"public\", \"unlisted\", \"private\" (for
+followers-only), or \"direct\"."
+ :group 'mastodon-toot
+ :type '(choice
+ (const :tag "public" "public")
+ (const :tag "unlisted" "unlisted")
+ (const :tag "followers only" "private")
+ (const :tag "direct" "direct")))
+
+(defcustom mastodon-toot--default-media-directory "~/"
+ "The default directory when prompting for a media file to upload."
+ :group 'mastodon-toot
+ :type 'string)
+
+(defcustom mastodon-toot--attachment-height 80
+ "Height of the attached images preview in the toot draft buffer."
+ :group 'mastodon-toot
+ :type 'integer)
+
+(defcustom mastodon-toot--enable-completion-for-mentions (if (require 'company nil :noerror) "following" "off")
+ "Whether to enable company completion for mentions.
+
+Used for completion in toot compose buffer.
+
+This is only used if company mode is installed."
+ :group 'mastodon-toot
+ :type '(choice
+ (const :tag "off" nil)
+ (const :tag "following only" "following")
+ (const :tag "all users" "all")))
+
+(defcustom mastodon-toot--enable-custom-instance-emoji nil
+ "Whether to enable your instance's custom emoji by default."
:group 'mastodon-toot
- :type '(choice ("public"
- "unlisted"
- "private"
- "direct")))
+ :type 'boolean)
-(defvar mastodon-toot--content-warning nil
+(defvar-local mastodon-toot--content-warning nil
"A flag whether the toot should be marked with a content warning.")
-(make-variable-buffer-local 'mastodon-toot--content-warning)
-(defvar mastodon-toot--content-nsfw nil
+(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.")
-(make-variable-buffer-local 'mastodon-toot--content-nsfw)
-(defvar mastodon-toot--visibility "public"
+(defvar-local mastodon-toot--visibility "public"
"A string indicating the visibility of the toot being composed.
-Valid values are \"direct\", \"private\", \"unlisted\", and \"public\".")
-(make-variable-buffer-local 'mastodon-toot--visibility)
+Valid values are \"direct\", \"private\" (followers-only), \"unlisted\", and \"public\".")
-(defvar mastodon-toot--reply-to-id nil
+(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--reply-to-id nil
"Buffer-local variable to hold the id of the toot being replied to.")
-(make-variable-buffer-local 'mastodon-toot--reply-to-id)
+
+(defvar mastodon-toot--max-toot-chars nil
+ "The maximum allowed characters count for a single toot.")
(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-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)
map)
"Keymap for `mastodon-toot'.")
+(defun mastodon-toot--get-max-toot-chars ()
+ "Fetch max_toot_chars from `mastodon-instance-url' asynchronously."
+ (mastodon-http--get-json-async
+ (mastodon-http--api "instance") 'mastodon-toot--get-max-toot-chars-callback))
+
+(defun mastodon-toot--get-max-toot-chars-callback (json-response)
+ "Set max_toot_chars returned in JSON-RESPONSE and display in new toot buffer."
+ (setq mastodon-toot--max-toot-chars
+ (number-to-string
+ (alist-get 'max_toot_chars json-response)))
+ (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.
@@ -109,12 +188,13 @@ Remove MARKER if REMOVE is non-nil, otherwise add it."
(propertize marker 'face 'success)))))))
(defun mastodon-toot--action (action callback)
- "Take ACTION on toot at point, then execute CALLBACK."
+ "Take ACTION on toot at point, then execute CALLBACK.
+Makes a POST request to the server."
(let* ((id (mastodon-tl--property 'base-toot-id))
(url (mastodon-http--api (concat "statuses/"
- (mastodon-tl--as-string id)
- "/"
- action))))
+ (mastodon-tl--as-string id)
+ "/"
+ action))))
(let ((response (mastodon-http--post url nil nil)))
(mastodon-http--triage response callback))))
@@ -169,6 +249,112 @@ Remove MARKER if REMOVE is non-nil, otherwise add it."
(message (format "%s #%s" action id))))
(message "Nothing to favorite here?!?"))))
+(defun mastodon-toot--copy-toot-url ()
+ "Copy URL of toot at point."
+ (interactive)
+ (let* ((toot (mastodon-tl--property 'toot-json))
+ (url (if (mastodon-tl--field 'reblog toot)
+ (alist-get 'url (alist-get 'reblog toot))
+ (alist-get 'url toot))))
+ (kill-new url)
+ (message "Toot URL copied to the clipboard.")))
+
+(defun mastodon-toot--own-toot-p (toot)
+ "Check if TOOT is user's own, e.g. for deleting it."
+ (and (not (alist-get 'reblog toot))
+ (equal (alist-get 'acct (alist-get 'account toot))
+ (mastodon-auth--user-acct))))
+
+(defun mastodon-toot--pin-toot-toggle ()
+ "Pin or unpin user's toot at point."
+ (interactive)
+ (let* ((toot (mastodon-tl--property 'toot-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.")
+ (if (y-or-n-p (format "%s this toot? " msg-y-or-n))
+ (mastodon-toot--action action
+ (lambda ()
+ (message "Toot %s!" msg)))))))
+
+(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 (mastodon-tl--property 'toot-json))
+ (id (mastodon-tl--as-string (mastodon-tl--toot-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)))
+ (if (not (mastodon-toot--own-toot-p toot))
+ (message "You can only delete (and redraft) your own toots.")
+ (if (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))
+ (message "Toot deleted!"))
+ (mastodon-toot--redraft response
+ reply-id
+ toot-visibility
+ toot-cw)))))))))
+
+(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 nil nil)
+ (goto-char (point-max))
+ (insert content)
+ ;; adopt reply-to-id, visibility and CW from deleted toot:
+ (when reply-id
+ (setq mastodon-toot--reply-to-id reply-id))
+ (setq mastodon-toot--visibility toot-visibility)
+ (when (not (equal toot-cw ""))
+ (setq mastodon-toot--content-warning t)
+ (setq mastodon-toot--content-warning-from-reply-or-redraft toot-cw))
+ (mastodon-toot--update-status-fields))))
+
+(defun mastodon-toot--bookmark-toot-toggle ()
+ "Bookmark or unbookmark toot at point synchronously."
+ (interactive)
+ (let* ((toot (mastodon-tl--property 'toot-json))
+ (id (mastodon-tl--as-string (mastodon-tl--toot-id toot)))
+ (bookmarked (alist-get 'bookmarked toot))
+ (url (mastodon-http--api (if (equal bookmarked t)
+ (format "statuses/%s/unbookmark" id)
+ (format "statuses/%s/bookmark" id))))
+ (prompt (if (equal bookmarked t)
+ (format "Toot already bookmarked. Remove? ")
+ (format "Bookmark this toot? ")))
+ (message (if (equal bookmarked t)
+ "Bookmark removed!"
+ "Toot bookmarked!")))
+ (when (y-or-n-p prompt)
+ (let ((response (mastodon-http--post url nil nil)))
+ (mastodon-http--triage response
+ (lambda ()
+ (message message)))))))
+
(defun mastodon-toot--kill ()
"Kill `mastodon-toot-mode' buffer and window."
(kill-buffer-and-window))
@@ -176,7 +362,86 @@ Remove MARKER if REMOVE is non-nil, otherwise add it."
(defun mastodon-toot--cancel ()
"Kill new-toot buffer/window. Does not POST content to Mastodon."
(interactive)
- (mastodon-toot--kill))
+ (let* ((toot (mastodon-toot--remove-docs))
+ (empty-toot-p (and (not mastodon-toot--media-attachments)
+ (string= "" (mastodon-tl--clean-tabs-and-nl toot)))))
+ (if empty-toot-p
+ (mastodon-toot--kill)
+ (when (y-or-n-p "Discard draft toot? ")
+ (mastodon-toot--kill)))))
+
+(defalias 'mastodon-toot--insert-emoji
+ 'emojify-insert-emoji
+ "Prompt to insert an emoji.")
+
+(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 ((custom-emoji (mastodon-http--get-json
+ (mastodon-http--api "custom_emojis")))
+ (mastodon-custom-emoji-dir (file-name-as-directory
+ (concat (file-name-as-directory
+ (expand-file-name
+ emojify-emojis-dir))
+ "mastodon-custom-emojis"))))
+ (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)
+ (url-copy-file (alist-get 'url x)
+ (concat
+ mastodon-custom-emoji-dir
+ (alist-get 'shortcode x)
+ "."
+ (file-name-extension (alist-get 'url x)))
+ 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 (concat (expand-file-name
+ emojify-emojis-dir)
+ "/mastodon-custom-emojis/"))
+ (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 (concat (expand-file-name
+ emojify-emojis-dir)
+ "/mastodon-custom-emojis/"))
+ (when (y-or-n-p "Looks like you haven't downloaded your instance's custom emoji yet. Download now? ")
+ (mastodon-toot--download-custom-emoji)))
+ (setq emojify-user-emojis
+ (append (mastodon-toot--collect-custom-emoji)
+ emojify-user-emojis))
+ ;; if already loaded, reload
+ (when (featurep 'emojify)
+ (emojify-set-emoji-data)))
+
(defun mastodon-toot--remove-docs ()
"Get the body of a toot from the current compose buffer."
@@ -185,7 +450,7 @@ Remove MARKER if REMOVE is non-nil, otherwise add it."
(buffer-substring (cdr header-region) (point-max))))
(defun mastodon-toot--set-visibility (visibility)
- "Sets the visiblity of the next toot"
+ "Set the visiblity of the next toot to VISIBILITY."
(interactive
(list (completing-read "Visiblity: " '("public"
"unlisted"
@@ -195,36 +460,54 @@ Remove MARKER if REMOVE is non-nil, otherwise add it."
(message "Visibility set to %s" visibility))
(defun mastodon-toot--send ()
- "Kill new-toot buffer/window and POST contents to the Mastodon instance."
+ "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."
(interactive)
(let* ((toot (mastodon-toot--remove-docs))
- (empty-toot-p (string= "" (mastodon-tl--clean-tabs-and-nl toot)))
+ (empty-toot-p (and (not mastodon-toot--media-attachments)
+ (string= "" (mastodon-tl--clean-tabs-and-nl toot))))
(endpoint (mastodon-http--api "statuses"))
(spoiler (when (and (not empty-toot-p)
mastodon-toot--content-warning)
- (read-string "Warning: ")))
- (args `(("status" . ,toot)
- ("in_reply_to_id" . ,mastodon-toot--reply-to-id)
- ("visibility" . ,mastodon-toot--visibility)
- ("sensitive" . ,(when mastodon-toot--content-nsfw
- (symbol-name t)))
- ("visibility" . ,mastodon-toot--visibility)
- ("spoiler_text" . ,spoiler))))
- (if empty-toot-p
- (message "Empty toot. Cowardly refusing to post this.")
- (mastodon-toot--kill)
- (let ((response (mastodon-http--post endpoint args nil)))
- (mastodon-http--triage response
- (lambda () (message "Toot toot!")))))))
+ (read-string "Warning: " mastodon-toot--content-warning-from-reply-or-redraft)))
+ (args-no-media `(("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" . ,spoiler)))
+ (args-media (when mastodon-toot--media-attachments
+ (mapcar (lambda (id)
+ (cons "media_ids[]" id))
+ mastodon-toot--media-attachment-ids)))
+ (args (append args-media args-no-media)))
+ (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."))
+ ((> (length toot) (string-to-number mastodon-toot--max-toot-chars))
+ (message "Looks like your toot is longer than that maximum allowed length."))
+ (empty-toot-p
+ (message "Empty toot. Cowardly refusing to post this."))
+ (t
+ (let ((response (mastodon-http--post endpoint args nil)))
+ (mastodon-http--triage response
+ (lambda ()
+ (mastodon-toot--kill)
+ (message "Toot toot!"))))))))
(defun mastodon-toot--process-local (acct)
- "Adds domain to local ACCT and replaces the curent user name with \"\".
+ "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\" "
+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
@@ -233,24 +516,89 @@ eg. \"feduser@fed.social\" -> \"feduser@fed.social\" "
(defun mastodon-toot--mentions (status)
"Extract mentions from STATUS and process them into a string."
(interactive)
- (let ((mentions (cdr (assoc 'mentions status))))
+ (let* ((boosted (mastodon-tl--field 'reblog status))
+ (mentions
+ (if boosted
+ (alist-get 'mentions (alist-get 'reblog status))
+ (alist-get 'mentions status))))
(mapconcat (lambda(x) (mastodon-toot--process-local
- (cdr (assoc 'acct x))))
+ (alist-get 'acct x)))
;; reverse does not work on vectors in 24.5
(reverse (append mentions nil))
"")))
+(defun mastodon-toot--mentions-company-meta (candidate)
+ "Format company completion CANDIDATE's meta field."
+ (format " %s"
+ (get-text-property 0 'meta candidate)))
+
+(defun mastodon-toot--mentions-company-annotation (candidate)
+ "Format company completion CANDIDATE's annotation."
+ (format " %s" (get-text-property 0 'annot candidate)))
+
+(defun mastodon-toot--mentions-company-candidates (prefix)
+ "Given a company PREFIX query, build a list of candidates.
+The prefix can match against both user handles and display names."
+ (let ((prefix (substring prefix 1)) ;remove @ for search
+ (res))
+ (dolist (item (mastodon-search--search-accounts-query prefix))
+ (when (or (string-prefix-p prefix (substring (cadr item) 1) t)
+ (string-prefix-p prefix (car item) t))
+ (push (mastodon-toot--mentions-company-make-candidate item) res)))
+ res))
+
+(defun mastodon-toot--mentions-company-make-candidate (candidate)
+ "Construct a company completion CANDIDATE for display."
+ (let ((display-name (car candidate))
+ (handle (cadr candidate))
+ (url (caddr candidate)))
+ (propertize handle 'annot display-name 'meta url)))
+
+(defun mastodon-toot-mentions (command &optional arg &rest ignored)
+ "A company completion backend for toot mentions.
+COMMAND is either prefix, to fetch a prefix query, candidates, to
+build a list of candidates with query ARG, annotation, to format
+an annotation for candidate ARG, or meta, to format meta info for
+candidate ARG. IGNORED remains a mystery."
+ (interactive (list 'interactive))
+ (cl-case command
+ (interactive (company-begin-backend 'mastodon-toot-mentions))
+ (prefix (when (and (bound-and-true-p mastodon-toot-mode) ; if masto toot minor mode
+ (save-excursion
+ (forward-whitespace -1)
+ (forward-whitespace 1)
+ (looking-at "@")))
+ ;; @ + thing before point
+ (concat "@" (company-grab-symbol))))
+ (candidates (mastodon-toot--mentions-company-candidates arg))
+ (annotation (mastodon-toot--mentions-company-annotation arg))
+ (meta (mastodon-toot--mentions-company-meta arg))))
+
(defun mastodon-toot--reply ()
"Reply to toot at `point'."
(interactive)
(let* ((toot (mastodon-tl--property 'toot-json))
(id (mastodon-tl--as-string (mastodon-tl--field 'id toot)))
(account (mastodon-tl--field 'account toot))
- (user (cdr (assoc 'acct account)))
- (mentions (mastodon-toot--mentions toot)))
- (mastodon-toot (when user (concat (mastodon-toot--process-local user)
- mentions))
- id)))
+ (user (alist-get 'acct account))
+ (mentions (mastodon-toot--mentions toot))
+ (boosted (mastodon-tl--field 'reblog toot))
+ (booster (when boosted
+ (alist-get 'acct
+ (alist-get 'account toot)))))
+ (mastodon-toot (when user
+ (if booster
+ (if (and
+ (not (equal user booster))
+ (not (string-match booster mentions)))
+ (concat (mastodon-toot--process-local user)
+ ;; "@" booster " "
+ (mastodon-toot--process-local booster) mentions)
+ (concat (mastodon-toot--process-local user)
+ mentions))
+ (concat (mastodon-toot--process-local user)
+ mentions)))
+ id toot)))
(defun mastodon-toot--toggle-warning ()
"Toggle `mastodon-toot--content-warning'."
@@ -261,10 +609,10 @@ eg. \"feduser@fed.social\" -> \"feduser@fed.social\" "
(defun mastodon-toot--toggle-nsfw ()
"Toggle `mastodon-toot--content-nsfw'."
- ;; This only makes sense once we have attachments.
(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 ()
@@ -281,6 +629,79 @@ eg. \"feduser@fed.social\" -> \"feduser@fed.social\" "
"public")))
(mastodon-toot--update-status-fields))
+(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 content-type description)
+ "Prompt for an attachment FILE of CONTENT-TYPE 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: \nsContent type: \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))
+ (:content-type . ,content-type)
+ (: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..." (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))
+ (type (alist-get :content-type attachment))
+ (description (alist-get :description attachment)))
+ (setq counter (1+ counter))
+ (list (format "\n %d: " counter)
+ image
+ (format " \"%s\" (%s)" description type))))
+ mastodon-toot--media-attachments))
+ (list "None")))
+
;; we'll need to revisit this if the binds get
;; more diverse than two-chord bindings
(defun mastodon-toot--get-mode-kbinds ()
@@ -308,24 +729,55 @@ e.g. mastodon-toot--send -> Send."
"Format a single keybinding, KBIND, for display in documentation."
(let ((key (help-key-description (car kbind) nil))
(command (mastodon-toot--format-kbind-command (cdr kbind))))
- (format "\t%s - %s" key command)))
+ (format " %s - %s" key command)))
(defun mastodon-toot--format-kbinds (kbinds)
- "Format a list keybindings, KBINDS, for display in documentation."
- (mapconcat 'identity (cons "" (mapcar #'mastodon-toot--format-kbind kbinds))
- "\n"))
+ "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 (lambda (x)
+ (length x))
+ kbinds-list)))
+ (car (sort lengths #'>))))
(defun mastodon-toot--make-mode-docs ()
"Create formatted documentation text for the mastodon-toot-mode."
- (let ((kbinds (mastodon-toot--get-mode-kbinds)))
+ (let* ((kbinds (mastodon-toot--get-mode-kbinds))
+ (longest-kbind
+ (mastodon-toot--formatted-kbinds-longest
+ (mastodon-toot--format-kbinds kbinds))))
(concat
" Compose a new toot here. The following keybindings are available:"
- (mastodon-toot--format-kbinds kbinds))))
+ ;; (mastodon-toot--format-kbinds kbinds))))
+ (mapconcat 'identity
+ (mastodon-toot--formatted-kbinds-pairs
+ (mastodon-toot--format-kbinds kbinds)
+ longest-kbind)
+ nil))))
(defun mastodon-toot--display-docs-and-status-fields ()
- "Insert propertized text with documentation about mastodon-toot mode and the
-status fields which will get updated based on the status of NSFW, content
-warning flags etc."
+ "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."
(let ((divider
"|=================================================================|"))
(insert
@@ -333,6 +785,8 @@ warning flags etc."
(concat
divider "\n"
(mastodon-toot--make-mode-docs) "\n"
+ ;; divider "\n"
+ ;; "\n"
divider "\n"
" "
(propertize "Count"
@@ -343,9 +797,12 @@ warning flags etc."
" ⋅ "
(propertize "CW"
'toot-post-cw-flag t)
- ;; " "
- ;; (propertize "NSFW"
- ;; 'toot-post-nsfw-flag t)
+ " "
+ (propertize "NSFW"
+ 'toot-post-nsfw-flag t)
+ "\n"
+ " Attachments: "
+ (propertize "None " 'toot-attachments t)
"\n"
divider
(propertize "\n"
@@ -354,57 +811,84 @@ warning flags etc."
'read-only "Edit your message below."
'toot-post-header t))))
-(defun mastodon-toot--setup-as-reply (reply-to-user reply-to-id)
+(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 the MASTODON-TOOT--REPLY-TO-ID var."
- (when reply-to-user
- (insert (format "%s " reply-to-user))
- (setq mastodon-toot--reply-to-id reply-to-id)))
-
-(defun mastodon-toot--update-status-fields (&rest args)
+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 (alist-get 'visibility reply-json))
+ (reply-cw (alist-get 'spoiler_text reply-json)))
+ (when reply-to-user
+ (insert (format "%s " reply-to-user))
+ (setq mastodon-toot--reply-to-id reply-to-id)
+ (if (not (equal mastodon-toot--visibility
+ reply-visibility))
+ (setq mastodon-toot--visibility reply-visibility))
+ (when (not (equal reply-cw ""))
+ (setq mastodon-toot--content-warning t)
+ (setq mastodon-toot--content-warning-from-reply-or-redraft reply-cw)))))
+
+(defun mastodon-toot--update-status-fields (&rest _args)
"Update the status fields in the header based on the current state."
- (let ((inhibit-read-only t)
- (header-region (mastodon-tl--find-property-range 'toot-post-header
+ (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)))
- (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)))
- )
- (add-text-properties (car count-region) (cdr count-region)
- (list 'display
- (format "%s characters in message"
- (- (point-max) (cdr header-region)))))
- (add-text-properties (car visibility-region) (cdr visibility-region)
- (list 'display
- (format "Visibility: %s"
- mastodon-toot--visibility)))
- ;; (add-text-properties (car nsfw-region) (cdr nsfw-region)
- ;; (list 'invisible (not mastodon-toot--content-nsfw)
- ;; '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--compose-buffer (reply-to-user reply-to-id)
+ (cw-region (mastodon-tl--find-property-range 'toot-post-cw-flag
+ (point-min))))
+ (add-text-properties (car count-region) (cdr count-region)
+ (list 'display
+ (format "%s/%s characters"
+ (- (point-max) (cdr header-region))
+ mastodon-toot--max-toot-chars)))
+ (add-text-properties (car visibility-region) (cdr visibility-region)
+ (list 'display
+ (format "Visibility: %s"
+ (if (equal
+ mastodon-toot--visibility
+ "private")
+ "followers-only"
+ mastodon-toot--visibility))))
+ (add-text-properties (car nsfw-region) (cdr nsfw-region)
+ (list 'display (if mastodon-toot--content-nsfw
+ (if mastodon-toot--media-attachments
+ "NSFW" "NSFW (no effect until attachments added)")
+ "")
+ '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--compose-buffer (reply-to-user reply-to-id &optional reply-json)
"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."
+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."
(let* ((buffer-exists (get-buffer "*new toot*"))
(buffer (or buffer-exists (get-buffer-create "*new toot*")))
(inhibit-read-only t))
(switch-to-buffer-other-window buffer)
+ (mastodon-toot-mode t)
(when (not buffer-exists)
(mastodon-toot--display-docs-and-status-fields)
- (mastodon-toot--setup-as-reply reply-to-user reply-to-id))
+ (mastodon-toot--setup-as-reply reply-to-user reply-to-id reply-json))
+ (mastodon-toot-mode t)
+ (unless mastodon-toot--max-toot-chars
+ (mastodon-toot--get-max-toot-chars))
+ (when (require 'company nil :noerror)
+ (when mastodon-toot--enable-completion-for-mentions
+ (set (make-local-variable 'company-backends)
+ (add-to-list 'company-backends 'mastodon-toot-mentions))
+ (company-mode-on)))
(make-local-variable 'after-change-functions)
(push #'mastodon-toot--update-status-fields after-change-functions)
- (mastodon-toot--update-status-fields)
- (mastodon-toot-mode t)))
+ (mastodon-toot--refresh-attachments-display)
+ (mastodon-toot--update-status-fields)))
(define-minor-mode mastodon-toot-mode
"Minor mode to capture Mastodon toots."