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:
parent
5cb46e2aa1
commit
7e9dc0773a
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user