From abb6cbc4469693a9e6f31c7a7d923988ecaa2804 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 3 Jul 2026 00:16:49 -0400 Subject: [PATCH] feat(chat): add bubbles for shared chat participant channels (#581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- lib/models/irc.dart | 6 +- .../channel/chat/stores/chat_store.dart | 124 ++++++++++------ .../chat/stores/shared_chat_bubble_state.dart | 45 ++++++ .../channel/chat/widgets/chat_message.dart | 12 +- .../channel/chat/widgets/chat_tabs.dart | 133 ++++++++++++++++-- lib/screens/settings/chat_settings.dart | 4 +- .../chat/shared_chat_bubble_state_test.dart | 57 ++++++++ 7 files changed, 321 insertions(+), 60 deletions(-) create mode 100644 lib/screens/channel/chat/stores/shared_chat_bubble_state.dart create mode 100644 test/screens/channel/chat/shared_chat_bubble_state_test.dart diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 8e94cbb3..e7f7ce78 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -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 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; diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index d5b60e49..356d09db 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -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(); + /// 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 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 []; - }, - onBadgeError: (error) { - debugPrint(error.toString()); - return {}; - }, - 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 []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return {}; + }, + 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; diff --git a/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart b/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart new file mode 100644 index 00000000..6d18c655 --- /dev/null +++ b/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart @@ -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(); + + /// Channel ids whose messages are filtered out of the feed entirely. + final hiddenChannelIds = ObservableSet(); + + /// 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(); + } +} diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 3bc8c289..339254dc 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -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) diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 724564aa..490ce0df 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -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 >[]; + 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 diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 16b086ef..2cd98029 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -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) => diff --git a/test/screens/channel/chat/shared_chat_bubble_state_test.dart b/test/screens/channel/chat/shared_chat_bubble_state_test.dart new file mode 100644 index 00000000..65a2b927 --- /dev/null +++ b/test/screens/channel/chat/shared_chat_bubble_state_test.dart @@ -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); + }); +}