Persist conversation deletions across launches
ober
d2816de3a5ef42e72df4b371503caee08e4b1f0f
--- a/README.md +++ b/README.md @@ -83,7 +83,12 @@ Ctrl-D to delete the selected conversation; a y/n prompt confirms first. For a direct contact this calls signal-cli `removeContact --forget`, which erases all local Signal data for that contact (identity keys, sessions, profile) — use it to remove someone entirely. For a group it just drops the conversation from the -list. Use q, Esc, or Ctrl-C to quit. +list. Deleted conversations are remembered in +`~/.local/share/jerboa-signal/removed-<account>.txt` and stay hidden across +relaunches, even though signal-cli (a linked device) keeps re-syncing your +phone's contacts. If a removed person messages you again the thread reappears; +to remove someone from your Signal account everywhere, delete them on your phone +too. Use q, Esc, or Ctrl-C to quit. ## Architecture new file mode 100644 --- /dev/null +++ b/signal/store.ss @@ -0,0 +1,87 @@ +#!chezscheme +;;; signal/store -- small persistent local state for the TUI. +;;; +;;; signal-cli runs as a linked secondary device, so contacts and groups are +;;; re-synced from the phone and the TUI re-seeds from listContacts/listGroups +;;; on every launch. That means a deleted conversation would otherwise reappear. +;;; To make the user's deletions stick, we persist the set of removed +;;; conversation ids in a plain text file and skip them when seeding. +;;; +;;; State lives in ~/.local/share/jerboa-signal/removed-<account>.txt, one +;;; conversation id per line. It holds no message content. + +(library (signal store) + (export load-removed-ids save-removed-ids removed-store-path) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?)) + + (def (store-dir) + (string-append (or (getenv "HOME") ".") "/.local/share/jerboa-signal")) + + (def (removed-store-path account) + (string-append (store-dir) "/removed-" (sanitize account) ".txt")) + + ;; Keep the filename safe: phone numbers (+, digits), uuids (-) and ordinary + ;; word characters pass through; anything else collapses to _. + (def (sanitize s) + (let ([str (if (and (string? s) (not (string=? s ""))) s "default")]) + (list->string + (map (lambda (c) + (if (or (char-alphabetic? c) (char-numeric? c) + (char=? c #\+) (char=? c #\-) + (char=? c #\_) (char=? c #\.)) + c + #\_)) + (string->list str))))) + + ;; mkdir each component in turn; mkdir needs the parent to exist already and + ;; ~/.local/share may be absent (notably on macOS). + (def (ensure-store-dir!) + (let* ([home (or (getenv "HOME") ".")] + [d1 (string-append home "/.local")] + [d2 (string-append d1 "/share")] + [d3 (string-append d2 "/jerboa-signal")]) + (try-mkdir! d1) + (try-mkdir! d2) + (try-mkdir! d3))) + + (def (try-mkdir! dir) + (unless (file-exists? dir) + (guard (e [#t (void)]) + (mkdir dir)))) + + (def (load-removed-ids account) + (let ([path (removed-store-path account)]) + (if (file-exists? path) + (guard (e [#t '()]) + (call-with-input-file path read-nonempty-lines)) + '()))) + + (def (read-nonempty-lines port) + (let loop ([acc '()]) + (let ([line (get-line port)]) + (if (eof-object? line) + (reverse acc) + (loop (if (string=? line "") acc (cons line acc))))))) + + ;; Best-effort: persistence must never break the UI, so failures are swallowed. + (def (save-removed-ids account ids) + (guard (e [#t (void)]) + (ensure-store-dir!) + (let ([path (removed-store-path account)]) + (when (file-exists? path) + (delete-file path)) + (call-with-output-file path + (lambda (port) + (for-each (lambda (id) (display id port) (newline port)) ids)))))) + + ) ;; end library --- a/signal/tui/main.ss +++ b/signal/tui/main.ss @@ -15,6 +15,7 @@ make-date make-time) (except (jerboa prelude) meta atom?) (signal rpc-actor) + (signal store) (signal tui ffi)) (defstruct chat-message (direction sender text timestamp kind)) @@ -22,13 +23,15 @@ (defstruct search-hit (conv-id conv-title message)) (defstruct tui-state (account version receive-mode width height input status quit? event-count - conversations selected-index mode picker-query picker-index)) + conversations selected-index mode picker-query picker-index removed)) (def (run-tui-terminal account actor version receive-mode) (with-tui (tb-set-input-mode! (bitwise-ior TB_INPUT_ESC TB_INPUT_MOUSE)) (tb-set-output-mode! TB_OUTPUT_TRUECOLOR) - (let ([state (make-tui-state (or account "default") + (let* ([acct (or account "default")] + [removed (load-removed-set acct)] + [state (make-tui-state acct version receive-mode (tb-width) @@ -45,7 +48,8 @@ 0 'chat "" - 0)]) + 0 + removed)]) (tb-hide-cursor!) (draw! state) (tb-present!) @@ -187,14 +191,16 @@ (hashtable-ref group "isMember" #f) (let ([id (first-non-empty-string (hashtable-ref group "id" #f))]) (and id - (begin - (ensure-conversation! - state - (conversation-id-for 'group id id) - (group-display-name group id) - 'group - id) - #t))))) + (let ([conv-id (conversation-id-for 'group id id)]) + (and (not (conversation-removed? state conv-id)) + (begin + (ensure-conversation! + state + conv-id + (group-display-name group id) + 'group + id) + #t))))))) (def (seed-contact-conversation! state contact) (and (hashtable? contact) @@ -202,14 +208,16 @@ (not (hashtable-ref contact "unregistered" #f)) (let ([target (contact-target contact)]) (and target - (begin - (ensure-conversation! - state - (conversation-id-for 'direct target target) - (contact-display-name contact) - 'direct - target) - #t))))) + (let ([id (conversation-id-for 'direct target target)]) + (and (not (conversation-removed? state id)) + (begin + (ensure-conversation! + state + id + (contact-display-name contact) + 'direct + target) + #t))))))) (def (find-conversation conversations id) (cond @@ -218,6 +226,9 @@ [else (find-conversation (cdr conversations) id)])) (def (ensure-conversation! state id title kind target) + ;; Re-creating a conversation (inbound message or explicit pick) means the + ;; user wants it back, so clear any persisted removal. + (unmark-removed! state id) (let ([existing (find-conversation (tui-state-conversations state) id)]) (if existing (begin @@ -726,6 +737,7 @@ (let* ([id (conversation-id conv)] [remaining (conversations-without (tui-state-conversations state) id)] [count (length remaining)]) + (mark-removed! state id) (tui-state-conversations-set! state remaining) (tui-state-selected-index-set! state @@ -740,6 +752,34 @@ (cons (car conversations) (conversations-without (cdr conversations) id))])) + ;; --- Persistent removed set --- + ;; + ;; Deletions are remembered on disk so they survive relaunches. Seeding skips + ;; ids in this set; re-creating a conversation (an inbound message, or picking + ;; it again) clears it via ensure-conversation!. + + (def (load-removed-set account) + (let ([ht (make-hashtable equal-hash equal?)]) + (for-each (lambda (id) (hashtable-set! ht id #t)) + (load-removed-ids account)) + ht)) + + (def (conversation-removed? state id) + (hashtable-ref (tui-state-removed state) id #f)) + + (def (mark-removed! state id) + (hashtable-set! (tui-state-removed state) id #t) + (persist-removed! state)) + + (def (unmark-removed! state id) + (when (hashtable-ref (tui-state-removed state) id #f) + (hashtable-delete! (tui-state-removed state) id) + (persist-removed! state))) + + (def (persist-removed! state) + (save-removed-ids (tui-state-account state) + (vector->list (hashtable-keys (tui-state-removed state))))) + ;; --- Search across all conversations --- ;; ;; Scans the in-memory messages of every conversation this session. There is