openvibers/openvibe.shared purse.js
purse.js
| 1 | /* purse.js — the balances in the bar as one pill (4.189.0). The owner, 2026-11-10: "we just show one, but it can |
| 2 | animate between all three, especially when you spend some … first load it should scroll through them all, or every once |
| 3 | in a while … a custom animation for gaining and losing, with coins coming out of it … nice icons that are animated, |
| 4 | designed for the three currencies … it only needs to show the things you have." |
| 5 | |
| 6 | navbar.js draws the pill (#openvibe-chips-all: one .ovnav-chip-mini per balance the person has, the first one showing) |
| 7 | and loads this file beside itself. This file gives each currency its own drawn icon, rolls the pill through the |
| 8 | balances on the first page of a visit and now and then after, and, when a balance changes, turns to it, counts the |
| 9 | number up or down and sends a few of its pieces in or out. catchUp reads the person's change window. |
| 10 | |
| 11 | OpenVibePurse.mount(button) the pill, after each draw of the bar |
| 12 | OpenVibePurse.set(id, value) a balance changed ('coins', '1,234') |
| 13 | OpenVibePurse.icon(id, size) the currency's icon as SVG text ('coins' | 'vibes' | 'balance'), for other surfaces |
| 14 | OpenVibePurse.play(id, delta) the gain or spend effect at the bar's pill |
| 15 | OpenVibePurse.burst(el, id, delta) the same effect at any element: a buy button, a card, a tip |
| 16 | OpenVibePurse.count(el, from, to, format) count a number from one value to another |
| 17 | A holder with data-purse="coins|vibes|balance" has the currency's colour as --pc; .ovp-play plays its icon's move. */ |
| 18 | (function (root) { |
| 19 | ; |
| 20 | if (!root || !root.document || root.OpenVibePurse) return; |
| 21 | const doc = root.document; |
| 22 | const svg = (size, body) => `<svg class="ovp-ic" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${body}</svg>`; |
| 23 | // Each icon: the currency's colour as its body (--pc), the detail cut in a darker mix of it, one light edge. |
| 24 | const ICONS = { |
| 25 | // OpenCoins: a coin with the network's ring struck in it. |
| 26 | coins: '<g class="ovp-spin"><circle cx="12" cy="12" r="9.6" fill="var(--pc)"/><circle cx="12" cy="12" r="9.6" fill="none" stroke="var(--pd)" stroke-width="1.3" opacity=".55"/><circle cx="12" cy="12" r="4.4" fill="none" stroke="var(--pd)" stroke-width="2.5"/><path d="M5.6 9.2a7 7 0 0 1 4.6-4.1" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity=".7"/></g>', |
| 27 | // Vibes: a cut gem (bought, then given). |
| 28 | vibes: '<g class="ovp-gem"><path d="M7.2 3.6h9.6l4.4 5.6L12 21 2.8 9.2z" fill="var(--pc)" stroke="var(--pc)" stroke-width="1.6" stroke-linejoin="round"/><path d="M2.8 9.2h18.4M8.9 9.2 12 20.4l3.1-11.2M7.2 3.6l1.7 5.6L12 3.6l3.1 5.6 1.7-5.6" fill="none" stroke="var(--pd)" stroke-width="1.25" stroke-linejoin="round" opacity=".8"/><path class="ovp-glint" d="M6.3 7.3 8 5.2" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity=".75"/></g>', |
| 29 | // The money balance: a note with a round seal. |
| 30 | balance: '<g class="ovp-note"><rect x="2.2" y="5.6" width="19.6" height="12.8" rx="3.2" fill="var(--pc)"/><circle cx="12" cy="12" r="3.1" fill="none" stroke="var(--pd)" stroke-width="2.1"/><path d="M5.6 9.2v.01M18.4 14.8v.01" stroke="var(--pd)" stroke-width="2.2" stroke-linecap="round"/><path d="M5 8.4h3.4" stroke="#fff" stroke-width="1.3" stroke-linecap="round" opacity=".6"/></g>', |
| 31 | 'channel-points': '<path d="m12 2.5 2.8 6.6 7.1.6-5.4 4.7 1.7 7-6.2-3.7-6.2 3.7 1.7-7-5.4-4.7 7.1-.6z" fill="var(--pc)" stroke="var(--pd)" stroke-width="1.2"/>', |
| 32 | }; |
| 33 | const CSS = ` |
| 34 | [data-purse], .ovnav-chip-mini, .ovp-bit, .ovp-delta, .ovp-icw { --pc: var(--text-secondary, #a8b3c4); --pd: color-mix(in oklab, var(--pc) 42%, #000); } |
| 35 | /* Each colour leans toward the page's text, so it is brighter on a dark theme and deeper on a light one. */ |
| 36 | [data-purse="coins"] { --pc: color-mix(in oklab, #f6b21d 88%, var(--text-primary, #e6edf7)); } |
| 37 | [data-purse="vibes"] { --pc: color-mix(in oklab, #1fc78c 86%, var(--text-primary, #e6edf7)); } |
| 38 | [data-purse="balance"] { --pc: color-mix(in oklab, var(--accent, #3b82f6) 78%, var(--text-primary, #e6edf7)); } |
| 39 | [data-purse="channel-points"] { --pc: color-mix(in oklab, #e899fb 85%, var(--text-primary, #e6edf7)); } |
| 40 | .ovp-ic { display: block; flex: none; overflow: visible; } |
| 41 | .openvibe-navbar .ovnav-chip.ovnav-chip--all.is-purse { height: 32px; padding: 0 3px 0 2px; border-radius: 999px; overflow: hidden; font-size: 13.5px; font-weight: 750; letter-spacing: -.1px; transition: background-color .2s, border-color .3s, box-shadow .3s, transform .15s; } |
| 42 | .openvibe-navbar .ovnav-chip--all.is-purse:active { transform: scale(.95); } |
| 43 | .is-purse .ovnav-chip-mini { gap: 6px; padding: 0 8px 0 5px; font-variant-numeric: tabular-nums; transform: translateY(calc(85% * var(--ov-move, 1))); transition: transform .5s cubic-bezier(.3, 1.35, .45, 1), opacity .28s; } |
| 44 | .is-purse .ovnav-chip-mini.is-on { transform: none; } |
| 45 | .is-purse .ovnav-chip-mini.is-out { transform: translateY(calc(-85% * var(--ov-move, 1))); } |
| 46 | .is-purse .ovnav-chip-mini .ovp-ic { width: 19px; height: 19px; filter: drop-shadow(0 1px 1.5px color-mix(in srgb, var(--pd) 55%, transparent)); } |
| 47 | /* Each icon moves its own way as its balance turns up. */ |
| 48 | .is-purse .is-on .ovp-spin { transform-origin: 12px 12px; animation: ovpSpin .7s cubic-bezier(.3, .9, .3, 1) both; } |
| 49 | .is-purse .is-on .ovp-gem { transform-origin: 12px 12px; animation: ovpGem .7s cubic-bezier(.3, 1.4, .4, 1) both; } |
| 50 | .is-purse .is-on .ovp-note { transform-origin: 12px 12px; animation: ovpNote .6s cubic-bezier(.3, 1.3, .4, 1) both; } |
| 51 | .is-purse .is-on .ovp-glint { animation: ovpGlint 2.8s ease-in-out .6s infinite; } |
| 52 | /* Anywhere else: .ovp-play on an icon's holder plays its move once (take the class off and put it back to replay). */ |
| 53 | .ovp-play .ovp-spin { transform-origin: 12px 12px; animation: ovpSpin .7s cubic-bezier(.3, .9, .3, 1) both; } |
| 54 | .ovp-play .ovp-gem { transform-origin: 12px 12px; animation: ovpGem .7s cubic-bezier(.3, 1.4, .4, 1) both; } |
| 55 | .ovp-play .ovp-note { transform-origin: 12px 12px; animation: ovpNote .6s cubic-bezier(.3, 1.3, .4, 1) both; } |
| 56 | .ovp-play .ovp-glint { animation: ovpGlint 2.8s ease-in-out .6s infinite; } |
| 57 | @keyframes ovpSpin { from { transform: scaleX(calc(1 - 2 * var(--ov-move, 1))); } 50% { transform: scaleX(calc(1 - .9 * var(--ov-move, 1))); } } |
| 58 | @keyframes ovpGem { from { transform: rotate(calc(-28deg * var(--ov-move, 1))) scale(.6); } } |
| 59 | @keyframes ovpNote { from { transform: translateX(calc(-5px * var(--ov-move, 1))) rotate(calc(-12deg * var(--ov-move, 1))); } } |
| 60 | @keyframes ovpGlint { 0%, 70%, 100% { opacity: .75; } 82% { opacity: .1; } } |
| 61 | /* A balance went up or down: the pill takes that balance's colour for a moment and its icon jumps or dips. */ |
| 62 | .openvibe-navbar .ovnav-chip--all.is-purse.is-gain, .openvibe-navbar .ovnav-chip--all.is-purse.is-loss { border-color: color-mix(in srgb, var(--pf, currentColor) 60%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pf, currentColor) 16%, transparent); } |
| 63 | .is-purse.is-gain .is-on .ovp-ic { animation: ovpHop .55s cubic-bezier(.3, 1.6, .4, 1); } |
| 64 | .is-purse.is-loss .is-on .ovp-ic { animation: ovpDip .5s ease-out; } |
| 65 | @keyframes ovpHop { 35% { transform: translateY(calc(-4px * var(--ov-move, 1))) scale(1.22); } } |
| 66 | @keyframes ovpDip { 35% { transform: translateY(calc(2px * var(--ov-move, 1))) scale(.84); } } |
| 67 | .ovp-bit, .ovp-delta { position: fixed; z-index: 2147483000; pointer-events: none; left: 0; top: 0; will-change: transform, opacity; } |
| 68 | .ovp-delta { padding: 2px 8px; border-radius: 999px; font: 800 12.5px/1.3 var(--font, system-ui, sans-serif); font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--pc); |
| 69 | background: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, #111826)) 92%, var(--pc)); border: 1px solid color-mix(in srgb, var(--pc) 45%, transparent); box-shadow: 0 6px 18px rgba(0, 0, 0, .28); } |
| 70 | @media (max-width: 400px) { .openvibe-navbar .ovnav-chip.ovnav-chip--all.is-purse { font-size: 13px; letter-spacing: -.2px; } .is-purse .ovnav-chip-mini { gap: 4px; padding: 0 6px 0 4px; } .is-purse .ovnav-chip-mini .ovp-ic { width: 17px; height: 17px; } } |
| 71 | :root:root[data-ov-motion=off] .is-purse .ovnav-chip-mini, :root:root[data-ov-motion=off] .is-purse .ovnav-chip-mini * { animation: none !important; transition: none !important; }`; |
| 72 | |
| 73 | const num = (v) => { const s = v == null ? '' : String(v).trim(); if (!/^-?[\d,]+(\.\d+)?$/.test(s)) return null; const n = Number(s.replace(/,/g, '')); return isFinite(n) ? n : null; }; |
| 74 | /** 160774 → "161k" (the same short form the bar writes). */ |
| 75 | function short(n) { |
| 76 | const a = Math.abs(n); |
| 77 | if (a >= 1e6) return `${(n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, '')}M`; |
| 78 | if (a >= 1e4) return `${Math.round(n / 1e3)}k`; |
| 79 | if (a >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, '')}k`; |
| 80 | return String(Math.round(n)); |
| 81 | } |
| 82 | const motion = () => { const m = doc.documentElement.getAttribute('data-ov-motion'); return m === 'off' ? 0 : m === 'reduced' ? .35 : 1; }; |
| 83 | const seen = () => { try { return root.sessionStorage.getItem('ovp-toured') === '1'; } catch { return true; } }; |
| 84 | const markSeen = () => { try { root.sessionStorage.setItem('ovp-toured', '1'); } catch { /* a private window: it tours again */ } }; |
| 85 | |
| 86 | let btn = null, minis = [], home = 0, at = 0, timers = [], last = {}, busy = false; |
| 87 | const later = (fn, ms) => { const t = setTimeout(fn, ms); timers.push(t); return t; }; |
| 88 | const clear = () => { timers.forEach(clearTimeout); timers = []; }; |
| 89 | const visible = () => btn && btn.isConnected && !doc.hidden && btn.getClientRects().length > 0; |
| 90 | |
| 91 | function style() { |
| 92 | if (doc.getElementById('ovp-style')) return; |
| 93 | const st = doc.createElement('style'); st.id = 'ovp-style'; st.textContent = CSS; doc.head.appendChild(st); |
| 94 | } |
| 95 | /** Turn the pill to one balance: the one showing leaves upward, the next comes up from below. */ |
| 96 | function show(i) { |
| 97 | if (!minis.length) return; i = ((i % minis.length) + minis.length) % minis.length; |
| 98 | if (i === at && minis[i].classList.contains('is-on')) return; |
| 99 | const out = minis[at]; |
| 100 | minis.forEach((m) => m.classList.remove('is-out')); |
| 101 | if (out && out !== minis[i]) { out.classList.remove('is-on'); out.classList.add('is-out'); later(() => out.classList.remove('is-out'), 520); } |
| 102 | minis[i].classList.add('is-on'); at = i; |
| 103 | } |
| 104 | /** Now and then the pill turns to the next balance for a few seconds, then back to the largest. */ |
| 105 | function idle() { |
| 106 | if (minis.length < 2) return; |
| 107 | let next = home; |
| 108 | const tick = () => { |
| 109 | if (!btn || !btn.isConnected) return; |
| 110 | if (!busy && visible()) { next = (next + 1) % minis.length; if (next === home) next = (next + 1) % minis.length; show(next); later(() => { if (!busy) show(home); }, 3600); } |
| 111 | later(tick, 42000); |
| 112 | }; |
| 113 | later(tick, 42000); |
| 114 | } |
| 115 | /** The first page of a visit: each balance in turn, ending on the largest. */ |
| 116 | function tour() { |
| 117 | if (minis.length < 2 || seen()) return idle(); |
| 118 | markSeen(); |
| 119 | let i = 0; |
| 120 | const step = () => { if (busy) return idle(); i += 1; if (i < minis.length) { show(i); later(step, 2300); } else { show(home); idle(); } }; |
| 121 | later(step, 2600); |
| 122 | } |
| 123 | function mount(el) { |
| 124 | if (!el || !el.querySelectorAll) return; |
| 125 | style(); clear(); busy = false; |
| 126 | btn = el; minis = Array.from(el.querySelectorAll('.ovnav-chip-mini')); home = 0; at = 0; last = {}; |
| 127 | el.classList.add('is-purse'); |
| 128 | minis.forEach((m, i) => { |
| 129 | const id = m.getAttribute('data-chip-mini'); |
| 130 | last[id] = num(m.getAttribute('data-v')); |
| 131 | if (ICONS[id]) { |
| 132 | m.setAttribute('data-purse', id); |
| 133 | const old = m.querySelector('.ovnav-ic, .ovp-ic'); const holder = doc.createElement('span'); holder.innerHTML = svg(19, ICONS[id]); |
| 134 | if (old) old.replaceWith(holder.firstChild); else m.insertBefore(holder.firstChild, m.firstChild); |
| 135 | } |
| 136 | m.classList.toggle('is-on', i === 0); m.classList.remove('is-out'); |
| 137 | }); |
| 138 | tour(); |
| 139 | } |
| 140 | /** A few of the currency's pieces: into the pill from below on a gain, out of it and down on a spend. */ |
| 141 | function pieces(id, rect, gain, k, at) { |
| 142 | if (!ICONS[id] || typeof doc.body.animate !== 'function' || k === 0) return; |
| 143 | // at: { x, y } where the pieces meet (the pill's icon by default), host: the layer they are drawn in, up: they fly above it. |
| 144 | const cx = at && at.x != null ? at.x : rect.left + 14, cy = at && at.y != null ? at.y : rect.top + rect.height / 2, n = k < 1 ? 3 : 7, host = (at && at.host) || doc.body, sign = at && at.up ? -1 : 1; |
| 145 | for (let i = 0; i < n; i += 1) { |
| 146 | const b = doc.createElement('span'); b.className = 'ovp-bit'; b.setAttribute('data-purse', id); b.innerHTML = svg(13, ICONS[id]); |
| 147 | host.appendChild(b); |
| 148 | const spread = ((i / (n - 1)) - .5) * 2, dx = spread * 34 * k + (Math.random() - .5) * 10, dy = sign * (38 + Math.random() * 26) * k, turn = (Math.random() - .5) * 260 * k; |
| 149 | const far = `translate(${cx - 6 + dx}px, ${cy - 6 + dy}px) rotate(${turn}deg) scale(.9)`, near = `translate(${cx - 6}px, ${cy - 6}px) rotate(0deg) scale(.55)`; |
| 150 | const mid = `translate(${cx - 6 + dx * .7}px, ${cy - 6 + dy * (gain ? .75 : .25) - (gain ? 0 : 9 * k)}px) rotate(${turn / 2}deg) scale(1)`; |
| 151 | const frames = gain ? [{ transform: far, opacity: 0 }, { transform: mid, opacity: 1, offset: .3 }, { transform: near, opacity: .9, offset: .92 }, { transform: near, opacity: 0 }] |
| 152 | : [{ transform: near, opacity: 0 }, { transform: mid, opacity: 1, offset: .28 }, { transform: far, opacity: 0 }]; |
| 153 | const a = b.animate(frames, { duration: gain ? 760 : 900, delay: i * (gain ? 55 : 40), easing: gain ? 'cubic-bezier(.5,0,.3,1)' : 'cubic-bezier(.2,.6,.5,1)', fill: 'both' }); |
| 154 | const done = () => b.remove(); a.onfinish = done; a.oncancel = done; setTimeout(done, 2200); |
| 155 | } |
| 156 | } |
| 157 | /** "+15" or "−200" under the pill: it rises into the pill on a gain and falls away on a spend. */ |
| 158 | function delta(id, rect, d, k, at) { |
| 159 | if (typeof doc.body.animate !== 'function') return; |
| 160 | const host = (at && at.host) || doc.body, up = !!(at && at.up); |
| 161 | const t = doc.createElement('span'); t.className = 'ovp-delta'; t.setAttribute('data-purse', id); |
| 162 | t.textContent = `${d > 0 ? '+' : '−'}${Math.abs(d) >= 1e4 ? short(Math.abs(d)) : Math.abs(d).toLocaleString()}`; |
| 163 | host.appendChild(t); |
| 164 | const x = Math.max(6, Math.min(root.innerWidth - t.offsetWidth - 6, rect.left + rect.width / 2 - t.offsetWidth / 2)), y = up ? rect.top - t.offsetHeight - 6 : rect.bottom + 6, m = (up ? -16 : 16) * (k || .01); |
| 165 | const frames = d > 0 ? [{ transform: `translate(${x}px, ${y + m}px)`, opacity: 0 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .22 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .78 }, { transform: `translate(${x}px, ${y - m * .6}px) scale(.9)`, opacity: 0 }] |
| 166 | : [{ transform: `translate(${x}px, ${y - m * .5}px)`, opacity: 0 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .22 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .75 }, { transform: `translate(${x}px, ${y + m}px)`, opacity: 0 }]; |
| 167 | const a = t.animate(frames, { duration: 1900, easing: 'ease-out', fill: 'both' }); |
| 168 | const done = () => t.remove(); a.onfinish = done; a.oncancel = done; setTimeout(done, 2600); |
| 169 | } |
| 170 | function play(id, d) { |
| 171 | if (!btn || !visible() || !d || motion() === 0) return false; |
| 172 | const i = minis.findIndex((m) => m.getAttribute('data-chip-mini') === id); |
| 173 | const k = motion(), rect = btn.getBoundingClientRect(); |
| 174 | busy = true; clear(); if (i >= 0) show(i); |
| 175 | btn.style.setProperty('--pf', i >= 0 ? root.getComputedStyle(minis[i]).getPropertyValue('--pc') || 'currentColor' : 'currentColor'); |
| 176 | btn.classList.remove('is-gain', 'is-loss'); void btn.offsetWidth; btn.classList.add(d > 0 ? 'is-gain' : 'is-loss'); |
| 177 | if (k === 1) pieces(id, rect, d > 0, k); delta(id, rect, d, k); |
| 178 | later(() => { btn.classList.remove('is-gain', 'is-loss'); }, 1500); |
| 179 | later(() => { busy = false; show(home); idle(); }, 3400); |
| 180 | return true; |
| 181 | } |
| 182 | /** |
| 183 | * The gain or spend effect at any element (4.190.0): the currency's pieces fly into it (a gain) or out of it (a |
| 184 | * spend) and the amount shows beside it. It works inside an open dialog, and goes upward near the bottom of the |
| 185 | * screen. burst(button, 'coins', -200) when someone spends; burst(card, 'vibes', 50) when they receive. |
| 186 | */ |
| 187 | function burst(el, id, d) { |
| 188 | if (!el || !el.getBoundingClientRect || !d || !ICONS[id]) return; |
| 189 | style(); |
| 190 | const rect = el.getBoundingClientRect(); if (!rect.width) return; |
| 191 | const icon = el.querySelector && el.querySelector('.ovp-ic'), ir = icon ? icon.getBoundingClientRect() : rect; |
| 192 | const at = { x: ir.left + ir.width / 2, y: ir.top + ir.height / 2, host: (el.closest && el.closest('dialog[open]')) || doc.body, up: rect.bottom > root.innerHeight - 110 }; |
| 193 | const k = motion(); |
| 194 | pieces(id, rect, d > 0, k, at); delta(id, rect, d, k, at); |
| 195 | } |
| 196 | /** Count a number from what it was to what it is: in the bar's short form, or with the format given. */ |
| 197 | function count(el, from, to, fmt) { |
| 198 | if (!el || typeof root.requestAnimationFrame !== 'function') return; |
| 199 | if (motion() === 0) { el.textContent = (fmt || short)(to); return; } |
| 200 | const t0 = root.performance.now(), ms = 800; |
| 201 | const step = (t) => { const p = Math.min(1, (t - t0) / ms), e = 1 - Math.pow(1 - p, 3); if (!el.isConnected) return; el.textContent = (fmt || short)(p < 1 ? from + (to - from) * e : to); if (p < 1) root.requestAnimationFrame(step); }; |
| 202 | root.requestAnimationFrame(step); |
| 203 | } |
| 204 | function set(id, value) { |
| 205 | const n = num(value), was = last[id]; |
| 206 | last[id] = n; |
| 207 | if (!btn || n == null || was == null || n === was) return; |
| 208 | const m = minis.find((x) => x.getAttribute('data-chip-mini') === id); if (!m) return; |
| 209 | m.setAttribute('data-v', String(value)); |
| 210 | play(id, n - was); count(m.querySelector('.ovnav-chip-mv'), was, n); |
| 211 | } |
| 212 | let recent = null, recentUser = null, running = null, runningUser = null; |
| 213 | const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
| 214 | function recentChanges(user) { |
| 215 | const id = user || recentUser; |
| 216 | if (!id) return null; |
| 217 | if (recent && id === recentUser) return recent; |
| 218 | try { return JSON.parse(root.sessionStorage.getItem(`ovp-changes:${id}`) || 'null'); } catch { return null; } |
| 219 | } |
| 220 | async function catchUp(options = {}) { |
| 221 | const { frameBase = 'https://openvibe.network/api/frame', token, user, streamer, valid } = options; |
| 222 | if (!user || typeof root.fetch !== 'function') return; |
| 223 | if (recentUser !== user) recent = null; |
| 224 | recentUser = user; |
| 225 | if (running) return runningUser === user ? running : running.then(() => catchUp(options)); |
| 226 | runningUser = user; |
| 227 | const base = String(frameBase).replace(/\/+$/, ''); |
| 228 | const headers = token ? { Authorization: `Bearer ${token}` } : {}; |
| 229 | const credentials = token ? 'omit' : 'include'; |
| 230 | const channel = /^[1-9]\d{0,15}$/.test(String(streamer || '')) ? String(streamer) : ''; |
| 231 | const url = `${base}/wallet/changes${channel ? `?streamer=${channel}` : ''}`; |
| 232 | running = (async () => { |
| 233 | let answer; |
| 234 | try { |
| 235 | const response = await root.fetch(url, { headers, credentials }); |
| 236 | if (!response.ok) return; |
| 237 | answer = await response.json(); |
| 238 | } catch { return; } |
| 239 | if (!answer || typeof answer !== 'object' || (valid && !valid())) return; |
| 240 | const order = [['opencoins', 'coins'], ['vibes', 'vibes'], ['channel_points', 'channel-points']]; |
| 241 | const changed = order.filter(([name]) => answer[name] && Number.isSafeInteger(Number(answer[name].net)) && Number(answer[name].net) !== 0 && answer[name].cursor != null); |
| 242 | if (!changed.length) return; |
| 243 | recent = answer; recentUser = user; |
| 244 | try { root.sessionStorage.setItem(`ovp-changes:${user}`, JSON.stringify(answer)); } catch { /* closed storage */ } |
| 245 | for (const [name, id] of changed) { |
| 246 | const part = answer[name], cursor = String(part.cursor); |
| 247 | const guard = `ovp-read:${user}:${name}:${name === 'channel_points' ? channel : ''}`; |
| 248 | let previous = null; try { previous = root.sessionStorage.getItem(guard); } catch { /* closed storage */ } |
| 249 | if ((valid && !valid()) || previous === cursor || (motion() !== 0 && !visible())) continue; |
| 250 | if (motion() !== 0) play(id, Number(part.net)); |
| 251 | try { root.sessionStorage.setItem(guard, cursor); } catch { /* closed storage */ } |
| 252 | try { |
| 253 | await root.fetch(`${base}/wallet/changes/read`, { method: 'PUT', credentials, headers: { ...headers, 'Content-Type': 'application/json' }, |
| 254 | body: JSON.stringify({ currency: id === 'coins' ? 'opencoins' : id, cursor: part.cursor, ...(id === 'channel-points' ? { streamer: Number(channel) } : {}) }) }); |
| 255 | } catch { /* the server's read state will be retried on the next visit */ } |
| 256 | if (changed.at(-1)[0] !== name) await pause(700); |
| 257 | } |
| 258 | })().finally(() => { running = null; runningUser = null; }); |
| 259 | return running; |
| 260 | } |
| 261 | root.OpenVibePurse = { mount, set, play, burst, count, catchUp, recentChanges, icon: (id, size) => { style(); return ICONS[id] ? svg(size || 20, ICONS[id]) : ''; }, ids: Object.keys(ICONS) }; |
| 262 | if (root.__ovPursePending) { const pending = root.__ovPursePending; delete root.__ovPursePending; catchUp(pending); } |
| 263 | const now = doc.getElementById('openvibe-chips-all'); if (now) mount(now); |
| 264 | })(typeof window !== 'undefined' ? window : null); |
| 265 |