Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
142 changes: 111 additions & 31 deletions src/content/feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,57 @@
// 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() {
const s = getSettings();
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) {
Expand All @@ -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 });
Expand All @@ -75,15 +131,18 @@
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.
if (pending.length > cap) pending.splice(0, pending.length - cap);

// 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;
}
Expand All @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions src/content/overlay.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; } }
Expand Down
80 changes: 80 additions & 0 deletions tests/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down