Never auto-resend a failed message: clear composer on hand-off, Ctrl-R restores
ober
1243a76792cfd2da3aaef38ee513a910dea12462
--- 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_U + TB_KEY_CTRL_N TB_KEY_CTRL_Q TB_KEY_CTRL_R 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_R #x12) (def TB_KEY_CTRL_U #x15) (def TB_MOD_ALT 1) --- a/signal/tui/main.ss +++ b/signal/tui/main.ss @@ -24,9 +24,13 @@ (defstruct chat-message (direction sender text timestamp kind status)) (defstruct conversation (id title kind target messages unread typing)) (defstruct search-hit (conv-id conv-title message)) + ;; resend holds the text of the last failed send (or #f). The composer is + ;; cleared the moment a message is handed to signal-cli, so a "failed" send + ;; can never be re-fired by mashing Enter; Ctrl-R restores it deliberately. (defstruct tui-state (account version receive-mode width height input status quit? event-count - conversations selected-index mode picker-query picker-index removed logdb)) + conversations selected-index mode picker-query picker-index removed logdb + resend)) (def (run-tui-terminal account actor version receive-mode) ;; Prompt for the log passphrase and open the DB BEFORE termbox grabs the @@ -59,7 +63,8 @@ "" 0 removed - logdb)]) + logdb + #f)]) (append-system-message! state (if logdb @@ -551,6 +556,8 @@ (open-search! state)] [(= key TB_KEY_CTRL_U) (open-attach! state)] + [(= key TB_KEY_CTRL_R) + (restore-failed-send! state)] [(= key TB_KEY_CTRL_D) (open-delete-confirm! state)] [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2)) @@ -754,23 +761,47 @@ (not (conversation-target conv))) (tui-state-status-set! state "Select a Signal conversation before sending.")] [else + ;; Clear the composer BEFORE the call: once the request is written to + ;; signal-cli the message may be delivered even if the RPC reply is an + ;; error (partial/multi-device failures), so Enter must never re-fire + ;; the same text. On failure the text is stashed for Ctrl-R. + (tui-state-input-set! state "") (tui-state-status-set! state "Sending...") - (guard (e [#t - (tui-state-status-set! - state - (string-append "Send failed: " (safe-display e)))]) - (let* ([result (actor-call actor "send" - (make-send-params-for-conversation conv text))] - [ts (send-result-timestamp result)]) - (append-message-to-conversation! - conv - (make-chat-message 'out "You" text ts 'data 'sent)) - (capture-outbound! (tui-state-logdb state) - (tui-state-account state) - (conversation-id conv) text ts)) - (tui-state-input-set! state "") - (conversation-unread-set! conv 0) - (tui-state-status-set! state "Sent."))]))) + (let ([outcome + (guard (e [#t (cons 'failed (safe-display e))]) + (cons 'sent (actor-call actor "send" + (make-send-params-for-conversation conv text))))]) + (if (eq? (car outcome) 'sent) + (let ([ts (send-result-timestamp (cdr outcome))]) + (append-message-to-conversation! + conv + (make-chat-message 'out "You" text ts 'data 'sent)) + (capture-outbound! (tui-state-logdb state) + (tui-state-account state) + (conversation-id conv) text ts) + (tui-state-resend-set! state #f) + (conversation-unread-set! conv 0) + (tui-state-status-set! state "Sent.")) + (begin + (tui-state-resend-set! state text) + (tui-state-status-set! + state + (string-append "Send failed (it may still have gone out): " + (cdr outcome) + " -- Ctrl-R restores the message.")))))]))) + + ;; Ctrl-R: put the last failed message back in the composer. Resending is a + ;; deliberate two-step (restore, then Enter), never an accident. + (def (restore-failed-send! state) + (let ([text (tui-state-resend state)]) + (if text + (begin + (tui-state-input-set! state text) + (tui-state-resend-set! state #f) + (tui-state-status-set! + state + "Message restored. Enter resends it (it may arrive twice).")) + (tui-state-status-set! state "No failed message to restore.")))) (def (send-result-timestamp result) (and (hashtable? result) (hashtable-ref result "timestamp" #f))) @@ -848,26 +879,33 @@ (tui-state-status-set! state (string-append "No such file: " path))] [else + ;; Leave attach mode and clear the path/caption BEFORE the call, for + ;; the same reason as send-composer-message!: a "failed" reply may + ;; still have delivered the file, so Enter must never re-fire it. + (tui-state-input-set! state "") + (tui-state-mode-set! state 'chat) + (tui-state-picker-query-set! state "") (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."))]))) + (let ([outcome + (guard (e [#t (cons 'failed (safe-display e))]) + (cons 'sent (actor-call actor "send" + (make-send-params conv caption (list path)))))]) + (if (eq? (car outcome) 'sent) + (let* ([ts (send-result-timestamp (cdr outcome))] + [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) + (conversation-unread-set! conv 0) + (tui-state-status-set! state "File sent.")) + (tui-state-status-set! + state + (string-append "Send failed (it may still have gone out): " + (cdr outcome) + " -- Ctrl-U to retry " path))))]))) (def (attachment-label path caption) (let ([base (string-append "[file: " (path-basename path) "]")])