From 074d8ed21fb0b863daec950614ccd999ed4bdac9 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 23:54:30 -0400 Subject: [PATCH] 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 --- lib/models/irc.dart | 6 +++- .../channel/chat/stores/chat_store.dart | 28 +++++++++++++++-- .../channel/chat/widgets/chat_message.dart | 25 +++------------- .../channel/chat/widgets/chat_tabs.dart | 30 ++++++++++++------- 4 files changed, 54 insertions(+), 35 deletions(-) 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 7a7dc4de..a22971ae 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -211,6 +211,31 @@ abstract class ChatStoreBase with Store { 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; @@ -255,8 +280,7 @@ abstract class ChatStoreBase with Store { // 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']; + final sourceChannelId = message.sourceChannelId; return sourceChannelId == null || !sharedChatBubbles.hiddenChannelIds.contains(sourceChannelId); }).toList(); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index eb044ad0..339254dc 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -626,27 +626,10 @@ class ChatMessage extends StatelessWidget { child: dividedMessage, ); - final sourceChannelId = - 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 = - sourceChannelId != null && - sourceChannelId != currentChannelId && - ((isMerged && chatStore.settings.focusCurrentChannel) || - (hasSpotlight && - !chatStore.sharedChatBubbles.isFocused(sourceChannelId))); + 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 6d3c23cf..490ce0df 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -49,6 +49,16 @@ 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( @@ -163,24 +173,22 @@ class ChatTabs extends StatelessWidget { 1; if (index < tabs.length) { final tabInfo = chatTabsStore.tabs[index]; - return Padding( + return _wrapItem( key: ValueKey(tabInfo.channelId), - padding: EdgeInsets.only(right: isLast ? 0 : 4), - child: Center(child: _buildTab(context, index)), + isLast: isLast, + child: _buildTab(context, index), ); } final participant = sharedChatParticipants[index - tabs.length]; - return Padding( + return _wrapItem( key: ValueKey('shared_${participant.key}'), - padding: EdgeInsets.only(right: isLast ? 0 : 4), - child: Center( - child: _buildSharedChatBubble( - context, - participant.key, - participant.value, - ), + isLast: isLast, + child: _buildSharedChatBubble( + context, + participant.key, + participant.value, ), ); },