openvibers/openvibe.live Commits

Live: a community badge (the Workshop's network.badge) shows before a name in chat — Live's decor carries it as badgeFX (#61)

OP OpenVibers committed 9 Oct 2026 · 984e785a04 · 5 files, +34 −3

Changes

ddiff --git a/public/css/cosmetics.css b/public/css/cosmetics.css
iindex ded87aa9..bb2fc6a9 100644
---- a/public/css/cosmetics.css
++++ b/public/css/cosmetics.css
@@@ -210,6 +210,17 @@
HAT EFFECTS — shown before username in chat
══════════════════════════════════════════════════════════════ */
++/* A community badge (the Workshop, network.badge): its reviewed image before the name. */
++.chat-ws-badge {
++ display: inline-block;
++ width: 1.15em;
++ height: 1.15em;
++ margin-right: 3px;
++ vertical-align: -0.2em;
++ border-radius: 3px;
++ object-fit: contain;
++}
++
.chat-hat {
display: inline-block;
margin-right: 2px;
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex b3feba77..a8014a0b 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3749,9 +3749,13 @@ function buildChatMessageEl(msg, opts = {}) {
// ── Cosmetic rendering ───────────────────────────────
// Hat emoji before name
let hatHtml = '';
++ // A community badge (the Workshop): its reviewed image on OpenVibe.Media, before the hat and the name.
++ if (msg.badgeFX && /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(msg.badgeFX.mediaId || ''))) {
++ hatHtml += `<img class="chat-ws-badge" src="https://openvibe.media/o/${msg.badgeFX.mediaId}" alt="" width="16" height="16" loading="lazy" aria-hidden="true">`;
++ }
if (msg.hatFX && msg.hatFX.hatChar) {
const animClass = msg.hatFX.animated ? ` hat-${esc(msg.hatFX.animated)}` : '';
-- hatHtml = `<span class="chat-hat${animClass}">${esc(msg.hatFX.hatChar)}</span>`;
++ hatHtml += `<span class="chat-hat${animClass}">${esc(msg.hatFX.hatChar)}</span>`;
}
// Name effect CSS class on username
@@@ -4707,6 +4711,7 @@ function _renderHistoryMainMsg(m) {
nameFX: m.nameFX || undefined,
particleFX: m.particleFX || undefined,
hatFX: m.hatFX || undefined,
++ badgeFX: m.badgeFX || undefined,
tag: m.tag || undefined,
// Source-stream context (for the per-message origin/channel badge)
stream_id: m.stream_id,
@@@ -4817,6 +4822,7 @@ function _renderGlobalHistoryMessages(msgs) {
source_managed_id: m.source_managed_id || null,
source_is_live: m.source_is_live ? 1 : 0,
hatFX: m.hatFX || null,
++ badgeFX: m.badgeFX || null,
nameFX: m.nameFX || null,
particleFX: m.particleFX || null,
reply_to: m.reply_to || null,
ddiff --git a/server/monetization/cosmetics.js b/server/monetization/cosmetics.js
iindex 54be431e..61cdd2de 100644
---- a/server/monetization/cosmetics.js
++++ b/server/monetization/cosmetics.js
@@@ -127,7 +127,7 @@ async function getEquipped(userId) {
}, {});
}
--/** What a chat line carries for one { slot → item id } set: { nameFX, particleFX, hatFX, voiceFX } (each optional). */
++/** What a chat line carries for one { slot → item id } set: { nameFX, particleFX, hatFX, badgeFX, voiceFX } (each optional). */
function profileOf(equipped) {
const result = {};
if (equipped.name_effect && COSMETICS[equipped.name_effect]) {
@@@ -142,6 +142,8 @@ function profileOf(equipped) {
const c = COSMETICS[equipped.hat];
result.hatFX = { itemId: equipped.hat, hatChar: c.hatChar, cssClass: c.hatChar, animated: c.animated };
}
++ // A community badge from the Workshop (network.badge): its reviewed image on OpenVibe.Media, before the name.
++ if (/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(equipped._badge || ''))) result.badgeFX = { mediaId: equipped._badge };
if (equipped.voice && COSMETICS[equipped.voice]) {
const c = COSMETICS[equipped.voice];
result.voiceFX = { itemId: equipped.voice, pitch: c.pitch, rate: c.rate };
ddiff --git a/server/monetization/inventory-client.js b/server/monetization/inventory-client.js
iindex f0bef582..2cd9bfad 100644
---- a/server/monetization/inventory-client.js
++++ b/server/monetization/inventory-client.js
@@@ -5,7 +5,8 @@
* catalog() Live's definitions (alias = Live's item id ↔ itd_ id), cached 10 minutes
* subjectOf(userId) a Live user's Network subject (linked_accounts, server/auth/identity-sync.js), cached
* owned(subject) { alias → instance_id } of the live.* items the person owns
-- * equipped(subject) { slot → alias } of what they wear, cached 30 s (chat and calls read it per message)
++ * equipped(subject) { slot → alias } of what they wear (and `_badge`: a worn community badge's media id),
++ * cached 30 s (chat and calls read it per message)
* equippedMany(subjects) Map(subject → { slot → alias }), 100 people per read (OpenVibe.Chat's decor lookups)
* grant / revoke / equip the writes, acting for the person with Live's own token
*
@@@ -119,6 +120,8 @@ function remember(subject, slots, byId) {
const value = {};
for (const [key, v] of Object.entries(slots || {})) {
const [kind, slot] = key.split(':');
++ // A community badge (OpenVibe.Inventory's Workshop, network.badge): its image, for the chat line.
++ if (kind === 'network.badge' && /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(v.media_id || ''))) { value._badge = v.media_id; continue; }
if (!kind.startsWith('live.')) continue;
const alias = byId.get(v.definition_id);
if (alias) value[slot] = alias;
ddiff --git a/test/cosmetics-inventory.test.js b/test/cosmetics-inventory.test.js
iindex 391cd3fd..c552aeb4 100644
---- a/test/cosmetics-inventory.test.js
++++ b/test/cosmetics-inventory.test.js
@@@ -103,6 +103,15 @@ function stub() {
assert.strictEqual((await cosmetics.revokeCosmetic(70, 'hat_crown')).success, true);
assert.ok(!(await cosmetics.getUnlocked(70)).some((u) => u.item_id === 'hat_crown'));
++ // A community badge from the Workshop rides along as badgeFX (its Media image), alone and in a batch.
++ state.slots['network.badge:badge'] = { instance_id: 'inv_01JZ000000000000000000BAD1', definition_id: 'itd_01JZ000000000000000000BAD1', media_id: 'med_01JZ00000000000000000000B1' };
++ inventory.forget(A);
++ assert.deepStrictEqual((await cosmetics.getCosmeticProfile(70)).badgeFX, { mediaId: 'med_01JZ00000000000000000000B1' });
++ inventory.forget(A);
++ assert.deepStrictEqual((await cosmetics.getCosmeticProfiles([70]))[70].badgeFX, { mediaId: 'med_01JZ00000000000000000000B1' });
++ delete state.slots['network.badge:badge'];
++ inventory.forget(A);
++
// The shop says where an item is earned: Quest gives the Rainbow (Inventory's grantors).
assert.deepStrictEqual(await cosmetics.earnable(), { fx_rainbow: 'quest' });