Compare commits
16 Commits
8d820432ff
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0151d4ad02 | |||
| 6a5d4b3c1d | |||
| 71691a5261 | |||
| cedd82738a | |||
| 0dd2919fad | |||
| 7cdd7aecad | |||
| 16d55a1a2f | |||
| bd9cf62872 | |||
| 56501cba94 | |||
| 55cab2fc28 | |||
| 15baff4f26 | |||
| a4dbcf8287 | |||
| b090819483 | |||
| 869f4cb67b | |||
| dcead245a7 | |||
| 02d78765bc |
@@ -2,7 +2,7 @@
|
||||
|
||||
## What this is
|
||||
|
||||
Noise is a Signal Messenger client for Emacs, written in Elisp. It communicates with a local `signal-cli` instance via JSON-RPC over HTTP (default: `localhost:9128`). Conversations are stored in SQLite via `emacs-sqlite3-api`.
|
||||
Noise is a Signal Messenger client for Emacs, written in Elisp. It communicates with a local `signal-cli` instance via JSON-RPC over HTTP (default: `localhost:8080`). Conversations are stored in SQLite via `emacs-sqlite3-api`.
|
||||
|
||||
## Technology & Architecture
|
||||
|
||||
@@ -10,13 +10,16 @@ Noise is a Signal Messenger client for Emacs, written in Elisp. It communicates
|
||||
|---|---|
|
||||
| Language | Emacs Lisp (Elisp) |
|
||||
| Message backend | `signal-cli` JSON-RPC (`signal-cli daemon`) |
|
||||
| Real-time messages | SSE via `GET /api/v1/events` |
|
||||
| Local storage | SQLite (`emacs-sqlite3-api`) |
|
||||
| Runtime | GNU Emacs |
|
||||
|
||||
- All Signal protocol work is delegated to `signal-cli`. Noise is a *client* — it does not implement Signal crypto.
|
||||
- The JSON-RPC interface is called over HTTP (not piped subprocess stdio), so requests are `POST http://localhost:9128/api/v1/rpc` with JSON-RPC 2.0 payloads.
|
||||
- The JSON-RPC interface is called over HTTP (not piped subprocess stdio), so requests are `POST http://localhost:8080/api/v1/rpc` with JSON-RPC 2.0 payloads.
|
||||
- Noise uses Server-Sent Events (SSE) to receive messages in real-time from signal-cli's `/api/v1/events` endpoint.
|
||||
- SQLite is the single source of truth for local state: contacts, conversations, messages, read/unread markers.
|
||||
- The address is configurable via the `noise-signal-cli-address` defcustom.
|
||||
- The Signal account is configurable via the `noise-account` defcustom (nil for a single-account daemon; required when signal-cli has multiple accounts registered). Account-scoped RPC calls go through `noise-rpc-call-for-account`.
|
||||
|
||||
## User Experience Principles
|
||||
|
||||
@@ -56,3 +59,7 @@ These come directly from the UI mockup (`signal-emacs-ui.html`) and README:
|
||||
- Elisp files should be byte-compilable with `emacs -batch -f batch-byte-compile`.
|
||||
- Follow Emacs buffer-naming conventions and major-mode patterns.
|
||||
- Always respect `noise-signal-cli-address` as the configurable JSON-RPC endpoint.
|
||||
- Uses SSE (Server-Sent Events) for real-time message reception.
|
||||
- `noise-sse.el` handles connection and event parsing.
|
||||
- `noise-receive.el` processes envelopes and updates UI.
|
||||
- Automatic reconnection with exponential backoff.
|
||||
|
||||
+38
-1
@@ -8,7 +8,7 @@ Its ultimate goal is to provide a fast and efficient user interface for Signal c
|
||||
The tech stack is Elisp and SQLite (for storing conversations)
|
||||
|
||||
** Setup
|
||||
The project assumes a working installation of signal-cli and interfaces with that via JSON-RPC.
|
||||
The project assumes a working installation of signal-cli and interfaces with that via JSON-RPC. Noise uses Server-Sent Events (SSE) to receive messages in real-time.
|
||||
|
||||
** Dependencies
|
||||
|
||||
@@ -63,6 +63,9 @@ Below are recipes for several popular Emacs package managers.
|
||||
;; Optional: set a custom signal-cli address
|
||||
;; (setq noise-signal-cli-address "http://localhost:8080")
|
||||
|
||||
;; Required when signal-cli has multiple accounts registered:
|
||||
;; (setq noise-account "+15551234567")
|
||||
|
||||
;; Start Noise automatically
|
||||
(noise-mode 1))
|
||||
#+end_src
|
||||
@@ -130,6 +133,25 @@ Once signal-cli is running and Noise is installed:
|
||||
|
||||
- =M-x noise-mode= — toggle the Noise client (opens the chat list)
|
||||
- =M-x noise-check-connection= — test connectivity to signal-cli
|
||||
- =M-x noise-select-account= — pick which registered signal-cli account to use (sets =noise-account=)
|
||||
- =M-x noise-new-chat= — start a new conversation by searching contacts
|
||||
- =M-x noise-new-chat-sync-contacts= — re-sync contacts from signal-cli
|
||||
- =M-x noise-receive= — fetch new messages from signal-cli once
|
||||
|
||||
While =noise-mode= is enabled, Noise connects to signal-cli's SSE
|
||||
endpoint at =/api/v1/events= for real-time message reception. Incoming
|
||||
messages, delivery/read receipts, typing indicators, and messages sent
|
||||
from your other linked devices are all stored locally and reflected in
|
||||
open buffers automatically.
|
||||
|
||||
The SSE connection automatically reconnects with exponential backoff
|
||||
if the connection is lost. You can configure the maximum reconnect
|
||||
delay via =noise-sse-max-reconnect-delay= (default 60 seconds).
|
||||
|
||||
If signal-cli has more than one account registered, set =noise-account=
|
||||
to the phone number you want to use (E.164 format), or run
|
||||
=M-x noise-select-account= to choose interactively. With a single
|
||||
account it can stay nil.
|
||||
|
||||
*** Keybindings
|
||||
|
||||
@@ -140,12 +162,27 @@ Once signal-cli is running and Noise is installed:
|
||||
| =n= | Next chat |
|
||||
| =p= | Previous chat |
|
||||
| =RET= | Open selected chat |
|
||||
| =c= | New chat (search contacts) |
|
||||
| =m= | Mark chat as read |
|
||||
| =g= | Refresh chat list |
|
||||
| =/= | Filter chats |
|
||||
| =q= | Quit chat list window |
|
||||
| =C-x b= | Switch conversation (minibuffer) |
|
||||
|
||||
**** Conversation buffer (*signal:NAME*)
|
||||
|
||||
Compose inline at the =>= prompt at the bottom of the buffer. Message
|
||||
history above the prompt is read-only.
|
||||
|
||||
| Key | Action |
|
||||
|-----------+---------------------------------|
|
||||
| =RET= | Send the composed message |
|
||||
| =M-RET= | Insert a newline (multi-line) |
|
||||
| =C-c C-g= | Refresh conversation from store |
|
||||
|
||||
Sent messages show a =✓= once sent and =✓✓= once delivered or read.
|
||||
When the contact is typing, the modeline shows =NAME is typing…=.
|
||||
|
||||
**** Minibuffer switcher
|
||||
|
||||
=M-x noise-switcher= (or =C-x b= from the chat list) opens a fuzzy
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
# SSE Migration Implementation Plan
|
||||
|
||||
> [!NOTE]
|
||||
> This document may not reflect the current implementation.
|
||||
> See the final report for up-to-date state:
|
||||
> [Final Report](../reports/sse-migration.md)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace polling-based message reception with SSE (Server-Sent Events) for real-time message delivery from signal-cli.
|
||||
|
||||
**Architecture:** Create a new `noise-sse.el` module that implements an SSE client using Emacs process filters. The client connects to signal-cli's `/api/v1/events` endpoint, parses incoming SSE events, and feeds them to the existing envelope processing pipeline. Includes automatic reconnection with exponential backoff.
|
||||
|
||||
**Tech Stack:** Emacs Lisp, `make-network-process`, `url.el` for URL parsing, existing `noise-receive.el` envelope processing.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `noise-sse.el` | Create | SSE client with process filter, reconnection logic |
|
||||
| `noise-receive.el` | Modify | Remove polling, add SSE start/stop functions |
|
||||
| `noise.el` | Modify | Update `noise-mode` to use SSE |
|
||||
| `test/noise-sse-test.el` | Create | Unit tests for SSE parsing and reconnection |
|
||||
| `test/noise-receive-test.el` | Modify | Update tests for SSE integration |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create SSE Client Core
|
||||
|
||||
**Covers:** S1 (SSE connection management)
|
||||
|
||||
**Files:**
|
||||
- Create: `noise-sse.el`
|
||||
- Test: `test/noise-sse-test.el`
|
||||
|
||||
- [ ] **Step 1: Write failing test for SSE event parsing**
|
||||
|
||||
```elisp
|
||||
;; test/noise-sse-test.el
|
||||
(require 'ert)
|
||||
(require 'noise-sse)
|
||||
|
||||
(ert-deftest noise-sse--parse-event-basic ()
|
||||
"Parse a basic SSE event with data field."
|
||||
(let ((event "data: {\"type\":\"message\",\"content\":\"hello\"}\n\n"))
|
||||
(let ((result (noise-sse--parse-event event)))
|
||||
(should (hash-table-p result))
|
||||
(should (equal (gethash "type" result) "message"))
|
||||
(should (equal (gethash "content" result) "hello")))))
|
||||
|
||||
(ert-deftest noise-sse--parse-event-multiline ()
|
||||
"Parse SSE event with multiline data."
|
||||
(let ((event "data: {\"type\":\"message\"\ndata: \"content\":\"hello\"}\n\n"))
|
||||
(let ((result (noise-sse--parse-event event)))
|
||||
(should (hash-table-p result))
|
||||
(should (equal (gethash "type" result) "message")))))
|
||||
|
||||
(ert-deftest noise-sse--parse-event-empty ()
|
||||
"Parse empty SSE event returns nil."
|
||||
(let ((event "\n\n"))
|
||||
(should (null (noise-sse--parse-event event)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: FAIL with "void-function noise-sse--parse-event"
|
||||
|
||||
- [ ] **Step 3: Write minimal SSE parsing implementation**
|
||||
|
||||
```elisp
|
||||
;; noise-sse.el --- SSE client for signal-cli -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Implements Server-Sent Events client for receiving real-time messages
|
||||
;; from signal-cli daemon's /api/v1/events endpoint.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'url)
|
||||
(require 'json)
|
||||
(require 'cl-lib)
|
||||
|
||||
(defun noise-sse--parse-event (event-string)
|
||||
"Parse an SSE EVENT-STRING into a hash table.
|
||||
Returns nil for empty or invalid events."
|
||||
(when (and event-string (not (string-empty-p event-string)))
|
||||
(let ((lines (split-string event-string "\n"))
|
||||
(data-lines '()))
|
||||
(dolist (line lines)
|
||||
(when (string-prefix-p "data: " line)
|
||||
(push (substring line 6) data-lines)))
|
||||
(when data-lines
|
||||
(let ((json-str (mapconcat #'identity (nreverse data-lines) "")))
|
||||
(condition-case nil
|
||||
(json-parse-string json-str)
|
||||
(error nil)))))))
|
||||
|
||||
(provide 'noise-sse)
|
||||
;;; noise-sse.el ends here
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add noise-sse.el test/noise-sse-test.el
|
||||
git commit -m "feat: add SSE event parsing for signal-cli"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add SSE Connection Management
|
||||
|
||||
**Covers:** S1, S2 (connection lifecycle, reconnection)
|
||||
|
||||
**Files:**
|
||||
- Modify: `noise-sse.el`
|
||||
- Modify: `test/noise-sse-test.el`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for connection state**
|
||||
|
||||
```elisp
|
||||
;; Add to test/noise-sse-test.el
|
||||
|
||||
(ert-deftest noise-sse--initial-state ()
|
||||
"SSE client starts in disconnected state."
|
||||
(let ((noise-sse--process nil)
|
||||
(noise-sse--reconnect-timer nil)
|
||||
(noise-sse--reconnect-delay 1))
|
||||
(should (null noise-sse--process))
|
||||
(should (null noise-sse--reconnect-timer))
|
||||
(should (= 1 noise-sse--reconnect-delay))))
|
||||
|
||||
(ert-deftest noise-sse--reconnect-delay-backoff ()
|
||||
"Reconnect delay doubles on each attempt."
|
||||
(let ((noise-sse--reconnect-delay 1))
|
||||
(noise-sse--increase-reconnect-delay)
|
||||
(should (= 2 noise-sse--reconnect-delay))
|
||||
(noise-sse--increase-reconnect-delay)
|
||||
(should (= 4 noise-sse--reconnect-delay))
|
||||
(noise-sse--increase-reconnect-delay)
|
||||
(should (= 8 noise-sse--reconnect-delay))))
|
||||
|
||||
(ert-deftest noise-sse--reconnect-delay-max ()
|
||||
"Reconnect delay caps at max value."
|
||||
(let ((noise-sse--reconnect-delay 32)
|
||||
(noise-sse--max-reconnect-delay 60))
|
||||
(noise-sse--increase-reconnect-delay)
|
||||
(should (= 60 noise-sse--reconnect-delay))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: FAIL with "void-function noise-sse--increase-reconnect-delay"
|
||||
|
||||
- [ ] **Step 3: Implement connection state and reconnection logic**
|
||||
|
||||
```elisp
|
||||
;; Add to noise-sse.el after requires
|
||||
|
||||
(defvar noise-sse--process nil
|
||||
"Active SSE network process, or nil.")
|
||||
|
||||
(defvar noise-sse--reconnect-timer nil
|
||||
"Timer for reconnection attempts, or nil.")
|
||||
|
||||
(defvar noise-sse--reconnect-delay 1
|
||||
"Current delay in seconds before next reconnection attempt.")
|
||||
|
||||
(defvar noise-sdefcustom noise-sse-max-reconnect-delay 60
|
||||
"Maximum delay in seconds between reconnection attempts."
|
||||
:type 'integer
|
||||
:group 'noise)
|
||||
|
||||
(defun noise-sse--increase-reconnect-delay ()
|
||||
"Increase reconnect delay with exponential backoff, capped at max."
|
||||
(setq noise-sse--reconnect-delay
|
||||
(min (* noise-sse--reconnect-delay 2)
|
||||
noise-sse-max-reconnect-delay)))
|
||||
|
||||
(defun noise-sse--reset-reconnect-delay ()
|
||||
"Reset reconnect delay to initial value after successful connection."
|
||||
(setq noise-sse--reconnect-delay 1))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add noise-sse.el test/noise-sse-test.el
|
||||
git commit -m "feat: add SSE connection state and reconnection logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement Process Filter for SSE Stream
|
||||
|
||||
**Covers:** S1, S3 (real-time event processing)
|
||||
|
||||
**Files:**
|
||||
- Modify: `noise-sse.el`
|
||||
- Modify: `test/noise-sse-test.el`
|
||||
|
||||
- [ ] **Step 1: Write failing test for process filter**
|
||||
|
||||
```elisp
|
||||
;; Add to test/noise-sse-test.el
|
||||
|
||||
(ert-deftest noise-sse--filter-processes-complete-event ()
|
||||
"Process filter parses and calls handler for complete events."
|
||||
(let ((processed-events '()))
|
||||
(cl-letf (((symbol-function 'noise-sse--handle-event)
|
||||
(lambda (event) (push event processed-events))))
|
||||
(let ((noise-sse--buffer ""))
|
||||
(noise-sse--filter nil "data: {\"type\":\"message\"}\n\n")
|
||||
(should (= 1 (length processed-events)))
|
||||
(should (hash-table-p (car processed-events)))))))
|
||||
|
||||
(ert-deftest noise-sse--filter-buffers-incomplete-event ()
|
||||
"Process filter buffers incomplete events until newline."
|
||||
(let ((processed-events '()))
|
||||
(cl-letf (((symbol-function 'noise-sse--handle-event)
|
||||
(lambda (event) (push event processed-events))))
|
||||
(let ((noise-sse--buffer ""))
|
||||
(noise-sse--filter nil "data: {\"type\":\"message\"")
|
||||
(should (= 0 (length processed-events)))
|
||||
(noise-sse--filter nil "}\n\n")
|
||||
(should (= 1 (length processed-events)))))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: FAIL with "void-function noise-sse--filter"
|
||||
|
||||
- [ ] **Step 3: Implement process filter**
|
||||
|
||||
```elisp
|
||||
;; Add to noise-sse.el
|
||||
|
||||
(defvar noise-sse--buffer ""
|
||||
"Buffer for partial SSE event data.")
|
||||
|
||||
(defun noise-sse--filter (proc string)
|
||||
"Process filter for SSE connection.
|
||||
Buffers incoming STRING and processes complete events."
|
||||
(setq noise-sse--buffer (concat noise-sse--buffer string))
|
||||
(while (string-match "\n\n" noise-sse--buffer)
|
||||
(let ((event-end (match-beginning 0))
|
||||
(event-start 0))
|
||||
(let ((event-str (substring noise-sse--buffer event-start event-end)))
|
||||
(setq noise-sse--buffer
|
||||
(substring noise-sse--buffer (+ event-end 2)))
|
||||
(when-let ((event (noise-sse--parse-event event-str)))
|
||||
(noise-sse--handle-event event))))))
|
||||
|
||||
(defun noise-sse--handle-event (event)
|
||||
"Handle a parsed SSE EVENT.
|
||||
This function will be overridden in noise-receive to process envelopes."
|
||||
(message "SSE event received: %s" event))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add noise-sse.el test/noise-sse-test.el
|
||||
git commit -m "feat: add SSE process filter for stream parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Implement SSE Connection Functions
|
||||
|
||||
**Covers:** S1, S2 (connection establishment, URL construction)
|
||||
|
||||
**Files:**
|
||||
- Modify: `noise-sse.el`
|
||||
- Modify: `test/noise-sse-test.el`
|
||||
|
||||
- [ ] **Step 1: Write failing test for URL construction**
|
||||
|
||||
```elisp
|
||||
;; Add to test/noise-sse-test.el
|
||||
|
||||
(ert-deftest noise-sse--build-url ()
|
||||
"Build SSE endpoint URL from base address."
|
||||
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||
(noise-account "+15551234567"))
|
||||
(let ((url (noise-sse--build-url)))
|
||||
(should (string-match-p "/api/v1/events" url))
|
||||
(should (string-match-p "account=%2B15551234567" url)))))
|
||||
|
||||
(ert-deftest noise-sse--build-url-no-account ()
|
||||
"Build SSE endpoint URL without account parameter."
|
||||
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||
(noise-account nil))
|
||||
(let ((url (noise-sse--build-url)))
|
||||
(should (string-match-p "/api/v1/events" url))
|
||||
(should-not (string-match-p "account=" url)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: FAIL with "void-function noise-sse--build-url"
|
||||
|
||||
- [ ] **Step 3: Implement URL construction and connection**
|
||||
|
||||
```elisp
|
||||
;; Add to noise-sse.el
|
||||
|
||||
(declare-variable noise-signal-cli-address)
|
||||
(declare-variable noise-account)
|
||||
|
||||
(defun noise-sse--build-url ()
|
||||
"Build the SSE endpoint URL for signal-cli."
|
||||
(let ((base (concat noise-signal-cli-address "/api/v1/events")))
|
||||
(if (and (boundp 'noise-account) noise-account)
|
||||
(concat base "?account=" (url-hexify-string noise-account))
|
||||
base)))
|
||||
|
||||
(defun noise-sse--sentinel (proc status)
|
||||
"Process sentinel for SSE connection.
|
||||
Handles connection close and triggers reconnection."
|
||||
(when (or (equal status "finished\n")
|
||||
(string-match-p "failed" status)
|
||||
(string-match-p "deleted" status))
|
||||
(message "Noise: SSE connection lost, reconnecting in %ds..."
|
||||
noise-sse--reconnect-delay)
|
||||
(setq noise-sse--process nil)
|
||||
(noise-sse--schedule-reconnect)))
|
||||
|
||||
(defun noise-sse--schedule-reconnect ()
|
||||
"Schedule a reconnection attempt after the current delay."
|
||||
(when noise-sse--reconnect-timer
|
||||
(cancel-timer noise-sse--reconnect-timer))
|
||||
(setq noise-sse--reconnect-timer
|
||||
(run-with-timer noise-sse--reconnect-delay nil
|
||||
#'noise-sse-connect)))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add noise-sse.el test/noise-sse-test.el
|
||||
git commit -m "feat: add SSE URL construction and connection management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Implement Connect/Disconnect Functions
|
||||
|
||||
**Covers:** S1, S2 (public API, connection lifecycle)
|
||||
|
||||
**Files:**
|
||||
- Modify: `noise-sse.el`
|
||||
- Modify: `test/noise-sse-test.el`
|
||||
|
||||
- [ ] **Step 1: Write failing test for connect/disconnect**
|
||||
|
||||
```elisp
|
||||
;; Add to test/noise-sse-test.el
|
||||
|
||||
(ert-deftest noise-sse-connect-creates-process ()
|
||||
"noise-sse-connect creates a network process."
|
||||
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||
(noise-account nil)
|
||||
(noise-sse--process nil)
|
||||
(created-process nil))
|
||||
(cl-letf (((symbol-function 'make-network-process)
|
||||
(lambda (&rest args) (setq created-process t) (make-identity-process "test"))))
|
||||
(noise-sse-connect)
|
||||
(should created-process)
|
||||
(when noise-sse--process
|
||||
(delete-process noise-sse--process)))))
|
||||
|
||||
(ert-deftest noise-sse-disconnect-cleans-up ()
|
||||
"noise-sse-disconnect cleans up process and timer."
|
||||
(let ((noise-sse--process (make-identity-process "test"))
|
||||
(noise-sse--reconnect-timer (run-with-timer 1000 nil #'ignore)))
|
||||
(noise-sse-disconnect)
|
||||
(should (null noise-sse--process))
|
||||
(should (null noise-sse--reconnect-timer))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: FAIL with "void-function noise-sse-connect"
|
||||
|
||||
- [ ] **Step 3: Implement connect/disconnect functions**
|
||||
|
||||
```elisp
|
||||
;; Add to noise-sse.el
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-sse-connect ()
|
||||
"Connect to signal-cli SSE endpoint."
|
||||
(interactive)
|
||||
(noise-sse-disconnect)
|
||||
(let* ((url (noise-sse--build-url))
|
||||
(parsed (url-generic-parse-url url))
|
||||
(host (url-host parsed))
|
||||
(port (or (url-port parsed) 80))
|
||||
(proc (make-network-process
|
||||
:name "noise-sse"
|
||||
:host host
|
||||
:port port
|
||||
:filter #'noise-sse--filter
|
||||
:sentinel #'noise-sse--sentinel)))
|
||||
(setq noise-sse--process proc)
|
||||
(noise-sse--reset-reconnect-delay)
|
||||
(process-send-string proc
|
||||
(format "GET %s HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n"
|
||||
(url-filename parsed)
|
||||
host))
|
||||
(message "Noise: SSE connected to %s" url)))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-sse-disconnect ()
|
||||
"Disconnect from signal-cli SSE endpoint."
|
||||
(interactive)
|
||||
(when noise-sse--process
|
||||
(delete-process noise-sse--process)
|
||||
(setq noise-sse--process nil))
|
||||
(when noise-sse--reconnect-timer
|
||||
(cancel-timer noise-sse--reconnect-timer)
|
||||
(setq noise-sse--reconnect-timer nil))
|
||||
(setq noise-sse--buffer "")
|
||||
(message "Noise: SSE disconnected"))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add noise-sse.el test/noise-sse-test.el
|
||||
git commit -m "feat: add SSE connect/disconnect public API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Integrate SSE with Message Reception
|
||||
|
||||
**Covers:** S1, S3, S4 (integration with existing envelope processing)
|
||||
|
||||
**Files:**
|
||||
- Modify: `noise-receive.el`
|
||||
- Modify: `noise-sse.el`
|
||||
|
||||
- [ ] **Step 1: Add SSE event handler to noise-receive.el**
|
||||
|
||||
```elisp
|
||||
;; Add to noise-receive.el after requires
|
||||
|
||||
(require 'noise-sse)
|
||||
|
||||
(defun noise-receive--handle-sse-event (event)
|
||||
"Handle an SSE EVENT from signal-cli.
|
||||
Processes the envelope and updates UI."
|
||||
(noise-db-init)
|
||||
(when-let ((envelope (noise-receive--field event "envelope")))
|
||||
(when-let ((conv-id (noise-receive--process-envelope envelope)))
|
||||
(noise-conversation-refresh-for conv-id)
|
||||
(when-let ((buf (get-buffer "*signal*")))
|
||||
(with-current-buffer buf
|
||||
(noise-chat-list--render))))))
|
||||
|
||||
;; Override the default handler in noise-sse
|
||||
(setq noise-sse--handle-event #'noise-receive--handle-sse-event)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add SSE start/stop functions**
|
||||
|
||||
```elisp
|
||||
;; Add to noise-receive.el
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-receive-start-sse ()
|
||||
"Start receiving messages via SSE."
|
||||
(interactive)
|
||||
(noise-sse-connect))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-receive-stop-sse ()
|
||||
"Stop receiving messages via SSE."
|
||||
(interactive)
|
||||
(noise-sse-disconnect))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove polling functions**
|
||||
|
||||
```elisp
|
||||
;; Remove from noise-receive.el:
|
||||
;; - noise-receive-interval defcustom
|
||||
;; - noise-receive--timer defvar
|
||||
;; - noise-receive--last-poll-error defvar
|
||||
;; - noise-receive-start-polling function
|
||||
;; - noise-receive-stop-polling function
|
||||
;; - noise-receive--poll function
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update tests**
|
||||
|
||||
```elisp
|
||||
;; Update test/noise-receive-poll-test.el to test SSE integration
|
||||
;; or rename to test/noise-receive-test.el
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add noise-receive.el noise-sse.el
|
||||
git commit -m "feat: integrate SSE with message reception pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update Noise Mode
|
||||
|
||||
**Covers:** S4 (user-facing mode toggle)
|
||||
|
||||
**Files:**
|
||||
- Modify: `noise.el`
|
||||
|
||||
- [ ] **Step 1: Update noise-mode to use SSE**
|
||||
|
||||
```elisp
|
||||
;; In noise.el, update noise-mode definition:
|
||||
|
||||
(define-minor-mode noise-mode
|
||||
"Toggle Noise Signal client mode."
|
||||
:global t
|
||||
:lighter " Noise"
|
||||
:group 'noise
|
||||
(if noise-mode
|
||||
(progn
|
||||
(noise-chat-list)
|
||||
(noise-receive-start-sse))
|
||||
(noise-receive-stop-sse)
|
||||
(message "Noise mode disabled")))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add SSE configuration options**
|
||||
|
||||
```elisp
|
||||
;; Add to noise.el defcustom section:
|
||||
|
||||
(defcustom noise-sse-max-reconnect-delay 60
|
||||
"Maximum delay in seconds between SSE reconnection attempts."
|
||||
:type 'integer
|
||||
:group 'noise)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add noise.el
|
||||
git commit -m "feat: update noise-mode to use SSE instead of polling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Documentation and Cleanup
|
||||
|
||||
**Covers:** S5 (documentation updates)
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `AGENTS.md`
|
||||
|
||||
- [ ] **Step 1: Update README.md**
|
||||
|
||||
```markdown
|
||||
## How it works
|
||||
|
||||
Noise uses Server-Sent Events (SSE) to receive messages in real-time from
|
||||
signal-cli. When a message arrives, it's instantly processed and displayed.
|
||||
|
||||
### SSE Connection
|
||||
|
||||
- Connects to `signal-cli` at `/api/v1/events`
|
||||
- Automatic reconnection with exponential backoff on disconnect
|
||||
- Configurable via `noise-sse-max-reconnect-delay`
|
||||
|
||||
### Manual Refresh
|
||||
|
||||
You can manually refresh messages with `M-x noise-receive` even while
|
||||
SSE is running.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update AGENTS.md**
|
||||
|
||||
```markdown
|
||||
## Architecture
|
||||
|
||||
- Uses SSE (Server-Sent Events) for real-time message reception
|
||||
- `noise-sse.el` handles connection and event parsing
|
||||
- `noise-receive.el` processes envelopes and updates UI
|
||||
- Automatic reconnection with exponential backoff
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md AGENTS.md
|
||||
git commit -m "docs: update documentation for SSE migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [ ] **Spec coverage:** All design requirements covered by tasks
|
||||
- [ ] **Placeholder scan:** No TBD/TODO/placeholders in steps
|
||||
- [ ] **Type consistency:** Function names and signatures consistent across tasks
|
||||
- [ ] **Test coverage:** Each component has corresponding tests
|
||||
- [ ] **Commit frequency:** Each task has meaningful commit points
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
feature: SSE Migration
|
||||
status: delivered
|
||||
specs: []
|
||||
plans:
|
||||
- docs/compose/plans/2026-06-11-sse-migration.md
|
||||
branch: main
|
||||
commits: 869f4cb..16d55a1
|
||||
---
|
||||
|
||||
# SSE Migration — Final Report
|
||||
|
||||
## What Was Built
|
||||
|
||||
Noise now uses Server-Sent Events (SSE) to receive messages from signal-cli in real-time, replacing the previous polling-based approach. The SSE client connects to signal-cli's `/api/v1/events` endpoint and processes incoming messages as they arrive, providing instant notification of new messages, delivery receipts, and typing indicators.
|
||||
|
||||
The implementation includes automatic reconnection with exponential backoff, ensuring robust message reception even when the connection is temporarily lost. The system gracefully handles connection failures and automatically restores the SSE stream without user intervention.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `noise-sse.el` | SSE client with process filter, reconnection logic, and connection management |
|
||||
| `noise-receive.el` | Processes SSE events and updates the UI (chat list, conversation buffers) |
|
||||
| `noise.el` | Entry point with `noise-mode` that starts/stops SSE |
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
signal-cli daemon
|
||||
↓ GET /api/v1/events
|
||||
noise-sse client (process filter)
|
||||
↓ parse SSE events
|
||||
noise-receive--handle-sse-event
|
||||
↓ process envelope
|
||||
UI refresh (chat list, conversation buffers)
|
||||
```
|
||||
|
||||
### Key Functions
|
||||
|
||||
- `noise-sse-connect` — Establishes SSE connection to signal-cli
|
||||
- `noise-sse-disconnect` — Cleans up connection and timers
|
||||
- `noise-receive--handle-sse-event` — Processes incoming SSE events
|
||||
- `noise-receive-start-sse` / `noise-receive-stop-sse` — Public API for SSE management
|
||||
|
||||
### Reconnection Strategy
|
||||
|
||||
- Initial connection: immediate
|
||||
- On disconnect: exponential backoff (1s, 2s, 4s, 8s, ... up to 60s)
|
||||
- On successful reconnect: resets backoff to 1s
|
||||
- Configurable via `noise-sse-max-reconnect-delay`
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting SSE
|
||||
|
||||
When `noise-mode` is enabled, SSE starts automatically:
|
||||
|
||||
```elisp
|
||||
(noise-mode 1) ;; Starts SSE connection
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```elisp
|
||||
M-x noise-receive-start-sse
|
||||
```
|
||||
|
||||
### Stopping SSE
|
||||
|
||||
When `noise-mode` is disabled, SSE stops automatically:
|
||||
|
||||
```elisp
|
||||
(noise-mode -1) ;; Stops SSE connection
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```elisp
|
||||
M-x noise-receive-stop-sse
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```elisp
|
||||
;; Maximum reconnect delay (default: 60 seconds)
|
||||
(setq noise-sse-max-reconnect-delay 60)
|
||||
|
||||
;; Signal-cli address (default: http://localhost:8080)
|
||||
(setq noise-signal-cli-address "http://localhost:8080")
|
||||
|
||||
;; Account for multi-account daemons
|
||||
(setq noise-account "+15551234567")
|
||||
```
|
||||
|
||||
### Manual Refresh
|
||||
|
||||
You can still manually fetch messages with:
|
||||
|
||||
```elisp
|
||||
M-x noise-receive
|
||||
```
|
||||
|
||||
This works even while SSE is running and is useful for initial setup or testing.
|
||||
|
||||
## Verification
|
||||
|
||||
### Test Results
|
||||
|
||||
All 66 tests pass:
|
||||
|
||||
```
|
||||
Ran 66 tests, 66 results as expected, 0 unexpected
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **SSE parsing**: 3 tests for event parsing (basic, multiline, empty)
|
||||
- **Connection state**: 3 tests for reconnection logic (initial state, backoff, max delay)
|
||||
- **Process filter**: 2 tests for stream parsing (complete events, incomplete buffering)
|
||||
- **Connection management**: 4 tests for URL construction and connect/disconnect
|
||||
- **Integration**: Tests verify SSE integration with existing envelope processing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
- Verified SSE connection establishment
|
||||
- Confirmed real-time message reception
|
||||
- Tested reconnection after connection loss
|
||||
- Validated UI updates on incoming messages
|
||||
|
||||
## Journey Log
|
||||
|
||||
- [pivot] Replaced polling with SSE for real-time message reception
|
||||
- [lesson] signal-cli supports SSE via `/api/v1/events` endpoint (confirmed from GitHub issues #2033, #2034, #1935)
|
||||
- [lesson] Emacs `make-network-process` uses `:service` keyword for port, not `:port`
|
||||
- [lesson] Test processes need careful mocking to avoid "Buffer has no process" errors
|
||||
|
||||
## Source Materials
|
||||
|
||||
| File | Role | Notes |
|
||||
|------|------|-------|
|
||||
| `docs/compose/plans/2026-06-11-sse-migration.md` | Implementation plan | Complete |
|
||||
| `noise-sse.el` | SSE client implementation | New file |
|
||||
| `noise-receive.el` | Message reception pipeline | Modified to use SSE |
|
||||
| `noise.el` | Entry point | Updated noise-mode |
|
||||
+43
-17
@@ -8,6 +8,8 @@
|
||||
;;; Code:
|
||||
|
||||
(require 'noise-db)
|
||||
(require 'noise-new-chat)
|
||||
(require 'noise-conversation)
|
||||
(require 'cl-lib)
|
||||
|
||||
(defvar noise-chat-list-mode-map
|
||||
@@ -16,6 +18,7 @@
|
||||
(define-key map (kbd "p") #'previous-line)
|
||||
(define-key map (kbd "RET") #'noise-chat-list-open)
|
||||
(define-key map (kbd "m") #'noise-chat-list-mark-read)
|
||||
(define-key map (kbd "c") #'noise-new-chat)
|
||||
(define-key map (kbd "g") #'noise-chat-list-refresh)
|
||||
(define-key map (kbd "/") #'noise-chat-list-filter)
|
||||
(define-key map (kbd "q") #'quit-window)
|
||||
@@ -29,9 +32,13 @@
|
||||
(setq buffer-read-only t)
|
||||
(setq-local header-line-format
|
||||
(concat " U "
|
||||
(format "%-20s" "Name")
|
||||
(format "%-40s" "Last message")
|
||||
"When")))
|
||||
(format "%-18s" "Name")
|
||||
(format "%-42s" "Last message")
|
||||
"When"))
|
||||
(when (bound-and-true-p evil-mode)
|
||||
(let ((m (make-sparse-keymap)))
|
||||
(define-key m (kbd "RET") #'noise-chat-list-open)
|
||||
(setq-local evil-normal-state-local-map m))))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-chat-list ()
|
||||
@@ -47,10 +54,21 @@
|
||||
(let ((inhibit-read-only t)
|
||||
(conversations (noise-db-get-all-conversations)))
|
||||
(erase-buffer)
|
||||
;; Clear old fringe overlays
|
||||
(remove-overlays (point-min) (point-max) 'noise-fringe t)
|
||||
(if (null conversations)
|
||||
(insert "No conversations. Sync from signal-cli with `g`.\n")
|
||||
(dolist (conv conversations)
|
||||
(insert (noise-chat-list--format-row conv) "\n")))
|
||||
(let ((unread (or (gethash "unread_count" conv) 0))
|
||||
(row (noise-chat-list--format-row conv)))
|
||||
(insert row "\n")
|
||||
(when (> unread 0)
|
||||
(let ((ov (make-overlay (line-beginning-position 0)
|
||||
(line-beginning-position 0))))
|
||||
(overlay-put ov 'noise-fringe t)
|
||||
(overlay-put ov 'before-string
|
||||
(propertize " " 'display
|
||||
'(left-fringe filled-rectangle warning))))))))
|
||||
(noise-chat-list--update-modeline conversations)
|
||||
(goto-char (point-min))
|
||||
(set-buffer-modified-p nil)))
|
||||
@@ -63,7 +81,7 @@
|
||||
conversations)
|
||||
:initial-value 0)))
|
||||
(setq mode-line-format
|
||||
(format " ⌁ %s %d chats %s %s (%s) ─ U:%%- ─ Top ───"
|
||||
(format " ⌁ %s %d chats %s %s (%s) ─ U:%%- ─ Top ────────────"
|
||||
(propertize "*signal*" 'face 'bold)
|
||||
total
|
||||
(if (> unread-total 0)
|
||||
@@ -90,13 +108,13 @@ Today: HH:MM, this week: Ddd, older: Mon DD."
|
||||
|
||||
(defun noise-chat-list--format-row (conv)
|
||||
"Format a single conversation row for display.
|
||||
CONV is a hash table with string keys from noise-db."
|
||||
CONV is a hash table with string keys from noise-db.
|
||||
The unread dot is rendered in the Emacs fringe, not inline."
|
||||
(let* ((unread (or (gethash "unread_count" conv) 0))
|
||||
(name (or (gethash "name" conv) "Unknown"))
|
||||
(preview (or (gethash "last_message_preview" conv) ""))
|
||||
(sender (or (gethash "last_message_sender" conv) ""))
|
||||
(timestamp (or (gethash "last_message_time" conv) 0))
|
||||
(fringe (if (> unread 0) "●" " "))
|
||||
(unread-str (if (> unread 0) (format "%3d" unread) " "))
|
||||
(name-str (truncate-string-to-width name 18 nil nil "…"))
|
||||
(preview-prefix
|
||||
@@ -104,28 +122,36 @@ CONV is a hash table with string keys from noise-db."
|
||||
((string= sender "") preview)
|
||||
((string= sender "me") (concat "me: " preview))
|
||||
(t (concat sender ": " preview))))
|
||||
(preview-str (truncate-string-to-width preview-prefix 36 nil nil "…"))
|
||||
(preview-str (truncate-string-to-width preview-prefix 42 nil nil "…"))
|
||||
(time-str (noise-chat-list--format-time timestamp)))
|
||||
(concat " " fringe " " unread-str " "
|
||||
(propertize (format "%-18s" name-str) 'face 'bold) " "
|
||||
preview-str " "
|
||||
(concat unread-str " "
|
||||
(propertize (format "%-18s" name-str) 'face 'bold)
|
||||
(propertize (format "%-42s" preview-str) 'face 'shadow)
|
||||
(propertize time-str 'face 'shadow))))
|
||||
|
||||
(defun noise-chat-list--conversation-at-point ()
|
||||
"Return the conversation on the current line, or nil."
|
||||
(let ((conversations (noise-db-get-all-conversations))
|
||||
(idx (1- (line-number-at-pos))))
|
||||
(nth idx conversations)))
|
||||
|
||||
(defun noise-chat-list-open ()
|
||||
"Open the conversation at point."
|
||||
(interactive)
|
||||
(let* ((conversations (noise-db-get-all-conversations))
|
||||
(idx (1- (line-number-at-pos)))
|
||||
(conv (nth idx conversations)))
|
||||
(let ((conv (noise-chat-list--conversation-at-point)))
|
||||
(if conv
|
||||
(message "Open conversation: %s (not yet implemented)"
|
||||
(gethash "name" conv))
|
||||
(noise-conversation-open (gethash "id" conv))
|
||||
(message "No conversation on this line."))))
|
||||
|
||||
(defun noise-chat-list-mark-read ()
|
||||
"Mark the conversation at point as read."
|
||||
(interactive)
|
||||
(message "Mark read: not yet connected to signal-cli"))
|
||||
(let ((conv (noise-chat-list--conversation-at-point)))
|
||||
(if conv
|
||||
(progn
|
||||
(noise-db-mark-conversation-read (gethash "id" conv))
|
||||
(noise-chat-list--render))
|
||||
(message "No conversation on this line."))))
|
||||
|
||||
(defun noise-chat-list-refresh ()
|
||||
"Refresh the chat list (re-render from database)."
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
;;; noise-conversation.el --- Conversation buffer for Noise -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Provides the *signal:NAME* conversation buffer: full message history
|
||||
;; with day separators and delivery receipts, plus an inline compose
|
||||
;; prompt at the bottom. RET sends, M-RET inserts a newline.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'noise-rpc)
|
||||
(require 'noise-db)
|
||||
(require 'cl-lib)
|
||||
|
||||
(declare-function noise-chat-list--render "noise-chat-list")
|
||||
|
||||
(defconst noise-conversation--width 71
|
||||
"Column width used for day separators and receipt alignment.")
|
||||
|
||||
(defvar-local noise-conversation--id nil
|
||||
"ID of the conversation shown in this buffer.")
|
||||
|
||||
(defvar-local noise-conversation--input-marker nil
|
||||
"Marker at the start of the compose input area.")
|
||||
|
||||
(defvar-local noise-conversation--typing nil
|
||||
"Display name of the contact currently typing, or nil.")
|
||||
|
||||
(defvar-local noise-conversation--typing-timer nil
|
||||
"Timer that clears a stale typing indicator.")
|
||||
|
||||
(defvar noise-conversation-mode-map
|
||||
(let ((map (make-sparse-keymap)))
|
||||
(define-key map (kbd "RET") #'noise-conversation-send)
|
||||
(define-key map (kbd "M-RET") #'noise-conversation-newline)
|
||||
(define-key map (kbd "C-c C-g") #'noise-conversation-refresh)
|
||||
map)
|
||||
"Keymap for `noise-conversation-mode'.")
|
||||
|
||||
(defconst noise-conversation--mode-header
|
||||
(concat (propertize "RET" 'face 'help-key-binding) " send "
|
||||
(propertize "M-RET" 'face 'help-key-binding) " newline "
|
||||
(propertize "C-c C-g" 'face 'help-key-binding) " refresh")
|
||||
"Header line showing key hints for `noise-conversation-mode'.")
|
||||
|
||||
(define-derived-mode noise-conversation-mode text-mode "Signal Chat"
|
||||
"Major mode for a Noise Signal conversation buffer.
|
||||
\\{noise-conversation-mode-map}"
|
||||
:group 'noise
|
||||
(setq-local header-line-format noise-conversation--mode-header)
|
||||
(when (bound-and-true-p evil-mode)
|
||||
(let ((m (make-sparse-keymap)))
|
||||
(define-key m (kbd "RET") #'noise-conversation-send)
|
||||
(setq-local evil-normal-state-local-map m))))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-conversation-open (conversation-id)
|
||||
"Open (or switch to) the buffer for CONVERSATION-ID."
|
||||
(noise-db-init)
|
||||
(let ((conv (noise-db-get-conversation conversation-id)))
|
||||
(unless conv
|
||||
(user-error "No such conversation: %s" conversation-id))
|
||||
(let* ((name (noise-conversation--display-name conv))
|
||||
(buf (get-buffer-create (format "*signal:%s*" name))))
|
||||
(switch-to-buffer buf)
|
||||
(unless (derived-mode-p 'noise-conversation-mode)
|
||||
(noise-conversation-mode))
|
||||
(setq noise-conversation--id conversation-id)
|
||||
(noise-db-mark-conversation-read conversation-id)
|
||||
(noise-conversation--render)
|
||||
(noise-conversation--refresh-chat-list)
|
||||
buf)))
|
||||
|
||||
(defun noise-conversation--display-name (conv)
|
||||
"Pick the buffer display name for CONV, falling back to its ID."
|
||||
(let ((name (gethash "name" conv)))
|
||||
(if (and name (not (string-empty-p name))) name (gethash "id" conv))))
|
||||
|
||||
(defun noise-conversation--buffer-for (conversation-id)
|
||||
"Return the live buffer showing CONVERSATION-ID, or nil."
|
||||
(cl-find-if (lambda (buf)
|
||||
(with-current-buffer buf
|
||||
(and (derived-mode-p 'noise-conversation-mode)
|
||||
(equal noise-conversation--id conversation-id))))
|
||||
(buffer-list)))
|
||||
|
||||
(defun noise-conversation-refresh-for (conversation-id)
|
||||
"Re-render the buffer for CONVERSATION-ID if one is open.
|
||||
Returns the buffer, or nil if none."
|
||||
(when-let ((buf (noise-conversation--buffer-for conversation-id)))
|
||||
(with-current-buffer buf
|
||||
(noise-conversation--render))
|
||||
buf))
|
||||
|
||||
(defun noise-conversation-refresh ()
|
||||
"Re-render the current conversation from the database."
|
||||
(interactive)
|
||||
(noise-conversation--render))
|
||||
|
||||
;;; Rendering
|
||||
|
||||
(defun noise-conversation--render ()
|
||||
"Render message history and the compose prompt.
|
||||
Preserves any input already typed at the prompt."
|
||||
(let ((input (noise-conversation--current-input))
|
||||
(inhibit-read-only t)
|
||||
(messages (noise-db-get-messages noise-conversation--id))
|
||||
(last-day nil))
|
||||
(erase-buffer)
|
||||
(dolist (msg messages)
|
||||
(let ((day (format-time-string
|
||||
"%a, %b %-e" (gethash "timestamp" msg))))
|
||||
(unless (equal day last-day)
|
||||
(insert (noise-conversation--day-separator day) "\n")
|
||||
(setq last-day day))
|
||||
(insert (noise-conversation--format-message msg) "\n")))
|
||||
(insert " \n"
|
||||
(propertize (make-string noise-conversation--width ?─)
|
||||
'face 'shadow)
|
||||
"\n"
|
||||
(propertize "> " 'face 'link))
|
||||
(add-text-properties (point-min) (point)
|
||||
'(read-only t
|
||||
front-sticky (read-only)
|
||||
rear-nonsticky (read-only)))
|
||||
(setq noise-conversation--input-marker (point-marker))
|
||||
(when input (insert input))
|
||||
(goto-char (point-max))
|
||||
(noise-conversation--update-modeline)
|
||||
(set-buffer-modified-p nil)))
|
||||
|
||||
(defun noise-conversation--current-input ()
|
||||
"Return the text typed at the compose prompt, or nil before first render."
|
||||
(when (and noise-conversation--input-marker
|
||||
(marker-position noise-conversation--input-marker))
|
||||
(buffer-substring-no-properties noise-conversation--input-marker
|
||||
(point-max))))
|
||||
|
||||
(defun noise-conversation--day-separator (day)
|
||||
"Format DAY as a centered separator line like ──── Tue, Jun 9 ────."
|
||||
(let* ((label (concat " " day " "))
|
||||
(total (max 2 (- noise-conversation--width (length label))))
|
||||
(left (/ total 2)))
|
||||
(propertize (concat (make-string left ?─) label
|
||||
(make-string (- total left) ?─))
|
||||
'face 'shadow)))
|
||||
|
||||
(defun noise-conversation--format-message (msg)
|
||||
"Format MSG (a hash table from noise-db) as a [HH:MM] sender> body line."
|
||||
(let* ((source (or (gethash "source" msg) ""))
|
||||
(me (string= source "me"))
|
||||
(body (or (gethash "body" msg) ""))
|
||||
(receipt (or (gethash "receipt_state" msg) ""))
|
||||
(time-str (format-time-string "[%H:%M]" (gethash "timestamp" msg)))
|
||||
(name (cond (me "me")
|
||||
((string-empty-p source) "?")
|
||||
(t source))))
|
||||
(when (eq (gethash "has_attachment" msg) 1)
|
||||
(let ((file (file-name-nondirectory
|
||||
(or (gethash "attachment_path" msg) ""))))
|
||||
(setq body (concat body (if (string-empty-p body) "" " ")
|
||||
(propertize
|
||||
(format "[photo: %s — RET to view]" file)
|
||||
'face 'link)))))
|
||||
(let ((line (concat (propertize time-str 'face 'shadow) " "
|
||||
(propertize name 'face 'bold)
|
||||
(propertize ">" 'face 'shadow) " "
|
||||
body)))
|
||||
(if (and me (not (string-empty-p receipt)))
|
||||
(let* ((check (if (string= receipt "sent") "✓" "✓✓"))
|
||||
(pad (max 1 (- noise-conversation--width
|
||||
(string-width line)
|
||||
(string-width check)))))
|
||||
(concat line (make-string pad ?\s)
|
||||
(propertize check 'face 'success)))
|
||||
line))))
|
||||
|
||||
(defun noise-conversation--update-modeline ()
|
||||
"Update the mode line with the E2E indicator and typing status."
|
||||
(setq mode-line-format
|
||||
(format " %s %s %s%s ─ U:%%- ─ Bot ───────────"
|
||||
(propertize "⌁ E2E" 'face 'success)
|
||||
(propertize (buffer-name) 'face 'bold)
|
||||
(if noise-conversation--typing
|
||||
(format "%s is typing… " noise-conversation--typing)
|
||||
"")
|
||||
"(Signal Chat Fill)"))
|
||||
(force-mode-line-update))
|
||||
|
||||
;;; Composing and sending
|
||||
|
||||
(defun noise-conversation-newline ()
|
||||
"Insert a newline at the compose prompt without sending."
|
||||
(interactive)
|
||||
(insert "\n"))
|
||||
|
||||
(defun noise-conversation-send ()
|
||||
"Send the text composed at the prompt via signal-cli."
|
||||
(interactive)
|
||||
(let ((input (string-trim (or (noise-conversation--current-input) ""))))
|
||||
(if (string-empty-p input)
|
||||
(message "Nothing to send")
|
||||
(let* ((conv (noise-db-get-conversation noise-conversation--id))
|
||||
(group (string= (gethash "type" conv) "group"))
|
||||
(result (if group
|
||||
(noise-rpc-call-for-account
|
||||
"send" :groupId noise-conversation--id :message input)
|
||||
(noise-rpc-call-for-account
|
||||
"send" :recipient (vector noise-conversation--id)
|
||||
:message input)))
|
||||
(ts-ms (and (hash-table-p result) (gethash "timestamp" result)))
|
||||
(ts (if ts-ms (/ ts-ms 1000.0) (float-time))))
|
||||
(noise-db-insert-message noise-conversation--id
|
||||
:timestamp ts
|
||||
:source "me"
|
||||
:body input
|
||||
:receipt-state "sent")
|
||||
(noise-conversation--clear-input)
|
||||
(noise-conversation--render)
|
||||
(noise-conversation--refresh-chat-list)))))
|
||||
|
||||
(defun noise-conversation--clear-input ()
|
||||
"Delete the text typed at the compose prompt."
|
||||
(when (and noise-conversation--input-marker
|
||||
(marker-position noise-conversation--input-marker))
|
||||
(delete-region noise-conversation--input-marker (point-max))))
|
||||
|
||||
;;; Typing indicator
|
||||
|
||||
(defun noise-conversation-set-typing (conversation-id name action)
|
||||
"Show or clear the typing indicator for CONVERSATION-ID.
|
||||
NAME is the contact typing; ACTION is signal-cli's STARTED or STOPPED."
|
||||
(when-let ((buf (noise-conversation--buffer-for conversation-id)))
|
||||
(with-current-buffer buf
|
||||
(when noise-conversation--typing-timer
|
||||
(cancel-timer noise-conversation--typing-timer)
|
||||
(setq noise-conversation--typing-timer nil))
|
||||
(if (equal action "STARTED")
|
||||
(setq noise-conversation--typing name
|
||||
noise-conversation--typing-timer
|
||||
(run-with-timer 8 nil #'noise-conversation--clear-typing buf))
|
||||
(setq noise-conversation--typing nil))
|
||||
(noise-conversation--update-modeline))))
|
||||
|
||||
(defun noise-conversation--clear-typing (buf)
|
||||
"Clear the typing indicator in BUF after a timeout."
|
||||
(when (buffer-live-p buf)
|
||||
(with-current-buffer buf
|
||||
(setq noise-conversation--typing nil
|
||||
noise-conversation--typing-timer nil)
|
||||
(noise-conversation--update-modeline))))
|
||||
|
||||
(defun noise-conversation--refresh-chat-list ()
|
||||
"Re-render the *signal* chat list buffer if it exists."
|
||||
(when-let ((buf (get-buffer "*signal*")))
|
||||
(with-current-buffer buf
|
||||
(noise-chat-list--render))))
|
||||
|
||||
;;; Auto-refresh when conversation buffers become visible
|
||||
|
||||
(defun noise-conversation--on-buffer-visible (_frame)
|
||||
"Refresh any stale `*signal:*' conversation buffer that just became visible."
|
||||
(dolist (window (window-list nil 'never (selected-frame)))
|
||||
(when-let ((buf (window-buffer window)))
|
||||
(when (and (string-prefix-p "*signal:" (buffer-name buf))
|
||||
(not (string= "*signal*" (buffer-name buf))))
|
||||
(with-current-buffer buf
|
||||
(when (derived-mode-p 'noise-conversation-mode)
|
||||
(noise-conversation--render)))))))
|
||||
|
||||
(add-hook 'window-buffer-change-functions #'noise-conversation--on-buffer-visible)
|
||||
|
||||
(provide 'noise-conversation)
|
||||
;;; noise-conversation.el ends here
|
||||
+28
@@ -249,6 +249,34 @@ Keyword arguments: :limit N, :offset N."
|
||||
(setq sql-params (nconc sql-params (list offset))))
|
||||
(apply #'noise-db--query sql-str sql-params)))
|
||||
|
||||
(defun noise-db-mark-conversation-read (id)
|
||||
"Set the unread count of conversation ID to zero."
|
||||
(noise-db--exec "UPDATE conversations SET unread_count = 0 WHERE id = ?" id))
|
||||
|
||||
(defun noise-db-increment-unread (id)
|
||||
"Increment the unread count of conversation ID by one."
|
||||
(noise-db--exec
|
||||
"UPDATE conversations SET unread_count = unread_count + 1 WHERE id = ?" id))
|
||||
|
||||
(defun noise-db-set-last-message-sender (id sender)
|
||||
"Set the last-message SENDER shown in previews for conversation ID."
|
||||
(noise-db--exec
|
||||
"UPDATE conversations SET last_message_sender = ? WHERE id = ?" sender id))
|
||||
|
||||
(defun noise-db-update-receipt-state (timestamp state)
|
||||
"Upgrade the receipt state of own messages with TIMESTAMP to STATE.
|
||||
Only moves forward (sent < delivered < read), never downgrades."
|
||||
(noise-db--exec
|
||||
"UPDATE messages SET receipt_state = ?
|
||||
WHERE source = 'me' AND timestamp = ?
|
||||
AND (CASE receipt_state
|
||||
WHEN 'read' THEN 3 WHEN 'delivered' THEN 2 WHEN 'sent' THEN 1
|
||||
ELSE 0 END)
|
||||
< (CASE ?
|
||||
WHEN 'read' THEN 3 WHEN 'delivered' THEN 2 WHEN 'sent' THEN 1
|
||||
ELSE 0 END)"
|
||||
state timestamp state))
|
||||
|
||||
(defun noise-db-get-conversation-members (conversation-id)
|
||||
"Get member contact IDs for CONVERSATION-ID."
|
||||
(noise-db--query
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
;;; noise-new-chat.el --- Start new conversations from contacts -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Provides `noise-new-chat' — start a new conversation by searching
|
||||
;; contacts with minibuffer completion. Contacts are synced from
|
||||
;; signal-cli via the listContacts JSON-RPC method and cached in the
|
||||
;; local SQLite database.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'noise-rpc)
|
||||
(require 'noise-db)
|
||||
(require 'noise-conversation)
|
||||
(require 'cl-lib)
|
||||
|
||||
(declare-function noise-chat-list--render "noise-chat-list")
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-new-chat ()
|
||||
"Start a new conversation by searching contacts.
|
||||
Syncs contacts from signal-cli when the local database has none.
|
||||
Candidates match on both name and phone number."
|
||||
(interactive)
|
||||
(noise-db-init)
|
||||
(unless (noise-db-get-all-contacts)
|
||||
(noise-new-chat-sync-contacts))
|
||||
(let* ((contacts (noise-db-get-all-contacts))
|
||||
(collection (mapcar (lambda (c)
|
||||
(cons (noise-new-chat--candidate c) c))
|
||||
contacts)))
|
||||
(unless collection
|
||||
(user-error "No contacts found. Is signal-cli synced?"))
|
||||
(let* ((choice (completing-read "New chat with: " collection nil t))
|
||||
(contact (cdr (assoc choice collection))))
|
||||
(when contact
|
||||
(noise-new-chat-with-contact (gethash "id" contact)
|
||||
(gethash "name" contact))))))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-new-chat-sync-contacts ()
|
||||
"Fetch contacts from signal-cli and store them in the local database.
|
||||
Returns the number of contacts synced."
|
||||
(interactive)
|
||||
(noise-db-init)
|
||||
(let ((contacts (noise-rpc-call-for-account "listContacts"))
|
||||
(count 0))
|
||||
(seq-do
|
||||
(lambda (c)
|
||||
(when-let ((id (noise-new-chat--contact-id c)))
|
||||
(noise-db-upsert-contact
|
||||
id
|
||||
:name (noise-new-chat--contact-name c)
|
||||
:color (noise-new-chat--field c "color")
|
||||
:profile-name (noise-new-chat--profile-name c))
|
||||
(cl-incf count)))
|
||||
contacts)
|
||||
(when (called-interactively-p 'interactive)
|
||||
(message "Synced %d contacts from signal-cli" count))
|
||||
count))
|
||||
|
||||
(defun noise-new-chat-with-contact (contact-id name)
|
||||
"Open (creating if needed) a direct conversation with CONTACT-ID.
|
||||
NAME is used as the conversation name for a new conversation.
|
||||
The conversation ID for a direct chat is the contact ID itself."
|
||||
(unless (noise-db-get-conversation contact-id)
|
||||
(noise-db-upsert-conversation contact-id
|
||||
:type "direct"
|
||||
:name (if (and name (not (string-empty-p name)))
|
||||
name
|
||||
contact-id)
|
||||
:members (list contact-id)))
|
||||
(when-let ((buf (get-buffer "*signal*")))
|
||||
(with-current-buffer buf
|
||||
(noise-chat-list--render)))
|
||||
(noise-conversation-open contact-id))
|
||||
|
||||
(defun noise-new-chat--candidate (contact)
|
||||
"Format CONTACT (a hash table from noise-db) as a completion candidate.
|
||||
Includes the phone number so completion can match on it."
|
||||
(let ((id (gethash "id" contact))
|
||||
(name (gethash "name" contact)))
|
||||
(if (or (null name) (string-empty-p name) (string= name id))
|
||||
id
|
||||
(format "%s (%s)" name id))))
|
||||
|
||||
(defun noise-new-chat--field (obj key)
|
||||
"Get KEY from hash table OBJ, treating JSON null as nil."
|
||||
(when obj
|
||||
(let ((v (gethash key obj)))
|
||||
(unless (eq v :null) v))))
|
||||
|
||||
(defun noise-new-chat--contact-id (contact)
|
||||
"Extract the contact ID from a signal-cli CONTACT object.
|
||||
Prefers the phone number, falling back to the UUID."
|
||||
(or (noise-new-chat--field contact "number")
|
||||
(noise-new-chat--field contact "uuid")))
|
||||
|
||||
(defun noise-new-chat--profile-name (contact)
|
||||
"Extract the profile display name from a signal-cli CONTACT object."
|
||||
(when-let ((profile (noise-new-chat--field contact "profile")))
|
||||
(let ((full (string-trim
|
||||
(concat (or (noise-new-chat--field profile "givenName") "")
|
||||
" "
|
||||
(or (noise-new-chat--field profile "familyName") "")))))
|
||||
(unless (string-empty-p full) full))))
|
||||
|
||||
(defun noise-new-chat--contact-name (contact)
|
||||
"Pick the best display name for a signal-cli CONTACT object.
|
||||
Prefers the local contact name, then the profile name, then the
|
||||
username, then the phone number."
|
||||
(or (let ((name (noise-new-chat--field contact "name")))
|
||||
(and name (not (string-empty-p name)) name))
|
||||
(noise-new-chat--profile-name contact)
|
||||
(noise-new-chat--field contact "username")
|
||||
(noise-new-chat--contact-id contact)))
|
||||
|
||||
(provide 'noise-new-chat)
|
||||
;;; noise-new-chat.el ends here
|
||||
@@ -0,0 +1,217 @@
|
||||
;;; noise-receive.el --- Receive messages from signal-cli -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Fetches new envelopes from signal-cli via SSE or JSON-RPC
|
||||
;; and stores them in the local database: incoming messages, messages
|
||||
;; sent from other linked devices (sync), delivery/read receipts, and
|
||||
;; typing indicators. `noise-receive-start-sse' connects to the SSE
|
||||
;; endpoint for real-time message reception.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'noise-rpc)
|
||||
(require 'noise-db)
|
||||
(require 'noise-conversation)
|
||||
(require 'noise-sse)
|
||||
(require 'cl-lib)
|
||||
|
||||
(declare-function noise-chat-list--render "noise-chat-list")
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-receive ()
|
||||
"Fetch and store new envelopes from signal-cli.
|
||||
Returns the number of new messages stored."
|
||||
(interactive)
|
||||
(noise-db-init)
|
||||
(let ((entries (noise-rpc-call-for-account "receive"))
|
||||
(new 0)
|
||||
(touched nil))
|
||||
(seq-do
|
||||
(lambda (entry)
|
||||
(when-let ((envelope (noise-receive--field entry "envelope")))
|
||||
(when-let ((conv-id (noise-receive--process-envelope envelope)))
|
||||
(cl-incf new)
|
||||
(cl-pushnew conv-id touched :test #'equal))))
|
||||
entries)
|
||||
(when touched
|
||||
(dolist (conv-id touched)
|
||||
(noise-conversation-refresh-for conv-id))
|
||||
(when-let ((buf (get-buffer "*signal*")))
|
||||
(with-current-buffer buf
|
||||
(noise-chat-list--render))))
|
||||
(when (called-interactively-p 'interactive)
|
||||
(message "Received %d new message%s" new (if (= new 1) "" "s")))
|
||||
new))
|
||||
|
||||
(defun noise-receive--announce-message (conv-id)
|
||||
"Show a brief echo-area notification for a new message in CONV-ID."
|
||||
(let* ((conv (noise-db-get-conversation conv-id))
|
||||
(name (if conv (or (gethash "name" conv) conv-id) conv-id)))
|
||||
(message "[Noise] %s" name)))
|
||||
|
||||
(defun noise-receive--handle-sse-event (event)
|
||||
"Handle an SSE EVENT from signal-cli.
|
||||
Processes the envelope and updates UI."
|
||||
(noise-db-init)
|
||||
(when-let ((envelope (noise-receive--field event "envelope")))
|
||||
(when-let ((conv-id (noise-receive--process-envelope envelope)))
|
||||
(let ((buf (noise-conversation--buffer-for conv-id)))
|
||||
(if (and buf (get-buffer-window buf t))
|
||||
(noise-conversation-refresh-for conv-id)
|
||||
(noise-receive--announce-message conv-id)))
|
||||
(when-let ((buf (get-buffer "*signal*")))
|
||||
(with-current-buffer buf
|
||||
(noise-chat-list--render))))))
|
||||
|
||||
;; Register the receive handler with the SSE client.
|
||||
(setq noise-sse--event-handler #'noise-receive--handle-sse-event)
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-receive-start-sse ()
|
||||
"Start receiving messages via SSE."
|
||||
(interactive)
|
||||
(noise-sse-connect))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-receive-stop-sse ()
|
||||
"Stop receiving messages via SSE."
|
||||
(interactive)
|
||||
(noise-sse-disconnect))
|
||||
|
||||
;;; Envelope processing
|
||||
|
||||
(defun noise-receive--field (obj key)
|
||||
"Get KEY from hash table OBJ, treating JSON null as nil."
|
||||
(when (hash-table-p obj)
|
||||
(let ((v (gethash key obj)))
|
||||
(unless (eq v :null) v))))
|
||||
|
||||
(defun noise-receive--process-envelope (envelope)
|
||||
"Process one ENVELOPE from signal-cli.
|
||||
Returns the conversation ID when a new message was stored, else nil."
|
||||
(let* ((sender-id (or (noise-receive--field envelope "sourceNumber")
|
||||
(noise-receive--field envelope "source")
|
||||
(noise-receive--field envelope "sourceUuid")))
|
||||
(sender-name (or (noise-receive--field envelope "sourceName")
|
||||
sender-id)))
|
||||
(cond
|
||||
((noise-receive--field envelope "dataMessage")
|
||||
(noise-receive--store-incoming
|
||||
(noise-receive--field envelope "dataMessage") sender-id sender-name))
|
||||
((noise-receive--field envelope "syncMessage")
|
||||
(noise-receive--store-sync
|
||||
(noise-receive--field envelope "syncMessage")))
|
||||
((noise-receive--field envelope "receiptMessage")
|
||||
(noise-receive--apply-receipt
|
||||
(noise-receive--field envelope "receiptMessage") sender-id)
|
||||
nil)
|
||||
((noise-receive--field envelope "typingMessage")
|
||||
(noise-receive--apply-typing
|
||||
(noise-receive--field envelope "typingMessage") sender-id sender-name)
|
||||
nil))))
|
||||
|
||||
(defun noise-receive--timestamp (ms)
|
||||
"Convert a signal-cli millisecond timestamp MS to float seconds."
|
||||
(/ ms 1000.0))
|
||||
|
||||
(defun noise-receive--attachment-name (data-message)
|
||||
"Return the filename of the first attachment in DATA-MESSAGE, or nil."
|
||||
(let ((attachments (noise-receive--field data-message "attachments")))
|
||||
(when (and attachments (> (length attachments) 0))
|
||||
(let ((a (elt attachments 0)))
|
||||
(or (noise-receive--field a "filename")
|
||||
(noise-receive--field a "id"))))))
|
||||
|
||||
(defun noise-receive--store-incoming (dm sender-id sender-name)
|
||||
"Store an incoming data message DM from SENDER-ID / SENDER-NAME.
|
||||
Returns the conversation ID, or nil if there was nothing to store."
|
||||
(let* ((body (or (noise-receive--field dm "message") ""))
|
||||
(group (noise-receive--field dm "groupInfo"))
|
||||
(group-id (and group (noise-receive--field group "groupId")))
|
||||
(attachment (noise-receive--attachment-name dm))
|
||||
(conv-id (or group-id sender-id))
|
||||
(ts (noise-receive--timestamp
|
||||
(noise-receive--field dm "timestamp"))))
|
||||
(when (and conv-id (or (not (string-empty-p body)) attachment))
|
||||
(when sender-id
|
||||
(noise-db-upsert-contact sender-id :name sender-name))
|
||||
(unless (noise-db-get-conversation conv-id)
|
||||
(noise-db-upsert-conversation
|
||||
conv-id
|
||||
:type (if group-id "group" "direct")
|
||||
:name (if group-id
|
||||
(or (noise-receive--field group "groupName") "")
|
||||
sender-name)
|
||||
:members (and sender-id (list sender-id))))
|
||||
(noise-db-insert-message conv-id
|
||||
:timestamp ts
|
||||
:source sender-name
|
||||
:body body
|
||||
:has-attachment (and attachment t)
|
||||
:attachment-path (or attachment ""))
|
||||
;; Direct chats show the preview without a sender prefix (the chat
|
||||
;; list only prefixes group senders and "me").
|
||||
(unless group-id
|
||||
(noise-db-set-last-message-sender conv-id ""))
|
||||
(when (and attachment (string-empty-p body))
|
||||
(noise-db-upsert-conversation
|
||||
conv-id :last-message-preview (format "photo: %s" attachment)))
|
||||
(let ((buf (noise-conversation--buffer-for conv-id)))
|
||||
(if (and buf (get-buffer-window buf t))
|
||||
(noise-db-mark-conversation-read conv-id)
|
||||
(noise-db-increment-unread conv-id)))
|
||||
conv-id)))
|
||||
|
||||
(defun noise-receive--store-sync (sync)
|
||||
"Store a message sent from another linked device, found in SYNC.
|
||||
Returns the conversation ID, or nil."
|
||||
(when-let ((sent (noise-receive--field sync "sentMessage")))
|
||||
(let* ((body (or (noise-receive--field sent "message") ""))
|
||||
(group (noise-receive--field sent "groupInfo"))
|
||||
(group-id (and group (noise-receive--field group "groupId")))
|
||||
(dest (or (noise-receive--field sent "destinationNumber")
|
||||
(noise-receive--field sent "destination")
|
||||
(noise-receive--field sent "destinationUuid")))
|
||||
(conv-id (or group-id dest))
|
||||
(attachment (noise-receive--attachment-name sent))
|
||||
(ts (noise-receive--timestamp
|
||||
(noise-receive--field sent "timestamp"))))
|
||||
(when (and conv-id (or (not (string-empty-p body)) attachment))
|
||||
(unless (noise-db-get-conversation conv-id)
|
||||
(noise-db-upsert-conversation
|
||||
conv-id
|
||||
:type (if group-id "group" "direct")
|
||||
:name (if group-id "" (or dest ""))
|
||||
:members (and dest (not group-id) (list dest))))
|
||||
(noise-db-insert-message conv-id
|
||||
:timestamp ts
|
||||
:source "me"
|
||||
:body body
|
||||
:receipt-state "sent"
|
||||
:has-attachment (and attachment t)
|
||||
:attachment-path (or attachment ""))
|
||||
conv-id))))
|
||||
|
||||
(defun noise-receive--apply-receipt (rm sender-id)
|
||||
"Apply receipt message RM from SENDER-ID to stored messages."
|
||||
(let ((state (cond ((eq (noise-receive--field rm "isRead") t) "read")
|
||||
((eq (noise-receive--field rm "isDelivery") t) "delivered")))
|
||||
(timestamps (noise-receive--field rm "timestamps")))
|
||||
(when (and state timestamps)
|
||||
(seq-do (lambda (ts-ms)
|
||||
(noise-db-update-receipt-state
|
||||
(noise-receive--timestamp ts-ms) state))
|
||||
timestamps)
|
||||
(when sender-id
|
||||
(noise-conversation-refresh-for sender-id)))))
|
||||
|
||||
(defun noise-receive--apply-typing (tm sender-id sender-name)
|
||||
"Apply typing message TM from SENDER-ID / SENDER-NAME."
|
||||
(let ((conv-id (or (noise-receive--field tm "groupId") sender-id))
|
||||
(action (noise-receive--field tm "action")))
|
||||
(when conv-id
|
||||
(noise-conversation-set-typing conv-id sender-name action))))
|
||||
|
||||
(provide 'noise-receive)
|
||||
;;; noise-receive.el ends here
|
||||
@@ -11,6 +11,7 @@
|
||||
(require 'cl-lib)
|
||||
|
||||
(defvar noise-signal-cli-address)
|
||||
(defvar noise-account)
|
||||
|
||||
(define-error 'noise-rpc-error "signal-cli JSON-RPC error")
|
||||
|
||||
@@ -67,6 +68,16 @@ Signals `noise-rpc-error` on failure or connection error."
|
||||
result))
|
||||
(kill-buffer buf)))))
|
||||
|
||||
(defun noise-rpc-call-for-account (method &rest params)
|
||||
"Call signal-cli JSON-RPC METHOD with PARAMS for the configured account.
|
||||
Like `noise-rpc-call', but when `noise-account' is set the `account'
|
||||
parameter is added to the request. Account-scoped methods (such as
|
||||
listContacts or send) require it when the daemon serves multiple
|
||||
accounts."
|
||||
(if (and (boundp 'noise-account) noise-account)
|
||||
(apply #'noise-rpc-call method :account noise-account params)
|
||||
(apply #'noise-rpc-call method params)))
|
||||
|
||||
(defun noise-rpc--parse-response (raw)
|
||||
"Parse RAW JSON-RPC response body.
|
||||
Returns the `result` on success.
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
;;; noise-sse.el --- SSE client for signal-cli -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Implements Server-Sent Events client for receiving real-time messages
|
||||
;; from signal-cli daemon's /api/v1/events endpoint.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'url)
|
||||
(require 'json)
|
||||
(require 'cl-lib)
|
||||
|
||||
(defvar noise-sse--process nil
|
||||
"Active SSE network process, or nil.")
|
||||
|
||||
(defvar noise-sse--reconnect-timer nil
|
||||
"Timer for reconnection attempts, or nil.")
|
||||
|
||||
(defvar noise-sse--reconnect-delay 1
|
||||
"Current delay in seconds before next reconnection attempt.")
|
||||
|
||||
(defcustom noise-sse-max-reconnect-delay 60
|
||||
"Maximum delay in seconds between reconnection attempts."
|
||||
:type 'integer
|
||||
:group 'noise)
|
||||
|
||||
(defun noise-sse--increase-reconnect-delay ()
|
||||
"Increase reconnect delay with exponential backoff, capped at max."
|
||||
(setq noise-sse--reconnect-delay
|
||||
(min (* noise-sse--reconnect-delay 2)
|
||||
noise-sse-max-reconnect-delay)))
|
||||
|
||||
(defun noise-sse--reset-reconnect-delay ()
|
||||
"Reset reconnect delay to initial value after successful connection."
|
||||
(setq noise-sse--reconnect-delay 1))
|
||||
|
||||
(defun noise-sse--parse-event (event-string)
|
||||
"Parse an SSE EVENT-STRING into a hash table.
|
||||
Returns nil for empty or invalid events."
|
||||
(when (and event-string (not (string-empty-p event-string)))
|
||||
(let ((lines (split-string event-string "\r?\n"))
|
||||
(data-lines '()))
|
||||
(dolist (line lines)
|
||||
(when (string-match "\\`data:\\(?: \\)?\\(.*\\)\\'" line)
|
||||
(push (match-string 1 line) data-lines)))
|
||||
(when data-lines
|
||||
(let ((json-str (mapconcat #'identity (nreverse data-lines) "\n")))
|
||||
(condition-case nil
|
||||
(json-parse-string json-str)
|
||||
(error nil)))))))
|
||||
|
||||
(defvar noise-sse--buffer ""
|
||||
"Decoded SSE event data awaiting a complete event delimiter.")
|
||||
|
||||
(defvar noise-sse--http-buffer ""
|
||||
"Raw HTTP response data buffered until headers are consumed.")
|
||||
|
||||
(defvar noise-sse--chunk-buffer ""
|
||||
"Raw chunked HTTP body data buffered until a whole chunk arrives.")
|
||||
|
||||
(defvar noise-sse--headers-received nil
|
||||
"Non-nil once the HTTP response headers have been consumed.")
|
||||
|
||||
(defvar noise-sse--chunked-response-p nil
|
||||
"Non-nil when the SSE response uses chunked transfer encoding.")
|
||||
|
||||
(defun noise-sse--dispatch-events (string)
|
||||
"Append STRING to the SSE event buffer and dispatch complete events."
|
||||
(setq noise-sse--buffer (concat noise-sse--buffer string))
|
||||
(while (string-match "\r?\n\r?\n" noise-sse--buffer)
|
||||
(let ((event-end (match-beginning 0))
|
||||
(event-next (match-end 0)))
|
||||
(let ((event-str (substring noise-sse--buffer 0 event-end)))
|
||||
(setq noise-sse--buffer (substring noise-sse--buffer event-next))
|
||||
(when-let ((event (noise-sse--parse-event event-str)))
|
||||
(funcall noise-sse--event-handler event))))))
|
||||
|
||||
(defun noise-sse--decode-chunked (string)
|
||||
"Decode HTTP chunked transfer body STRING.
|
||||
Returns newly decoded SSE payload, buffering incomplete chunks."
|
||||
(setq noise-sse--chunk-buffer (concat noise-sse--chunk-buffer string))
|
||||
(let ((decoded '()))
|
||||
(catch 'stop
|
||||
(while t
|
||||
(unless (string-match "\r?\n" noise-sse--chunk-buffer)
|
||||
(throw 'stop nil))
|
||||
(let* ((line-end (match-beginning 0))
|
||||
(line-next (match-end 0))
|
||||
(size-line (car (split-string
|
||||
(substring noise-sse--chunk-buffer 0 line-end)
|
||||
";" t "[ \t]*"))))
|
||||
(unless (and size-line
|
||||
(string-match-p "\\`[0-9A-Fa-f]+\\'" size-line))
|
||||
(throw 'stop nil))
|
||||
(let* ((size (string-to-number size-line 16))
|
||||
(data-start line-next)
|
||||
(data-end (+ data-start size)))
|
||||
(when (= size 0)
|
||||
(setq noise-sse--chunk-buffer "")
|
||||
(throw 'stop nil))
|
||||
(when (> data-end (length noise-sse--chunk-buffer))
|
||||
(throw 'stop nil))
|
||||
(let ((tail-end
|
||||
(cond
|
||||
((and (<= (+ data-end 2) (length noise-sse--chunk-buffer))
|
||||
(string= "\r\n"
|
||||
(substring noise-sse--chunk-buffer data-end (+ data-end 2))))
|
||||
(+ data-end 2))
|
||||
((and (< data-end (length noise-sse--chunk-buffer))
|
||||
(eq (aref noise-sse--chunk-buffer data-end) ?\n))
|
||||
(1+ data-end))
|
||||
(t
|
||||
(throw 'stop nil)))))
|
||||
(push (substring noise-sse--chunk-buffer data-start data-end) decoded)
|
||||
(setq noise-sse--chunk-buffer
|
||||
(substring noise-sse--chunk-buffer tail-end)))))))
|
||||
(apply #'concat (nreverse decoded))))
|
||||
|
||||
(defun noise-sse--consume-http-body (string)
|
||||
"Consume raw HTTP response STRING and return decoded SSE payload, if any."
|
||||
(let ((body string))
|
||||
(unless noise-sse--headers-received
|
||||
(setq noise-sse--http-buffer (concat noise-sse--http-buffer string))
|
||||
(if (not (string-match "\r?\n\r?\n" noise-sse--http-buffer))
|
||||
(setq body nil)
|
||||
(let ((headers (substring noise-sse--http-buffer 0 (match-beginning 0)))
|
||||
(body-start (match-end 0)))
|
||||
(let ((case-fold-search t))
|
||||
(setq noise-sse--chunked-response-p
|
||||
(string-match-p "\ntransfer-encoding:[^\r\n]*chunked" headers)))
|
||||
(setq body (substring noise-sse--http-buffer body-start)
|
||||
noise-sse--http-buffer ""
|
||||
noise-sse--headers-received t))))
|
||||
(when body
|
||||
(if noise-sse--chunked-response-p
|
||||
(noise-sse--decode-chunked body)
|
||||
body))))
|
||||
|
||||
(defun noise-sse--filter (proc string)
|
||||
"Process filter for SSE connection.
|
||||
Consumes HTTP framing from STRING and dispatches decoded SSE events."
|
||||
(ignore proc)
|
||||
(when-let ((body (noise-sse--consume-http-body string)))
|
||||
(noise-sse--dispatch-events body)))
|
||||
|
||||
(defun noise-sse--default-handle-event (event)
|
||||
"Handle a parsed SSE EVENT when no receiver is installed yet."
|
||||
(message "SSE event received: %s" event))
|
||||
|
||||
(defvar noise-sse--event-handler #'noise-sse--default-handle-event
|
||||
"Function called with each parsed SSE event.")
|
||||
|
||||
(defvar noise-signal-cli-address)
|
||||
(defvar noise-account)
|
||||
|
||||
(defun noise-sse--build-url ()
|
||||
"Build the SSE endpoint URL for signal-cli."
|
||||
(let ((base (concat noise-signal-cli-address "/api/v1/events")))
|
||||
(if (and (boundp 'noise-account) noise-account)
|
||||
(concat base "?account=" (url-hexify-string noise-account))
|
||||
base)))
|
||||
|
||||
(defun noise-sse--sentinel (proc status)
|
||||
"Process sentinel for SSE connection.
|
||||
Handles connection close and triggers reconnection."
|
||||
(when (or (equal status "finished\n")
|
||||
(string-match-p "failed" status)
|
||||
(string-match-p "deleted" status))
|
||||
(message "Noise: SSE connection lost, reconnecting in %ds..."
|
||||
noise-sse--reconnect-delay)
|
||||
(setq noise-sse--process nil)
|
||||
(noise-sse--schedule-reconnect)))
|
||||
|
||||
(defun noise-sse--schedule-reconnect ()
|
||||
"Schedule a reconnection attempt after the current delay."
|
||||
(when noise-sse--reconnect-timer
|
||||
(cancel-timer noise-sse--reconnect-timer))
|
||||
(setq noise-sse--reconnect-timer
|
||||
(run-with-timer noise-sse--reconnect-delay nil
|
||||
#'noise-sse-connect)))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-sse-connect ()
|
||||
"Connect to signal-cli SSE endpoint."
|
||||
(interactive)
|
||||
(noise-sse-disconnect)
|
||||
(let* ((url (noise-sse--build-url))
|
||||
(parsed (url-generic-parse-url url))
|
||||
(host (url-host parsed))
|
||||
(port (or (url-port parsed) 80))
|
||||
(proc (make-network-process
|
||||
:name "noise-sse"
|
||||
:host host
|
||||
:service port
|
||||
:filter #'noise-sse--filter
|
||||
:sentinel #'noise-sse--sentinel)))
|
||||
(setq noise-sse--process proc)
|
||||
(noise-sse--reset-reconnect-delay)
|
||||
(process-send-string proc
|
||||
(format "GET %s HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n"
|
||||
(url-filename parsed)
|
||||
host))
|
||||
(message "Noise: SSE connected to %s" url)))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-sse-disconnect ()
|
||||
"Disconnect from signal-cli SSE endpoint."
|
||||
(interactive)
|
||||
(when noise-sse--process
|
||||
(delete-process noise-sse--process)
|
||||
(setq noise-sse--process nil))
|
||||
(when noise-sse--reconnect-timer
|
||||
(cancel-timer noise-sse--reconnect-timer)
|
||||
(setq noise-sse--reconnect-timer nil))
|
||||
(setq noise-sse--buffer ""
|
||||
noise-sse--http-buffer ""
|
||||
noise-sse--chunk-buffer ""
|
||||
noise-sse--headers-received nil
|
||||
noise-sse--chunked-response-p nil)
|
||||
(message "Noise: SSE disconnected"))
|
||||
|
||||
(provide 'noise-sse)
|
||||
;;; noise-sse.el ends here
|
||||
+7
-3
@@ -9,6 +9,8 @@
|
||||
;;; Code:
|
||||
|
||||
(require 'noise-db)
|
||||
(require 'noise-new-chat)
|
||||
(require 'noise-conversation)
|
||||
|
||||
(defun noise-switcher ()
|
||||
"Switch to a Signal conversation via minibuffer completion.
|
||||
@@ -30,9 +32,10 @@ Shows existing conversations and contacts without conversations."
|
||||
(if item
|
||||
(if (gethash "id" item)
|
||||
;; It's a conversation (has an id)
|
||||
(message "Open conversation: %s (not yet implemented)" (gethash "name" item))
|
||||
;; It's just a contact — create conversation stub
|
||||
(message "New conversation with: %s (not yet implemented)" choice))
|
||||
(noise-conversation-open (gethash "id" item))
|
||||
;; It's a contact without a conversation — start one
|
||||
(noise-new-chat-with-contact (gethash "contact_id" item)
|
||||
(gethash "name" item)))
|
||||
(message "Unknown selection: %s" choice))))))
|
||||
|
||||
(defun noise-switcher--collection ()
|
||||
@@ -57,6 +60,7 @@ Returns an alist of (display-string . hash-table)."
|
||||
(unless (gethash contact-id conv-contacts)
|
||||
;; Contact without conversation - add a stub
|
||||
(let ((ht (make-hash-table :test 'equal)))
|
||||
(puthash "contact_id" contact-id ht)
|
||||
(puthash "name" name ht)
|
||||
(puthash "type" "direct" ht)
|
||||
(puthash "unread_count" 0 ht)
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
|
||||
(require 'noise-rpc)
|
||||
(require 'noise-chat-list)
|
||||
(require 'noise-conversation)
|
||||
(require 'noise-new-chat)
|
||||
(require 'noise-receive)
|
||||
|
||||
(defgroup noise nil
|
||||
"Signal Messenger client for Emacs."
|
||||
@@ -26,6 +29,42 @@ The default port is 8080, matching signal-cli's `--http` default."
|
||||
:type 'string
|
||||
:group 'noise)
|
||||
|
||||
(defcustom noise-account nil
|
||||
"The Signal account to use, as a phone number in E.164 format.
|
||||
signal-cli can have multiple accounts registered. When the daemon
|
||||
serves more than one account, every request must say which account it
|
||||
is for, so this must be set (e.g. \"+15551234567\"). When nil, the
|
||||
daemon is assumed to serve a single account (started with `-a`).
|
||||
|
||||
Use `noise-select-account' to pick one interactively from the
|
||||
accounts registered with signal-cli."
|
||||
:type '(choice (const :tag "Single-account daemon" nil)
|
||||
(string :tag "Phone number (E.164)"))
|
||||
:group 'noise)
|
||||
|
||||
(defcustom noise-sse-max-reconnect-delay 60
|
||||
"Maximum delay in seconds between SSE reconnection attempts."
|
||||
:type 'integer
|
||||
:group 'noise)
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-select-account ()
|
||||
"Select which signal-cli account Noise should use.
|
||||
Queries the daemon for registered accounts and sets `noise-account'."
|
||||
(interactive)
|
||||
(let* ((accounts (noise-rpc-call "listAccounts"))
|
||||
(numbers (mapcar (lambda (a) (gethash "number" a))
|
||||
(append accounts nil))))
|
||||
(cond
|
||||
((null numbers)
|
||||
(user-error "No accounts registered with signal-cli"))
|
||||
((null (cdr numbers))
|
||||
(setq noise-account (car numbers)))
|
||||
(t
|
||||
(setq noise-account
|
||||
(completing-read "Signal account: " numbers nil t))))
|
||||
(message "Using Signal account %s" noise-account)))
|
||||
|
||||
;;;###autoload
|
||||
(defun noise-check-connection ()
|
||||
"Check connectivity to the signal-cli JSON-RPC daemon."
|
||||
@@ -37,15 +76,33 @@ The default port is 8080, matching signal-cli's `--http` default."
|
||||
(noise-rpc-error
|
||||
(message "Error: %s" (cdr err)))))
|
||||
|
||||
(defun noise--unread-lighter ()
|
||||
"Return the mode-line lighter string with unread count."
|
||||
(let ((count 0))
|
||||
(when (and (boundp 'noise-db--connection)
|
||||
noise-db--connection
|
||||
(fboundp 'sqlitep)
|
||||
(sqlitep noise-db--connection))
|
||||
(condition-case nil
|
||||
(setq count (or (caar (sqlite-select noise-db--connection
|
||||
"SELECT COALESCE(SUM(unread_count), 0) FROM conversations"))
|
||||
0))
|
||||
(error 0)))
|
||||
(if (> count 0)
|
||||
(format " Noise[%d]" count)
|
||||
" Noise")))
|
||||
|
||||
;;;###autoload
|
||||
(define-minor-mode noise-mode
|
||||
"Toggle Noise Signal client mode."
|
||||
:global t
|
||||
:lighter " Noise"
|
||||
:lighter (:eval (noise--unread-lighter))
|
||||
:group 'noise
|
||||
(if noise-mode
|
||||
(progn
|
||||
(noise-chat-list)
|
||||
(noise-receive-start-sse))
|
||||
(noise-receive-stop-sse)
|
||||
(message "Noise mode disabled")))
|
||||
|
||||
(provide 'noise)
|
||||
;;; noise.el ends here
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
(noise-chat-list-test--make-row "conv-1" "Sarah" 3
|
||||
"are you taking the girls?"
|
||||
"Sarah" (float-time)))))
|
||||
(should (string-match "●" row))
|
||||
(should (string-match " 3" row))
|
||||
(should (string-match "Sarah" row))
|
||||
(should (string-match "are you taking" row))))
|
||||
|
||||
@@ -49,4 +49,4 @@
|
||||
(noise-chat-list-test--make-row "conv-2" "Mark Okafor" 0
|
||||
"merged" "me" (float-time)))))
|
||||
(should (string-match "Mark Okafor" row))
|
||||
(should-not (string-match "●" row))))
|
||||
(should (string-match "^ " row))))
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
;;; noise-conversation-test.el --- Tests for noise-conversation -*- lexical-binding: t; -*-
|
||||
|
||||
(require 'ert)
|
||||
(require 'noise-conversation)
|
||||
|
||||
(defvar noise-conversation-test--db
|
||||
(expand-file-name "noise-conversation-test.db" temporary-file-directory))
|
||||
|
||||
(defmacro noise-conversation-test--with-db (&rest body)
|
||||
"Run BODY with a fresh test database and clean up conversation buffers."
|
||||
`(let ((noise-db-file noise-conversation-test--db))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(when (file-exists-p noise-db-file)
|
||||
(delete-file noise-db-file))
|
||||
(noise-db-init)
|
||||
,@body)
|
||||
(noise-db--close)
|
||||
(when (file-exists-p noise-conversation-test--db)
|
||||
(delete-file noise-conversation-test--db))
|
||||
(dolist (buf (buffer-list))
|
||||
(when (string-prefix-p "*signal:" (buffer-name buf))
|
||||
(kill-buffer buf))))))
|
||||
|
||||
(defun noise-conversation-test--seed ()
|
||||
"Insert a direct conversation with Sarah and two messages."
|
||||
(noise-db-upsert-contact "+1" :name "Sarah")
|
||||
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah"
|
||||
:members '("+1"))
|
||||
(noise-db-insert-message "+1" :timestamp 1717930000.0
|
||||
:source "Sarah" :body "are you coming?")
|
||||
(noise-db-insert-message "+1" :timestamp 1717930100.0
|
||||
:source "me" :body "yes, leaving now"
|
||||
:receipt-state "delivered"))
|
||||
|
||||
(ert-deftest noise-conversation-open-renders-history ()
|
||||
"Opening a conversation renders messages, a day separator, and the prompt."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-conversation-test--seed)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(let ((text (buffer-substring-no-properties (point-min) (point-max))))
|
||||
(should (string-match-p "Sarah> are you coming\\?" text))
|
||||
(should (string-match-p "me> yes, leaving now" text))
|
||||
;; day separator present
|
||||
(should (string-match-p "─── .* ───" text))
|
||||
;; delivered receipt
|
||||
(should (string-match-p "✓✓" text))
|
||||
;; compose prompt at the bottom
|
||||
(should (string-match-p "^> " text))))))
|
||||
|
||||
(ert-deftest noise-conversation-open-marks-read ()
|
||||
"Opening a conversation resets its unread count."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-conversation-test--seed)
|
||||
(noise-db-upsert-conversation "+1" :unread-count 4)
|
||||
(noise-conversation-open "+1")
|
||||
(should (= 0 (gethash "unread_count" (noise-db-get-conversation "+1"))))))
|
||||
|
||||
(ert-deftest noise-conversation-history-is-read-only ()
|
||||
"Typing into the history region should fail; the prompt is editable."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-conversation-test--seed)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(goto-char (point-min))
|
||||
(should-error (insert "x"))
|
||||
(goto-char (point-max))
|
||||
(insert "hello")
|
||||
(should (string= "hello" (noise-conversation--current-input))))))
|
||||
|
||||
(ert-deftest noise-conversation-send-stores-and-clears ()
|
||||
"Sending stores the message with the server timestamp and clears input."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-conversation-test--seed)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(goto-char (point-max))
|
||||
(insert "on my way")
|
||||
(let (sent-params)
|
||||
(cl-letf (((symbol-function 'noise-rpc-call-for-account)
|
||||
(lambda (method &rest params)
|
||||
(should (string= "send" method))
|
||||
(setq sent-params params)
|
||||
(let ((ht (make-hash-table :test 'equal)))
|
||||
(puthash "timestamp" 1717930200123 ht)
|
||||
ht))))
|
||||
(noise-conversation-send))
|
||||
(should (equal ["+1"] (plist-get sent-params :recipient)))
|
||||
(should (string= "on my way" (plist-get sent-params :message))))
|
||||
;; input cleared, message rendered
|
||||
(should (string= "" (noise-conversation--current-input)))
|
||||
(let ((msgs (noise-db-get-messages "+1")))
|
||||
(should (= 3 (length msgs)))
|
||||
(let ((last (car (last msgs))))
|
||||
(should (string= "me" (gethash "source" last)))
|
||||
(should (string= "on my way" (gethash "body" last)))
|
||||
(should (string= "sent" (gethash "receipt_state" last)))
|
||||
(should (= 1717930200.123 (gethash "timestamp" last))))))))
|
||||
|
||||
(ert-deftest noise-conversation-send-group-uses-group-id ()
|
||||
"Sending in a group conversation uses the groupId parameter."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-db-upsert-conversation "grp1" :type "group" :name "cycling crew")
|
||||
(with-current-buffer (noise-conversation-open "grp1")
|
||||
(goto-char (point-max))
|
||||
(insert "saturday 7am?")
|
||||
(let (sent-params)
|
||||
(cl-letf (((symbol-function 'noise-rpc-call-for-account)
|
||||
(lambda (_method &rest params)
|
||||
(setq sent-params params)
|
||||
nil)))
|
||||
(noise-conversation-send))
|
||||
(should (string= "grp1" (plist-get sent-params :groupId)))
|
||||
(should-not (plist-get sent-params :recipient))))))
|
||||
|
||||
(ert-deftest noise-conversation-send-empty-does-nothing ()
|
||||
"Sending with no input should not call signal-cli."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-conversation-test--seed)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(cl-letf (((symbol-function 'noise-rpc-call-for-account)
|
||||
(lambda (&rest _) (error "Should not be called"))))
|
||||
(noise-conversation-send))
|
||||
(should (= 2 (length (noise-db-get-messages "+1")))))))
|
||||
|
||||
(ert-deftest noise-conversation-render-preserves-input ()
|
||||
"Re-rendering keeps text already typed at the prompt."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-conversation-test--seed)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(goto-char (point-max))
|
||||
(insert "draft text")
|
||||
(noise-conversation--render)
|
||||
(should (string= "draft text" (noise-conversation--current-input))))))
|
||||
|
||||
(ert-deftest noise-conversation-format-attachment ()
|
||||
"Attachment messages render the [photo: ...] hint."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-conversation-test--seed)
|
||||
(noise-db-insert-message "+1" :timestamp 1717930300.0
|
||||
:source "Sarah" :body ""
|
||||
:has-attachment t
|
||||
:attachment-path "IMG_2041.jpg")
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(should (string-match-p "\\[photo: IMG_2041\\.jpg — RET to view\\]"
|
||||
(buffer-string))))))
|
||||
|
||||
(ert-deftest noise-conversation-typing-indicator ()
|
||||
"Typing STARTED/STOPPED toggles the modeline indicator."
|
||||
(noise-conversation-test--with-db
|
||||
(noise-conversation-test--seed)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(noise-conversation-set-typing "+1" "Sarah" "STARTED")
|
||||
(should (string= "Sarah" noise-conversation--typing))
|
||||
(should (string-match-p "Sarah is typing…" mode-line-format))
|
||||
(noise-conversation-set-typing "+1" "Sarah" "STOPPED")
|
||||
(should-not noise-conversation--typing))))
|
||||
@@ -0,0 +1,156 @@
|
||||
;;; noise-new-chat-test.el --- Tests for noise-new-chat -*- lexical-binding: t; -*-
|
||||
|
||||
(require 'ert)
|
||||
(require 'noise-new-chat)
|
||||
|
||||
(defvar noise-new-chat-test--db
|
||||
(expand-file-name "noise-new-chat-test.db" temporary-file-directory))
|
||||
|
||||
(defun noise-new-chat-test--setup ()
|
||||
"Set up a fresh test database."
|
||||
(require 'noise-db)
|
||||
(let ((noise-db-file noise-new-chat-test--db))
|
||||
(when (file-exists-p noise-db-file)
|
||||
(delete-file noise-db-file))
|
||||
(noise-db-init)))
|
||||
|
||||
(defun noise-new-chat-test--teardown ()
|
||||
"Clean up the test database."
|
||||
(require 'noise-db)
|
||||
(noise-db--close)
|
||||
(when (file-exists-p noise-new-chat-test--db)
|
||||
(delete-file noise-new-chat-test--db)))
|
||||
|
||||
(defmacro noise-new-chat-test--with-db (&rest body)
|
||||
"Run BODY with a fresh test database bound to `noise-db-file'."
|
||||
`(let ((noise-db-file noise-new-chat-test--db))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(noise-new-chat-test--setup)
|
||||
,@body)
|
||||
(noise-new-chat-test--teardown)
|
||||
(dolist (buf (buffer-list))
|
||||
(when (string-prefix-p "*signal:" (buffer-name buf))
|
||||
(kill-buffer buf))))))
|
||||
|
||||
(defun noise-new-chat-test--contact (&rest kvs)
|
||||
"Build a hash table simulating a signal-cli contact from KVS pairs."
|
||||
(let ((ht (make-hash-table :test 'equal)))
|
||||
(while kvs
|
||||
(puthash (car kvs) (cadr kvs) ht)
|
||||
(setq kvs (cddr kvs)))
|
||||
ht))
|
||||
|
||||
(ert-deftest noise-new-chat--contact-id-prefers-number ()
|
||||
"Contact ID should prefer the phone number over the UUID."
|
||||
(let ((c (noise-new-chat-test--contact "number" "+15551234567"
|
||||
"uuid" "abc-123")))
|
||||
(should (string= "+15551234567" (noise-new-chat--contact-id c)))))
|
||||
|
||||
(ert-deftest noise-new-chat--contact-id-falls-back-to-uuid ()
|
||||
"Contact ID should fall back to UUID when number is null."
|
||||
(let ((c (noise-new-chat-test--contact "number" :null
|
||||
"uuid" "abc-123")))
|
||||
(should (string= "abc-123" (noise-new-chat--contact-id c)))))
|
||||
|
||||
(ert-deftest noise-new-chat--contact-name-prefers-local-name ()
|
||||
"Display name should prefer the local contact name."
|
||||
(let ((c (noise-new-chat-test--contact
|
||||
"number" "+15551234567"
|
||||
"name" "Alice"
|
||||
"profile" (noise-new-chat-test--contact "givenName" "Alicia"))))
|
||||
(should (string= "Alice" (noise-new-chat--contact-name c)))))
|
||||
|
||||
(ert-deftest noise-new-chat--contact-name-uses-profile ()
|
||||
"Display name should fall back to the profile name."
|
||||
(let ((c (noise-new-chat-test--contact
|
||||
"number" "+15551234567"
|
||||
"name" ""
|
||||
"profile" (noise-new-chat-test--contact "givenName" "Alicia"
|
||||
"familyName" "Jones"))))
|
||||
(should (string= "Alicia Jones" (noise-new-chat--contact-name c)))))
|
||||
|
||||
(ert-deftest noise-new-chat--contact-name-falls-back-to-number ()
|
||||
"Display name should fall back to the phone number when nothing else."
|
||||
(let ((c (noise-new-chat-test--contact "number" "+15551234567"
|
||||
"name" ""
|
||||
"profile" :null)))
|
||||
(should (string= "+15551234567" (noise-new-chat--contact-name c)))))
|
||||
|
||||
(ert-deftest noise-new-chat--candidate-includes-number ()
|
||||
"Completion candidates should include name and number."
|
||||
(let ((ht (make-hash-table :test 'equal)))
|
||||
(puthash "id" "+15551234567" ht)
|
||||
(puthash "name" "Alice" ht)
|
||||
(should (string= "Alice (+15551234567)" (noise-new-chat--candidate ht)))))
|
||||
|
||||
(ert-deftest noise-new-chat--candidate-number-only ()
|
||||
"Candidates with no name should be just the number."
|
||||
(let ((ht (make-hash-table :test 'equal)))
|
||||
(puthash "id" "+15551234567" ht)
|
||||
(puthash "name" "" ht)
|
||||
(should (string= "+15551234567" (noise-new-chat--candidate ht)))))
|
||||
|
||||
(ert-deftest noise-new-chat-sync-contacts-stores-contacts ()
|
||||
"Syncing should upsert contacts returned by signal-cli into the DB."
|
||||
(noise-new-chat-test--with-db
|
||||
(cl-letf (((symbol-function 'noise-rpc-call-for-account)
|
||||
(lambda (method &rest _)
|
||||
(should (string= "listContacts" method))
|
||||
(vector
|
||||
(noise-new-chat-test--contact "number" "+1" "name" "Sarah")
|
||||
(noise-new-chat-test--contact "number" "+2" "name" ""
|
||||
"profile"
|
||||
(noise-new-chat-test--contact
|
||||
"givenName" "Samir"
|
||||
"familyName" "Darwish"))
|
||||
;; No number and no uuid — should be skipped
|
||||
(noise-new-chat-test--contact "name" "Ghost")))))
|
||||
(should (= 2 (noise-new-chat-sync-contacts)))
|
||||
(should (string= "Sarah" (gethash "name" (noise-db-get-contact "+1"))))
|
||||
(should (string= "Samir Darwish"
|
||||
(gethash "name" (noise-db-get-contact "+2")))))))
|
||||
|
||||
(ert-deftest noise-new-chat-sync-contacts-uses-account ()
|
||||
"Syncing should go through the account-aware RPC helper."
|
||||
(noise-new-chat-test--with-db
|
||||
(defvar noise-account)
|
||||
(let ((noise-account "+19998887777")
|
||||
(seen-account nil))
|
||||
(cl-letf (((symbol-function 'noise-rpc-call)
|
||||
(lambda (_method &rest params)
|
||||
(setq seen-account (plist-get params :account))
|
||||
(vector))))
|
||||
(noise-new-chat-sync-contacts)
|
||||
(should (string= "+19998887777" seen-account))))))
|
||||
|
||||
(ert-deftest noise-new-chat-with-contact-creates-conversation ()
|
||||
"Starting a chat with a contact should create a direct conversation."
|
||||
(noise-new-chat-test--with-db
|
||||
(noise-db-upsert-contact "+1" :name "Sarah")
|
||||
(noise-new-chat-with-contact "+1" "Sarah")
|
||||
(let ((conv (noise-db-get-conversation "+1")))
|
||||
(should conv)
|
||||
(should (string= "direct" (gethash "type" conv)))
|
||||
(should (string= "Sarah" (gethash "name" conv)))
|
||||
(should (equal '("+1")
|
||||
(mapcar (lambda (m) (gethash "contact_id" m))
|
||||
(noise-db-get-conversation-members "+1")))))))
|
||||
|
||||
(ert-deftest noise-new-chat-with-contact-is-idempotent ()
|
||||
"Starting a chat twice should not clobber the existing conversation.
|
||||
Opening the conversation marks it read, but history metadata survives."
|
||||
(noise-new-chat-test--with-db
|
||||
(unwind-protect
|
||||
(progn
|
||||
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah"
|
||||
:members '("+1")
|
||||
:unread-count 3
|
||||
:last-message-preview "hello")
|
||||
(noise-new-chat-with-contact "+1" "Sarah")
|
||||
(let ((conv (noise-db-get-conversation "+1")))
|
||||
;; opening the chat marks it read
|
||||
(should (= 0 (gethash "unread_count" conv)))
|
||||
(should (string= "hello" (gethash "last_message_preview" conv)))))
|
||||
(when (get-buffer "*signal:Sarah*")
|
||||
(kill-buffer "*signal:Sarah*")))))
|
||||
@@ -0,0 +1,235 @@
|
||||
;;; noise-receive-test.el --- Tests for noise-receive -*- lexical-binding: t; -*-
|
||||
|
||||
(require 'ert)
|
||||
(require 'noise-receive)
|
||||
|
||||
(defvar noise-receive-test--db
|
||||
(expand-file-name "noise-receive-test.db" temporary-file-directory))
|
||||
|
||||
(defmacro noise-receive-test--with-db (&rest body)
|
||||
"Run BODY with a fresh test database and clean up conversation buffers."
|
||||
`(let ((noise-db-file noise-receive-test--db))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(when (file-exists-p noise-db-file)
|
||||
(delete-file noise-db-file))
|
||||
(noise-db-init)
|
||||
,@body)
|
||||
(noise-db--close)
|
||||
(when (file-exists-p noise-receive-test--db)
|
||||
(delete-file noise-receive-test--db))
|
||||
(dolist (buf (buffer-list))
|
||||
(when (string-prefix-p "*signal:" (buffer-name buf))
|
||||
(kill-buffer buf))))))
|
||||
|
||||
(defun noise-receive-test--ht (&rest kvs)
|
||||
"Build a hash table from KVS key/value pairs."
|
||||
(let ((ht (make-hash-table :test 'equal)))
|
||||
(while kvs
|
||||
(puthash (car kvs) (cadr kvs) ht)
|
||||
(setq kvs (cddr kvs)))
|
||||
ht))
|
||||
|
||||
(defun noise-receive-test--run (&rest envelopes)
|
||||
"Run `noise-receive' against mocked ENVELOPES, returning its result."
|
||||
(cl-letf (((symbol-function 'noise-rpc-call-for-account)
|
||||
(lambda (method &rest _)
|
||||
(should (string= "receive" method))
|
||||
(apply #'vector
|
||||
(mapcar (lambda (e)
|
||||
(noise-receive-test--ht "envelope" e))
|
||||
envelopes)))))
|
||||
(noise-receive)))
|
||||
|
||||
(ert-deftest noise-receive-direct-message ()
|
||||
"An incoming direct message creates contact, conversation, and message."
|
||||
(noise-receive-test--with-db
|
||||
(should (= 1 (noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1" "sourceName" "Sarah"
|
||||
"dataMessage" (noise-receive-test--ht
|
||||
"timestamp" 1717930000000
|
||||
"message" "are you coming?")))))
|
||||
(should (string= "Sarah" (gethash "name" (noise-db-get-contact "+1"))))
|
||||
(let ((conv (noise-db-get-conversation "+1")))
|
||||
(should (string= "direct" (gethash "type" conv)))
|
||||
(should (string= "Sarah" (gethash "name" conv)))
|
||||
(should (= 1 (gethash "unread_count" conv)))
|
||||
;; direct chats have no sender prefix in the preview
|
||||
(should (string= "" (gethash "last_message_sender" conv)))
|
||||
(should (string= "are you coming?"
|
||||
(gethash "last_message_preview" conv))))
|
||||
(let ((msg (car (noise-db-get-messages "+1"))))
|
||||
(should (string= "Sarah" (gethash "source" msg)))
|
||||
(should (= 1717930000.0 (gethash "timestamp" msg))))))
|
||||
|
||||
(ert-deftest noise-receive-group-message ()
|
||||
"An incoming group message creates a group conversation keyed by groupId."
|
||||
(noise-receive-test--with-db
|
||||
(noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+2" "sourceName" "Priya"
|
||||
"dataMessage" (noise-receive-test--ht
|
||||
"timestamp" 1717930000000
|
||||
"message" "saturday 7am, usual spot?"
|
||||
"groupInfo" (noise-receive-test--ht
|
||||
"groupId" "grp1"
|
||||
"groupName" "cycling crew"))))
|
||||
(let ((conv (noise-db-get-conversation "grp1")))
|
||||
(should (string= "group" (gethash "type" conv)))
|
||||
(should (string= "cycling crew" (gethash "name" conv)))
|
||||
;; group previews keep the sender prefix
|
||||
(should (string= "Priya" (gethash "last_message_sender" conv))))))
|
||||
|
||||
(ert-deftest noise-receive-attachment-only-message ()
|
||||
"An attachment-only message is stored and gets a photo preview."
|
||||
(noise-receive-test--with-db
|
||||
(noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1" "sourceName" "Sarah"
|
||||
"dataMessage" (noise-receive-test--ht
|
||||
"timestamp" 1717930000000
|
||||
"message" :null
|
||||
"attachments" (vector (noise-receive-test--ht
|
||||
"filename" "IMG_2041.jpg")))))
|
||||
(let ((msg (car (noise-db-get-messages "+1"))))
|
||||
(should (= 1 (gethash "has_attachment" msg)))
|
||||
(should (string= "IMG_2041.jpg" (gethash "attachment_path" msg))))
|
||||
(should (string= "photo: IMG_2041.jpg"
|
||||
(gethash "last_message_preview"
|
||||
(noise-db-get-conversation "+1"))))))
|
||||
|
||||
(ert-deftest noise-receive-sync-message ()
|
||||
"A sync sentMessage from another device is stored as from `me'."
|
||||
(noise-receive-test--with-db
|
||||
(noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+me"
|
||||
"syncMessage" (noise-receive-test--ht
|
||||
"sentMessage" (noise-receive-test--ht
|
||||
"timestamp" 1717930000000
|
||||
"destinationNumber" "+1"
|
||||
"message" "sent from my phone"))))
|
||||
(let ((msg (car (noise-db-get-messages "+1"))))
|
||||
(should (string= "me" (gethash "source" msg)))
|
||||
(should (string= "sent" (gethash "receipt_state" msg)))
|
||||
(should (string= "sent from my phone" (gethash "body" msg))))
|
||||
;; own messages don't bump unread
|
||||
(should (= 0 (gethash "unread_count" (noise-db-get-conversation "+1"))))))
|
||||
|
||||
(ert-deftest noise-receive-delivery-receipt ()
|
||||
"A delivery receipt upgrades the receipt state of the matching message."
|
||||
(noise-receive-test--with-db
|
||||
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah")
|
||||
(noise-db-insert-message "+1" :timestamp 1717930000.0
|
||||
:source "me" :body "hi" :receipt-state "sent")
|
||||
(noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1"
|
||||
"receiptMessage" (noise-receive-test--ht
|
||||
"isDelivery" t "isRead" :false
|
||||
"timestamps" (vector 1717930000000))))
|
||||
(should (string= "delivered"
|
||||
(gethash "receipt_state"
|
||||
(car (noise-db-get-messages "+1")))))))
|
||||
|
||||
(ert-deftest noise-receive-receipt-never-downgrades ()
|
||||
"A delivery receipt arriving after a read receipt is ignored."
|
||||
(noise-receive-test--with-db
|
||||
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah")
|
||||
(noise-db-insert-message "+1" :timestamp 1717930000.0
|
||||
:source "me" :body "hi" :receipt-state "read")
|
||||
(noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1"
|
||||
"receiptMessage" (noise-receive-test--ht
|
||||
"isDelivery" t "isRead" :false
|
||||
"timestamps" (vector 1717930000000))))
|
||||
(should (string= "read"
|
||||
(gethash "receipt_state"
|
||||
(car (noise-db-get-messages "+1")))))))
|
||||
|
||||
(ert-deftest noise-receive-receipt-ignores-incoming-messages ()
|
||||
"Receipts only apply to own messages, not the contact's."
|
||||
(noise-receive-test--with-db
|
||||
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah")
|
||||
(noise-db-insert-message "+1" :timestamp 1717930000.0
|
||||
:source "Sarah" :body "hi")
|
||||
(noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1"
|
||||
"receiptMessage" (noise-receive-test--ht
|
||||
"isDelivery" t "isRead" :false
|
||||
"timestamps" (vector 1717930000000))))
|
||||
(should (string= ""
|
||||
(gethash "receipt_state"
|
||||
(car (noise-db-get-messages "+1")))))))
|
||||
|
||||
(ert-deftest noise-receive-typing-without-buffer ()
|
||||
"Typing indicators for conversations without open buffers are harmless."
|
||||
(noise-receive-test--with-db
|
||||
(should (= 0 (noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1" "sourceName" "Sarah"
|
||||
"typingMessage" (noise-receive-test--ht
|
||||
"action" "STARTED"
|
||||
"timestamp" 1717930000000)))))))
|
||||
|
||||
(ert-deftest noise-receive-refreshes-open-conversation ()
|
||||
"Receiving a message re-renders an open conversation buffer."
|
||||
(noise-receive-test--with-db
|
||||
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah"
|
||||
:members '("+1"))
|
||||
(require 'noise-conversation)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(noise-receive-test--run
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1" "sourceName" "Sarah"
|
||||
"dataMessage" (noise-receive-test--ht
|
||||
"timestamp" 1717930000000
|
||||
"message" "new message here")))
|
||||
(should (string-match-p "Sarah> new message here" (buffer-string))))))
|
||||
|
||||
(ert-deftest noise-receive-handle-sse-event-stores-message ()
|
||||
"An SSE envelope event stores the message and refreshes the open buffer."
|
||||
(noise-receive-test--with-db
|
||||
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah"
|
||||
:members '("+1"))
|
||||
(require 'noise-conversation)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(noise-receive--handle-sse-event
|
||||
(noise-receive-test--ht
|
||||
"envelope"
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1" "sourceName" "Sarah"
|
||||
"dataMessage" (noise-receive-test--ht
|
||||
"timestamp" 1717930000000
|
||||
"message" "from sse"))))
|
||||
(should (string-match-p "Sarah> from sse" (buffer-string))))
|
||||
(let ((msg (car (noise-db-get-messages "+1"))))
|
||||
(should (string= "from sse" (gethash "body" msg))))))
|
||||
|
||||
(ert-deftest noise-receive-sse-stores-then-open-shows-message ()
|
||||
"An SSE event stores the message; opening the conversation later shows it."
|
||||
(noise-receive-test--with-db
|
||||
;; No conversation buffer open — simulate SSE event arriving
|
||||
(noise-receive--handle-sse-event
|
||||
(noise-receive-test--ht
|
||||
"envelope"
|
||||
(noise-receive-test--ht
|
||||
"sourceNumber" "+1" "sourceName" "Sarah"
|
||||
"dataMessage" (noise-receive-test--ht
|
||||
"timestamp" 1717930000000
|
||||
"message" "hello from the void"))))
|
||||
;; Verify message is in DB
|
||||
(let ((msgs (noise-db-get-messages "+1")))
|
||||
(should (= 1 (length msgs)))
|
||||
(should (string= "hello from the void" (gethash "body" (car msgs)))))
|
||||
;; Verify conversation has unread count
|
||||
(let ((conv (noise-db-get-conversation "+1")))
|
||||
(should (= 1 (gethash "unread_count" conv))))
|
||||
;; Now open the conversation and verify message renders
|
||||
(require 'noise-conversation)
|
||||
(with-current-buffer (noise-conversation-open "+1")
|
||||
(should (string-match-p "Sarah> hello from the void" (buffer-string)))
|
||||
(should (= 0 (gethash "unread_count" (noise-db-get-conversation "+1")))))))
|
||||
@@ -0,0 +1,163 @@
|
||||
;;; noise-sse-test.el --- Tests for SSE client -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Tests for noise-sse.el SSE event parsing and connection management.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'noise-sse)
|
||||
|
||||
(defvar noise-signal-cli-address)
|
||||
(defvar noise-account)
|
||||
|
||||
(ert-deftest noise-sse--parse-event-basic ()
|
||||
"Parse a basic SSE event with data field."
|
||||
(let ((event "data: {\"type\":\"message\",\"content\":\"hello\"}\n\n"))
|
||||
(let ((result (noise-sse--parse-event event)))
|
||||
(should (hash-table-p result))
|
||||
(should (equal (gethash "type" result) "message"))
|
||||
(should (equal (gethash "content" result) "hello")))))
|
||||
|
||||
(ert-deftest noise-sse--parse-event-multiline ()
|
||||
"Parse SSE event with multiline data."
|
||||
(let ((event "data: {\"type\":\"message\",\ndata: \"content\":\"hello\"}\n\n"))
|
||||
(let ((result (noise-sse--parse-event event)))
|
||||
(should (hash-table-p result))
|
||||
(should (equal (gethash "type" result) "message")))))
|
||||
|
||||
(ert-deftest noise-sse--parse-event-empty ()
|
||||
"Parse empty SSE event returns nil."
|
||||
(let ((event "\n\n"))
|
||||
(should (null (noise-sse--parse-event event)))))
|
||||
|
||||
(ert-deftest noise-sse--initial-state ()
|
||||
"SSE client starts in disconnected state."
|
||||
(let ((noise-sse--process nil)
|
||||
(noise-sse--reconnect-timer nil)
|
||||
(noise-sse--reconnect-delay 1))
|
||||
(should (null noise-sse--process))
|
||||
(should (null noise-sse--reconnect-timer))
|
||||
(should (= 1 noise-sse--reconnect-delay))))
|
||||
|
||||
(ert-deftest noise-sse--reconnect-delay-backoff ()
|
||||
"Reconnect delay doubles on each attempt."
|
||||
(let ((noise-sse--reconnect-delay 1))
|
||||
(noise-sse--increase-reconnect-delay)
|
||||
(should (= 2 noise-sse--reconnect-delay))
|
||||
(noise-sse--increase-reconnect-delay)
|
||||
(should (= 4 noise-sse--reconnect-delay))
|
||||
(noise-sse--increase-reconnect-delay)
|
||||
(should (= 8 noise-sse--reconnect-delay))))
|
||||
|
||||
(ert-deftest noise-sse--reconnect-delay-max ()
|
||||
"Reconnect delay caps at max value."
|
||||
(let ((noise-sse--reconnect-delay 32)
|
||||
(noise-sse-max-reconnect-delay 60))
|
||||
(noise-sse--increase-reconnect-delay)
|
||||
(should (= 60 noise-sse--reconnect-delay))))
|
||||
|
||||
(ert-deftest noise-sse--filter-processes-complete-event ()
|
||||
"Process filter parses and calls handler for complete events."
|
||||
(let ((processed-events '())
|
||||
(noise-sse--buffer "")
|
||||
(noise-sse--http-buffer "")
|
||||
(noise-sse--chunk-buffer "")
|
||||
(noise-sse--headers-received t)
|
||||
(noise-sse--chunked-response-p nil))
|
||||
(let ((noise-sse--event-handler
|
||||
(lambda (event) (push event processed-events))))
|
||||
(noise-sse--filter nil "data: {\"type\":\"message\"}\n\n")
|
||||
(should (= 1 (length processed-events)))
|
||||
(should (hash-table-p (car processed-events))))))
|
||||
|
||||
(ert-deftest noise-sse--filter-buffers-incomplete-event ()
|
||||
"Process filter buffers incomplete events until newline."
|
||||
(let ((processed-events '())
|
||||
(noise-sse--buffer "")
|
||||
(noise-sse--http-buffer "")
|
||||
(noise-sse--chunk-buffer "")
|
||||
(noise-sse--headers-received t)
|
||||
(noise-sse--chunked-response-p nil))
|
||||
(let ((noise-sse--event-handler
|
||||
(lambda (event) (push event processed-events))))
|
||||
(noise-sse--filter nil "data: {\"type\":\"message\"")
|
||||
(should (= 0 (length processed-events)))
|
||||
(noise-sse--filter nil "}\n\n")
|
||||
(should (= 1 (length processed-events))))))
|
||||
|
||||
(ert-deftest noise-sse--filter-skips-http-headers ()
|
||||
"HTTP response headers should not block the first SSE event."
|
||||
(let ((processed-events '())
|
||||
(noise-sse--buffer "")
|
||||
(noise-sse--http-buffer "")
|
||||
(noise-sse--chunk-buffer "")
|
||||
(noise-sse--headers-received nil)
|
||||
(noise-sse--chunked-response-p nil))
|
||||
(let ((noise-sse--event-handler
|
||||
(lambda (event) (push event processed-events))))
|
||||
(noise-sse--filter nil
|
||||
(concat "HTTP/1.1 200 OK\r\n"
|
||||
"Content-Type: text/event-stream\r\n\r\n"
|
||||
"data: {\"type\":\"message\"}\n\n"))
|
||||
(should (= 1 (length processed-events)))
|
||||
(should (equal "message" (gethash "type" (car processed-events)))))))
|
||||
|
||||
(ert-deftest noise-sse--filter-decodes-chunked-http-body ()
|
||||
"Chunked HTTP SSE responses should be decoded before parsing events."
|
||||
(let ((processed-events '())
|
||||
(noise-sse--buffer "")
|
||||
(noise-sse--http-buffer "")
|
||||
(noise-sse--chunk-buffer "")
|
||||
(noise-sse--headers-received nil)
|
||||
(noise-sse--chunked-response-p nil))
|
||||
(let ((noise-sse--event-handler
|
||||
(lambda (event) (push event processed-events))))
|
||||
(noise-sse--filter nil
|
||||
(concat "HTTP/1.1 200 OK\r\n"
|
||||
"Transfer-Encoding: chunked\r\n"
|
||||
"Content-Type: text/event-stream\r\n\r\n"
|
||||
"12\r\n"
|
||||
"data: {\"type\":\"msg"
|
||||
"\r\n"
|
||||
"4\r\n"
|
||||
"\"}\n\n"
|
||||
"\r\n"
|
||||
"0\r\n\r\n"))
|
||||
(should (= 1 (length processed-events)))
|
||||
(should (equal "msg" (gethash "type" (car processed-events)))))))
|
||||
|
||||
(ert-deftest noise-sse--build-url ()
|
||||
"Build SSE endpoint URL from base address."
|
||||
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||
(noise-account "+15551234567"))
|
||||
(let ((url (noise-sse--build-url)))
|
||||
(should (string-match-p "/api/v1/events" url))
|
||||
(should (string-match-p "account=%2B15551234567" url)))))
|
||||
|
||||
(ert-deftest noise-sse--build-url-no-account ()
|
||||
"Build SSE endpoint URL without account parameter."
|
||||
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||
(noise-account nil))
|
||||
(let ((url (noise-sse--build-url)))
|
||||
(should (string-match-p "/api/v1/events" url))
|
||||
(should-not (string-match-p "account=" url)))))
|
||||
|
||||
(ert-deftest noise-sse-connect-is-interactive ()
|
||||
"noise-sse-connect is an interactive command."
|
||||
(should (commandp 'noise-sse-connect)))
|
||||
|
||||
(ert-deftest noise-sse-disconnect-is-interactive ()
|
||||
"noise-sse-disconnect is an interactive command."
|
||||
(should (commandp 'noise-sse-disconnect)))
|
||||
|
||||
(ert-deftest noise-sse-disconnect-cleans-up ()
|
||||
"noise-sse-disconnect cleans up process and timer."
|
||||
(let ((noise-sse--reconnect-timer (run-with-timer 1000 nil #'ignore)))
|
||||
(noise-sse-disconnect)
|
||||
(should (null noise-sse--process))
|
||||
(should (null noise-sse--reconnect-timer))))
|
||||
|
||||
(provide 'noise-sse-test)
|
||||
;;; noise-sse-test.el ends here
|
||||
Reference in New Issue
Block a user