From e86d0e23a335d6c061fa7d8ea081165089526d7d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 7 Nov 2026 15:40:11 -0800 Subject: [PATCH] feat(mobile): long-press a Solo chat to open its details sheet (#9163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-pressing a team channel in the Messages list opens a bottom sheet of channel actions. Long-pressing a Solo chat did nothing, so its settings (star, mute, members, move, copy, leave/archive/delete) were only reachable from inside the chat. Now a long-press on a Solo chat row opens that chat's details screen as a sheet, with the same presentation as the team channel sheet: drag handle, close button, 640 pt max width, 70% max height. The content is the existing channel details page rendered without its app bar (`ChannelDetailsPage(inSheet: true)`), so it stays in sync with the full page. Tapping a chat still opens it as before. Solo chats are placed in Solo workspaces, not channel sections, so their details (sheet and full page) no longer show "Move to section…". That row wrote channel sections, which left the chat where it was. Moving a Solo chat between workspaces stays on the chat header's workspace control. ### Demo Long-pressing a Solo chat in the Messages list (Solo filter) opens its details sheet; Star toggles, the sheet scrolls to the lifecycle actions, and the close button dismisses it. Synthetic chat named "QA demo". https://github.com/user-attachments/assets/e8b8a799-be1f-4d4b-b47d-f2209e2b1920 ### Validation - Added test: long-pressing a Solo chat on the Me page opens the details sheet with the chat's name, the sheet scrolls to its lifecycle actions, and the channel-section move is absent. - The full mobile test suite and `flutter analyze` pass. - Full automated simulator run on the final code (iPhone 17 Pro, debug build, signed-in Builderlab account, light and dark mode). In the Solo long-press sheet: Star and Mute toggle and revert; Edit, a member's profile and Add members open and close back to the sheet; Copy channel name and Copy channel ID copy the right values and show a toast; the sheet scrolls; the Leave, Archive and Delete confirmations appear and were cancelled; the close button, scrim tap and drag down all dismiss it; "Move to section…" is absent. Tapping the chat still opens it, and its in-chat details page also omits the section move. Regression check: the team channel long-press sheet and team details page are unchanged, including a working "Move to section…". The recording is in Buzz rather than here because it shows internal channel names. Limitation: destructive actions were cancelled, not confirmed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../channels/channel_detail_page.dart | 1 + .../channels/channel_details_page.dart | 638 ++++++++++-------- mobile/lib/features/channels/me/me_page.dart | 19 + .../features/channels/me/me_page_helpers.dart | 4 + .../features/channels/me/me_page_test.dart | 61 ++ 5 files changed, 423 insertions(+), 300 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 17ab0c28e4..dd05e81466 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -676,6 +676,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { .read(channelSectionsProvider) .store .assignments[resolvedChannel.id], + personalChat: personalChat, ); if (shouldClose == true && context.mounted) { Navigator.of(context).pop(); diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index d09cd2cfc4..3bd79c05b0 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -11,6 +11,7 @@ Future showChannelDetailsPage({ required String? currentPubkey, required void Function(BuildContext context, String pubkey) onMemberTap, String? sectionId, + bool personalChat = false, }) => Navigator.of(context).push( MaterialPageRoute( builder: (_) => CoveredRouteKeyboardInsets( @@ -19,11 +20,39 @@ Future showChannelDetailsPage({ currentPubkey: currentPubkey, onMemberTap: onMemberTap, sectionId: sectionId, + personalChat: personalChat, ), ), ), ); +/// Opens the channel settings page as a bottom sheet, presented like the +/// channel list's long-press actions sheet. +Future showChannelDetailsSheet({ + required BuildContext context, + required Channel channel, + required String? currentPubkey, + required void Function(BuildContext context, String pubkey) onMemberTap, + String? sectionId, + bool personalChat = false, +}) => showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + builder: (_) => ChannelDetailsPage( + channel: channel, + currentPubkey: currentPubkey, + onMemberTap: onMemberTap, + sectionId: sectionId, + personalChat: personalChat, + inSheet: true, + ), +); + /// Combined channel identity, membership, organization, and lifecycle page. class ChannelDetailsPage extends HookConsumerWidget { const ChannelDetailsPage({ @@ -32,6 +61,8 @@ class ChannelDetailsPage extends HookConsumerWidget { required this.currentPubkey, required this.onMemberTap, this.sectionId, + this.personalChat = false, + this.inSheet = false, }); final Channel channel; @@ -39,6 +70,14 @@ class ChannelDetailsPage extends HookConsumerWidget { final void Function(BuildContext context, String pubkey) onMemberTap; final String? sectionId; + /// Solo chats are organized into Solo workspaces, not channel sections, so + /// their details omit the channel-section move. + final bool personalChat; + + /// Renders only the scrolling content, without the page's app bar, for a + /// bottom sheet that supplies its own drag handle. + final bool inSheet; + @override Widget build(BuildContext context, WidgetRef ref) { final displayedChannel = useState(channel); @@ -270,6 +309,304 @@ class ChannelDetailsPage extends HookConsumerWidget { final usesNativeIosGlassBackButton = Navigator.canPop(context) && Theme.of(context).platform == TargetPlatform.iOS; + final content = ListView( + key: const ValueKey('channel-details-page-list'), + controller: scrollController, + shrinkWrap: inSheet, + padding: EdgeInsets.only( + top: inSheet ? 0 : frostedAppBarHeight(context) + Grid.xs, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xs, + ), + children: [ + _ChannelDetailsHero( + channel: resolvedChannel, + nameKey: heroNameKey, + displayName: displayName, + avatar: counterpart == null + ? null + : AvatarImage( + key: const ValueKey('dm-details-avatar'), + imageUrl: counterpartProfile?.avatarUrl, + radius: 40, + isAgent: counterpartProfile?.isAgent ?? false, + backgroundColor: context.controlSurface, + fallback: Text( + counterpartProfile?.initial ?? + dmAvatarInitial( + resolvedChannel, + currentPubkey: resolvedCurrentPubkey, + ), + style: context.textTheme.headlineMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + ), + Padding( + key: const ValueKey('channel-details-actions'), + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + _channelDetailsSectionPadding, + Grid.gutter, + _channelDetailsSectionPadding, + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-star-action'), + icon: null, + iconWidget: TablerStarIcon( + filled: isStarred, + color: isStarred + ? context.colors.primary + : context.colors.onSurface, + ), + label: isStarred ? 'Unstar' : 'Star', + onTap: toggleStar, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-mute-action'), + icon: isMuted ? BuzzIcons.bell : BuzzIcons.bellOff, + iconColor: isMuted ? context.colors.primary : null, + label: isMuted ? 'Unmute' : 'Mute', + onTap: toggleMute, + ), + ), + if (!resolvedChannel.isDm) ...[ + const SizedBox(width: Grid.xxs), + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-edit-action'), + icon: BuzzIcons.pencil, + label: 'Edit', + isEnabled: canEdit, + onTap: openManageChannel, + ), + ), + ], + ], + ), + ), + ), + if (!isDirectDm) + AppListCard( + key: const ValueKey('channel-details-members-card'), + label: memberLabel, + dividerIndent: Grid.xs + 40 + Grid.xs, + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (canAddMembers) + AppListRowRaw( + key: const ValueKey('channel-details-add-members-row'), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.colors.surfaceContainer, + shape: BoxShape.circle, + ), + child: Icon( + BuzzIcons.plus, + color: context.colors.onSurfaceVariant, + ), + ), + title: Text( + 'Add members', + style: context.textTheme.bodyLarge, + ), + trailing: const _ChannelDetailsChevron(), + onTap: openAddMembers, + verticalPadding: Grid.xxs, + ), + if (membersAsync.isLoading && members.isEmpty) + const AppListRow( + icon: BuzzIcons.loaderCircle, + title: 'Loading members…', + trailing: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading members', + ), + ) + else if (membersAsync.hasError && members.isEmpty) + const AppListRow( + icon: BuzzIcons.triangleAlert, + title: 'Members unavailable', + ) + else ...[ + for (final member in previewMembers) + _ChannelMemberPreviewRow( + key: ValueKey('channel-details-member-${member.pubkey}'), + member: member, + currentPubkey: resolvedCurrentPubkey, + onMemberTap: onMemberTap, + displayName: + userCache[member.pubkey.toLowerCase()]?.displayName, + contextualLabel: memberLabels[member.pubkey.toLowerCase()], + avatarUrl: + userCache[member.pubkey.toLowerCase()]?.avatarUrl, + ), + if (memberCount > _channelMemberPreviewLimit) + AppListRowRaw( + key: const ValueKey('channel-details-members-row'), + leading: const SizedBox.square(dimension: 40), + title: Text('See all', style: context.textTheme.bodyLarge), + trailing: const _ChannelDetailsChevron(), + onTap: openMembers, + verticalPadding: Grid.xxs, + ), + ], + ], + ), + AppListCard( + key: const ValueKey('channel-details-channel-card'), + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (!personalChat) + AppListRow( + icon: BuzzIcons.folderInput, + title: 'Move to section…', + trailing: const _ChannelDetailsChevron(), + onTap: () async { + await _showMoveSectionSheet( + context, + ref, + channel: resolvedChannel, + sectionId: currentSectionId, + ); + }, + ), + AppListRow( + icon: BuzzIcons.copy, + title: 'Copy channel name', + onTap: () { + copyToClipboard( + context, + resolvedChannel.name, + message: 'Channel name copied', + ); + }, + ), + AppListRow( + icon: BuzzIcons.hash, + title: 'Copy channel ID', + onTap: () { + copyToClipboard( + context, + resolvedChannel.id, + message: 'Channel ID copied', + ); + }, + ), + ], + ), + if (resolvedChannel.isMember || + canJoin || + lifecycleCapabilitiesLoading || + lifecycleCapabilitiesUnavailable || + canArchive || + canUnarchive || + canDelete) + AppListCard( + key: const ValueKey('channel-details-lifecycle-card'), + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (canJoin) + AppListRow( + icon: BuzzIcons.logIn, + title: isJoining.value ? 'Joining channel…' : 'Join channel', + onTap: isJoining.value ? null : joinChannel, + ), + if (resolvedChannel.isMember && !resolvedChannel.isArchived) + AppListRow( + icon: BuzzIcons.logOut, + title: 'Leave channel', + titleColor: context.colors.error, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Leave #${resolvedChannel.name}?', + body: 'You’ll stop receiving messages from this channel.', + confirmLabel: 'Leave', + action: () => ref + .read(channelActionsProvider) + .leaveChannel(resolvedChannel.id), + ), + ), + if (lifecycleCapabilitiesLoading) + const AppListRow( + icon: BuzzIcons.loaderCircle, + title: 'Loading channel actions…', + trailing: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading channel actions', + ), + ) + else if (lifecycleCapabilitiesUnavailable) + const AppListRow( + icon: BuzzIcons.triangleAlert, + title: 'Channel actions unavailable', + ) + else ...[ + if (canArchive) + AppListRow( + icon: BuzzIcons.archive, + title: 'Archive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Archive #${resolvedChannel.name}?', + body: 'The channel will become read-only.', + confirmLabel: 'Archive', + action: () => ref + .read(channelActionsProvider) + .archiveChannel(resolvedChannel.id), + ), + ), + if (canUnarchive) + AppListRow( + icon: BuzzIcons.archiveRestore, + title: 'Unarchive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Unarchive #${resolvedChannel.name}?', + body: 'The channel will become active again.', + confirmLabel: 'Unarchive', + action: () => ref + .read(channelActionsProvider) + .unarchiveChannel(resolvedChannel.id), + ), + ), + if (canDelete) + AppListRow( + icon: BuzzIcons.trash2, + title: 'Delete channel', + titleColor: context.colors.error, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Delete #${resolvedChannel.name}?', + body: + 'This permanently deletes the channel and cannot be undone.', + confirmLabel: 'Delete', + action: () => ref + .read(channelActionsProvider) + .deleteChannel(resolvedChannel.id), + ), + ), + ], + ], + ), + ], + ); + if (inSheet) return content; + return FrostedScaffold( useUtilitySurfaceTheme: true, appBar: FrostedAppBar( @@ -314,306 +651,7 @@ class ChannelDetailsPage extends HookConsumerWidget { ), ), ), - body: ListView( - key: const ValueKey('channel-details-page-list'), - controller: scrollController, - padding: EdgeInsets.only( - top: frostedAppBarHeight(context) + Grid.xs, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xs, - ), - children: [ - _ChannelDetailsHero( - channel: resolvedChannel, - nameKey: heroNameKey, - displayName: displayName, - avatar: counterpart == null - ? null - : AvatarImage( - key: const ValueKey('dm-details-avatar'), - imageUrl: counterpartProfile?.avatarUrl, - radius: 40, - isAgent: counterpartProfile?.isAgent ?? false, - backgroundColor: context.controlSurface, - fallback: Text( - counterpartProfile?.initial ?? - dmAvatarInitial( - resolvedChannel, - currentPubkey: resolvedCurrentPubkey, - ), - style: context.textTheme.headlineMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), - ), - ), - Padding( - key: const ValueKey('channel-details-actions'), - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - _channelDetailsSectionPadding, - Grid.gutter, - _channelDetailsSectionPadding, - ), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: BuzzActionTile( - key: const ValueKey('channel-details-star-action'), - icon: null, - iconWidget: TablerStarIcon( - filled: isStarred, - color: isStarred - ? context.colors.primary - : context.colors.onSurface, - ), - label: isStarred ? 'Unstar' : 'Star', - onTap: toggleStar, - ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: BuzzActionTile( - key: const ValueKey('channel-details-mute-action'), - icon: isMuted ? BuzzIcons.bell : BuzzIcons.bellOff, - iconColor: isMuted ? context.colors.primary : null, - label: isMuted ? 'Unmute' : 'Mute', - onTap: toggleMute, - ), - ), - if (!resolvedChannel.isDm) ...[ - const SizedBox(width: Grid.xxs), - Expanded( - child: BuzzActionTile( - key: const ValueKey('channel-details-edit-action'), - icon: BuzzIcons.pencil, - label: 'Edit', - isEnabled: canEdit, - onTap: openManageChannel, - ), - ), - ], - ], - ), - ), - ), - if (!isDirectDm) - AppListCard( - key: const ValueKey('channel-details-members-card'), - label: memberLabel, - dividerIndent: Grid.xs + 40 + Grid.xs, - verticalPadding: _channelDetailsSectionPadding, - children: [ - if (canAddMembers) - AppListRowRaw( - key: const ValueKey('channel-details-add-members-row'), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: context.colors.surfaceContainer, - shape: BoxShape.circle, - ), - child: Icon( - BuzzIcons.plus, - color: context.colors.onSurfaceVariant, - ), - ), - title: Text( - 'Add members', - style: context.textTheme.bodyLarge, - ), - trailing: const _ChannelDetailsChevron(), - onTap: openAddMembers, - verticalPadding: Grid.xxs, - ), - if (membersAsync.isLoading && members.isEmpty) - const AppListRow( - icon: BuzzIcons.loaderCircle, - title: 'Loading members…', - trailing: BuzzLoadingIndicator( - size: 20, - semanticLabel: 'Loading members', - ), - ) - else if (membersAsync.hasError && members.isEmpty) - const AppListRow( - icon: BuzzIcons.triangleAlert, - title: 'Members unavailable', - ) - else ...[ - for (final member in previewMembers) - _ChannelMemberPreviewRow( - key: ValueKey('channel-details-member-${member.pubkey}'), - member: member, - currentPubkey: resolvedCurrentPubkey, - onMemberTap: onMemberTap, - displayName: - userCache[member.pubkey.toLowerCase()]?.displayName, - contextualLabel: - memberLabels[member.pubkey.toLowerCase()], - avatarUrl: - userCache[member.pubkey.toLowerCase()]?.avatarUrl, - ), - if (memberCount > _channelMemberPreviewLimit) - AppListRowRaw( - key: const ValueKey('channel-details-members-row'), - leading: const SizedBox.square(dimension: 40), - title: Text( - 'See all', - style: context.textTheme.bodyLarge, - ), - trailing: const _ChannelDetailsChevron(), - onTap: openMembers, - verticalPadding: Grid.xxs, - ), - ], - ], - ), - AppListCard( - key: const ValueKey('channel-details-channel-card'), - verticalPadding: _channelDetailsSectionPadding, - children: [ - AppListRow( - icon: BuzzIcons.folderInput, - title: 'Move to section…', - trailing: const _ChannelDetailsChevron(), - onTap: () async { - await _showMoveSectionSheet( - context, - ref, - channel: resolvedChannel, - sectionId: currentSectionId, - ); - }, - ), - AppListRow( - icon: BuzzIcons.copy, - title: 'Copy channel name', - onTap: () { - copyToClipboard( - context, - resolvedChannel.name, - message: 'Channel name copied', - ); - }, - ), - AppListRow( - icon: BuzzIcons.hash, - title: 'Copy channel ID', - onTap: () { - copyToClipboard( - context, - resolvedChannel.id, - message: 'Channel ID copied', - ); - }, - ), - ], - ), - if (resolvedChannel.isMember || - canJoin || - lifecycleCapabilitiesLoading || - lifecycleCapabilitiesUnavailable || - canArchive || - canUnarchive || - canDelete) - AppListCard( - key: const ValueKey('channel-details-lifecycle-card'), - verticalPadding: _channelDetailsSectionPadding, - children: [ - if (canJoin) - AppListRow( - icon: BuzzIcons.logIn, - title: isJoining.value - ? 'Joining channel…' - : 'Join channel', - onTap: isJoining.value ? null : joinChannel, - ), - if (resolvedChannel.isMember && !resolvedChannel.isArchived) - AppListRow( - icon: BuzzIcons.logOut, - title: 'Leave channel', - titleColor: context.colors.error, - onTap: () => _confirmAndRun( - context, - ref, - title: 'Leave #${resolvedChannel.name}?', - body: 'You’ll stop receiving messages from this channel.', - confirmLabel: 'Leave', - action: () => ref - .read(channelActionsProvider) - .leaveChannel(resolvedChannel.id), - ), - ), - if (lifecycleCapabilitiesLoading) - const AppListRow( - icon: BuzzIcons.loaderCircle, - title: 'Loading channel actions…', - trailing: BuzzLoadingIndicator( - size: 20, - semanticLabel: 'Loading channel actions', - ), - ) - else if (lifecycleCapabilitiesUnavailable) - const AppListRow( - icon: BuzzIcons.triangleAlert, - title: 'Channel actions unavailable', - ) - else ...[ - if (canArchive) - AppListRow( - icon: BuzzIcons.archive, - title: 'Archive channel', - onTap: () => _confirmAndRun( - context, - ref, - title: 'Archive #${resolvedChannel.name}?', - body: 'The channel will become read-only.', - confirmLabel: 'Archive', - action: () => ref - .read(channelActionsProvider) - .archiveChannel(resolvedChannel.id), - ), - ), - if (canUnarchive) - AppListRow( - icon: BuzzIcons.archiveRestore, - title: 'Unarchive channel', - onTap: () => _confirmAndRun( - context, - ref, - title: 'Unarchive #${resolvedChannel.name}?', - body: 'The channel will become active again.', - confirmLabel: 'Unarchive', - action: () => ref - .read(channelActionsProvider) - .unarchiveChannel(resolvedChannel.id), - ), - ), - if (canDelete) - AppListRow( - icon: BuzzIcons.trash2, - title: 'Delete channel', - titleColor: context.colors.error, - onTap: () => _confirmAndRun( - context, - ref, - title: 'Delete #${resolvedChannel.name}?', - body: - 'This permanently deletes the channel and cannot be undone.', - confirmLabel: 'Delete', - action: () => ref - .read(channelActionsProvider) - .deleteChannel(resolvedChannel.id), - ), - ), - ], - ], - ), - ], - ), + body: content, ); } } diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 7ad212d61d..e878a894fc 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -35,7 +35,11 @@ import 'me_assistant_picker_sheet.dart'; import 'new_me_project.dart'; import 'me_workspace_picker.dart'; import 'me_recovery_dialog.dart'; +import '../../profile/user_profile_sheet.dart'; +import '../channel_actions_sheet.dart'; import '../channel_detail_page.dart'; +import '../channel_identity_names_provider.dart'; +import '../channel_member_profile_actions.dart'; import '../channel_management_provider.dart'; import '../channels_provider.dart'; @@ -179,6 +183,21 @@ class MePage extends HookConsumerWidget { ChannelDetailPage(channel: channel, personalChat: true), ), ), + onLongPress: () => showChannelDetailsSheet( + context: context, + channel: channel, + currentPubkey: viewer, + onMemberTap: (context, pubkey) => showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider(channel.id), + contextualActions: (_) => ChannelMemberProfileActions( + channel: channel, + pubkey: pubkey, + ), + ), + personalChat: true, + ), ); final inventorySlivers = [ SliverPadding( diff --git a/mobile/lib/features/channels/me/me_page_helpers.dart b/mobile/lib/features/channels/me/me_page_helpers.dart index a0ee92d285..41214dea03 100644 --- a/mobile/lib/features/channels/me/me_page_helpers.dart +++ b/mobile/lib/features/channels/me/me_page_helpers.dart @@ -20,12 +20,14 @@ class _MeChatRow extends ConsumerWidget { required this.channelId, required this.name, required this.onTap, + required this.onLongPress, required this.isUnread, }); final String channelId; final String name; final bool isUnread; final VoidCallback onTap; + final VoidCallback onLongPress; @override Widget build(BuildContext context, WidgetRef ref) { @@ -35,11 +37,13 @@ class _MeChatRow extends ConsumerWidget { return Semantics( label: '${isUnread ? '$name, unread' : name}$status', onTap: onTap, + onLongPress: onLongPress, excludeSemantics: true, button: true, child: InkWell( borderRadius: BorderRadius.circular(Radii.md), onTap: onTap, + onLongPress: onLongPress, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.xxs, diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index c67d340641..7bef3f3c7c 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_groups.dart'; @@ -117,6 +118,66 @@ void main() { expect(find.text('A place to work with your agents'), findsNothing); }); + testWidgets('long-pressing a chat opens its details as a sheet', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + defaultAgentProvider.overrideWith((ref) => Stream.value(null)), + myPubkeyProvider.overrideWithValue('owner'), + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelsProvider.overrideWith(LabeledChannels.new), + channelMembersProvider('loose').overrideWith( + (ref) async => [ + ChannelMember( + pubkey: 'owner', + role: 'owner', + joinedAt: DateTime(2026), + ), + ], + ), + agentOwnersProvider.overrideWithValue( + const AsyncValue.data({}), + ), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups(groups: [], assignments: {})), + ), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Unfiled labeled chat')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsOneWidget); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.byKey(const ValueKey('channel-details-page-list')), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text('Unfiled labeled chat'), + ), + findsOneWidget, + ); + + await tester.drag( + find.byKey(const ValueKey('channel-details-page-list')), + const Offset(0, -600), + ); + await tester.pumpAndSettle(); + expect(find.text('Leave channel'), findsOneWidget); + // Solo chats move between Solo workspaces, not channel sections. + expect(find.text('Move to section…'), findsNothing); + }); + testWidgets('empty workspace stays centered behind the New chat keyboard', ( tester, ) async {