aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-27 22:11:24 -0500
committerCraig Jennings <c@cjennings.net>2026-05-27 22:11:24 -0500
commit4daf2328756fa29b087870a2b1f672bc01aa6b51 (patch)
tree69e51b9156cbdda8946b4b24c795da2df2685c33 /modules
parentbfec0eab1132b7713a35500323e06ebea2da17a4 (diff)
downloaddotemacs-4daf2328756fa29b087870a2b1f672bc01aa6b51.tar.gz
dotemacs-4daf2328756fa29b087870a2b1f672bc01aa6b51.zip
feat(signal): initiate-message workflow (picker, guard, cache, keymap)
I built pieces 2-7 of the initiate-message workflow from docs/design/signal-client.org and added tests covering the fork's clobber-fix (commit 5ec56c0 over there). The picker is the user-facing change: a single key opens a name-based completing-read for any contact, with "Note to Self" pinned first. The picker stack from the bottom up: cj/signel--ensure-started is the daemon guard. With a live process it's a no-op. With signel-account set but no process it calls signel-start and pre-warms the contact cache. With signel-account nil it user-errors naming the remedy. Pre-warming on start means the picker feels instant on first use. cj/signel--fetch-contacts issues a listContacts RPC through the new request-callback contract (signel--send-rpc with a success-callback). The callback runs the result through the verified cj/signal--parse-contacts and stores the (LABEL . RECIPIENT) alist in cj/signel--contact-cache, a cj-owned variable kept separate from signel's receive-time contact-map. An empty result populates the cache as nil, distinct from an RPC failure (which never invokes the callback so the prior cache survives). cj/signel-refresh-contacts is the user-facing command that clears and refetches. cj/signel-message is the picker. Warm cache opens completing-read immediately. Cold cache kicks off a fetch and accept-process-outputs up to cj/signel-fetch-timeout seconds (3s default), then user-errors if the daemon hasn't responded so a wedged process can't hang Emacs. The candidate list pins "Note to Self" first (resolves to signel-account) with a display-sort metadata function that preserves the given order rather than alphabetizing. cj/signel-message-self skips the picker and goes straight to signel-account. cj/signel-connect is the friendly verb on the prefix key. cj/signel-prefix-map binds m / s / d / q / SPC and attaches under C-; M via with-eval-after-load keybindings so the binding survives load-order. l stays unbound for the future link command. 15 new ERT tests cover the ensure-started branches, the fetch + cache contract (issued, populated, empty), refresh-contacts, the picker's four scenarios (warm-cache contact, warm-cache Note to Self, cold-cache resolves in time, cold-cache timeout), message-self, and the keymap bindings. Plus 4 new tests in tests/test-signel-input-preservation.el for the fork's clobber fix: pending-input captures typed text and returns nil when empty; both signel--insert-msg and signel--insert-system-msg redraw the prompt without clobbering "halfwritten". todo.org closes three tasks as dated event-log entries: the contact picker, the input clobber, and the use-package wiring.
Diffstat (limited to 'modules')
-rw-r--r--modules/signal-config.el152
1 files changed, 152 insertions, 0 deletions
diff --git a/modules/signal-config.el b/modules/signal-config.el
index 0b6668b1..bed0f1e6 100644
--- a/modules/signal-config.el
+++ b/modules/signal-config.el
@@ -127,5 +127,157 @@ time."
(when (file-readable-p cj/signal-private-config-file)
(load cj/signal-private-config-file nil t)))
+;;; Connection guard, contact fetch, and cache
+
+;; Forward declarations: signel.el is loaded by the use-package above (with
+;; :load-path on the fork), but the byte-compiler doesn't see those symbols
+;; statically. Declaring them keeps the compile clean without changing
+;; runtime behavior.
+(defvar signel-account)
+(defvar signel--process-name)
+(declare-function signel-start "signel" ())
+(declare-function signel--send-rpc "signel" (method params &optional target-buffer success-callback))
+
+(defvar cj/signel--contact-cache nil
+ "Cached `(LABEL . RECIPIENT)' alist for the contact picker.
+Populated by `cj/signel--fetch-contacts' on first invocation (or after a
+`cj/signel-refresh-contacts'), and cleared on `signel-stop' / restart so
+a stale list can't survive a reconnect. In-memory only.")
+
+(defcustom cj/signel-fetch-timeout 3.0
+ "Seconds the picker blocks on `accept-process-output' for a cold-cache fetch.
+On warm cache the picker opens instantly; on cold cache it kicks off a
+fetch and waits up to this many seconds for the RPC result before
+reporting a `user-error' so a dead or wedged daemon can't hang Emacs."
+ :type 'number
+ :group 'signel)
+
+(defun cj/signel--ensure-started ()
+ "Ensure the signel daemon is live, starting it if needed.
+Three branches:
+- The process is already live -- no-op, return nil.
+- `signel-account' is set but no live process exists -- call `signel-start'
+ and pre-warm the contact cache with a background `listContacts' fetch so
+ the picker is instant on first use.
+- `signel-account' is nil -- `user-error' naming the remedy (set the
+ account in `cj/signal-private-config-file').
+
+If startup launches but the RPC handshake exits before the first response,
+the subsequent `signel--send-rpc' call (in the pre-warm or any later
+fetch) signals through its own error path; check =*signel-log*= and
+=*signel-stderr*= for detail and link the account manually."
+ (cond
+ ((process-live-p (get-process signel--process-name))
+ nil)
+ ((null signel-account)
+ (user-error
+ "signel-account is unset. Set it in %s (or your private config) and link the device manually with `signal-cli link', then retry"
+ cj/signal-private-config-file))
+ (t
+ (signel-start)
+ (cj/signel--fetch-contacts))))
+
+(defun cj/signel--fetch-contacts (&optional after-callback)
+ "Fetch the contact list from signal-cli and populate `cj/signel--contact-cache'.
+Issues a `listContacts' RPC and registers a success callback that runs
+the result through `cj/signal--parse-contacts' (the verified parser) and
+stores the resulting `(LABEL . RECIPIENT)' alist in the cache. An empty
+result populates the cache as nil; a failure goes through the dispatch
+error path and never invokes the callback, so the prior cache survives.
+
+AFTER-CALLBACK, when non-nil, is invoked with no arguments after the
+cache has been populated -- the picker uses this to unblock its
+bounded-wait on cold caches."
+ (signel--send-rpc
+ "listContacts" nil nil
+ (lambda (result)
+ (setq cj/signel--contact-cache (cj/signal--parse-contacts result))
+ (when after-callback (funcall after-callback)))))
+
+(defun cj/signel-refresh-contacts ()
+ "Clear the picker's contact cache and refetch it from signal-cli.
+Use when a contact added or renamed on the phone hasn't shown up in the
+picker yet; this forces a fresh `listContacts' rather than reading the
+cached snapshot."
+ (interactive)
+ (setq cj/signel--contact-cache nil)
+ (cj/signel--fetch-contacts))
+
+;;; Picker, self-message, and connect
+
+(declare-function signel-chat "signel" (recipient))
+(declare-function signel-dashboard "signel" ())
+(declare-function signel-stop "signel" ())
+
+(defun cj/signel-connect ()
+ "Connect to signal-cli, starting the daemon if needed.
+Thin interactive wrapper around `cj/signel--ensure-started' so the
+keymap has a friendly verb to bind."
+ (interactive)
+ (cj/signel--ensure-started)
+ (message "Signel connected."))
+
+(defun cj/signel-message ()
+ "Pick a Signal contact by name and open the chat buffer.
+Ensures the daemon is connected first (auto-starts and pre-warms on
+cold start, or errors with the remedy if the account isn't set). Uses
+the cached contact list when warm; on a cold cache, kicks off a fetch
+and waits up to `cj/signel-fetch-timeout' seconds for the result before
+raising a `user-error' so a dead daemon can't hang Emacs. The picker
+offers a pinned \"Note to Self\" entry plus every Signal contact, and
+opens the chosen recipient in `signel-chat'."
+ (interactive)
+ (cj/signel--ensure-started)
+ (unless cj/signel--contact-cache
+ (let ((done nil)
+ (deadline (+ (float-time) cj/signel-fetch-timeout)))
+ (cj/signel--fetch-contacts (lambda () (setq done t)))
+ (while (and (not done) (< (float-time) deadline))
+ (accept-process-output nil 0.1))
+ (unless done
+ (user-error
+ "Signal contact fetch timed out after %.1fs; try again or run M-x cj/signel-refresh-contacts (see *signel-log* for detail)"
+ cj/signel-fetch-timeout))))
+ (let* ((note-self (cons "Note to Self" signel-account))
+ (candidates (cons note-self cj/signel--contact-cache))
+ (table (lambda (string pred action)
+ (if (eq action 'metadata)
+ '(metadata
+ (display-sort-function . identity)
+ (cycle-sort-function . identity))
+ (complete-with-action action candidates string pred))))
+ (label (completing-read "Signal recipient: " table nil t))
+ (recipient (cdr (assoc label candidates))))
+ (when recipient
+ (signel-chat recipient))))
+
+(defun cj/signel-message-self ()
+ "Open a Signal chat buffer addressed to Note to Self.
+Resolves to `signel-account' (the linked phone number). Sending to it
+lands in the Signal Note-to-Self thread on the phone; manual-verify
+that on first use."
+ (interactive)
+ (cj/signel--ensure-started)
+ (unless signel-account
+ (user-error "signel-account is unset; cannot send to self"))
+ (signel-chat signel-account))
+
+(defvar cj/signel-prefix-map
+ (let ((map (make-sparse-keymap)))
+ (keymap-set map "m" #'cj/signel-message)
+ (keymap-set map "s" #'cj/signel-message-self)
+ (keymap-set map "d" #'signel-dashboard)
+ (keymap-set map "q" #'signel-stop)
+ (keymap-set map "SPC" #'cj/signel-connect)
+ map)
+ "Signel \"Messages\" prefix keymap, bound under `C-; M'.
+Leaves =l= unbound for now -- the future =cj/signel-link= command lands
+in a later pass. See =docs/design/signal-client.org= scope summary.")
+
+(declare-function cj/custom-keymap "keybindings" ())
+(with-eval-after-load 'keybindings
+ (when (boundp 'cj/custom-keymap)
+ (keymap-set cj/custom-keymap "M" cj/signel-prefix-map)))
+
(provide 'signal-config)
;;; signal-config.el ends here