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); + }); +}