feat(kez-chat): Messages UX rebuild — Keybase-style, friendly handles, explainer

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>
This commit is contained in:
Jason Tudisco 2026-05-25 22:12:46 -06:00
parent 5cb46e2aa1
commit 7e9dc0773a
5 changed files with 308 additions and 96 deletions

View File

@ -38,6 +38,7 @@ pub fn router(state: AppState) -> axum::Router {
let mut router = axum::Router::new()
.route("/v1/healthz", get(healthz))
.route("/v1/u/:handle", get(lookup))
.route("/v1/by-primary/:primary", get(lookup_by_primary))
.route("/v1/register", post(register))
.route("/v1/messages", post(crate::messages::send_message))
.route("/v1/inbox/:handle", get(crate::messages::inbox))
@ -92,6 +93,23 @@ async fn lookup(
Ok(Json(handle_response(&state.config, &record)))
}
/// Reverse lookup: `ed25519:<hex>` → handle record. The Messages UI calls
/// this when an inbound envelope's `from` field is a primary it hasn't
/// seen before, so it can render "@alice" instead of "ed25519:abc…".
async fn lookup_by_primary(
State(state): State<AppState>,
Path(primary): Path<String>,
) -> Result<Json<HandleResponse>, ApiError> {
let parsed = kez_core::Identity::parse(&primary)
.map_err(|e| ApiError::BadRequest(format!("invalid primary: {e}")))?;
let record = state
.store
.lookup_by_primary(&parsed)
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(handle_response(&state.config, &record)))
}
fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
let scheme = record.primary.scheme();
let id = record.primary.value();

View File

