openvibers/openvibe.live Commits

Avatars load at the size they are drawn (#532)

OP OpenVibers committed 12 days ago · e386ef1955 · 7 files, +62 −11

* Avatars load at the size they are drawn: the home page fetched 6.8 MB of full-size avatars on a phone

* Budget: 1253.8 KB of JS with the avatar width helper

Changes

ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 68d3a16b..9550e78b 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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();
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 2ae220b9..f4023115 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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>`;
}
ddiff --git a/public/js/chat-shared.js b/public/js/chat-shared.js
iindex d9a0888e..3b224ee8 100644
---- a/public/js/chat-shared.js
++++ b/public/js/chat-shared.js
@@@ -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);
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex cd2802e0..68abd49e 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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>`;
ddiff --git a/public/js/messenger.js b/public/js/messenger.js
iindex fc109df5..94e96f4d 100644
---- a/public/js/messenger.js
++++ b/public/js/messenger.js
@@@ -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';
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 5b7ce4e9..0e57c779 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -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
htmlBrotliKB: 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)
ddiff --git a/test/avatar-widths.test.js b/test/avatar-widths.test.js
nnew file mode 100644
iindex 00000000..c0e3520e
---- /dev/null
++++ b/test/avatar-widths.test.js
@@@ -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');