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>
457 lines
16 KiB
Svelte
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>
|