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.
This commit is contained in:
Tommy Chow 2026-07-02 22:36:20 -04:00
parent 98c3a031bc
commit f9d8ef5fb2
6 changed files with 243 additions and 18 deletions

View file

@ -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<String>();
/// 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<IRCMessage> 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.

View file

@ -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<String>();
/// Channel ids whose messages are filtered out of the feed entirely.
final hiddenChannelIds = ObservableSet<String>();
/// 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();
}
}

View file

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

View file

@ -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 <MapEntry<String, UserTwitch>>[];
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

View file

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

View file

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