openvibers/openvibe.shared Commits

Shared 4.3.0: Vibes in the account menu on every site (OpenVibe.Billing's /api/me/vibes, hidden until Billing is the ledger) (#85)

OP OpenVibers committed 27 days ago · 13d9ce0afd · 5 files, +95 −11

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fcb0bd0..8ed9b2a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,15 @@ 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.3.0 — 2026-10-17
++
++- **Vibes in the account menu on every site.** Next to the OpenCoins chip, the menu header shows the person's Vibes
++ from OpenVibe.Billing (`GET https://billing.openvibe.network/api/me/vibes`, their token, no cookies; the owner: "make
++ vibes cross site too like opencoins"). Billing answers 409 until it is the money ledger, and then no chip shows,
++ never a shadow number. The chip links to openvibe.live, where Vibes are bought and spent. `billingBase` points
++ elsewhere for a test cell. Both balances load in parallel, once per page, and a site with its own `headerChips`
++ (Live) still asks nothing.
++
## 4.2.2 — 2026-10-16
- **IndexNow sends 100 URLs a request.** `ping()` (and the debounced `pingSoon()` flush) splits a list into requests of
ddiff --git a/navbar.js b/navbar.js
iindex 8098996..8f13bd0 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -4,7 +4,7 @@
'use strict';
let _config = {
-- service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',
++ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', billingBase: null,
onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
brand: null, brandName: null, brandIcon: null, compact: 'auto',
links: null, menu: null, recent: true,
@@@ -174,6 +174,9 @@
.openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }
.openvibe-navbar-dropdown-header { background: linear-gradient(180deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent), transparent); }
.openvibe-navbar-dropdown-header img { border-radius: 50%; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
++ .openvibe-navbar-dropdown-header .ud-wallet { display: inline-block; margin-right: 6px; }
++ .openvibe-navbar-dropdown-header .ud-wallet[hidden] { display: none; }
++ .openvibe-navbar-dropdown-header .ud-vibes a { color: #4ade80; background: rgba(74,222,128,.12); border-color: rgba(74,222,128,.3); }
.openvibe-navbar-dropdown-header .ud-wallet a { display: inline-flex; align-items: center; gap: 5px; margin-top: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); }
.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
@@@ -769,16 +772,22 @@
root.addEventListener('ov:display', paint, onDocument()); paint();
}
-- /** OpenCoins balance in the menu header (one request when the menu first opens). */
++ /** OpenCoins (Network) and Vibes (OpenVibe.Billing) in the menu header: one request each when the menu first opens. */
let _walletLoaded = false;
async function loadWallet(dropdown) {
if (_walletLoaded || !_config.token || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;
-- try {
-- const r = await fetch(`${_config.apiBase}/api/coins/me`, { headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'include' });
++ const auth = { Authorization: `Bearer ${_config.token}` };
++ const chip = (id, html) => { const el = dropdown.querySelector(id); if (el) { el.innerHTML = html; el.hidden = false; } };
++ const coins = fetch(`${_config.apiBase}/api/coins/me`, { headers: auth, credentials: 'include' }).then(async (r) => {
if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));
-- const el = dropdown.querySelector('#openvibe-wallet'); if (!el || !isFinite(bal)) return;
-- el.innerHTML = `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`; el.hidden = false;
-- } catch { /* the chip is optional */ }
++ if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`);
++ });
++ // Vibes cross-site (the spendable balance Billing keeps). Billing answers 409 until it is the ledger: no chip then.
++ const vibes = fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' }).then(async (r) => {
++ if (!r.ok) return; const v = Number((await r.json()).credit);
++ if (isFinite(v)) chip('#openvibe-vibes', `<a href="https://openvibe.live/#vibes" title="Your Vibes">${v.toLocaleString()} Vibes</a>`);
++ });
++ await Promise.all([coins.catch(() => {}), vibes.catch(() => {})]); // both chips are optional
}
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
@@@ -1405,7 +1414,7 @@
<div class="info">
<div class="name">${escapeAttr(u.display_name || u.username)}</div>
<div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>
-- ${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div>'}
++ ${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
</div>
</div>
@@@ -1674,6 +1683,7 @@
fillUrl,
},
_ask: { selectedText, askActorHref },
++ _wallet: { loadWallet, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } },
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
ddiff --git a/package-lock.json b/package-lock.json
iindex f8e0d7b..c65efaa 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.2.2",
++ "version": "4.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.2.2",
++ "version": "4.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex aede909..4f21440 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.2.2",
++ "version": "4.3.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/navbar-wallet.test.js b/test/navbar-wallet.test.js
nnew file mode 100644
iindex 0000000..0a7863f
---- /dev/null
++++ b/test/navbar-wallet.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++// The account menu's balances: OpenCoins from Network and Vibes from OpenVibe.Billing (the owner: "make vibes cross site
++// too like opencoins"), one request each when the menu first opens. Billing answers 409 until it is the money ledger:
++// then there is no Vibes chip at all, never a shadow number.
++const assert = require('assert');
++const path = require('path');
++
++global.window = global;
++global.location = { href: 'https://openvibe.wiki/', hostname: 'openvibe.wiki', protocol: 'https:' };
++global.document = { readyState: 'complete', cookie: '', addEventListener() {}, querySelector() { return null; }, getElementById() { return null; } };
++const navbar = require(path.join(__dirname, '..', 'navbar.js'));
++const { loadWallet, configure } = navbar._wallet;
++
++function dropdown() {
++ const els = { '#openvibe-wallet': { hidden: true, innerHTML: '' }, '#openvibe-vibes': { hidden: true, innerHTML: '' } };
++ return { els, querySelector: (sel) => els[sel] || null };
++}
++
++(async () => {
++ const calls = [];
++ const answer = { coins: { status: 200, body: { balance: 1234 } }, vibes: { status: 200, body: { credit: 5600, payable: 0 } } };
++ global.fetch = async (url, init) => {
++ calls.push({ url, init });
++ const a = /\/api\/me\/vibes$/.test(url) ? answer.vibes : answer.coins;
++ return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
++ };
++
++ configure({ token: 'tok', apiBase: 'https://openvibe.network', menu: null });
++ let d = dropdown();
++ await loadWallet(d);
++ assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me', 'https://billing.openvibe.network/api/me/vibes']);
++ assert.strictEqual(calls[1].init.headers.Authorization, 'Bearer tok');
++ assert.strictEqual(calls[1].init.credentials, 'omit', 'no cookies to Billing');
++ assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);
++ assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234/);
++ assert.strictEqual(d.els['#openvibe-vibes'].hidden, false);
++ assert.match(d.els['#openvibe-vibes'].innerHTML, /5,600 Vibes/);
++ assert.match(d.els['#openvibe-vibes'].innerHTML, /href="https:\/\/openvibe\.live\/#vibes"/);
++
++ await loadWallet(d);
++ assert.strictEqual(calls.length, 2, 'once per page');
++
++ // Before the cutover Billing says 409: OpenCoins show, Vibes do not.
++ answer.vibes = { status: 409, body: { code: 'billing.not_authoritative' } };
++ configure({ token: 'tok' });
++ d = dropdown();
++ await loadWallet(d);
++ assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);
++ assert.strictEqual(d.els['#openvibe-vibes'].hidden, true, 'no shadow balance');
++
++ // A site with its own header chips (Live) and a signed-out page ask nothing.
++ calls.length = 0;
++ configure({ token: 'tok', menu: { headerChips: [] } });
++ await loadWallet(dropdown());
++ configure({ token: null, menu: null });
++ await loadWallet(dropdown());
++ assert.strictEqual(calls.length, 0);
++
++ // billingBase points elsewhere (a test cell).
++ configure({ token: 'tok', billingBase: 'https://billing.example.test' });
++ calls.length = 0;
++ await loadWallet(dropdown());
++ assert.ok(calls.some((c) => c.url === 'https://billing.example.test/api/me/vibes'));
++ console.log('navbar-wallet: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });