openvibers/openvibe.live Commits
Avatars load at the size they are drawn (#532)
Changes
@@@ -1720,7 +1720,7 @@ async function loadHomePastes(opts) {const icon = p.type === 'screenshot' ? 'fa-image' : (p.language && p.language !== 'plaintext' ? 'fa-code' : 'fa-file-lines');const preview = p.type === 'paste' ? esc((p.content || '').slice(0, 220)).replace(/\n{3,}/g, '\n\n') : '';const media = p.type === 'screenshot' && p.screenshot_url-- ? `<div class="home-paste-media"><img src="${esc(p.screenshot_url)}" alt="${esc(p.title || 'Screenshot paste')}" loading="lazy"><span class="home-paste-type">Image</span></div>`++ ? `<div class="home-paste-media"><img src="${esc(ovImgW(p.screenshot_url, 480))}" data-full="${esc(p.screenshot_url)}" alt="${esc(p.title || 'Screenshot paste')}" loading="lazy" onerror="${OV_IMG_RETRY}"><span class="home-paste-type">Image</span></div>`: `<div class="home-paste-media"><div class="home-paste-snippet">${preview || esc(p.title || 'Untitled paste')}</div><div class="home-paste-icon"><i class="fa-solid ${icon}"></i></div><span class="home-paste-type">${p.language && p.language !== 'plaintext' ? esc(p.language) : 'Text'}</span></div>`;return `<a class="home-paste-card" href="${esc(pasteHref(p.slug))}">@@@ -2299,7 +2299,7 @@ function _renderHomeStar(data) {const path = channelPath(s.username);const initial = esc(String(s.display_name || s.username || '?').charAt(0).toUpperCase());const avatar = s.avatar_url-- ? `<img src="${esc(s.avatar_url)}" alt="${name}" loading="lazy">`++ ? `<img src="${esc(ovImgW(s.avatar_url, 160))}" data-full="${esc(s.avatar_url)}" alt="${name}" loading="lazy" onerror="${OV_IMG_RETRY}">`: initial;const bioSrc = (s.bio || '').trim();const bioEn = (s.bio_en || '').trim();@@@ -65,11 +65,21 @@ function channelPath(username, managedStreamIdOrSlug = null) {return `${base}/${encodeURIComponent(String(managedStreamIdOrSlug))}`;}++/** A Media image (openvibe.media/o/med_…) at a width from Media's ladder (64, 96, 160, 320, 480, 640); any other URL++ as it is. Avatars are kept as uploaded (one is 3072 px and 3 MB) and drawn at 24-80 px: the home page fetched 5.4 MB++ of them on a phone (2026-11-01). Put the original in data-full and start onerror with OV_IMG_RETRY, which loads it++ once if the sized copy fails (Media answers 404 for an object it cannot resize). */++function ovImgW(url, w) {++ const s = String(url || '');++ return /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]+$/.test(s) ? `${s}?w=${w}` : s;++}++const OV_IMG_RETRY = 'if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}';++/** Inner img-or-letter for an element that is itself the avatar circle. */function _avatarInner(url, name) {const letter = ((String(name || '?'))[0] || '?').toUpperCase();return url-- ? `<img src="${esc(url)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block" onerror="var p=this.parentNode;this.remove();if(p)p.textContent='${letter}'">`++ ? `<img src="${esc(ovImgW(url, 160))}" data-full="${esc(url)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block" onerror="${OV_IMG_RETRY}var p=this.parentNode;this.remove();if(p)p.textContent='${letter}'">`: letter;}@@@ -79,7 +89,7 @@ function _avatarSpan(url, name, color, extraCls) {const cls = extraCls ? ` ${extraCls}` : '';const bg = color ? ` style="background:${esc(color)}"` : '';return url-- ? `<span class="stream-card-avatar${cls}"${bg}><img src="${esc(url)}" alt="" onerror="var p=this.parentNode;this.remove();if(p)p.textContent='${letter}'"></span>`++ ? `<span class="stream-card-avatar${cls}"${bg}><img src="${esc(ovImgW(url, 96))}" data-full="${esc(url)}" alt="" onerror="${OV_IMG_RETRY}var p=this.parentNode;this.remove();if(p)p.textContent='${letter}'"></span>`: `<span class="stream-card-avatar${cls}"${bg}>${letter}</span>`;}@@@ -440,7 +440,7 @@const tAgo = (d.deps && d.deps.timeAgoShort) || ((x) => x || '?');const numf = (d.deps && d.deps.formatNumber) || _num;const avatarHtml = p.avatar_url-- ? `<img class="ctx-avatar" src="${esc(p.avatar_url)}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display=''">`++ ? `<img class="ctx-avatar" src="${esc(typeof ovImgW === 'function' ? ovImgW(p.avatar_url, 64) : p.avatar_url)}" data-full="${esc(p.avatar_url)}" alt="" onerror="if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}this.style.display='none';this.nextElementSibling.style.display=''">`+ `<span class="ctx-avatar-letter" style="display:none;background:${esc(p.profile_color || '#999')}">${esc(username[0] ? username[0].toUpperCase() : '?')}</span>`: `<span class="ctx-avatar-letter" style="background:${esc(p.profile_color || '#999')}">${esc(username[0] ? username[0].toUpperCase() : '?')}</span>`;const badge = _badgeHTML(p.role);@@@ -2626,7 +2626,7 @@ function buildChatMessageEl(msg, opts = {}) {: `<div class="clip-chat-label"><i class="fa-solid fa-scissors"></i> New clip</div>`;const creator = esc(clip.creator || msg.username || 'someone');const avatar = clip.creator_avatar-- ? `<img class="clip-chat-avatar" src="${esc(clip.creator_avatar)}" alt="" loading="lazy">`++ ? `<img class="clip-chat-avatar" src="${esc(ovImgW(clip.creator_avatar, 64))}" data-full="${esc(clip.creator_avatar)}" alt="" loading="lazy" onerror="${OV_IMG_RETRY}">`: `<span class="clip-chat-avatar clip-chat-avatar-ltr" style="background:${esc(clip.creator_color || 'var(--accent)')}">${creator.charAt(0).toUpperCase()}</span>`;const metaLine = isAuto? `<div class="clip-chat-meta"><i class="fa-solid fa-robot"></i> Auto-generated highlight</div>`@@@ -2705,7 +2705,7 @@ function buildChatMessageEl(msg, opts = {}) {// Avatar (respects showAvatars setting via CSS class on root, but still render for toggle)const avatarHtml = widget ? '' : msg.avatar_url-- ? `<img class="chat-avatar" src="${esc(msg.avatar_url)}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display=''">`++ ? `<img class="chat-avatar" src="${esc(ovImgW(msg.avatar_url, 64))}" data-full="${esc(msg.avatar_url)}" alt="" onerror="${OV_IMG_RETRY}this.style.display='none';this.nextElementSibling.style.display=''">`+ `<span class="chat-avatar-letter" style="display:none;background:${esc(nameColor)}">${avatarInitial}</span>`: `<span class="chat-avatar-letter" style="background:${esc(nameColor)}">${avatarInitial}</span>`;@@@ -2911,7 +2911,7 @@ function getChatAvatarHTML(msg) {const raw = (msg.username || msg.displayName || `anon${msg.anonId || ''}`).replace(/^\[[^\]]+\]\s*/, '');const initial = esc((raw.charAt(0) || '?').toUpperCase());return msg.avatar_url-- ? `<img class="chat-avatar" src="${esc(msg.avatar_url)}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display=''">`++ ? `<img class="chat-avatar" src="${esc(ovImgW(msg.avatar_url, 64))}" data-full="${esc(msg.avatar_url)}" alt="" onerror="${OV_IMG_RETRY}this.style.display='none';this.nextElementSibling.style.display=''">`+ `<span class="chat-avatar-letter" style="display:none;background:${esc(nameColor)}">${initial}</span>`: `<span class="chat-avatar-letter" style="background:${esc(nameColor)}">${initial}</span>`;}@@@ -6690,7 +6690,7 @@ function renderChatUsersList(users) {let listHtml = '<div class="chat-users-list">';for (const u of logged) {const avatar = u.avatar_url-- ? `<img src="${esc(u.avatar_url)}" class="chat-users-avatar" alt="" loading="lazy">`++ ? `<img src="${esc(ovImgW(u.avatar_url, 64))}" data-full="${esc(u.avatar_url)}" class="chat-users-avatar" alt="" loading="lazy" onerror="${OV_IMG_RETRY}">`: `<div class="chat-users-avatar chat-users-avatar-default"><i class="fa-solid fa-user"></i></div>`;const badge = getBadgeHTML(u.role);listHtml += `<div class="chat-users-row"><a href="/@${esc(u.username)}" class="chat-users-link" onclick="if(typeof handleLinkClick==='function') handleLinkClick(event, this.getAttribute('href'))">${avatar}<span class="chat-users-name">${esc(u.display_name)}</span>${badge}</a></div>`;@@@ -192,7 +192,7 @@if (!user) return `<div class="msg-conv-avatar" style="width:${size}px;height:${size}px"><i class="fa-solid fa-user"></i></div>`;if (user.avatar_url) {return `<div class="msg-conv-avatar" style="width:${size}px;height:${size}px">-- <img src="${esc(user.avatar_url)}" alt="" onerror="this.parentElement.innerHTML='<span style=\\'background:${esc(user.profile_color || '#999')}\\';font-size:${Math.round(size*0.4)}px>${esc((user.display_name||user.username||'?')[0].toUpperCase())}</span>'">++ <img src="${esc(ovImgW(user.avatar_url, 96))}" data-full="${esc(user.avatar_url)}" alt="" onerror="${OV_IMG_RETRY}this.parentElement.innerHTML='<span style=\\'background:${esc(user.profile_color || '#999')}\\';font-size:${Math.round(size*0.4)}px>${esc((user.display_name||user.username||'?')[0].toUpperCase())}</span>'"></div>`;}const color = user.profile_color || '#999';@@@ -22,7 +22,7 @@ const BUDGETS = {htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 beforehtmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")-- jsRawKB: 1253, // 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)++ jsRawKB: 1254, // 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)jsBrotliKB: 293.3, // 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++// Avatars and paste pictures ask Media for a width from its ladder (openvibe.media/o/<id>?w=64|96|160|480) instead of++// the original upload: one avatar is 3072 px and 3 MB, drawn at 24-50 px, and the home page fetched 6.8 MB of images++// on a phone (2026-11-01; 53 KB after). The original stays in data-full, and onerror loads it once if the sized copy++// fails (Media answers 404 for an object it cannot resize) before the letter fallback.++// node test/avatar-widths.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const js = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', 'js', f), 'utf8');++const app = js('app.js');++const fn = /function ovImgW\(url, w\) \{[\s\S]*?\n\}\n/.exec(app);++assert.ok(fn, 'app.js has ovImgW');++const ovImgW = new Function(`${fn[0]}; return ovImgW;`)();++assert.strictEqual(ovImgW('https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38', 96), 'https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38?w=96');++assert.strictEqual(ovImgW('https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38?w=64', 96), 'https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38?w=64', 'a URL with a query is left alone');++assert.strictEqual(ovImgW('https://example.com/a.png', 96), 'https://example.com/a.png', 'other hosts as they are');++assert.strictEqual(ovImgW('https://openvibe.media/t/vod-1.jpg', 96), 'https://openvibe.media/t/vod-1.jpg', 'thumbnails as they are');++assert.strictEqual(ovImgW(null, 96), '');++assert.match(app, /const OV_IMG_RETRY = 'if\(this\.dataset\.full&&this\.src!==this\.dataset\.full\)\{this\.src=this\.dataset\.full;return\}';/);++++// Every avatar path asks for a width and keeps the original for the retry.++const uses = [++ ['app.js', /<img src="\$\{esc\(ovImgW\(url, 160\)\)\}" data-full="\$\{esc\(url\)\}"[^`]*onerror="\$\{OV_IMG_RETRY\}/],++ ['app.js', /<img src="\$\{esc\(ovImgW\(url, 96\)\)\}" data-full="\$\{esc\(url\)\}"[^`]*onerror="\$\{OV_IMG_RETRY\}/],++ ['app-home.js', /ovImgW\(s\.avatar_url, 160\)/],++ ['app-home.js', /ovImgW\(p\.screenshot_url, 480\)/],++ ['chat.js', /ovImgW\(msg\.avatar_url, 64\)[\s\S]*ovImgW\(msg\.avatar_url, 64\)/],++ ['chat.js', /ovImgW\(clip\.creator_avatar, 64\)/],++ ['chat.js', /ovImgW\(u\.avatar_url, 64\)/],++ ['chat-shared.js', /typeof ovImgW === 'function' \? ovImgW\(p\.avatar_url, 64\) : p\.avatar_url/],++ ['messenger.js', /ovImgW\(user\.avatar_url, 96\)/],++];++uses.forEach(([f, re]) => assert.match(js(f), re, `${f}: ${re}`));++// Read by name too, so the test runner maps the kiosk-safe chat file and the messenger to this test.++const chatShared = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat-shared.js'), 'utf8');++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\)\}"/);++console.log('avatar widths: all checks passed');
@@ -1720,7 +1720,7 @@ async function loadHomePastes(opts) {
@@ -1720,7 +1720,7 @@ async function loadHomePastes(opts) {
const icon = p.type === 'screenshot' ? 'fa-image' : (p.language && p.language !== 'plaintext' ? 'fa-code' : 'fa-file-lines');
const icon = p.type === 'screenshot' ? 'fa-image' : (p.language && p.language !== 'plaintext' ? 'fa-code' : 'fa-file-lines');
const preview = p.type === 'paste' ? esc((p.content || '').slice(0, 220)).replace(/\n{3,}/g, '\n\n') : '';
const preview = p.type === 'paste' ? esc((p.content || '').slice(0, 220)).replace(/\n{3,}/g, '\n\n') : '';
const media = p.type === 'screenshot' && p.screenshot_url
const media = p.type === 'screenshot' && p.screenshot_url
- ? `<div class="home-paste-media"><img src="${esc(p.screenshot_url)}" alt="${esc(p.title || 'Screenshot paste')}" loading="lazy"><span class="home-paste-type">Image</span></div>`
+ ? `<div class="home-paste-media"><img src="${esc(ovImgW(p.screenshot_url, 480))}" data-full="${esc(p.screenshot_url)}" alt="${esc(p.title || 'Screenshot paste')}" loading="lazy" onerror="${OV_IMG_RETRY}"><span class="home-paste-type">Image</span></div>`
: `<div class="home-paste-media"><div class="home-paste-snippet">${preview || esc(p.title || 'Untitled paste')}</div><div class="home-paste-icon"><i class="fa-solid ${icon}"></i></div><span class="home-paste-type">${p.language && p.language !== 'plaintext' ? esc(p.language) : 'Text'}</span></div>`;
: `<div class="home-paste-media"><div class="home-paste-snippet">${preview || esc(p.title || 'Untitled paste')}</div><div class="home-paste-icon"><i class="fa-solid ${icon}"></i></div><span class="home-paste-type">${p.language && p.language !== 'plaintext' ? esc(p.language) : 'Text'}</span></div>`;
return `
return `
<a class="home-paste-card" href="${esc(pasteHref(p.slug))}">
<a class="home-paste-card" href="${esc(pasteHref(p.slug))}">
@@ -2299,7 +2299,7 @@ function _renderHomeStar(data) {
@@ -2299,7 +2299,7 @@ function _renderHomeStar(data) {
const path = channelPath(s.username);
const path = channelPath(s.username);
const initial = esc(String(s.display_name || s.username || '?').charAt(0).toUpperCase());
const initial = esc(String(s.display_name || s.username || '?').charAt(0).toUpperCase());
const avatar = s.avatar_url
const avatar = s.avatar_url
- ? `<img src="${esc(s.avatar_url)}" alt="${name}" loading="lazy">`
+ ? `<img src="${esc(ovImgW(s.avatar_url, 160))}" data-full="${esc(s.avatar_url)}" alt="${name}" loading="lazy" onerror="${OV_IMG_RETRY}">`
: initial;
: initial;
const bioSrc = (s.bio || '').trim();
const bioSrc = (s.bio || '').trim();
const bioEn = (s.bio_en || '').trim();
const bioEn = (s.bio_en || '').trim();
@@ -65,11 +65,21 @@ function channelPath(username, managedStreamIdOrSlug = null) {
@@ -65,11 +65,21 @@ function channelPath(username, managedStreamIdOrSlug = null) {
return `${base}/${encodeURIComponent(String(managedStreamIdOrSlug))}`;
return `${base}/${encodeURIComponent(String(managedStreamIdOrSlug))}`;
}
}
+/** A Media image (openvibe.media/o/med_…) at a width from Media's ladder (64, 96, 160, 320, 480, 640); any other URL
+ as it is. Avatars are kept as uploaded (one is 3072 px and 3 MB) and drawn at 24-80 px: the home page fetched 5.4 MB
+ of them on a phone (2026-11-01). Put the original in data-full and start onerror with OV_IMG_RETRY, which loads it
+ once if the sized copy fails (Media answers 404 for an object it cannot resize). */
+function ovImgW(url, w) {
+ const s = String(url || '');
+ return /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]+$/.test(s) ? `${s}?w=${w}` : s;
+}
+const OV_IMG_RETRY = 'if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}';
+
/** Inner img-or-letter for an element that is itself the avatar circle. */
/** Inner img-or-letter for an element that is itself the avatar circle. */
function _avatarInner(url, name) {
function _avatarInner(url, name) {
const letter = ((String(name || '?'))[0] || '?').toUpperCase();
const letter = ((String(name || '?'))[0] || '?').toUpperCase();
return url
return url
- ? `<img src="${esc(url)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block" onerror="var p=this.parentNode;this.remove();if(p)p.textContent='${letter}'">`
+ ? `<img src="${esc(ovImgW(url, 160))}" data-full="${esc(url)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block" onerror="${OV_IMG_RETRY}var p=this.parentNode;this.remove();if(p)p.textContent='${letter}'">`
: letter;
: letter;
}
}
@@ -79,7 +89,7 @@ function _avatarSpan(url, name, color, extraCls) {
@@ -79,7 +89,7 @@ function _avatarSpan(url, name, color, extraCls) {
const cls = extraCls ? ` ${extraCls}` : '';
const cls = extraCls ? ` ${extraCls}` : '';
const bg = color ? ` style="background:${esc(color)}"` : '';
const bg = color ? ` style="background:${esc(color)}"` : '';
return url
return url
- ? `<span class="stream-card-avatar${cls}"${bg}><img src="${esc(url)}" alt="" onerror="var p=this.parentNode;this.remove();if(p)p.textContent='${letter}'"></span>`
+ ? `<span class="stream-card-avatar${cls}"${bg}><img src="${esc(ovImgW(url, 96))}" data-full="${esc(url)}" alt="" onerror="${OV_IMG_RETRY}var p=this.parentNode;this.remove();if(p)p.textContent='${letter}'"></span>`
: `<span class="stream-card-avatar${cls}"${bg}>${letter}</span>`;
: `<span class="stream-card-avatar${cls}"${bg}>${letter}</span>`;
}
}
@@ -440,7 +440,7 @@
@@ -440,7 +440,7 @@
const tAgo = (d.deps && d.deps.timeAgoShort) || ((x) => x || '?');
const tAgo = (d.deps && d.deps.timeAgoShort) || ((x) => x || '?');
const numf = (d.deps && d.deps.formatNumber) || _num;
const numf = (d.deps && d.deps.formatNumber) || _num;
const avatarHtml = p.avatar_url
const avatarHtml = p.avatar_url
- ? `<img class="ctx-avatar" src="${esc(p.avatar_url)}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display=''">`
+ ? `<img class="ctx-avatar" src="${esc(typeof ovImgW === 'function' ? ovImgW(p.avatar_url, 64) : p.avatar_url)}" data-full="${esc(p.avatar_url)}" alt="" onerror="if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}this.style.display='none';this.nextElementSibling.style.display=''">`
+ `<span class="ctx-avatar-letter" style="display:none;background:${esc(p.profile_color || '#999')}">${esc(username[0] ? username[0].toUpperCase() : '?')}</span>`
+ `<span class="ctx-avatar-letter" style="display:none;background:${esc(p.profile_color || '#999')}">${esc(username[0] ? username[0].toUpperCase() : '?')}</span>`
: `<span class="ctx-avatar-letter" style="background:${esc(p.profile_color || '#999')}">${esc(username[0] ? username[0].toUpperCase() : '?')}</span>`;
: `<span class="ctx-avatar-letter" style="background:${esc(p.profile_color || '#999')}">${esc(username[0] ? username[0].toUpperCase() : '?')}</span>`;
const badge = _badgeHTML(p.role);
const badge = _badgeHTML(p.role);
@@ -2626,7 +2626,7 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2626,7 +2626,7 @@ function buildChatMessageEl(msg, opts = {}) {
: `<div class="clip-chat-label"><i class="fa-solid fa-scissors"></i> New clip</div>`;
: `<div class="clip-chat-label"><i class="fa-solid fa-scissors"></i> New clip</div>`;
const creator = esc(clip.creator || msg.username || 'someone');
const creator = esc(clip.creator || msg.username || 'someone');
const avatar = clip.creator_avatar
const avatar = clip.creator_avatar
- ? `<img class="clip-chat-avatar" src="${esc(clip.creator_avatar)}" alt="" loading="lazy">`
+ ? `<img class="clip-chat-avatar" src="${esc(ovImgW(clip.creator_avatar, 64))}" data-full="${esc(clip.creator_avatar)}" alt="" loading="lazy" onerror="${OV_IMG_RETRY}">`
: `<span class="clip-chat-avatar clip-chat-avatar-ltr" style="background:${esc(clip.creator_color || 'var(--accent)')}">${creator.charAt(0).toUpperCase()}</span>`;
: `<span class="clip-chat-avatar clip-chat-avatar-ltr" style="background:${esc(clip.creator_color || 'var(--accent)')}">${creator.charAt(0).toUpperCase()}</span>`;
const metaLine = isAuto
const metaLine = isAuto
? `<div class="clip-chat-meta"><i class="fa-solid fa-robot"></i> Auto-generated highlight</div>`
? `<div class="clip-chat-meta"><i class="fa-solid fa-robot"></i> Auto-generated highlight</div>`
@@ -2705,7 +2705,7 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2705,7 +2705,7 @@ function buildChatMessageEl(msg, opts = {}) {
// Avatar (respects showAvatars setting via CSS class on root, but still render for toggle)
// Avatar (respects showAvatars setting via CSS class on root, but still render for toggle)
const avatarHtml = widget ? '' : msg.avatar_url
const avatarHtml = widget ? '' : msg.avatar_url
- ? `<img class="chat-avatar" src="${esc(msg.avatar_url)}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display=''">`
+ ? `<img class="chat-avatar" src="${esc(ovImgW(msg.avatar_url, 64))}" data-full="${esc(msg.avatar_url)}" alt="" onerror="${OV_IMG_RETRY}this.style.display='none';this.nextElementSibling.style.display=''">`
+ `<span class="chat-avatar-letter" style="display:none;background:${esc(nameColor)}">${avatarInitial}</span>`
+ `<span class="chat-avatar-letter" style="display:none;background:${esc(nameColor)}">${avatarInitial}</span>`
: `<span class="chat-avatar-letter" style="background:${esc(nameColor)}">${avatarInitial}</span>`;
: `<span class="chat-avatar-letter" style="background:${esc(nameColor)}">${avatarInitial}</span>`;
@@ -2911,7 +2911,7 @@ function getChatAvatarHTML(msg) {
@@ -2911,7 +2911,7 @@ function getChatAvatarHTML(msg) {
const raw = (msg.username || msg.displayName || `anon${msg.anonId || ''}`).replace(/^\[[^\]]+\]\s*/, '');
const raw = (msg.username || msg.displayName || `anon${msg.anonId || ''}`).replace(/^\[[^\]]+\]\s*/, '');
const initial = esc((raw.charAt(0) || '?').toUpperCase());
const initial = esc((raw.charAt(0) || '?').toUpperCase());
return msg.avatar_url
return msg.avatar_url
- ? `<img class="chat-avatar" src="${esc(msg.avatar_url)}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display=''">`
+ ? `<img class="chat-avatar" src="${esc(ovImgW(msg.avatar_url, 64))}" data-full="${esc(msg.avatar_url)}" alt="" onerror="${OV_IMG_RETRY}this.style.display='none';this.nextElementSibling.style.display=''">`
+ `<span class="chat-avatar-letter" style="display:none;background:${esc(nameColor)}">${initial}</span>`
+ `<span class="chat-avatar-letter" style="display:none;background:${esc(nameColor)}">${initial}</span>`
: `<span class="chat-avatar-letter" style="background:${esc(nameColor)}">${initial}</span>`;
: `<span class="chat-avatar-letter" style="background:${esc(nameColor)}">${initial}</span>`;
}
}
@@ -6690,7 +6690,7 @@ function renderChatUsersList(users) {
@@ -6690,7 +6690,7 @@ function renderChatUsersList(users) {
let listHtml = '<div class="chat-users-list">';
let listHtml = '<div class="chat-users-list">';
for (const u of logged) {
for (const u of logged) {
const avatar = u.avatar_url
const avatar = u.avatar_url
- ? `<img src="${esc(u.avatar_url)}" class="chat-users-avatar" alt="" loading="lazy">`
+ ? `<img src="${esc(ovImgW(u.avatar_url, 64))}" data-full="${esc(u.avatar_url)}" class="chat-users-avatar" alt="" loading="lazy" onerror="${OV_IMG_RETRY}">`
: `<div class="chat-users-avatar chat-users-avatar-default"><i class="fa-solid fa-user"></i></div>`;
: `<div class="chat-users-avatar chat-users-avatar-default"><i class="fa-solid fa-user"></i></div>`;
const badge = getBadgeHTML(u.role);
const badge = getBadgeHTML(u.role);
listHtml += `<div class="chat-users-row"><a href="/@${esc(u.username)}" class="chat-users-link" onclick="if(typeof handleLinkClick==='function') handleLinkClick(event, this.getAttribute('href'))">${avatar}<span class="chat-users-name">${esc(u.display_name)}</span>${badge}</a></div>`;
listHtml += `<div class="chat-users-row"><a href="/@${esc(u.username)}" class="chat-users-link" onclick="if(typeof handleLinkClick==='function') handleLinkClick(event, this.getAttribute('href'))">${avatar}<span class="chat-users-name">${esc(u.display_name)}</span>${badge}</a></div>`;
@@ -192,7 +192,7 @@
@@ -192,7 +192,7 @@
if (!user) return `<div class="msg-conv-avatar" style="width:${size}px;height:${size}px"><i class="fa-solid fa-user"></i></div>`;
if (!user) return `<div class="msg-conv-avatar" style="width:${size}px;height:${size}px"><i class="fa-solid fa-user"></i></div>`;
if (user.avatar_url) {
if (user.avatar_url) {
return `<div class="msg-conv-avatar" style="width:${size}px;height:${size}px">
return `<div class="msg-conv-avatar" style="width:${size}px;height:${size}px">
- <img src="${esc(user.avatar_url)}" alt="" onerror="this.parentElement.innerHTML='<span style=\\'background:${esc(user.profile_color || '#999')}\\';font-size:${Math.round(size*0.4)}px>${esc((user.display_name||user.username||'?')[0].toUpperCase())}</span>'">
+ <img src="${esc(ovImgW(user.avatar_url, 96))}" data-full="${esc(user.avatar_url)}" alt="" onerror="${OV_IMG_RETRY}this.parentElement.innerHTML='<span style=\\'background:${esc(user.profile_color || '#999')}\\';font-size:${Math.round(size*0.4)}px>${esc((user.display_name||user.username||'?')[0].toUpperCase())}</span>'">
</div>`;
</div>`;
}
}
const color = user.profile_color || '#999';
const color = user.profile_color || '#999';
@@ -22,7 +22,7 @@ const BUDGETS = {
@@ -22,7 +22,7 @@ const BUDGETS = {
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
- jsRawKB: 1253, // 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
+ jsRawKB: 1254, // 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
jsBrotliKB: 293.3, // 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
jsBrotliKB: 293.3, // 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+// Avatars and paste pictures ask Media for a width from its ladder (openvibe.media/o/<id>?w=64|96|160|480) instead of
+// the original upload: one avatar is 3072 px and 3 MB, drawn at 24-50 px, and the home page fetched 6.8 MB of images
+// on a phone (2026-11-01; 53 KB after). The original stays in data-full, and onerror loads it once if the sized copy
+// fails (Media answers 404 for an object it cannot resize) before the letter fallback.
+// node test/avatar-widths.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const js = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', 'js', f), 'utf8');
+const app = js('app.js');
+const fn = /function ovImgW\(url, w\) \{[\s\S]*?\n\}\n/.exec(app);
+assert.ok(fn, 'app.js has ovImgW');
+const ovImgW = new Function(`${fn[0]}; return ovImgW;`)();
+assert.strictEqual(ovImgW('https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38', 96), 'https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38?w=96');
+assert.strictEqual(ovImgW('https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38?w=64', 96), 'https://openvibe.media/o/med_01M0E4X90R0VPTR7JBXFXC2Y38?w=64', 'a URL with a query is left alone');
+assert.strictEqual(ovImgW('https://example.com/a.png', 96), 'https://example.com/a.png', 'other hosts as they are');
+assert.strictEqual(ovImgW('https://openvibe.media/t/vod-1.jpg', 96), 'https://openvibe.media/t/vod-1.jpg', 'thumbnails as they are');
+assert.strictEqual(ovImgW(null, 96), '');
+assert.match(app, /const OV_IMG_RETRY = 'if\(this\.dataset\.full&&this\.src!==this\.dataset\.full\)\{this\.src=this\.dataset\.full;return\}';/);
+
+// Every avatar path asks for a width and keeps the original for the retry.
+const uses = [
+ ['app.js', /<img src="\$\{esc\(ovImgW\(url, 160\)\)\}" data-full="\$\{esc\(url\)\}"[^`]*onerror="\$\{OV_IMG_RETRY\}/],
+ ['app.js', /<img src="\$\{esc\(ovImgW\(url, 96\)\)\}" data-full="\$\{esc\(url\)\}"[^`]*onerror="\$\{OV_IMG_RETRY\}/],
+ ['app-home.js', /ovImgW\(s\.avatar_url, 160\)/],
+ ['app-home.js', /ovImgW\(p\.screenshot_url, 480\)/],
+ ['chat.js', /ovImgW\(msg\.avatar_url, 64\)[\s\S]*ovImgW\(msg\.avatar_url, 64\)/],
+ ['chat.js', /ovImgW\(clip\.creator_avatar, 64\)/],
+ ['chat.js', /ovImgW\(u\.avatar_url, 64\)/],
+ ['chat-shared.js', /typeof ovImgW === 'function' \? ovImgW\(p\.avatar_url, 64\) : p\.avatar_url/],
+ ['messenger.js', /ovImgW\(user\.avatar_url, 96\)/],
+];
+uses.forEach(([f, re]) => assert.match(js(f), re, `${f}: ${re}`));
+// Read by name too, so the test runner maps the kiosk-safe chat file and the messenger to this test.
+const chatShared = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat-shared.js'), 'utf8');
+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\)\}"/);
+console.log('avatar widths: all checks passed');