Two rules, picked to feel natural:
1. Conversation just opened (peer_primary changed) → always scroll to
bottom. You expect to see the latest exchange first.
2. New message landed in the current conversation → scroll to bottom
IF you were already within 120px of the bottom. If you're scrolled
up reading older history, the auto-scroll doesn't yank you back
down. (Slack / Telegram / iMessage all do this; getting yanked out
of history when you're searching for something is infuriating.)
Implementation: a single $effect tracks activeConv.messages.length +
activeConv.peer_primary. Compares against two cursor vars (prevPrimary,
prevMessageCount) to distinguish "opened a new conversation" from
"new message in current one". queueMicrotask after the DOM updates so
scrollHeight reflects the just-rendered message.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously the SSE stream only ran while the Messages component was
mounted. Navigate to Dashboard or Claims and new messages just piled
up server-side until you came back. Now the stream runs for the whole
session, drives an unread badge in the nav, and (with permission)
fires a system notification when a message lands while you're in
another tab.
inboxService (lib/inbox-service.svelte.ts):
• Singleton Svelte 5 $state class. session.setUnlocked() starts it,
session.lock() stops it. Holds the SSE stream + the 30s heartbeat
poll for the entire session lifetime.
• Reactive state read by anyone: status (off/connecting/live/
reconnecting), unreadCount (since last visit to /messages), and
lastError (surfaced in the Messages footer).
• onMessage(fn) lets components subscribe to repaint when ingest
succeeds — Messages page uses this instead of owning its own
stream.
• #fireSystemNotification fires Notification API on inbound when
Notification.permission === "granted" AND document.visibilityState
!== "visible". Silent while you're actively looking at the tab.
Uses tag="kez-chat-inbox" so multiple notifications collapse.
Messages.svelte:
• Stripped its own stream/poll. Now just subscribes to inboxService.
onMount also calls markAllRead() — landing on /messages = you've
seen the new stuff.
• Footer status indicator reads from inboxService instead of local
state.
App.svelte nav:
• Messages link grows a red unread-count badge (1, 2, …, 9+) when
inboxService.unreadCount > 0 and the user isn't already on the
Messages route.
Dashboard:
• New "Notifications" section between Quick unlock and Backup with
the standard 3-state UX: granted (green confirm), denied (amber
"fix in site settings"), default (button to request).
• Helpers in inbox-service.ts wrap the Notification API so non-
supporting browsers (older Safari, Firefox in some configs) get
graceful "not supported" copy.
Caveat (for v0.3): notifications only fire while the tab is open in
SOME state (background-but-not-closed). Closing the tab kills the
SSE stream so nothing arrives at the page to notify about. True
background push (Web Push API + VAPID + server-side push) is a
separate piece of work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Compose bar gets a 😀 button. Click → emoji-picker-element (the
canonical web component, ~140 KB) lazy-loads on first open and stays
cached after. Pick → inserts at cursor position, focus returns to
input. Click-outside closes the popover.
Bonus: emoji-only messages render iMessage-style — no bubble, larger
text. Uses Intl.Segmenter to count grapheme clusters so 👨👩👧 reads
as 1 not 5 code points.
• 1 emoji → text-5xl
• 2-3 → text-4xl
• 4-6 → text-3xl
• 7+ or any letters/digits/punct → normal bubble
Bundle: emoji picker chunked separately via dynamic import (38 KB
gzipped). Initial Messages-page JS only nudged ~159→162 KB.
Native emoji input (macOS ⌃⌘Space, iOS keyboard, Android long-press)
still works — the picker is just for discoverability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chat was polling every 5s, which felt sluggish with two users online.
Switched to Server-Sent Events for push delivery. Polling now runs as
a 30s heartbeat just to catch anything missed during reconnect windows.
NATS is still bundled in docker-compose but no Rust code talks to it
yet — that lands in v0.2 for cross-instance fanout. The migration is
"swap the in-process broker for nats.publish/subscribe against
kez.chat.inbox.<handle>"; SSE subscribers don't notice.
Server (kez-chat-server):
• New broker module: per-recipient tokio::sync::broadcast channels,
in-process pub/sub. 64-slot buffer per channel; lagging subscribers
drop on the floor and resync via the polling heartbeat. 4 unit
tests cover subscribe/publish, multi-subscriber fanout, per-handle
isolation, no-op on no-subscribers.
• POST /v1/messages now publishes to broker after persisting → any
open SSE stream for the recipient gets the envelope immediately.
• New GET /v1/inbox/:handle/stream — SSE endpoint, ?auth=<ts>:<sig>
query param (EventSource can't set headers). Signed message is
distinct from the polling header ("GET\n/v1/inbox/<h>/stream\n<ts>"
vs "GET\n/v1/inbox/<h>\nsince=<n>\n<ts>") so a captured poll sig
can't be replayed as a stream sig and vice versa.
• 15s SSE keep-alive ping so Cloudflare/NAT/load balancers don't
drop idle connections.
• 3 new stream-auth unit tests, including the cross-endpoint replay
rejection. 19 unit + 20 integration tests all green.
• New deps: tokio-stream (sync feature for BroadcastStream),
futures (for the Stream trait the Sse handler returns).
Browser (kez-chat/web):
• streamInbox() in lib/messages.ts: long-lived EventSource,
auto-reconnects on error with fresh auth (tears down on `error`,
re-opens after 3s — EventSource's native retry uses the stale URL).
Exposes onMessage + onStatus callbacks.
• Messages.svelte: opens SSE on mount, decrypts pushed envelopes
inline via the new shared ingest() helper. Polling dropped from
5s → 30s heartbeat.
• Sidebar footer shows live status:
● live (green)
● reconnecting… (amber)
○ connecting… (gray)
Verified live: /v1/inbox/<registered>/stream?auth=bad returns 401,
no-auth returns 400. Asset index-C1ogRtUG.js serving.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous Messages page assumed you knew what a "handle" was and showed
truncated ed25519 hex everywhere. Reframed it so a newcomer can figure
out what to do without having read the spec.
Server:
• GET /v1/by-primary/:primary — reverse lookup, ed25519:<hex> →
handle record. Used by the SPA to render @alice instead of the
truncated hex when an inbound envelope arrives from a peer we
haven't chatted with yet. 3 new integration tests cover round-trip,
NotFound, BadRequest-on-garbage.
Web — sidebar:
• "Your KEZ" panel at top — handle@server with a copy button. The
whole point: someone needs your KEZ to message you, so make
sharing it one click.
• "Start a chat" input accepts `alice` or `alice@kez.lat`. Resolves
via /v1/u/:handle before adding — explicit error if unregistered,
friendly "that's you" guard for self.
• Conversation rows show resolved handles, not hex blobs.
Web — empty state:
• 🔒 + "End-to-end encrypted chat" headline + plain-English paragraph
explaining that even the server can't read messages.
• Concrete starter hint: "open kez.lat in a second browser, create
another account, message yourself between the two."
Conversation cache redesign:
• Now keyed by peer_primary (canonical KEZ identity) with peer_handle
as display metadata. Resolves the same-person-as-two-threads bug
you'd hit when you sent to "alice" then alice replied (her primary
didn't match the "alice" key).
• IDB key bumped to :v2 — old shape abandoned (was placeholder data).
• On inbound, ensureConversation refreshes the cached handle if we
just resolved a fresher one.
Followups still queued: cross-server lookups, NATS push, group chats,
"find someone by their published claim" (paste their gist / dns proof
to discover their handle).
Live at https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Time to actually chat. Server is a dumb relay storing opaque envelopes;
recipients decrypt client-side. Everything below is end-to-end encrypted,
the server can't read anything it stores.
Server (kez-chat-server):
• New messages table (seq autoinc, recipient_handle, envelope blob,
created_at). Indexed by (recipient, seq) for cursor paging.
• POST /v1/messages
body: { to: handle, envelope: <opaque JSON> }
validates recipient exists; rejects > 256 KB envelopes.
• GET /v1/inbox/:handle?since=<seq>&limit=<n>
auth: X-KEZ-Auth: <unix_ts>:<sig_hex>
sig = ed25519(handle's primary,
"GET\n/v1/inbox/<handle>\nsince=<n>\n<ts>")
60s clock-skew tolerance; signed message includes cursor so a
captured header can't page through history.
• New ApiError::Unauthorized → 401.
• kez-core: verify_ed25519_hex is now pub so the auth handler can
use it for arbitrary-message verification (outside JCS envelopes).
Crypto (browser):
• ed25519 seed → x25519 priv via Montgomery conversion
(ed25519.utils.toMontgomerySecret).
• ed25519 pubkey → x25519 pubkey for the recipient (toMontgomery).
• ECDH → 32-byte shared secret → HKDF-SHA256(salt=nonce, info=
"kez-chat-msg-v1") → AES-256-GCM key.
• Per-message random 12-byte nonce; each message gets a unique AES key.
• Sender signs envelope-minus-sig with their ed25519 primary so the
recipient can confirm the sender authored the ciphertext + binding.
SPA UI:
• /messages route, two-pane layout (sidebar conversations, thread view,
compose box).
• 5-second poller against /v1/inbox using the global cursor; new
messages get decrypted + appended to the right thread.
• Local IDB cache (lib/conversations-store.ts) so decrypted history
survives reloads. Dedupes by seq+direction.
• Page-specific max-w-6xl so the two-pane layout has room.
Tests:
• 6 new unit tests in messages.rs covering auth header verification
(stale ts, wrong handle, wrong cursor, malformed).
• 4 new integration tests in tests/http.rs: full send + inbox round-
trip, wrong-signer rejected, missing header rejected, unknown
recipient → 404.
• All 17 chat-server tests pass.
Followups (deferred):
• NATS WebSocket push (live messages without 5s poll lag).
• Group chats with proper member-key rotation.
• Reverse handle resolution (/v1/by-primary) so the UI can show
"@alice" instead of the truncated ed25519 hex.
• At-rest encryption for the IDB conversations cache.
• Sender spam mitigation on POST /v1/messages.
Live at https://kez.lat — try /messages with two browsers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>