branch: elpa/fedi
commit 911f68bdb198ba35cb6a81ae93a9340bdbfe7f42
Author: marty hiatt <martianhiatus [a t] riseup [d o t] net>
Commit: marty hiatt <martianhiatus [a t] riseup [d o t] net>
add fedi-post.el
---
fedi-post.el | 777 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 777 insertions(+)
diff --git a/fedi-post.el b/fedi-post.el
new file mode 100644
index 00000000000..b7e2fe4ed28
--- /dev/null
+++ b/fedi-post.el
@@ -0,0 +1,777 @@
+;;; fedi-post.el --- Minor mode for posting to fediverse services -*-
lexical-binding: t -*-
+
+;; Copyright (C) 2020-2022 Marty Hiatt
+;; Author: Marty Hiatt <[email protected]> and mastodon.el authors
+;; Package-Requires: ((emacs "28.1"))
+;; Version: 1.0.0
+;; Homepage: https://codeberg.org/martianh/fedi.el
+
+;; This file is not part of GNU Emacs.
+
+;; This file is part of fedi.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:
+
+;; fedi-post.el supports POSTing status data to fediverse services.
+
+;;; 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-toot-strict "mastodon-tl")
+;; (autoload 'mastodon-tl--field "mastodon-tl")
+(autoload 'fedi--find-property-range "fedi")
+(autoload 'fedi--find-property-range "fedi")
+;; (autoload 'mastodon-tl--goto-next-toot "mastodon-tl")
+;; (autoload 'mastodon-tl--map-alist "mastodon-tl")
+;; (autoload 'fedi--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--toot-id "mastodon-tl")
+;; (autoload 'fedi-post "mastodon")
+;; (autoload 'mastodon-views--cancel-scheduled-toot "mastodon-views")
+;; (autoload 'mastodon-views--view-scheduled-toots "mastodon-views")
+;; (autoload 'org-read-date "org")
+
+(defface fedi-post-docs-face
+ `((t :inherit font-lock-comment-face))
+ "Face used for documentation in post compose buffer.
+If `mastodon-tl--enable-proportional-fonts' is changed,
+mastodon.el needs to be re-loaded for this to be correctly set.")
+
+(defgroup fedi-post nil
+ "Posting in Mastodon."
+ :prefix "fedi-post-"
+ :group 'mastodon)
+
+(defcustom fedi-post--enable-completion t
+ "Whether to enable completion of mentions and hashtags.
+Used for completion in post compose buffer."
+ :type 'boolean)
+
+(defcustom fedi-post--use-company-for-completion nil
+ "Whether to enable company for completion.
+When non-nil, `company-mode' is enabled in the post compose
+buffer, and mastodon completion backends are added to
+`company-capf'.
+
+You need to install company yourself to use this."
+ :type 'boolean)
+
+(defvar-local fedi-post-content-nsfw nil
+ "A flag indicating whether the post should be marked as NSFW.")
+
+(defvar-local fedi-post-language nil
+ "The language of the post being composed, in ISO 639 (two-letter).")
+
+(defvar-local fedi-post--reply-to-id nil
+ "Buffer-local variable to hold the id of the post being replied to.")
+
+(defvar-local fedi-post--edit-post-id nil
+ "The id of the post being edited.")
+
+(defvar-local fedi-post-previous-window-config nil
+ "A list of window configuration prior to composing a post.
+Takes its form from `window-configuration-to-register'.")
+
+(defvar fedi-post--max-chars nil
+ "The maximum allowed characters count for a single post.")
+
+(defvar-local fedi-post-completions nil
+ "The data of completion candidates for the current completion at point.")
+
+(defvar fedi-post-current-post-text nil
+ "The text of the post being composed.")
+
+(persist-defvar fedi-post-draft-posts-list nil
+ "A list of posts that have been saved as drafts.
+For the moment we just put all composed posts in here, as we want
+to also capture posts that are 'sent' but that don't successfully
+send.")
+
+
+;;; REGEXES
+
+(defvar fedi-post-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 fedi-post-tag-regex
+ (rx (| (any ?\( "\n" "\t" " ") bol)
+ (group-n 2 ?# (+ (any "A-Z" "a-z" "0-9")))
+ (| "'" word-boundary))) ; boundary or possessive
+
+(defvar fedi-post-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
+
+
+;;; MODE MAP
+
+(defvar fedi-post-mode-map
+ (let ((map (make-sparse-keymap)))
+ ;; (define-key map (kbd "C-c C-c") #'fedi-post-send)
+ (define-key map (kbd "C-c C-k") #'fedi-post-cancel)
+ (define-key map (kbd "C-c C-n") #'fedi-post-toggle-nsfw)
+ (when (require 'emojify nil :noerror)
+ (define-key map (kbd "C-c C-e") #'fedi-post-insert-emoji))
+ (define-key map (kbd "C-c C-l") #'fedi-post-set-post-language)
+ map)
+ "Keymap for `fedi-post'.")
+
+(defun fedi-post-kill (&optional cancel)
+ "Kill `fedi-post-mode' buffer and window.
+CANCEL means the post was not sent, so we save the post text as a draft."
+ (let ((prev-window-config fedi-post-previous-window-config))
+ (unless (eq fedi-post-current-post-text nil)
+ (when cancel
+ (cl-pushnew fedi-post-current-post-text
+ fedi-post-draft-posts-list :test 'equal)))
+ ;; prevent some weird bug when cancelling a non-empty post:
+ ;; (delete #'fedi-post--save-post-text after-change-functions)
+ (kill-buffer-and-window)
+ (fedi-post--restore-previous-window-config prev-window-config)))
+
+(defun fedi-post-cancel ()
+ "Kill new-post buffer/window. Does not POST content to Mastodon.
+If post is not empty, prompt to save text as a draft."
+ (interactive)
+ (if (fedi-post--empty-p)
+ (fedi-post-kill)
+ (when (y-or-n-p "Save draft post?")
+ (fedi-post--save-draft))
+ (fedi-post-kill)))
+
+(defun fedi-post--empty-p (&optional text-only)
+ "Return t if post has no text, attachments, or polls.
+TEXT-ONLY means don't check for attachments or polls."
+ (string-empty-p (mastodon-tl--clean-tabs-and-nl
+ (fedi-post--remove-docs))))
+
+
+(defun fedi-post--remove-docs ()
+ "Get the body of a post from the current compose buffer."
+ (let ((header-region (fedi--find-property-range 'post-post-header
+ (point-min))))
+ (buffer-substring (cdr header-region) (point-max))))
+
+
+;;; SEND POST FUNCTION
+
+;; (defun fedi-post-send ()
+;; "POST contents of new-post buffer to Mastodon instance and kill buffer.
+;; If media items have been attached and uploaded with
+;; `fedi-post--attach-media', they are attached to the post.
+;; If `fedi-post--edit-post-id' is non-nil, PUT contents to
+;; instance to edit a post."
+;; (interactive)
+;; (let* ((post (fedi-post--remove-docs))
+;; (endpoint (if edit-id ; we are sending an edit:
+;; (mastodon-http--api (format "statuses/%s" edit-id))
+;; (mastodon-http--api "statuses")))
+;; (args-no-media (append `(("status" . ,post)
+;; ("in_reply_to_id" .
,fedi-post--reply-to-id)
+;; ("visibility" . ,fedi-post--visibility)
+;; ("sensitive" . ,(when
fedi-post-content-nsfw
+;; (symbol-name t)))
+;; ("spoiler_text" . ,cw)
+;; ("language" . ,fedi-post-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 fedi-post--media-attachments
+;; (mastodon-http--build-array-params-alist
+;; "media_ids[]"
+;; fedi-post--media-attachment-ids)))
+;; (args-poll (when fedi-post-poll
+;; (fedi-post--build-poll-params)))
+;; ;; media || polls:
+;; (args (if fedi-post--media-attachments
+;; (append args-media args-no-media)
+;; (if fedi-post-poll
+;; (append args-no-media args-poll)
+;; args-no-media)))
+;; (prev-window-config fedi-post-previous-window-config))
+;; (cond ((and fedi-post--media-attachments
+;; ;; make sure we have media args
+;; ;; and the same num of ids as attachments
+;; (or (not args-media)
+;; (not (= (length fedi-post--media-attachments)
+;; (length fedi-post--media-attachment-ids)))))
+;; (message "Something is wrong with your uploads. Wait for them to
complete or try again."))
+;; ((and fedi-post--max-chars
+;; (> (fedi-post--count-post-chars post cw)
fedi-post--max-chars))
+;; (message "Looks like your post (inc. CW) is longer than that
maximum allowed length."))
+;; ((fedi-post--empty-p)
+;; (message "Empty post. 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 ()
+;; (fedi-post-kill)
+;; (if scheduled
+;; (message "Post scheduled!")
+;; (message "Post post!"))
+;; ;; cancel scheduled post if we were editing it:
+;; (when scheduled-id
+;; (mastodon-views--cancel-scheduled-post
+;; scheduled-id :no-confirm))
+;; (fedi-post--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))))))))))
+
+(defun fedi-post--restore-previous-window-config (config)
+ "Restore the window CONFIG after killing the post compose buffer.
+Buffer-local variable `fedi-post-previous-window-config' holds the config."
+ (set-window-configuration (car config))
+ (goto-char (cadr config)))
+
+(defun fedi-post--mentions-to-string (mentions)
+ "Apply `fedi-post--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 #'fedi-post--process-local mentions))
+ " "))
+
+(defun fedi-post--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\" -> \"@[email protected]\" (when local.social is the domain of the
+mastodon-instance-url).
+eg. \"yourusername\" -> \"\"
+eg. \"[email protected]\" -> \"@[email protected]\"."
+ (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 fedi-post--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: `[email protected]`."
+ (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 fedi-post--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 fedi-post--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 fedi-post-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 fedi-post--mentions-capf ()
+ "Build a mentions completion backend for `completion-at-point-functions'."
+ (let* ((bounds (fedi-post--get-bounds fedi-post-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
+ (fedi-post--fetch-completion-candidates start end))))
+ (and (consp result) result))))
+ :exclusive 'no
+ :annotation-function
+ (lambda (cand)
+ (concat " " (fedi-post--mentions-annotation-fun cand)))))))
+
+(defun fedi-post--tags-capf ()
+ "Build a tags completion backend for `completion-at-point-functions'."
+ (let* ((bounds (fedi-post--get-bounds fedi-post-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
+ (fedi-post--fetch-completion-candidates start end
:tags))))
+ (and (consp result) result))))
+ :exclusive 'no
+ :annotation-function
+ (lambda (cand)
+ (concat " " (fedi-post--tags-annotation-fun cand)))))))
+
+(defun fedi-post--mentions-annotation-fun (candidate)
+ "Given a handle completion CANDIDATE, return its annotation string, a
username."
+ (caddr (assoc candidate fedi-post-completions)))
+
+(defun fedi-post--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 fedi-post-completions)))
+
+
+;;; REPLY
+
+;; (defun fedi-post--reply ()
+;; "Reply to post at `point'.
+;; Customize `fedi-post-display-orig-in-reply-buffer' to display
+;; text of the post being replied to in the compose buffer."
+;; (interactive)
+;; (mastodon-tl--do-if-post-strict
+;; (let* ((post (fedi--property 'post-json))
+;; ;; no-move arg for base post: don't try next post
+;; (base-post (fedi--property 'base-post)) ; for new notifs handling
+;; (id (mastodon-tl--as-string (mastodon-tl--field 'id (or base-post
post))))
+;; (account (mastodon-tl--field 'account post))
+;; (user (alist-get 'acct account))
+;; (mentions (fedi-post--mentions (or base-post post)))
+;; (boosted (mastodon-tl--field 'reblog (or base-post post)))
+;; (booster (when boosted
+;; (alist-get 'acct
+;; (alist-get 'account post)))))
+;; (fedi-post
+;; (when user
+;; (if booster
+;; (if (and (not (equal user booster))
+;; (not (member booster mentions)))
+;; ;; different booster, user and mentions:
+;; (fedi-post--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:
+;; (fedi-post--mentions-to-string (append (list user) mentions
nil))
+;; ;; user already in mentions:
+;; (fedi-post--mentions-to-string (copy-sequence mentions))))
+;; ;; ELSE no booster:
+;; (if (not (member user mentions))
+;; ;; user not in mentions:
+;; (fedi-post--mentions-to-string (append (list user) mentions nil))
+;; ;; user in mentions already:
+;; (fedi-post--mentions-to-string (copy-sequence mentions)))))
+;; id
+;; (or base-post post)))))
+
+
+;;; COMPOSE POST SETTINGS
+
+(defun fedi-post-toggle-nsfw ()
+ "Toggle `fedi-post-content-nsfw'."
+ (interactive)
+ (setq fedi-post-content-nsfw
+ (not fedi-post-content-nsfw))
+ (message "NSFW flag is now %s" (if fedi-post-content-nsfw "on" "off"))
+ (fedi-post--update-status-fields))
+
+(defun fedi-post-set-post-language ()
+ "Prompt for a language and set `fedi-post-language'.
+Return its two letter ISO 639 1 code."
+ (interactive)
+ (let* ((choice (completing-read "Language for this post: "
+ mastodon-iso-639-1)))
+ (setq fedi-post-language
+ (alist-get choice mastodon-iso-639-1 nil nil 'equal))
+ (message "Language set to %s" choice)
+ (fedi-post--update-status-fields)))
+
+(defun fedi-post--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 fedi-post--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 fedi-post--get-mode-kbinds ()
+ "Get a list of the keybindings in the fedi-post-mode."
+ (let* ((binds (copy-tree fedi-post-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 fedi-post--format-kbind-command (cmd)
+ "Format CMD to be more readable.
+e.g. fedi-post-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 fedi-post--format-kbind (kbind)
+ "Format a single keybinding, KBIND, for display in documentation."
+ (let ((key (help-key-description (car kbind) nil))
+ (command (fedi-post--format-kbind-command (cdr kbind))))
+ (format " %s - %s" key command)))
+
+(defun fedi-post--format-kbinds (kbinds)
+ "Format a list of keybindings, KBINDS, for display in documentation."
+ (mapcar #'fedi-post--format-kbind kbinds))
+
+(defvar-local fedi-post--kbinds-pairs nil
+ "Contains a list of paired post compose buffer keybindings for inserting.")
+
+(defun fedi-post--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))
+ fedi-post--kbinds-pairs)
+ (fedi-post--formatted-kbinds-pairs (cddr kbinds-list) longest))
+ (reverse fedi-post--kbinds-pairs))
+
+(defun fedi-post--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 fedi-post--make-mode-docs ()
+ "Create formatted documentation text for the fedi-post-mode."
+ (let* ((kbinds (fedi-post--get-mode-kbinds))
+ (longest-kbind (fedi-post--formatted-kbinds-longest
+ (fedi-post--format-kbinds kbinds))))
+ (concat
+ " Compose a new post here. The following keybindings are available:"
+ (mapconcat #'identity
+ (fedi-post--formatted-kbinds-pairs
+ (fedi-post--format-kbinds kbinds)
+ longest-kbind)
+ nil))))
+
+(defun fedi-post--display-docs-and-status-fields ()
+ "Insert propertized text with documentation about `fedi-post-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
+ (propertize
+ (concat
+ (fedi-post--make-mode-docs) "\n"
+ divider "\n"
+ " "
+ (propertize "Count"
+ 'post-post-counter t)
+ " ⋅ "
+ (propertize "Language"
+ 'post-post-language t)
+ " "
+ (propertize "NSFW"
+ 'post-post-nsfw-flag t)
+ "\n"
+ divider
+ "\n")
+ 'rear-nonsticky t
+ 'face 'fedi-post-docs-face
+ 'read-only "Edit your message below."
+ 'post-post-header t))))
+
+(defun fedi-post--count-post-chars (post-string)
+ "Count the characters in POST-STRING.
+URLs always = 23, and domain names of handles are not counted.
+This is how mastodon does it."
+ (with-temp-buffer
+ (switch-to-buffer (current-buffer))
+ (insert post-string)
+ (goto-char (point-min))
+ ;; handle URLs
+ ;; (while (search-forward-regexp mastodon-post-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-post-handle-regex nil t)
+ ;; (replace-match (match-string 2))) ; replace with handle only
+ ;; (+ (length cw)
+ (length (buffer-substring (point-min) (point-max)))))
+
+(defun fedi-post--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 (fedi--find-property-range 'post-post-header
+ (point-min)))
+ (count-region (fedi--find-property-range 'post-post-counter
+ (point-min)))
+ (nsfw-region (fedi--find-property-range 'post-post-nsfw-flag
+ (point-min)))
+ (lang-region (fedi--find-property-range 'post-post-language
+ (point-min)))
+ (post-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"
+ ;; (fedi-post--count-post-chars
post-string)
+ ;; (number-to-string
fedi-post--max-chars))))
+ (add-text-properties (car lang-region) (cdr lang-region)
+ (list 'display
+ (if fedi-post-language
+ (format "Lang: %s ⋅"
+ fedi-post-language)
+ "")))
+ (add-text-properties (car nsfw-region) (cdr nsfw-region)
+ (list 'display
+ (if fedi-post-content-nsfw
+ "NSFW"
+ "")
+ 'face 'mastodon-cw-face)))))
+ ;; (add-text-properties (car cw-region) (cdr cw-region)
+ ;; (list 'invisible (not fedi-post--content-warning)
+ ;; 'face 'mastodon-cw-face)))))
+
+
+
+;;; PROPERTIZE TAGS AND HANDLES
+
+(defun fedi-post--propertize-tags-and-handles (&rest _args)
+ "Propertize tags and handles in post compose buffer.
+Added to `after-change-functions'."
+ (when (fedi-post--compose-buffer-p)
+ (let ((header-region (fedi--find-property-range 'post-post-header
+ (point-min)))
+ (face (when fedi-post--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))
+ (fedi-post--propertize-item fedi-post-tag-regex
+ 'success
+ (cdr header-region))
+ (fedi-post--propertize-item fedi-post-handle-regex
+ 'mastodon-display-name-face
+ (cdr header-region))
+ (fedi-post--propertize-item fedi-post-url-regex
+ 'link
+ (cdr header-region)))))
+
+(defun fedi-post--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 fedi-post--compose-buffer-p ()
+ "Return t if compose buffer is current."
+ (or (mastodon-tl--buffer-type-eq 'edit-post)
+ (mastodon-tl--buffer-type-eq 'new-post)))
+
+(defun fedi-post--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 'post-reply)))
+ (fill-region (prop-match-beginning prop)
+ (point)))))))
+
+
+;;; COMPOSE BUFFER FUNCTION
+
+(defun fedi-post--compose-buffer
+ (&optional reply-to-user reply-to-id reply-json initial-text edit mode)
+ "Create a new buffer to capture text for a new post.
+If REPLY-TO-USER is provided, inject their handle into the message.
+If REPLY-TO-ID is provided, set the `fedi-post--reply-to-id' var.
+REPLY-JSON is the full JSON of the post being replied to.
+INITIAL-TEXT is used by `fedi-post-insert-draft-post' to add
+a draft into the buffer.
+EDIT means we are editing an existing post, not composing a new one."
+ (let* ((buffer-name (if edit "*edit post*" "*new post*"))
+ (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)
+ (or (funcall mode)
+ (fedi-post-mode t))
+ (unless buffer-exists
+ (fedi-post--display-docs-and-status-fields))
+ ;; set up completion:
+ (when fedi-post--enable-completion
+ (set (make-local-variable 'completion-at-point-functions)
+ (add-to-list 'completion-at-point-functions
+ #'fedi-post--mentions-capf))
+ (add-to-list 'completion-at-point-functions
+ #'fedi-post--tags-capf)
+ ;; company
+ (when (and fedi-post--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 #'fedi-post--save-post-text after-change-functions)
+ (cl-pushnew #'fedi-post--update-status-fields after-change-functions)
+ (fedi-post--update-status-fields)
+ (cl-pushnew #'fedi-post--propertize-tags-and-handles
after-change-functions)
+ (fedi-post--propertize-tags-and-handles)
+ ;; draft post text saving:
+ (setq fedi-post-current-post-text nil)
+ ;; if we set this before changing modes, it gets nuked:
+ (setq fedi-post-previous-window-config previous-window-config)
+ (when initial-text
+ (insert initial-text))))
+
+;; flyspell ignore masto post regexes:
+(defvar flyspell-generic-check-word-predicate)
+
+(defun fedi-post-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
+ fedi-post-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))))))
+
+(add-hook 'fedi-post-mode-hook
+ (lambda ()
+ (setq flyspell-generic-check-word-predicate
+ 'fedi-post-mode-flyspell-verify)))
+
+;;;###autoload
+;; (add-hook 'fedi-post-mode-hook
+;; #'mastodon-profile--fetch-server-account-settings-maybe)
+
+;; disable auto-fill-mode:
+(add-hook 'fedi-post-mode-hook
+ (lambda ()
+ (auto-fill-mode -1)))
+
+(define-minor-mode fedi-post-mode
+ "Minor mode for posting to fediverse services."
+ :keymap fedi-post-mode-map
+ :global nil)
+
+(provide 'fedi-post)
+;;; fedi-post.el ends here