diff options
-rw-r--r-- | README.org | 234 | ||||
-rw-r--r-- | lisp/mastodon-auth.el | 8 | ||||
-rw-r--r-- | lisp/mastodon-discover.el | 14 | ||||
-rw-r--r-- | lisp/mastodon-http.el | 167 | ||||
-rw-r--r-- | lisp/mastodon-iso.el | 246 | ||||
-rw-r--r-- | lisp/mastodon-media.el | 35 | ||||
-rw-r--r-- | lisp/mastodon-notifications.el | 87 | ||||
-rw-r--r-- | lisp/mastodon-profile.el | 170 | ||||
-rw-r--r-- | lisp/mastodon-search.el | 72 | ||||
-rw-r--r-- | lisp/mastodon-tl.el | 777 | ||||
-rw-r--r-- | lisp/mastodon-toot.el | 509 | ||||
-rw-r--r-- | lisp/mastodon.el | 59 | ||||
-rw-r--r-- | test/mastodon-notifications-tests.el | 6 | ||||
-rw-r--r-- | test/mastodon-profile-tests.el | 22 | ||||
-rw-r--r-- | test/mastodon-tl-tests.el | 119 | ||||
-rw-r--r-- | test/mastodon-toot-tests.el | 57 |
16 files changed, 1832 insertions, 750 deletions
@@ -6,7 +6,8 @@ * README -=mastodon.el= is an Emacs client for the Mastodon and Pleroma social networks. For info see https://joinmastodon.org/. +=mastodon.el= is an Emacs client for the AcitivityPub social networks that +implement the Mastodon API. For info see https://joinmastodon.org/. ** Installation @@ -25,7 +26,8 @@ Or, with =use-package=: :ensure t) #+END_SRC -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. +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. *** MELPA @@ -100,76 +102,86 @@ restart Emacs and follow the steps again. =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=. +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=. **** Keybindings -|---------------+-----------------------------------------------------------------------| -| Key | Action | -|---------------+-----------------------------------------------------------------------| -| | *Help* | -| =?= | Open context menu if =discover= is available | -|---------------+-----------------------------------------------------------------------| -| | *Timeline actions* | -| =n= | Go to next item (toot, notification) | -| =p= | Go to previous item (toot, notification) | -| =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 | -| =H= | Open home timeline | -| =L= | Open local timeline | -| =N= | Open notifications timeline | -| =u= | Update current timeline | -| =T= | Open thread for toot under =point= | -| =#= | Prompt for tag and open its timeline | -| =A= | Open author profile of toot under =point= | -| =P= | Open profile of user attached to toot under =point= | -| =O= | View own profile | -| =U= | update your profile bio note | -|---------------+-----------------------------------------------------------------------| -| | *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 | -|---------------+-----------------------------------------------------------------------| -| | *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=) | -| =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 | -|---------------+-----------------------------------------------------------------------| -| | Notifications view | -| =a=, =j= | accept/reject follow request | -|---------------+-----------------------------------------------------------------------| -| | *Quitting* | -| =q= | Quit mastodon buffer, leave window open | -| =Q= | Quit mastodon buffer and kill window | -|---------------+-----------------------------------------------------------------------| +|----------------+-----------------------------------------------------------------------| +| Key | Action | +|----------------+-----------------------------------------------------------------------| +| | *Help* | +| =?= | Open context menu if =discover= is available | +|----------------+-----------------------------------------------------------------------| +| | *Timeline actions* | +| =n= | Go to next item (toot, notification) | +| =p= | Go to previous item (toot, notification) | +| =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 | +| =H= | Open home timeline | +| =L= | Open local timeline | +| =N= | Open notifications timeline | +| =@= | Open mentions-only notifications timeline | +| =u= | Update current timeline | +| =T= | Open thread for toot under =point= | +| =#= | Prompt for tag and open its timeline | +| =A= | Open author profile of toot under =point= | +| =P= | Open profile of user attached to toot under =point= | +| =O= | View own profile | +| =U= | update your profile bio note | +|----------------+-----------------------------------------------------------------------| +| | *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 | +|----------------+-----------------------------------------------------------------------| +| | *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, followers, following, and statuses without boosts | +| | =mastodon-profile--account-account-to-list= (see lists view) | +|----------------+-----------------------------------------------------------------------| +| | *Notifications view* | +| =a=, =j= | accept/reject follow request | +| =c= | 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 | +|----------------+-----------------------------------------------------------------------| **** Toot byline legend -|----------------+------------------------| -| Marker | Meaning | -|----------------+------------------------| -| =(B)= | I boosted this toot | -| =(F)= | I favourited this toot | -| (=K=) (or emoji) | I bookmarked this toot | -|----------------+------------------------| +|---------------+------------------------| +| Marker | Meaning | +|---------------+------------------------| +| =(B)= | I boosted this toot | +| =(F)= | I favourited this toot | +| =(🔖)= (or (=K=)) | I bookmarked this toot | +|---------------+------------------------| *** Composing toots @@ -179,9 +191,7 @@ Pops a new buffer/window in =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. -Autocompletion of mentions and tags is provided by mastodon company backends -(requires =company-mode= and =mastodon-toot--enable-completion= must be set to =t=) -. Type =@= or =#= followed by two or more characters for candidates to appear. +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.) Replies preserve visibility status/content warnings, and include boosters by default. @@ -204,32 +214,54 @@ You can download and use your instance's custom emoji | =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 | |---------+----------------------------------| **** 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. +- =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. *** Other commands and account settings: -- =mastodon-url-lookup=: Attempt to load URL in =mastodon.el=. URL may be the one at point or provided in the minibuffer. Should also work if =mastodon.el= is not yet loaded. - -- =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-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. +In addition to =mastodon=, the following 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-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-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. *** Customization @@ -244,7 +276,7 @@ See =M-x customize-group RET mastodon= to view all customize options. - Enable image caching - Compose options: - - Completion for mentions and tags + - Completion style for mentions and tags - Enable custom emoji - Display toot being replied to @@ -255,7 +287,7 @@ See =M-x customize-group RET mastodon= to view all customize options. 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 improve this feature. +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 @@ -292,11 +324,18 @@ Hard dependencies (should all install with =mastodon.el=): - =ts= for poll relative expiry times Optional dependencies: -- =company= for autocompletion of mentions and tags when composing a toot - =emojify= for inserting and viewing emojis - =mpv= and =mpv.el= for viewing videos and gifs - =lingva.el= for translating toots +** 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 and +Gotosocial. If you attempt to use =mastodon.el= with another server that +implements the Mastodon API and run into problems, feel free to open an issue. + ** Contributing PRs, issues, feature requests, and general feedback are very welcome! @@ -305,20 +344,27 @@ PRs, issues, feature requests, and general feedback are very welcome! 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 [[https://codeberg.org/martianh/mastodon.el/issues/300][here]]) 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. +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 [[https://codeberg.org/martianh/mastodon.el/issues/300][here]]) 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. *** Fixes and features 1. Create an [[https://codeberg.org/martianh/mastodon.el/issues][issue]] detailing what you'd like to do. 2. Fork the repository and create a branch off of =develop=. -3. Run the tests against your code and ensure it doesn't break any of them. +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. ** Supporting mastodon.el -If you'd like to support continued development of =mastodon.el=, I accept donations via paypal at martianhiatus [ at ] riseup [ dot ] net. If you would prefer a different payment method, write to me at that address and I can provide IBAN or other details. +If you'd like to support continued development of =mastodon.el=, I accept +donations via paypal at martianhiatus [ at ] riseup [ dot ] net. If you would +prefer a different payment method, write to me at that address and I can +provide IBAN or other details. I don't have a tech worker's income, so even a small tip would help out. diff --git a/lisp/mastodon-auth.el b/lisp/mastodon-auth.el index 02799bf..263ece2 100644 --- a/lisp/mastodon-auth.el +++ b/lisp/mastodon-auth.el @@ -222,6 +222,14 @@ Handle any errors from the server." (mastodon-http--api "accounts/verify_credentials")))) +(defun mastodon-auth--get-account-id () + "Request user credentials and return an account name." + (alist-get + 'id + (mastodon-http--get-json + (mastodon-http--api + "accounts/verify_credentials")))) + (defun mastodon-auth--user-acct () "Return a mastodon user acct name." (or (cdr (assoc mastodon-instance-url mastodon-auth--acct-alist)) diff --git a/lisp/mastodon-discover.el b/lisp/mastodon-discover.el index 0ef64e2..08df46e 100644 --- a/lisp/mastodon-discover.el +++ b/lisp/mastodon-discover.el @@ -57,8 +57,8 @@ ("p" "Prev" mastodon-tl--goto-prev-toot) ("TAB" "Next link item" mastodon-tl--next-tab-item) ("S-TAB" "Prev link item" mastodon-tl--previous-tab-item) - (when (require 'mpv nil :noerror) - ("C-RET" "Play media" mastodon-tl--mpv-play-video-at-point)) + ;; 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) @@ -66,8 +66,7 @@ ("D" "Delete and redraft (your) toot" mastodon-toot--delete-toot) ("i" "Pin/Unpin (your) toot" mastodon-toot--pin-toot-toggle) ("P" "View user profile" mastodon-profile--show-user) - (when (require 'lingva nil :noerror) - "s" "Translate toot at point" mastodon-toot--translate-toot-text) + ("s" "Translate toot at point" mastodon-toot--translate-toot-text) ("T" "View thread" mastodon-tl--thread) ("v" "Vote on poll" mastodon-tl--poll-vote)) ("Views" @@ -76,7 +75,7 @@ ("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) + ("N" "Notifications" mastodon-notifications-get) ("u" "Update timeline" mastodon-tl--update) ("S" "Search" mastodon-search--search-query) ("O" "Jump to your profile" mastodon-profile--my-profile) @@ -94,13 +93,14 @@ ("B" "Block" mastodon-tl--block-user) ("C-S-B" "Unblock" mastodon-tl--unblock-user)) ("Images" - ("RET/i" "Load full image in browser" 'shr-browse-image) + ;; 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)) + ("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))))))) diff --git a/lisp/mastodon-http.el b/lisp/mastodon-http.el index 66707b7..d677e57 100644 --- a/lisp/mastodon-http.el +++ b/lisp/mastodon-http.el @@ -88,9 +88,14 @@ Message status and JSON error from RESPONSE if unsuccessful." (mastodon-http--status)))) (if (string-prefix-p "2" status) (funcall success) - (switch-to-buffer response) - (let ((json-response (mastodon-http--process-json))) - (message "Error %s: %s" status (alist-get 'error json-response)))))) + ;; don't switch to buffer, just with-current-buffer the response: + ;; (switch-to-buffer response) + ;; 404 sometimes returns http response so --process-json fails: + (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." @@ -109,27 +114,33 @@ Unless UNAUTHENTICATED-P is non-nil." (concat "Bearer " (mastodon-auth--access-token))))))) ,body)) -(defun mastodon-http--build-query-string (args) - "Build a request query string from ARGS." +(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' to empty params so lets stay with our ;; own: - (mapconcat (lambda (arg) - (concat (url-hexify-string (car arg)) + (mapconcat (lambda (p) + (concat (url-hexify-string (car p)) "=" - (url-hexify-string (cdr arg)))) - args + (url-hexify-string (cdr p)))) + params "&")) -(defun mastodon-http--post (url args headers &optional unauthenticated-p) - "POST synchronously to URL with ARGS and HEADERS. +(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) + "POST synchronously to URL, optionally with PARAMS and HEADERS. Authorization header is included by default unless UNAUTHENTICATED-P is non-nil." (mastodon-http--authorized-request "POST" (let ((url-request-data - (when args - (mastodon-http--build-query-string args))) + (when params + (mastodon-http--build-params-string params))) (url-request-extra-headers (append url-request-extra-headers ; auth set in macro ;; pleroma compat: @@ -140,27 +151,34 @@ Authorization header is included by default unless UNAUTHENTICATED-P is non-nil. (mastodon-http--url-retrieve-synchronously url))) unauthenticated-p)) -(defun mastodon-http--get (url &optional silent) +(defun mastodon-http--get (url &optional params silent) "Make synchronous GET request to URL. -Pass response buffer to CALLBACK function. +PARAMS is an alist of any extra parameters to send with the request. SILENT means don't message." (mastodon-http--authorized-request "GET" - (mastodon-http--url-retrieve-synchronously url silent))) + ;; url-request-data doesn't seem to work with GET requests: + (let ((url (if params + (concat url "?" + (mastodon-http--build-params-string params)) + url))) + (mastodon-http--url-retrieve-synchronously url silent)))) -(defun mastodon-http--get-response (url &optional no-headers silent vector) +(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 silent) + (with-current-buffer (mastodon-http--get url params silent) (mastodon-http--process-response no-headers vector))) -(defun mastodon-http--get-json (url &optional silent 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 :no-headers silent vector))) + (car (mastodon-http--get-response url params :no-headers silent vector))) (defun mastodon-http--process-json () "Return only JSON data from async URL request. @@ -203,52 +221,42 @@ Callback to `mastodon-http--get-response-async', usually (cons (car list) (cadr list)))) head-list))) -(defun mastodon-http--delete (url) - "Make DELETE request to URL." +(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 + (if params + (concat url "?" + (mastodon-http--build-params-string params)) + url))) + (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 - "DELETE" - (with-temp-buffer - (mastodon-http--url-retrieve-synchronously url)))) + "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 + ;; pleroma compat: + (unless (assoc "Content-Type" headers) + '(("Content-Type" . "application/x-www-form-urlencoded"))) + headers))) + (with-temp-buffer (mastodon-http--url-retrieve-synchronously url))))) (defun mastodon-http--append-query-string (url params) "Append PARAMS to URL as query strings and return it. - PARAMS should be an alist as required by `url-build-query-string'." (let ((query-string (url-build-query-string params))) (concat url "?" query-string))) -;; search functions: -(defun mastodon-http--process-json-search () - "Process JSON returned by a search query to the server." - (goto-char (point-min)) - (re-search-forward "^$" nil 'move) - (let ((json-string - (decode-coding-string - (buffer-substring-no-properties (point) (point-max)) - 'utf-8))) - (kill-buffer) - (json-read-from-string json-string))) - -(defun mastodon-http--get-search-json (url query &optional param silent) - "Make GET request to URL, searching for QUERY and return JSON response. -PARAM is any extra parameters to send with the request. -SILENT means don't message." - (let ((buffer (mastodon-http--get-search url query param silent))) - (with-current-buffer buffer - (mastodon-http--process-json-search)))) - -(defun mastodon-http--get-search (base-url query &optional param silent) - "Make GET request to BASE-URL, searching for QUERY. -Pass response buffer to CALLBACK function. -PARAM is a formatted request parameter, eg 'following=true'. -SILENT means don't message." - (mastodon-http--authorized-request - "GET" - (let ((url (if param - (concat base-url "?" param "&q=" (url-hexify-string query)) - (concat base-url "?q=" (url-hexify-string query))))) - (mastodon-http--url-retrieve-synchronously url silent)))) - ;; profile update functions (defun mastodon-http--patch-json (url &optional params) @@ -264,44 +272,53 @@ Optionally specify the PARAMS to send." "PATCH" (let ((url (concat base-url "?" - (mastodon-http--build-query-string params)))) + (mastodon-http--build-params-string params)))) (mastodon-http--url-retrieve-synchronously url)))) ;; Asynchronous functions -(defun mastodon-http--get-async (url &optional callback &rest cbargs) +(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." - (mastodon-http--authorized-request - "GET" - (url-retrieve url callback cbargs))) - -(defun mastodon-http--get-response-async (url callback &rest args) - "Make GET request to URL. Call CALLBACK with http response and ARGS." +Pass response buffer to CALLBACK function with args CBARGS. +PARAMS is an alist of any extra parameters to send with the request." + (let ((url (if params + (concat url "?" + (mastodon-http--build-params-string params)) + url))) + (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 ;; only when we actually get sth? - (apply callback (mastodon-http--process-response) args))))) + (apply callback (mastodon-http--process-response) cbargs))))) -(defun mastodon-http--get-json-async (url callback &rest args) - "Make GET request to URL. Call CALLBACK with json-list and ARGS." +(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) args))))) + (apply callback (mastodon-http--process-json) cbargs))))) -(defun mastodon-http--post-async (url args headers &optional callback &rest cbargs) - "POST asynchronously to URL with ARGS and HEADERS. +(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) (url-request-data - (when args - (mastodon-http--build-query-string args)))) + (when params + (mastodon-http--build-params-string params)))) (with-temp-buffer (url-retrieve url callback cbargs))))) diff --git a/lisp/mastodon-iso.el b/lisp/mastodon-iso.el new file mode 100644 index 0000000..8baff3c --- /dev/null +++ b/lisp/mastodon-iso.el @@ -0,0 +1,246 @@ +;;; 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 +;; Package-Requires: ((emacs "27.1") (request "0.3.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 + '(("ab" "Abkhazian") + ("aa" "Afar") + ("af" "Afrikaans") + ("ak" "Akan") + ("sq" "Albanian") + ("am" "Amharic") + ("ar" "Arabic") + ("an" "Aragonese") + ("hy" "Armenian") + ("as" "Assamese") + ("av" "Avaric") + ("ae" "Avestan") + ("ay" "Aymara") + ("az" "Azerbaijani") + ("bm" "Bambara") + ("ba" "Bashkir") + ("eu" "Basque") + ("be" "Belarusian") + ("bn" "Bengali") + ("bh" "Bihari languages") + ("bi" "Bislama") + ("bs" "Bosnian") + ("br" "Breton") + ("bg" "Bulgarian") + ("my" "Burmese") + ("km" "Central Khmer") + ("ch" "Chamorro") + ("ce" "Chechen") + ("zh" "Chinese") + ("cv" "Chuvash") + ("kw" "Cornish") + ("co" "Corsican") + ("cr" "Cree") + ("hr" "Croatian") + ("cs" "Czech") + ("da" "Danish") + ("dz" "Dzongkha") + ("en" "English") + ("eo" "Esperanto") + ("et" "Estonian") + ("ee" "Ewe") + ("fo" "Faroese") + ("fj" "Fijian") + ("fi" "Finnish") + ("nl" "Dutch" "Flemish") + ("fr" "French") + ("ff" "Fulah") + ("gl" "Galician") + ("lg" "Ganda") + ("ka" "Georgian") + ("de" "German") + ("el" "Greek") + ("gn" "Guarani") + ("gu" "Gujarati") + ("ht" "Haitian" "Haitian Creole") + ("ha" "Hausa") + ("he" "Hebrew") + ("hz" "Herero") + ("hi" "Hindi") + ("ho" "Hiri Motu") + ("hu" "Hungarian") + ("is" "Icelandic") + ("io" "Ido") + ("ig" "Igbo") + ("id" "Indonesian") + ("ia" "Interlingua" "Interlingua (International Auxiliary Language Association)") + ("iu" "Inuktitut") + ("ik" "Inupiaq") + ("ga" "Irish") + ("it" "Italian") + ("ja" "Japanese") + ("jp" "Japanese") + ("jv" "Javanese") + ("kl" "Kalaallisut" "Greenlandic") + ("kn" "Kannada") + ("kr" "Kanuri") + ("ks" "Kashmiri") + ("kk" "Kazakh") + ("ki" "Kikuyu" "Gikuyu") + ("rw" "Kinyarwanda") + ("kv" "Komi") + ("kg" "Kongo") + ("ko" "Korean") + ("ku" "Kurdish") + ("kj" "Kuanyama" "Kwanyama") + ("ky" "Kirghiz" "Kyrgyz") + ("lo" "Lao") + ("la" "Latin") + ("lv" "Latvian") + ("li" "Limburgan" "Limburger" "Limburgish") + ("ln" "Lingala") + ("lt" "Lithuanian") + ("lu" "Luba-Katanga") + ("lb" "Luxembourgish" "Letzeburgesch") + ("mk" "Macedonian") + ("mg" "Malagasy") + ("ms" "Malay") + ("ml" "Malayalam") + ("dv" "Divehi" "Dhivehi" "Maldivian") + ("mt" "Maltese") + ("gv" "Manx") + ("mi" "Maori") + ("mr" "Marathi") + ("mh" "Marshallese") + ("mn" "Mongolian") + ("na" "Nauru") + ("nv" "Navajo" "Navaho") + ("ng" "Ndonga") + ("ne" "Nepali") + ("nd" "Ndebele, North" "North Ndebele") + ("se" "Northern Sami") + ("no" "Norwegian") + ("nb" "Bokmål, Norwegian" "Norwegian Bokmål") + ("ny" "Chichewa" "Chewa" "Nyanja") + ("nn" "Norwegian Nynorsk" "Nynorsk, Norwegian") + ("ie" "Interlingue" "Occidental") + ("oc" "Occitan") + ("oj" "Ojibwa") + ("cu" "Church Slavic" "Old Slavonic" "Church Slavonic" "Old Bulgarian" "Old Church Slavonic") + ("or" "Oriya") + ("om" "Oromo") + ("os" "Ossetian" "Ossetic") + ("pi" "Pali") + ("fa" "Persian") + ("pl" "Polish") + ("pt" "Portuguese") + ("pa" "Panjabi" "Punjabi") + ("ps" "Pushto" "Pashto") + ("qu" "Quechua") + ("ro" "Romanian" "Moldavian" "Moldovan") + ("rm" "Romansh") + ("rn" "Rundi") + ("ru" "Russian") + ("sm" "Samoan") + ("sg" "Sango") + ("sa" "Sanskrit") + ("sc" "Sardinian") + ("gd" "Gaelic" "Scottish Gaelic") + ("sr" "Serbian") + ("sn" "Shona") + ("ii" "Sichuan Yi" "Nuosu") + ("sd" "Sindhi") + ("si" "Sinhala" "Sinhalese") + ("sk" "Slovak") + ("sl" "Slovenian") + ("so" "Somali") + ("st" "Sotho, Southern") + ("nr" "Ndebele, South" "South Ndebele") + ("es" "Spanish" "Castilian") + ("su" "Sundanese") + ("sw" "Swahili") + ("ss" "Swati") + ("sv" "Swedish") + ("tl" "Tagalog") + ("ty" "Tahitian") + ("tg" "Tajik") + ("ta" "Tamil") + ("tt" "Tatar") + ("te" "Telugu") + ("th" "Thai") + ("bo" "Tibetan") + ("ti" "Tigrinya") + ("to" "Tonga (Tonga Islands)") + ("ts" "Tsonga") + ("tn" "Tswana") + ("tr" "Turkish") + ("tk" "Turkmen") + ("tw" "Twi") + ("uk" "Ukrainian") + ("ur" "Urdu") + ("ug" "Uighur" "Uyghur") + ("uz" "Uzbek") + ("ca" "Catalan" "Valencian") + ("ve" "Venda") + ("vi" "Vietnamese") + ("vo" "Volapük") + ("wa" "Walloon") + ("cy" "Welsh") + ("fy" "Western Frisian") + ("wo" "Wolof") + ("xh" "Xhosa") + ("yi" "Yiddish") + ("yo" "Yoruba") + ("za" "Zhuang" "Chuang") + ("zu" "Zulu"))) + +;; 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 diff --git a/lisp/mastodon-media.el b/lisp/mastodon-media.el index 9715a6c..c783130 100644 --- a/lisp/mastodon-media.el +++ b/lisp/mastodon-media.el @@ -35,6 +35,8 @@ ;;; 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) @@ -306,34 +308,23 @@ Replace them with the referenced image." t image-options)) " "))) -(defun mastodon-media--get-media-link-rendering (media-url &optional full-remote-url type caption) +(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))) + (help-echo (if caption + (concat help-echo-base + "\n\"" + caption "\"") + help-echo-base))) (concat - (propertize "[img]" - 'media-url media-url - 'media-state 'needs-loading - 'media-type 'media-link - 'mastodon-media-type type - 'display (create-image mastodon-media--generic-broken-image-data nil t) - '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"))) - " "))) + (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 diff --git a/lisp/mastodon-notifications.el b/lisp/mastodon-notifications.el index 7c5d40b..f5ddea3 100644 --- a/lisp/mastodon-notifications.el +++ b/lisp/mastodon-notifications.el @@ -50,29 +50,43 @@ (autoload 'mastodon-http--get-params-async-json "mastodon-http.el") (autoload 'mastodon-profile--view-follow-requests "mastodon-profile.el") (autoload 'mastodon-tl--reload-timeline-or-profile "mastodon-tl") +(autoload 'mastodon-tl--update "mastodon-tl") +(autoload 'mastodon-notifications-get "mastodon") (defvar mastodon-tl--buffer-spec) (defvar mastodon-tl--display-media-p) +(defvar mastodon-mode-map) (defvar mastodon-notifications--types-alist - '(("mention" . mastodon-notifications--mention) - ("follow" . mastodon-notifications--follow) + '(("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) - ("poll" . mastodon-notifications--poll)) + ("update" . mastodon-notifications--edit)) "Alist of notification types and their corresponding function.") (defvar mastodon-notifications--response-alist - '(("Mentioned" . "you") - ("Followed" . "you") + '(("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") - ("Posted a poll" . "that has now ended")) + ("Edited" . "a post")) "Alist of subjects for notification types.") +(defvar mastodon-notifications--map + (let ((map + (copy-keymap 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") #'mastodon-notifications--clear-current) + (keymap-canonicalize map)) + "Keymap for viewing notifications.") + (defun mastodon-notifications--byline-concat (message) "Add byline for TOOT with MESSAGE." (concat @@ -106,13 +120,12 @@ follow-requests view." (mastodon-http--api "follow_requests") (format "/%s/%s" id (if reject "reject" - "authorize"))) - nil nil))) + "authorize")))))) (mastodon-http--triage response (lambda () (if f-reqs-view-p (mastodon-profile--view-follow-requests) - (mastodon-notifications--get)) + (mastodon-notifications-get)) (message "Follow request of %s (@%s) %s!" name handle (if reject "rejected" @@ -130,7 +143,7 @@ Can be called in notifications view or in follow-requests view." "Reject a follow request. Can be called in notifications view or in follow-requests view." (interactive) - (mastodon-notifications--follow-request-process t)) + (mastodon-notifications--follow-request-process :reject)) (defun mastodon-notifications--mention (note) "Format for a `mention' NOTE." @@ -162,6 +175,10 @@ Status notifications are given when "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)) @@ -186,7 +203,7 @@ Status notifications are given when "Congratulations, you have a new follower!" (format "You have a follow request from... %s" follower)) - 'face 'default) + 'face 'default) (mastodon-tl--clean-tabs-and-nl (if (mastodon-tl--has-spoiler status) (mastodon-tl--spoiler status) @@ -213,7 +230,9 @@ Status notifications are given when ((equal type 'status) "Posted") ((equal type 'poll) - "Posted a poll")))) + "Posted a poll") + ((equal type 'edit) + "Edited")))) id (when (or (equal type 'favourite) (equal type 'boost)) @@ -258,22 +277,45 @@ of the toot responded to." (mapc #'mastodon-notifications--by-type json) (goto-char (point-min)))) -(defun mastodon-notifications--get () - "Display NOTIFICATIONS in buffer." +(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) - (message "Loading your notifications...") - (mastodon-tl--init-sync - "notifications" - "notifications" - 'mastodon-notifications--timeline)) + (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") - nil nil))) + (mastodon-http--post (mastodon-http--api "notifications/clear")))) (mastodon-http--triage response (lambda () (when mastodon-tl--buffer-spec @@ -288,8 +330,7 @@ of the toot responded to." (mastodon-tl--property 'toot-json)))) (response (mastodon-http--post (mastodon-http--api - (format "notifications/%s/dismiss" id)) - nil nil))) + (format "notifications/%s/dismiss" id))))) (mastodon-http--triage response (lambda () (when mastodon-tl--buffer-spec diff --git a/lisp/mastodon-profile.el b/lisp/mastodon-profile.el index 63c062b..658b1d4 100644 --- a/lisp/mastodon-profile.el +++ b/lisp/mastodon-profile.el @@ -36,6 +36,7 @@ (require 'seq) (require 'cl-lib) (require 'persist) +(require 'ts) (autoload 'mastodon-http--api "mastodon-http.el") (autoload 'mastodon-http--get-json "mastodon-http.el") @@ -69,6 +70,11 @@ (autoload 'mastodon-search--insert-users-propertized "mastodon-search") (autoload 'mastodon-tl--get-endpoint "mastodon-tl.el") (autoload 'mastodon-toot--get-max-toot-chars "mastodon-toot") +(autoload 'mastodon-tl--add-account-to-list "mastodon-tl") +(autoload 'mastodon-http--get-response "mastodon-http") +(autoload 'mastodon-tl--get-link-header-from-response "mastodon-tl") +(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl") + (defvar mastodon-instance-url) (defvar mastodon-tl--buffer-spec) (defvar mastodon-tl--update-point) @@ -87,7 +93,7 @@ ;; maybe we can retire both of these awful bindings ;; (define-key map (kbd "s") #'mastodon-profile--open-followers) ;; (define-key map (kbd "g") #'mastodon-profile--open-following) - (define-key map (kbd "C-c C-c") #'mastodon-profile-account-view-cycle) + (define-key map (kbd "C-c C-c") #'mastodon-profile--account-view-cycle) map) "Keymap for `mastodon-profile-mode'.") @@ -110,7 +116,6 @@ (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 @@ -150,13 +155,14 @@ contains") ;; or handle --property failing (mastodon-tl--property 'toot-json)) -(defun mastodon-profile--make-author-buffer (account) - "Take an ACCOUNT json and insert a user account into a new buffer." +(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)) + 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 () +(defun mastodon-profile--account-view-cycle () "Cycle through profile view: toots, followers, and following." (interactive) (let ((endpoint (plist-get mastodon-tl--buffer-spec 'endpoint))) @@ -164,9 +170,17 @@ contains") (mastodon-profile--open-followers)) ((string-suffix-p "followers" endpoint) (mastodon-profile--open-following)) + ((string-suffix-p "following" endpoint) + (mastodon-profile--open-statuses-no-reblogs)) (t - (mastodon-profile--make-profile-buffer-for - mastodon-profile--account "statuses" #'mastodon-tl--timeline))))) + (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) + (error "Not in a mastodon profile"))) (defun mastodon-profile--open-following () "Open a profile buffer showing the accounts that current profile follows." @@ -175,7 +189,9 @@ contains") (mastodon-profile--make-profile-buffer-for mastodon-profile--account "following" - #'mastodon-profile--add-author-bylines) + #'mastodon-profile--add-author-bylines + nil + :headers) (error "Not in a mastodon profile"))) (defun mastodon-profile--open-followers () @@ -185,7 +201,9 @@ contains") (mastodon-profile--make-profile-buffer-for mastodon-profile--account "followers" - #'mastodon-profile--add-author-bylines) + #'mastodon-profile--add-author-bylines + nil + :headers) (error "Not in a mastodon profile"))) (defun mastodon-profile--view-favourites () @@ -203,7 +221,8 @@ contains") (message "Loading your bookmarked toots...") (mastodon-tl--init "bookmarks" "bookmarks" - 'mastodon-tl--timeline)) + 'mastodon-tl--timeline + :headers)) (defun mastodon-profile--view-follow-requests () "Open a new buffer displaying the user's follow requests." @@ -234,6 +253,15 @@ JSON is the data returned by the server." (mastodon-search--insert-users-propertized json :note))) ;; (mastodon-profile--add-author-bylines json))) +(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-tl--add-account-to-list nil id handle)))) + ;;; ACCOUNT PREFERENCES (defun mastodon-profile--get-json-value (val) @@ -295,25 +323,25 @@ SOURCE means that the preference is in the 'source' part of the account JSON." (response (mastodon-http--patch url `((,pref-formatted . ,val))))) (mastodon-http--triage response (lambda () - (mastodon-profile-fetch-server-account-settings) + (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) +(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))) -(defun mastodon-profile-fetch-server-account-settings-maybe () +(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)) + (mastodon-profile--fetch-server-account-settings :no-force)) -(defun mastodon-profile-fetch-server-account-settings (&optional 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'. @@ -324,42 +352,42 @@ If NO-FORCE, only fetch if `mastodon-profile-account-settings' is nil." (let ((keys '(locked discoverable display_name bot)) (source-keys '(privacy sensitive language))) (mapc (lambda (k) - (mastodon-profile-update-preference-plist + (mastodon-profile--update-preference-plist k (mastodon-profile--get-json-value k))) keys) (mapc (lambda (sk) - (mastodon-profile-update-preference-plist + (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) + (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 () +(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 () +(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 () +(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 () +(defun mastodon-profile--account-sensitive-toggle () "Toggle the sensitive status of your account. When enabled, statuses are marked as sensitive by default." (interactive) @@ -387,7 +415,7 @@ Current settings are fetched from the server." val))) (mastodon-profile--update-preference (symbol-name key) new-val))) -(defun mastodon-profile-update-display-name () +(defun mastodon-profile--update-display-name () "Update display name for your account." (interactive) (mastodon-profile--edit-string-value 'display_name)) @@ -396,8 +424,8 @@ Current settings are fetched from the server." "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))))) + 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) @@ -405,7 +433,7 @@ Returns an alist." (cons (cdr a-pair) (cdr b-pair)))))) -(defun mastodon-profile-update-meta-fields () +(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")) @@ -414,7 +442,7 @@ Returns an alist." (response (mastodon-http--patch url params))) (mastodon-http--triage response (lambda () - (mastodon-profile-fetch-server-account-settings) + (mastodon-profile--fetch-server-account-settings) (message "Account setting %s updated to %s!" "metadata fields" fields-updated))))) @@ -458,7 +486,7 @@ This endpoint only holds a few preferences. For others, see (mastodon-http--get-json (mastodon-http--api "preferences")))) -(defun mastodon-profile-view-preferences () +(defun mastodon-profile--view-preferences () "View user preferences in another window." (interactive) (let* ((url (mastodon-http--api "preferences")) @@ -483,11 +511,10 @@ This endpoint only holds a few preferences. For others, see (defun mastodon-profile--relationships-get (id) "Fetch info about logged-in user's relationship to user with id ID." (let* ((their-id id) - (url (mastodon-http--api (format - "accounts/relationships?id[]=%s" - their-id)))) + (args `(("id[]" . ,their-id))) + (url (mastodon-http--api "accounts/relationships"))) ;; FIXME: not sure why we need to do this for relationships only! - (car (mastodon-http--get-json url)))) + (car (mastodon-http--get-json url args)))) (defun mastodon-profile--fields-get (&optional account fields) "Fetch the fields vector (aka profile metadata) from profile of ACCOUNT. @@ -518,8 +545,9 @@ FIELDS means provide a fields vector fetched by other means." (defun mastodon-profile--get-statuses-pinned (account) "Fetch the pinned toots for ACCOUNT." (let* ((id (mastodon-profile--account-field account 'id)) - (url (mastodon-http--api (format "accounts/%s/statuses?pinned=true" id)))) - (mastodon-http--get-json url))) + (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." @@ -529,14 +557,27 @@ FIELDS means provide a fields vector fetched by other means." (mastodon-tl--toot pinned-status)) pinned-statuses)) -(defun mastodon-profile--make-profile-buffer-for (account endpoint-type update-function) - "Display profile of ACCOUNT, using ENDPOINT-TYPE and UPDATE-FUNCTION." +(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* ((id (mastodon-profile--account-field account 'id)) + (args (when no-reblogs '(("exclude_reblogs" . "t")))) (url (mastodon-http--api (format "accounts/%s/%s" id endpoint-type))) (acct (mastodon-profile--account-field account 'acct)) (buffer (concat "*mastodon-" acct "-" endpoint-type "*")) + (response (if headers + (mastodon-http--get-response url args) + (mastodon-http--get-json url args))) + (json (if headers (car response) response)) + (endpoint (format "accounts/%s/%s" id endpoint-type)) + (link-header (when headers + (mastodon-tl--get-link-header-from-response + (cdr response)))) + (note (mastodon-profile--account-field account 'note)) - (json (mastodon-http--get-json url)) (locked (mastodon-profile--account-field account 'locked)) (followers-count (mastodon-tl--as-string (mastodon-profile--account-field @@ -554,16 +595,17 @@ FIELDS means provide a fields vector fetched by other means." (alist-get 'followed_by relationships))) (followsp (or (equal follows-you 't) (equal followed-by-you 't))) (fields (mastodon-profile--fields-get account)) - (pinned (mastodon-profile--get-statuses-pinned account))) + (pinned (mastodon-profile--get-statuses-pinned account)) + (joined (mastodon-profile--account-field account 'created_at))) (with-output-to-temp-buffer buffer (switch-to-buffer buffer) (mastodon-mode) (mastodon-profile-mode) - (setq mastodon-profile--account account - mastodon-tl--buffer-spec - `(buffer-name ,buffer - endpoint ,(format "accounts/%s/%s" id endpoint-type) - update-function ,update-function)) + (setq mastodon-profile--account account) + (mastodon-tl--set-buffer-spec buffer + endpoint + update-function + link-header) (let* ((inhibit-read-only t) (is-statuses (string= endpoint-type "statuses")) (is-followers (string= endpoint-type "followers")) @@ -591,15 +633,22 @@ FIELDS means provide a fields vector fetched by other means." " [locked]") "") "\n ------------\n" - (mastodon-tl--render-text note account) + ;; profile note: ;; account here to enable tab-stops in profile note + (mastodon-tl--render-text note account) + ;; meta fields: (if fields (concat "\n" (mastodon-tl--set-face (mastodon-profile--fields-insert fields) - 'success) - "\n") - "")) + 'success)) + "") + "\n" + ;; Joined date: + (propertize + (mastodon-profile--format-joined-date-string joined) + 'face 'success) + "\n\n") 'profile-json account) ;; insert counts (mastodon-tl--set-face @@ -634,9 +683,16 @@ FIELDS means provide a fields vector fetched by other means." (funcall update-function json))) (goto-char (point-min)))) +(defun mastodon-profile--format-joined-date-string (joined) + "Format a human-readable Joined string from timestamp JOINED." + (let ((joined-ts (ts-parse joined))) + (format "Joined %s" (concat (ts-month-name joined-ts) + " " + (number-to-string + (ts-year joined-ts)))))) + (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 @@ -692,7 +748,6 @@ IMG_TYPE is the JSON key from the account data." (defun mastodon-profile--account-field (account field) "Return FIELD from the ACCOUNT. - FIELD is used to identify regions under 'account" (cdr (assoc field account))) @@ -707,7 +762,8 @@ Used to view a user's followers and those they're following." (let ((start-pos (point))) (insert "\n" (propertize - (mastodon-tl--byline-author `((account . ,toot))) + (mastodon-tl--byline-author `((account . ,toot)) + :avatar) 'byline 't 'toot-id (alist-get 'id toot) 'base-toot-id (mastodon-tl--toot-id toot) @@ -722,17 +778,18 @@ Used to view a user's followers and those they're following." (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 (format "accounts/search?q=%s" handle)))))) + (mastodon-http--api "accounts/search") + args)))) (when (equal 1 (length matching-account)) (elt matching-account 0)))) @@ -743,15 +800,14 @@ If the handle does not match a search return then retun NIL." (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)) + (mentions (or (alist-get 'mentions (alist-get 'status status)) (alist-get 'mentions status))) - (reblog (or (alist-get 'reblog (alist-get 'status status)) + (reblog (or (alist-get 'reblog (alist-get 'status status)) (alist-get 'reblog status)))) (seq-filter 'stringp diff --git a/lisp/mastodon-search.el b/lisp/mastodon-search.el index d161544..65c5aba 100644 --- a/lisp/mastodon-search.el +++ b/lisp/mastodon-search.el @@ -47,7 +47,13 @@ (defvar mastodon-toot--enable-completion-for-mentions) (defvar mastodon-tl--buffer-spec) -;; functions for company completion of mentions in mastodon-toot +;; functions for completion of mentions in mastodon-toot + +(defun mastodon-search--get-user-info-@-capf (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--get-user-info-@ (account) "Get user handle, display name and account URL from ACCOUNT." @@ -55,15 +61,17 @@ (concat "@" (cdr (assoc 'acct account))) (cdr (assoc 'url account)))) -(defun mastodon-search--search-accounts-query (query) +(defun mastodon-search--search-accounts-query (query &optional capf) "Prompt for a search QUERY and return accounts synchronously. Returns a nested list containing user handle, display name, and URL." (interactive "sSearch mastodon for: ") (let* ((url (mastodon-http--api "accounts/search")) (response (if (equal mastodon-toot--completion-style-for-mentions "following") - (mastodon-http--get-search-json url query "following=true") - (mastodon-http--get-search-json url query)))) - (mapcar #'mastodon-search--get-user-info-@ response))) + (mastodon-http--get-json url `(("q" . ,query) ("following" . "true")) :silent) + (mastodon-http--get-json url `(("q" . ,query)) :silent)))) + (if capf + (mapcar #'mastodon-search--get-user-info-@-capf response) + (mapcar #'mastodon-search--get-user-info-@ response)))) ;; functions for tags completion: @@ -72,8 +80,9 @@ Returns a nested list containing user handle, display name, and URL." QUERY is the string to search." (interactive "sSearch for hashtag: ") (let* ((url (format "%s/api/v2/search" mastodon-instance-url)) - (type-param (concat "type=hashtags")) - (response (mastodon-http--get-search-json url query type-param)) + (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))) @@ -112,7 +121,7 @@ QUERY is the string to search." (interactive "sSearch mastodon for: ") (let* ((url (format "%s/api/v2/search" mastodon-instance-url)) (buffer (format "*mastodon-search-%s*" query)) - (response (mastodon-http--get-search-json url query)) + (response (mastodon-http--get-json url `(("q" . ,query)))) (accts (alist-get 'accounts response)) (tags (alist-get 'hashtags response)) (statuses (alist-get 'statuses response)) @@ -162,34 +171,37 @@ QUERY is the string to search." (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 +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) - (let ((user (mastodon-search--get-user-info acct))) - (insert - (propertize - (concat (propertize (car user) - 'face 'mastodon-display-name-face - 'byline t - 'toot-id "0") - " : \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) nil) - "") - "\n") - 'toot-json acct)))) ; so named for compat w other processing functions + (insert (mastodon-search--propertize-user acct note))) 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))) + (propertize + (concat (propertize (car user) + 'face 'mastodon-display-name-face + 'byline t + 'toot-id "0") + " : \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) nil) + "") + "\n") + 'toot-json acct))) ; so named for compat w other processing functions + (defun mastodon-search--print-tags-list (tags) "Insert a propertized list of TAGS." (mapc (lambda (el) diff --git a/lisp/mastodon-tl.el b/lisp/mastodon-tl.el index 7477b25..1a5fc33 100644 --- a/lisp/mastodon-tl.el +++ b/lisp/mastodon-tl.el @@ -61,8 +61,9 @@ (autoload 'mastodon-profile--lookup-account-in-status "mastodon-profile") (autoload 'mastodon-profile-mode "mastodon-profile") ;; make notifications--get available via M-x and outside our keymap: -(autoload 'mastodon-notifications--get "mastodon-notifications" +(autoload 'mastodon-notifications-get "mastodon-notifications" "Display NOTIFICATIONS in buffer." t) ; interactive +(autoload 'mastodon-search--propertize-user "mastodon-search") (autoload 'mastodon-search--insert-users-propertized "mastodon-search") (autoload 'mastodon-search--get-user-info "mastodon-search") (autoload 'mastodon-http--delete "mastodon-http") @@ -70,6 +71,14 @@ (autoload 'mastodon-profile--get-preferences-pref "mastodon-profile") (autoload 'mastodon-http--get-response-async "mastodon-http") (autoload 'mastodon-url-lookup "mastodon") +(autoload 'mastodon-auth--get-account-id "mastodon-auth") +(autoload 'mastodon-http--put "mastodon-http") +(autoload 'mastodon-http--process-json "mastodon-http") +(autoload 'mastodon-http--build-array-params-alist "mastodon-http") +(autoload 'mastodon-http--build-params-string "mastodon-http") +(autoload 'mastodon-notifications--filter-types-list "mastodon-notifications") +(autoload 'mastodon-toot--get-toot-edits "mastodon-toot") + (when (require 'mpv nil :no-error) (declare-function mpv-start "mpv")) (defvar mastodon-instance-url) @@ -98,6 +107,13 @@ 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." + :group 'mastodon-tl + :type 'boolean) + (defvar-local mastodon-tl--buffer-spec nil "A unique identifier and functions for each Mastodon buffer.") @@ -136,6 +152,8 @@ If nil `(point-min)' is used instead.") (defvar-local mastodon-tl--timestamp-update-timer nil "The timer that, when set will scan the buffer to update the timestamps.") +;; KEYMAPS + (defvar mastodon-tl--link-keymap (let ((map (make-sparse-keymap))) (define-key map [return] 'mastodon-tl--do-link-action-at-point) @@ -187,7 +205,7 @@ 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--view-filters-keymap - (let ((map ;(make-sparse-keymap))) + (let ((map (copy-keymap mastodon-mode-map))) (define-key map (kbd "d") 'mastodon-tl--delete-filter) (define-key map (kbd "c") 'mastodon-tl--create-filter) @@ -199,7 +217,7 @@ types of mastodon links and not just shr.el-generated ones.") "Keymap for viewing filters.") (defvar mastodon-tl--follow-suggestions-map - (let ((map ;(make-sparse-keymap))) + (let ((map (copy-keymap mastodon-mode-map))) (define-key map (kbd "n") 'mastodon-tl--goto-next-item) (define-key map (kbd "p") 'mastodon-tl--goto-prev-item) @@ -207,6 +225,30 @@ types of mastodon links and not just shr.el-generated ones.") (keymap-canonicalize map)) "Keymap for viewing follow suggestions.") +(defvar mastodon-tl--view-lists-keymap + (let ((map ;(make-sparse-keymap))) + (copy-keymap mastodon-mode-map))) + (define-key map (kbd "D") 'mastodon-tl--delete-list) + (define-key map (kbd "C") 'mastodon-tl--create-list) + (define-key map (kbd "A") 'mastodon-tl--add-account-to-list) + (define-key map (kbd "R") 'mastodon-tl--remove-account-from-list) + (define-key map (kbd "E") 'mastodon-tl--edit-list) + (define-key map (kbd "n") 'mastodon-tl--goto-next-item) + (define-key map (kbd "p") 'mastodon-tl--goto-prev-item) + (define-key map (kbd "g") 'mastodon-tl--view-lists) + (keymap-canonicalize map)) + "Keymap for viewing lists.") + +(defvar mastodon-tl--list-name-keymap + (let ((map (make-sparse-keymap))) + (define-key map (kbd "<return>") 'mastodon-tl--view-timeline-list-at-point) + (define-key map (kbd "d") 'mastodon-tl--delete-list-at-point) + (define-key map (kbd "a") 'mastodon-tl--add-account-to-list-at-point) + (define-key map (kbd "r") 'mastodon-tl--remove-account-from-list-at-point) + (define-key map (kbd "e") 'mastodon-tl--edit-list-at-point) + (keymap-canonicalize map)) + "Keymap for when point is on list name.") + (defvar mastodon-tl--byline-link-keymap (when (require 'mpv nil :no-error) (let ((map (make-sparse-keymap))) @@ -226,6 +268,7 @@ NAME is not part of the symbol table, '?' is returned." (cdr symbol)) "?")) +;; NAV (defun mastodon-tl--next-tab-item () "Move to the next interesting item. @@ -269,52 +312,6 @@ text, i.e. hidden spoiler text." (goto-char (car next-range)) (message "%s" (get-text-property (point) 'help-echo))))) -(defun mastodon-tl--get-federated-timeline () - "Opens federated timeline." - (interactive) - (message "Loading federated timeline...") - (mastodon-tl--init - "federated" "timelines/public" 'mastodon-tl--timeline)) - -(defun mastodon-tl--get-home-timeline () - "Opens home timeline." - (interactive) - (message "Loading home timeline...") - (mastodon-tl--init - "home" "timelines/home" 'mastodon-tl--timeline)) - -(defun mastodon-tl--get-local-timeline () - "Opens local timeline." - (interactive) - (message "Loading local timeline...") - (mastodon-tl--init - "local" "timelines/public?local=true" 'mastodon-tl--timeline)) - -(defun mastodon-tl--get-tag-timeline () - "Prompt for tag and opens its timeline." - (interactive) - (let* ((word (or (word-at-point) "")) - (input (read-string (format "Load timeline for tag (%s): " word))) - (tag (if (string-empty-p input) word input))) - (message "Loading timeline for #%s..." tag) - (mastodon-tl--show-tag-timeline tag))) - -(defun mastodon-tl--show-tag-timeline (tag) - "Opens a new buffer showing the timeline of posts with hastag TAG." - (mastodon-tl--init - (concat "tag-" tag) (concat "timelines/tag/" tag) 'mastodon-tl--timeline)) - -(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 - (get-text-property (point) 'toot-json))) - (echo (get-text-property (point) 'help-echo))) - (when echo ; not for followers/following in profile - (unless (or (string= type "follow_request") - (string= type "follow")) ; no counts for these - (message "%s" (get-text-property (point) 'help-echo)))))) (defun mastodon-tl--goto-toot-pos (find-pos refresh &optional pos) "Search for toot with FIND-POS. @@ -366,14 +363,64 @@ Used on initializing a timeline or thread." (mastodon-tl--goto-toot-pos 'previous-single-property-change 'previous-line)) +;; TIMELINES + +(defun mastodon-tl--get-federated-timeline () + "Opens federated timeline." + (interactive) + (message "Loading federated timeline...") + (mastodon-tl--init + "federated" "timelines/public" 'mastodon-tl--timeline)) + +(defun mastodon-tl--get-home-timeline () + "Opens home timeline." + (interactive) + (message "Loading home timeline...") + (mastodon-tl--init + "home" "timelines/home" 'mastodon-tl--timeline)) + +(defun mastodon-tl--get-local-timeline () + "Opens local timeline." + (interactive) + (message "Loading local timeline...") + (mastodon-tl--init + "local" "timelines/public?local=true" 'mastodon-tl--timeline)) + +(defun mastodon-tl--get-tag-timeline () + "Prompt for tag and opens its timeline." + (interactive) + (let* ((word (or (word-at-point) "")) + (input (read-string (format "Load timeline for tag (%s): " word))) + (tag (if (string-empty-p input) word input))) + (message "Loading timeline for #%s..." tag) + (mastodon-tl--show-tag-timeline tag))) + +(defun mastodon-tl--show-tag-timeline (tag) + "Opens a new buffer showing the timeline of posts with hastag TAG." + (mastodon-tl--init + (concat "tag-" tag) (concat "timelines/tag/" tag) 'mastodon-tl--timeline)) + +(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 + (get-text-property (point) 'toot-json))) + (echo (get-text-property (point) 'help-echo))) + (when echo ; not for followers/following in profile + (unless (or (string= type "follow_request") + (string= type "follow")) ; no counts for these + (message "%s" (get-text-property (point) 'help-echo)))))) + (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--byline-author (toot) - "Propertize author of TOOT." +(defun mastodon-tl--byline-author (toot &optional avatar) + "Propertize author of TOOT. +With arg AVATAR, include the account's avatar image." (let* ((account (alist-get 'account toot)) (handle (alist-get 'acct account)) (name (if (not (string-empty-p (alist-get 'display_name account))) @@ -384,7 +431,11 @@ Used on initializing a timeline or thread." ;; TODO: Once we have a view for a user (e.g. their posts ;; timeline) make this a tab-stop and attach an action (concat - (when (and mastodon-tl--show-avatars + ;; avatar insertion moved up to `mastodon-tl--byline' by default in order + ;; to be outside of text prop 'byline t. arg avatar is used by + ;; `mastodon-profile--add-author-bylines' + (when (and avatar + mastodon-tl--show-avatars mastodon-tl--display-media-p (if (version< emacs-version "27.1") (image-type-available-p 'imagemagick) @@ -477,7 +528,7 @@ The result is added as an attachments property to author-byline." (let ((reblog (alist-get 'reblog toot))) (when reblog (concat - "\n " + "\n " (propertize "Boosted" 'face 'mastodon-boosted-face) " " (mastodon-tl--byline-author reblog))))) @@ -579,7 +630,11 @@ this just means displaying toot client." (boosted (equal 't (mastodon-tl--field 'reblogged toot))) (bookmarked (equal 't (mastodon-tl--field 'bookmarked toot))) (bookmark-str (mastodon-tl--symbol 'bookmark)) - (visibility (mastodon-tl--field 'visibility toot))) + (visibility (mastodon-tl--field 'visibility toot)) + (account (alist-get 'account toot)) + (avatar-url (alist-get 'avatar account)) + (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 @@ -589,16 +644,28 @@ this just means displaying toot client." ;; 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 "B")) + (mastodon-tl--format-faved-or-boosted-byline + (mastodon-tl--return-boost-char))) (when faved - (mastodon-tl--format-faved-or-boosted-byline "F")) + (mastodon-tl--format-faved-or-boosted-byline + (mastodon-tl--return-fave-char))) (when bookmarked - (mastodon-tl--format-faved-or-boosted-byline bookmark-str))) + (mastodon-tl--format-faved-or-boosted-byline + (mastodon-tl--return-bookmark-char)))) + ;; we remove avatars from the byline also, so that they also do not mess + ;; with `mastodon-tl--goto-next-toot': + (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") (mastodon-tl--symbol 'direct)) ((equal visibility "private") @@ -628,12 +695,69 @@ this just means displaying toot client." 'shr-url app-url 'help-echo app-url 'keymap mastodon-tl--shr-map-replacement))))) + (if edited-time + (concat + (if (fontp (char-displayable-p #10r128274)) + " ✍ " + " [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 "\n ------------\n" 'face 'default)) '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)))) +(defun mastodon-tl--return-boost-char () + "" + (cond + ((fontp (char-displayable-p #10r128257)) + "🔁") + (t + "B"))) + +(defun mastodon-tl--return-fave-char () + "" + (cond + ((fontp (char-displayable-p #10r11088)) + "⭐") + ((fontp (char-displayable-p #10r9733)) + "★") + (t + "F"))) + +(defun mastodon-tl--return-bookmark-char () + "" + (if (fontp (char-displayable-p #10r128278)) + "🔖" + "K")) + +(defun mastodon-tl--format-edit-timestamp (timestamp) + "Convert edit TIMESTAMP into a descriptive string." + (let ((parsed (ts-human-duration + (ts-diff (ts-now) (ts-parse timestamp))))) + (cond ((> (plist-get parsed :days) 0) + (format "%s days ago" (plist-get parsed :days) (plist-get parsed :hours))) + ((> (plist-get parsed :hours) 0) + (format "%s hours ago" (plist-get parsed :hours) (plist-get parsed :minutes))) + ((> (plist-get parsed :minutes) 0) + (format "%s minutes ago" (plist-get parsed :minutes))) + (t ;; we failed to guess: + (format "%s days, %s hours, %s minutes ago" + (plist-get parsed :days) + (plist-get parsed :hours) + (plist-get parsed :minutes)))))) + (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." @@ -932,27 +1056,70 @@ message is a link which unhides/hides the main body." (defun mastodon-tl--media (toot) "Retrieve a media attachment link for TOOT if one exists." - (let* ((media-attachements (mastodon-tl--field 'media_attachments toot)) - (media-string (mapconcat - (lambda (media-attachement) - (let ((preview-url - (alist-get 'preview_url media-attachement)) - (remote-url - (or (alist-get 'remote_url media-attachement) - ;; fallback b/c notifications don't have remote_url - (alist-get 'url media-attachement))) - (type (alist-get 'type media-attachement)) - (caption (alist-get 'description media-attachement))) - (if mastodon-tl--display-media-p - (mastodon-media--get-media-link-rendering - preview-url remote-url type caption) ; 2nd arg for shr-browse-url - (concat "Media::" preview-url "\n")))) - media-attachements ""))) + (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* ((preview-url + (alist-get 'preview_url media-attachment)) + (remote-url + (or (alist-get 'remote_url media-attachment) + ;; fallback b/c notifications don't have remote_url + (alist-get 'url media-attachment))) + (type (alist-get 'type media-attachment)) + (caption (alist-get 'description media-attachment)) + (display-str + (if (and mastodon-tl--display-caption-not-url-when-no-media + caption) + (concat "Media:: " caption) + (concat "Media:: " preview-url)))) + (if mastodon-tl--display-media-p + ;; return placeholder [img]: + (mastodon-media--get-media-link-rendering + preview-url remote-url type caption) ; 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 caption + display-str ;; display + ;; FIXME: shr-link underlining is awful for captions with + ;; newlines, as the underlining runs to the edge of the + ;; frame even if the text doesn' + '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")))) + (defun mastodon-tl--content (toot) "Retrieve text content from TOOT. Runs `mastodon-tl--render-text' and fetches poll or media." @@ -1014,7 +1181,8 @@ this just means displaying toot client." (expiry (mastodon-tl--field 'expires_at poll)) (expired-p (if (eq (mastodon-tl--field 'expired poll) :json-false) nil t)) (multi (mastodon-tl--field 'multiple poll)) - (vote-count (mastodon-tl--field 'voters_count poll)) + (voters-count (mastodon-tl--field 'voters_count poll)) + (vote-count (mastodon-tl--field 'votes_count poll)) (options (mastodon-tl--field 'options poll)) (option-titles (mapcar (lambda (x) (alist-get 'title x)) @@ -1043,10 +1211,16 @@ this just means displaying toot client." options "\n") "\n" - (propertize (if (= vote-count 1) - (format "%s person | " vote-count) - (format "%s people | " vote-count)) - 'face 'font-lock-comment-face) + (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 expired-p "Poll expired." (mastodon-tl--format-poll-expiry expiry)))) @@ -1112,7 +1286,7 @@ this just means displaying toot client." ;; 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 nil))) + (response (mastodon-http--post url arg))) (mastodon-http--triage response (lambda () (message "You voted for option %s: %s!" @@ -1199,7 +1373,7 @@ Optionally get it for BUFFER." (mastodon-tl--get-buffer-property 'buffer-name buffer)) (defun mastodon-tl--link-header (&optional buffer) - "Get the BUFFER-NAME stored in `mastodon-tl--buffer-spec'. + "Get the LINK HEADER stored in `mastodon-tl--buffer-spec'. Optionally get it for BUFFER." (mastodon-tl--get-buffer-property 'link-header buffer)) @@ -1225,39 +1399,24 @@ LINK-HEADER is the http Link header if present." (defun mastodon-tl--more-json (endpoint id) "Return JSON for timeline ENDPOINT before ID." - (let* ((url (mastodon-http--api (concat - endpoint - (if (string-match-p "?" endpoint) - "&" - "?") - "max_id=" - (mastodon-tl--as-string id))))) - (mastodon-http--get-json url))) + (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 callback &rest cbargs) "Return JSON for timeline ENDPOINT before ID. Then run CALLBACK with arguments CBARGS." - (let* ((url (mastodon-http--api (concat - endpoint - (if (string-match-p "?" endpoint) - "&" - "?") - "max_id=" - (mastodon-tl--as-string id))))) - (apply 'mastodon-http--get-json-async url callback cbargs))) + (let* ((args `(("max_id" . ,(mastodon-tl--as-string id)))) + (url (mastodon-http--api endpoint))) + (apply 'mastodon-http--get-json-async url args callback cbargs))) ;; TODO ;; Look into the JSON returned here by Local (defun mastodon-tl--updated-json (endpoint id) "Return JSON for timeline ENDPOINT since ID." - (let ((url (mastodon-http--api (concat - endpoint - (if (string-match-p "?" endpoint) - "&" - "?") - "since_id=" - (mastodon-tl--as-string id))))) - (mastodon-http--get-json url))) + (let* ((args `(("since_id" . ,(mastodon-tl--as-string id)))) + (url (mastodon-http--api endpoint))) + (mastodon-http--get-json url args))) (defun mastodon-tl--property (prop &optional backward) "Get property PROP for toot at point. @@ -1337,8 +1496,9 @@ ID is that of the toot to view." ;; refetch current toot in case we just faved/boosted: (mastodon-http--get-json (mastodon-http--api (concat "statuses/" id)) + nil :silent)) - (context (mastodon-http--get-json url :silent)) + (context (mastodon-http--get-json url nil :silent)) (marker (make-marker))) (if (equal (caar toot) 'error) (message "Error: %s" (cdar toot)) @@ -1369,6 +1529,271 @@ ID is that of the toot to view." ;; else just print the lone toot: (mastodon-tl--single-toot id))))))) +;;; LISTS + +(defun mastodon-tl--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-tl--get-lists-names () + "Return a list of the user's lists' names." + (let ((lists (mastodon-tl--get-users-lists))) + (mapcar (lambda (x) + (alist-get 'title x)) + lists))) + +(defun mastodon-tl--get-list-by-name (name) + "Return the list data for list with NAME." + (let* ((lists (mastodon-tl--get-users-lists))) + (cl-loop for list in lists + if (string= (alist-get 'title list) name) + return list))) + +(defun mastodon-tl--get-list-id (name) + "Return id for list with NAME." + (let ((list (mastodon-tl--get-list-by-name name))) + (alist-get 'id list))) + +(defun mastodon-tl--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-tl--edit-list-at-point () + "Edit list at point." + (interactive) + (let ((id (get-text-property (point) 'list-id))) + (mastodon-tl--edit-list id))) + +(defun mastodon-tl--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-tl--get-lists-names))) + (name-old (if id + (get-text-property (point) 'list-name) + (completing-read "Edit list: " + list-names))) + (id (or id (mastodon-tl--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")) + (url (mastodon-http--api (format "lists/%s" id))) + (response (mastodon-http--put url + `(("title" . ,name-choice) + ("replies_policy" . ,replies-policy))))) + (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 (equal (buffer-name (current-buffer)) + "*mastodon-lists*") + (mastodon-tl--view-lists)))))) + +(defun mastodon-tl--view-timeline-list-at-point () + "View timeline of list at point." + (interactive) + (let ((list-id (get-text-property (point) 'list-id))) + (mastodon-tl--view-list-timeline list-id))) + +(defun mastodon-tl--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-tl--get-lists-names))) + (list-name (unless id (completing-read "View list: " list-names))) + (id (or id (mastodon-tl--get-list-id list-name))) + (endpoint (format "timelines/list/%s" id)) + (name (mastodon-tl--get-list-name id)) + (buffer-name (format "list-%s" name))) + (mastodon-tl--init buffer-name endpoint 'mastodon-tl--timeline))) + +(defun mastodon-tl--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 + (response (mastodon-http--post (mastodon-http--api "lists") + `(("title" . ,title) + ("replies_policy" . ,replies-policy)) + nil))) + (mastodon-tl--list-action-triage response + (message "list %s created!" title)))) + +(defun mastodon-tl--delete-list-at-point () + "Delete list at point." + (interactive) + (let ((id (get-text-property (point) 'list-id))) + (mastodon-tl--delete-list id))) + +(defun mastodon-tl--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-tl--get-lists-names))) + (name (if id + (mastodon-tl--get-list-name id) + (completing-read "Delete list: " + list-names))) + (id (or id (mastodon-tl--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-tl--list-action-triage response + (message "list %s deleted!" name)))))) + +(defun mastodon-tl--view-lists () + "Show the user's lists in a new buffer." + (interactive) + (mastodon-tl--init-sync "lists" + "lists" + 'mastodon-tl--insert-lists) + (use-local-map mastodon-tl--view-lists-keymap)) + +(defun mastodon-tl--insert-lists (_json) + "Insert the user's lists from JSON." + ;; TODO: for now we don't use the JSON, we get it ourself again + (let* ((lists-names (mastodon-tl--get-lists-names))) + (erase-buffer) + (insert (mastodon-tl--set-face + (concat "\n ------------\n" + " YOUR LISTS\n" + " ------------\n\n") + 'success) + (mastodon-tl--set-face + "[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]\n\n" + 'font-lock-comment-face)) + (mapc (lambda (x) + (mastodon-tl--print-list-accounts x) + (insert (propertize " ------------\n\n" + 'face 'success))) + lists-names) + (goto-char (point-min)))) +;; (mastodon-tl--goto-next-item))) ; causes another request! + +(defun mastodon-tl--print-list-accounts (list-name) + "Insert the accounts in list named LIST-NAME." + (let* ((id (mastodon-tl--get-list-id list-name)) + (accounts (mastodon-tl--accounts-in-list id))) + (insert + (propertize list-name + 'byline t ; so we nav here + 'toot-id "0" ; so we nav here + '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-tl--list-name-keymap + 'list-name list-name + 'list-id id) + (propertize + "\n\n" + 'list t + 'keymap mastodon-tl--list-name-keymap + 'list-name list-name + 'list-id id) + (propertize + (mapconcat #'mastodon-search--propertize-user accounts + " ") + ;; (mastodon-search--insert-users-propertized accounts) + 'list t + 'keymap mastodon-tl--list-name-keymap + 'list-name list-name + 'list-id id)))) + +(defun mastodon-tl--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))) + +(defun mastodon-tl--add-account-to-list-at-point () + "Prompt for account and add to list at point." + (interactive) + (let ((id (get-text-property (point) 'list-id))) + (mastodon-tl--add-account-to-list id))) + +(defun mastodon-tl--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-name (if id + (get-text-property (point) 'list-name) + (completing-read "Add account to list: " + (mastodon-tl--get-lists-names) nil t))) + (list-id (or id (mastodon-tl--get-list-id list-name))) + (followings (mastodon-tl--get-users-followings)) + (handles (mapcar (lambda (x) + (cons (alist-get 'acct x) + (alist-get 'id x))) + followings)) + (account (or handle (completing-read "Account to add: " + handles nil t))) + (account-id (or account-id (alist-get account handles nil nil 'equal))) + (url (mastodon-http--api (format "lists/%s/accounts" list-id))) + (response (mastodon-http--post url + `(("account_ids[]" . ,account-id))))) + (mastodon-tl--list-action-triage + response + (message "%s added to list %s!" account list-name)))) + +(defun mastodon-tl--remove-account-from-list-at-point () + "Prompt for account and remove from list at point." + (interactive) + (let ((id (get-text-property (point) 'list-id))) + (mastodon-tl--remove-account-from-list id))) + +(defun mastodon-tl--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 + (get-text-property (point) 'list-name) + (completing-read "Remove account from list: " + (mastodon-tl--get-lists-names) nil t))) + (list-id (or id (mastodon-tl--get-list-id list-name))) + (accounts (mastodon-tl--accounts-in-list list-id)) + (handles (mapcar (lambda (x) + (cons (alist-get 'acct x) + (alist-get 'id x))) + accounts)) + (account (completing-read "Account to remove: " + handles nil t)) + (account-id (alist-get account handles nil nil 'equal)) + (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-tl--list-action-triage + response + (message "%s removed from list %s!" account list-name)))) + +(defun mastodon-tl--list-action-triage (response message) + "Call `mastodon-http--triage' on RESPONSE and display MESSAGE." + (mastodon-http--triage response + (lambda () + (when (equal (buffer-name (current-buffer)) + "*mastodon-lists*") + (mastodon-tl--view-lists)) + message))) + +(defun mastodon-tl--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))) + +;;; FILTERS + (defun mastodon-tl--create-filter () "Create a filter for a word. Prompt for a context, must be a list containting at least one of \"home\", @@ -1382,7 +1807,7 @@ Prompt for a context, must be a list containting at least one of \"home\", (if (string-empty-p word) (error "You must select at least one word for a filter") (completing-read-multiple - "Contexts to filter [TAB for options]:" + "Contexts to filter [TAB for options]: " '("home" "notifications" "public" "thread") nil ; no predicate t))) ; require-match, as context is mandatory @@ -1394,8 +1819,7 @@ Prompt for a context, must be a list containting at least one of \"home\", contexts))) (response (mastodon-http--post url (push `("phrase" . ,word) - contexts-processed) - nil))) + contexts-processed)))) (mastodon-http--triage response (lambda () (message "Filter created for %s!" word) @@ -1464,6 +1888,8 @@ JSON is what is returned by by the server." (mastodon-tl--view-filters) (message "Filter for \"%s\" deleted!" phrase))))))) +;;; FOLLOW SUGGESTIONS + (defun mastodon-tl--get-follow-suggestions () "Display a buffer of suggested accounts to follow." (interactive) @@ -1491,23 +1917,25 @@ RESPONSE is the JSON returned by the server." (message "Looks like there's no toot or user at point?") ,@body)) -(defun mastodon-tl-view-own-instance (&optional brief) +;;;; INSTANCES + +(defun mastodon-tl--view-own-instance (&optional brief) "View details of your own instance. BRIEF means show fewer details." (interactive) - (mastodon-tl-view-instance-description :user brief)) + (mastodon-tl--view-instance-description :user brief)) -(defun mastodon-tl-view-own-instance-brief () +(defun mastodon-tl--view-own-instance-brief () "View brief details of your own instance." (interactive) - (mastodon-tl-view-instance-description :user :brief)) + (mastodon-tl--view-instance-description :user :brief)) -(defun mastodon-tl-view-instance-description-brief () +(defun mastodon-tl--view-instance-description-brief () "View brief details of the instance the current post's author is on." (interactive) - (mastodon-tl-view-instance-description nil :brief)) + (mastodon-tl--view-instance-description nil :brief)) -(defun mastodon-tl-view-instance-description (&optional user brief instance) +(defun mastodon-tl--view-instance-description (&optional user brief instance) "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. @@ -1539,9 +1967,9 @@ INSTANCE is an instance domain name." (response (mastodon-http--get-json (if user (mastodon-http--api "instance") - (concat instance - "/api/v1/instance")) - nil + (concat instance "/api/v1/instance")) + nil ; params + nil ; silent :vector))) (when response (let ((buf (get-buffer-create "*mastodon-instance*"))) @@ -1563,6 +1991,7 @@ INSTANCE is an instance domain name." (assoc 'rules response) (assoc 'stats response)))) (mastodon-tl--print-json-keys response) + (mastodon-mode) (goto-char (point-min))))))))) (defun mastodon-tl--format-key (el pad) @@ -1666,6 +2095,8 @@ IND is the optional indentation level to print at." "\n" ""))) +;;; FOLLOW/BLOCK/MUTE, ETC + (defun mastodon-tl--follow-user (user-handle &optional notify) "Query for USER-HANDLE from current status and follow that user. If NOTIFY is \"true\", enable notifications when that user posts. @@ -1768,7 +2199,7 @@ Can be called to toggle NOTIFY on users already being followed." (defun mastodon-tl--interactive-blocks-or-mutes-list-get (action) "Fetch the list of accounts for ACTION from the server. -Action must be either \"unblock\" or \"mute\"." +Action must be either \"unblock\" or \"unmute\"." (let* ((endpoint (cond ((equal action "unblock") "blocks") ((equal action "unmute") @@ -1820,7 +2251,7 @@ NOTIFY is only non-nil when called by `mastodon-tl--follow-user'." "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." - (let ((response (mastodon-http--post url nil nil))) + (let ((response (mastodon-http--post url))) (mastodon-http--triage response (lambda () (cond ((string-equal notify "true") @@ -1835,6 +2266,56 @@ by `mastodon-tl--follow-user' to enable or disable notifications." ((eq notify nil) (message "User %s (@%s) %sed!" name user-handle action))))))) +;; FOLLOW TAGS + +(defun mastodon-tl--get-tag-json (tag) + "Return JSON data about TAG." + (let ((url (mastodon-http--api (format "tags/%s" tag)))) + (mastodon-http--get-json url))) + +(defun mastodon-tl--follow-tag (&optional tag) + "Prompt for a tag and follow it. +If TAG provided, follow it." + (interactive) + (let* ((tag (or tag (read-string "Tag to follow: "))) + (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 if provided, unfollow it." + (interactive) + (let* ((followed-tags-json (unless tag (mastodon-tl--followed-tags))) + (tags (unless tag (mapcar (lambda (x) + (alist-get 'name x)) + 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 () + "List tags followed. If user choses one, display its JSON." + (interactive) + (let* ((followed-tags-json (mastodon-tl--followed-tags)) + (tags (mapcar (lambda (x) + (alist-get 'name x)) + followed-tags-json)) + (tag (completing-read "Tag: " tags))) + (message (prin1-to-string + (mastodon-tl--get-tag-json tag))))) + ;; TODO: add this to new posts in some cases, e.g. in thread view. (defun mastodon-tl--reload-timeline-or-profile () "Reload the current timeline or profile page. @@ -1846,7 +2327,7 @@ For use after e.g. deleting a toot." ((equal (mastodon-tl--get-endpoint) "timelines/public?local=true") (mastodon-tl--get-local-timeline)) ((equal (mastodon-tl--get-endpoint) "notifications") - (mastodon-notifications--get)) + (mastodon-notifications-get)) ((equal (mastodon-tl--buffer-name) (concat "*mastodon-" (mastodon-auth--get-account-name) "-statuses*")) (mastodon-profile--my-profile)) @@ -1864,16 +2345,27 @@ For use after e.g. deleting a toot." (param (cadr split))) (concat url-base "&" param))) +(defun mastodon-tl--use-link-header-p () + "Return t if we are in a view that uses Link header pagination. +Currently this includes favourites, bookmarks, and profile pages +when showing followers or accounts followed." + (let ((buf (buffer-name (current-buffer))) + (endpoint (mastodon-tl--get-endpoint))) + (or (member buf '("*mastodon-favourites*" "*mastodon-bookmarks*")) + (and (string-prefix-p "accounts" endpoint) + (or (string-suffix-p "followers" endpoint) + (string-suffix-p "following" endpoint)))))) + (defun mastodon-tl--more () "Append older toots to timeline, asynchronously." (interactive) (message "Loading older toots...") - (if (string= (buffer-name (current-buffer)) "*mastodon-favourites*") + (if (mastodon-tl--use-link-header-p) ;; link-header: can't build a URL with --more-json-async, endpoint/id: (let* ((next (car (mastodon-tl--link-header))) - (prev (cadr (mastodon-tl--link-header))) + ;;(prev (cadr (mastodon-tl--link-header))) (url (mastodon-tl--build-link-header-url next))) - (mastodon-http--get-response-async url 'mastodon-tl--more* (current-buffer) + (mastodon-http--get-response-async url nil 'mastodon-tl--more* (current-buffer) (point) :headers)) (mastodon-tl--more-json-async (mastodon-tl--get-endpoint) (mastodon-tl--oldest-id) 'mastodon-tl--more* (current-buffer) (point)))) @@ -2054,10 +2546,11 @@ from the start if it is nil." (update-function (mastodon-tl--get-update-function)) (id (mastodon-tl--newest-id)) (json (mastodon-tl--updated-json endpoint id))) - (when json - (let ((inhibit-read-only t)) - (goto-char (or mastodon-tl--update-point (point-min))) - (funcall update-function json))))) + (if json + (let ((inhibit-read-only t)) + (goto-char (or mastodon-tl--update-point (point-min))) + (funcall update-function json)) + (message "nothing to update")))) (defun mastodon-tl--get-link-header-from-response (headers) "Get http Link header from list of http HEADERS." @@ -2068,21 +2561,21 @@ from the start if it is nil." "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." +favourites and bookmarks." (let ((url (mastodon-http--api endpoint)) (buffer (concat "*mastodon-" buffer-name "*"))) (if headers (mastodon-http--get-response-async - url 'mastodon-tl--init* buffer endpoint update-function headers) + url nil 'mastodon-tl--init* buffer endpoint update-function headers) (mastodon-http--get-json-async - url 'mastodon-tl--init* buffer endpoint update-function)))) + url nil 'mastodon-tl--init* buffer endpoint update-function)))) (defun mastodon-tl--init* (response buffer endpoint update-function &optional headers) "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', a cons cell of JSON and http -headers." +`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)) (headers (if headers (cdr response) nil)) (link-header (mastodon-tl--get-link-header-from-response headers))) @@ -2121,14 +2614,24 @@ headers." ;; for everything save profiles (mastodon-tl--goto-first-item))))) -(defun mastodon-tl--init-sync (buffer-name endpoint update-function) +(defun mastodon-tl--init-sync (buffer-name endpoint update-function &optional note-type) "Initialize BUFFER-NAME with timeline targeted by ENDPOINT. UPDATE-FUNCTION is used to receive more toots. -Runs synchronously." - (let* ((url (mastodon-http--api endpoint)) +Runs synchronously. +Optional arg NOTE-TYPE means only get that type of note." + (let* ((exclude-types (when note-type + (mastodon-notifications--filter-types-list note-type))) + (args (when note-type (mastodon-http--build-array-params-alist + "exclude_types[]" exclude-types))) + ;; (query-string (when note-type + ;; (mastodon-http--build-params-string args))) + ;; add note-type exclusions to endpoint so it works in `mastodon-tl--buffer-spec' + ;; that way `mastodon-tl--more' works seamlessly too: + ;; (endpoint (if note-type (concat endpoint "?" query-string) endpoint)) + (url (mastodon-http--api endpoint)) (buffer (concat "*mastodon-" buffer-name "*")) - (json (mastodon-http--get-json url))) + (json (mastodon-http--get-json url args))) (with-output-to-temp-buffer buffer (switch-to-buffer buffer) ;; mastodon-mode wipes buffer-spec, so order must unforch be: diff --git a/lisp/mastodon-toot.el b/lisp/mastodon-toot.el index 125eeea..27e7ce5 100644 --- a/lisp/mastodon-toot.el +++ b/lisp/mastodon-toot.el @@ -41,11 +41,7 @@ (require 'cl-lib) (require 'persist) -(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)) +(require 'mastodon-iso) (defvar mastodon-instance-url) (defvar mastodon-tl--buffer-spec) @@ -76,9 +72,13 @@ (autoload 'mastodon-toot "mastodon") (autoload 'mastodon-profile--get-source-pref "mastodon-profile") (autoload 'mastodon-profile--update-preference "mastodon-profile") -(autoload 'mastodon-profile-fetch-server-account-settings "mastodon-profile") +(autoload 'mastodon-profile--fetch-server-account-settings "mastodon-profile") (autoload 'mastodon-tl--render-text "mastodon-tl") -(autoload 'mastodon-profile-fetch-server-account-settings-maybe "mastodon-profile") +(autoload 'mastodon-profile--fetch-server-account-settings-maybe "mastodon-profile") +(autoload 'mastodon-http--build-array-params-alist "mastodon-http") +(autoload 'mastodon-tl--get-endpoint "mastodon-tl") +(autoload 'mastodon-http--put "mastodon-http") +(autoload 'mastodon-tl--return-fave-char "mastodon-tl") ;; for mastodon-toot--translate-toot-text (autoload 'mastodon-tl--content "mastodon-tl") @@ -100,18 +100,24 @@ :group 'mastodon-toot :type 'integer) -(defcustom mastodon-toot--enable-completion - (if (require 'company nil :noerror) t nil) +(defcustom mastodon-toot--enable-completion t "Whether to enable completion of mentions and hashtags. +Used for completion in toot compose buffer." + :group 'mastodon-toot + :type 'boolean) + +(defcustom mastodon-toot--use-company-for-completion nil + "Whether to enable company for completion. -Used for completion in toot compose buffer. +When non-nil, `company-mode' is enabled in the toot compose +buffer, and mastodon completion backends are added to +`company-capf'. -This is only used if company mode is installed." +You need to install company yourself to use this." :group 'mastodon-toot :type 'boolean) -(defcustom mastodon-toot--completion-style-for-mentions - (if (require 'company nil :noerror) "following" "off") +(defcustom mastodon-toot--completion-style-for-mentions "all" "The company completion style to use for mentions." :group 'mastodon-toot :type '(choice @@ -155,7 +161,7 @@ Valid values are \"direct\", \"private\" (followers-only), This is determined by the account setting on the server. To change the setting on the server, see -`mastodon-toot-set-default-visibility'.") +`mastodon-toot--set-default-visibility'.") (defvar-local mastodon-toot--media-attachments nil "A list of the media attachments of the toot being composed.") @@ -166,9 +172,14 @@ change the setting on the server, see (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--reply-to-id nil "Buffer-local variable to hold the id of the toot being replied to.") +(defvar-local mastodon-toot--edit-toot-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. @@ -177,6 +188,9 @@ 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.") @@ -186,6 +200,21 @@ 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.") +(defvar mastodon-toot-handle-regex + (concat + ;; preceding space or bol [boundary doesn't work with @] + "\\([\n\t ]\\|^\\)" + "\\(?2:@[1-9a-zA-Z._-]+" ; a handle + "\\(@[^ \n\t]*\\)?\\)" ; with poss domain, * = allow only @ + "\\b")) + +(defvar mastodon-toot-tag-regex + (concat + ;; preceding space or bol [boundary doesn't work with #] + "\\([\n\t ]\\|^\\)" + "\\(?2:#[1-9a-zA-Z_]+\\)" ; tag + "\\b")) ; boundary + (defvar mastodon-toot-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "C-c C-c") #'mastodon-toot--send) @@ -198,10 +227,11 @@ send.") (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-lang) map) "Keymap for `mastodon-toot'.") -(defun mastodon-toot-set-default-visibility () +(defun mastodon-toot--set-default-visibility () "Set the default visibility for toots on the server." (interactive) (let ((vis (completing-read "Set default visibility to:" @@ -214,6 +244,7 @@ send.") 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 @@ -266,7 +297,7 @@ boosting, or bookmarking toots." (mastodon-tl--as-string id) "/" action)))) - (let ((response (mastodon-http--post url nil nil))) + (let ((response (mastodon-http--post url))) (mastodon-http--triage response callback)))) (defun mastodon-toot--toggle-boost-or-favourite (type) @@ -313,7 +344,9 @@ TYPE is a symbol, either 'favourite or 'boost." (list 'boosted-p (not boosted)) (list 'favourited-p (not faved)))) (mastodon-toot--action-success - (if boost-p "B" "F") + (if boost-p + (mastodon-tl--return-boost-char) + (mastodon-tl--return-fave-char)) byline-region remove)) (message (format "%s #%s" (if boost-p msg action) id)))))) (message (format "Nothing to %s here?!?" action-string))))) @@ -366,9 +399,12 @@ TYPE is a symbol, either 'favourite or 'boost." (message (format "Nothing to %s here?!?" action))))) (defun mastodon-toot--copy-toot-url () - "Copy URL of toot at point." + "Copy URL of toot at point. +If the toot is a fave/boost notification, copy the URLof the +base toot." (interactive) - (let* ((toot (mastodon-tl--property 'toot-json)) + (let* ((toot (or (mastodon-tl--property 'base-toot) + (mastodon-tl--property 'toot-json))) (url (if (mastodon-tl--field 'reblog toot) (alist-get 'url (alist-get 'reblog toot)) (alist-get 'url toot)))) @@ -376,9 +412,12 @@ TYPE is a symbol, either 'favourite or 'boost." (message "Toot URL copied to the clipboard."))) (defun mastodon-toot--copy-toot-text () - "Copy text of toot at point." + "Copy text of toot at point. +If the toot is a fave/boost notification, copy the text of the +base toot." (interactive) - (let* ((toot (mastodon-tl--property 'toot-json))) + (let* ((toot (or (mastodon-tl--property 'base-toot) + (mastodon-tl--property 'toot-json)))) (kill-new (mastodon-tl--content toot)) (message "Toot content copied to the clipboard."))) @@ -408,7 +447,8 @@ Uses `lingva.el'." (defun mastodon-toot--pin-toot-toggle () "Pin or unpin user's toot at point." (interactive) - (let* ((toot (mastodon-tl--property 'toot-json)) + (let* ((toot (or (mastodon-tl--property 'base-toot) ;fave/boost notifs + (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")) @@ -433,7 +473,8 @@ Uses `lingva.el'." "Delete and redraft user's toot at point synchronously. NO-REDRAFT means delete toot only." (interactive) - (let* ((toot (mastodon-tl--property 'toot-json)) + (let* ((toot (or (mastodon-tl--property 'base-toot) ;fave/boost notifs + (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)) @@ -458,7 +499,7 @@ NO-REDRAFT means delete toot only." toot-visibility toot-cw))))))))) -(defun mastodon-toot-set-cw (&optional cw) +(defun mastodon-toot--set-cw (&optional cw) "Set content warning to CW if it is non-nil." (unless (string-empty-p cw) (setq mastodon-toot--content-warning t) @@ -477,7 +518,7 @@ REPLY-ID, TOOT-VISIBILITY, and TOOT-CW of deleted toot are preseved." (when reply-id (setq mastodon-toot--reply-to-id reply-id)) (setq mastodon-toot--visibility toot-visibility) - (mastodon-toot-set-cw toot-cw) + (mastodon-toot--set-cw toot-cw) (mastodon-toot--update-status-fields)))) (defun mastodon-toot--kill (&optional cancel) @@ -497,13 +538,13 @@ CANCEL means the toot was not sent, so we save the toot text as a draft." "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) + (if (mastodon-toot--empty-p) (mastodon-toot--kill) (when (y-or-n-p "Save draft toot?") - (mastodon-toot-save-draft)) + (mastodon-toot--save-draft)) (mastodon-toot--kill))) -(defun mastodon-toot-save-draft () +(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'." @@ -513,9 +554,9 @@ Pushes `mastodon-toot-current-toot-text' to mastodon-toot-draft-toots-list :test 'equal) (message "Draft saved!"))) -(defun mastodon-toot-empty-p (&optional text-only) - "Return t if no text, attachments, or polls have been added to the compose buffer. -TEXT-ONLY means don't check for attachments." +(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 (not mastodon-toot--media-attachments) @@ -615,7 +656,8 @@ to `emojify-user-emojis', and the emoji data is updated." (defun mastodon-toot--build-poll-params () "Return an alist of parameters for POSTing a poll status." (append - (mastodon-toot--make-poll-options-params + (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)))) @@ -624,23 +666,33 @@ to `emojify-user-emojis', and the emoji data is updated." (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." +`mastodon-toot--attach-media', they are attached to the toot. +If `mastodon-toot--edit-toot-id' is non-nil, PUT contents to +instance to edit a toot." (interactive) - (let* ((toot (mastodon-toot--remove-docs)) - (endpoint (mastodon-http--api "statuses")) - (spoiler (when (and (not (mastodon-toot-empty-p)) + (let* ((edit-p (if mastodon-toot--edit-toot-id t nil)) + (toot (mastodon-toot--remove-docs)) + (endpoint + (if edit-p + ;; we are sending an edit: + (mastodon-http--api (format "statuses/%s" + mastodon-toot--edit-toot-id)) + (mastodon-http--api "statuses"))) + (spoiler (when (and (not (mastodon-toot--empty-p)) mastodon-toot--content-warning) - (read-string "Warning: " mastodon-toot--content-warning-from-reply-or-redraft))) + (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))) + ("spoiler_text" . ,spoiler) + ("language" . ,mastodon-toot--language))) (args-media (when mastodon-toot--media-attachments - (mapcar (lambda (id) - (cons "media_ids[]" id)) - mastodon-toot--media-attachment-ids))) + (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: @@ -660,16 +712,89 @@ If media items have been attached and uploaded with ((and mastodon-toot--max-toot-chars (> (length toot) mastodon-toot--max-toot-chars)) (message "Looks like your toot is longer than that maximum allowed length.")) - ((mastodon-toot-empty-p) + ((mastodon-toot--empty-p) (message "Empty toot. Cowardly refusing to post this.")) (t - (let ((response (mastodon-http--post endpoint args nil))) + (let ((response (if edit-p + ;; we are sending an edit: + (mastodon-http--put endpoint args) + (mastodon-http--post endpoint args)))) (mastodon-http--triage response (lambda () (mastodon-toot--kill) (message "Toot toot!") (mastodon-toot--restore-previous-window-config prev-window-config)))))))) +;; EDITING TOOTS: + +(defun mastodon-toot--edit-toot-at-point () + "Edit the user's toot at point." + (interactive) + (let ((toot (or (mastodon-tl--property 'base-toot); fave/boost notifs + (mastodon-tl--property 'toot-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--toot-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)) + (reply-id (alist-get 'in_reply_to_id toot))) + (when (y-or-n-p "Edit this toot? ") + (mastodon-toot--compose-buffer) + (goto-char (point-max)) + (insert content) + ;; adopt reply-to-id, visibility and CW: + (when reply-id + (setq mastodon-toot--reply-to-id reply-id)) + (setq mastodon-toot--visibility toot-visibility) + (mastodon-toot--set-cw source-cw) + (mastodon-toot--update-status-fields) + (setq mastodon-toot--edit-toot-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 ((history (mastodon-tl--property 'edit-history))) + (with-current-buffer (get-buffer-create "*mastodon-toot-edits*") + (let ((inhibit-read-only t)) + (special-mode) + (erase-buffer) + (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)) + (switch-to-buffer-other-window (current-buffer)) + (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)))))) + +(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." @@ -703,116 +828,75 @@ eg. \"feduser@fed.social\" -> \"feduser@fed.social\"." (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-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--tags-company-make-candidate (candidate) - "Construct a company completion CANDIDATE for display." - (let ((tag (concat "#" (car candidate))) - (url (cadr candidate))) - (propertize tag 'annot url 'meta url))) - -(defun mastodon-toot--company-build-candidates (query list-fun make-fun) - "Build a list of completion candidates for a company backend. -QUERY is the search prefix, LIST-FUN builds a list of items to -match against, and MAKE-FUN builds the actual cadidate list item -for display by company." - (let ((query (substring query 1)) ; remove @ or # for search - (res)) - (dolist (item (funcall list-fun query)) - (when (or (string-prefix-p query (substring (cadr item) 1) t) - (string-prefix-p query (car item) t)) - (push (funcall make-fun item) res))) - res)) - -(defun mastodon-toot--mentions-company-candidates (query) - "Given a company QUERY, build a list of candidates. -The query can match both user handles and display names." - (mastodon-toot--company-build-candidates - query - 'mastodon-search--search-accounts-query - 'mastodon-toot--mentions-company-make-candidate)) - -(defun mastodon-toot--tags-company-candidates (query) - "Given a company QUERY, build a list of candidates. -The query is matched against a tag search on the server." - (mastodon-toot--company-build-candidates - query - 'mastodon-search--search-tags-query - 'mastodon-toot--tags-company-make-candidate)) - -(defun mastodon-toot--make-company-backend - (command _backend-name str-prefix candidates-fun annot-fun meta-fun - &optional arg - &rest ignored) - "Make a company backend for `mastodon-toot-mode'. -COMMAND, ARG, IGNORED are all company backend args. -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. - -BACKEND-NAME is the backend's name, STR-PREFIX is used to search -for matches, CANDIDATES-FUN, ANNOT-FUN, and META-FUN are -functions called on ARG to generate formatted candidates, annotation, and -meta fields respectively." - (interactive (list 'interactive)) - (cl-case command - (interactive (company-begin-backend (quote backend-name))) - (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 str-prefix))) - (concat str-prefix (company-grab-symbol)))) - (candidates (funcall candidates-fun arg)) - (annotation (funcall annot-fun arg)) - (meta (funcall meta-fun arg)))) - -(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." - (mastodon-toot--make-company-backend - command - 'mastodon-toot-mentions - "@" - 'mastodon-toot--mentions-company-candidates - 'mastodon-toot--mentions-company-annotation - 'mastodon-toot--mentions-company-meta - arg - ignored)) - -(defun mastodon-toot-tags (command &optional arg &rest ignored) - "A company completion backend for toot tags. -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." - (mastodon-toot--make-company-backend - command - 'mastodon-toot-tags - "#" - 'mastodon-toot--tags-company-candidates - 'mastodon-toot--mentions-company-annotation - 'mastodon-toot--mentions-company-meta - arg - ignored)) +(defun mastodon-toot--get-bounds (regex) + "Get bounds of tag or handle before point." + ;; needed because # 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 nil :no-error) + (cons (match-beginning 2) + (match-end 2)))))) + +(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 + ;; only search when necessary: + (completion-table-dynamic + (lambda (_) + ;; TODO: do we really need to set a local var here + ;; just for the annotation-function? + (setq mastodon-toot-completions + (mastodon-search--search-accounts-query + (buffer-substring-no-properties start end) + :capf)))) + :exclusive 'no + :annotation-function + (lambda (candidate) + (concat " " + (mastodon-toot--mentions-annotation-fun candidate))))))) + +(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 + ;; only search when necessary: + (completion-table-dynamic + (lambda (_) + (setq mastodon-toot-completions + (let ((tags (mastodon-search--search-tags-query + (buffer-substring-no-properties start end)))) + (mapcar (lambda (x) + (list (concat "#" (car x)) + (cdr x))) + tags))))) + :exclusive 'no + :annotation-function + (lambda (candidate) + (concat " " + (mastodon-toot--tags-annotation-fun candidate))))))) + +(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." + ;; FIXME check the list returned here? should be cadr + ;;or make it an alist and use cdr + (caadr (assoc candidate mastodon-toot-completions))) (defun mastodon-toot--reply () "Reply to toot at `point'. @@ -960,12 +1044,6 @@ which is used to attach it to a toot when posting." mastodon-toot--media-attachments)) (list "None"))) -(defun mastodon-toot--make-poll-options-params (options) - "Return an parameter query alist from poll OPTIONS." - (let ((key "poll[options][]")) - (cl-loop for o in options - collect `(,key . ,o)))) - (defun mastodon-toot--fetch-max-poll-options () "Return the maximum number of poll options." (mastodon-toot--fetch-poll-field 'max_options)) @@ -976,7 +1054,7 @@ which is used to attach it to a toot when posting." 50)) ; masto default (defun mastodon-toot--fetch-poll-field (field) - "Return FIELD from the poll settings from the user's instance. " + "Return FIELD from the poll settings from the user's instance." (let* ((instance (mastodon-http--get-json (mastodon-http--api "instance")))) (alist-get field (alist-get 'polls @@ -1008,7 +1086,8 @@ MAX is the maximum number set by their instance." (message "poll created!"))) (defun mastodon-toot--read-poll-options (count length) - "Read a list of options for poll of LENGTH options." + "Read a list of options for poll with COUNT options. +LENGTH is the maximum character length allowed for a poll option." (cl-loop for x from 1 to count collect (read-string (format "Poll option [%s/%s] [max %s chars]: " x count length)))) @@ -1035,6 +1114,19 @@ MAX is the maximum number set by their instance." ("14 days" . ,(number-to-string (* 60 60 24 14))) ("30 days" . ,(number-to-string (* 60 60 24 30))))) +(defun mastodon-toot--set-toot-lang () + "Prompt for a language and set `mastodon-toot--language'. +Return its two letter ISO 639 1 code." + (interactive) + (let* ((langs (mapcar (lambda (x) + (cons (cadr x) + (car x))) + mastodon-iso-639-1)) + (choice (completing-read "Language for this toot: " + langs))) + (setq mastodon-toot--language + (alist-get choice langs nil nil 'equal)))) + ;; we'll need to revisit this if the binds get ;; more diverse than two-chord bindings (defun mastodon-toot--get-mode-kbinds () @@ -1162,26 +1254,28 @@ REPLY-JSON is the full JSON of the toot being replied to." (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)))) + (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 + (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))) - (count-region (mastodon-tl--find-property-range 'toot-post-counter + (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))) - (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)))) + (cw-region (mastodon-tl--find-property-range 'toot-post-cw-flag + (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 characters" - (- (point-max) (cdr header-region)) + (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 @@ -1201,6 +1295,26 @@ REPLY-JSON is the full JSON of the toot being replied to." (list 'invisible (not mastodon-toot--content-warning) 'face 'mastodon-cw-face))))) +(defun mastodon-toot--count-toot-chars (toot-string) + "Count the characters in TOOT-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 toot-string) + (goto-char (point-min)) + ;; handle URLs + (while (search-forward-regexp "\\w+://[^ \n]*" nil t) ; URL + (replace-match "xxxxxxxxxxxxxxxxxxxxxxx")) ; 23 x's + ;; handle @handles + (goto-char (point-min)) + (while (search-forward-regexp (concat "\\(?2:@[^ @\n]+\\)" ; a handle only + "\\(@[^ \n]+\\)?" ; with poss domain + "\\b") + nil t) + (replace-match (match-string 2))) ; replace with handle only + (length (buffer-substring (point-min) (point-max))))) + (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." @@ -1208,15 +1322,15 @@ Added to `after-change-functions' in new toot buffers." (unless (string-empty-p text) (setq mastodon-toot-current-toot-text text)))) -(defun mastodon-toot-open-draft-toot () +(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)) + (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) @@ -1228,11 +1342,11 @@ Added to `after-change-functions' in new toot buffers." ;; (delete-region (point) (point-max)) (insert text)) (mastodon-toot--compose-buffer nil nil nil text))) - (unless (mastodon-toot-compose-buffer-p) + (unless (mastodon-toot--compose-buffer-p) (mastodon-toot--compose-buffer)) (message "No drafts available."))) -(defun mastodon-toot-delete-draft-toot () +(defun mastodon-toot--delete-draft-toot () "Prompt for a draft toot and delete it." (interactive) (if mastodon-toot-draft-toots-list @@ -1245,7 +1359,7 @@ Added to `after-change-functions' in new toot buffers." (message "Draft deleted!")) (message "No drafts to delete."))) -(defun mastodon-toot-delete-all-drafts () +(defun mastodon-toot--delete-all-drafts () "Delete all drafts." (interactive) (setq mastodon-toot-draft-toots-list nil) @@ -1254,7 +1368,7 @@ Added to `after-change-functions' in new toot buffers." (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) + (when (mastodon-toot--compose-buffer-p) (let ((header-region (mastodon-tl--find-property-range 'toot-post-header (point-min)))) @@ -1262,16 +1376,12 @@ Added to `after-change-functions'." ;; stops all text after a handle or mention being propertized: (set-text-properties (cdr header-region) (point-max) nil) ;; TODO: confirm allowed hashtag/handle characters: - (mastodon-toot--propertize-item "\\([\n\t ]\\|^\\)\\(?2:#[1-9a-zA-Z_]+\\)\\b" + (mastodon-toot--propertize-item mastodon-toot-tag-regex 'success (cdr header-region)) - (mastodon-toot--propertize-item - (concat "\\([\n\t ]\\|^\\)" ; preceding space or bol - "\\(?2:@[1-9a-zA-Z._-]+" ; a handle - "\\(@[1-9a-zA-Z._-]+\\)?\\)" ; with poss domain - "\\b") ; boundary - 'mastodon-display-name-face - (cdr header-region))))) + (mastodon-toot--propertize-item mastodon-toot-handle-regex + 'mastodon-display-name-face + (cdr header-region))))) (defun mastodon-toot--propertize-item (regex face start) "Propertize item matching REGEX with FACE starting from START." @@ -1282,7 +1392,7 @@ Added to `after-change-functions'." (match-end 2) `(face ,face))))) -(defun mastodon-toot-compose-buffer-p () +(defun mastodon-toot--compose-buffer-p () "Return t if compose buffer is current." (equal (buffer-name (current-buffer)) "*new toot*")) @@ -1319,13 +1429,22 @@ a draft into the buffer." ;; 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 company backends: - (when (require 'company nil :noerror) - (when mastodon-toot--enable-completion + ;; set up completion: + (when mastodon-toot--enable-completion + (set ; (setq-local + (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 mastodon-toot--use-company-for-completion (set (make-local-variable 'company-backends) - (add-to-list 'company-backends 'mastodon-toot-mentions)) - (add-to-list 'company-backends 'mastodon-toot-tags)) - (company-mode-on)) + (add-to-list 'company-backends 'company-capf)) + (company-mode-on))) + ;; after-change: (make-local-variable 'after-change-functions) (push #'mastodon-toot--update-status-fields after-change-functions) (mastodon-toot--refresh-attachments-display) @@ -1340,7 +1459,7 @@ a draft into the buffer." (insert initial-text)))) ;;;###autoload -(add-hook 'mastodon-toot-mode-hook #'mastodon-profile-fetch-server-account-settings-maybe) +(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." diff --git a/lisp/mastodon.el b/lisp/mastodon.el index 7ec6ee3..921e3af 100644 --- a/lisp/mastodon.el +++ b/lisp/mastodon.el @@ -57,7 +57,6 @@ (autoload 'mastodon-tl--thread "mastodon-tl") (autoload 'mastodon-tl--toggle-spoiler-text-in-toot "mastodon-tl") (autoload 'mastodon-tl--update "mastodon-tl") -(autoload 'mastodon-notifications--get "mastodon-notifications") (autoload 'mastodon-profile--get-toot-author "mastodon-profile") (autoload 'mastodon-profile--make-author-buffer "mastodon-profile") (autoload 'mastodon-profile--show-user "mastodon-profile") @@ -91,8 +90,15 @@ (when (require 'lingva nil :no-error) (autoload 'mastodon-toot--translate-toot-text "mastodon-toot")) (autoload 'mastodon-search--trending-tags "mastodon-search") -(autoload 'mastodon-profile-fetch-server-account-settings "mastodon-profile") -(autoload 'mastodon-profile-account-settings "mastodon-profile") +(autoload 'mastodon-profile--fetch-server-account-settings "mastodon-profile") +(autoload 'mastodon-notifications--get-mentions "mastodon-notifications") +(autoload 'mastodon-tl--view-lists "mastodon-tl") +(autoload 'mastodon-toot--edit-toot-at-point "mastodon-toot") +(autoload 'mastodon-toot--view-toot-history "mastodon-tl") +(autoload 'mastodon-tl--init-sync "mastodon-tl") +(autoload 'mastodon-notifications--timeline "mastodon-notifications") + +(defvar mastodon-notifications--map) (defgroup mastodon nil "Interface with Mastodon." @@ -157,7 +163,7 @@ Use. e.g. \"%c\" for your locale's date and time format." (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 "N") #'mastodon-notifications-get) (define-key map (kbd "P") #'mastodon-profile--show-user) (define-key map (kbd "T") #'mastodon-tl--thread) ;; navigation out of mastodon @@ -168,11 +174,13 @@ Use. e.g. \"%c\" for your locale's date and time format." (define-key map (kbd "c") #'mastodon-tl--toggle-spoiler-text-in-toot) (define-key map (kbd "f") #'mastodon-toot--toggle-favourite) (define-key map (kbd "r") #'mastodon-toot--reply) + ;; this is now duplicated by 'g', cd remove/use for else: (define-key map (kbd "u") #'mastodon-tl--update) ;; new toot (define-key map (kbd "t") #'mastodon-toot) ;; override special mode binding (define-key map (kbd "g") #'undefined) + (define-key map (kbd "g") #'mastodon-tl--update) ;; mousebot additions (define-key map (kbd "W") #'mastodon-tl--follow-user) (define-key map (kbd "C-S-W") #'mastodon-tl--unfollow-user) @@ -188,22 +196,19 @@ Use. e.g. \"%c\" for your locale's date and time format." (define-key map (kbd "i") #'mastodon-toot--pin-toot-toggle) (define-key map (kbd "V") #'mastodon-profile--view-favourites) (define-key map (kbd "R") #'mastodon-profile--view-follow-requests) - ;; (define-key map (kbd "C-c h") #'mastodon-async--stream-home) - ;; (define-key map (kbd "C-c f") #'mastodon-async--stream-federated) - ;; (define-key map (kbd "C-c l") #'mastodon-async--stream-local) - ;; (define-key map (kbd "C-c n") #'mastodon-async--stream-notifications) (define-key map (kbd "U") #'mastodon-profile--update-user-profile-note) - (define-key map (kbd "a") #'mastodon-notifications--follow-request-accept) - (define-key map (kbd "j") #'mastodon-notifications--follow-request-reject) (define-key map (kbd "v") #'mastodon-tl--poll-vote) (define-key map (kbd "k") #'mastodon-toot--bookmark-toot-toggle) (define-key map (kbd "K") #'mastodon-profile--view-bookmarks) (define-key map (kbd "I") #'mastodon-tl--view-filters) (define-key map (kbd "G") #'mastodon-tl--get-follow-suggestions) + (define-key map (kbd "X") #'mastodon-tl--view-lists) + (define-key map (kbd "@") #'mastodon-notifications--get-mentions) + (define-key map (kbd "e") #'mastodon-toot--edit-toot-at-point) + (define-key map (kbd "E") #'mastodon-toot--view-toot-edits) (when (require 'lingva nil :no-error) (define-key map (kbd "s") #'mastodon-toot--translate-toot-text)) map) - "Keymap for `mastodon-mode'.") (defcustom mastodon-mode-hook nil @@ -214,7 +219,7 @@ Use. e.g. \"%c\" for your locale's date and time format." (defface mastodon-handle-face '((t :inherit default)) - "Face used for user display names.") + "Face used for user handles in bylines.") (defface mastodon-display-name-face '((t :inherit warning)) @@ -253,7 +258,9 @@ Use. e.g. \"%c\" for your locale's date and time format." (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)))) + (message "Loading Mastodon account %s on %s..." + (mastodon-auth--user-acct) + mastodon-instance-url)))) ;;;###autoload (defun mastodon-toot (&optional user reply-to-id reply-json) @@ -264,6 +271,25 @@ 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) + "Display NOTIFICATIONS in buffer. +Optionally only print notifications of type TYPE, a string. +BUFFER-NAME is added to \"*mastodon-\" to create the buffer name." + (interactive) + (let ((buffer (or (concat "*mastodon-" buffer-name "*") + "*mastodon-notifications*"))) + (if (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) + (use-local-map mastodon-notifications--map)))) + ;; URL lookup: should be available even if `mastodon.el' not loaded: ;;;###autoload @@ -287,8 +313,9 @@ not, just browse the URL in the normal fashion." (browse-url query) (message "Performing lookup...") (let* ((url (format "%s/api/v2/search" mastodon-instance-url)) - (param (concat "resolve=t")) ; webfinger - (response (mastodon-http--get-search-json url query param :silent))) + (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)) @@ -330,7 +357,7 @@ not, just browse the URL in the normal fashion." (mastodon-toot--enable-custom-emoji))))) ;;;###autoload -(add-hook 'mastodon-mode-hook #'mastodon-profile-fetch-server-account-settings) +(add-hook 'mastodon-mode-hook #'mastodon-profile--fetch-server-account-settings) (define-derived-mode mastodon-mode special-mode "Mastodon" "Major mode for Mastodon, the federated microblogging network." diff --git a/test/mastodon-notifications-tests.el b/test/mastodon-notifications-tests.el index 7c87933..942a7cb 100644 --- a/test/mastodon-notifications-tests.el +++ b/test/mastodon-notifications-tests.el @@ -187,11 +187,11 @@ "Ensure get request format for notifictions is accurate." (let ((mastodon-instance-url "https://instance.url")) (with-mock - (mock (mastodon-http--get-json "https://instance.url/api/v1/notifications")) - (mock (mastodon-profile-fetch-server-account-settings) + (mock (mastodon-http--get-json "https://instance.url/api/v1/notifications" nil)) + (mock (mastodon-profile--fetch-server-account-settings) => '(max_toot_chars 1312 privacy "public" display_name "Eugen" discoverable t locked :json-false bot :json-false sensitive :json-false language "")) - (mastodon-notifications--get)))) + (mastodon-notifications-get)))) (defun mastodon-notifications--test-type (fun sample) "Test notification draw functions. diff --git a/test/mastodon-profile-tests.el b/test/mastodon-profile-tests.el index 267e48b..1ce9514 100644 --- a/test/mastodon-profile-tests.el +++ b/test/mastodon-profile-tests.el @@ -172,7 +172,8 @@ The search will happen as if called without the \"@\"." (with-mock (mock (mastodon-http--get-json - "https://instance.url/api/v1/accounts/search?q=gargron")) + "https://instance.url/api/v1/accounts/search" + '(("q" . "gargron")))) (let ((mastodon-instance-url "https://instance.url")) ;; We don't check anything from the return value. We only care @@ -182,7 +183,9 @@ The search will happen as if called without the \"@\"." (ert-deftest mastodon-profile--search-account-by-handle--filters-out-false-results () "Should ignore results that don't match the searched handle." (with-mock - (mock (mastodon-http--get-json *) + (mock (mastodon-http--get-json + "https://instance.url/api/v1/accounts/search" + '(("q" . "Gargron"))) => (vector ccc-profile-json gargron-profile-json)) @@ -197,7 +200,9 @@ The search will happen as if called without the \"@\"." TODO: We need to decide if this is actually desired or not." (with-mock - (mock (mastodon-http--get-json *) => (vector gargron-profile-json)) + (mock (mastodon-http--get-json * + '(("q" . "gargron"))) + => (vector gargron-profile-json)) (let ((mastodon-instance-url "https://instance.url")) (should @@ -232,7 +237,7 @@ content generation in the function under test." (if (version< emacs-version "27.1") (mock (image-type-available-p 'imagemagick) => t) (mock (image-transforms-p) => t)) - (mock (mastodon-http--get-json "https://instance.url/api/v1/accounts/1/statuses") + (mock (mastodon-http--get-json "https://instance.url/api/v1/accounts/1/statuses" nil) => gargon-statuses-json) (mock (mastodon-profile--get-statuses-pinned *) @@ -246,9 +251,11 @@ content generation in the function under test." (mock (shr-render-region * *) => nil) ;; Don't perform the actual update call at the end. ;;(mock (mastodon-tl--timeline *)) - (mock (mastodon-profile-fetch-server-account-settings) + (mock (mastodon-profile--fetch-server-account-settings) => '(max_toot_chars 1312 privacy "public" display_name "Eugen" discoverable t locked :json-false bot :json-false sensitive :json-false language "")) + (mock (mastodon-profile--format-joined-date-string *) => "Joined March 2016") + (let ((mastodon-tl--show-avatars t) (mastodon-tl--display-media-p t) (mastodon-instance-url "https://instance.url")) @@ -264,7 +271,10 @@ content generation in the function under test." "@Gargron\n" " ------------\n" "<p>Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.</p>\n" - "_ Patreon __ :: <a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>_ Homepage _ :: <a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span></a>\n" + "_ Patreon __ :: <a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>_ Homepage _ :: <a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span></a>" + "\n" + "Joined March 2016" + "\n\n" " ------------\n" " TOOTS: 70741 | FOLLOWERS: 470905 | FOLLOWING: 451\n" " ------------\n" diff --git a/test/mastodon-tl-tests.el b/test/mastodon-tl-tests.el index bb5d00f..a80c3ee 100644 --- a/test/mastodon-tl-tests.el +++ b/test/mastodon-tl-tests.el @@ -175,27 +175,30 @@ Strict-Transport-Security: max-age=31536000 "Should request toots older than max_id." (let ((mastodon-instance-url "https://instance.url")) (with-mock - (mock (mastodon-http--get-json "https://instance.url/api/v1/timelines/foo?max_id=12345")) - (mastodon-tl--more-json "timelines/foo" 12345)))) + (mock (mastodon-http--get-json "https://instance.url/api/v1/timelines/foo" + '(("max_id" . "12345")))) + (mastodon-tl--more-json "timelines/foo" "12345")))) (ert-deftest mastodon-tl--more-json-id-string () "Should request toots older than max_id. -`mastodon-tl--more-json' should accept and id that is either -a string or a numeric." + `mastodon-tl--more-json' should accept and id that is either + a string or a numeric." (let ((mastodon-instance-url "https://instance.url")) (with-mock - (mock (mastodon-http--get-json "https://instance.url/api/v1/timelines/foo?max_id=12345")) + (mock (mastodon-http--get-json "https://instance.url/api/v1/timelines/foo" + '(("max_id" . "12345")))) (mastodon-tl--more-json "timelines/foo" "12345")))) (ert-deftest mastodon-tl--update-json-id-string () "Should request toots more recent than since_id. -`mastodon-tl--updated-json' should accept and id that is either -a string or a numeric." + `mastodon-tl--updated-json' should accept and id that is either + a string or a numeric." (let ((mastodon-instance-url "https://instance.url")) (with-mock - (mock (mastodon-http--get-json "https://instance.url/api/v1/timelines/foo?since_id=12345")) + (mock (mastodon-http--get-json "https://instance.url/api/v1/timelines/foo" + '(("since_id" . "12345")))) (mastodon-tl--updated-json "timelines/foo" "12345")))) (ert-deftest mastodon-tl--relative-time-description () @@ -413,7 +416,7 @@ a string or a numeric." (handle2-location 65)) (should (string= (substring-no-properties byline) "Account 42 (@acct42@example.space) - Boosted Account 43 (@acct43@example.space) original time + Boosted Account 43 (@acct43@example.space) original time ------------ ")) (should (eq (get-text-property handle1-location 'mastodon-tab-stop byline) @@ -446,7 +449,7 @@ a string or a numeric." 'mastodon-tl--byline-author 'mastodon-tl--byline-boosted)) "Account 42 (@acct42@example.space) - Boosted Account 43 (@acct43@example.space) original time + Boosted Account 43 (@acct43@example.space) original time ------------ "))))) @@ -470,7 +473,7 @@ a string or a numeric." 'mastodon-tl--byline-author 'mastodon-tl--byline-boosted)) "(B) (F) Account 42 (@acct42@example.space) - Boosted Account 43 (@acct43@example.space) original time + Boosted Account 43 (@acct43@example.space) original time ------------ "))))) @@ -808,8 +811,8 @@ a string or a numeric." (defun tl-tests--property-values-at (property ranges) "Returns a list with property values at the given ranges. -The property value for PROPERTY within a region is assumed to be -constant." + The property value for PROPERTY within a region is assumed to be + constant." (let (result) (dolist (range ranges (nreverse result)) (push (get-text-property (car range) property) result)))) @@ -1047,53 +1050,53 @@ correct value for following, as well as notifications enabled or disabled." (let ((response-buffer-true (current-buffer))) (insert mastodon-tl--follow-notify-true-response) (with-mock - (mock (mastodon-http--post url-follow-only nil nil) - => response-buffer-true) - (should - (equal - (mastodon-tl--do-user-action-function url-follow-only - user-name - user-handle - "follow") - "User some-user (@some-user@instance.url) followed!")) - (mock (mastodon-http--post url-mute nil nil) - => response-buffer-true) - (should - (equal - (mastodon-tl--do-user-action-function url-mute - user-name - user-handle - "mute") - "User some-user (@some-user@instance.url) muted!")) - (mock (mastodon-http--post url-block nil nil) - => response-buffer-true) - (should - (equal - (mastodon-tl--do-user-action-function url-block - user-name - user-handle - "block") - "User some-user (@some-user@instance.url) blocked!"))) + (mock (mastodon-http--post url-follow-only) + => response-buffer-true) + (should + (equal + (mastodon-tl--do-user-action-function url-follow-only + user-name + user-handle + "follow") + "User some-user (@some-user@instance.url) followed!")) + (mock (mastodon-http--post url-mute) + => response-buffer-true) + (should + (equal + (mastodon-tl--do-user-action-function url-mute + user-name + user-handle + "mute") + "User some-user (@some-user@instance.url) muted!")) + (mock (mastodon-http--post url-block) + => response-buffer-true) + (should + (equal + (mastodon-tl--do-user-action-function url-block + user-name + user-handle + "block") + "User some-user (@some-user@instance.url) blocked!"))) (with-mock - (mock (mastodon-http--post url-true nil nil) => response-buffer-true) - (should - (equal - (mastodon-tl--do-user-action-function url-true - user-name - user-handle - "follow" - "true") - "Receiving notifications for user some-user (@some-user@instance.url)!"))))) + (mock (mastodon-http--post url-true) => response-buffer-true) + (should + (equal + (mastodon-tl--do-user-action-function url-true + user-name + user-handle + "follow" + "true") + "Receiving notifications for user some-user (@some-user@instance.url)!"))))) (with-temp-buffer (let ((response-buffer-false (current-buffer))) (insert mastodon-tl--follow-notify-false-response) (with-mock - (mock (mastodon-http--post url-false nil nil) => response-buffer-false) - (should - (equal - (mastodon-tl--do-user-action-function url-false - user-name - user-handle - "follow" - "false") - "Not receiving notifications for user some-user (@some-user@instance.url)!"))))))) + (mock (mastodon-http--post url-false) => response-buffer-false) + (should + (equal + (mastodon-tl--do-user-action-function url-false + user-name + user-handle + "follow" + "false") + "Not receiving notifications for user some-user (@some-user@instance.url)!"))))))) diff --git a/test/mastodon-toot-tests.el b/test/mastodon-toot-tests.el index 39e0984..b88510c 100644 --- a/test/mastodon-toot-tests.el +++ b/test/mastodon-toot-tests.el @@ -120,11 +120,12 @@ mention string." "Should refuse to delete toot." (let ((toot mastodon-toot-test-base-toot)) (with-mock - (mock (mastodon-auth--user-acct) => "joebogus") - ;; (mock (mastodon-toot--own-toot-p toot) => nil) - (mock (mastodon-tl--property 'toot-json) => mastodon-toot-test-base-toot) - (should (equal (mastodon-toot--delete-toot) - "You can only delete (and redraft) your own toots."))))) + (mock (mastodon-auth--user-acct) => "joebogus") + ;; (mock (mastodon-toot--own-toot-p toot) => nil) + (mock (mastodon-tl--property 'toot-json) => mastodon-toot-test-base-toot) + (mock (mastodon-tl--property 'base-toot) => toot) + (should (equal (mastodon-toot--delete-toot) + "You can only delete (and redraft) your own toots."))))) (ert-deftest mastodon-toot--delete-toot () "Should return correct triaged response to a legitimate DELETE request." @@ -133,16 +134,17 @@ mention string." (let ((delete-response (current-buffer)) (toot mastodon-toot-test-base-toot)) (with-mock - (mock (mastodon-tl--property 'toot-json) => toot) - ;; (mock (mastodon-toot--own-toot-p toot) => t) - (mock (mastodon-auth--user-acct) => "acct42@example.space") - (mock (mastodon-http--api (format "statuses/61208")) - => "https://example.space/statuses/61208") - (mock (y-or-n-p "Delete this toot? ") => t) - (mock (mastodon-http--delete "https://example.space/statuses/61208") - => delete-response) - (should (equal (mastodon-toot--delete-toot) - "Toot deleted!")))))) + (mock (mastodon-tl--property 'toot-json) => toot) + (mock (mastodon-tl--property 'base-toot) => toot) + ;; (mock (mastodon-toot--own-toot-p toot) => t) + (mock (mastodon-auth--user-acct) => "acct42@example.space") + (mock (mastodon-http--api (format "statuses/61208")) + => "https://example.space/statuses/61208") + (mock (y-or-n-p "Delete this toot? ") => t) + (mock (mastodon-http--delete "https://example.space/statuses/61208") + => delete-response) + (should (equal (mastodon-toot--delete-toot) + "Toot deleted!")))))) (ert-deftest mastodon-toot-action-pin () "Should return callback provided by `mastodon-toot--pin-toot-toggle'." @@ -152,14 +154,14 @@ mention string." (toot mastodon-toot-test-base-toot) (id 61208)) (with-mock - (mock (mastodon-tl--property 'base-toot-id) => id) - (mock (mastodon-http--api "statuses/61208/pin") - => "https://example.space/statuses/61208/pin") - (mock (mastodon-http--post "https://example.space/statuses/61208/pin" nil nil) - => pin-response) - (should (equal (mastodon-toot--action "pin" (lambda () - (message "Toot pinned!"))) - "Toot pinned!")))))) + (mock (mastodon-tl--property 'base-toot-id) => id) + (mock (mastodon-http--api "statuses/61208/pin") + => "https://example.space/statuses/61208/pin") + (mock (mastodon-http--post "https://example.space/statuses/61208/pin") + => pin-response) + (should (equal (mastodon-toot--action "pin" (lambda () + (message "Toot pinned!"))) + "Toot pinned!")))))) (ert-deftest mastodon-toot--pin-toot-fail () (with-temp-buffer @@ -167,7 +169,8 @@ mention string." (let ((pin-response (current-buffer)) (toot mastodon-toot-test-base-toot)) (with-mock - (mock (mastodon-tl--property 'toot-json) => toot) - (mock (mastodon-auth--user-acct) => "joebogus@example.space") - (should (equal (mastodon-toot--pin-toot-toggle) - "You can only pin your own toots.")))))) + (mock (mastodon-tl--property 'toot-json) => toot) + (mock (mastodon-tl--property 'base-toot) => toot) + (mock (mastodon-auth--user-acct) => "joebogus@example.space") + (should (equal (mastodon-toot--pin-toot-toggle) + "You can only pin your own toots.")))))) |