openvibers/openvibe.live Commits
Live updates: stop the page flashing, stop the scroll yanks, hide the strip when the board opens
Changes
@@@ -14804,6 +14804,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgwould make each chip absurdly long, so the strip caps and centres itself. Everything insidescales with clamp() rather than switching at breakpoints, so it reads the same on a 320pxphone, a landscape phone, a tablet and a monitor. */++/* An author `display` declaration beats the browser's own [hidden] { display: none } rule, so++ setting .hidden on the strip did nothing and the expanded board showed both copies of the same++ nine numbers. Restate it explicitly. */++.hero-stat-strip[hidden] { display: none !important; }.hero-stat-strip {display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));gap: clamp(4px, 0.8vw, 9px); padding: clamp(7px, 1vw, 11px);@@@ -88,7 +88,7 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat it as non-critical, and the onload flips it back to all once it has arrived. --><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>-- <link rel="stylesheet" href="/css/style.css?v=172">++ <link rel="stylesheet" href="/css/style.css?v=173"><link rel="stylesheet" href="/css/home-fx.css?v=10"><link rel="stylesheet" href="/css/home-tour.css?v=7"><link rel="stylesheet" href="/css/i18n-star.css?v=5">@@@ -5129,7 +5129,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/live-notify.js?v=3" defer></script><script src="/js/ov-skeleton.js?v=1" defer></script><script src="/js/ov-odometer.js?v=3" defer></script>--<script src="/js/app.js?v=147" defer></script>++<script src="/js/app.js?v=148" defer></script><script src="/js/stream-player.js?v=21" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -5186,7 +5186,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig</div><script src="/js/vibes.js?v=11" defer></script>--<script src="/js/opencoins.js?v=6" defer></script>++<script src="/js/opencoins.js?v=7" defer></script><script src="/js/cosmetics-ui.js?v=2" defer></script><!-- Broadcast modules — load order matters: state → devices → vods → workspace → core --><script src="/js/broadcast-state.js?v=1" defer></script>@@@ -5207,12 +5207,12 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/powerchat.js?v=10" defer></script><script src="/js/pastes.js?v=15" defer></script><script src="/js/draggable-fab.js?v=2" defer></script>--<script src="/js/messenger.js?v=2" defer></script>--<script src="/js/ov-mark.js?v=1" defer></script>++<script src="/js/messenger.js?v=3" defer></script>++<script src="/js/ov-mark.js?v=2" defer></script><script src="/shared/tooltip.js?v=1" defer></script><script src="/shared/footer.js?v=1" defer></script><script src="/js/footer-live.js?v=1" defer></script>--<script src="/js/home-fx.js?v=8" defer></script>++<script src="/js/home-fx.js?v=9" defer></script><script src="/js/home-tour.js?v=6" defer></script><script src="/js/ov-guide.js?v=1" defer></script><script src="/js/guides-live.js?v=5" defer></script>@@@ -4153,6 +4153,7 @@ function getLastStream(username) {/*** Auto-scroll the active tab into view within the tab bar.*/++let _lastScrolledTab = '';function scrollActiveTabIntoView() {const active = document.querySelector('.live-tab.active');if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });@@@ -4226,8 +4227,11 @@ function loadLiveStreamTabs(currentUsername, activeStreamId, channelStreams = []<i class="fa-solid fa-eye"></i> <strong>${totalViewers}</strong> total</span>`;-- // Auto-scroll active tab into view after render-- requestAnimationFrame(scrollActiveTabIntoView);++ // Auto-scroll the active tab into view, but only when it actually changed. This render runs++ // on the 15-second channel poll (every 2 seconds during a go-live burst), and smooth-scrolling++ // the strip on every tick moved it under the reader's finger for no reason.++ const activeNow = document.querySelector('.live-tab.active')?.dataset.username || '';++ if (activeNow !== _lastScrolledTab) { _lastScrolledTab = activeNow; requestAnimationFrame(scrollActiveTabIntoView); }// Setup keyboard navigation (arrow keys between tabs)setupTabKeyboardNav(tabsScroll, currentUsername);@@@ -5470,6 +5474,8 @@ function _startCaptions(username, displayName, isLive) {const box = document.getElementById('ch-captions-lines');if (!box) return;box.querySelector('.ch-captions-wait')?.remove();++ // Is the reader following the live edge right now? Decide before we touch the DOM.++ const wasPinned = box.scrollHeight - box.scrollTop - box.clientHeight < 60;for (const l of lines) {_captionsAfter = Math.max(_captionsAfter, Number(l.id) || 0);const row = document.createElement('div');@@@ -5479,8 +5485,17 @@ function _startCaptions(username, displayName, isLive) {: `<div class="ch-caption-en">${esc(l.text)}</div>`;box.appendChild(row);}-- while (box.children.length > 14) box.removeChild(box.firstChild);-- box.scrollTop = box.scrollHeight;++ // Measure BEFORE trimming and before deciding to follow: scrollHeight already includes++ // whatever we just appended, and trimming from the top moves the content up under the++ // reader. Someone who scrolled back to re-read a caption should stay where they put++ // themselves instead of being yanked to the bottom every six seconds.++ const follow = wasPinned;++ while (box.children.length > 14) {++ const gone = box.firstChild.getBoundingClientRect().height;++ box.removeChild(box.firstChild);++ if (!follow) box.scrollTop = Math.max(0, box.scrollTop - gone);++ }++ if (follow) box.scrollTop = box.scrollHeight;};void tick();_captionsTimer = setInterval(tick, 6000);@@@ -88,7 +88,7 @@}// ── Scroll reveal ──────────────────────────────────────────-- let io = null, revealSafety = 0, revealPasses = 0;++ let io = null, revealSafety = 0, revealPasses = 0, firstRevealPass = true;/*** Failsafe for the reveal animation.*@@@ -119,7 +119,21 @@if (!('IntersectionObserver' in window)) return;if (!io) io = new IntersectionObserver((entries) => { for (const en of entries) if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });const sel = '#page-home .section-header, #page-home .stream-card, #page-home .home-cta-banner, #page-home .home-star-section, #page-home .rs-hero-mount, #page-home .pulse-grid > *, #page-home .moments-row > *, #page-home .home-digest';-- document.querySelectorAll(sel).forEach((el, i) => { if (el.classList.contains('hfx-reveal')) return; el.classList.add('hfx-reveal'); el.style.setProperty('--d', `${(i % 6) * 60}ms`); io.observe(el); });++ document.querySelectorAll(sel).forEach((el, i) => {++ if (el.classList.contains('hfx-reveal')) return;++ // Anything already on screen after the first pass is NOT new to the reader — it is a++ // card the 60-second section refresh just rebuilt underneath them. Fading it out and++ // back in made the whole home page flash once a minute, collapsing expanded AI++ // overviews and destroying text selection on the way. Show it and move on.++ if (!firstRevealPass) {++ const r = el.getBoundingClientRect();++ if (r.height && r.top < window.innerHeight && r.bottom > 0) { el.classList.add('hfx-reveal', 'is-in'); return; }++ }++ el.classList.add('hfx-reveal');++ el.style.setProperty('--d', `${(i % 6) * 60}ms`);++ io.observe(el);++ });++ firstRevealPass = false;startRevealSafety();}@@@ -204,7 +218,11 @@});watch();// Sections mount asynchronously, so pick up anything that arrives later.-- try { new MutationObserver(watch).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }++ try {++ let pending = 0;++ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; watch(); }); };++ new MutationObserver(queue).observe(document.body, { childList: true, subtree: true });++ } catch { /* */ }}function boot() {@@@ -216,7 +234,14 @@if (page && 'MutationObserver' in window) new MutationObserver(apply).observe(page, { attributes: true, attributeFilter: ['class'] });// New cards arrive as sections load — reveal them too.const container = page && page.querySelector('.container');-- if (container) new MutationObserver(() => { if (onHome()) attachReveal(); }).observe(container, { childList: true, subtree: true });++ if (container) {++ // Coalesce to one pass per frame. Unthrottled, this ran an eight-selector++ // querySelectorAll over the whole document on every single DOM mutation — every chat++ // message, every grid tick, every activity row.++ let pending = 0;++ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; if (onHome()) attachReveal(); }); };++ new MutationObserver(queue).observe(container, { childList: true, subtree: true });++ }}if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();})();@@@ -431,10 +431,12 @@</div></div>`;}++ // Was the reader at the live edge before we replaced the thread? Measured first, because++ // after the write scrollHeight already includes the new messages. Someone reading back++ // through a conversation should not be thrown to the bottom every three seconds.++ const wasPinned = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80 || thread.scrollTop === 0;thread.innerHTML = html;---- // Scroll to bottom-- thread.scrollTop = thread.scrollHeight;++ if (wasPinned) thread.scrollTop = thread.scrollHeight;// Wire up message delete buttonsthread.querySelectorAll('.msg-bubble-delete').forEach(btn => {@@@ -237,8 +237,11 @@ function renderRedemption(msg, container) {const el = document.createElement('div');el.className = 'chat-msg redemption';el.innerHTML = `<i class="fa-solid fa-gem" style="color:${esc(msg.reward_color || 'var(--accent)')}"></i> <strong>${esc(msg.username || 'Someone')}</strong> redeemed <strong>${esc(msg.reward_title || 'a reward')}</strong>${msg.user_input ? `: ${esc(msg.user_input)}` : ''} <span class="muted">(${(msg.cost || 0).toLocaleString()} ${esc(channelPointsName())})</span>`;++ // Follow the live edge only if the reader was already there — measured before the append,++ // since scrollHeight counts the new row the moment it lands.++ const wasPinned = container.scrollHeight - container.scrollTop - container.clientHeight < 100;container.appendChild(el);-- container.scrollTop = container.scrollHeight;++ if (wasPinned) container.scrollTop = container.scrollHeight;}// ── Add Reward Modal (Dashboard) ─────────────────────────────@@@ -52,7 +52,13 @@function init() {if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }mount();-- try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }++ try {++ // One pass per frame, not one per mutation. Unthrottled this ran a full-document++ // querySelectorAll on every chat message and every feed tick.++ let pending = 0;++ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; mount(); }); };++ new MutationObserver(queue).observe(document.body, { childList: true, subtree: true });++ } catch { /* */ }}window.ovMarkMount = mount;if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
@@ -14804,6 +14804,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -14804,6 +14804,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
would make each chip absurdly long, so the strip caps and centres itself. Everything inside
would make each chip absurdly long, so the strip caps and centres itself. Everything inside
scales with clamp() rather than switching at breakpoints, so it reads the same on a 320px
scales with clamp() rather than switching at breakpoints, so it reads the same on a 320px
phone, a landscape phone, a tablet and a monitor. */
phone, a landscape phone, a tablet and a monitor. */
+/* An author `display` declaration beats the browser's own [hidden] { display: none } rule, so
+ setting .hidden on the strip did nothing and the expanded board showed both copies of the same
+ nine numbers. Restate it explicitly. */
+.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip {
.hero-stat-strip {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(4px, 0.8vw, 9px); padding: clamp(7px, 1vw, 11px);
gap: clamp(4px, 0.8vw, 9px); padding: clamp(7px, 1vw, 11px);
@@ -88,7 +88,7 @@
@@ -88,7 +88,7 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
- <link rel="stylesheet" href="/css/style.css?v=172">
+ <link rel="stylesheet" href="/css/style.css?v=173">
<link rel="stylesheet" href="/css/home-fx.css?v=10">
<link rel="stylesheet" href="/css/home-fx.css?v=10">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@ -5129,7 +5129,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5129,7 +5129,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
-<script src="/js/app.js?v=147" defer></script>
+<script src="/js/app.js?v=148" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -5186,7 +5186,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5186,7 +5186,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
</div>
</div>
<script src="/js/vibes.js?v=11" defer></script>
<script src="/js/vibes.js?v=11" defer></script>
-<script src="/js/opencoins.js?v=6" defer></script>
+<script src="/js/opencoins.js?v=7" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<script src="/js/broadcast-state.js?v=1" defer></script>
<script src="/js/broadcast-state.js?v=1" defer></script>
@@ -5207,12 +5207,12 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5207,12 +5207,12 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
-<script src="/js/messenger.js?v=2" defer></script>
-<script src="/js/ov-mark.js?v=1" defer></script>
+<script src="/js/messenger.js?v=3" defer></script>
+<script src="/js/ov-mark.js?v=2" defer></script>
<script src="/shared/tooltip.js?v=1" defer></script>
<script src="/shared/tooltip.js?v=1" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
-<script src="/js/home-fx.js?v=8" defer></script>
+<script src="/js/home-fx.js?v=9" defer></script>
<script src="/js/home-tour.js?v=6" defer></script>
<script src="/js/home-tour.js?v=6" defer></script>
<script src="/js/ov-guide.js?v=1" defer></script>
<script src="/js/ov-guide.js?v=1" defer></script>
<script src="/js/guides-live.js?v=5" defer></script>
<script src="/js/guides-live.js?v=5" defer></script>
@@ -4153,6 +4153,7 @@ function getLastStream(username) {
@@ -4153,6 +4153,7 @@ function getLastStream(username) {
/**
/**
* Auto-scroll the active tab into view within the tab bar.
* Auto-scroll the active tab into view within the tab bar.
*/
*/
+let _lastScrolledTab = '';
function scrollActiveTabIntoView() {
function scrollActiveTabIntoView() {
const active = document.querySelector('.live-tab.active');
const active = document.querySelector('.live-tab.active');
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
@@ -4226,8 +4227,11 @@ function loadLiveStreamTabs(currentUsername, activeStreamId, channelStreams = []
@@ -4226,8 +4227,11 @@ function loadLiveStreamTabs(currentUsername, activeStreamId, channelStreams = []
<i class="fa-solid fa-eye"></i> <strong>${totalViewers}</strong> total
<i class="fa-solid fa-eye"></i> <strong>${totalViewers}</strong> total
</span>`;
</span>`;
- // Auto-scroll active tab into view after render
- requestAnimationFrame(scrollActiveTabIntoView);
+ // Auto-scroll the active tab into view, but only when it actually changed. This render runs
+ // on the 15-second channel poll (every 2 seconds during a go-live burst), and smooth-scrolling
+ // the strip on every tick moved it under the reader's finger for no reason.
+ const activeNow = document.querySelector('.live-tab.active')?.dataset.username || '';
+ if (activeNow !== _lastScrolledTab) { _lastScrolledTab = activeNow; requestAnimationFrame(scrollActiveTabIntoView); }
// Setup keyboard navigation (arrow keys between tabs)
// Setup keyboard navigation (arrow keys between tabs)
setupTabKeyboardNav(tabsScroll, currentUsername);
setupTabKeyboardNav(tabsScroll, currentUsername);
@@ -5470,6 +5474,8 @@ function _startCaptions(username, displayName, isLive) {
@@ -5470,6 +5474,8 @@ function _startCaptions(username, displayName, isLive) {
const box = document.getElementById('ch-captions-lines');
const box = document.getElementById('ch-captions-lines');
if (!box) return;
if (!box) return;
box.querySelector('.ch-captions-wait')?.remove();
box.querySelector('.ch-captions-wait')?.remove();
+ // Is the reader following the live edge right now? Decide before we touch the DOM.
+ const wasPinned = box.scrollHeight - box.scrollTop - box.clientHeight < 60;
for (const l of lines) {
for (const l of lines) {
_captionsAfter = Math.max(_captionsAfter, Number(l.id) || 0);
_captionsAfter = Math.max(_captionsAfter, Number(l.id) || 0);
const row = document.createElement('div');
const row = document.createElement('div');
@@ -5479,8 +5485,17 @@ function _startCaptions(username, displayName, isLive) {
@@ -5479,8 +5485,17 @@ function _startCaptions(username, displayName, isLive) {
: `<div class="ch-caption-en">${esc(l.text)}</div>`;
: `<div class="ch-caption-en">${esc(l.text)}</div>`;
box.appendChild(row);
box.appendChild(row);
}
}
- while (box.children.length > 14) box.removeChild(box.firstChild);
- box.scrollTop = box.scrollHeight;
+ // Measure BEFORE trimming and before deciding to follow: scrollHeight already includes
+ // whatever we just appended, and trimming from the top moves the content up under the
+ // reader. Someone who scrolled back to re-read a caption should stay where they put
+ // themselves instead of being yanked to the bottom every six seconds.
+ const follow = wasPinned;
+ while (box.children.length > 14) {
+ const gone = box.firstChild.getBoundingClientRect().height;
+ box.removeChild(box.firstChild);
+ if (!follow) box.scrollTop = Math.max(0, box.scrollTop - gone);
+ }
+ if (follow) box.scrollTop = box.scrollHeight;
};
};
void tick();
void tick();
_captionsTimer = setInterval(tick, 6000);
_captionsTimer = setInterval(tick, 6000);
@@ -88,7 +88,7 @@
@@ -88,7 +88,7 @@
}
}
// ── Scroll reveal ──────────────────────────────────────────
// ── Scroll reveal ──────────────────────────────────────────
- let io = null, revealSafety = 0, revealPasses = 0;
+ let io = null, revealSafety = 0, revealPasses = 0, firstRevealPass = true;
/**
/**
* Failsafe for the reveal animation.
* Failsafe for the reveal animation.
*
*
@@ -119,7 +119,21 @@
@@ -119,7 +119,21 @@
if (!('IntersectionObserver' in window)) return;
if (!('IntersectionObserver' in window)) return;
if (!io) io = new IntersectionObserver((entries) => { for (const en of entries) if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
if (!io) io = new IntersectionObserver((entries) => { for (const en of entries) if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
const sel = '#page-home .section-header, #page-home .stream-card, #page-home .home-cta-banner, #page-home .home-star-section, #page-home .rs-hero-mount, #page-home .pulse-grid > *, #page-home .moments-row > *, #page-home .home-digest';
const sel = '#page-home .section-header, #page-home .stream-card, #page-home .home-cta-banner, #page-home .home-star-section, #page-home .rs-hero-mount, #page-home .pulse-grid > *, #page-home .moments-row > *, #page-home .home-digest';
- document.querySelectorAll(sel).forEach((el, i) => { if (el.classList.contains('hfx-reveal')) return; el.classList.add('hfx-reveal'); el.style.setProperty('--d', `${(i % 6) * 60}ms`); io.observe(el); });
+ document.querySelectorAll(sel).forEach((el, i) => {
+ if (el.classList.contains('hfx-reveal')) return;
+ // Anything already on screen after the first pass is NOT new to the reader — it is a
+ // card the 60-second section refresh just rebuilt underneath them. Fading it out and
+ // back in made the whole home page flash once a minute, collapsing expanded AI
+ // overviews and destroying text selection on the way. Show it and move on.
+ if (!firstRevealPass) {
+ const r = el.getBoundingClientRect();
+ if (r.height && r.top < window.innerHeight && r.bottom > 0) { el.classList.add('hfx-reveal', 'is-in'); return; }
+ }
+ el.classList.add('hfx-reveal');
+ el.style.setProperty('--d', `${(i % 6) * 60}ms`);
+ io.observe(el);
+ });
+ firstRevealPass = false;
startRevealSafety();
startRevealSafety();
}
}
@@ -204,7 +218,11 @@
@@ -204,7 +218,11 @@
});
});
watch();
watch();
// Sections mount asynchronously, so pick up anything that arrives later.
// Sections mount asynchronously, so pick up anything that arrives later.
- try { new MutationObserver(watch).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
+ try {
+ let pending = 0;
+ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; watch(); }); };
+ new MutationObserver(queue).observe(document.body, { childList: true, subtree: true });
+ } catch { /* */ }
}
}
function boot() {
function boot() {
@@ -216,7 +234,14 @@
@@ -216,7 +234,14 @@
if (page && 'MutationObserver' in window) new MutationObserver(apply).observe(page, { attributes: true, attributeFilter: ['class'] });
if (page && 'MutationObserver' in window) new MutationObserver(apply).observe(page, { attributes: true, attributeFilter: ['class'] });
// New cards arrive as sections load — reveal them too.
// New cards arrive as sections load — reveal them too.
const container = page && page.querySelector('.container');
const container = page && page.querySelector('.container');
- if (container) new MutationObserver(() => { if (onHome()) attachReveal(); }).observe(container, { childList: true, subtree: true });
+ if (container) {
+ // Coalesce to one pass per frame. Unthrottled, this ran an eight-selector
+ // querySelectorAll over the whole document on every single DOM mutation — every chat
+ // message, every grid tick, every activity row.
+ let pending = 0;
+ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; if (onHome()) attachReveal(); }); };
+ new MutationObserver(queue).observe(container, { childList: true, subtree: true });
+ }
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
})();
})();
@@ -431,10 +431,12 @@
@@ -431,10 +431,12 @@
</div>
</div>
</div>`;
</div>`;
}
}
+ // Was the reader at the live edge before we replaced the thread? Measured first, because
+ // after the write scrollHeight already includes the new messages. Someone reading back
+ // through a conversation should not be thrown to the bottom every three seconds.
+ const wasPinned = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80 || thread.scrollTop === 0;
thread.innerHTML = html;
thread.innerHTML = html;
-
- // Scroll to bottom
- thread.scrollTop = thread.scrollHeight;
+ if (wasPinned) thread.scrollTop = thread.scrollHeight;
// Wire up message delete buttons
// Wire up message delete buttons
thread.querySelectorAll('.msg-bubble-delete').forEach(btn => {
thread.querySelectorAll('.msg-bubble-delete').forEach(btn => {
@@ -237,8 +237,11 @@ function renderRedemption(msg, container) {
@@ -237,8 +237,11 @@ function renderRedemption(msg, container) {
const el = document.createElement('div');
const el = document.createElement('div');
el.className = 'chat-msg redemption';
el.className = 'chat-msg redemption';
el.innerHTML = `<i class="fa-solid fa-gem" style="color:${esc(msg.reward_color || 'var(--accent)')}"></i> <strong>${esc(msg.username || 'Someone')}</strong> redeemed <strong>${esc(msg.reward_title || 'a reward')}</strong>${msg.user_input ? `: ${esc(msg.user_input)}` : ''} <span class="muted">(${(msg.cost || 0).toLocaleString()} ${esc(channelPointsName())})</span>`;
el.innerHTML = `<i class="fa-solid fa-gem" style="color:${esc(msg.reward_color || 'var(--accent)')}"></i> <strong>${esc(msg.username || 'Someone')}</strong> redeemed <strong>${esc(msg.reward_title || 'a reward')}</strong>${msg.user_input ? `: ${esc(msg.user_input)}` : ''} <span class="muted">(${(msg.cost || 0).toLocaleString()} ${esc(channelPointsName())})</span>`;
+ // Follow the live edge only if the reader was already there — measured before the append,
+ // since scrollHeight counts the new row the moment it lands.
+ const wasPinned = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
container.appendChild(el);
container.appendChild(el);
- container.scrollTop = container.scrollHeight;
+ if (wasPinned) container.scrollTop = container.scrollHeight;
}
}
// ── Add Reward Modal (Dashboard) ─────────────────────────────
// ── Add Reward Modal (Dashboard) ─────────────────────────────
@@ -52,7 +52,13 @@
@@ -52,7 +52,13 @@
function init() {
function init() {
if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
mount();
mount();
- try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
+ try {
+ // One pass per frame, not one per mutation. Unthrottled this ran a full-document
+ // querySelectorAll on every chat message and every feed tick.
+ let pending = 0;
+ const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; mount(); }); };
+ new MutationObserver(queue).observe(document.body, { childList: true, subtree: true });
+ } catch { /* */ }
}
}
window.ovMarkMount = mount;
window.ovMarkMount = mount;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();