Kez/kez-chat/web/src/routes/Messages.svelte
Jason Tudisco 4eeedb38fb feat(kez-chat/web): auto-scroll thread on new message + on conversation open
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>
2026-05-27 00:27:33 -06:00

457 lines
16 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { push } from "svelte-spa-router";
import { session } from "../lib/store.svelte.js";
import { sendMessage } from "../lib/messages.js";
import { lookup, ApiError } from "../lib/api.js";
import { inboxService } from "../lib/inbox-service.svelte.js";
import EmojiButton from "../lib/EmojiButton.svelte";
import {
appendOutbound,
ensureConversation,
listConversations,
type Conversation,
} from "../lib/conversations-store.js";
import type { Identity } from "../lib/kez.js";
let conversations = $state<Conversation[]>([]);
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 composeEl: HTMLInputElement | null = $state(null);
/**
* Insert an emoji at the current cursor position in the compose input
* (or append if the input isn't focused). Keeps the cursor right after
* the inserted character so you can keep typing.
*/
function insertEmoji(emoji: string) {
if (!composeEl) {
composeText += emoji;
return;
}
const start = composeEl.selectionStart ?? composeText.length;
const end = composeEl.selectionEnd ?? composeText.length;
composeText = composeText.slice(0, start) + emoji + composeText.slice(end);
// Restore focus + position the caret after the inserted emoji
// (need a tick for the value to flush through the binding).
queueMicrotask(() => {
if (!composeEl) return;
composeEl.focus();
const pos = start + emoji.length;
composeEl.setSelectionRange(pos, pos);
});
}
// Lazy grapheme segmenter — reused across messages.
const graphemeSeg =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
/**
* iMessage-style: if the whole message is just a handful of emoji and
* nothing else, drop the bubble chrome and render it big. We count
* grapheme clusters (so "👨‍👩‍👧" reads as 1, not 5 code points).
*
* "Emoji-only" = the string contains no letters, digits, whitespace,
* or punctuation. That leaves Symbol_Other + emoji bits.
*/
function emojiOnlyBoost(body: string): "lg" | "xl" | "2xl" | null {
const trimmed = body.trim();
if (!trimmed) return null;
if (/[\p{L}\p{N}\p{P}\p{Zs}]/u.test(trimmed)) return null;
const count = graphemeSeg
? [...graphemeSeg.segment(trimmed)].length
: [...trimmed].length;
if (count === 1) return "2xl";
if (count <= 3) return "xl";
if (count <= 6) return "lg";
return null;
}
/** Unsubscribe handle for the inbox-service "new message" listener. */
let unsubscribe: (() => void) | null = null;
/** Scroll container for the thread view — bound for auto-scroll. */
let scrollEl: HTMLDivElement | null = $state(null);
/** Tracks the previously-rendered conversation so we know when the
* user just OPENED a different thread (always scroll to bottom) vs.
* when a new message landed in the current one (scroll only if they
* were near the bottom already — don't yank them out of history). */
let prevPrimary: string | null = null;
let prevMessageCount = 0;
/** Distance (px) from the bottom of the thread within which we still
* count "near bottom" for the follow-along rule. Roughly two short
* bubble heights — generous enough that small misses don't sticky. */
const SCROLL_FOLLOW_THRESHOLD_PX = 120;
// Auto-scroll effect — runs whenever activeConv.messages.length or
// activeConv.peer_primary changes (both are reactive reads).
$effect(() => {
if (!activeConv || !scrollEl) {
prevPrimary = null;
prevMessageCount = 0;
return;
}
const el = scrollEl;
const count = activeConv.messages.length;
const conversationChanged = activeConv.peer_primary !== prevPrimary;
if (conversationChanged) {
prevPrimary = activeConv.peer_primary;
prevMessageCount = count;
// Wait a microtask so Svelte's DOM updates have flushed.
queueMicrotask(() => (el.scrollTop = el.scrollHeight));
return;
}
if (count > prevMessageCount) {
prevMessageCount = count;
const distFromBottom =
el.scrollHeight - el.scrollTop - el.clientHeight;
if (distFromBottom < SCROLL_FOLLOW_THRESHOLD_PX) {
queueMicrotask(() => (el.scrollTop = el.scrollHeight));
}
} else {
prevMessageCount = count;
}
});
// "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);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
await refresh();
// Subscribe to the always-on inbox service — re-render whenever a
// new message lands. The service is already running (it started on
// session unlock in store.svelte.ts) regardless of which route the
// user was on.
unsubscribe = inboxService.onMessage(() => void refresh());
// Landing here = the user has seen new messages; reset the badge.
inboxService.markAllRead();
});
onDestroy(() => {
unsubscribe?.();
});
async function refresh() {
conversations = await listConversations();
}
/** "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;
}
const record = await lookup(local);
await ensureConversation(record.primary as Identity, record.fqhn);
activePrimary = record.primary as Identity;
newPeerInput = "";
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 || !activeConv || !composeText.trim()) return;
composing = true;
try {
const body = composeText;
composeText = "";
await sendMessage({
senderHandle: session.unlocked.handle,
senderSeed: session.unlocked.seed,
senderPrimary: session.unlocked.primary,
recipient: activeConv.peer_handle || activeConv.peer_primary,
body,
});
await appendOutbound({
peer_primary: activeConv.peer_primary,
peer_handle: activeConv.peer_handle,
from: session.unlocked.primary,
body,
});
await refresh();
} catch (e) {
alert(`Send failed: ${(e as Error).message}`);
composeText = composeText; // no-op, keep linter happy
} finally {
composing = false;
}
}
function shortKey(primary: Identity): string {
if (primary.startsWith("ed25519:")) {
const hex = primary.slice("ed25519:".length);
return `${hex.slice(0, 8)}${hex.slice(-4)}`;
}
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 {
const d = new Date(iso);
const today = new Date();
if (
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate()
) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
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 -->
<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">
Start a chat
</p>
<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"
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() || 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.
</p>
{:else}
<ul>
{#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 ${activePrimary === c.peer_primary ? "bg-gray-100" : ""}`}
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}
</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">
{#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">
{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" 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" : ""}`}>
{#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>
{: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>
{/if}
<p class={`mt-1 text-xs text-gray-400 ${m.direction === "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>
{/if}
</div>
<form
class="p-3 border-t border-gray-200 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"
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"
disabled={composing || !composeText.trim()}
>
{composing ? "Sending…" : "Send"}
</button>
</form>
{/if}
</main>
</div>