fixes for @ names
ober
5c041b61b2d108879ba29e14c97dd4148956b769
--- a/signal/tui/main.ss +++ b/signal/tui/main.ss @@ -49,7 +49,8 @@ (account version receive-mode width height input status quit? event-count conversations selected-index mode picker-query picker-index removed aliases logdb live-capture? resend challenge rate-limits send-events - capture-events capture-stop-box capture-worker)) + capture-events capture-stop-box capture-worker + thread-scroll mention-active? mention-index mention-candidates)) ;; Open the encrypted message log. Passphrase comes from JERBOA_SIGNAL_DB_KEY ;; if set (enables headless use), otherwise we prompt. A blank passphrase or a @@ -204,8 +205,12 @@ (make-hashtable equal-hash equal?) (make-channel/buf 64) (make-channel/buf 256) - (box #f) - #f)) + (box #f) + #f + 0 + #f + 0 + '())) ;; Pull recent rows out of the encrypted log so search and scrollback cover ;; previous sessions, not just this one. Runs after contact/group seeding so @@ -808,6 +813,10 @@ (conversation-typing-set! conv #f)) (unless (selected-conversation? state id) (conversation-unread-set! conv (+ (conversation-unread conv) 1))) + ;; Reset scroll to newest messages when we receive a message in the + ;; currently visible conversation. + (when (selected-conversation? state id) + (tui-state-thread-scroll-set! state 0)) (tui-state-status-set! state "New Signal message received."))) ;; Match the receipt's timestamps against our outbound messages in every @@ -1023,6 +1032,8 @@ [conv (list-ref conversations next-index)]) (tui-state-selected-index-set! state next-index) (conversation-unread-set! conv 0) + (tui-state-thread-scroll-set! state 0) + (cancel-mention! state) (tui-state-status-set! state (string-append "Selected " (conversation-title conv) ".")))))) @@ -1387,6 +1398,34 @@ (let ([key (tui-event-key ev)] [ch (tui-event-ch ev)]) (cond + ;; Mention navigation: when mention popup is active, arrow keys + ;; navigate, Enter/Tab selects, Esc cancels. + [(and (tui-state-mention-active? state) + (or (= key TB_KEY_ESC) + (= key TB_KEY_TAB) + (= key TB_KEY_ENTER))) + (if (= key TB_KEY_ESC) + (cancel-mention! state) + (if (or (= key TB_KEY_ENTER) (= key TB_KEY_TAB)) + (select-mention! state) + (void)))] + [(and (tui-state-mention-active? state) + (= key TB_KEY_ARROW_UP)) + (tui-state-mention-index-set! state + (clamp-index (- (tui-state-mention-index state) 1) + (length (tui-state-mention-candidates state))))] + [(and (tui-state-mention-active? state) + (= key TB_KEY_ARROW_DOWN)) + (tui-state-mention-index-set! state + (clamp-index (+ (tui-state-mention-index state) 1) + (length (tui-state-mention-candidates state))))] + ;; Scroll keys (only in chat mode, no mention active) + [(or (= key TB_KEY_PGUP) + (= key TB_KEY_MOUSE_WHEEL_UP)) + (scroll-thread! state 1)] + [(or (= key TB_KEY_PGDN) + (= key TB_KEY_MOUSE_WHEEL_DOWN)) + (scroll-thread! state -1)] [(or (= key TB_KEY_CTRL_C) (= key TB_KEY_CTRL_Q)) (tui-state-quit?-set! state #t)] @@ -1408,6 +1447,7 @@ (delete-input-char! state)] [(= key TB_KEY_CTRL_K) (tui-state-input-set! state "") + (cancel-mention! state) (tui-state-status-set! state "Composer cleared.")] [(= key TB_KEY_ARROW_UP) (select-conversation! state (- (tui-state-selected-index state) 1))] @@ -1583,13 +1623,21 @@ (def (append-input-char! state ch) (tui-state-input-set! state - (string-append (tui-state-input state) (string ch)))) + (string-append (tui-state-input state) (string ch))) + (when (char=? ch #\@) + (activate-mention! state))) (def (delete-input-char! state) (let* ([s (tui-state-input state)] [n (string-length s)]) (when (> n 0) - (tui-state-input-set! state (substring s 0 (- n 1)))))) + (tui-state-input-set! state (substring s 0 (- n 1))))) + (when (tui-state-mention-active? state) + (let* ([mq (mention-query (tui-state-input state))] + [has-at? (and mq (not (string=? (cdr mq) "")))]) + (if has-at? + (activate-mention! state) + (cancel-mention! state))))) (def (send-composer-message! state actor) (let ([conv (selected-conversation state)] @@ -2543,6 +2591,7 @@ (draw-details! state (+ details-x 1) 2 (- details-w 2) (- body-h 3))) (draw-composer! state 0 composer-y w) + (draw-mention-popup! state 0 composer-y w) (when (eq? (tui-state-mode state) 'confirm-delete) (draw-confirm-delete! state w h))]) (draw-status! state 0 status-y w) @@ -2780,9 +2829,11 @@ (let* ([typing (active-typing-sender conv)] [reserved (if typing 1 0)] [rows (max 0 (- height 3 reserved))] + [scroll (tui-state-thread-scroll state)] [rendered (thread-render-rows (conversation-messages conv) - width - rows)]) + width + rows + scroll)]) (let loop ([rs rendered] [row (+ y 3)]) (if (and (pair? rs) (< row (+ y 3 rows))) (begin @@ -2797,16 +2848,162 @@ (draw-text! x (+ y 1) width (fg-dim) (bg) "Waiting for Signal receive notifications."))))) - (def (thread-render-rows messages width rows) + (def (thread-render-rows messages width rows scroll) (if (<= rows 0) '() - (let loop ([ms (reverse messages)] [acc '()]) + (let* ([all (let loop ([ms (reverse messages)] [acc '()]) + (if (null? ms) + (reverse acc) + (loop (cdr ms) + (append (message-render-rows (car ms) width) acc))))] + [total (length all)]) + (let loop ([i 0] [row 0] [rs all] [acc '()]) + (cond + [(or (null? rs) (>= (length acc) rows)) + (last-n-items acc rows)] + [(< row scroll) + (loop (+ i 1) (+ row 1) (cdr rs) acc)] + [else + (loop (+ i 1) (+ row 1) (cdr rs) (cons (car rs) acc))]))))) + + ;; Build every rendered row for the conversation, newest first. + (def (render-all-rows conv width) + (let loop ([ms (reverse (conversation-messages conv))] + [acc '()]) + (if (null? ms) + (reverse acc) + (loop (cdr ms) + (append (message-render-rows (car ms) width) acc))))) + + ;; Number of rows this conversation fills when fully rendered. + (def (total-thread-rows conv width) + (length (render-all-rows conv width))) + + ;; Clamp scroll so the visible bottom never goes above the first row. + (def (max-thread-scroll conv width height) + (max 0 (- (total-thread-rows conv width) height))) + + ;; Scroll the thread. Positive delta means show older messages. + (def (scroll-thread! state delta) + (let ([conv (selected-conversation state)]) + (when conv + (let* ([w (max 10 (- (tui-state-width state) + (if (< (tui-state-width state) 80) 20 26) + 2))] + [height (let* ([body-h (max 4 (- (tui-state-height state) 4))] + [typing (active-typing-sender conv)] + [reserved (if typing 1 0)]) + (max 0 (- body-h 3 reserved)))] + [max-scroll (max-thread-scroll conv w height)]) + (tui-state-thread-scroll-set! state + (clamp-index (+ (tui-state-thread-scroll state) delta) + (+ max-scroll 1))))))) + + ;; --- @mention support --- + + ;; All direct conversation titles (contact names) as mention candidates. + (def (mention-candidates state) + (let loop ([convs (tui-state-conversations state)] + [acc '()]) + (cond + [(null? convs) (reverse acc)] + [else + (let ([conv (car convs)]) + (loop (cdr convs) + (if (eq? (conversation-kind conv) 'direct) + (cons (conversation-title conv) acc) + acc)))]))) + + ;; Filter candidates by the partial query after the @. + (def (filter-mentions candidates query) + (let ([needle (ascii-downcase query)]) + (let loop ([xs candidates] [acc '()]) (cond - [(or (null? ms) (>= (length acc) rows)) - (last-n-items acc rows)] + [(null? xs) (reverse acc)] [else - (loop (cdr ms) - (append (message-render-rows (car ms) width) acc))])))) + (let ([name (car xs)]) + (loop (cdr xs) + (if (string-contains? (ascii-downcase name) needle) + (cons name acc) + acc)))])))) + + ;; Find the @ that triggered mention mode; return (pos . query) or #f. + (def (mention-query input) + (let* ([len (string-length input)] + [at-pos (find-last-at input (- len 1))]) + (if at-pos + (let* ([query (substring input (+ at-pos 1) len)] + [trimmed (string-trim query)]) + (if (string=? trimmed "") + (cons at-pos "") + (cons at-pos trimmed))) + #f))) + + (def (find-last-at input i) + (cond + [(< i 0) #f] + [(char=? (string-ref input i) #\@) i] + [else (find-last-at input (- i 1))])) + + (def (string-trim s) + (let* ([len (string-length s)] + [start (let loop ([i 0]) + (if (or (>= i len) + (not (char-whitespace? (string-ref s i)))) + i + (loop (+ i 1))))] + [end (let loop ([i (- len 1)]) + (if (or (< i 0) + (not (char-whitespace? (string-ref s i)))) + (+ i 1) + (- i 1)))]) + (if (>= start end) + "" + (substring s start end)))) + + (def (char-whitespace? ch) + (or (char=? ch #\space) + (char=? ch #\tab) + (char=? ch #\newline) + (char=? ch #\return))) + + ;; Activate mention mode: gather candidates filtered by the current query. + (def (activate-mention! state) + (let* ([input (tui-state-input state)] + [mq (mention-query input)]) + (if mq + (let ([query (cdr mq)] + [candidates (filter-mentions (mention-candidates state) + (cdr mq))]) + (tui-state-mention-active?-set! state #t) + (tui-state-mention-index-set! state 0) + (tui-state-mention-candidates-set! state candidates)) + (cancel-mention! state)))) + + (def (cancel-mention! state) + (tui-state-mention-active?-set! state #f) + (tui-state-mention-index-set! state 0) + (tui-state-mention-candidates-set! state '())) + + ;; Replace the @query text with the selected mention name + space. + (def (select-mention! state) + (let* ([candidates (tui-state-mention-candidates state)] + [count (length candidates)]) + (if (= count 0) + (cancel-mention! state) + (let* ([name (list-ref candidates + (clamp-index (tui-state-mention-index state) + count))] + [input (tui-state-input state)] + [mq (mention-query input)]) + (when mq + (let* ([at-pos (car mq)] + [before (substring input 0 at-pos)] + [after (substring input (+ at-pos (string-length (cdr mq)) 1) + (string-length input))] + [new-text (string-append before "@" name " " after)]) + (tui-state-input-set! state new-text))) + (cancel-mention! state))))) (def (message-render-rows msg width) (let loop ([lines (chat-message->display-lines msg width)] [acc '()]) @@ -2838,6 +3035,25 @@ (draw-text! (+ x 3) (+ y 1) (- width 4) (fg-strong) (input-bg) (tui-state-input state))) + (def (draw-mention-popup! state x y width) + (let* ([candidates (tui-state-mention-candidates state)] + [count (length candidates)] + [popup-h (min 10 (+ 2 count))] + [popup-y (- y popup-h)]) + (when (and (tui-state-mention-active? state) (> count 0)) + (fill-rect! x popup-y width popup-h (fg) (selected-bg)) + (draw-text! x popup-y width (fg-strong) (selected-bg) "Mentions") + (let loop ([xs candidates] [idx 0] [row (+ popup-y 1)]) + (when (and (pair? xs) (< row (+ popup-y popup-h))) + (let* ([selected? (= idx (clamp-index (tui-state-mention-index state) + count))] + [fgc (if selected? (fg-accent) (fg))] + [bgc (if selected? (selected-bg) (selected-bg))] + [marker (if selected? "> " " ")]) + (draw-text! x row width fgc bgc + (string-append marker (car xs))) + (loop (cdr xs) (+ idx 1) (+ row 1)))))))) + (def (draw-status! state x y width) (fill-rect! x y width 1 (status-fg) (status-bg)) (draw-text! (+ x 1) y (- width 2) (status-fg) (status-bg)