@ -94,6 +94,40 @@ async fn healthz_returns_ok() {
assert_eq!(body["server"], "kez.test");
}
#[tokio::test]
async fn by_primary_round_trips() {
let server = spawn_server().await;
let secret = register_user(&server.base, "tudisco").await;
let primary = secret.identity().unwrap().to_string();
let resp = reqwest::get(format!("{}/v1/by-primary/{primary}", server.base))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["handle"], "tudisco");
assert_eq!(body["primary"], primary);
}
#[tokio::test]
async fn by_primary_unknown_404() {
let server = spawn_server().await;
let unregistered = Ed25519Secret::generate().identity().unwrap().to_string();
let resp = reqwest::get(format!("{}/v1/by-primary/{unregistered}", server.base))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn by_primary_garbage_400() {
let server = spawn_server().await;
let resp = reqwest::get(format!("{}/v1/by-primary/not-a-primary", server.base))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn unknown_handle_returns_404() {
let server = spawn_server().await;

View File

@ -68,6 +68,16 @@ export async function lookup(handle: string): Promise<HandleResponse> {
return unwrap(resp);
}
/**
* Reverse lookup: ed25519:<hex> primary handle record. Used by the
* Messages UI to render "@alice" instead of the truncated hex when an
* inbound envelope arrives from someone we haven't chatted with yet.
*/
export async function lookupByPrimary(primary: string): Promise<HandleResponse> {
const resp = await fetch(url(`/v1/by-primary/${encodeURIComponent(primary)}`));
return unwrap(resp);
}
export async function register(
signed: SignedRegistration,
): Promise<HandleResponse> {

View File

@ -1,37 +1,45 @@
// Local cache of decrypted messages, keyed by the other party's handle.
// IndexedDB so it survives reloads. Encrypted-at-rest is *not* attempted
// here — anyone with access to this browser profile already has the
// user's KEZ seed (which is the real secret). If we ever want at-rest
// encryption, encrypt the whole IDB store under a session-derived key.
// Local cache of decrypted message threads. Keyed by the *peer's primary*
// (the canonical KEZ identity — handles can in principle change, primaries
// can't), with the handle stored as display metadata.
//
// IndexedDB so it survives reloads. At-rest encryption deferred to v0.2:
// anyone with this browser profile already has the user's seed (the real
// secret), so encrypting the message log adds little practical security.
import { get, set } from "idb-keyval";
import type { Identity } from "./kez.js";
const KEY = "kez-chat:conversations";
// :v2 — schema reshaped to key by peer_primary (canonical identity) instead
// of an ad-hoc string. Old data under :conversations (no version) is
// abandoned rather than migrated — those messages were the truncated-hex
// placeholders anyway.
const KEY = "kez-chat:conversations:v2";
export interface ConversationMessage {
/** Server seq when received (own sends use Date.now() to keep ordering). */
/** Server seq for inbound, Date.now() for outbound. Only used for ordering + dedupe. */
seq: number;
direction: "in" | "out";
/** Display name = the other party (always the SAME for all messages in a thread). */
peer: string; // bare handle
/** Sender's primary, for verifying who signed. */
from: Identity;
body: string;
/** ISO timestamp (sender's clock for `out`, recipient receive-time for `in`). */
/** Sender's KEZ primary — for outbound this is my own primary. */
from: Identity;
/** ISO timestamp (sender's clock for `in`, click-time for `out`). */
ts: string;
}
export interface Conversation {
peer: string;
/** Canonical key — never changes for this peer. */
peer_primary: Identity;
/** Display handle, e.g. "alice@kez.lat". Empty string if we couldn't resolve. */
peer_handle: string;
messages: ConversationMessage[];
/** Highest server seq we've processed from the inbox stream. */
/** Max server-seq we've processed for any message from this peer. */
last_seq: number;
}
interface Store {
by_peer: Record<string, Conversation>;
/** Global highest seq across all peers — passed to /v1/inbox?since=. */
/** Keyed by peer_primary. */
by_peer: Record<Identity, Conversation>;
/** Highest server seq we've seen across all peers — passed as ?since=. */
global_cursor: number;
}
@ -52,64 +60,98 @@ export async function listConversations(): Promise<Conversation[]> {
});
}
export async function getConversation(peer: string): Promise<Conversation> {
export async function getConversation(
peer_primary: Identity,
): Promise<Conversation | null> {
const s = await read();
return s.by_peer[peer] ?? { peer, messages: [], last_seq: 0 };
return s.by_peer[peer_primary] ?? null;
}
export async function getGlobalCursor(): Promise<number> {
return (await read()).global_cursor;
}
/**
* Ensure a conversation row exists for this peer. Updates the display
* handle if a fresher value is passed in. Returns the conversation.
*/
export async function ensureConversation(
peer_primary: Identity,
peer_handle: string,
): Promise<Conversation> {
const s = await read();
const existing = s.by_peer[peer_primary];
if (existing) {
if (peer_handle && existing.peer_handle !== peer_handle) {
existing.peer_handle = peer_handle;
await write(s);
}
return existing;
}
const fresh: Conversation = {
peer_primary,
peer_handle,
messages: [],
last_seq: 0,
};
s.by_peer[peer_primary] = fresh;
await write(s);
return fresh;
}
export async function appendInbound(opts: {
peer_primary: Identity;
peer_handle: string;
seq: number;
peer: string;
from: Identity;
body: string;
ts: string;
}): Promise<void> {
const s = await read();
const conv = s.by_peer[opts.peer] ?? { peer: opts.peer, messages: [], last_seq: 0 };
// Dedupe by seq in case a poll overlaps.
const conv = s.by_peer[opts.peer_primary] ?? {
peer_primary: opts.peer_primary,
peer_handle: opts.peer_handle,
messages: [],
last_seq: 0,
};
// Refresh display name in case we just resolved it.
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
if (!conv.messages.find((m) => m.direction === "in" && m.seq === opts.seq)) {
conv.messages.push({
seq: opts.seq,
direction: "in",
peer: opts.peer,
from: opts.from,
body: opts.body,
from: opts.peer_primary,
ts: opts.ts,
});
}
conv.last_seq = Math.max(conv.last_seq, opts.seq);
s.by_peer[opts.peer] = conv;
s.by_peer[opts.peer_primary] = conv;
s.global_cursor = Math.max(s.global_cursor, opts.seq);
await write(s);
}
export async function appendOutbound(opts: {
peer: string;
peer_primary: Identity;
peer_handle: string;
from: Identity;
body: string;
}): Promise<void> {
const s = await read();
const conv = s.by_peer[opts.peer] ?? { peer: opts.peer, messages: [], last_seq: 0 };
const conv =
s.by_peer[opts.peer_primary] ?? {
peer_primary: opts.peer_primary,
peer_handle: opts.peer_handle,
messages: [],
last_seq: 0,
};
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
conv.messages.push({
seq: Date.now(),
direction: "out",
peer: opts.peer,
from: opts.from,
body: opts.body,
from: opts.from,
ts: new Date().toISOString(),
});
s.by_peer[opts.peer] = conv;
s.by_peer[opts.peer_primary] = conv;
await write(s);
}
export async function ensureConversation(peer: string): Promise<void> {
const s = await read();
if (!s.by_peer[peer]) {
s.by_peer[peer] = { peer, messages: [], last_seq: 0 };
await write(s);
}
}

View File

@ -3,6 +3,7 @@
import { push } from "svelte-spa-router";
import { session } from "../lib/store.svelte.js";
import { decrypt, pollInbox, sendMessage } from "../lib/messages.js";
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import {
appendInbound,
appendOutbound,
@ -12,16 +13,29 @@
listConversations,
type Conversation,
} from "../lib/conversations-store.js";
import type { Identity } from "../lib/kez.js";
let conversations = $state<Conversation[]>([]);
let active = $state<Conversation | null>(null);
let activePrimary = $state<Identity | null>(null);
let activeConv = $derived(
activePrimary
? conversations.find((c) => c.peer_primary === activePrimary) ?? null
: null,
);
let composeText = $state("");
let composing = $state(false);
let newPeerInput = $state("");
let pollError = $state<string | null>(null);
let lastPolledAt = $state<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
// "Start chat with" lookup state.
let newPeerInput = $state("");
let resolving = $state(false);
let resolveError = $state<string | null>(null);
// Toast for the share-link copy action.
let copied = $state(false);
const POLL_INTERVAL_MS = 5_000;
onMount(async () => {
@ -29,9 +43,8 @@
push("/unlock");
return;
}
await refreshConversations();
// Eager first poll so the UI doesn't sit empty waiting for the 5s tick.
await pollOnce();
await refresh();
await pollOnce(); // first tick immediately so the UI isn't blank
pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
});
@ -39,9 +52,8 @@
if (pollTimer) clearInterval(pollTimer);
});
async function refreshConversations() {
async function refresh() {
conversations = await listConversations();
if (active) active = await getConversation(active.peer);
}
async function pollOnce() {
@ -60,16 +72,23 @@
session.unlocked.handle,
session.unlocked.seed,
);
// Peer = sender's handle. We only have the sender's primary
// here; resolve to a handle by looking at the envelope.from.
// For v0.1 the SPA can't reverse primary→handle cheaply, so
// we display the local-part of the primary if we can't figure
// it out — TODO add /v1/by-primary endpoint and cache.
const peer = pt.from; // primary as the "peer key" for now
// Resolve the sender's handle for display. Cache miss → ask server.
let handle = "";
const existing = await getConversation(pt.from);
if (existing?.peer_handle) {
handle = existing.peer_handle;
} else {
try {
const record = await lookupByPrimary(pt.from);
handle = record.fqhn;
} catch {
// Unknown to this server (cross-server v0.2). Show truncated key.
}
}
await appendInbound({
peer_primary: pt.from,
peer_handle: handle,
seq: m.seq,
peer,
from: pt.from,
body: pt.body,
ts: pt.sent_at,
});
@ -77,7 +96,7 @@
console.error(`seq ${m.seq}: decrypt failed`, e);
}
}
if (messages.length > 0) await refreshConversations();
if (messages.length > 0) await refresh();
pollError = null;
lastPolledAt = new Date().toISOString();
} catch (e) {
@ -85,21 +104,37 @@
}
}
async function openConversation(peer: string) {
active = await getConversation(peer);
/** "Start chat with handle" — resolve, ensure conversation, open it. */
async function startConversation() {
if (!session.unlocked || !newPeerInput.trim()) return;
resolving = true;
resolveError = null;
try {
// Accept "alice" or "alice@kez.lat". v0.1 ignores the server part —
// cross-server lookup lands in v0.2 along with sigchain push.
const local = newPeerInput.trim().split("@")[0];
if (local === session.unlocked.handle) {
resolveError = "That's you — can't chat with yourself.";
return;
}
async function startNewConversation() {
const peer = newPeerInput.trim();
if (!peer) return;
await ensureConversation(peer);
const record = await lookup(local);
await ensureConversation(record.primary as Identity, record.fqhn);
activePrimary = record.primary as Identity;
newPeerInput = "";
await refreshConversations();
await openConversation(peer);
await refresh();
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
resolveError = `No one with handle "${newPeerInput}" is registered on this server.`;
} else {
resolveError = (e as Error).message;
}
} finally {
resolving = false;
}
}
async function send() {
if (!session.unlocked || !active || !composeText.trim()) return;
if (!session.unlocked || !activeConv || !composeText.trim()) return;
composing = true;
try {
const body = composeText;
@ -108,30 +143,35 @@
senderHandle: session.unlocked.handle,
senderSeed: session.unlocked.seed,
senderPrimary: session.unlocked.primary,
recipient: active.peer,
recipient: activeConv.peer_handle || activeConv.peer_primary,
body,
});
await appendOutbound({
peer: active.peer,
peer_primary: activeConv.peer_primary,
peer_handle: activeConv.peer_handle,
from: session.unlocked.primary,
body,
});
await refreshConversations();
await refresh();
} catch (e) {
alert(`Send failed: ${(e as Error).message}`);
composeText += "";
composeText = composeText; // no-op, keep linter happy
} finally {
composing = false;
}
}
/** Show short identifier — drop "ed25519:" prefix, keep first 12 chars. */
function shortPeer(peer: string): string {
if (peer.startsWith("ed25519:")) {
const hex = peer.slice("ed25519:".length);
function shortKey(primary: Identity): string {
if (primary.startsWith("ed25519:")) {
const hex = primary.slice("ed25519:".length);
return `${hex.slice(0, 8)}…${hex.slice(-4)}`;
}
return peer;
return primary;
}
/** What we show as the conversation title. */
function displayName(c: Conversation): string {
return c.peer_handle || shortKey(c.peer_primary);
}
function formatTime(iso: string): string {
@ -146,61 +186,102 @@
}
return d.toLocaleString([], { dateStyle: "short", timeStyle: "short" });
}
async function copyMyKez() {
if (!session.unlocked) return;
await navigator.clipboard.writeText(
`${session.unlocked.handle}@${session.unlocked.server}`,
);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<div class="flex h-[calc(100vh-8rem)] gap-4">
<!-- Sidebar: conversation list + new-conv form -->
<aside class="w-72 shrink-0 border border-gray-200 rounded-lg bg-white flex flex-col">
<!-- Sidebar -->
<aside class="w-80 shrink-0 border border-gray-200 rounded-lg bg-white flex flex-col">
<!-- Your KEZ (so users can share it to start chats) -->
<div class="p-3 border-b border-gray-200 bg-gray-50">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Your KEZ</p>
{#if session.unlocked}
<div class="flex items-center justify-between gap-2">
<code class="font-mono text-sm text-gray-900 truncate">
{session.unlocked.handle}@{session.unlocked.server}
</code>
<button
class="text-xs px-2 py-0.5 border border-gray-300 rounded text-gray-700 hover:bg-white shrink-0"
onclick={copyMyKez}
title="Copy your KEZ — share it with someone so they can message you"
>
{copied ? "✓" : "copy"}
</button>
</div>
{/if}
</div>
<!-- Start a new conversation -->
<div class="p-3 border-b border-gray-200">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
New conversation
Start a chat
</p>
<form
class="flex gap-2"
onsubmit={(e) => {
e.preventDefault();
startNewConversation();
startConversation();
}}
>
<input
type="text"
bind:value={newPeerInput}
placeholder="handle"
placeholder="alice or alice@kez.lat"
class="flex-1 min-w-0 px-2 py-1 text-sm border border-gray-300 rounded font-mono"
autocomplete="off"
disabled={resolving}
/>
<button
type="submit"
class="px-3 py-1 text-sm bg-gray-900 text-white rounded hover:bg-gray-700 disabled:opacity-50"
disabled={!newPeerInput.trim()}
disabled={!newPeerInput.trim() || resolving}
>
+
{resolving ? "…" : "+"}
</button>
</form>
{#if resolveError}
<p class="mt-2 text-xs text-red-700">{resolveError}</p>
{:else}
<p class="mt-2 text-xs text-gray-500">
Enter the KEZ of someone you want to message. Ask them to share it
with you (the button above does the same for yours).
</p>
{/if}
</div>
<!-- Conversation list -->
<div class="flex-1 overflow-y-auto">
{#if conversations.length === 0}
<p class="p-4 text-sm text-gray-500 italic">
No conversations yet. Type a handle above to start one.
No conversations yet.
</p>
{:else}
<ul>
{#each conversations as c (c.peer)}
{#each conversations as c (c.peer_primary)}
{@const last = c.messages[c.messages.length - 1]}
<li>
<button
class={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-50 ${active?.peer === c.peer ? "bg-gray-100" : ""}`}
onclick={() => openConversation(c.peer)}
class={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-50 ${activePrimary === c.peer_primary ? "bg-gray-100" : ""}`}
onclick={() => (activePrimary = c.peer_primary)}
>
<p class="font-mono text-sm font-semibold text-gray-900 truncate">
{shortPeer(c.peer)}
{displayName(c)}
</p>
{#if last}
<p class="text-xs text-gray-500 truncate">
{last.direction === "out" ? "→ " : "← "}{last.body}
</p>
<p class="text-xs text-gray-400 mt-0.5">{formatTime(last.ts)}</p>
{:else}
<p class="text-xs text-gray-400 italic">No messages yet</p>
{/if}
</button>
</li>
@ -209,6 +290,7 @@
{/if}
</div>
<!-- Footer status -->
<div class="p-2 border-t border-gray-200 text-xs text-gray-500">
{#if pollError}
<p class="text-red-700">{pollError}</p>
@ -218,20 +300,45 @@
</div>
</aside>
<!-- Main: thread view + compose -->
<!-- Main: thread or empty state -->
<main class="flex-1 min-w-0 border border-gray-200 rounded-lg bg-white flex flex-col">
{#if !active}
<div class="flex-1 flex items-center justify-center text-gray-400 text-sm">
Select or start a conversation
{#if !activeConv}
<div class="flex-1 flex items-center justify-center p-8">
<div class="max-w-md text-center space-y-4">
<div class="text-4xl">🔒</div>
<h2 class="text-lg font-semibold text-gray-900">
End-to-end encrypted chat
</h2>
<p class="text-sm text-gray-700">
Messages on kez-chat are encrypted between you and the recipient.
Even kez.lat (the server) can't read them — it just relays
opaque ciphertext.
</p>
<p class="text-sm text-gray-700">
To start, ask someone for their <strong>KEZ</strong> — their
<code class="bg-gray-100 px-1 rounded text-xs">handle@server</code>
(like an email address). Enter it on the left under
<strong>Start a chat</strong>. Or share yours with them using the
copy button.
</p>
<p class="text-xs text-gray-500 pt-2 border-t border-gray-200">
Don't know anyone yet? Open kez.lat in a second browser window,
create a different account, and message yourself between the two.
</p>
</div>
</div>
{:else}
<div class="p-3 border-b border-gray-200">
<p class="font-mono text-sm font-semibold text-gray-900">{shortPeer(active.peer)}</p>
<p class="text-xs text-gray-500 break-all">{active.peer}</p>
<p class="font-mono text-sm font-semibold text-gray-900">
{displayName(activeConv)}
</p>
<p class="text-xs text-gray-500 break-all font-mono">
{activeConv.peer_primary}
</p>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-2">
{#each active.messages as m (m.seq + ":" + m.direction)}
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
<div class={`max-w-md ${m.direction === "out" ? "ml-auto" : ""}`}>
<div
class={`px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words ${m.direction === "out" ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-900"}`}
@ -243,9 +350,10 @@
</p>
</div>
{/each}
{#if active.messages.length === 0}
{#if activeConv.messages.length === 0}
<p class="text-gray-400 text-sm italic text-center mt-8">
No messages yet. Say hi.
No messages yet. Say hi — it's encrypted before it leaves your
browser.
</p>
{/if}
</div>