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>
This commit is contained in:
parent
de120f7d6c
commit
4eeedb38fb
@ -77,6 +77,50 @@
|
||||
/** 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);
|
||||
@ -351,7 +395,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
<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" : ""}`}>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user