Add Ctrl-U attach-file flow to the TUI
ober
749ec1f82ed7c8413e1a9348d7f67ec2db1fadbf
--- a/signal/tui/ffi.ss +++ b/signal/tui/ffi.ss @@ -21,7 +21,7 @@ TB_KEY_MOUSE_WHEEL_UP TB_KEY_MOUSE_WHEEL_DOWN TB_KEY_CTRL_A TB_KEY_CTRL_B TB_KEY_CTRL_C TB_KEY_CTRL_D TB_KEY_CTRL_E TB_KEY_CTRL_F TB_KEY_CTRL_J TB_KEY_CTRL_K TB_KEY_CTRL_L - TB_KEY_CTRL_N TB_KEY_CTRL_Q + TB_KEY_CTRL_N TB_KEY_CTRL_Q TB_KEY_CTRL_U TB_MOD_ALT TB_MOD_CTRL TB_MOD_SHIFT TB_INPUT_ESC TB_INPUT_MOUSE TB_OUTPUT_TRUECOLOR @@ -155,6 +155,7 @@ (def TB_KEY_CTRL_L #x0C) (def TB_KEY_CTRL_N #x0E) (def TB_KEY_CTRL_Q #x11) + (def TB_KEY_CTRL_U #x15) (def TB_MOD_ALT 1) (def TB_MOD_CTRL 2) --- a/signal/tui/main.ss +++ b/signal/tui/main.ss @@ -519,6 +519,8 @@ (handle-confirm-delete-event! state actor ev)] [(eq? (tui-state-mode state) 'search) (handle-search-event! state ev)] + [(eq? (tui-state-mode state) 'attach) + (handle-attach-event! state actor ev)] [else (handle-chat-event! state actor ev)])] [else (void)])) @@ -537,6 +539,8 @@ (open-new-message! state)] [(= key TB_KEY_CTRL_F) (open-search! state)] + [(= key TB_KEY_CTRL_U) + (open-attach! state)] [(= key TB_KEY_CTRL_D) (open-delete-confirm! state)] [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2)) @@ -762,14 +766,134 @@ (and (hashtable? result) (hashtable-ref result "timestamp" #f))) (def (make-send-params-for-conversation conv text) + (make-send-params conv text '())) + + ;; signal-cli's "send" maps "attachment" to a list of file paths (or data + ;; URIs). Message is optional when an attachment is present, so we omit an + ;; empty caption rather than sending a blank body. + (def (make-send-params conv text attachments) (let ([p (make-hashtable equal-hash equal?)] [target (conversation-target conv)]) (if (eq? (conversation-kind conv) 'group) (hashtable-set! p "groupId" target) (hashtable-set! p "recipient" (list target))) - (hashtable-set! p "message" text) + (when (non-empty-string? text) + (hashtable-set! p "message" text)) + (when (pair? attachments) + (hashtable-set! p "attachment" attachments)) p)) + ;; --- Attach a file (Ctrl-U) --- + + (def (open-attach! state) + (let ([conv (selected-conversation state)]) + (cond + [(not conv) + (tui-state-status-set! state "No conversation selected.")] + [(or (eq? (conversation-kind conv) 'system) + (not (conversation-target conv))) + (tui-state-status-set! state "Select a Signal conversation first.")] + [else + (tui-state-mode-set! state 'attach) + (tui-state-picker-query-set! state "") + (tui-state-status-set! state "Attach a file: type a path, Enter to send.")]))) + + (def (close-attach! state) + (tui-state-mode-set! state 'chat) + (tui-state-picker-query-set! state "") + (tui-state-status-set! state "Attach cancelled.")) + + (def (handle-attach-event! state actor ev) + (let ([key (tui-event-key ev)] + [ch (tui-event-ch ev)]) + (cond + [(= key TB_KEY_ESC) + (close-attach! state)] + [(or (= key TB_KEY_CTRL_C) (= key TB_KEY_CTRL_Q)) + (tui-state-quit?-set! state #t)] + [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2)) + (tui-state-picker-query-set! + state (delete-last-char (tui-state-picker-query state)))] + [(= key TB_KEY_CTRL_K) + (tui-state-picker-query-set! state "")] + [(= key TB_KEY_ENTER) + (send-attachment! state actor)] + [(and (> ch 0) (>= ch 32)) + (tui-state-picker-query-set! + state + (string-append (tui-state-picker-query state) + (string (integer->char ch))))] + [else (void)]))) + + (def (send-attachment! state actor) + (let* ([conv (selected-conversation state)] + [path (expand-user-path (trim-spaces (tui-state-picker-query state)))] + [caption (tui-state-input state)]) + (cond + [(not conv) + (tui-state-status-set! state "No conversation selected.")] + [(string=? path "") + (tui-state-status-set! state "Enter a file path to attach.")] + [(not (file-exists? path)) + (tui-state-status-set! + state (string-append "No such file: " path))] + [else + (tui-state-status-set! state "Sending file...") + (guard (e [#t + (tui-state-status-set! + state + (string-append "Send failed: " (safe-display e)))]) + (let* ([result (actor-call actor "send" + (make-send-params conv caption (list path)))] + [ts (send-result-timestamp result)] + [label (attachment-label path caption)]) + (append-message-to-conversation! + conv + (make-chat-message 'out "You" label ts 'data 'sent)) + (capture-outbound! (tui-state-logdb state) + (tui-state-account state) + (conversation-id conv) label ts)) + (tui-state-input-set! state "") + (tui-state-mode-set! state 'chat) + (tui-state-picker-query-set! state "") + (conversation-unread-set! conv 0) + (tui-state-status-set! state "File sent."))]))) + + (def (attachment-label path caption) + (let ([base (string-append "[file: " (path-basename path) "]")]) + (if (non-empty-string? caption) + (string-append base " " caption) + base))) + + (def (expand-user-path path) + (if (and (> (string-length path) 0) + (char=? (string-ref path 0) #\~)) + (let ([home (getenv "HOME")]) + (if (non-empty-string? home) + (string-append home (substring path 1 (string-length path))) + path)) + path)) + + (def (path-basename path) + (let loop ([i (- (string-length path) 1)]) + (cond + [(< i 0) path] + [(char=? (string-ref path i) #\/) + (substring path (+ i 1) (string-length path))] + [else (loop (- i 1))]))) + + (def (trim-spaces s) + (let* ([n (string-length s)] + [start (let loop ([i 0]) + (if (and (< i n) (char-whitespace? (string-ref s i))) + (loop (+ i 1)) + i))] + [end (let loop ([i n]) + (if (and (> i start) (char-whitespace? (string-ref s (- i 1)))) + (loop (- i 1)) + i))]) + (substring s start end))) + ;; --- Delete conversation --- (def (open-delete-confirm! state) @@ -1034,6 +1158,8 @@ (draw-new-message-page! state 0 0 w status-y)] [(eq? (tui-state-mode state) 'search) (draw-search-page! state 0 0 w status-y)] + [(eq? (tui-state-mode state) 'attach) + (draw-attach-page! state 0 0 w status-y)] [else (draw-panel! 0 0 left-w body-h "Conversations") (draw-panel! thread-x 0 thread-w body-h "Messages") @@ -1137,6 +1263,22 @@ " " (conversation-kind-label conv))) + (def (draw-attach-page! state x y width height) + (draw-panel! x y width height "Attach file") + (draw-text! (+ x 2) (+ y 2) 6 (fg-strong) (panel-bg) "File:") + (fill-rect! (+ x 8) (+ y 2) (max 1 (- width 10)) 1 (fg) (input-bg)) + (draw-text! (+ x 9) (+ y 2) (max 1 (- width 12)) (fg-strong) (input-bg) + (tui-state-picker-query state)) + (draw-text! (+ x 2) (+ y 4) (- width 4) (fg-dim) (panel-bg) + "Type or paste a path. ~ expands to home.") + (let ([cap (tui-state-input state)]) + (draw-text! (+ x 2) (+ y 5) (- width 4) (fg-dim) (panel-bg) + (if (non-empty-string? cap) + (string-append "Caption: " cap) + "No caption (type in the composer before Ctrl-U to add one)."))) + (draw-text! (+ x 2) (+ y 7) (- width 4) (fg-accent) (panel-bg) + "Enter send Esc cancel")) + (def (draw-search-page! state x y width height) (draw-panel! x y width height "Search session history") (draw-text! (+ x 2) (+ y 2) 6 (fg-strong) (panel-bg) "Find:") @@ -1246,7 +1388,7 @@ (draw-text! (+ x 1) y (- width 2) (status-fg) (status-bg) (string-append "TUI | " (tui-state-status state) - " | Ctrl-N new Ctrl-F find Shift-Up/Dn move Ctrl-D del q quit"))) + " | Ctrl-N new Ctrl-F find Ctrl-U attach Ctrl-D del q quit"))) (def (draw-cursor! state width composer-y) (cond @@ -1258,6 +1400,10 @@ (let* ([query-len (string-length (tui-state-picker-query state))] [cursor-x (min (- width 2) (+ 9 query-len))]) (tb-set-cursor! cursor-x 2))] + [(eq? (tui-state-mode state) 'attach) + (let* ([query-len (string-length (tui-state-picker-query state))] + [cursor-x (min (- width 2) (+ 9 query-len))]) + (tb-set-cursor! cursor-x 2))] [(eq? (tui-state-mode state) 'confirm-delete) (tb-hide-cursor!)] [else