design(kez-chat/web): new IA + nav shell, Chats/Identity/Settings (phase 1-2)

Kills the dashboard-as-home. Logged-in users now land on Chats like a
real messenger. Implements the design-team's IA recommendation.

Navigation:
  • Desktop: slim left icon rail (Chats / Identity / Settings) with the
    cyan key-cursor logo, active-state accent bar, unread badge.
  • Mobile: fixed bottom tab bar, same 3 destinations, safe-area inset.
  • Unauthenticated flow renders full-bleed with a wordmark header.
  • Legacy /dashboard + /messages redirect to /identity + /chats.

Chats (restyled Messages):
  • Two-pane on desktop; on mobile the list is full-screen and the
    thread pushes over it with a back chevron.
  • Conversation rows get identicon avatars, active accent bar, truncated
    previews. Thread header shows avatar + handle + key.
  • Bubbles: sent = cyan accent fill / near-black text / tail; received =
    dark bubble-recv + hairline border. Emoji-only boost retained.
  • Compose + "start a chat" inputs use the dark token styling; live/
    reconnecting status moved into the list header.
  • All functionality preserved: SSE, emoji picker, auto-scroll,
    notifications, unread badge.

Identity (new — was Dashboard's identity + claims):
  • Identity card: identicon avatar (ring), copyable handle@server chip,
    key fingerprint, registration date.
  • Proofs grouped verified / failed / pending with verified-green
    badges; Add proof + Manage links.

Settings (new — was Dashboard's remainder):
  • Security (app lock / biometric, reveal seed), Notifications (perm +
    test), Account (lock + build sha + source).

Dashboard.svelte is now unused (left in tree, removed from routes;
cleanup later). Claims/AddClaim + auth pages (Landing/Create/Restore/
Unlock) still use the old light classes — restyle is the next phase.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-27 21:50:10 -06:00
parent 60ff82b4a2
commit 40ebd63ed7
4 changed files with 552 additions and 191 deletions

View File

@ -4,14 +4,16 @@
import { hasStoredIdentity } from "./lib/identity-store.js";
import { session } from "./lib/store.svelte.js";
import { inboxService } from "./lib/inbox-service.svelte.js";
import Wordmark from "./lib/Wordmark.svelte";
import Landing from "./routes/Landing.svelte";
import CreateAccount from "./routes/CreateAccount.svelte";
import Restore from "./routes/Restore.svelte";
import Unlock from "./routes/Unlock.svelte";
import Dashboard from "./routes/Dashboard.svelte";
import Identity from "./routes/Identity.svelte";
import Claims from "./routes/Claims.svelte";
import AddClaim from "./routes/AddClaim.svelte";
import Settings from "./routes/Settings.svelte";
import Messages from "./routes/Messages.svelte";
const routes = {
@ -19,83 +21,109 @@
"/create": CreateAccount,
"/restore": Restore,
"/unlock": Unlock,
"/dashboard": Dashboard,
"/chats": Messages,
"/identity": Identity,
"/claims": Claims,
"/claims/add": AddClaim,
"/messages": Messages,
"/settings": Settings,
};
// First-load: if there's a stored identity but session is locked,
// bounce to /unlock. If no stored identity and on a protected page,
// bounce to /.
// App routes show the nav chrome; everything else (auth flow) is full-bleed.
const APP_ROUTES = ["/chats", "/identity", "/claims", "/claims/add", "/settings"];
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
onMount(async () => {
const stored = await hasStoredIdentity();
const protectedRoutes = ["/dashboard", "/claims", "/claims/add", "/messages"];
if (!stored && protectedRoutes.includes($location)) {
// Redirect legacy paths.
if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock");
if ($location === "/messages") return push(session.unlocked ? "/chats" : "/unlock");
if (!stored && APP_ROUTES.includes($location)) {
push("/");
} else if (stored && !session.unlocked && protectedRoutes.includes($location)) {
} else if (stored && !session.unlocked && APP_ROUTES.includes($location)) {
push("/unlock");
}
});
// Nav destinations — Chats / Identity / Settings.
const nav = [
{ path: "/chats", label: "Chats", badge: true },
{ path: "/identity", label: "Identity", badge: false },
{ path: "/settings", label: "Settings", badge: false },
];
function isActive(path: string): boolean {
if (path === "/identity") return $location === "/identity" || $location.startsWith("/claims");
return $location === path;
}
</script>
<header class="border-b border-gray-200 bg-white">
<div class="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="#/" class="text-lg font-semibold text-gray-900 no-underline">
🔑 kez-chat
</a>
{#if session.unlocked}
<nav class="flex items-center gap-4 text-sm">
<a href="#/dashboard" class="text-gray-700 hover:text-gray-900">Dashboard</a>
<a href="#/messages" class="text-gray-700 hover:text-gray-900 inline-flex items-center gap-1.5">
Messages
{#if inboxService.unreadCount > 0 && $location !== "/messages"}
<span
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-semibold bg-red-600 text-white rounded-full"
aria-label="{inboxService.unreadCount} unread"
>
{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}
</span>
{#if showNav}
<div class="flex h-dvh bg-bg text-text">
<!-- Desktop: left icon rail -->
<nav class="hidden sm:flex flex-col items-center w-16 shrink-0 border-r border-border bg-surface py-4 gap-2">
<a href="#/chats" class="mb-4 w-9 h-9 flex items-center justify-center" aria-label="kez home">
<img src="/kez-icon.svg" alt="kez" class="w-9 h-9" />
</a>
{#each nav as item}
{@const active = isActive(item.path)}
<a
href={`#${item.path}`}
class={`relative flex items-center justify-center w-11 h-11 rounded-lg transition-colors ${active ? "bg-elevated text-accent" : "text-text-secondary hover:bg-elevated hover:text-text"}`}
aria-label={item.label}
title={item.label}
>
{#if active}<span class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-6 rounded-r bg-accent"></span>{/if}
{#if item.path === "/chats"}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
{:else if item.path === "/identity"}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
{:else}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{/if}
{#if item.badge && inboxService.unreadCount > 0 && $location !== "/chats"}
<span class="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-accent text-accent-contrast rounded-full">{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}</span>
{/if}
</a>
<a href="#/claims" class="text-gray-700 hover:text-gray-900">Claims</a>
<span class="text-gray-400">|</span>
<span class="text-gray-500">{session.unlocked.handle}@{session.unlocked.server}</span>
<button
class="text-gray-500 hover:text-gray-900 underline"
onclick={() => { session.lock(); push("/unlock"); }}
>
Lock
</button>
</nav>
{/if}
</div>
</header>
{/each}
</nav>
<main class={`mx-auto px-6 py-8 ${$location === "/messages" ? "max-w-6xl" : "max-w-3xl"}`}>
<Router {routes} />
</main>
<!-- Content -->
<main class="flex-1 min-w-0 overflow-y-auto pb-16 sm:pb-0">
<div class="h-full">
<Router {routes} />
</div>
</main>
<footer class="border-t border-gray-200 bg-white mt-16">
<div class="max-w-3xl mx-auto px-6 py-4 text-xs text-gray-500 flex items-center gap-2 flex-wrap">
<span>kez-chat web</span>
<a
href={`https://git.ptud.biz/DukeInc/Kez/commit/${__BUILD_SHA__}`}
target="_blank"
rel="noopener"
class="font-mono px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 no-underline"
title={`built ${__BUILD_TIME__}`}
>
{__BUILD_SHA__}
</a>
<span>·</span>
<a
href="https://git.ptud.biz/DukeInc/Kez"
target="_blank"
rel="noopener"
class="text-gray-500 hover:text-gray-700"
>
source
</a>
<!-- Mobile: bottom tab bar -->
<nav class="sm:hidden fixed bottom-0 inset-x-0 z-40 h-16 border-t border-border bg-surface flex items-stretch" style="padding-bottom: env(safe-area-inset-bottom);">
{#each nav as item}
{@const active = isActive(item.path)}
<a href={`#${item.path}`} class={`relative flex-1 flex flex-col items-center justify-center gap-0.5 ${active ? "text-accent" : "text-text-secondary"}`} aria-label={item.label}>
{#if item.path === "/chats"}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
{:else if item.path === "/identity"}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
{:else}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{/if}
<span class="text-[10px] font-medium">{item.label}</span>
{#if item.badge && inboxService.unreadCount > 0 && $location !== "/chats"}
<span class="absolute top-2 right-[calc(50%-1.5rem)] min-w-4 h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-accent text-accent-contrast rounded-full">{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}</span>
{/if}
</a>
{/each}
</nav>
</div>
</footer>
{:else}
<!-- Unauthenticated / auth flow — full-bleed, centered. -->
<div class="min-h-dvh bg-bg text-text flex flex-col">
<header class="border-b border-border">
<div class="max-w-3xl mx-auto px-6 py-4">
<a href="#/" class="no-underline"><Wordmark size={20} /></a>
</div>
</header>
<main class="flex-1 max-w-3xl w-full mx-auto px-6 py-8">
<Router {routes} />
</main>
</div>
{/if}

View File

@ -0,0 +1,186 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { lookup, ApiError } from "../lib/api.js";
import { session } from "../lib/store.svelte.js";
import {
listClaims,
setVerifyResult,
type StoredClaim,
} from "../lib/claims-store.js";
import { verifyClaim } from "../lib/verify.js";
import Avatar from "../lib/Avatar.svelte";
let registryRecord = $state<any | null>(null);
let claims = $state<StoredClaim[]>([]);
let verifyingAll = $state(false);
let copied = $state(false);
const verified = $derived(claims.filter((c) => c.last_verify?.status === "ok"));
const failed = $derived(claims.filter((c) => c.last_verify?.status === "fail"));
const pending = $derived(claims.filter((c) => !c.last_verify));
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
claims = await listClaims();
try {
registryRecord = await lookup(session.unlocked.handle);
} catch (e) {
if (!(e instanceof ApiError && e.status === 404)) {
console.error(e);
}
}
});
async function reverifyAll() {
verifyingAll = true;
try {
for (const c of claims) {
const result = await verifyClaim($state.snapshot(c) as StoredClaim);
await setVerifyResult(c.id, result);
}
claims = await listClaims();
} finally {
verifyingAll = false;
}
}
function channelLabel(ch: string): string {
return (
{
github: "GitHub",
dns: "DNS",
web: "Website",
nostr: "Nostr",
bluesky: "Bluesky",
ap: "ActivityPub",
} as Record<string, string>
)[ch] ?? ch;
}
async function copyKez() {
if (!session.unlocked) return;
await navigator.clipboard.writeText(
`${session.unlocked.handle}@${session.unlocked.server}`,
);
copied = true;
setTimeout(() => (copied = false), 1500);
}
function fingerprint(primary: string): string {
// ed25519:<hex> → grouped short fingerprint for human comparison.
const hex = primary.startsWith("ed25519:")
? primary.slice("ed25519:".length)
: primary;
return (hex.match(/.{1,4}/g) ?? []).slice(0, 8).join(" ");
}
</script>
{#if session.unlocked}
<div class="max-w-2xl mx-auto space-y-6">
<!-- Identity card -->
<section class="bg-surface border border-border rounded-xl p-6">
<div class="flex items-start gap-4">
<Avatar seed={session.unlocked.primary} size={64} ring />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-lg font-semibold text-text truncate">
{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span>
</span>
<button
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"
onclick={copyKez}
>
{copied ? "✓ copied" : "copy"}
</button>
</div>
<p class="mt-2 text-xs text-text-muted uppercase tracking-wider">Key fingerprint</p>
<p class="font-mono text-sm text-text-secondary break-all">
{fingerprint(session.unlocked.primary)}<span class="text-text-muted"></span>
</p>
{#if registryRecord}
<p class="mt-2 text-xs text-text-muted">
Registered {new Date(registryRecord.registered_at).toLocaleDateString()}
</p>
{/if}
</div>
</div>
</section>
<!-- Proofs -->
<section class="bg-surface border border-border rounded-xl p-6">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Proofs</h2>
<p class="text-sm text-text-secondary mt-1">
Other accounts cryptographically linked to your KEZ. Anyone can
verify these without trusting the server.
</p>
</div>
<div class="flex flex-col gap-2 shrink-0">
{#if claims.length > 0}
<button
class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
onclick={reverifyAll}
disabled={verifyingAll}
>
{verifyingAll ? "Verifying…" : "Re-verify"}
</button>
{/if}
<a
href="#/claims/add"
class="px-3 py-1.5 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim no-underline text-center"
>
+ Add proof
</a>
</div>
</div>
{#if claims.length === 0}
<div class="border border-dashed border-border rounded-lg p-6 text-center">
<p class="text-sm text-text-muted">No proofs yet.</p>
<p class="text-xs text-text-muted mt-1">
Link GitHub, your domain, nostr, Bluesky — prove the accounts you control.
</p>
</div>
{:else}
<ul class="space-y-2">
{#each verified as c (c.id)}
<li class="flex items-center justify-between gap-3 p-3 bg-elevated border border-border rounded-lg">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">
{channelLabel(c.channel)}
</span>
<span class="font-mono text-sm text-text truncate">{c.envelope.payload.subject}</span>
<span class="text-xs font-mono font-semibold px-1.5 py-0.5 rounded-sm" style="background:#4ade8014;border:1px solid #4ade8040;color:var(--color-verified);">✓ verified</span>
</div>
</div>
{#if c.last_verify?.evidence_url}
<a href={c.last_verify.evidence_url} target="_blank" rel="noopener noreferrer" class="text-xs text-accent hover:text-accent-dim underline shrink-0">proof ↗</a>
{/if}
</li>
{/each}
{#each failed as c (c.id)}
<li class="flex items-center gap-2 p-3 bg-elevated border border-border rounded-lg">
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">{channelLabel(c.channel)}</span>
<span class="font-mono text-sm text-text truncate flex-1">{c.envelope.payload.subject}</span>
<span class="text-xs font-mono font-semibold text-danger shrink-0">✗ failed</span>
</li>
{/each}
{#each pending as c (c.id)}
<li class="flex items-center gap-2 p-3 bg-elevated border border-border rounded-lg">
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">{channelLabel(c.channel)}</span>
<span class="font-mono text-sm text-text truncate flex-1">{c.envelope.payload.subject}</span>
<span class="text-xs font-mono text-warning shrink-0">pending</span>
</li>
{/each}
</ul>
<a href="#/claims" class="mt-3 inline-block text-xs text-text-secondary hover:text-text underline">Manage proofs →</a>
{/if}
</section>
</div>
{/if}

View File

@ -6,6 +6,7 @@
import { lookup, ApiError } from "../lib/api.js";
import { inboxService } from "../lib/inbox-service.svelte.js";
import EmojiButton from "../lib/EmojiButton.svelte";
import Avatar from "../lib/Avatar.svelte";
import {
appendOutbound,
ensureConversation,
@ -245,210 +246,171 @@
}
</script>
<div class="flex h-[calc(100vh-8rem)] gap-4">
<!-- 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>
<div class="flex h-full bg-bg">
<!-- Sidebar (conversation list). On mobile it's full-width and hides
when a conversation is open. -->
<aside class={`${activeConv ? "hidden" : "flex"} sm:flex w-full sm:w-80 shrink-0 border-r border-border bg-surface flex-col`}>
<!-- Header: your KEZ + status -->
<div class="p-3 border-b border-border">
<div class="flex items-center justify-between gap-2">
<h1 class="text-sm font-semibold text-text uppercase tracking-wider">Chats</h1>
<span class="text-xs">
{#if inboxService.status === "live"}
<span class="text-accent">● live</span>
{:else if inboxService.status === "reconnecting"}
<span class="text-warning">● reconnecting</span>
{:else if inboxService.status === "connecting"}
<span class="text-text-muted">○ connecting</span>
{:else}
<span class="text-text-muted">○ off</span>
{/if}
</span>
</div>
{#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>
<button
class="mt-2 w-full flex items-center justify-between gap-2 px-2 py-1.5 rounded-md bg-elevated border border-border hover:border-accent-dim transition-colors"
onclick={copyMyKez}
title="Copy your KEZ to share"
>
<code class="font-mono text-xs text-accent truncate">{session.unlocked.handle}@{session.unlocked.server}</code>
<span class="text-[10px] text-text-muted shrink-0">{copied ? "✓ copied" : "copy"}</span>
</button>
{/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">
Start a chat
</p>
<form
class="flex gap-2"
onsubmit={(e) => {
e.preventDefault();
startConversation();
}}
>
<!-- Start a chat -->
<div class="p-3 border-b border-border">
<form class="flex gap-2" onsubmit={(e) => { e.preventDefault(); startConversation(); }}>
<input
type="text"
bind:value={newPeerInput}
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"
placeholder="handle@kez.lat"
class="flex-1 min-w-0 px-3 py-2 text-sm bg-elevated border border-border rounded-md font-mono text-text placeholder:text-text-muted focus:border-accent focus:outline-none"
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"
class="px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
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>
<p class="mt-2 text-xs text-danger">{resolveError}</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.
</p>
<div class="p-6 text-center">
<p class="text-sm text-text-muted">No conversations yet.</p>
<p class="text-xs text-text-muted mt-1">Enter someone's KEZ above to start.</p>
</div>
{:else}
<ul>
{#each conversations as c (c.peer_primary)}
{@const last = c.messages[c.messages.length - 1]}
{@const active = activePrimary === c.peer_primary}
<li>
<button
class={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-50 ${activePrimary === c.peer_primary ? "bg-gray-100" : ""}`}
class={`relative w-full text-left flex items-center gap-3 px-3 py-3 border-b border-border/60 transition-colors ${active ? "bg-elevated" : "hover:bg-elevated/60"}`}
onclick={() => (activePrimary = c.peer_primary)}
>
<p class="font-mono text-sm font-semibold text-gray-900 truncate">
{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}
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
<Avatar seed={c.peer_primary} size={40} />
<div class="min-w-0 flex-1">
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(c)}</p>
{#if last}
<p class="text-xs text-text-secondary truncate">
{last.direction === "out" ? "→ " : ""}{last.body}
</p>
{:else}
<p class="text-xs text-text-muted italic">No messages yet</p>
{/if}
</div>
{#if last}<span class="text-[10px] text-text-muted shrink-0 self-start mt-0.5">{formatTime(last.ts)}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Footer status — reads from the global inbox service so it
reflects the SAME connection that's running everywhere else. -->
<div class="p-2 border-t border-gray-200 text-xs text-gray-500 space-y-0.5">
<p>
{#if inboxService.status === "live"}
<span class="text-green-700">● live</span>
{:else if inboxService.status === "reconnecting"}
<span class="text-amber-700">● reconnecting…</span>
{:else if inboxService.status === "connecting"}
<span class="text-gray-500">○ connecting…</span>
{:else}
<span class="text-gray-400">○ off</span>
{/if}
</p>
{#if inboxService.lastError}
<p class="text-red-700">{inboxService.lastError}</p>
{/if}
</div>
</aside>
<!-- Main: thread or empty state -->
<main class="flex-1 min-w-0 border border-gray-200 rounded-lg bg-white flex flex-col">
<main class={`${activeConv ? "flex" : "hidden"} sm:flex flex-1 min-w-0 bg-bg flex-col`}>
{#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.
<div class="text-5xl">🔒</div>
<h2 class="text-lg font-semibold text-text">End-to-end encrypted</h2>
<p class="text-sm text-text-secondary">
Messages are encrypted between you and the recipient. The server
only relays opaque ciphertext — it can't read anything.
</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 class="text-sm text-text-secondary">
Ask someone for their <span class="font-mono text-accent">handle@server</span>
and enter it on the left to start. Or share yours with the copy button.
</p>
</div>
</div>
{:else}
<div class="p-3 border-b border-gray-200">
<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>
<!-- Thread header -->
<div class="flex items-center gap-3 p-3 border-b border-border bg-surface">
<button class="sm:hidden text-text-secondary hover:text-text -ml-1" onclick={() => (activePrimary = null)} aria-label="Back">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
<Avatar seed={activeConv.peer_primary} size={36} />
<div class="min-w-0">
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(activeConv)}</p>
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-2" bind:this={scrollEl}>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-1.5" bind:this={scrollEl}>
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
{@const boost = emojiOnlyBoost(m.body)}
<div class={`max-w-md ${m.direction === "out" ? "ml-auto" : ""}`}>
{@const out = m.direction === "out"}
<div class={`max-w-[78%] ${out ? "ml-auto" : ""}`}>
{#if boost}
<!-- Emoji-only message: drop the bubble chrome, render big. -->
<div
class={`whitespace-pre-wrap break-words leading-none ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${m.direction === "out" ? "text-right" : ""}`}
>
{m.body}
</div>
<div class={`whitespace-pre-wrap break-words leading-none py-1 ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${out ? "text-right" : ""}`}>{m.body}</div>
{:else}
<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"}`}
>
{m.body}
</div>
class={`px-3 py-2 text-sm whitespace-pre-wrap break-words ${out ? "bg-accent text-accent-contrast rounded-lg rounded-br-sm" : "bg-bubble-recv text-text border border-border rounded-lg rounded-bl-sm"}`}
>{m.body}</div>
{/if}
<p class={`mt-1 text-xs text-gray-400 ${m.direction === "out" ? "text-right" : ""}`}>
{formatTime(m.ts)}
</p>
<p class={`mt-0.5 text-[10px] text-text-muted ${out ? "text-right" : ""}`}>{formatTime(m.ts)}</p>
</div>
{/each}
{#if activeConv.messages.length === 0}
<p class="text-gray-400 text-sm italic text-center mt-8">
No messages yet. Say hi — it's encrypted before it leaves your
browser.
<p class="text-text-muted text-sm italic text-center mt-8">
No messages yet. Say hi — it's encrypted before it leaves your browser.
</p>
{/if}
</div>
<form
class="p-3 border-t border-gray-200 flex gap-2 items-center"
onsubmit={(e) => {
e.preventDefault();
send();
}}
>
<!-- Compose -->
<form class="p-3 border-t border-border bg-surface flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
<EmojiButton onpick={insertEmoji} />
<input
type="text"
bind:value={composeText}
bind:this={composeEl}
placeholder="Type a message…"
class="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 rounded"
class="flex-1 min-w-0 px-3 py-2 text-sm bg-elevated border border-border rounded-md text-text placeholder:text-text-muted focus:border-accent focus:outline-none"
autocomplete="off"
disabled={composing}
/>
<button
type="submit"
class="px-4 py-2 text-sm bg-gray-900 text-white rounded hover:bg-gray-700 disabled:opacity-50"
class="px-4 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={composing || !composeText.trim()}
>
{composing ? "Sending…" : "Send"}
{composing ? "…" : "Send"}
</button>
</form>
{/if}

View File

@ -0,0 +1,185 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { session } from "../lib/store.svelte.js";
import {
hasStoredBiometric,
getStoredBiometricMeta,
setupBiometricUnlock,
removeStoredBiometric,
isPlatformAuthenticatorAvailable,
} from "../lib/webauthn.js";
import {
notificationsSupported,
notificationsPermission,
requestNotificationsPermission,
fireTestNotification,
} from "../lib/inbox-service.svelte.js";
let biometricSupported = $state(false);
let biometricEnrolled = $state(false);
let biometricLabel = $state<string | null>(null);
let biometricCreatedAt = $state<string | null>(null);
let biometricBusy = $state(false);
let biometricError = $state<string | null>(null);
let notifSupported = $state(false);
let notifPerm = $state<NotificationPermission | "unsupported">("default");
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
await refreshBiometricStatus();
notifSupported = notificationsSupported();
notifPerm = notificationsPermission();
});
async function refreshBiometricStatus() {
biometricSupported = await isPlatformAuthenticatorAvailable();
biometricEnrolled = await hasStoredBiometric();
const m = await getStoredBiometricMeta();
biometricLabel = m?.label ?? null;
biometricCreatedAt = m?.created_at ?? null;
}
async function enableBiometric() {
if (!session.unlocked) return;
biometricBusy = true;
biometricError = null;
try {
await setupBiometricUnlock({
handle: session.unlocked.handle,
primary: session.unlocked.primary,
seed: session.unlocked.seed,
});
await refreshBiometricStatus();
} catch (e) {
biometricError = (e as Error).message;
} finally {
biometricBusy = false;
}
}
async function disableBiometric() {
if (!confirm("Disable biometric unlock for this device? You'll need your passphrase next time.")) return;
await removeStoredBiometric();
await refreshBiometricStatus();
}
async function enableNotifications() {
notifPerm = await requestNotificationsPermission();
}
function sendTestNotification() {
testNotifResult = fireTestNotification();
setTimeout(() => (testNotifResult = null), 5_000);
}
function showSeed() {
if (!session.unlocked) return;
const hex = bytesToHex(session.unlocked.seed);
alert(`Your recovery seed (KEEP SECRET):\n\n${hex}\n\nWrite this down somewhere safe. It's the ONLY way to recover this account.`);
}
function lock() {
session.lock();
push("/unlock");
}
const buildSha = __BUILD_SHA__;
</script>
{#if session.unlocked}
<div class="max-w-2xl mx-auto space-y-6">
<!-- Security -->
<section class="bg-surface border border-border rounded-xl p-6 space-y-5">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Security</h2>
<!-- App lock / biometric -->
<div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-text">App lock</p>
<p class="text-sm text-text-secondary mt-0.5">
Unlock with Touch ID / Face ID / Windows Hello instead of typing
your passphrase. The passphrase still works as a backup.
</p>
</div>
{#if biometricEnrolled}
<button class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger shrink-0" onclick={disableBiometric}>Disable</button>
{/if}
</div>
{#if !biometricSupported && !biometricEnrolled}
<p class="mt-2 text-sm text-text-muted italic">No platform authenticator detected in this browser.</p>
{:else if biometricEnrolled}
<p class="mt-2 text-sm" style="color:var(--color-verified)">
{biometricLabel} enrolled{#if biometricCreatedAt} · {new Date(biometricCreatedAt).toLocaleDateString()}{/if}
</p>
{:else}
<button class="mt-2 px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50" onclick={enableBiometric} disabled={biometricBusy}>
{biometricBusy ? "Setting up…" : "Enable app lock"}
</button>
{#if biometricError}<p class="mt-2 text-xs text-danger">{biometricError}</p>{/if}
{/if}
</div>
<div class="border-t border-border"></div>
<!-- Recovery phrase -->
<div>
<p class="text-sm font-medium text-text">Recovery seed</p>
<p class="text-sm text-text-secondary mt-0.5">
The only thing that can recover this account. Write it down offline.
</p>
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}>
Reveal seed
</button>
</div>
</section>
<!-- Notifications -->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-2">Notifications</h2>
<p class="text-sm text-text-secondary">
System notification when a new message arrives while you're in another
tab or app. Silent while you're looking at this one.
</p>
{#if !notifSupported}
<p class="mt-3 text-sm text-text-muted italic">Not supported in this browser.</p>
{:else if notifPerm === "granted"}
<div class="mt-3 space-y-2">
<p class="text-sm" style="color:var(--color-verified)">✓ Enabled.</p>
<button class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={sendTestNotification}>Send test notification</button>
{#if testNotifResult?.ok === true}
<p class="text-xs text-text-muted">✓ Sent. Nothing popped? Check your OS notification settings for this browser.</p>
{:else if testNotifResult?.ok === false}
<p class="text-xs text-danger">{testNotifResult.reason}</p>
{/if}
</div>
{:else if notifPerm === "denied"}
<p class="mt-3 text-sm text-warning">Blocked. Re-enable in your browser's site settings.</p>
{:else}
<button class="mt-3 px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim" onclick={enableNotifications}>Enable notifications</button>
{/if}
</section>
<!-- Account / About -->
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>
<button class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger" onclick={lock}>
Lock
</button>
<div class="border-t border-border"></div>
<p class="text-xs text-text-muted">
kez-chat ·
<a href={`https://git.ptud.biz/DukeInc/Kez/commit/${buildSha}`} target="_blank" rel="noopener" class="font-mono text-text-secondary hover:text-accent">{buildSha}</a>
·
<a href="https://git.ptud.biz/DukeInc/Kez" target="_blank" rel="noopener" class="text-text-secondary hover:text-accent">source</a>
</p>
</section>
</div>
{/if}