* 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. * 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 * fix(chat): don't spotlight a hidden shared-chat bubble toggleFocus didn't check hiddenChannelIds, so tapping a hidden bubble's chip added it to the spotlight set — fading every other participant to showcase a channel whose own messages were already filtered out of the feed. * fix(chat): stop shared-chat detection flapping on system messages _isInSharedChatMode was recomputed from every PRIVMSG/NOTICE/USERNOTICE, but only PRIVMSG reliably carries source-room-id for a session's duration. A sub/raid/announcement without the tag would flip the flag off and back on within a single live session. Previously this only risked a redundant asset refetch (already guarded); now the shared chat bubble bar reads the flag directly, so a flap would visibly collapse and reopen it. Scope detection to PRIVMSG only.
542 lines
19 KiB
Dart
542 lines
19 KiB
Dart
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';
|
|
import 'package:frosty/utils.dart';
|
|
import 'package:frosty/widgets/profile_picture.dart';
|
|
|
|
/// Widget that displays multiple chat tabs with a tab bar.
|
|
/// Wraps the existing Chat widget and manages tab switching.
|
|
class ChatTabs extends StatelessWidget {
|
|
final ChatTabsStore chatTabsStore;
|
|
final EdgeInsetsGeometry? listPadding;
|
|
|
|
const ChatTabs({super.key, required this.chatTabsStore, this.listPadding});
|
|
|
|
Future<void> _handleAddChat(BuildContext context) async {
|
|
if (!chatTabsStore.canAddTab) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Maximum 10 chats open'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final result = await AddChatSheet.show(context, chatTabsStore.twitchApi);
|
|
|
|
if (result != null) {
|
|
final added = chatTabsStore.addTab(
|
|
channelId: result.channelId,
|
|
channelLogin: result.channelLogin,
|
|
displayName: result.displayName,
|
|
);
|
|
|
|
if (!added && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Channel already open, switched to it'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 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;
|
|
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
|
|
final adjustedPadding = listPadding != null
|
|
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
|
|
: EdgeInsets.only(top: tabBarHeight);
|
|
|
|
return PopScope(
|
|
canPop: Platform.isIOS,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (didPop) return;
|
|
|
|
// Android back gesture: dismiss overlays before navigating away.
|
|
final activeStore = chatTabsStore.activeChatStore;
|
|
if (activeStore.assetsStore.showEmoteMenu) {
|
|
activeStore.assetsStore.showEmoteMenu = false;
|
|
return;
|
|
}
|
|
if (activeStore.textFieldFocusNode.hasFocus) {
|
|
activeStore.unfocusInput();
|
|
return;
|
|
}
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: chatTabsStore.mergedMode
|
|
? Chat(
|
|
chatStore: chatTabsStore.activeChatStore,
|
|
chatTabsStore: chatTabsStore,
|
|
listPadding: adjustedPadding,
|
|
onAddChat: () => _handleAddChat(context),
|
|
)
|
|
: IndexedStack(
|
|
index: activeIndex,
|
|
children: tabs.map((tabInfo) {
|
|
if (tabInfo.chatStore == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Chat(
|
|
key: ValueKey(tabInfo.channelId),
|
|
chatStore: tabInfo.chatStore!,
|
|
listPadding: adjustedPadding,
|
|
onAddChat: () => _handleAddChat(context),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
if (showTabBar)
|
|
Positioned(
|
|
top: topInset,
|
|
left: 0,
|
|
right: 0,
|
|
child: SizedBox(
|
|
height: 48,
|
|
child: Stack(
|
|
children: [
|
|
// Right-clip past the merge button so chips slide
|
|
// under its left half then disappear.
|
|
Positioned(
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
right: showMerge ? 33 : 0,
|
|
child: ReorderableListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
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);
|
|
},
|
|
proxyDecorator: (child, index, animation) {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: child,
|
|
);
|
|
},
|
|
itemBuilder: (context, index) {
|
|
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,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (showMerge)
|
|
Positioned(
|
|
right: 12,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: _buildMoreActionsMenu(context),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildMoreActionsMenu(BuildContext context) {
|
|
final settings = chatTabsStore.activeChatStore.settings;
|
|
|
|
return _AnchoredPopupMenu(
|
|
itemsBuilder: (close) => [
|
|
Observer(
|
|
builder: (_) => CheckboxMenuButton(
|
|
value: chatTabsStore.mergedMode,
|
|
onChanged: (_) {
|
|
HapticFeedback.selectionClick();
|
|
chatTabsStore.toggleMergedMode();
|
|
},
|
|
child: const Text('Merge chats'),
|
|
),
|
|
),
|
|
Observer(
|
|
builder: (_) => CheckboxMenuButton(
|
|
value: settings.focusCurrentChannel,
|
|
onChanged: (newValue) {
|
|
HapticFeedback.selectionClick();
|
|
settings.focusCurrentChannel = newValue ?? false;
|
|
},
|
|
child: const Text('Focus current channel'),
|
|
),
|
|
),
|
|
],
|
|
anchorBuilder: (context, toggle) {
|
|
return IconButton.filledTonal(
|
|
icon: const Icon(Icons.more_vert, size: 18),
|
|
tooltip: 'Chat options',
|
|
visualDensity: VisualDensity.compact,
|
|
style: IconButton.styleFrom(
|
|
minimumSize: const Size(42, 42),
|
|
backgroundColor:
|
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
),
|
|
onPressed: () {
|
|
HapticFeedback.selectionClick();
|
|
toggle();
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildTab(BuildContext context, int index) {
|
|
final tabInfo = chatTabsStore.tabs[index];
|
|
final isActive = index == chatTabsStore.activeTabIndex;
|
|
final isSecondary = !tabInfo.isPrimary;
|
|
final displayName = getReadableName(
|
|
tabInfo.displayName,
|
|
tabInfo.channelLogin,
|
|
);
|
|
|
|
return Observer(
|
|
builder: (context) {
|
|
final theme = Theme.of(context);
|
|
final isActivated = tabInfo.isActivated;
|
|
final hasUnread = chatTabsStore.hasUnreadMessages(index);
|
|
final dimmed = !isActivated || !(tabInfo.chatStore?.isConnected ?? false);
|
|
|
|
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
|
|
avatar: Badge(
|
|
smallSize: 8,
|
|
backgroundColor: theme.colorScheme.primary,
|
|
isLabelVisible: hasUnread,
|
|
child: Opacity(
|
|
opacity: dimmed ? 0.5 : 1.0,
|
|
child: ProfilePicture(
|
|
userLogin: tabInfo.channelLogin,
|
|
radius: 12,
|
|
),
|
|
),
|
|
),
|
|
label: Text(
|
|
displayName,
|
|
style: dimmed
|
|
? TextStyle(
|
|
color: theme.textTheme.bodyMedium?.color
|
|
?.withValues(alpha: 0.5),
|
|
)
|
|
: null,
|
|
),
|
|
selected: isActive,
|
|
showCheckmark: false,
|
|
visualDensity: VisualDensity.compact,
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
onPressed: () {
|
|
if (isActive) return;
|
|
HapticFeedback.selectionClick();
|
|
chatTabsStore.setActiveTab(index);
|
|
},
|
|
onDeleted: onDeleted,
|
|
deleteButtonTooltipMessage:
|
|
onDeleted != null ? 'Tab options' : null,
|
|
);
|
|
|
|
if (!isSecondary) return buildChip();
|
|
|
|
return _AnchoredPopupMenu(
|
|
itemsBuilder: (close) => [
|
|
if (isActivated)
|
|
MenuItemButton(
|
|
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
|
|
child: const Text('Disconnect'),
|
|
onPressed: () {
|
|
HapticFeedback.lightImpact();
|
|
close();
|
|
chatTabsStore.deactivateTab(index);
|
|
},
|
|
),
|
|
MenuItemButton(
|
|
leadingIcon: Icon(
|
|
Icons.delete_outline_rounded,
|
|
size: 18,
|
|
color: theme.colorScheme.error,
|
|
),
|
|
child: Text(
|
|
'Remove',
|
|
style: TextStyle(color: theme.colorScheme.error),
|
|
),
|
|
onPressed: () {
|
|
HapticFeedback.lightImpact();
|
|
close();
|
|
chatTabsStore.removeTab(index);
|
|
},
|
|
),
|
|
],
|
|
anchorBuilder: (context, toggle) =>
|
|
buildChip(onDeleted: toggle),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 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
|
|
/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and
|
|
/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry
|
|
/// animation hook in this Flutter version, so we drive one ourselves.
|
|
class _AnchoredPopupMenu extends StatefulWidget {
|
|
final Widget Function(BuildContext context, VoidCallback toggle)
|
|
anchorBuilder;
|
|
final List<Widget> Function(VoidCallback close) itemsBuilder;
|
|
|
|
const _AnchoredPopupMenu({
|
|
required this.anchorBuilder,
|
|
required this.itemsBuilder,
|
|
});
|
|
|
|
@override
|
|
State<_AnchoredPopupMenu> createState() => _AnchoredPopupMenuState();
|
|
}
|
|
|
|
class _AnchoredPopupMenuState extends State<_AnchoredPopupMenu>
|
|
with SingleTickerProviderStateMixin {
|
|
final _link = LayerLink();
|
|
final _portalCtrl = OverlayPortalController();
|
|
late final _anim = AnimationController(
|
|
duration: const Duration(milliseconds: 150),
|
|
reverseDuration: const Duration(milliseconds: 100),
|
|
vsync: this,
|
|
)..addStatusListener((status) {
|
|
if (status == AnimationStatus.dismissed && _portalCtrl.isShowing) {
|
|
_portalCtrl.hide();
|
|
}
|
|
});
|
|
late final _curve =
|
|
CurvedAnimation(parent: _anim, curve: Curves.easeOut);
|
|
late final _scale = Tween<double>(begin: 0.92, end: 1.0).animate(_curve);
|
|
|
|
@override
|
|
void dispose() {
|
|
_curve.dispose();
|
|
_anim.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggle() {
|
|
if (_portalCtrl.isShowing) {
|
|
_anim.reverse();
|
|
} else {
|
|
_portalCtrl.show();
|
|
_anim.forward();
|
|
}
|
|
}
|
|
|
|
void _close() {
|
|
if (_portalCtrl.isShowing) _anim.reverse();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CompositedTransformTarget(
|
|
link: _link,
|
|
child: OverlayPortal(
|
|
controller: _portalCtrl,
|
|
overlayChildBuilder: (overlayContext) {
|
|
final theme = Theme.of(overlayContext);
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: _close,
|
|
),
|
|
),
|
|
CompositedTransformFollower(
|
|
link: _link,
|
|
targetAnchor: Alignment.bottomRight,
|
|
followerAnchor: Alignment.topRight,
|
|
offset: const Offset(0, 4),
|
|
showWhenUnlinked: false,
|
|
child: FadeTransition(
|
|
opacity: _anim,
|
|
child: ScaleTransition(
|
|
scale: _scale,
|
|
alignment: Alignment.topRight,
|
|
child: Material(
|
|
color: theme.colorScheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: const BorderRadius.all(
|
|
Radius.circular(12),
|
|
),
|
|
side: BorderSide(
|
|
color: theme.colorScheme.outlineVariant
|
|
.withValues(alpha: 0.5),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: IntrinsicWidth(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: widget.itemsBuilder(_close),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
child: widget.anchorBuilder(context, _toggle),
|
|
),
|
|
);
|
|
}
|
|
}
|