openvibers/openvibe.live Commits

Chat: stop a tall message silently un-pinning the reader

OP OpenVibers committed 16 Sept 2026 · c1cb28a4d9 · 2 files, +46 −5

The pin check ran inside scrollChat(), which every caller invokes AFTER appending the new
message — so scrollHeight already counted the message being announced, and the distance from the
bottom was inflated by that message's own height. Anything over 100px tall (a wrapped multi-line
message, a reply header, a clip card, a GIF card, a goal-reached card) classified a reader
sitting exactly at the bottom as "scrolled up". Chat then quietly stopped following and showed an
unread badge they never asked for. This is the "chat randomly stops following" bug.

Appending content doesn't fire a scroll event, so the pin state is now tracked from the scroll
listener instead: the value scrollChat() reads is the one from before the append, which is what
it wanted all along, with nothing to thread through six call sites. The listener is attached
wherever chat scrolls rather than only on the cold-connect path, because initChat() returns early
when a stream goes live or offline and that swaps the render container — which used to leave the
new one with no listener at all, so scrolling back down never re-armed following.

Trimming old messages off the top now gives back the height it removes, so a reader scrolled
back no longer sees the text jump upward when the 500-message cap kicks in.

Verified: pinned reader plus a 300px message stays pinned and at the bottom; a reader scrolled up
is not moved by a new message; scrolling back to the bottom resumes following.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 9f4146e4..77803666 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5136,7 +5136,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
--<script src="/js/chat.js?v=81" defer></script>
++<script src="/js/chat.js?v=82" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 2ac97834..89fdc604 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3367,7 +3367,13 @@ function addChatMessage(msg) {
// message nodes (memory + scroll/layout thrash). Mirrors the caps the cross-feed /
// widget containers already have. Don't trim mid history-hydration.
if (!_loadingHistory) {
-- while (container.children.length > MAIN_CHAT_MAX_MESSAGES) container.removeChild(container.firstChild);
++ while (container.children.length > MAIN_CHAT_MAX_MESSAGES) {
++ // Removing from the top shortens everything above the viewport, so a reader who has
++ // scrolled back sees the text jump upward. Give back exactly what we took.
++ const lost = container.firstChild.getBoundingClientRect().height;
++ container.removeChild(container.firstChild);
++ if (!_chatPinned) container.scrollTop = Math.max(0, container.scrollTop - lost);
++ }
}
if (chatSettings.autoScroll) {
if (_loadingHistory) {
@@@ -5402,12 +5408,47 @@ let _chatUserScrolledUp = false;
let _chatUnreadCount = 0;
let _loadingHistory = false;
++/**
++ * Is the reader following the live edge?
++ *
++ * This used to be measured inside scrollChat(), which every caller runs AFTER appending the new
++ * message — so scrollHeight already counted the message being announced, and the distance from
++ * the bottom was inflated by that message's own height. Anything taller than 100px (a wrapped
++ * multi-line message, a reply header, a clip card, a GIF card, a goal-reached card) therefore
++ * classified a reader sitting exactly at the bottom as "scrolled up", and chat silently stopped
++ * following with an unread badge they never asked for. That is the single most-felt bug in a
++ * live-streaming product.
++ *
++ * Appending content does not fire a scroll event, so tracking the state from the scroll listener
++ * instead means the value scrollChat() reads is the one from before the append — the thing it
++ * actually wanted — with no need to pass anything through six call sites.
++ */
++let _chatPinned = true;
++function _watchChatPin(container) {
++ if (!container || container._ovPinWatch) return;
++ container._ovPinWatch = true;
++ const update = () => {
++ _chatPinned = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
++ if (_chatPinned) {
++ _chatUserScrolledUp = false;
++ _chatUnreadCount = 0;
++ _hideChatNewMessagesIndicator();
++ } else {
++ _chatUserScrolledUp = true;
++ }
++ };
++ container.addEventListener('scroll', update, { passive: true });
++ update();
++}
++
function scrollChat() {
const { messages: container } = getChatEl();
if (!container) return;
-- // Only auto-scroll if user is near the bottom
-- const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
-- if (nearBottom) {
++ // Attach here rather than only on the cold-connect path: initChat() returns early when a
++ // stream goes live or offline, and that swaps the render container — which used to leave the
++ // new one with no listener at all, so scrolling back to the bottom never re-armed following.
++ _watchChatPin(container);
++ if (_chatPinned) {
container.scrollTop = container.scrollHeight;
_chatUserScrolledUp = false;
_chatUnreadCount = 0;