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)
Changes
@@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -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;@@@ -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"@@@ -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": {nnew file mode 100644@@@ -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); });
@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 4.2.2 — 2026-10-16
- **IndexNow sends 100 URLs a request.** `ping()` (and the debounced `pingSoon()` flush) splits a list into requests of
- **IndexNow sends 100 URLs a request.** `ping()` (and the debounced `pingSoon()` flush) splits a list into requests of
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
'use strict';
'use strict';
let _config = {
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,
onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
brand: null, brandName: null, brandIcon: null, compact: 'auto',
brand: null, brandName: null, brandIcon: null, compact: 'auto',
links: null, menu: null, recent: true,
links: null, menu: null, recent: true,
@@ -174,6 +174,9 @@
@@ -174,6 +174,9 @@
.openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }
.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 { 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 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-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; }
.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
@@ -769,16 +772,22 @@
@@ -769,16 +772,22 @@
root.addEventListener('ov:display', paint, onDocument()); paint();
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;
let _walletLoaded = false;
async function loadWallet(dropdown) {
async function loadWallet(dropdown) {
if (_walletLoaded || !_config.token || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;
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));
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). */
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
@@ -1405,7 +1414,7 @@
@@ -1405,7 +1414,7 @@
<div class="info">
<div class="info">
<div class="name">${escapeAttr(u.display_name || u.username)}</div>
<div class="name">${escapeAttr(u.display_name || u.username)}</div>
<div class="email">${isAnon ? '' : '@' + escapeAttr(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>` : ''}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
</div>
</div>
</div>
</div>
@@ -1674,6 +1683,7 @@
@@ -1674,6 +1683,7 @@
fillUrl,
fillUrl,
},
},
_ask: { selectedText, askActorHref },
_ask: { selectedText, askActorHref },
+ _wallet: { loadWallet, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } },
};
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.2.2",
+ "version": "4.3.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.2.2",
+ "version": "4.3.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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); });