openvibers/openvibe.live Commits
Feed, context card, recap and channel avatars load at their drawn size (#534)
Changes
@@@ -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); }@@@ -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');@@@ -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>@@@ -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)}')"`);@@@ -38,4 +38,16 @@ const chatShared = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'cassert.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');
@@ -217,8 +217,10 @@ function _fillChannelAvatar(el, ch, path) {
@@ -217,8 +217,10 @@ function _fillChannelAvatar(el, ch, path) {
if (ch.avatar_url) {
if (ch.avatar_url) {
const img = document.createElement('img');
const img = document.createElement('img');
img.alt = '';
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);
el.replaceChildren(img);
}
}
if (path) { el.style.cursor = 'pointer'; el.onclick = () => navigate(path); }
if (path) { el.style.cursor = 'pointer'; el.onclick = () => navigate(path); }
@@ -80,16 +80,28 @@
@@ -80,16 +80,28 @@
const box = el('span', { class: 'cf-av', 'aria-hidden': 'true' });
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.profile_color && /^#[0-9a-f]{3,8}$/i.test(p.profile_color)) box.style.background = p.profile_color;
if (p && p.avatar_url) {
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);
box.append(img);
} else box.textContent = letter;
} else box.textContent = letter;
return box;
return box;
}
}
function thumb(url, fallbackIcon) {
function thumb(url, fallbackIcon) {
if (!url) return el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(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;
return img;
}
}
const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-wand-magic-sparkles'), 'AI');
const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-wand-magic-sparkles'), 'AI');
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
const go = (href) => (/^https?:\/\//.test(href) ? `href="${esc(href)}"` : `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`);
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 ago = (ts) => { try { return timeAgo(ts); } catch { return ''; } };
const GRADE = { S: '#fbbf24', A: '#a78bfa', B: '#38bdf8', C: '#94a3b8' };
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')}">
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>'}
${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>
<span class="cc-clip-dur">${dur(c.duration_seconds)}</span>
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
const n = (v) => Number(v || 0).toLocaleString();
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 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 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).
// 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)}')"`);
const go = (href) => (/^https?:\/\//.test(href) ? `href="${esc(href)}"` : `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`);
@@ -38,4 +38,16 @@ const chatShared = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'c
@@ -38,4 +38,16 @@ const chatShared = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'c
assert.match(chatShared, /data-full="\$\{esc\(p\.avatar_url\)\}"/);
assert.match(chatShared, /data-full="\$\{esc\(p\.avatar_url\)\}"/);
const messenger = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'messenger.js'), 'utf8');
const messenger = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'messenger.js'), 'utf8');
assert.match(messenger, /data-full="\$\{esc\(user\.avatar_url\)\}"/);
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');
console.log('avatar widths: all checks passed');