From b6d6a5ca2d15f9a704205d96f81d2b4bc980cec6 Mon Sep 17 00:00:00 2001 From: JRBlaze <40746670+JRBlaze@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:10:02 -0400 Subject: [PATCH] Follow the live end when the end is what moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.18.2 let the browser skip rows scrolled out of view, and a row it has not drawn yet only has an assumed height. Scrolling to the end of the feed is therefore only as good as the height the feed has at that moment: the rows just attached are measured for real straight afterwards, the end drops a few hundred pixels below where it was, and the scroll that had just landed on it is short. That alone would have been a flicker. What made it a wall was the way following the live end was decided: fresh on every flush, from the distance to the bottom. Being short read as "the viewer has scrolled up", so the feed stopped following — and nothing scrolled again while it was behind, so the gap only ever grew. The only way back was the jump button, and a fast chat stranded them again a moment later. On a live channel the feed sat eleven thousand pixels behind after a couple of minutes. Following is now what the viewer asked for rather than what a measurement says. A feed that is following and finds itself away from the end got there one of two ways, and the scroll position tells them apart: if it moved, the viewer moved, so hold still and offer the way back; if it did not move at all, the end moved, so go after it. Growth below a viewer who has not touched anything can no longer be read as a gesture, which means a single bad measurement can no longer strand anybody — and the way back still needs no gesture, because being at the end is being at the end however you got there. A flush also takes one more look on the next frame, for the growth that lands after the flush that caused it, and the assumed row height is now 30px: measured on a live channel rather than guessed from the harness's simpler rows, where 24px came from. Measured against live Kick traffic with both chats joined, 2259 messages through a full feed: the end stays 0px away at the median and 21px at the worst, where before it was 11223px and climbing. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- manifest.json | 2 +- src/content/feed.js | 142 +++++++++++++++++++++++++++++++--------- src/content/overlay.css | 8 ++- tests/run.js | 80 ++++++++++++++++++++++ 5 files changed, 199 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 8b9c34c..d9705dd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ feed. Open a Kick channel and it works the other way round. There is nothing to build and nothing to install first — Chrome loads the folder as it is. **[⬇ Download the latest release](../../releases/latest)** — grab -`FriendlyChatExtension-v1.18.2.zip` from the Assets list, then follow the steps below. +`FriendlyChatExtension-v1.18.3.zip` from the Assets list, then follow the steps below. (You can also use the green **Code → Download ZIP** button, but that gives you the whole repository — tests, the Cloudflare worker, and a folder named `FriendlyChatExtension-main`. The diff --git a/manifest.json b/manifest.json index 0e5943e..4ba11d8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Friendly Chat Extension", - "version": "1.18.2", + "version": "1.18.3", "description": "Overlays a merged Twitch + Kick chat on the channel you are watching, and offers to connect the other platform when the streamer is live on both.", "minimum_chrome_version": "116", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0vdefhQX2a3wYoBWqcRPP5DrXABtsBjLzSDmZrqnG3T4vpBLOsgUGShsxCHkMB9DRJFujPYi/aizVVVIcvtFEcpVDmtnavLpIw8iBAoOhiAesi8Kt1OAvXps0CvmVJN3KCNWDf27uWqHxqChDZHPaZXKaqmy/fyZSAb79W+2OPXCs51IBzE8uUUEiv7t1Qg8RMdeXSIWBFBCqHagebuKXf4HlkzdsGXJ0jmMewK3TktyCRu8Js85oU66nsdkxUkdh43e/98s1QZAJpzlQ/CHkEGZFZAFTBbGDSdqvx/O/SEnseyMsR40dO9is6SK5OY3MpFKUBemnnvt4g1Xa1F0sQIDAQAB", diff --git a/src/content/feed.js b/src/content/feed.js index 8e321d4..89b598b 100644 --- a/src/content/feed.js +++ b/src/content/feed.js @@ -25,7 +25,24 @@ // Told whenever the feed starts or stops following the live end, and how // many messages have arrived since it stopped. let onPinChange = null; - let wasPinned = true; + // Whether the feed is following the live end. + // + // This is what the viewer asked for, not what a measurement says, and the + // difference is the whole of it. The feed's content moves underneath itself + // constantly: rows are trimmed off the top, an emote finishes loading and + // grows the row it is in, a row that was scrolled out of view is measured + // for real the moment it comes back. Every one of those moves the numbers + // an "are we at the bottom?" test reads. + // + // Deciding it afresh from those numbers on every flush meant any one of + // them reading wrong once stopped the feed following for good: nothing + // scrolled again while it was behind, so the gap only ever grew, and the + // way back was a button the viewer had to keep pressing while the chat ran + // away from them again. Nothing but the viewer scrolling up stops it now. + let following = true; + // The scroll position this feed last set for itself, so its own scrolling + // is never mistaken for the viewer's. + let lastTop = 0; let missed = 0; function limit() { @@ -33,18 +50,32 @@ return FCM.clampNumber(s.maxMessages, FCM.MAX_MESSAGES_MIN, FCM.MAX_MESSAGES_MAX, FCM.MAX_MESSAGES_DEFAULT); } - function isPinned() { - // A collapsed or hidden panel gives the feed no box at all, and every - // measurement off it reads zero — which came out as "following the live - // end" however far behind it actually was. So the messages that arrived - // while it was away were counted as seen, the jump button stayed hidden, - // and opening the panel again left the viewer somewhere in the middle of - // an hour ago with nothing on screen offering to take them back. - // Whatever was true when it had a box is still true now. - if (!feedEl.clientHeight) return wasPinned; - return feedEl.scrollHeight - feedEl.scrollTop < feedEl.clientHeight + 120; + // Close enough to the end to count as being at it. Only ever asked about + // a feed the viewer has scrolled, to know whether they have come back. + const AT_BOTTOM_SLACK = 120; + function atBottom() { + // A collapsed or hidden panel gives the feed no box at all and every + // measurement off it reads zero, which is not an answer. Whatever was + // true when it had a box is still true now. + if (!feedEl.clientHeight) return following; + return feedEl.scrollHeight - feedEl.scrollTop - feedEl.clientHeight <= AT_BOTTOM_SLACK; } + function stickToBottom() { + feedEl.scrollTop = feedEl.scrollHeight; + lastTop = feedEl.scrollTop; + } + + function setFollowing(next) { + if (next === following) return; + following = next; + // Coming back to the live end clears what was missed while away from it. + if (next) missed = 0; + if (onPinChange) onPinChange(next, missed); + } + + function isPinned() { return following; } + function trim() { let excess = feedEl.childElementCount - limit(); while (excess > 0 && feedEl.firstElementChild) { @@ -54,19 +85,44 @@ } /** - * Says whether the feed is following the live end, and how far behind it is. + * Reads one scroll, and decides nothing it does not have to. + * + * A feed that is following the live end and finds itself no longer at it + * got there one of two ways, and they want opposite answers: + * + * * The viewer moved away from the end. The scroll position changed, and + * they are reading something — hold still and offer them the way back. + * * The end moved away from the viewer. The scroll position did not + * change at all; the feed simply grew below it, because a row that had + * been scrolled out of view was measured for real, or an emote finished + * loading and grew the row it is in. On a busy channel that is a few + * hundred pixels at a time. Follow it down. + * + * Telling them apart by whether the scroll position moved is what makes + * this safe. Measuring the distance to the end and calling anything far + * enough "the viewer scrolled up" is what used to strand people: one growth + * spurt read as a gesture, the feed stopped following, and it never started + * again on its own however long they waited. + * + * The way back needs no gesture, because there is none to give: a viewer + * who has scrolled back is following again once they are at the end, + * however they got there. * - * Called on every scroll, so it reads the three layout values it needs and - * nothing else — and only reports when the answer has actually changed, - * because a scrolling chat fires this continuously. + * Called on every scroll of a busy chat, so it reads the layout values it + * needs and nothing else, and reports only when the answer has changed. */ function notePinState() { - const pinned = isPinned(); - if (pinned === wasPinned) return; - wasPinned = pinned; - // Coming back to the live end clears what was missed while away from it. - if (pinned) missed = 0; - if (onPinChange) onPinChange(pinned, missed); + const top = feedEl.scrollTop; + const moved = top !== lastTop; + lastTop = top; + + if (!following) { + if (atBottom()) setFollowing(true); + return; + } + if (atBottom()) return; + if (moved) setFollowing(false); + else stickToBottom(); } feedEl.addEventListener('scroll', notePinState, { passive: true }); @@ -75,7 +131,10 @@ scheduled = false; if (!pending.length) return; - const pinned = isPinned(); + // A scroll the viewer has just made, whose event has not been delivered + // yet, has to be seen before this decides to take them back to the end. + notePinState(); + const cap = limit(); // If a single burst already exceeds the cap, drop the surplus before it is // ever attached instead of attaching and immediately removing it. @@ -83,7 +142,7 @@ // Counted before the queue is emptied. Only chat rows count: a status // line arriving is not something the viewer scrolled up to avoid missing. - if (!pinned) { + if (!following) { missed += pending.filter((el) => el.classList && el.classList.contains('fcm-msg')).length; } @@ -94,17 +153,37 @@ feedEl.appendChild(fragment); trim(); - if (pinned) { - feedEl.scrollTop = feedEl.scrollHeight; - } else { + if (following) { + settleToBottom(); + } else if (onPinChange) { // Appending does not move the scroll position, so the feed has just // fallen further behind. Say so, or the count on the button stops // climbing while messages carry on arriving. - wasPinned = false; - if (onPinChange) onPinChange(false, missed); + onPinChange(false, missed); } } + /** + * Puts the feed back on the live end, and again once the browser has + * caught up with itself. + * + * Scrolling to the end is only as good as the height the feed has at that + * moment, and rows just attached are measured after this, not during it — + * so the end is a little further down than it was when it was scrolled to. + * A scroll of its own does not always follow (the position may not have + * changed, only the height below it), so one more look on the next frame is + * what keeps the newest messages on screen rather than just below the fold. + */ + let settleFrame = null; + function settleToBottom() { + stickToBottom(); + if (settleFrame !== null || !window.requestAnimationFrame) return; + settleFrame = window.requestAnimationFrame(() => { + settleFrame = null; + if (following && !atBottom()) stickToBottom(); + }); + } + /** * Asks for the next flush. * @@ -279,15 +358,16 @@ seen.clear(); msgCount = 0; missed = 0; - wasPinned = true; + following = true; + lastTop = 0; if (onCount) onCount(0); if (onPinChange) onPinChange(true, 0); }, scrollToBottom() { - feedEl.scrollTop = feedEl.scrollHeight; + stickToBottom(); missed = 0; - wasPinned = true; + following = true; if (onPinChange) onPinChange(true, 0); }, isPinned, diff --git a/src/content/overlay.css b/src/content/overlay.css index 27a210a..680c2f9 100644 --- a/src/content/overlay.css +++ b/src/content/overlay.css @@ -546,11 +546,15 @@ * measured for real when it arrives at the live end, and that measurement is * remembered while the row is skipped, so scrolling back lands where the * viewer expects rather than on an estimate. The length after it is only ever - * used for a row that has not been rendered even once. */ + * used for a row that has not been rendered even once — a batch of history + * replayed on join, or a burst that lands several screens at a time. It is a + * plain chat row on the default text size, measured on a live channel rather + * than guessed at: too small a number and every scroll to the live end lands + * short of it, which is exactly as annoying as it sounds. */ .fcm-msg, .fcm-sys { content-visibility: auto; - contain-intrinsic-size: auto 24px; + contain-intrinsic-size: auto 30px; } .fcm-root[data-animate="true"] .fcm-msg { animation: fcm-fade .18s ease; } @keyframes fcm-fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } } diff --git a/tests/run.js b/tests/run.js index f3334ee..974eb7d 100644 --- a/tests/run.js +++ b/tests/run.js @@ -4860,6 +4860,86 @@ suites.feed = function () { null, 'feed: recent ids are still deduped'); } + // ── The end moving is not the viewer moving ── + // + // The feed follows the live end until the viewer scrolls away from it, and + // the two ways of stopping being at the end want opposite answers. The + // viewer moving away is a gesture: hold still and offer the way back. The end + // moving away from a viewer who has not touched anything is the feed growing + // underneath them — a row scrolled out of view being measured for real, an + // emote finishing loading — and the answer is to follow it down. + // + // Deciding this by distance alone stranded people: one growth spurt read as a + // gesture, the feed stopped following, and nothing ever started it again, so + // the only way back was pressing the button over and over while a fast chat + // ran away again between presses. + { + const t = build(); + const el = t.feedEl; + const seen = []; + t.feed.onPinChange((pinned, missed) => seen.push({ pinned, missed })); + + el.scrollHeight = 1000; el.clientHeight = 400; el.scrollTop = 1000; + el.__fire('scroll'); + ok(t.feed.isPinned(), 'feed: it starts out following the live end'); + + // The end moves without the viewer: taller rows, measured after the fact. + // Nothing was touched, so this is not somebody reading back. + el.scrollHeight = 1600; + el.__fire('scroll'); + ok(t.feed.isPinned(), 'feed: the end growing underneath does not stop it following'); + eq(el.scrollTop, 1600, 'feed: it goes after the end instead of sitting short of it'); + eq(seen.length, 0, 'feed: and says nothing, because nothing happened to report'); + + // The same again on a flush, which is where it actually bites. + t.feed.addMessage({ platform: 'twitch', author: 'a', text: '1', messageId: 'g1' }, filter); + el.scrollHeight = 2000; + t.flush(); + eq(el.scrollTop, 2000, 'feed: and every flush lands on the end, however far it moved'); + ok(t.feed.isPinned(), 'feed: still following after a busy stretch'); + + // The viewer moving is a gesture, and is still honoured. + el.scrollTop = 400; + el.__fire('scroll'); + ok(!t.feed.isPinned(), 'feed: the viewer scrolling up does stop it following'); + eq(seen[seen.length - 1].pinned, false, 'feed: and that is reported'); + + // Held still while they read, however much the feed grows below them. + t.feed.addMessage({ platform: 'twitch', author: 'a', text: '2', messageId: 'g2' }, filter); + el.scrollHeight = 2600; + t.flush(); + eq(el.scrollTop, 400, 'feed: and they are left exactly where they were reading'); + ok(!t.feed.isPinned(), 'feed: growth below them does not drag them back'); + + // Back at the end under their own steam, and it follows again. + el.scrollTop = 2200; + el.__fire('scroll'); + ok(t.feed.isPinned(), 'feed: scrolling back to the end follows again'); + eq(seen[seen.length - 1].missed, 0, 'feed: with nothing left outstanding'); + } + + // ── A hidden panel is not a viewer scrolling ── + // + // Collapsed or popped away, the feed has no box and every measurement off it + // reads zero. That must not be taken for anything. + { + const t = build(); + const el = t.feedEl; + el.scrollHeight = 1000; el.clientHeight = 400; el.scrollTop = 1000; + el.__fire('scroll'); + ok(t.feed.isPinned(), 'feed: following before the panel is put away'); + + el.clientHeight = 0; + el.__fire('scroll'); + ok(t.feed.isPinned(), 'feed: a panel with no box does not stop it following'); + + el.clientHeight = 400; + t.feed.addMessage({ platform: 'twitch', author: 'a', text: '1', messageId: 'h1' }, filter); + el.scrollHeight = 1400; + t.flush(); + eq(el.scrollTop, 1400, 'feed: and it is on the live end when the panel comes back'); + } + // ── The empty-state row belongs to the feed ── // // It used to be found by searching the feed for it on every queued message,