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
This commit is contained in:
Tommy Chow 2026-07-02 23:54:30 -04:00
parent f9d8ef5fb2
commit 074d8ed21f
4 changed files with 54 additions and 35 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

@ -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();

View file

@ -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)

View file

@ -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,
),
);
},