1
0
Fork 0
tinmop/src/ui-goodies.lisp

1098 lines
50 KiB
Common Lisp
Raw Normal View History

2020-05-08 15:45:43 +02:00
;; tinmop: an humble mastodon client
;; Copyright (C) 2020 cage
;; This program is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program.
;; If not, see [[http://www.gnu.org/licenses/][http://www.gnu.org/licenses/]].
(in-package :ui-goodies)
(defun boolean-input-accepted-p (user-input)
(string-equal user-input (_ "y")))
(defun quit-program ()
"This is not the right function to quit the program, use
'clean-close-program'."
(hooks:run-hooks 'hooks:*before-quit*)
(db-utils:close-db)
(os-utils:exit-program))
(defun clean-close-program ()
"Use this to close the program"
(flet ((on-input-complete (maybe-accepted)
(if (boolean-input-accepted-p maybe-accepted)
(let ((delete-event (make-instance 'delete-all-status-event))
(quit-event (make-instance 'quit-program-event)))
(push-event delete-event)
(push-event quit-event))
(quit-program))))
(let ((delete-count (db:count-status-marked-to-delete)))
(if (> delete-count 0)
(ask-string-input #'on-input-complete
:prompt (format nil
(n_ "Delete ~a message? [y/N] "
"Delete ~a messages? [y/N] "
delete-count)
delete-count))
(quit-program)))))
(defun notify (message &key (life nil) (as-error nil))
(let ((event (make-instance 'notify-user-event
:life life
:notify-error as-error
:payload message)))
(push-event event)))
(defun notify-procedure (procedure starting-message
&key
(ending-message (_ "Task completed"))
(life-start nil)
(life-end nil))
(bt:make-thread (lambda ()
(notify starting-message :life life-start)
(funcall procedure)
(notify ending-message :life life-end))))
(defmacro with-blocking-notify-procedure ((starting-message
&optional (ending-message `(_ "Task completed")))
&body body)
`(progn
(notify ,starting-message)
,@body
(notify ,ending-message)))
(defun info-dialog (message &key (buttons nil) (title (_ "Information")))
(let ((event (make-instance 'info-dialog-event
:buttons buttons
:title title
:payload message)))
(push-event event)))
(defun error-dialog (message &key (buttons nil) (title (_ "Error")))
(let ((event (make-instance 'error-dialog-event
:buttons buttons
:title title
:payload message)))
(push-event event)))
(defun info-dialog-immediate (message &key (buttons nil) (title (_ "Information")))
(let ((dialog-window (windows:make-info-message-dialog specials:*main-window*
title
message
buttons)))
(windows:menu-select dialog-window)))
(defun error-dialog-immediate (message &key (buttons nil) (title (_ "Error")))
(let ((dialog-window (windows:make-error-message-dialog specials:*main-window*
title
message
buttons)))
(windows:menu-select dialog-window)))
(defun input-dialog-immediate (message)
(windows:make-input-dialog specials:*main-window* specials:*main-window* message))
(defun error-message (message)
(let ((event (make-instance 'error-message-event
:payload message)))
(push-event event)))
(defun info-message (message)
(let ((event (make-instance 'info-message-event
:payload message)))
(push-event event)))
(defun confirm-file-overwrite-dialog-immediate (filepath)
(let ((res (info-dialog-immediate (format nil
(_ "File \"~a\" exists, overwrite?")
filepath)
:buttons (list (_ "Cancel")))))
(string= res +menu-button-ok+)))
(defun confirm-dialog-immediate (message)
(let ((res (info-dialog-immediate message
:buttons (list (_ "Cancel")))))
(string= res +menu-button-ok+)))
(defun request-error-window (condition-object)
(error-dialog (format nil
(_ "Request failed: error code ~d message \"~a\"")
(tooter:code condition-object)
(tooter:message condition-object))))
(defun ask-string-input (on-input-complete-fn
&key
(initial-value nil)
(prompt +default-command-prompt+)
(complete-fn #'complete:directory-complete))
(flet ((thread-fn ()
(let ((event (make-instance 'ask-user-input-string-event
:initial-value initial-value
:complete-fn complete-fn
:prompt prompt
:payload (box:dbox nil))))
(with-accessors ((lock lock)
(condition-variable condition-variable)) event
(push-event event)
(with-lock (lock)
(bt:condition-wait condition-variable lock)
(funcall on-input-complete-fn (box:dunbox (payload event))))))))
(bt:make-thread #'thread-fn)))
(defun thread-go-up ()
(thread-window:go-message-up specials:*thread-window*))
(defun thread-go-down ()
(thread-window:go-message-down specials:*thread-window*))
(defun thread-goto-message ()
"Jump to message"
(flet ((on-input-complete (index)
(when-let* ((index-as-number (num:safe-parse-number index))
(event (make-instance 'thread-goto-message
:payload index-as-number)))
(push-event event))))
(ask-string-input #'on-input-complete :prompt (_ "Jump to message: "))))
(defun thread-goto-first-message ()
"Jump to first message"
(thread-window:goto-first-message specials:*thread-window*))
(defun thread-goto-last-message ()
"Jump to last message"
(thread-window:goto-last-message specials:*thread-window*))
(defun thread-search-message-body (direction)
"Search in messages body"
(flet ((on-input-complete (text-looking-for)
(let ((event (make-instance 'thread-search-message-body-event
:payload text-looking-for
:search-direction direction)))
(push-event event))))
(ask-string-input #'on-input-complete :prompt (_ "Search key: "))))
(defun thread-search-next-message-body ()
"Search next matching message's body"
(thread-search-message-body :next))
(defun thread-search-previous-message-body ()
"Search previous matching messages body"
(thread-search-message-body :previous))
(defun thread-search-message-meta (direction)
"Search in messages metadata.
Metadata includes:
- spoiler-text (subject of message)
- tags
- username"
(flet ((on-input-complete (text-looking-for)
(let ((event (make-instance 'thread-search-message-meta-event
:payload text-looking-for
:search-direction direction)))
(push-event event))))
(ask-string-input #'on-input-complete :prompt (_ "Search key: "))))
(defun thread-search-next-message-meta ()
"Search next matching message's metadata
Metadata includes:
- spoiler-text (subject of message)
- tags
- username"
(thread-search-message-meta :next))
(defun thread-search-previous-message-meta ()
"Search previous matching message's metadata
Metadata includes:
- spoiler-text (subject of message)
- tags
- username"
(thread-search-message-meta :previous))
(defun thread-search-next-unread-message ()
"Jump to next unread message"
(thread-window:search-next-unread specials:*thread-window*))
(defun thread-open-selected-message ()
"Open selected message"
(thread-window:open-message specials:*thread-window*))
(defun thread-mark-delete-selected-message ()
"Mark selected message for deletion"
(thread-window:mark-selected-message-to-delete specials:*thread-window*
:move-down-selected-message t))
(defun thread-mark-prevent-delete-selected-message ()
"Unmark selected message for deletion"
(thread-window:mark-selected-message-prevent-delete specials:*thread-window*
:move-down-selected-message t))
(defun subscribe-to-hash ()
"Subscribe to hashtag"
(flet ((on-input-complete (tags)
(let ((event (make-instance 'subscribe-tags-event
:payload tags))
(refresh-event (make-instance 'refresh-tag-window-event)))
(push-event refresh-event)
(push-event event))))
(let* ((selected-row (line-oriented-window:selected-row-fields specials:*thread-window*))
(tags (and selected-row
(db:row-message-tags selected-row))))
(ask-string-input #'on-input-complete
:initial-value tags
:prompt (_ "Subscribe to: ")))))
(defun unsubscribe-to-hash ()
"Unsubscribe to hashtag"
(flet ((on-input-complete (tag)
(db-utils:with-ready-database (:connect nil)
(when (and (string-not-empty-p tag)
(> (length tag)
(length +folder-tag-prefix+)))
(let ((unsubscribe-event (make-instance 'unsubscribe-tags-event
:payload (db:folder-name->tag tag)))
(refresh-event (make-instance 'refresh-tag-window-event)))
(push-event unsubscribe-event)
(push-event refresh-event))))))
(ask-string-input #'on-input-complete
:initial-value +folder-tag-prefix+
:prompt (_ "Unsubscribe to: ")
:complete-fn #'complete:tags-complete)))
(defun message-scroll-up ()
(message-window:scroll-up specials:*message-window*))
(defun message-scroll-down ()
(message-window:scroll-down specials:*message-window*))
(defun message-scroll-begin ()
(message-window:scroll-begin specials:*message-window*))
(defun message-scroll-end ()
(message-window:scroll-end specials:*message-window*))
(defun message-scroll-next-page ()
(message-window:scroll-next-page specials:*message-window*))
(defun message-scroll-previous-page ()
(message-window:scroll-previous-page specials:*message-window*))
(defun message-search-regex ()
"Search regular expression in message"
(flet ((on-input-complete (regex)
(let ((event (make-instance 'search-regex-message-content-event
:payload regex)))
(push-event event))))
(ask-string-input #'on-input-complete :prompt (_ "Search key: "))))
(defun give-focus (win info-change-focus-message &rest windows-lose-focus)
(setf (main-window:focused-window specials:*main-window*)
win)
(setf (windows:in-focus win) t)
(loop for win in windows-lose-focus when win do
(setf (windows:in-focus win) nil))
(windows:draw-all)
(info-message info-change-focus-message))
(defmacro gen-focus-to-window (function-suffix window-get-focus
&key
(info-change-focus-message (_ "Focus changed"))
(windows-lose-focus nil)
(documentation nil))
`(defun ,(misc:format-fn-symbol t "focus-to-~a" function-suffix) ()
,documentation
(give-focus ,window-get-focus , info-change-focus-message ,@windows-lose-focus)))
(gen-focus-to-window thread-window
specials:*thread-window*
:documentation "Move focus on thread window"
:info-change-focus-message (_ "Focus passed on threads window")
:windows-lose-focus (specials:*conversations-window*
specials:*tags-window*
specials:*send-message-window*
specials:*message-window*
specials:*follow-requests-window*))
(gen-focus-to-window message-window
specials:*message-window*
:documentation "Move focus on message window"
:info-change-focus-message (_ "Focus passed on message window")
:windows-lose-focus (specials:*conversations-window*
specials:*tags-window*
specials:*thread-window*
specials:*send-message-window*
specials:*follow-requests-window*))
(gen-focus-to-window send-message-window
specials:*send-message-window*
:documentation "Move focus on send message window"
:info-change-focus-message (_ "Focus passed on send message window")
:windows-lose-focus (specials:*open-attach-window*
specials:*conversations-window*
specials:*tags-window*
specials:*thread-window*
specials:*message-window*
specials:*follow-requests-window*))
(gen-focus-to-window follow-requests-window
specials:*follow-requests-window*
:documentation "Move focus on follow requests window"
:info-change-focus-message (_ "Focus passed on follow requests window")
:windows-lose-focus (specials:*open-attach-window*
specials:*conversations-window*
specials:*tags-window*
specials:*thread-window*
specials:*message-window*
specials:*send-message-window*))
(gen-focus-to-window tags-window
specials:*tags-window*
:documentation "Move focus on tags window"
:info-change-focus-message (_ "Focus passed on tags window")
:windows-lose-focus (specials:*open-attach-window*
specials:*conversations-window*
specials:*follow-requests-window*
specials:*thread-window*
specials:*message-window*
specials:*send-message-window*))
(gen-focus-to-window conversations-window
specials:*conversations-window*
:documentation "Move focus on conversations window"
:info-change-focus-message (_ "Focus passed on conversation window")
:windows-lose-focus (specials:*open-attach-window*
specials:*tags-window*
specials:*follow-requests-window*
specials:*thread-window*
specials:*message-window*
specials:*send-message-window*))
(gen-focus-to-window open-attach-window
specials:*open-attach-window*
:documentation "Move focus on open-attach window"
:info-change-focus-message (_ "Focus passed on attach window")
:windows-lose-focus (specials:*conversations-window*
specials:*tags-window*
specials:*follow-requests-window*
specials:*thread-window*
specials:*message-window*
specials:*send-message-window*))
(defun print-quick-help ()
"Print a quick help"
(keybindings:print-help specials:*main-window*))
(defun move-message-tree ()
"Move messages tree"
(flet ((on-input-complete (new-folder)
(let ((move-event (make-instance 'move-selected-tree-event
:new-folder new-folder))
(refresh-event (make-instance 'refresh-thread-windows-event)))
(if (string-not-empty-p new-folder)
(with-blocking-notify-procedure
((format nil (_ "Saving messages in ~s") new-folder)
(format nil (_ "Saved message in ~s") new-folder))
(push-event move-event)
(push-event refresh-event))
(error-message (_ "No folder specified."))))))
(ask-string-input #'on-input-complete
:prompt (_ "Move to folder: ")
:complete-fn #'complete:folder-complete)))
(defun change-folder ()
"Change folder"
(flet ((on-input-complete (new-folder)
(db-utils:with-ready-database (:connect nil)
(let ((refresh-event (make-instance 'refresh-thread-windows-event
:new-folder new-folder))
(folder-exists-p (db:folder-exists-p new-folder)))
(if (string-not-empty-p new-folder)
(if folder-exists-p
(push-event refresh-event)
(error-message (format nil
(_ "Folder ~s does not exists.")
new-folder)))
(error-message (_ "No folder specified.")))))))
(ask-string-input #'on-input-complete
:prompt (_ "Change folder: ")
:complete-fn #'complete:folder-complete)))
(defun change-timeline ()
"Change timeline"
(let ((folder (thread-window:timeline-folder specials:*thread-window*)))
(flet ((on-input-complete (new-timeline)
(let* ((refresh-event (make-instance 'refresh-thread-windows-event
:new-timeline new-timeline)))
(if (string-empty-p new-timeline)
(error-message (_ "No timeline specified."))
(push-event refresh-event)))))
(ask-string-input #'on-input-complete
:prompt (_ "Change timeline: ")
:complete-fn (complete:timeline-complete-fn folder)))))
(defun timeline->kind (timeline)
"Return two values: the kind of timeline (on the server) to fetch
and if fetch local (again, to server) statuses only."
(cond
((string= timeline db:+federated-timeline+)
(values :public nil))
((string= timeline db:+local-timeline+)
(values :public t))
((string= timeline db:+home-timeline+)
(values :home nil))))
(defun update-current-timeline ()
"Update current timeline"
(let* ((timeline (thread-window:timeline-type specials:*thread-window*))
(folder (thread-window:timeline-folder specials:*thread-window*))
(max-id (db:last-status-id-timeline-folder timeline folder)))
(multiple-value-bind (kind localp)
(timeline->kind timeline)
(flet ((update ()
(client:update-timeline timeline
kind
folder
:min-id max-id
:local localp)
(let ((refresh-event (make-instance 'refresh-thread-windows-event)))
(push-event refresh-event))))
(notify-procedure #'update
(_ "Downloading messages.")
:ending-message (_ "Messages downloaded.")
:life-start (* (swconf:config-notification-life) 5))))))
(defun refresh-tags ()
"Update messages for subscribed tags"
(let ((all-tags (db:all-subscribed-tags-name)))
(flet ((update ()
(client:update-subscribed-tags all-tags)
(let ((update-got-message-event
(make-instance 'tag-mark-got-messages-event))
(notify-event
(make-instance 'notify-fetched-new-tag-messages-event))
(update-subscribed-event
(make-instance 'update-last-refresh-subscribe-tags-event))
(refresh-window-event (make-instance 'refresh-tag-window-event)))
(push-event update-got-message-event)
(push-event notify-event)
(push-event update-subscribed-event)
(push-event refresh-window-event))))
(notify-procedure #'update
(_ "Downloading tags messages.")
:ending-message (_ "Messages downloaded.")
:life-start (* (swconf:config-notification-life) 5)))))
(defun confirm-selected-row-action (message)
(when-let* ((selected-row (line-oriented-window:selected-row-fields specials:*thread-window*))
(status-id (db:row-message-status-id selected-row))
(confirmedp (confirm-dialog-immediate message)))
(values status-id selected-row)))
(defun favourite-selected-status ()
"Favourite selected status"
(multiple-value-bind (selected-id selected-message)
(confirm-selected-row-action (_ "Favorite this message?"))
(when selected-id
(let ((selected-index (db:row-message-index selected-message)))
(flet ((update ()
(let* ((favourite-event (make-instance 'favourite-status-event
:payload selected-id
:message-index selected-index)))
(push-event favourite-event))))
(notify-procedure #'update
(_ "Favouring message.")
:ending-message (_ "Favoured message.")))))))
(defun unfavourite-selected-status ()
"Unfavourite selected status"
(multiple-value-bind (selected-id selected-message)
(confirm-selected-row-action (_ "Remove this message from your favourites?"))
(when selected-id
(let ((selected-index (db:row-message-index selected-message)))
(flet ((update ()
(let* ((unfavourite-event (make-instance 'unfavourite-status-event
:payload selected-id
:message-index selected-index)))
(push-event unfavourite-event))))
(notify-procedure #'update
(_ "Unfavouring message.")
:ending-message (_ "Unfavoured message.")))))))
(defun boost-selected-status ()
"Boost selected status"
(multiple-value-bind (selected-id selected-message)
(confirm-selected-row-action (_ "Boost this message?"))
(when selected-id
(let ((selected-index (db:row-message-index selected-message)))
(flet ((update ()
(let* ((reblog-event (make-instance 'reblog-status-event
:payload selected-id
:message-index selected-index)))
(push-event reblog-event))))
(notify-procedure #'update
(_ "Boosting message.")
:ending-message (_ "Boosted message.")))))))
(defun unboost-selected-status ()
"Unboost selected status"
(multiple-value-bind (selected-id selected-message)
(confirm-selected-row-action (_ "Unboost this message?"))
(when selected-id
(let ((selected-index (db:row-message-index selected-message)))
(flet ((update ()
(let* ((unreblog-event (make-instance 'unreblog-status-event
:payload selected-id
:message-index selected-index)))
(push-event unreblog-event))))
(notify-procedure #'update
(_ "Uboosting message.")
:ending-message (_ "Unboosted message.")))))))
(defun ignore-user ()
"Ignore user"
(when-let* ((selected-row (line-oriented-window:selected-row-fields
specials:*thread-window*))
(username (db:row-message-username selected-row))
(selected-id (confirm-selected-row-action (format nil
(_ "Ignore ~s?")
username))))
(with-blocking-notify-procedure
((format nil (_ "Ignoring ~s") username)
(format nil (_ "User ~s ignored") username))
(db:ignore-status-author selected-id))))
(defun unignore-user ()
"Unignore user"
(flet ((on-input-complete (username)
(let* ((event (make-instance 'unignore-user-event
:payload username)))
(if (string-not-empty-p username)
(push-event event)
(error-message (_ "No username specified."))))))
(ask-string-input #'on-input-complete
:prompt (_ "Unignore username: ")
:complete-fn #'complete:ignored-username-complete)))
(defun attach-move (amount)
(ignore-errors
(line-oriented-window:unselect-all specials:*send-message-window*)
(line-oriented-window:row-move specials:*send-message-window* amount)
(draw specials:*send-message-window*)))
(defun attach-go-down ()
(attach-move 1))
(defun attach-go-up ()
(attach-move -1))
(defun attach-delete ()
"Delete an attach"
(line-oriented-window:selected-row-delete specials:*send-message-window*)
(draw specials:*send-message-window*))
(defun attach-add ()
"Add an attach"
(flet ((on-add-attach (attach-path)
(when (string-not-empty-p attach-path)
(let ((add-event (make-instance 'send-message-add-attachment-event
:payload attach-path)))
(if (fs:file-exists-p attach-path)
(push-event add-event)
(error-message (format nil (_ "File ~s does not exists.") attach-path)))
(attach-add)))))
(ask-string-input #'on-add-attach
:prompt (_ "Add attachment: ")
:complete-fn #'complete:directory-complete)))
(defun change-subject ()
"Change subject"
(flet ((on-change-subject (new-subject)
(let* ((event (make-instance 'send-message-change-subject-event
:payload new-subject)))
(push-event event))))
(ask-string-input #'on-change-subject
:prompt (_ "New subject: "))))
(defun change-visibility ()
"Change message's visibility"
(flet ((on-change-visibility (new-visibility)
(let* ((event (make-instance 'send-message-change-visibility-event
:payload new-visibility)))
(push-event event))))
(ask-string-input #'on-change-visibility
:prompt (_ "New visibility: ")
:complete-fn #'complete:visibility-complete)))
(defmacro close-window-and-return-to-threads (window-to-close)
`(progn
(win-close ,window-to-close)
(setf ,window-to-close nil)
(focus-to-thread-window)))
(defun cancel-send-message ()
"Cancel sending operation"
(close-window-and-return-to-threads specials:*send-message-window*))
(defun edit-message-body ()
"Edit message"
(when (and specials:*send-message-window*
(sending-message:message-data specials:*send-message-window*))
(with-accessors ((body sending-message:body)
(subject sending-message:subject)
(reply-to sending-message:reply-to)
(visibility sending-message:visibility))
(sending-message:message-data specials:*send-message-window*)
(let ((temp-file (fs:temporary-filename)))
(with-open-file (stream temp-file
:direction :output
:if-exists :supersede
:if-does-not-exist :error)
(write-sequence body stream))
(croatoan:end-screen)
(os-utils:open-with-editor temp-file)
(setf body (fs:slurp-file temp-file))))))
(defun close-send-message-window ()
"Close message window and cancel operation"
(cancel-send-message))
(defparameter *message-to-send* nil)
(defun message-exceeds-server-limit-p (body)
(if (> (length body)
(swconf:max-message-length))
(- (length body)
(swconf:max-message-length))
nil))
(defun exceeding-characters-notify (exceeding)
(error-message (format nil
(n_ "Your message is ~a character too long."
"Your message is ~a characters too long."
exceeding)
exceeding)))
(defun compose-message (&optional reply-id subject (visibility +status-public-visibility+))
"Compose a new message"
(setf *message-to-send* (make-instance 'sending-message:message-ready-to-send
:visibility visibility
:reply-to reply-id
:subject subject))
(labels ((open-window ()
(let ((event (make-instance 'open-send-message-window-event
:payload *message-to-send*)))
(push-event event)))
(add-subject ()
(flet ((on-add-subject (new-subject)
(setf (sending-message:subject *message-to-send*)
new-subject)
(open-window)
(attach-add)))
(let ((old-subject (sending-message:subject *message-to-send*)))
(if (string-empty-p old-subject)
(ask-string-input #'on-add-subject
:prompt (_ "Add subject: "))
(progn
(open-window)
(attach-add))))))
(prepare-reply-body (file)
(when reply-id
;; we do not need to take into account folder or
;; timeline here as the id in unique identifier for a
;; single message *content* regardless of the position
;; in db (folder, timeline).
(when-let* ((message (db:find-status-id reply-id))
(quoted-text (db:row-message-rendered-text message))
(lines (split-lines quoted-text))
(quote-mark (swconf:quote-char))
(quoted-lines (mapcar (lambda (a) (strcat quote-mark a))
lines)))
(with-open-file (stream file
:if-exists :append
:direction :output
:element-type 'character)
(loop for line in quoted-lines do
(format stream "~a~%" line))))))
(add-body ()
(let ((temp-file (fs:temporary-filename))
(reference-open-file (get-universal-time)))
(prepare-reply-body temp-file)
(croatoan:end-screen)
(os-utils:open-with-editor temp-file)
(when (and (> (fs:file-size temp-file)
0)
(> (fs:get-stat-mtime temp-file)
reference-open-file))
(let ((body (fs:slurp-file temp-file)))
(setf (sending-message:body *message-to-send*) body)
(add-subject))))))
(add-body)))
(defun reply-message ()
"Reply to message"
(when-let* ((win specials:*thread-window*)
(selected-message (line-oriented-window:selected-row-fields win))
(username (db:row-message-username selected-message))
(visibility (db:row-message-visibility selected-message))
(reply-id (db:row-message-status-id selected-message)))
(let ((subject (db:row-message-subject selected-message)))
(compose-message reply-id subject visibility))))
(defun send-message ()
"Send message"
(when (and specials:*send-message-window*
(sending-message:message-data specials:*send-message-window*))
(let ((data (sending-message:message-data specials:*send-message-window*))
(attachments (line-oriented-window:rows specials:*send-message-window*))
(max-allowed-attach (swconf:max-attachments-allowed)))
(if (> (length attachments)
max-allowed-attach)
(error-message (format nil
(_ "The maximum allowed number of media is ~a.")
(swconf:max-attachments-allowed)))
(progn
(notify (_ "Sending message"))
(let ((event (make-instance 'send-message-event
:use-ui-notification t
:payload data)))
(push-event event)))))))
(defun open-message-attach ()
"Open message attachments window"
(when-let* ((win specials:*thread-window*)
(selected-message (line-oriented-window:selected-row-fields win)))
(open-attach-window:init (db:row-message-status-id selected-message))
(focus-to-open-attach-window)))
(defun open-message-attach-move (amount)
(ignore-errors
(line-oriented-window:unselect-all specials:*open-attach-window*)
(line-oriented-window:row-move specials:*open-attach-window* amount)
(draw specials:*open-attach-window*)))
(defun open-message-attach-go-down ()
(open-message-attach-move 1))
(defun open-message-attach-go-up ()
(open-message-attach-move -1))
(defun open-message-attach-perform-opening ()
(when-let* ((selected-line (line-oriented-window:selected-row specials:*open-attach-window*))
(url (line-oriented-window:normal-text selected-line)))
(open-attach-window:open-attachment url)))
(defun close-open-message-window ()
(close-window-and-return-to-threads specials:*open-attach-window*))
(defun prompt-for-username (prompt complete-function event
notify-starting-message
notify-ending-message)
(flet ((on-input-complete (username)
(when (string-not-empty-p username)
(with-blocking-notify-procedure
((format nil notify-starting-message username)
(format nil notify-ending-message username))
(let ((event (make-instance event
:payload username)))
(push-event event))))))
(ask-string-input #'on-input-complete
:prompt prompt
:complete-fn complete-function)))
(defun follow-user ()
"Follow user"
(prompt-for-username (_ "Follow: ")
#'complete:unfollowed-user-complete
'follow-user-event
(_ "Following ~a")
(_ "Followed ~a")))
(defun unfollow-user ()
"Unfollow user"
(prompt-for-username (_ "Unfollow: ")
#'complete:followed-user-complete
'unfollow-user-event
(_ "Unfollowing ~a")
(_ "Unfollowed ~a")))
(defun follow-request-move (amount)
(ignore-errors
(line-oriented-window:unselect-all specials:*follow-requests-window*)
(line-oriented-window:row-move specials:*follow-requests-window* amount)
(draw specials:*follow-requests-window*)))
(defun follow-request-go-down ()
(follow-request-move 1))
(defun follow-request-go-up ()
(follow-request-move -1))
(defun follow-request-delete ()
(line-oriented-window:selected-row-delete specials:*follow-requests-window*)
(draw specials:*follow-requests-window*))
(defun start-follow-request-processing ()
(let ((event (make-instance 'open-follow-requests-window-event)))
(push-event event)))
(defun close-follow-requests-window ()
(close-window-and-return-to-threads specials:*follow-requests-window*))
(defun cancel-follow-requests ()
(close-follow-requests-window))
(defun process-follow-requests ()
(when (confirm-dialog-immediate (_ "Confirm operation?"))
(follow-requests:process-requests))
(close-follow-requests-window))
(defun tag-move (amount)
(ignore-errors
(line-oriented-window:unselect-all specials:*tags-window*)
(line-oriented-window:row-move specials:*tags-window* amount)
(draw specials:*tags-window*)))
(defun tag-go-down ()
(tag-move 1))
(defun tag-go-up ()
(tag-move -1))
(defun open-tag-folder ()
"Open tag folder"
(when-let* ((selected-line (line-oriented-window:selected-row specials:*tags-window*))
(tag (line-oriented-window:normal-text selected-line))
(refresh-thread (make-instance 'refresh-thread-windows-event
:new-timeline db:+default-tag-timeline+
:new-folder tag))
(refresh-tags (make-instance 'refresh-tag-window-event)))
(db:unmark-tag-got-new-messages (db:folder-name->tag tag))
(push-event refresh-tags)
(push-event refresh-thread)))
(defun update-conversations ()
"Update conversations"
(flet ((update ()
(let* ((timeline (thread-window:timeline-type specials:*thread-window*))
(folder (thread-window:timeline-folder specials:*thread-window*))
(update-event (make-instance 'update-conversations-event
:new-timeline timeline
:new-folder folder)))
(push-event update-event))))
(notify-procedure #'update
(_ "Updating conversations.")
:ending-message (_ "Conversations updated."))))
(defun open-conversation ()
"Open conversation"
(flet ((on-input-complete (folder)
(let ((refresh-event (make-instance 'refresh-thread-windows-event
:new-timeline db:+default-converation-timeline+
:new-folder folder)))
(push-event refresh-event))))
(ask-string-input #'on-input-complete
:prompt (_ "Open conversation: ")
:complete-fn #'complete:conversation-folder)))
(defun conversation-move (amount)
(ignore-errors
(line-oriented-window:unselect-all specials:*conversations-window*)
(line-oriented-window:row-move specials:*conversations-window* amount)
(draw specials:*conversations-window*)))
(defun conversation-go-down ()
(conversation-move 1))
(defun conversation-go-up ()
(conversation-move -1))
(defun goto-conversation ()
(when-let* ((selected-row (line-oriented-window:selected-row
specials:*conversations-window*))
(folder (line-oriented-window:normal-text selected-row))
(refresh-event (make-instance 'refresh-thread-windows-event
:new-timeline db:+default-converation-timeline+
:new-folder folder)))
(push-event refresh-event)))
(defparameter *conversation-old-name* nil)
(defun change-conversation-name ()
"Change conversation's name"
(setf *conversation-old-name* nil)
(labels ((add-old-name ()
(flet ((on-add-old-name (old-name)
(when (string-not-empty-p old-name)
(setf *conversation-old-name* old-name)
(add-new-name))))
(ask-string-input #'on-add-old-name
:prompt (_ "Old name: ")
:complete-fn
#'complete:conversation-folder)))
(add-new-name ()
(flet ((on-add-new-name (new-name)
(db-utils:with-ready-database (:connect nil)
(let ((event (make-instance 'change-conversation-name-event
:old-name *conversation-old-name*
:new-name new-name)))
(when (string-not-empty-p new-name)
(if (db:conversation-folder-exists-p new-name)
(error-message (format nil
(_ "A conversation with name ~a already exists.")
new-name))
(push-event event)))))))
(ask-string-input #'on-add-new-name
:prompt (_ "New name: ")))))
(add-old-name)))
(defun ignore-conversation ()
"Ignore conversation"
(flet ((on-input-complete (maybe-accepted)
(when (boolean-input-accepted-p maybe-accepted)
(let ((ignore-event (make-instance 'ignore-conversations-event))
(refresh-event (make-instance 'refresh-conversations-window-event)))
(push-event ignore-event)
(push-event refresh-event)))))
(when-let* ((selected-row (line-oriented-window:selected-row
specials:*conversations-window*))
(folder (line-oriented-window:normal-text selected-row)))
(ask-string-input #'on-input-complete
:prompt (format nil
(_ "Ignore conversation ~s? [y/N] ")
folder)))))
(defun delete-conversation ()
"Delete conversation"
(flet ((on-input-complete (maybe-accepted)
(when (boolean-input-accepted-p maybe-accepted)
(let ((delete-event (make-instance 'delete-conversations-event))
(refresh-event (make-instance 'refresh-conversations-window-event)))
(push-event delete-event)
(push-event refresh-event)))))
(when-let* ((selected-row (line-oriented-window:selected-row
specials:*conversations-window*))
(folder (line-oriented-window:normal-text selected-row)))
(ask-string-input #'on-input-complete
:prompt (format nil
(_ "Delete conversation ~s? [y/N] ")
folder)))))
(defun report-status ()
"Report status to admins"
(let* ((selected-row (line-oriented-window:selected-row-fields specials:*thread-window*))
(status-id (db:row-message-status-id selected-row))
(username (db:row-message-username selected-row))
(account-id (db:acct->id username)))
(flet ((on-input-complete (comment)
(when (string-not-empty-p comment)
(let ((event (make-instance 'report-status-event
:account-id account-id
:status-id status-id
:comment comment))
(max-comment-length (swconf:max-report-comment-length)))
(if (> (length comment)
max-comment-length)
(error-message (format nil
(n_ "Comment too long by ~a character"
"Comment too long by ~a characters"
(- max-comment-length
(length comment)))
(- max-comment-length
(length comment))))
(with-blocking-notify-procedure
((format nil (_ "Reporting user: ~s") username)
(_ "Report trasmitted."))
(push-event event)))))))
(ask-string-input #'on-input-complete
:prompt (_ "Comment on reports: ")))))
(defparameter *crypto-username* nil)
(defun crypto-import-key ()
"Import crypto key for an user"
(setf *crypto-username* nil)
(labels ((add-username ()
(flet ((on-add-username (username)
(db-utils:with-ready-database (:connect nil)
(when (string-not-empty-p username)
(if (db:user-exists-p username)
(progn
(setf *crypto-username* username)
(add-key))
(error-message (format nil
(_ "User ~s does not exists in database")
username)))))))
(ask-string-input #'on-add-username
:prompt (_ "Username: ")
:complete-fn #'complete:username-complete)))
(add-key ()
(flet ((on-add-key (key)
(let ((event (make-instance 'add-crypto-data-event
:username *crypto-username*
:key key)))
(when (string-not-empty-p key)
(push-event event)
(notify (format nil
(_ "Added crypto key for user ~s")
*crypto-username*))))))
(ask-string-input #'on-add-key
:prompt (_ "Key: ")))))
(add-username)))
(defun crypto-generate-key ()
"Generate a crypto key for an user"
(labels ((on-add-username (username)
(db-utils:with-ready-database (:connect nil)
(when (string-not-empty-p username)
(if (db:user-exists-p username)
(let* ((key (crypto-utils:generate-key))
(event (make-instance 'add-crypto-data-event
:username username
:key key)))
(push-event event)
(notify (format nil (_ "Generated key for user ~s") username))
(info-message (format nil (_ "user ~s key ~s") username key)))
(error-message (format nil
(_ "User ~s does not exists in database")
username)))))))
(ask-string-input #'on-add-username
:prompt (_ "Username: ")
:complete-fn #'complete:username-complete)))
(defun crypto-export-key ()
"Show crypto key for an user"
(labels ((on-add-username (username)
(db-utils:with-ready-database (:connect nil)
(when (string-not-empty-p username)
(if (db:user-exists-p username)
(let* ((key (db:crypto-user-key username)))
(info-message (format nil
(_ "Added key for user ~s: ~a")
username
key)))
(error-message (format nil
(_ "User ~s does not exists in database")
username)))))))
(ask-string-input #'on-add-username
:prompt (_ "Username: ")
:complete-fn #'complete:username-complete)))