diff --git a/kez-chat/web/src/routes/Messages.svelte b/kez-chat/web/src/routes/Messages.svelte index 5a0d9bd..ace91e6 100644 --- a/kez-chat/web/src/routes/Messages.svelte +++ b/kez-chat/web/src/routes/Messages.svelte @@ -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 @@

-
+
{#each activeConv.messages as m (m.seq + ":" + m.direction)} {@const boost = emojiOnlyBoost(m.body)}