From f9d8ef5fb295e283c46ff2ad1f3cf94fa322541a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 22:36:20 -0400 Subject: [PATCH] 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. --- .../channel/chat/stores/chat_store.dart | 24 +++- .../chat/stores/shared_chat_bubble_state.dart | 42 ++++++ .../channel/chat/widgets/chat_message.dart | 17 ++- .../channel/chat/widgets/chat_tabs.dart | 123 +++++++++++++++++- lib/screens/settings/chat_settings.dart | 4 +- .../chat/shared_chat_bubble_state_test.dart | 51 ++++++++ 6 files changed, 243 insertions(+), 18 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/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index d5b60e49..7a7dc4de 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,6 +202,10 @@ 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); @@ -242,14 +247,19 @@ 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.tags['source-room-id'] ?? message.tags['room-id']; + return sourceChannelId == null || + !sharedChatBubbles.hiddenChannelIds.contains(sourceChannelId); + }).toList(); } /// If the chat should automatically scroll/jump to the latest message. 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..825c3ed4 --- /dev/null +++ b/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart @@ -0,0 +1,42 @@ +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. + void toggleFocus(String channelId) { + 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..eb044ad0 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -630,10 +630,23 @@ class ChatMessage extends StatelessWidget { ircMessage.tags['source-room-id'] ?? ircMessage.tags['room-id']; final currentChannelId = overrideCurrentChannelId ?? chatStore.channelId; + + // overrideCurrentChannelId is only set in manual "merged mode" + // (multiple manually-added tabs interleaved); that keeps its + // existing always-fade-non-active-tab behavior, gated by the + // focusCurrentChannel setting. Native Twitch shared chat instead + // 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. + final isMerged = overrideCurrentChannelId != null; + final hasSpotlight = + !isMerged && chatStore.sharedChatBubbles.focusedChannelIds.isNotEmpty; final shouldFade = - chatStore.settings.focusCurrentChannel && sourceChannelId != null && - sourceChannelId != currentChannelId; + sourceChannelId != currentChannelId && + ((isMerged && chatStore.settings.focusCurrentChannel) || + (hasSpotlight && + !chatStore.sharedChatBubbles.isFocused(sourceChannelId))); 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..6d3c23cf 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'; @@ -54,7 +55,16 @@ class ChatTabs extends StatelessWidget { 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 +133,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 +156,32 @@ class ChatTabs extends StatelessWidget { ); }, itemBuilder: (context, index) { - final tabInfo = chatTabsStore.tabs[index]; + final isLast = + index == + tabs.length + + sharedChatParticipants.length - + 1; + if (index < tabs.length) { + final tabInfo = chatTabsStore.tabs[index]; + return Padding( + key: ValueKey(tabInfo.channelId), + padding: EdgeInsets.only(right: isLast ? 0 : 4), + child: Center(child: _buildTab(context, index)), + ); + } + + final participant = + sharedChatParticipants[index - tabs.length]; return Padding( - key: ValueKey(tabInfo.channelId), - padding: EdgeInsets.only( - right: index < tabs.length - 1 ? 4 : 0, + key: ValueKey('shared_${participant.key}'), + padding: EdgeInsets.only(right: isLast ? 0 : 4), + child: Center( + child: _buildSharedChatBubble( + context, + participant.key, + participant.value, + ), ), - child: Center(child: _buildTab(context, index)), ); }, ), @@ -304,6 +340,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..7c12a727 --- /dev/null +++ b/test/screens/channel/chat/shared_chat_bubble_state_test.dart @@ -0,0 +1,51 @@ +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('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); + }); +}