openvibers/openvibe.shared Commits

Avatars show the account's current picture (the Network's /avatar/<username> when a record has none or a picture fails), and the launcher panel fits a phone (#106)

OP OpenVibers committed 19 days ago · b3a9ca949c · 4 files, +67 −13

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ab49738..6844857 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,7 +4,19 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
--## 4.12.0 — 2026-10-26
++## 4.12.0 — 2026-10-25
++
++- **Avatars show the picture the account has now.** The owner, 2026-10-25: their picture showed on Live but the
++ navbar on openvibe.network drew their initial. A user record can be older than its picture (a site's session, or a
++ remembered account, from before the picture was set).
++ - The navbar (`navbar.js`) and the footer's account row (`footer.js`) ask the Network's `/avatar/<username>` when a
++ record has no openvibe.media picture. That route redirects to the current picture, or answers an initial.
++ - A picture that fails to load asks `/avatar/<username>` once (`data-retry-src`) before falling back to the initial.
++ That also covers an image that failed before the handler was attached.
++ - On openvibe.network itself the request is same-origin.
++- **The launcher fits a phone.** Its panel was `min(760px, 100vw - 24px)` wide plus its padding and border, so on a page
++ without a global `box-sizing: border-box` it ran 10 px past the screen and the page scrolled sideways. It is now
++ `border-box`.
- **Every theme in light and dark.** The owner, 2026-10-25: "each theme should have a light counterpart instead of … a
mixed bag", with an easy light and dark switch in the account menu.
ddiff --git a/footer.js b/footer.js
iindex 8118334..ed1e05e 100644
---- a/footer.js
++++ b/footer.js
@@@ -291,8 +291,10 @@
function accountHTML(user, service) {
const name = esc(user.display_name || user.username || 'you');
// An openvibe.media object picture comes in sizes (Media's /o/:id?w=&fmt=): 64 px as WebP for the 24 px avatar.
-- const sized = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || '') ? `${user.avatar_url}?w=64&fmt=webp` : user.avatar_url;
-- const avatar = user.avatar_url
++ // Without one in the record, the Network's /avatar/<username>: the picture the account has now (navbar.js, the same rule).
++ const media = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || '');
++ const sized = media ? `${user.avatar_url}?w=64&fmt=webp` : (user.username && !user.is_anon ? `${NETWORK_URL}/avatar/${encodeURIComponent(user.username)}?s=32` : '');
++ const avatar = sized
? `<img src="${esc(sized)}" alt="">`
: `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
const quick = [
ddiff --git a/navbar.js b/navbar.js
iindex b7d5d77..ab24d10 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -104,7 +104,7 @@
.ovnav-drawer-net a.ovnav-net { display: flex !important; }
@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
-- .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
++ .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-top { position: sticky; top: 0; z-index: 1; margin: 0 -12px; padding: 12px 12px 2px; background: inherit; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
@@@ -1350,26 +1350,42 @@
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
}
++ // The account's picture now (the Network's /avatar/<username>): a record can predate its picture (CHANGELOG 4.12.0).
++ function currentAvatarUrl(user, size) {
++ if (!user || user.is_anon || user.id === 'anon' || !user.username) return null;
++ const network = /(^|\.)openvibe\.network$/.test(currentHost()) ? '' : 'https://openvibe.network';
++ return `${network}/avatar/${encodeURIComponent(user.username)}?s=${size}`;
++ }
++
function avatarSrc(user, size = 64) {
const url = user?.avatar_url;
-- return /^https:\/\/openvibe\.media\/o\/med_\w+$/.test(url || '') ? `${url}?w=${size}&fmt=webp` : url || makeAvatarPlaceholder(user, size);
++ if (/^https:\/\/openvibe\.media\/o\/med_\w+$/.test(url || '')) return `${url}?w=${size}&fmt=webp`;
++ if (/^https:\/\/openvibe\.media\//.test(url || '')) return url;
++ return currentAvatarUrl(user, size) || makeAvatarPlaceholder(user, size);
}
function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') {
const fallback = makeAvatarPlaceholder(user, size);
++ const src = avatarSrc(user, size);
++ const current = currentAvatarUrl(user, size);
++ const retry = current && current !== src ? ` data-retry-src="${escapeAttr(current)}"` : '';
const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
-- return `<img class="${escapeAttr(className)}" src="${escapeAttr(avatarSrc(user, size))}" data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;
++ return `<img class="${escapeAttr(className)}" src="${escapeAttr(src)}"${retry} data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;
}
++ // A picture that does not load: the current one once (data-retry-src), then the initial.
function attachAvatarFallbacks(rootEl) {
rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => {
-- img.addEventListener('error', () => {
++ const onError = () => {
++ const retry = img.dataset.retrySrc;
++ if (retry) { delete img.dataset.retrySrc; img.src = retry; return; }
++ img.removeEventListener('error', onError);
const fallback = img.dataset.fallbackSrc;
-- if (fallback && img.src !== fallback) {
-- img.src = fallback;
-- }
-- }, { once: true });
++ if (fallback && img.src !== fallback) img.src = fallback;
++ };
++ img.addEventListener('error', onError);
++ if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) onError();
});
}
ddiff --git a/test/navbar-auth.test.js b/test/navbar-auth.test.js
iindex 68fabdc..bbc52bc 100644
---- a/test/navbar-auth.test.js
++++ b/test/navbar-auth.test.js
@@@ -166,6 +166,9 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,
for (const [avatar, want] of [
['https://openvibe.media/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN', 'src="https://openvibe.media/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN?w=64&amp;fmt=webp"'],
['https://openvibe.media/f/b.png', 'src="https://openvibe.media/f/b.png"'],
++ // No picture in the record (or one that is not on openvibe.media): the account's current one, from the Network.
++ [null, 'src="https://openvibe.network/avatar/vibe?s=64"'],
++ ['https://example.com/me.png', 'src="https://openvibe.network/avatar/vibe?s=64"'],
]) {
const { navbar } = loadNavbar({
hostname: 'net.openvibe.tools', href: 'https://net.openvibe.tools/', cookie: 'ov_token=valid-jwt',
@@@ -176,8 +179,29 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,
assert.ok(navbar.getElement().innerHTML.includes(want), `${avatar}: ${want}`);
}
-- // The footer's 24 px signed-in avatar asks for 64 px WebP of an object picture too.
-- assert.match(require('fs').readFileSync(require('path').join(__dirname, '..', 'footer.js'), 'utf8'), /\? `\$\{user\.avatar_url\}\?w=64&fmt=webp` : user\.avatar_url;/, 'the footer sizes an object avatar');
++ // A picture that fails to load asks the Network for the current one once, then shows the initial.
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'net.openvibe.tools', href: 'https://net.openvibe.tools/', cookie: 'ov_token=valid-jwt',
++ fetch: async (url) => (url === 'https://openvibe.network/api/auth/me' ? jsonResponse(200, { user: { ...USER, avatar_url: 'https://openvibe.media/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN' }, preferences: {} }) : jsonResponse(404, {})),
++ });
++ navbar.init({ service: 'net', apiBase: 'https://openvibe.network', token: 'valid-jwt' });
++ await navbar.refreshAuth();
++ assert.ok(navbar.getElement().innerHTML.includes('data-retry-src="https://openvibe.network/avatar/vibe?s=64"'), 'a media picture carries the current-picture retry');
++ }
++ // On the Network itself the current picture is same-origin.
++ {
++ const { navbar } = loadNavbar({ hostname: 'openvibe.network', href: 'https://openvibe.network/updates', cookie: 'ov_token=valid-jwt',
++ fetch: async (url) => (/\/api\/auth\/me$/.test(url) ? jsonResponse(200, { user: USER, preferences: {} }) : jsonResponse(404, {})) });
++ navbar.init({ service: 'network', apiBase: 'https://openvibe.network', token: 'valid-jwt' });
++ await navbar.refreshAuth();
++ assert.ok(navbar.getElement().innerHTML.includes('src="/avatar/vibe?s=64"'), 'same-origin /avatar on openvibe.network');
++ }
++
++ // The footer's 24 px signed-in avatar asks for 64 px WebP of an object picture, and the current picture without one.
++ const footerSrc = require('fs').readFileSync(require('path').join(__dirname, '..', 'footer.js'), 'utf8');
++ assert.match(footerSrc, /media \? `\$\{user\.avatar_url\}\?w=64&fmt=webp`/, 'the footer sizes an object avatar');
++ assert.match(footerSrc, /\$\{NETWORK_URL\}\/avatar\/\$\{encodeURIComponent\(user\.username\)\}/, 'the footer asks for the current picture');
// ── 6. No token, no sessionUrl → signed out, zero fetches ─
{