Persist conversation deletions across launches

ober

d2816de3a5ef42e72df4b371503caee08e4b1f0f

diff --git a/README.md b/README.md
index 3cbb89c..113bb8c 100644
--- 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
 
diff --git a/signal/store.ss b/signal/store.ss
new file mode 100644
index 0000000..90b4413
--- /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
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 2253866..636782d 100644
--- 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