lazyloading
This commit is contained in:
@@ -23,6 +23,22 @@
|
|||||||
let chatFocused = document.hasFocus();
|
let chatFocused = document.hasFocus();
|
||||||
const ytOembedCache = new Map(); // videoId → {title, author_name}
|
const ytOembedCache = new Map(); // videoId → {title, author_name}
|
||||||
|
|
||||||
|
// Shared IntersectionObserver for lazy-loading embedded images.
|
||||||
|
// Images are rendered with data-lazy-src; this observer sets the real src
|
||||||
|
// when the image is within 200px of the visible scroll area.
|
||||||
|
const lazyImgObserver = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue;
|
||||||
|
const img = entry.target;
|
||||||
|
const src = img.dataset.lazySrc;
|
||||||
|
if (src) { img.src = src; delete img.dataset.lazySrc; }
|
||||||
|
lazyImgObserver.unobserve(img);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
rootMargin: '200px', // start loading 200px before entering viewport
|
||||||
|
threshold: 0
|
||||||
|
});
|
||||||
|
|
||||||
function updateBadge() {
|
function updateBadge() {
|
||||||
const badge = document.getElementById('gchat-badge');
|
const badge = document.getElementById('gchat-badge');
|
||||||
const bubble = document.getElementById('gchat-reopen-bubble');
|
const bubble = document.getElementById('gchat-reopen-bubble');
|
||||||
@@ -187,7 +203,7 @@
|
|||||||
'gi'
|
'gi'
|
||||||
);
|
);
|
||||||
html = html.replace(imageRegex, url =>
|
html = html.replace(imageRegex, url =>
|
||||||
`<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
|
`<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6b. Raw video URLs from allowed hosts → <video>
|
// 6b. Raw video URLs from allowed hosts → <video>
|
||||||
@@ -270,7 +286,7 @@
|
|||||||
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
|
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
|
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
|
||||||
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
|
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
return `${pre}<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
||||||
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
|
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
|
||||||
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
||||||
}
|
}
|
||||||
@@ -310,13 +326,17 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(force = false) {
|
function scrollToBottom(force = false, smooth = false) {
|
||||||
const el = document.getElementById('gchat-messages');
|
const el = document.getElementById('gchat-messages');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||||
if (!force && !nearBottom) return;
|
if (!force && !nearBottom) return;
|
||||||
// Double rAF ensures the browser has committed the layout before we measure scrollHeight
|
if (smooth) {
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }));
|
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
// Double rAF ensures layout is committed before reading scrollHeight
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,9 +371,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (typeof ResizeObserver !== 'undefined') {
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
// Debounce: batch rapid layout changes (e.g. progressive image renders)
|
||||||
|
// into a single smooth scroll instead of many jarring instant jumps.
|
||||||
|
let debounceTimer = null;
|
||||||
ro = new ResizeObserver(() => {
|
ro = new ResizeObserver(() => {
|
||||||
if (userScrolledUp) { cleanup(); return; }
|
if (userScrolledUp) { cleanup(); return; }
|
||||||
el.scrollTop = el.scrollHeight;
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
}, 80);
|
||||||
});
|
});
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
} else {
|
} else {
|
||||||
@@ -364,10 +390,9 @@
|
|||||||
|
|
||||||
setTimeout(cleanup, durationMs);
|
setTimeout(cleanup, durationMs);
|
||||||
|
|
||||||
// Immediate snaps
|
// First snap is instant (no animation — the panel just opened)
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 200);
|
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 150);
|
||||||
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchYtOembed(cardEl) {
|
async function fetchYtOembed(cardEl) {
|
||||||
@@ -470,9 +495,16 @@
|
|||||||
s.addEventListener('click', () => s.classList.toggle('revealed'));
|
s.addEventListener('click', () => s.classList.toggle('revealed'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Embedded images: scroll to bottom when loaded + open modal on click
|
// Embedded images: register with lazy observer; scroll on load only for new messages (not history)
|
||||||
node.querySelectorAll('.gchat-embed-img img').forEach(img => {
|
node.querySelectorAll('.gchat-embed-img img[data-lazy-src]').forEach(img => {
|
||||||
img.addEventListener('load', () => scrollToBottom(scrollForce));
|
img.addEventListener('load', () => scrollToBottom(scrollForce));
|
||||||
|
img.addEventListener('click', () => openImgModal(img.src));
|
||||||
|
img.style.cursor = 'zoom-in';
|
||||||
|
lazyImgObserver.observe(img);
|
||||||
|
});
|
||||||
|
// Also handle already-src'd images (avatars etc.)
|
||||||
|
node.querySelectorAll('.gchat-embed-img img:not([data-lazy-src])').forEach(img => {
|
||||||
|
img.addEventListener('load', () => scrollToBottom(scrollForce));
|
||||||
img.addEventListener('click', () => openImgModal(img.src));
|
img.addEventListener('click', () => openImgModal(img.src));
|
||||||
img.style.cursor = 'zoom-in';
|
img.style.cursor = 'zoom-in';
|
||||||
});
|
});
|
||||||
@@ -498,10 +530,9 @@
|
|||||||
if (!data.success) return;
|
if (!data.success) return;
|
||||||
const container = document.getElementById('gchat-messages');
|
const container = document.getElementById('gchat-messages');
|
||||||
if (container) container.innerHTML = '';
|
if (container) container.innerHTML = '';
|
||||||
// Pass scrollForce=true so every img.load in the batch force-scrolls
|
(data.messages || []).forEach(m => appendMsg(m, false));
|
||||||
(data.messages || []).forEach(m => appendMsg(m, true));
|
// One instant snap — images above viewport won't load (lazy) so no layout shift
|
||||||
// Start sticky scroll: watches ResizeObserver + input events, holds 8s
|
container.scrollTop = container.scrollHeight;
|
||||||
startStickyScroll(8000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Chat] Failed to load history:', e);
|
console.error('[Chat] Failed to load history:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1201,8 +1201,10 @@ if (window.__dmLoaded) {
|
|||||||
bubble.appendChild(placeholder);
|
bubble.appendChild(placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-decrypt and render
|
// Defer decrypt until the placeholder scrolls near the viewport.
|
||||||
(async () => {
|
// This means attachments above the initial view never cause layout shifts.
|
||||||
|
const decryptWhenVisible = () => {
|
||||||
|
(async () => {
|
||||||
const blob = await fetchAndDecryptAttachment(att.id, att.mime);
|
const blob = await fetchAndDecryptAttachment(att.id, att.mime);
|
||||||
if (!blob || !placeholder.parentNode) return;
|
if (!blob || !placeholder.parentNode) return;
|
||||||
|
|
||||||
@@ -1264,6 +1266,16 @@ if (window.__dmLoaded) {
|
|||||||
requestAnimationFrame(() => requestAnimationFrame(snapIfAtBottom));
|
requestAnimationFrame(() => requestAnimationFrame(snapIfAtBottom));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
}; // end decryptWhenVisible
|
||||||
|
|
||||||
|
// Use IntersectionObserver to defer decryption until near viewport.
|
||||||
|
// Bottom messages (already visible) fire immediately; older ones wait.
|
||||||
|
const io = new IntersectionObserver((entries, obs) => {
|
||||||
|
if (!entries[0].isIntersecting) return;
|
||||||
|
obs.disconnect();
|
||||||
|
decryptWhenVisible();
|
||||||
|
}, { rootMargin: '200px', threshold: 0 });
|
||||||
|
io.observe(placeholder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2481,9 +2493,13 @@ if (window.__dmLoaded) {
|
|||||||
|
|
||||||
// ResizeObserver: re-snap whenever content grows (images, attachments decrypting)
|
// ResizeObserver: re-snap whenever content grows (images, attachments decrypting)
|
||||||
if (typeof ResizeObserver !== 'undefined') {
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
let debounceTimer = null;
|
||||||
ro = new ResizeObserver(() => {
|
ro = new ResizeObserver(() => {
|
||||||
if (userScrolledUp) { cleanup(); return; }
|
if (userScrolledUp) { cleanup(); return; }
|
||||||
el.scrollTop = el.scrollHeight;
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
}, 80);
|
||||||
});
|
});
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
} else {
|
} else {
|
||||||
@@ -2496,7 +2512,7 @@ if (window.__dmLoaded) {
|
|||||||
// Disconnect after the deadline regardless
|
// Disconnect after the deadline regardless
|
||||||
setTimeout(cleanup, durationMs);
|
setTimeout(cleanup, durationMs);
|
||||||
|
|
||||||
// Immediate snaps as safety net for content already in the DOM
|
// First snaps are instant (panel just opened — no animation needed)
|
||||||
snapToBottom(el, true);
|
snapToBottom(el, true);
|
||||||
setTimeout(() => { if (!userScrolledUp) snapToBottom(el, true); }, 200);
|
setTimeout(() => { if (!userScrolledUp) snapToBottom(el, true); }, 200);
|
||||||
setTimeout(() => { if (!userScrolledUp) snapToBottom(el, true); }, 600);
|
setTimeout(() => { if (!userScrolledUp) snapToBottom(el, true); }, 600);
|
||||||
|
|||||||
Reference in New Issue
Block a user