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,