openvibers/openvibe.live Commits

Feed, context card, recap and channel avatars load at their drawn size (#534)

OP OpenVibers committed 12 days ago · 76bea1e1b9 · 5 files, +34 −8

* Feed, context card, recap and channel avatars load at their drawn size

* Merge branch 'main' into perf/feed-avatar-width

Changes

ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex d2bc3265..101be8fe 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -217,8 +217,10 @@ function _fillChannelAvatar(el, ch, path) {
if (ch.avatar_url) {
const img = document.createElement('img');
img.alt = '';
-- img.onerror = () => { el.textContent = letter; };
-- img.src = ch.avatar_url;
++ // At the drawn size: the original upload can be 3072 px and 3 MB (app.js ovImgW); the original once, then the letter.
++ const full = ch.avatar_url;
++ img.onerror = () => { if (img.src !== full && !img.dataset.retried) { img.dataset.retried = '1'; img.src = full; return; } el.textContent = letter; };
++ img.src = typeof ovImgW === 'function' ? ovImgW(full, 160) : full;
el.replaceChildren(img);
}
if (path) { el.style.cursor = 'pointer'; el.onclick = () => navigate(path); }
ddiff --git a/public/js/content-feed.js b/public/js/content-feed.js
iindex bbd1ba20..3c458fc0 100644
---- a/public/js/content-feed.js
++++ b/public/js/content-feed.js
@@@ -80,16 +80,28 @@
const box = el('span', { class: 'cf-av', 'aria-hidden': 'true' });
if (p && p.profile_color && /^#[0-9a-f]{3,8}$/i.test(p.profile_color)) box.style.background = p.profile_color;
if (p && p.avatar_url) {
-- const img = el('img', { src: p.avatar_url, alt: '', loading: 'lazy', decoding: 'async' });
-- img.addEventListener('error', () => { img.remove(); box.textContent = letter; }, { once: true });
++ // At the drawn size (26 px): the original upload can be 3072 px and 3 MB (app.js ovImgW). If the sized copy
++ // fails, the original once, then the letter.
++ const full = p.avatar_url;
++ const sized = typeof ovImgW === 'function' ? ovImgW(full, 64) : full;
++ const img = el('img', { src: sized, alt: '', loading: 'lazy', decoding: 'async' });
++ img.addEventListener('error', () => {
++ if (img.src !== full && !img.dataset.retried) { img.dataset.retried = '1'; img.src = full; return; }
++ img.remove(); box.textContent = letter;
++ });
box.append(img);
} else box.textContent = letter;
return box;
}
function thumb(url, fallbackIcon) {
if (!url) return el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon));
-- const img = el('img', { class: 'cf-thumb', src: url, alt: '', loading: 'lazy', decoding: 'async' });
-- img.addEventListener('error', () => img.replaceWith(el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon))), { once: true });
++ // A picture paste is its original upload (one is 2460 px, drawn at 390): Media's 640 px copy, the original once if it
++ // fails, then the placeholder.
++ const img = el('img', { class: 'cf-thumb', src: typeof ovImgW === 'function' ? ovImgW(url, 640) : url, alt: '', loading: 'lazy', decoding: 'async' });
++ img.addEventListener('error', () => {
++ if (img.src !== url && !img.dataset.retried) { img.dataset.retried = '1'; img.src = url; return; }
++ img.replaceWith(el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon)));
++ });
return img;
}
const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-wand-magic-sparkles'), 'AI');
ddiff --git a/public/js/context-cards.js b/public/js/context-cards.js
iindex 6677ff6e..33d9b3bf 100644
---- a/public/js/context-cards.js
++++ b/public/js/context-cards.js
@@@ -18,7 +18,7 @@
const go = (href) => (/^https?:\/\//.test(href) ? `href="${esc(href)}"` : `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`);
const ago = (ts) => { try { return timeAgo(ts); } catch { return ''; } };
const GRADE = { S: '#fbbf24', A: '#a78bfa', B: '#38bdf8', C: '#94a3b8' };
-- const av = (u, size) => `<span class="cc-avatar" style="width:${size}px;height:${size}px;${u && u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u && u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="" loading="lazy">` : esc(String((u && (u.display_name || u.username)) || '?').charAt(0).toUpperCase())}</span>`;
++ const av = (u, size) => `<span class="cc-avatar" style="width:${size}px;height:${size}px;${u && u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u && u.avatar_url ? `<img src="${esc(typeof ovImgW === 'function' ? ovImgW(u.avatar_url, size > 72 ? 320 : size > 40 ? 160 : 96) : u.avatar_url)}" data-full="${esc(u.avatar_url)}" alt="" loading="lazy" onerror="if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}">` : esc(String((u && (u.display_name || u.username)) || '?').charAt(0).toUpperCase())}</span>`;
const clipTile = (c, opts = {}) => `<a class="cc-clip" ${go(videoHref('clip', c.id))} title="${esc(c.title || 'Clip')}">
${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}
<span class="cc-clip-dur">${dur(c.duration_seconds)}</span>
ddiff --git a/public/js/recap.js b/public/js/recap.js
iindex 2aa8f49c..23cd72db 100644
---- a/public/js/recap.js
++++ b/public/js/recap.js
@@@ -9,7 +9,7 @@
const n = (v) => Number(v || 0).toLocaleString();
const dur = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; };
const when = (ts) => { const t = Date.parse(String(ts || '').replace(' ', 'T') + (String(ts || '').endsWith('Z') ? '' : 'Z')); return Number.isFinite(t) ? new Date(t).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''; };
-- const av = (u, size) => `<span class="recap-avatar" style="width:${size}px;height:${size}px;${u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="" loading="lazy">` : esc(String(u.display_name || u.username || '?').charAt(0).toUpperCase())}</span>`;
++ const av = (u, size) => `<span class="recap-avatar" style="width:${size}px;height:${size}px;${u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u.avatar_url ? `<img src="${esc(typeof ovImgW === 'function' ? ovImgW(u.avatar_url, size > 72 ? 320 : size > 40 ? 160 : 96) : u.avatar_url)}" data-full="${esc(u.avatar_url)}" alt="" loading="lazy" onerror="if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}">` : esc(String(u.display_name || u.username || '?').charAt(0).toUpperCase())}</span>`;
// Inside Live the SPA navigates; a recording or clip opens on openvibe.video (a plain link).
const go = (href) => (/^https?:\/\//.test(href) ? `href="${esc(href)}"` : `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`);
ddiff --git a/test/avatar-widths.test.js b/test/avatar-widths.test.js
iindex c0e3520e..e70955a8 100644
---- a/test/avatar-widths.test.js
++++ b/test/avatar-widths.test.js
@@@ -38,4 +38,16 @@ const chatShared = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'c
assert.match(chatShared, /data-full="\$\{esc\(p\.avatar_url\)\}"/);
const messenger = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'messenger.js'), 'utf8');
assert.match(messenger, /data-full="\$\{esc\(user\.avatar_url\)\}"/);
++// The content feed (Content, AI Moments) builds its avatars as nodes.
++const feed = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'content-feed.js'), 'utf8');
++assert.match(feed, /typeof ovImgW === 'function' \? ovImgW\(full, 64\) : full/);
++assert.match(feed, /if \(img\.src !== full && !img\.dataset\.retried\) \{ img\.dataset\.retried = '1'; img\.src = full; return; \}/);
++// Context cards, the after-show recap and the channel header too.
++const cards = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'context-cards.js'), 'utf8');
++assert.match(cards, /ovImgW\(u\.avatar_url, size > 72 \? 320 : size > 40 \? 160 : 96\)/);
++const recap = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'recap.js'), 'utf8');
++assert.match(recap, /ovImgW\(u\.avatar_url, size > 72 \? 320 : size > 40 \? 160 : 96\)/);
++const channel = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
++assert.match(channel, /img\.src = typeof ovImgW === 'function' \? ovImgW\(full, 160\) : full;/);
++assert.match(feed, /ovImgW\(url, 640\)/, 'feed pictures at 640');
console.log('avatar widths: all checks passed');