aboutsummaryrefslogtreecommitdiff
path: root/modules/signal-config.el
blob: 0b6668b166cfca4f3bd82721a7dc4f95bbd7d573 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
;;; signal-config.el --- Signal client (forked signel) configuration -*- lexical-binding: t -*-

;;; Commentary:
;; cj/-namespaced configuration and helpers layered on the forked `signel'
;; package, a Signal client that drives signal-cli over JSON-RPC.
;;
;; This file currently holds the pure, signal-cli-independent helper layer
;; that the fork edits and `use-package' wiring build on:
;;   - contact-list parsing for a completing-read contact picker, and
;;   - the predicate that suppresses a notification for the chat the user
;;     is actively viewing.
;; Both are unit-tested without a linked account.  The use-package wiring,
;; keybindings, and the signel fork edits that call these helpers land once
;; signal-cli is installed and the device is linked.

;;; Code:

(require 'seq)

(defun cj/signal--jstr (value)
  "Return VALUE if it is a non-blank string, else nil.
Normalizes a JSON field that may arrive as nil, the empty string, or a
null sentinel symbol into a plain string-or-nil."
  (and (stringp value)
       (not (string-empty-p (string-trim value)))
       value))

(defun cj/signal--combine-name (given family)
  "Join GIVEN and FAMILY name parts into a trimmed full name, or nil.
Either part may be nil, the empty string, or a JSON null sentinel."
  (let ((parts (delq nil (list (cj/signal--jstr given) (cj/signal--jstr family)))))
    (cj/signal--jstr (mapconcat #'identity parts " "))))

(defun cj/signal--contact-display-name (contact)
  "Return a display name for CONTACT, or nil when none is set.
CONTACT is one entry alist from signal-cli `listContacts'.  Picks the
first set source in priority order: the nickname (combined nickName, or
nickGivenName+nickFamilyName), the stored contact name, the top-level
givenName+familyName, the profile givenName+familyName, then username.
signal-cli 0.14 puts givenName/familyName at the top level; the profile
sub-object's name fields are usually null, so it is the deeper fallback."
  (let ((profile (alist-get 'profile contact)))
    (seq-find
     #'cj/signal--jstr
     (list (cj/signal--jstr (alist-get 'nickName contact))
           (cj/signal--combine-name (alist-get 'nickGivenName contact)
                                    (alist-get 'nickFamilyName contact))
           (cj/signal--jstr (alist-get 'name contact))
           (cj/signal--combine-name (alist-get 'givenName contact)
                                    (alist-get 'familyName contact))
           (cj/signal--combine-name (alist-get 'givenName profile)
                                    (alist-get 'familyName profile))
           (cj/signal--jstr (alist-get 'username contact))))))

(defun cj/signal--parse-contacts (result)
  "Parse RESULT from signal-cli `listContacts' into a completing-read alist.
RESULT is the JSON-RPC result value: a sequence (list or vector) of
contact alists.  Returns an alist of (LABEL . RECIPIENT) sorted by LABEL,
where RECIPIENT is the contact's phone number (falling back to its UUID)
and LABEL is \"Name (recipient)\" when a name is known, or the bare
recipient otherwise.  Contacts with no usable recipient are dropped."
  (let (pairs)
    (dolist (contact (append result nil))
      (let ((recipient (or (cj/signal--jstr (alist-get 'number contact))
                           (cj/signal--jstr (alist-get 'uuid contact))))
            (name (cj/signal--contact-display-name contact)))
        (when recipient
          (push (cons (if name (format "%s (%s)" name recipient) recipient)
                      recipient)
                pairs))))
    (sort pairs (lambda (a b) (string-lessp (car a) (car b))))))

(defun cj/signal--chat-buffer-name (id)
  "Return the chat buffer name `signel' uses for chat ID."
  (format "*Signel: %s*" id))

(defun cj/signal--suppress-notify-p (chat-id viewing-buffer-name frame-focused)
  "Return non-nil when a notification for CHAT-ID should be suppressed.
Suppress only while the user is actively viewing that chat: the chat
buffer named by `cj/signal--chat-buffer-name' is VIEWING-BUFFER-NAME and
FRAME-FOCUSED is non-nil.  A nil VIEWING-BUFFER-NAME or an unfocused
frame never suppresses."
  (and frame-focused
       (stringp viewing-buffer-name)
       (string= viewing-buffer-name (cj/signal--chat-buffer-name chat-id))))

(defun cj/signal--frame-focused-p ()
  "Return non-nil when the selected frame currently has input focus.
Treats an unknown focus state as focused."
  (if (fboundp 'frame-focus-state)
      (let ((state (frame-focus-state)))
        (if (eq state 'unknown) t state))
    t))

(defun cj/signal--should-notify-p (chat-id)
  "Return non-nil when an incoming message for CHAT-ID should notify.
Notify unless the user is actively viewing that chat in the selected
window of a focused frame."
  (not (cj/signal--suppress-notify-p
        chat-id
        (buffer-name (window-buffer (selected-window)))
        (cj/signal--frame-focused-p))))

;;; signel — fork integration

(defcustom cj/signal-private-config-file
  (expand-file-name "signal-config.local.el" user-emacs-directory)
  "Private signal-config file, loaded when readable.
This is the place to set `signel-account' to the linked phone number so
the number stays out of the version-controlled (and publicly mirrored)
config.  A phone number is an identifier rather than a credential, so it
lives here rather than in authinfo, which avoids a GPG prompt at connect
time."
  :type 'file
  :group 'signel)

(use-package signel
  :load-path "~/code/signel"
  :ensure nil
  :commands (signel-start signel-stop signel-chat signel-dashboard)
  :custom
  ;; Don't let an incoming message steal a window by auto-popping its chat
  ;; buffer; surface arrivals through notifications instead (see child task
  ;; "Notify only for the unviewed conversation").
  (signel-auto-open-buffer nil)
  :config
  (when (file-readable-p cj/signal-private-config-file)
    (load cj/signal-private-config-file nil t)))

(provide 'signal-config)
;;; signal-config.el ends here