From a8e6aee0191995136a6c446afabc19f6eb0a667c Mon Sep 17 00:00:00 2001 From: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Date: Mon, 17 Aug 2026 18:44:43 +0100 Subject: [PATCH] fix(mobile): preserve channel state across partial actions Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> --- .../features/channels/add_members_sheet.dart | 10 +++- .../channels/channel_actions_sheet.dart | 20 ++++--- .../channels/channel_actions_sheet_test.dart | 59 ++++++++++++++++++- .../channels/channel_detail_page_test.dart | 4 ++ 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index 936191621..a4a6d1e35 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -47,10 +47,12 @@ class AddChannelMembersSheet extends HookConsumerWidget { final selectedPubkeys = selectedUsers.value .map((user) => user.pubkey.toLowerCase()) .toSet(); + final successfulPubkeys = useState>({}); + final excludedPubkeys = {...normalizedExisting, ...successfulPubkeys.value}; final availableUsers = directoryAsync.asData?.value .where( - (user) => !normalizedExisting.contains(user.pubkey.toLowerCase()), + (user) => !excludedPubkeys.contains(user.pubkey.toLowerCase()), ) .toList() ?? const []; @@ -84,6 +86,12 @@ class AddChannelMembersSheet extends HookConsumerWidget { final failedPubkeys = error.failures.keys .map((pubkey) => pubkey.toLowerCase()) .toSet(); + successfulPubkeys.value = { + ...successfulPubkeys.value, + for (final user in submittedUsers) + if (!failedPubkeys.contains(user.pubkey.toLowerCase())) + user.pubkey.toLowerCase(), + }; selectedUsers.value = [ for (final user in submittedUsers) if (failedPubkeys.contains(user.pubkey.toLowerCase())) user, diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 9cbf69358..14ef456de 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -59,7 +59,7 @@ Future showChannelActionsSheet({ /// Mobile action sheet for channel-level read, organization, and lifecycle /// operations. -class ChannelActionsSheet extends ConsumerWidget { +class ChannelActionsSheet extends HookConsumerWidget { const ChannelActionsSheet({ super.key, required this.channel, @@ -75,6 +75,8 @@ class ChannelActionsSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final displayedChannel = useState(channel); + final currentChannel = displayedChannel.value; final isMuted = ref.watch(channelMutesProvider).store.channels[channel.id]?.muted == true; @@ -220,9 +222,11 @@ class ChannelActionsSheet extends ConsumerWidget { maxHeight: MediaQuery.sizeOf(context).height * 0.9, ), builder: (_) => ManageChannelSheet( - channel: channel, + channel: currentChannel, canEditDetails: - canManageLifecycle && !channel.isArchived, + canManageLifecycle && !currentChannel.isArchived, + onChannelUpdated: (updated) => + displayedChannel.value = updated, ), ); if (shouldClose == true && context.mounted) { @@ -238,7 +242,7 @@ class ChannelActionsSheet extends ConsumerWidget { close(); copyToClipboard( context, - channel.name, + currentChannel.name, message: 'Channel name copied to clipboard', ); }, @@ -266,7 +270,7 @@ class ChannelActionsSheet extends ConsumerWidget { onTap: () => _confirmAndRun( context, ref, - title: 'Leave #${channel.name}?', + title: 'Leave #${currentChannel.name}?', body: 'You’ll stop receiving messages from this channel.', confirmLabel: 'Leave', action: () => ref @@ -297,7 +301,7 @@ class ChannelActionsSheet extends ConsumerWidget { onTap: () => _confirmAndRun( context, ref, - title: 'Archive #${channel.name}?', + title: 'Archive #${currentChannel.name}?', body: 'The channel will become read-only.', confirmLabel: 'Archive', action: () => ref @@ -312,7 +316,7 @@ class ChannelActionsSheet extends ConsumerWidget { onTap: () => _confirmAndRun( context, ref, - title: 'Unarchive #${channel.name}?', + title: 'Unarchive #${currentChannel.name}?', body: 'The channel will become active again.', confirmLabel: 'Unarchive', action: () => ref @@ -328,7 +332,7 @@ class ChannelActionsSheet extends ConsumerWidget { onTap: () => _confirmAndRun( context, ref, - title: 'Delete #${channel.name}?', + title: 'Delete #${currentChannel.name}?', body: 'This permanently deletes the channel and cannot be undone.', confirmLabel: 'Delete', diff --git a/mobile/test/features/channels/channel_actions_sheet_test.dart b/mobile/test/features/channels/channel_actions_sheet_test.dart index 2304c3cc7..ca7c41cba 100644 --- a/mobile/test/features/channels/channel_actions_sheet_test.dart +++ b/mobile/test/features/channels/channel_actions_sheet_test.dart @@ -371,6 +371,47 @@ void main() { ); }); + testWidgets('Manage refreshes metadata after saving from actions', ( + tester, + ) async { + await tester.pumpWidget( + _modalApp( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onUpdateChannel: (channelId, name, description) async {}, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('manage-channel-name')), + 'renamed', + ); + await tester.tap(find.byKey(const ValueKey('manage-channel-save-details'))); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(ListTile, 'Manage channel'), findsOneWidget); + await tester.tap(find.widgetWithText(ListTile, 'Manage channel')); + await tester.pumpAndSettle(); + final nameField = tester.widget( + find.byKey(const ValueKey('manage-channel-name')), + ); + expect(nameField.controller?.text, 'renamed'); + }); + testWidgets('non-member cannot edit canvas from Manage channel', ( tester, ) async { @@ -491,7 +532,14 @@ class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { } class _FakeChannelActions extends ChannelActions { - _FakeChannelActions(Ref ref) + final Future Function( + String channelId, + String? name, + String? description, + )? + onUpdateChannel; + + _FakeChannelActions(Ref ref, {this.onUpdateChannel}) : super( ref: ref, session: ref.read(relaySessionProvider.notifier), @@ -502,6 +550,15 @@ class _FakeChannelActions extends ChannelActions { currentPubkey: _currentPubkey, ); + @override + Future updateChannel({ + required String channelId, + String? name, + String? description, + }) async { + await onUpdateChannel?.call(channelId, name, description); + } + String? unarchivedChannelId; @override diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f7f3a46d5..33b9eaa78 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1210,6 +1210,10 @@ void main() { find.byKey(const ValueKey('add-channel-member-selected-bob')), findsOneWidget, ); + expect( + find.byKey(const ValueKey('add-channel-member-alice')), + findsNothing, + ); // The successful add must not remain selected for a retry. expect( tester