openvibers/openvibe.live Commits

Chat: PowerChat relay follows the channel, cross-feed stops duplicating

OP OpenVibers committed 26 Aug 2026 · 80b4137270 · 3 files, +36 −5

A popout pinned to an expired stream id kept chatting in the channel but
the relay gate read the stale slot's config — the slot check now follows
the channel's CURRENT live stream (or allows when offline). Same popout
also rendered every own message twice (local + cross-feed copy): the
own-channel suppression only knew currentStreamData, empty for expired
pins — it now also matches chatChannel and the joined channel user id,
so the local copy always takes precedence.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 36da5792..483c8241 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -5293,18 +5293,28 @@ function _handleGlobalFeedMessage(msg) {
}
// Don't duplicate messages from the channel we're currently viewing (its own
-- // messages already render in the main timeline). Works live AND offline.
-- if (hasStreamChannel) {
++ // messages already render in the main timeline). Works live AND offline. The
++ // local copy always takes precedence over the cross-feed one.
++ // Fallback chain matters: in a popout pinned to an expired stream id,
++ // currentStreamData is empty — chatChannel (set by the history load) and the
++ // joined channel user id still identify the channel, so compare those too or
++ // every own message renders twice (local + cross-feed copy).
++ if (hasStreamChannel || msg.channel_user_id) {
const currentStreamChannel = (currentStreamData && currentStreamData.username)
|| (typeof currentChannelUsername !== 'undefined' && currentChannelUsername)
++ || (typeof chatChannel !== 'undefined' && chatChannel)
|| null;
-- if (currentStreamChannel) {
++ if (hasStreamChannel && currentStreamChannel) {
const currentChannelNorm = String(currentStreamChannel).trim().toLowerCase();
const sourceChannelNorm = String(msg.stream_channel).trim().toLowerCase();
if (currentChannelNorm && currentChannelNorm === sourceChannelNorm) {
return;
}
}
++ if (msg.channel_user_id && typeof chatChannelUserId !== 'undefined' && chatChannelUserId
++ && Number(msg.channel_user_id) === Number(chatChannelUserId)) {
++ return;
++ }
}
// Render into the ACTIVE chat container (live sidebar, offline chat, or broadcast).
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 73859b05..209834a7 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -906,7 +906,10 @@ class ChatServer {
// AI viewers genuinely need a live stream.
try {
const pc = require('../integrations/powerchat-platform');
-- if (client.channelUserId && pc.slotRelayEnabled(client.streamId)) {
++ // Relay is per CHANNEL, not per slot: a popout pinned to an expired stream
++ // id keeps chatting in the same channel, so the slot check follows the
++ // channel's current live stream rather than the stale id.
++ if (client.channelUserId && pc.channelRelayEnabled(client.channelUserId, client.streamId)) {
let isMod = false, isSub = false;
try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
ddiff --git a/server/integrations/powerchat-platform.js b/server/integrations/powerchat-platform.js
iindex 3d83722d..66883f44 100644
---- a/server/integrations/powerchat-platform.js
++++ b/server/integrations/powerchat-platform.js
@@@ -106,6 +106,24 @@ function slotRelayEnabled(streamId) {
return !ms || ms.slot_powerchat_relay !== 0;
} catch { return true; }
}
++// Channel-scoped relay check for chat: PowerChat's unified chat is per STREAMER, not
++// per stream slot, and viewers can be pinned to a long-expired stream id (a popout
++// opened during an earlier session keeps chatting in the same channel). A stale id
++// must not decide the relay — when the referenced stream isn't the live one, fall
++// back to the channel's CURRENT live stream's slot setting (or allow when offline).
++function channelRelayEnabled(channelUserId, streamId) {
++ try {
++ let s = streamId ? db.getStreamById(streamId) : null;
++ if (!s || !s.is_live) {
++ const live = channelUserId ? (db.getLiveStreamsByUserId(channelUserId) || []) : [];
++ s = live[0] || null;
++ }
++ if (!s || !s.managed_stream_id) return true;
++ const ms = db.get('SELECT slot_powerchat_relay FROM managed_streams WHERE id = ?', [s.managed_stream_id]);
++ return !ms || ms.slot_powerchat_relay !== 0;
++ } catch { return true; }
++}
++
// Destination switch: the Twitch/Kick/YouTube relay bridge for one restream destination.
function destRelayEnabled(destId) {
if (!destId) return true;
@@@ -440,7 +458,7 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
}
module.exports = {
-- slotRelayEnabled, destRelayEnabled, totalViewersForStream,
++ slotRelayEnabled, channelRelayEnabled, destRelayEnabled, totalViewersForStream,
CURRENCY_KEY, TIP_CURRENCY_KEY,
forwardChat, forwardFollow, forwardSubscription, forwardTip,
sendViewCount, startViewerCountSweeper,