feat(chat): add bubbles for shared chat participant channels (#581)

* feat(chat): add bubbles for shared chat participant channels

Twitch's native shared chat previously had no way to interact with
participant channels the way manually-added chats work. Adds
per-participant bubbles alongside manual tabs: tapping one spotlights
that channel (fading the rest), hiding one filters its messages from
the feed entirely. Defaults to unfaded, resets on rejoin (fresh
ChatStore per connect).

Manual "merged mode" fading is untouched and unaffected.

* refactor(chat): dedup shared-chat tag lookup and extract fade policy

- add IRCMessage.sourceChannelId, replacing three copies of the same
  source-room-id/room-id tag lookup
- move the shared-chat spotlight/merged-mode fade decision out of
  ChatMessage's build into ChatStore.shouldFadeMessage, matching the
  extract-into-store convention already used for SharedChatBubbleState
- factor the tab-bar item's Padding/Center wrapper into a shared
  _wrapItem helper instead of duplicating it per branch

* fix(chat): don't spotlight a hidden shared-chat bubble

toggleFocus didn't check hiddenChannelIds, so tapping a hidden
bubble's chip added it to the spotlight set — fading every other
participant to showcase a channel whose own messages were already
filtered out of the feed.

* fix(chat): stop shared-chat detection flapping on system messages

_isInSharedChatMode was recomputed from every PRIVMSG/NOTICE/USERNOTICE,
but only PRIVMSG reliably carries source-room-id for a session's
duration. A sub/raid/announcement without the tag would flip the flag
off and back on within a single live session. Previously this only
risked a redundant asset refetch (already guarded); now the shared
chat bubble bar reads the flag directly, so a flap would visibly
collapse and reopen it. Scope detection to PRIVMSG only.
This commit is contained in:
Tommy Chow 2026-07-03 00:16:49 -04:00 • committed by GitHub
parent 98c3a031bc
commit abb6cbc446
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 321 additions and 60 deletions

View file

@ -56,6 +56,11 @@ class IRCMessage {
this.actionLabel,
});
/// The channel this message actually originated from. In Twitch shared
/// chat, `source-room-id` names the participant channel a message was
/// sent in; otherwise it's just this connection's own room.
String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id'];
/// Applies the given CLEARCHAT message to the provided messages and buffer.
static void clearChat({
required List<IRCMessage> messages,
@ -186,7 +191,6 @@ class IRCMessage {
span.add(const TextSpan(text: ' '));
}
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
if (sourceChannelUser != null) {
final isCurrentChannel = sourceChannelId == currentChannelId;

View file

@ -13,6 +13,7 @@ import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
import 'package:frosty/screens/channel/chat/stores/shared_chat_bubble_state.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart';
@ -201,11 +202,40 @@ abstract class ChatStoreBase with Store {
/// The set of message IDs that have been revealed by the user (for deleted messages).
final revealedMessageIds = ObservableSet<String>();
/// User-driven spotlight/hide selections for shared chat participant
/// channels. See [SharedChatBubbleState].
final sharedChatBubbles = SharedChatBubbleState();
@action
void revealMessage(String id) {
revealedMessageIds.add(id);
}
/// Whether a message from [sourceChannelId] should render faded.
///
/// [overrideCurrentChannelId] is set only when rendering the manual
/// "merged mode" view (multiple manually-added tabs interleaved), which
/// always fades non-active-tab messages, gated by [focusCurrentChannel].
/// Otherwise this is the normal single-connection view, where native
/// Twitch shared chat defaults to unfaded and only fades once the user
/// spotlights a participant channel via a chat bubble — an explicit
/// action that isn't gated by that setting.
bool shouldFadeMessage({
required String? sourceChannelId,
required String? overrideCurrentChannelId,
}) {
if (sourceChannelId == null) return false;
final currentChannelId = overrideCurrentChannelId ?? channelId;
if (sourceChannelId == currentChannelId) return false;
final isMerged = overrideCurrentChannelId != null;
if (isMerged) return settings.focusCurrentChannel;
return sharedChatBubbles.focusedChannelIds.isNotEmpty &&
!sharedChatBubbles.isFocused(sourceChannelId);
}
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
@ -242,14 +272,18 @@ abstract class ChatStoreBase with Store {
List<IRCMessage> get renderMessages {
// If autoscroll is disabled, render ALL messages in chat.
// The second condition is to prevent an out of index error with sublist.
if (!_autoScroll || _messages.length < _renderMessageLimit) {
return _messages;
}
final base = (!_autoScroll || _messages.length < _renderMessageLimit)
? _messages
: _messages.sublist(_messages.length - _renderMessageLimit);
// When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
// This will improve performance by only rendering a limited amount of messages
// instead of the entire history at all times.
return _messages.sublist(_messages.length - _renderMessageLimit);
// Filter out messages from shared chat participant channels the user
// has hidden. Short-circuit when nothing is hidden (the common case).
if (sharedChatBubbles.hiddenChannelIds.isEmpty) return base;
return base.where((message) {
final sourceChannelId = message.sourceChannelId;
return sourceChannelId == null ||
!sharedChatBubbles.hiddenChannelIds.contains(sourceChannelId);
}).toList();
}
/// If the chat should automatically scroll/jump to the latest message.
@ -662,41 +696,49 @@ abstract class ChatStoreBase with Store {
}
}
// Update shared chat mode based on source-room-id tag presence
final wasShared = _isInSharedChatMode;
_isInSharedChatMode = parsedIRCMessage.tags.containsKey(
'source-room-id',
);
// On transition into shared chat mode, fetch assets for participants.
if (!wasShared && _isInSharedChatMode) {
assetsStore.fetchSharedChatAssets(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <Emote>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <String, ChatBadge>{};
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
// Update shared chat mode based on source-room-id tag presence.
// Only PRIVMSG reliably carries this tag for the session's
// duration — NOTICE/USERNOTICE (subs, raids, announcements)
// don't always, so gating on those too would flap this flag off
// and on within a single live session (now visibly, since the
// shared chat bubble bar reads it — previously this only risked
// a redundant asset refetch, guarded below).
if (parsedIRCMessage.command == Command.privateMessage) {
final wasShared = _isInSharedChatMode;
_isInSharedChatMode = parsedIRCMessage.tags.containsKey(
'source-room-id',
);
} else if (wasShared &&
!_isInSharedChatMode &&
assetsStore.hasLoadedSharedChatAssets) {
// On leaving shared chat, drop the participants' merged emotes and
// badges and restore just this channel's assets — otherwise the
// other streamer's emote set and badges linger (#522). Guarded on
// having actually loaded shared assets so a flapping tag can't
// trigger redundant refetches.
assetsStore.resetSharedChatAssets();
getAssets();
// On transition into shared chat mode, fetch assets for participants.
if (!wasShared && _isInSharedChatMode) {
assetsStore.fetchSharedChatAssets(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <Emote>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <String, ChatBadge>{};
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
);
} else if (wasShared &&
!_isInSharedChatMode &&
assetsStore.hasLoadedSharedChatAssets) {
// On leaving shared chat, drop the participants' merged emotes and
// badges and restore just this channel's assets — otherwise the
// other streamer's emote set and badges linger (#522). Guarded on
// having actually loaded shared assets so a flapping tag can't
// trigger redundant refetches.
assetsStore.resetSharedChatAssets();
getAssets();
}
}
messageBuffer.add(parsedIRCMessage);
break;

View file

@ -0,0 +1,45 @@
import 'package:mobx/mobx.dart';
/// Tracks user-driven spotlight/hide selections for participant channels in
/// a Twitch shared chat session (multiple broadcasters merged onto one IRC
/// connection). Pure state so it can be unit-tested without the chat store.
///
/// [ObservableSet] is reactive on its own, so [Observer] widgets reading
/// [focusedChannelIds]/[hiddenChannelIds] rebuild without any MobX codegen.
class SharedChatBubbleState {
/// Channel ids the user has spotlighted (kept unfaded). Empty means no
/// spotlight is active and nothing is faded.
final focusedChannelIds = ObservableSet<String>();
/// Channel ids whose messages are filtered out of the feed entirely.
final hiddenChannelIds = ObservableSet<String>();
/// Adds/removes [channelId] from the spotlight set. No-ops for a hidden
/// channel — its messages are filtered out, so spotlighting it would fade
/// every other channel to showcase nothing.
void toggleFocus(String channelId) {
if (hiddenChannelIds.contains(channelId)) return;
if (!focusedChannelIds.remove(channelId)) {
focusedChannelIds.add(channelId);
}
}
/// Hides [channelId]'s messages and drops it from the spotlight set.
void hide(String channelId) {
hiddenChannelIds.add(channelId);
focusedChannelIds.remove(channelId);
}
/// Un-hides [channelId]'s messages.
void show(String channelId) => hiddenChannelIds.remove(channelId);
bool isFocused(String channelId) => focusedChannelIds.contains(channelId);
bool isHidden(String channelId) => hiddenChannelIds.contains(channelId);
/// Clears both sets (e.g. when a shared chat session ends).
void reset() {
focusedChannelIds.clear();
hiddenChannelIds.clear();
}
}

View file

@ -626,14 +626,10 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage,
);
final sourceChannelId =
ircMessage.tags['source-room-id'] ?? ircMessage.tags['room-id'];
final currentChannelId =
overrideCurrentChannelId ?? chatStore.channelId;
final shouldFade =
chatStore.settings.focusCurrentChannel &&
sourceChannelId != null &&
sourceChannelId != currentChannelId;
final shouldFade = chatStore.shouldFadeMessage(
sourceChannelId: ircMessage.sourceChannelId,
overrideCurrentChannelId: overrideCurrentChannelId,
);
final fadedMessage = shouldFade
? Opacity(opacity: 0.55, child: coloredMessage)

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/chat.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart';
@ -48,13 +49,32 @@ class ChatTabs extends StatelessWidget {
}
}
/// Wraps a tab bar item (a tab chip or a shared-chat bubble) with its
/// trailing gap and centering, keyed for the reorderable list.
Widget _wrapItem({required Key key, required bool isLast, required Widget child}) {
return Padding(
key: key,
padding: EdgeInsets.only(right: isLast ? 0 : 4),
child: Center(child: child),
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final tabs = chatTabsStore.tabs;
final activeIndex = chatTabsStore.activeTabIndex;
final showTabBar = chatTabsStore.showTabBar;
final activeStore = chatTabsStore.activeChatStore;
// Twitch native shared chat participants (excluding the active
// channel itself, which is already shown as its own tab chip).
final sharedChatParticipants = activeStore.isInSharedChatMode
? activeStore.assetsStore.channelIdToUserTwitch.entries
.where((entry) => entry.key != activeStore.channelId)
.toList()
: const <MapEntry<String, UserTwitch>>[];
final showTabBar =
chatTabsStore.showTabBar || sharedChatParticipants.isNotEmpty;
final showMerge = tabs.where((t) => t.isActivated).length >= 2;
final tabBarHeight = showTabBar ? 48.0 : 0.0;
@ -123,12 +143,19 @@ class ChatTabs extends StatelessWidget {
right: showMerge ? 33 : 0,
child: ReorderableListView.builder(
scrollDirection: Axis.horizontal,
itemCount: tabs.length,
itemCount: tabs.length + sharedChatParticipants.length,
padding: EdgeInsets.only(
left: 12,
right: showMerge ? 32 : 12,
),
onReorderItem: (oldIndex, newIndex) {
// Shared chat bubbles are trailing, fixed-order
// items (not real tabs) — ignore any reorder
// that touches them.
if (oldIndex >= tabs.length ||
newIndex >= tabs.length) {
return;
}
HapticFeedback.lightImpact();
chatTabsStore.reorderTab(oldIndex, newIndex);
},
@ -139,13 +166,30 @@ class ChatTabs extends StatelessWidget {
);
},
itemBuilder: (context, index) {
final tabInfo = chatTabsStore.tabs[index];
return Padding(
key: ValueKey(tabInfo.channelId),
padding: EdgeInsets.only(
right: index < tabs.length - 1 ? 4 : 0,
final isLast =
index ==
tabs.length +
sharedChatParticipants.length -
1;
if (index < tabs.length) {
final tabInfo = chatTabsStore.tabs[index];
return _wrapItem(
key: ValueKey(tabInfo.channelId),
isLast: isLast,
child: _buildTab(context, index),
);
}
final participant =
sharedChatParticipants[index - tabs.length];
return _wrapItem(
key: ValueKey('shared_${participant.key}'),
isLast: isLast,
child: _buildSharedChatBubble(
context,
participant.key,
participant.value,
),
child: Center(child: _buildTab(context, index)),
);
},
),
@ -304,6 +348,79 @@ class ChatTabs extends StatelessWidget {
},
);
}
/// Builds a bubble for a Twitch native shared chat participant channel.
/// Unlike manual tabs, these don't own a connection: tapping spotlights
/// the channel (unfades it, fading the rest — see [chat_message.dart]),
/// and the trailing menu hides/shows the channel's messages instead of
/// disconnecting/removing a tab.
Widget _buildSharedChatBubble(
BuildContext context,
String channelId,
UserTwitch user,
) {
final activeStore = chatTabsStore.activeChatStore;
final displayName = getReadableName(user.displayName, user.login);
return Observer(
builder: (context) {
final theme = Theme.of(context);
final isFocused = activeStore.sharedChatBubbles.isFocused(channelId);
final isHidden = activeStore.sharedChatBubbles.isHidden(channelId);
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
avatar: Opacity(
opacity: isHidden ? 0.5 : 1.0,
child: ProfilePicture(userLogin: user.login, radius: 12),
),
label: Text(
displayName,
style: isHidden
? TextStyle(
color: theme.textTheme.bodyMedium?.color
?.withValues(alpha: 0.5),
)
: null,
),
selected: isFocused,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
HapticFeedback.selectionClick();
activeStore.sharedChatBubbles.toggleFocus(channelId);
},
onDeleted: onDeleted,
deleteButtonTooltipMessage:
onDeleted != null ? 'Channel options' : null,
);
return _AnchoredPopupMenu(
itemsBuilder: (close) => [
MenuItemButton(
leadingIcon: Icon(
isHidden
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
size: 18,
),
child: Text(isHidden ? 'Show channel' : 'Hide channel'),
onPressed: () {
HapticFeedback.lightImpact();
close();
if (isHidden) {
activeStore.sharedChatBubbles.show(channelId);
} else {
activeStore.sharedChatBubbles.hide(channelId);
}
},
),
],
anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle),
);
},
);
}
}
/// Lightweight popup menu anchored to a child widget. The menu's top-right

View file

@ -224,8 +224,8 @@ class ChatSettings extends StatelessWidget {
SettingsListSwitch(
title: 'Focus current channel',
subtitle: const Text(
'Fades messages from other channels in shared chat and '
'merged views, so the current channel stands out.',
'Fades messages from other channels in merged chat, '
'so the current channel stands out.',
),
value: settingsStore.focusCurrentChannel,
onChanged: (newValue) =>

View file

@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/screens/channel/chat/stores/shared_chat_bubble_state.dart';
void main() {
late SharedChatBubbleState state;
setUp(() => state = SharedChatBubbleState());
test('a channel is neither focused nor hidden by default', () {
expect(state.isFocused('1'), isFalse);
expect(state.isHidden('1'), isFalse);
});
test('toggleFocus spotlights an unfocused channel', () {
state.toggleFocus('1');
expect(state.isFocused('1'), isTrue);
});
test('toggleFocus un-spotlights an already-focused channel', () {
state.toggleFocus('1');
state.toggleFocus('1');
expect(state.isFocused('1'), isFalse);
});
test('multiple channels can be spotlighted at once', () {
state.toggleFocus('1');
state.toggleFocus('2');
expect(state.isFocused('1'), isTrue);
expect(state.isFocused('2'), isTrue);
});
test('hide filters a channel and drops it from the spotlight', () {
state.toggleFocus('1');
state.hide('1');
expect(state.isHidden('1'), isTrue);
expect(state.isFocused('1'), isFalse);
});
test('toggleFocus is a no-op for a hidden channel', () {
state.hide('1');
state.toggleFocus('1');
expect(state.isFocused('1'), isFalse);
});
test('show lifts a hide', () {
state.hide('1');
state.show('1');
expect(state.isHidden('1'), isFalse);
});
test('reset clears both focused and hidden channels', () {
state.toggleFocus('1');
state.hide('2');
state.reset();
expect(state.isFocused('1'), isFalse);
expect(state.isHidden('2'), isFalse);
});
}