openvibers/openvibe.live Commits
Channel: VIP tiers and Tips' other ways to support in the Subscribe and Donate windows (#181)
Changes
@@@ -1517,3 +1517,10 @@body:has(#page-channel.active #ch-live-area .stream-layout > .chat-sidebar) .mobile-chat-toggle,body:has(#page-channel.active #ch-live-area .stream-layout > .chat-sidebar) #messenger-toggle { display: none !important; }}++++/* The donate and subscribe modals: the channel's VIP tiers and other ways to support it (vibes.js _elsewhereBlock). */++.elsewhere { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }++.elsewhere-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 8px; }++.elsewhere-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.elsewhere-list .btn { font-size: 0.85rem; }++.elsewhere-note { font-size: 0.75rem; margin-top: 6px; }@@@ -108,6 +108,7 @@"js": ["/js/channel-socials.js","/js/channel-chat-resize.js",++ "/js/channel-support.js","/js/app-channel.js"],"stubs": [nnew file mode 100644@@@ -0,0 +1,61 @@++/**++ * The channel's support on OpenVibe.VIP and OpenVibe.Tips, in the Subscribe and Donate windows (vibes.js calls these++ * when they exist): a creator's higher membership tiers, and their other ways to support. Loaded with the channel++ * feature (features.json), not on every page, so the home page's script budget does not carry it.++ */++// Memberships (VIP tiers) and other ways to support (Tips), from /api/payments/channel/:username/elsewhere. Built as++// DOM nodes: the names and links come from other services.++const _elsewhereCache = new Map();++function _elsewhere(username) {++ const key = String(username || '').toLowerCase();++ if (!key) return Promise.resolve(null);++ if (!_elsewhereCache.has(key)) _elsewhereCache.set(key, api(`/payments/channel/${encodeURIComponent(key)}/elsewhere`).catch(() => null));++ return _elsewhereCache.get(key);++}++function _channelUsername() {++ if (typeof currentStreamData !== 'undefined' && currentStreamData && currentStreamData.username) return currentStreamData.username;++ const m = /^\/@([^/?#]+)/.exec(location.pathname);++ return m ? decodeURIComponent(m[1]) : null;++}++function _elsewhereBlock(title, items, note) {++ const wrap = document.createElement('div');++ wrap.className = 'elsewhere';++ const h = document.createElement('div');++ h.className = 'elsewhere-title';++ h.textContent = title;++ const ul = document.createElement('ul');++ ul.className = 'elsewhere-list';++ for (const it of items) {++ const li = document.createElement('li');++ const a = document.createElement('a');++ a.href = it.url;++ a.target = '_blank';++ a.rel = it.rel || 'noopener';++ a.className = 'btn btn-outline';++ a.textContent = it.label;++ li.appendChild(a);++ ul.appendChild(li);++ }++ wrap.append(h, ul);++ if (note) { const p = document.createElement('div'); p.className = 'muted elsewhere-note'; p.textContent = note; wrap.appendChild(p); }++ return wrap;++}++// Live's Subscribe sells tier 1; a creator's higher tiers are joined on openvibe.vip with the same account.++async function _appendTiers(box, username) {++ const d = await _elsewhere(username);++ const plans = d && d.memberships ? d.memberships.plans.filter((p) => p.tier > 1) : [];++ if (!plans.length || !box || !box.isConnected) return;++ box.appendChild(_elsewhereBlock('More tiers on OpenVibe.VIP', plans.map((p) => ({++ url: p.join_url, label: p.price_cents ? `${p.name} · $${(p.price_cents / 100).toFixed(2)}/mo` : p.name,++ })), 'Each tier has its own perks. You join on openvibe.vip with this account; one tier at a time.'));++}++async function _appendSupport(box, username) {++ const d = await _elsewhere(username);++ if (!d || !box || !box.isConnected) return;++ const items = [];++ if (d.tips_url) items.push({ url: d.tips_url, label: 'OpenVibe.Tips: paid messages, TTS and sounds' });++ for (const l of d.support_links || []) items.push({ url: l.url, label: l.label, rel: 'nofollow noopener ugc' });++ if (!items.length) return;++ box.appendChild(_elsewhereBlock('Other ways to support', items, d.support_links ? 'Ko-fi, PayPal and the like are paid outside OpenVibe, not in Vibes.' : ''));++}++@@@ -129,7 +129,7 @@ async function _initSubscribe() {catch { box.innerHTML = '<p class="muted">Subscriptions unavailable.</p>'; return; }const priceLine = document.getElementById('sub-price-line');if (priceLine) priceLine.textContent = `$${(info.priceUsd || cfg.subPriceUsd || 4.99).toFixed(2)}/month · ${info.subscriberCount || 0} subscriber${info.subscriberCount === 1 ? '' : 's'}`;-- if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; return; }++ if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; if (typeof _appendTiers === 'function') _appendTiers(box, username); return; }const pc = info.powerchat || {};const price = Number(info.priceUsd || cfg.subPriceUsd || 4.99);const anyPowerchat = !!(pc.direct || pc.site);@@@ -158,6 +158,7 @@ async function _initSubscribe() {Auto-renew monthly from my Vibes balance (Vibes/PowerChat)</label>`;box.innerHTML = html;++ if (typeof _appendTiers === 'function') _appendTiers(box, username); // channel-support.js (channel pages)}async function doSubscribe(username, provider) {@@@ -239,7 +240,8 @@ function openvibeBucksDonateModal() {<button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;background:#8b5cf6;color:#fff;border:none"><i class="fa-solid fa-bolt"></i> Tip real money via PowerChat</button>-- </div>`;++ </div>++ <div id="donate-elsewhere"></div>`;}// Donate modal boot: balance-first, but NEVER at the cost of the direct route.@@@ -258,6 +260,7 @@ async function _initDonateModal() {]);const box = document.getElementById('donate-powerchat-direct');if (box && directAvailable) box.style.display = '';++ if (typeof _appendSupport === 'function') _appendSupport(document.getElementById('donate-elsewhere'), _channelUsername()); // channel-support.jsconst line = document.getElementById('donate-balance-line');if (bal === null) return; // not logged in / balance unavailable — leave the form as-isif (line) line.innerHTML = `Your balance: <strong>${bal.toLocaleString()}</strong> Vibes${bal < 1 ? ' — <a href="#" onclick="showModal(\'buy-funds\');return false">buy some</a> to donate Vibes' : ''}`;nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * A channel's support on OpenVibe.VIP and OpenVibe.Tips, shown on Live's channel page (owner 2026-10-08: "openvibe.vip++ * and openvibe.tips … should be directly integrated to openvibe.live"):++ *++ * memberships the creator's VIP plans (one per tier, each with Billing's price) from VIP's public card++ * (GET /embed/:username/card.json): Live's own Subscribe sells tier 1, the rest are joined on VIP++ * support_links other ways to support the creator, paid outside OpenVibe, from Tips' public profile++ * (GET /api/v1/profiles/:handle): Tips builds every link from a known service and a handle++ *++ * Both are public reads on loopback, cached per channel for a minute (a miss or an error is cached too, briefly), and++ * never block the page: whatever does not answer within the timeout is left out.++ */++const TTL_MS = 60_000;++const MISS_TTL_MS = 15_000;++const TIMEOUT_MS = 1500;++const cache = new Map(); // username → { at, ttl, value }++++const vipUrl = () => String(process.env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620').replace(/\/+$/, '');++const tipsUrl = () => String(process.env.TIPS_URL || 'http://127.0.0.1:4610').replace(/\/+$/, '');++const HANDLE_RE = /^[A-Za-z0-9_.-]{1,40}$/;++const https = (u) => (typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null);++++async function getJson(url, fetchImpl) {++ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });++ if (!res.ok) return null;++ return await res.json().catch(() => null);++}++++/** VIP's card → the plans a viewer can join (name, tier, price, link), or null. */++function memberships(card) {++ if (!card || !Array.isArray(card.plans)) return null;++ const plans = card.plans.filter((p) => p && p.purchasable && https(p.join_url)).map((p) => ({++ name: String(p.name || '').slice(0, 80), tier: Number(p.tier) || 1,++ price_cents: Number.isSafeInteger(p.price_cents) && p.price_cents > 0 ? p.price_cents : null, join_url: p.join_url,++ }));++ if (!plans.length) return null;++ return { url: https(card.join_url) || https(card.creator && card.creator.url), plans: plans.slice(0, 10) };++}++++/** Tips' public profile → the creator's other ways to support (label and link), or null. */++function supportLinks(profile) {++ const list = profile && Array.isArray(profile.support_links) ? profile.support_links : [];++ const out = list.map((l) => ({ provider: String(l.provider || ''), label: String(l.label || '').slice(0, 40), url: https(l.url) })).filter((l) => l.url && l.label);++ return out.length ? out.slice(0, 8) : null;++}++++async function forChannel(username, { fetchImpl = globalThis.fetch, now = Date.now() } = {}) {++ const name = String(username || '');++ if (!HANDLE_RE.test(name)) return { memberships: null, support_links: null, tips_url: null };++ const key = name.toLowerCase();++ const hit = cache.get(key);++ if (hit && now - hit.at < hit.ttl) return hit.value;++ const [card, tips] = await Promise.all([++ getJson(`${vipUrl()}/embed/${encodeURIComponent(key)}/card.json`, fetchImpl).catch(() => null),++ getJson(`${tipsUrl()}/api/v1/profiles/${encodeURIComponent(key)}`, fetchImpl).catch(() => null),++ ]);++ const profile = tips && tips.profile ? tips.profile : null;++ const value = {++ memberships: memberships(card && card.card ? card.card : card),++ support_links: supportLinks(profile),++ tips_url: profile && profile.page_enabled && profile.accepting ? https(profile.url) : null,++ };++ const empty = !value.memberships && !value.support_links && !value.tips_url;++ cache.set(key, { at: now, ttl: empty ? MISS_TTL_MS : TTL_MS, value });++ if (cache.size > 2000) cache.delete(cache.keys().next().value);++ return value;++}++++function _reset() { cache.clear(); }++++module.exports = { forChannel, memberships, supportLinks, _reset };@@@ -239,6 +239,13 @@ router.get('/subscriptions/mine', requireAuth, async (req, res) => {res.json({ subscriptions: await db.getSubscriptionsBySubscriber(req.user.id) });});++// The channel's memberships on OpenVibe.VIP (tiers) and other ways to support it (OpenVibe.Tips): public, the same++// for everyone, cached a minute (elsewhere.js).++router.get('/channel/:username/elsewhere', async (req, res) => {++ const out = await require('./elsewhere').forChannel(req.params.username);++ res.set('Cache-Control', 'public, max-age=60').json(out);++});++// Am I subscribed to this channel? + subscriber countrouter.get('/channel/:username', optionalAuth, async (req, res) => {const streamer = await db.getUserByUsername(req.params.username);nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * A channel's support on OpenVibe.VIP and OpenVibe.Tips for Live's donate and subscribe modals (server/monetization/++ * elsewhere.js): VIP's joinable plans with their tier and Billing's price, Tips' other ways to support (links Tips++ * built) and the tip page while it takes tips. Only https links pass; whatever does not answer is left out; a minute's++ * cache per channel; a name that is not a handle asks nobody.++ *++ * node test/channel-elsewhere.test.js++ */++const assert = require('assert');++const elsewhere = require('../server/monetization/elsewhere');++++const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });++const card = {++ creator: { username: 'tierra', url: 'https://openvibe.vip/tierra' }, join_url: 'https://openvibe.vip/tierra',++ plans: [++ { name: 'Supporter', tier: 1, price_cents: 300, purchasable: true, join_url: 'https://openvibe.vip/tierra#plan-supporter' },++ { name: 'Superfan', tier: 2, price_cents: 1500, purchasable: true, join_url: 'https://openvibe.vip/tierra#plan-superfan' },++ { name: 'Patron', tier: 3, price_cents: null, purchasable: false, join_url: 'https://openvibe.vip/tierra#plan-patron' },++ { name: 'Sneaky', tier: 4, price_cents: 100, purchasable: true, join_url: 'javascript:alert(1)' },++ ],++};++const profile = {++ url: 'https://openvibe.tips/tierra', page_enabled: true, accepting: true,++ support_links: [++ { provider: 'kofi', label: 'Ko-fi', handle: 'tierra', url: 'https://ko-fi.com/tierra' },++ { provider: 'paypal', label: 'PayPal.me', handle: 'x', url: 'http://paypal.me/x' },++ ],++};++++(async () => {++ const calls = [];++ const fetchImpl = async (url) => {++ calls.push(url);++ if (url.endsWith('/embed/tierra/card.json')) return json(200, card);++ if (url.endsWith('/api/v1/profiles/tierra')) return json(200, { profile });++ if (url.includes('/embed/quiet/')) throw new Error('timeout');++ return json(404, { code: 'not_found' });++ };++++ const out = await elsewhere.forChannel('Tierra', { fetchImpl, now: 1000 });++ assert.deepStrictEqual(out.memberships, {++ url: 'https://openvibe.vip/tierra',++ plans: [++ { name: 'Supporter', tier: 1, price_cents: 300, join_url: 'https://openvibe.vip/tierra#plan-supporter' },++ { name: 'Superfan', tier: 2, price_cents: 1500, join_url: 'https://openvibe.vip/tierra#plan-superfan' },++ ],++ }, 'joinable plans only, and only https links');++ assert.deepStrictEqual(out.support_links, [{ provider: 'kofi', label: 'Ko-fi', url: 'https://ko-fi.com/tierra' }], 'an http link is dropped');++ assert.strictEqual(out.tips_url, 'https://openvibe.tips/tierra');++ assert.ok(calls.every((u) => u.startsWith('http://127.0.0.1:')), 'loopback reads');++++ const n = calls.length;++ await elsewhere.forChannel('tierra', { fetchImpl, now: 30_000 });++ assert.strictEqual(calls.length, n, 'cached for a minute');++ await elsewhere.forChannel('tierra', { fetchImpl, now: 62_000 });++ assert.strictEqual(calls.length, n + 2, 'read again after the minute');++++ const quiet = await elsewhere.forChannel('quiet', { fetchImpl, now: 1000 });++ assert.deepStrictEqual(quiet, { memberships: null, support_links: null, tips_url: null }, 'nothing answers: nothing shown');++++ profile.accepting = false;++ elsewhere._reset();++ assert.strictEqual((await elsewhere.forChannel('tierra', { fetchImpl, now: 1000 })).tips_url, null, 'no tip page link while it takes no tips');++++ const before = calls.length;++ assert.deepStrictEqual(await elsewhere.forChannel('../admin', { fetchImpl }), { memberships: null, support_links: null, tips_url: null });++ assert.strictEqual(calls.length, before, 'a name that is not a handle asks nobody');++ console.log('channel elsewhere: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -1517,3 +1517,10 @@
@@ -1517,3 +1517,10 @@
body:has(#page-channel.active #ch-live-area .stream-layout > .chat-sidebar) .mobile-chat-toggle,
body:has(#page-channel.active #ch-live-area .stream-layout > .chat-sidebar) .mobile-chat-toggle,
body:has(#page-channel.active #ch-live-area .stream-layout > .chat-sidebar) #messenger-toggle { display: none !important; }
body:has(#page-channel.active #ch-live-area .stream-layout > .chat-sidebar) #messenger-toggle { display: none !important; }
}
}
+
+/* The donate and subscribe modals: the channel's VIP tiers and other ways to support it (vibes.js _elsewhereBlock). */
+.elsewhere { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
+.elsewhere-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 8px; }
+.elsewhere-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.elsewhere-list .btn { font-size: 0.85rem; }
+.elsewhere-note { font-size: 0.75rem; margin-top: 6px; }
@@ -108,6 +108,7 @@
@@ -108,6 +108,7 @@
"js": [
"js": [
"/js/channel-socials.js",
"/js/channel-socials.js",
"/js/channel-chat-resize.js",
"/js/channel-chat-resize.js",
+ "/js/channel-support.js",
"/js/app-channel.js"
"/js/app-channel.js"
],
],
"stubs": [
"stubs": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+/**
+ * The channel's support on OpenVibe.VIP and OpenVibe.Tips, in the Subscribe and Donate windows (vibes.js calls these
+ * when they exist): a creator's higher membership tiers, and their other ways to support. Loaded with the channel
+ * feature (features.json), not on every page, so the home page's script budget does not carry it.
+ */
+// Memberships (VIP tiers) and other ways to support (Tips), from /api/payments/channel/:username/elsewhere. Built as
+// DOM nodes: the names and links come from other services.
+const _elsewhereCache = new Map();
+function _elsewhere(username) {
+ const key = String(username || '').toLowerCase();
+ if (!key) return Promise.resolve(null);
+ if (!_elsewhereCache.has(key)) _elsewhereCache.set(key, api(`/payments/channel/${encodeURIComponent(key)}/elsewhere`).catch(() => null));
+ return _elsewhereCache.get(key);
+}
+function _channelUsername() {
+ if (typeof currentStreamData !== 'undefined' && currentStreamData && currentStreamData.username) return currentStreamData.username;
+ const m = /^\/@([^/?#]+)/.exec(location.pathname);
+ return m ? decodeURIComponent(m[1]) : null;
+}
+function _elsewhereBlock(title, items, note) {
+ const wrap = document.createElement('div');
+ wrap.className = 'elsewhere';
+ const h = document.createElement('div');
+ h.className = 'elsewhere-title';
+ h.textContent = title;
+ const ul = document.createElement('ul');
+ ul.className = 'elsewhere-list';
+ for (const it of items) {
+ const li = document.createElement('li');
+ const a = document.createElement('a');
+ a.href = it.url;
+ a.target = '_blank';
+ a.rel = it.rel || 'noopener';
+ a.className = 'btn btn-outline';
+ a.textContent = it.label;
+ li.appendChild(a);
+ ul.appendChild(li);
+ }
+ wrap.append(h, ul);
+ if (note) { const p = document.createElement('div'); p.className = 'muted elsewhere-note'; p.textContent = note; wrap.appendChild(p); }
+ return wrap;
+}
+// Live's Subscribe sells tier 1; a creator's higher tiers are joined on openvibe.vip with the same account.
+async function _appendTiers(box, username) {
+ const d = await _elsewhere(username);
+ const plans = d && d.memberships ? d.memberships.plans.filter((p) => p.tier > 1) : [];
+ if (!plans.length || !box || !box.isConnected) return;
+ box.appendChild(_elsewhereBlock('More tiers on OpenVibe.VIP', plans.map((p) => ({
+ url: p.join_url, label: p.price_cents ? `${p.name} · $${(p.price_cents / 100).toFixed(2)}/mo` : p.name,
+ })), 'Each tier has its own perks. You join on openvibe.vip with this account; one tier at a time.'));
+}
+async function _appendSupport(box, username) {
+ const d = await _elsewhere(username);
+ if (!d || !box || !box.isConnected) return;
+ const items = [];
+ if (d.tips_url) items.push({ url: d.tips_url, label: 'OpenVibe.Tips: paid messages, TTS and sounds' });
+ for (const l of d.support_links || []) items.push({ url: l.url, label: l.label, rel: 'nofollow noopener ugc' });
+ if (!items.length) return;
+ box.appendChild(_elsewhereBlock('Other ways to support', items, d.support_links ? 'Ko-fi, PayPal and the like are paid outside OpenVibe, not in Vibes.' : ''));
+}
+
@@ -129,7 +129,7 @@ async function _initSubscribe() {
@@ -129,7 +129,7 @@ async function _initSubscribe() {
catch { box.innerHTML = '<p class="muted">Subscriptions unavailable.</p>'; return; }
catch { box.innerHTML = '<p class="muted">Subscriptions unavailable.</p>'; return; }
const priceLine = document.getElementById('sub-price-line');
const priceLine = document.getElementById('sub-price-line');
if (priceLine) priceLine.textContent = `$${(info.priceUsd || cfg.subPriceUsd || 4.99).toFixed(2)}/month · ${info.subscriberCount || 0} subscriber${info.subscriberCount === 1 ? '' : 's'}`;
if (priceLine) priceLine.textContent = `$${(info.priceUsd || cfg.subPriceUsd || 4.99).toFixed(2)}/month · ${info.subscriberCount || 0} subscriber${info.subscriberCount === 1 ? '' : 's'}`;
- if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; return; }
+ if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; if (typeof _appendTiers === 'function') _appendTiers(box, username); return; }
const pc = info.powerchat || {};
const pc = info.powerchat || {};
const price = Number(info.priceUsd || cfg.subPriceUsd || 4.99);
const price = Number(info.priceUsd || cfg.subPriceUsd || 4.99);
const anyPowerchat = !!(pc.direct || pc.site);
const anyPowerchat = !!(pc.direct || pc.site);
@@ -158,6 +158,7 @@ async function _initSubscribe() {
@@ -158,6 +158,7 @@ async function _initSubscribe() {
Auto-renew monthly from my Vibes balance (Vibes/PowerChat)
Auto-renew monthly from my Vibes balance (Vibes/PowerChat)
</label>`;
</label>`;
box.innerHTML = html;
box.innerHTML = html;
+ if (typeof _appendTiers === 'function') _appendTiers(box, username); // channel-support.js (channel pages)
}
}
async function doSubscribe(username, provider) {
async function doSubscribe(username, provider) {
@@ -239,7 +240,8 @@ function openvibeBucksDonateModal() {
@@ -239,7 +240,8 @@ function openvibeBucksDonateModal() {
<button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;background:#8b5cf6;color:#fff;border:none">
<button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;background:#8b5cf6;color:#fff;border:none">
<i class="fa-solid fa-bolt"></i> Tip real money via PowerChat
<i class="fa-solid fa-bolt"></i> Tip real money via PowerChat
</button>
</button>
- </div>`;
+ </div>
+ <div id="donate-elsewhere"></div>`;
}
}
// Donate modal boot: balance-first, but NEVER at the cost of the direct route.
// Donate modal boot: balance-first, but NEVER at the cost of the direct route.
@@ -258,6 +260,7 @@ async function _initDonateModal() {
@@ -258,6 +260,7 @@ async function _initDonateModal() {
]);
]);
const box = document.getElementById('donate-powerchat-direct');
const box = document.getElementById('donate-powerchat-direct');
if (box && directAvailable) box.style.display = '';
if (box && directAvailable) box.style.display = '';
+ if (typeof _appendSupport === 'function') _appendSupport(document.getElementById('donate-elsewhere'), _channelUsername()); // channel-support.js
const line = document.getElementById('donate-balance-line');
const line = document.getElementById('donate-balance-line');
if (bal === null) return; // not logged in / balance unavailable — leave the form as-is
if (bal === null) return; // not logged in / balance unavailable — leave the form as-is
if (line) line.innerHTML = `Your balance: <strong>${bal.toLocaleString()}</strong> Vibes${bal < 1 ? ' — <a href="#" onclick="showModal(\'buy-funds\');return false">buy some</a> to donate Vibes' : ''}`;
if (line) line.innerHTML = `Your balance: <strong>${bal.toLocaleString()}</strong> Vibes${bal < 1 ? ' — <a href="#" onclick="showModal(\'buy-funds\');return false">buy some</a> to donate Vibes' : ''}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * A channel's support on OpenVibe.VIP and OpenVibe.Tips, shown on Live's channel page (owner 2026-10-08: "openvibe.vip
+ * and openvibe.tips … should be directly integrated to openvibe.live"):
+ *
+ * memberships the creator's VIP plans (one per tier, each with Billing's price) from VIP's public card
+ * (GET /embed/:username/card.json): Live's own Subscribe sells tier 1, the rest are joined on VIP
+ * support_links other ways to support the creator, paid outside OpenVibe, from Tips' public profile
+ * (GET /api/v1/profiles/:handle): Tips builds every link from a known service and a handle
+ *
+ * Both are public reads on loopback, cached per channel for a minute (a miss or an error is cached too, briefly), and
+ * never block the page: whatever does not answer within the timeout is left out.
+ */
+const TTL_MS = 60_000;
+const MISS_TTL_MS = 15_000;
+const TIMEOUT_MS = 1500;
+const cache = new Map(); // username → { at, ttl, value }
+
+const vipUrl = () => String(process.env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620').replace(/\/+$/, '');
+const tipsUrl = () => String(process.env.TIPS_URL || 'http://127.0.0.1:4610').replace(/\/+$/, '');
+const HANDLE_RE = /^[A-Za-z0-9_.-]{1,40}$/;
+const https = (u) => (typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null);
+
+async function getJson(url, fetchImpl) {
+ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ if (!res.ok) return null;
+ return await res.json().catch(() => null);
+}
+
+/** VIP's card → the plans a viewer can join (name, tier, price, link), or null. */
+function memberships(card) {
+ if (!card || !Array.isArray(card.plans)) return null;
+ const plans = card.plans.filter((p) => p && p.purchasable && https(p.join_url)).map((p) => ({
+ name: String(p.name || '').slice(0, 80), tier: Number(p.tier) || 1,
+ price_cents: Number.isSafeInteger(p.price_cents) && p.price_cents > 0 ? p.price_cents : null, join_url: p.join_url,
+ }));
+ if (!plans.length) return null;
+ return { url: https(card.join_url) || https(card.creator && card.creator.url), plans: plans.slice(0, 10) };
+}
+
+/** Tips' public profile → the creator's other ways to support (label and link), or null. */
+function supportLinks(profile) {
+ const list = profile && Array.isArray(profile.support_links) ? profile.support_links : [];
+ const out = list.map((l) => ({ provider: String(l.provider || ''), label: String(l.label || '').slice(0, 40), url: https(l.url) })).filter((l) => l.url && l.label);
+ return out.length ? out.slice(0, 8) : null;
+}
+
+async function forChannel(username, { fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
+ const name = String(username || '');
+ if (!HANDLE_RE.test(name)) return { memberships: null, support_links: null, tips_url: null };
+ const key = name.toLowerCase();
+ const hit = cache.get(key);
+ if (hit && now - hit.at < hit.ttl) return hit.value;
+ const [card, tips] = await Promise.all([
+ getJson(`${vipUrl()}/embed/${encodeURIComponent(key)}/card.json`, fetchImpl).catch(() => null),
+ getJson(`${tipsUrl()}/api/v1/profiles/${encodeURIComponent(key)}`, fetchImpl).catch(() => null),
+ ]);
+ const profile = tips && tips.profile ? tips.profile : null;
+ const value = {
+ memberships: memberships(card && card.card ? card.card : card),
+ support_links: supportLinks(profile),
+ tips_url: profile && profile.page_enabled && profile.accepting ? https(profile.url) : null,
+ };
+ const empty = !value.memberships && !value.support_links && !value.tips_url;
+ cache.set(key, { at: now, ttl: empty ? MISS_TTL_MS : TTL_MS, value });
+ if (cache.size > 2000) cache.delete(cache.keys().next().value);
+ return value;
+}
+
+function _reset() { cache.clear(); }
+
+module.exports = { forChannel, memberships, supportLinks, _reset };
@@ -239,6 +239,13 @@ router.get('/subscriptions/mine', requireAuth, async (req, res) => {
@@ -239,6 +239,13 @@ router.get('/subscriptions/mine', requireAuth, async (req, res) => {
res.json({ subscriptions: await db.getSubscriptionsBySubscriber(req.user.id) });
res.json({ subscriptions: await db.getSubscriptionsBySubscriber(req.user.id) });
});
});
+// The channel's memberships on OpenVibe.VIP (tiers) and other ways to support it (OpenVibe.Tips): public, the same
+// for everyone, cached a minute (elsewhere.js).
+router.get('/channel/:username/elsewhere', async (req, res) => {
+ const out = await require('./elsewhere').forChannel(req.params.username);
+ res.set('Cache-Control', 'public, max-age=60').json(out);
+});
+
// Am I subscribed to this channel? + subscriber count
// Am I subscribed to this channel? + subscriber count
router.get('/channel/:username', optionalAuth, async (req, res) => {
router.get('/channel/:username', optionalAuth, async (req, res) => {
const streamer = await db.getUserByUsername(req.params.username);
const streamer = await db.getUserByUsername(req.params.username);
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * A channel's support on OpenVibe.VIP and OpenVibe.Tips for Live's donate and subscribe modals (server/monetization/
+ * elsewhere.js): VIP's joinable plans with their tier and Billing's price, Tips' other ways to support (links Tips
+ * built) and the tip page while it takes tips. Only https links pass; whatever does not answer is left out; a minute's
+ * cache per channel; a name that is not a handle asks nobody.
+ *
+ * node test/channel-elsewhere.test.js
+ */
+const assert = require('assert');
+const elsewhere = require('../server/monetization/elsewhere');
+
+const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
+const card = {
+ creator: { username: 'tierra', url: 'https://openvibe.vip/tierra' }, join_url: 'https://openvibe.vip/tierra',
+ plans: [
+ { name: 'Supporter', tier: 1, price_cents: 300, purchasable: true, join_url: 'https://openvibe.vip/tierra#plan-supporter' },
+ { name: 'Superfan', tier: 2, price_cents: 1500, purchasable: true, join_url: 'https://openvibe.vip/tierra#plan-superfan' },
+ { name: 'Patron', tier: 3, price_cents: null, purchasable: false, join_url: 'https://openvibe.vip/tierra#plan-patron' },
+ { name: 'Sneaky', tier: 4, price_cents: 100, purchasable: true, join_url: 'javascript:alert(1)' },
+ ],
+};
+const profile = {
+ url: 'https://openvibe.tips/tierra', page_enabled: true, accepting: true,
+ support_links: [
+ { provider: 'kofi', label: 'Ko-fi', handle: 'tierra', url: 'https://ko-fi.com/tierra' },
+ { provider: 'paypal', label: 'PayPal.me', handle: 'x', url: 'http://paypal.me/x' },
+ ],
+};
+
+(async () => {
+ const calls = [];
+ const fetchImpl = async (url) => {
+ calls.push(url);
+ if (url.endsWith('/embed/tierra/card.json')) return json(200, card);
+ if (url.endsWith('/api/v1/profiles/tierra')) return json(200, { profile });
+ if (url.includes('/embed/quiet/')) throw new Error('timeout');
+ return json(404, { code: 'not_found' });
+ };
+
+ const out = await elsewhere.forChannel('Tierra', { fetchImpl, now: 1000 });
+ assert.deepStrictEqual(out.memberships, {
+ url: 'https://openvibe.vip/tierra',
+ plans: [
+ { name: 'Supporter', tier: 1, price_cents: 300, join_url: 'https://openvibe.vip/tierra#plan-supporter' },
+ { name: 'Superfan', tier: 2, price_cents: 1500, join_url: 'https://openvibe.vip/tierra#plan-superfan' },
+ ],
+ }, 'joinable plans only, and only https links');
+ assert.deepStrictEqual(out.support_links, [{ provider: 'kofi', label: 'Ko-fi', url: 'https://ko-fi.com/tierra' }], 'an http link is dropped');
+ assert.strictEqual(out.tips_url, 'https://openvibe.tips/tierra');
+ assert.ok(calls.every((u) => u.startsWith('http://127.0.0.1:')), 'loopback reads');
+
+ const n = calls.length;
+ await elsewhere.forChannel('tierra', { fetchImpl, now: 30_000 });
+ assert.strictEqual(calls.length, n, 'cached for a minute');
+ await elsewhere.forChannel('tierra', { fetchImpl, now: 62_000 });
+ assert.strictEqual(calls.length, n + 2, 'read again after the minute');
+
+ const quiet = await elsewhere.forChannel('quiet', { fetchImpl, now: 1000 });
+ assert.deepStrictEqual(quiet, { memberships: null, support_links: null, tips_url: null }, 'nothing answers: nothing shown');
+
+ profile.accepting = false;
+ elsewhere._reset();
+ assert.strictEqual((await elsewhere.forChannel('tierra', { fetchImpl, now: 1000 })).tips_url, null, 'no tip page link while it takes no tips');
+
+ const before = calls.length;
+ assert.deepStrictEqual(await elsewhere.forChannel('../admin', { fetchImpl }), { memberships: null, support_links: null, tips_url: null });
+ assert.strictEqual(calls.length, before, 'a name that is not a handle asks nobody');
+ console.log('channel elsewhere: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });