chore: add project docs, readme, and UI mockup

This commit is contained in:
2026-06-09 19:30:00 -04:00
parent 5e5ffaee3c
commit 9498cbb32b
4 changed files with 419 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# Noise — Agent Instructions
## 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`.
## Technology & Architecture
| Layer | Technology |
|---|---|
| Language | Emacs Lisp (Elisp) |
| Message backend | `signal-cli` JSON-RPC (`signal-cli daemon`) |
| 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.
- 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.
## User Experience Principles
These come directly from the UI mockup (`signal-emacs-ui.html`) and README:
- **Every action has a keybinding.** There are no mouse targets, no toolbars, no sidebars.
- **Conversation switching is minibuffer completion** (fuzzy narrowing, vertico-style). Press `C-x b` from anywhere to switch chats.
- **Compose inline at the prompt** in a conversation buffer. `RET` sends; `M-RET` inserts a newline.
- **Chat list is an ibuffer-style root buffer** (`*signal*`). `n`/`p` to move, `RET` to open a conversation.
- **Discoverability via which-key.** The keymap is the menu — `C-c s` shows available bindings.
- **Evil compatibility out of the box.** When evil is active, provide modal bindings for searching, creating conversations, sending, etc.
- **End-to-end encryption indicator** (`⌁ E2E`) is always visible in the modeline.
## File Naming & Conventions
- Emacs Lisp source files live in the project root or a package directory and end in `.el`.
- Package prefix is `noise-` for all symbols, functions, and variables.
- Follow Emacs Lisp conventions: `defcustom` for user options, `defvar` for internal state, `defun` for commands.
- Use `;;;` as the comment starter for top-level headings, `;;` for inline comments.
## Key Dependencies
- `signal-cli` — external program, must be installed and running (`signal-cli daemon --receive-mode=manual`)
- `emacs-sqlite3-api` — Elisp package for SQLite (used for read/write on conversations, contacts, messages)
## UI Screens (from mockup)
1. **Chat list** (`*signal*`) — root buffer showing all conversations, unread counts, last message preview, timestamps
2. **Conversation buffer** (`*signal:<name>*`) — full message history with inline compose prompt, day separators, delivery receipts
3. **Minibuffer switcher** — fuzzy narrowing completion over all conversations, showing name, type (direct/group), unread count, last activity
4. **Which-key panel** — discoverable keybindings menu triggered by prefix keys
## Development Approach
- Build iteratively — get the JSON-RPC bridge working first, then the SQLite schema, then the buffers.
- Test with a real `signal-cli` daemon running locally. Verify registration/account linking before anything else.
- 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.
+27
View File
@@ -0,0 +1,27 @@
* Noise
Noise is a Signal Messenger client in Elisp for Emacs powered by https://github.com/AsamK/signal-cli
Its ultimate goal is to provide a fast and efficient user interfae for Signal chats.
** Tech stack
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.
** Dependencies
For SQLite access we use this library - https://github.com/pekingduck/emacs-sqlite3-api
We communicate with signal-cli via JSON-RPC over HTTP on localhost port 9128 by default but this can be configured by specifying =noise-signal-cli-address=
** User Experience
We follow Emacs conventions for keybindings with the addition of evil support out of the box. When evil compatibility is enabled then we provide a modal interface for searching conversations/contacts, creating conversations, sending messages, etc.
** User Interface
See the file signal-emacs-ui.html for user interface mockups we're targetting.
+126
View File
@@ -0,0 +1,126 @@
# Noise Phase 1-3 Implementation Plan
**Goal:** Build a minimal Emacs Signal client with JSON-RPC connectivity to signal-cli, SQLite persistence for contacts/conversations/messages, and a chat-list buffer with minibuffer conversation switcher.
**Architecture:** Five Elisp files with ERT tests. Each file is independently testable. No external Emacs packages beyond built-ins (url, json, sqlite, ert, cl-lib).
**Tech Stack:** Emacs Lisp 27+, signal-cli JSON-RPC over HTTP (localhost:9128)
---
### File Structure
```
noise.el - defgroup, defcustom, noise-mode, noise-check-connection
noise-rpc.el - JSON-RPC HTTP client: noise-rpc-call, noise-rpc-check
noise-db.el - SQLite schema (contacts, conversations, messages, members), CRUD
noise-chat-list.el - *signal* buffer, ibuffer-style rendering
noise-switcher.el - minibuffer completing-read fuzzy switcher
test/
noise-rpc-test.el
noise-db-test.el
noise-chat-list-test.el
noise-switcher-test.el
```
---
### Task 1: noise.el — Package Skeleton
Create `noise.el` with:
- defgroup noise
- defcustom noise-signal-cli-address (default "http://localhost:9128")
- noise-mode minor mode (placeholder, message on enable)
- Byte-compile check
- Commit: "feat: add noise.el package skeleton with defcustom and minor mode"
---
### Task 2: noise-rpc.el — JSON-RPC Client
Create `test/noise-rpc-test.el` first (5 tests):
- noise-rpc--build-request-no-params: valid JSON-RPC 2.0 without params
- noise-rpc--build-request-with-params: params included as alist
- noise-rpc--parse-response-success: parses result field
- noise-rpc--parse-response-error: signals noise-rpc-error on error
- noise-rpc--parse-response-malformed: signals noise-rpc-error on bad JSON
Create `noise-rpc.el`:
- define-error noise-rpc-error
- noise-rpc--build-request: JSON-RPC 2.0 request with plist-to-alist conversion
- noise-rpc--plist-to-alist: convert :key val plist to alist
- noise-rpc-call: POST to {address}/api/v1/rpc, parse response, signal error on connection failure
- noise-rpc--parse-response: parse JSON, extract result or signal noise-rpc-error
- noise-rpc-check: call getVersion method
Update noise.el: add (require 'noise-rpc), noise-check-connection command
Test: 5 tests pass, byte-compiles. Commit.
---
### Task 3: noise-db.el — SQLite Database Layer
Create `test/noise-db-test.el` first (9 tests):
- noise-db-init-creates-file: file exists + connection active
- noise-db-tables-exist: contacts, conversations, messages, conversation_members tables created
- noise-db-upsert-and-get-contact: insert/retrieve contact
- noise-db-upsert-contact-update: upsert updates existing
- noise-db-get-all-contacts: fetch all contacts
- noise-db-upsert-and-get-conversation: insert conversation with members
- noise-db-get-all-conversations-ordered: ordered by last_message_time DESC
- noise-db-insert-message: insert message, updates conversation preview
- noise-db-search-contacts: fuzzy search by name
Create `noise-db.el`:
- defcustom noise-db-file (default ~/.emacs.d/noise/noise.db)
- Schema tables: contacts, conversations, messages, conversation_members (CREATE TABLE IF NOT EXISTS)
- Indexes: idx_messages_conversation_time, idx_conversations_time
- noise-db-init, noise-db--open, noise-db--close, noise-db--exec, noise-db--query
- CRUD: noise-db-upsert-contact, noise-db-get-contact, noise-db-get-all-contacts
- CRUD: noise-db-upsert-conversation, noise-db-get-conversation, noise-db-get-all-conversations
- CRUD: noise-db-insert-message, noise-db-get-messages
- Queries: noise-db-get-conversation-members, noise-db-conversations-for-contact
- Search: noise-db-search-contacts, noise-db-search-conversations
Test: 9 tests pass, byte-compiles. Commit.
---
### Task 4: noise-chat-list.el — Chat List Buffer
Create `test/noise-chat-list-test.el` first (5 tests):
- noise-chat-list--format-time-today: returns HH:MM pattern
- noise-chat-list--format-time-yesterday: returns Ddd pattern
- noise-chat-list--format-time-older: returns Mon DD pattern
- noise-chat-list--format-row-with-unread: includes fringe char and name
- noise-chat-list--format-row-no-unread: excludes fringe char
Create `noise-chat-list.el`:
- noise-chat-list-mode derived from special-mode, read-only, header-line "U Name Last message When"
- Keymap: n (next-line), p (previous-line), RET (noise-chat-list-open), m (mark read), g (refresh), / (filter), q (quit-window)
- noise-chat-list entry point: init db, switch to *signal* buffer, enter mode, render
- noise-chat-list--render: iterate conversations from db, insert formatted rows, set mode-line with chat/unread counts
- noise-chat-list--format-row: format with: fringe glyph (● or space), unread count (right-aligned 3), bold name (18 chars truncated), sender-prefixed preview with "me:" for own messages (36 chars truncated), relative timestamp
- noise-chat-list--format-time: today HH:MM, this week Ddd, older Mon DD using format-time-string
- noise-chat-list-open: (placeholder) lookup conversation at point, message the name
- noise-chat-list-mark-read/refresh/filter: placeholder messages
Update noise.el: (require 'noise-chat-list), noise-mode enables calls (noise-chat-list)
Test: 5 tests pass, byte-compiles. Commit.
---
### Task 5: noise-switcher.el — Minibuffer Switcher
Create `test/noise-switcher-test.el` first (5 tests):
- noise-switcher--collection-includes-conversations: collection returns conversation entries
- noise-switcher--collection-includes-contacts-without-conversations: contacts with no conversation shown
- noise-switcher--format-candidate-with-unread: includes unread count annotation
- noise-switcher--format-candidate-no-unread: annotation omits unread
- noise-switcher--filter-matches-fuzzy: candidates filtered by substring match
Create `noise-switcher.el`:
- noise-switcher command: completes over conversations + contacts-without-conversations
- noise-switcher--collection: returns list of (display . id) cons cells for completing-read
- noise-switcher--format-candidate: "NAME TYPE · UNREAD · TIME" following mockup
- noise-switcher--filter: substring fuzzy match on input
- On selection: conversation exists → message name; contact only → create conversation stub, message contact name
- Annotation property on each candidate for completing-read display
Test: 5 tests pass, byte-compiles. Commit.
+208
View File
@@ -0,0 +1,208 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>signal.el — UI screens</title>
<style>
:root{
--page:#15171c;
--bg:#282c34;
--bg-dim:#21252b;
--fg:#bbc2cf;
--fg-dim:#5b6268;
--fg-faint:#3f4450;
--modeline-bg:#1c1f24;
--modeline-fg:#9da5b4;
--modeline-inactive:#5b6268;
--hl-line:#2c323c;
--blue:#3a76f0; /* Signal blue: prompt, matches, selection */
--blue-soft:#6f9bf5;
--green:#98be65; /* delivered, e2e */
--yellow:#ecbe7b; /* unread */
--red:#ff6c6b;
--mono:"Iosevka","JetBrains Mono","Fira Code",ui-monospace,"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--page);
color:var(--fg);
font-family:var(--mono);
font-size:14px;
line-height:1.5;
padding:56px 20px 96px;
}
.wrap{max-width:900px;margin:0 auto}
header.page{margin-bottom:56px}
header.page h1{
font-size:15px;font-weight:600;letter-spacing:.04em;color:var(--fg);
}
header.page h1 .ext{color:var(--blue-soft)}
header.page p{
margin-top:6px;color:var(--fg-dim);font-size:13px;max-width:62ch;
}
kbd{
color:var(--blue-soft);
font-family:inherit;font-size:inherit;
}
section.screen{margin-bottom:72px}
.caption{
display:flex;align-items:baseline;gap:14px;margin-bottom:12px;
}
.caption h2{
font-size:12px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;
color:var(--modeline-fg);
}
.caption span{font-size:12px;color:var(--fg-dim)}
/* ——— the Emacs frame ——— */
.frame{
background:var(--bg);
border:1px solid #0c0d10;
border-radius:6px;
overflow:hidden;
box-shadow:0 14px 40px rgba(0,0,0,.45);
}
.buffer{
padding:10px 0 14px;
min-height:330px;
white-space:pre;
overflow-x:auto;
tab-size:4;
}
.ln{padding:0 16px 0 22px;position:relative}
.fringe{position:absolute;left:8px;color:var(--yellow)}
.hl{background:var(--hl-line)}
.header-line{
background:var(--bg-dim);
color:var(--fg-dim);
padding:2px 16px 2px 22px;
border-bottom:1px solid #1a1d22;
white-space:pre;overflow-x:auto;
}
.dim{color:var(--fg-dim)}
.faint{color:var(--fg-faint)}
.name{color:var(--fg);font-weight:600}
.unread{color:var(--yellow);font-weight:600}
.blue{color:var(--blue-soft)}
.green{color:var(--green)}
.me{color:var(--blue-soft);font-weight:600}
.sep{color:var(--fg-faint)}
.modeline{
background:var(--modeline-bg);
color:var(--modeline-fg);
padding:2px 16px;
white-space:pre;overflow-x:auto;
border-top:1px solid #14161a;
}
.modeline .mode{color:var(--blue-soft)}
.modeline .e2e{color:var(--green)}
.echo{
background:var(--bg);
color:var(--fg-dim);
padding:3px 16px 5px;
white-space:pre;overflow-x:auto;
}
.echo kbd{color:var(--blue-soft)}
.cursor{
display:inline-block;width:.62em;height:1.25em;
background:var(--fg);vertical-align:text-bottom;
animation:blink 1.1s steps(1) infinite;
}
@media (prefers-reduced-motion:reduce){.cursor{animation:none}}
@keyframes blink{50%{opacity:0}}
/* minibuffer completion (vertico-style) */
.dimmed{opacity:.38}
.mini{
background:var(--bg);
border-top:1px solid #14161a;
padding:4px 0 6px;
white-space:pre;overflow-x:auto;
}
.cand{padding:0 16px 0 22px}
.cand.sel{background:var(--hl-line)}
.cand .m{color:var(--blue-soft);font-weight:700}
.cand .ann{color:var(--fg-faint)}
.prompt{padding:2px 16px 0 22px}
.prompt .p{color:var(--blue-soft);font-weight:600}
.prompt .count{color:var(--fg-dim)}
/* which-key panel */
.whichkey{
background:var(--bg-dim);
border-top:1px solid #14161a;
padding:8px 0 10px;
white-space:pre;overflow-x:auto;
}
.wk{padding:0 16px 0 22px}
.wk kbd{color:var(--blue-soft);font-weight:600}
.wk .arr{color:var(--fg-faint)}
@media (max-width:560px){
body{font-size:12px;padding:32px 8px 64px}
.buffer{min-height:240px}
}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1>signal<span class="ext">.el</span> — screens</h1>
<p>Four frames. No mouse targets, no chrome. Every action is a binding; conversation switching is a minibuffer completion, never a sidebar.</p>
</header>
<!-- ============ SCREEN 1: chat list ============ -->
<section class="screen">
<div class="caption"><h2>Chat list</h2><span>*signal* — ibuffer-style root buffer, <kbd>n</kbd>/<kbd>p</kbd> to move, <kbd>RET</kbd> to open</span></div>
<div class="frame">
<div class="header-line"> U Name Last message When</div>
<div class="buffer"><div class="ln"><span class="fringe"></span><span class="unread"> 3</span> <span class="name">Family</span> <span class="dim">Dad: see you sunday then</span> <span class="faint">09:41</span></div><div class="ln hl"><span class="fringe"></span><span class="unread"> 1</span> <span class="name">Sarah</span> <span class="dim">photo: IMG_2041.jpg</span> <span class="faint">09:12</span></div><div class="ln"> <span class="name">Mark Okafor</span> <span class="dim">me: merged, cutting the release now</span> <span class="faint">08:55</span></div><div class="ln"> <span class="name">cycling crew</span> <span class="dim">Priya: saturday 7am, usual spot?</span> <span class="faint">Mon</span></div><div class="ln"> <span class="name">Hawat alerts</span> <span class="dim">backup completed: zendo (4.2 GiB)</span> <span class="faint">Mon</span></div><div class="ln"> <span class="name">Elena V.</span> <span class="dim">thanks! that fixed it</span> <span class="faint">Sun</span></div><div class="ln"> <span class="name">school parents</span> <span class="dim">Reminder: early pickup friday</span> <span class="faint">Sun</span></div><div class="ln"> <span class="name">Tom</span> <span class="dim">me: voice message (0:42)</span> <span class="faint">Jun 5</span></div><div class="ln"> <span class="name">Note to Self</span> <span class="dim">uv pip compile --universal</span> <span class="faint">Jun 4</span></div></div>
<div class="modeline"> <span class="e2e"></span> <b>*signal*</b> 9 chats · 4 unread <span class="mode">(Signal Chats)</span> ─ U:%%- ─ Top ────────────</div>
<div class="echo"><kbd>C-x b</kbd> switch chat <kbd>RET</kbd> open <kbd>m</kbd> mark read <kbd>g</kbd> refresh <kbd>/</kbd> filter</div>
</div>
</section>
<!-- ============ SCREEN 2: conversation buffer ============ -->
<section class="screen">
<div class="caption"><h2>Conversation</h2><span>*signal:Sarah* — compose inline at the prompt, <kbd>RET</kbd> sends, <kbd>M-RET</kbd> newline</span></div>
<div class="frame">
<div class="buffer"><div class="ln"><span class="sep">───────────────────────────── Tue, Jun 9 ─────────────────────────────</span></div>
<div class="ln"><span class="faint">[08:58]</span> <span class="name">Sarah</span><span class="dim">&gt;</span> are you taking the girls to the pool after school?</div><div class="ln"><span class="faint">[09:02]</span> <span class="me">me</span><span class="dim">&gt;</span> yeah, leaving around 4. want me to grab dinner on the way back? <span class="green">✓✓</span></div><div class="ln"><span class="faint">[09:03]</span> <span class="name">Sarah</span><span class="dim">&gt;</span> yes please. thai?</div><div class="ln"><span class="faint">[09:10]</span> <span class="me">me</span><span class="dim">&gt;</span> done. ordering the usual <span class="green">✓✓</span></div><div class="ln"><span class="faint">[09:12]</span> <span class="name">Sarah</span><span class="dim">&gt;</span> <span class="blue">[photo: IMG_2041.jpg — RET to view]</span></div><div class="ln"> </div><div class="ln"><span class="sep">───────────────────────────────────────────────────────────────────────</span></div><div class="ln"><span class="blue">&gt;</span> looks great. see you toni<span class="cursor"></span></div></div>
<div class="modeline"> <span class="e2e">⌁ E2E</span> <b>*signal:Sarah*</b> Sarah is typing… <span class="mode">(Signal Chat Fill)</span> ─ U:%%- ─ Bot ───</div>
<div class="echo"><kbd>RET</kbd> send <kbd>C-c C-a</kbd> attach <kbd>C-c C-r</kbd> reply-to <kbd>C-c C-e</kbd> emoji react <kbd>M-p</kbd> edit last</div>
</div>
</section>
<!-- ============ SCREEN 3: minibuffer switcher ============ -->
<section class="screen">
<div class="caption"><h2>Minibuffer switcher</h2><span>the core interaction — <kbd>C-x b</kbd> from anywhere, fuzzy narrowing, <kbd>C-n</kbd>/<kbd>C-p</kbd>, <kbd>RET</kbd></span></div>
<div class="frame">
<div class="dimmed">
<div class="buffer" style="min-height:200px"><div class="ln"><span class="faint">[09:10]</span> <span class="me">me</span><span class="dim">&gt;</span> done. ordering the usual <span class="green">✓✓</span></div><div class="ln"><span class="faint">[09:12]</span> <span class="name">Sarah</span><span class="dim">&gt;</span> <span class="blue">[photo: IMG_2041.jpg — RET to view]</span></div><div class="ln"> </div><div class="ln"><span class="sep">───────────────────────────────────────────────────────────────────────</span></div><div class="ln"><span class="blue">&gt;</span> </div></div>
<div class="modeline"> <span class="e2e">⌁ E2E</span> <b>*signal:Sarah*</b> <span class="mode">(Signal Chat Fill)</span> ─ U:%%- ─ Bot ───</div>
</div>
<div class="mini"><div class="cand sel"><span class="name"><span class="m">s</span><span class="m">a</span><span class="m">r</span>ah</span> <span class="ann">direct · 1 unread · 09:12</span></div><div class="cand">school parent<span class="m">s</span> <span class="m">a</span>nd ca<span class="m">r</span>pool <span class="ann">group · Sun</span></div><div class="cand"><span class="m">S</span>amir D<span class="m">a</span><span class="m">r</span>wish <span class="ann">direct · May 30</span></div><div class="prompt"><span class="p">Switch to chat</span> <span class="count">(3/47)</span><span class="p">:</span> sar<span class="cursor"></span></div></div>
</div>
</section>
<!-- ============ SCREEN 4: which-key ============ -->
<section class="screen">
<div class="caption"><h2>Discoverability</h2><span>which-key panel after <kbd>C-c s</kbd> — the keymap is the menu</span></div>
<div class="frame">
<div class="dimmed">
<div class="buffer" style="min-height:170px"><div class="ln"><span class="faint">[09:03]</span> <span class="name">Sarah</span><span class="dim">&gt;</span> yes please. thai?</div><div class="ln"><span class="faint">[09:10]</span> <span class="me">me</span><span class="dim">&gt;</span> done. ordering the usual <span class="green">✓✓</span></div><div class="ln"> </div><div class="ln"><span class="sep">───────────────────────────────────────────────────────────────────────</span></div><div class="ln"><span class="blue">&gt;</span> </div></div>
<div class="modeline"> <span class="e2e">⌁ E2E</span> <b>*signal:Sarah*</b> <span class="mode">(Signal Chat Fill)</span> ─ U:%%- ─ Bot ───</div>
</div>
<div class="whichkey"><div class="wk"><kbd>C-c s</kbd></div><div class="wk"> </div><div class="wk"><kbd>a</kbd> <span class="arr"></span> attach file <kbd>m</kbd> <span class="arr"></span> mute chat <kbd>v</kbd> <span class="arr"></span> verify safety number</div><div class="wk"><kbd>d</kbd> <span class="arr"></span> disappearing msgs <kbd>n</kbd> <span class="arr"></span> new chat <kbd>x</kbd> <span class="arr"></span> delete message</div><div class="wk"><kbd>g</kbd> <span class="arr"></span> new group <kbd>p</kbd> <span class="arr"></span> pin chat <kbd>/</kbd> <span class="arr"></span> search in chat</div><div class="wk"><kbd>i</kbd> <span class="arr"></span> chat info <kbd>r</kbd> <span class="arr"></span> mark all read <kbd>?</kbd> <span class="arr"></span> describe bindings</div></div>
</div>
</section>
</div>
</body>
</html>