diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index 7e2e38f3d1..9361916216 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -69,6 +69,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { Future addSelectedMembers() async { if (selectedUsers.value.isEmpty || isSubmitting.value) return; + final submittedUsers = List.of(selectedUsers.value); isSubmitting.value = true; submitError.value = null; try { @@ -76,10 +77,17 @@ class AddChannelMembersSheet extends HookConsumerWidget { .read(channelActionsProvider) .addMembers( channelId: channelId, - pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(), + pubkeys: submittedUsers.map((user) => user.pubkey).toList(), ); if (context.mounted) Navigator.of(context).pop(true); } on AddMembersException catch (error) { + final failedPubkeys = error.failures.keys + .map((pubkey) => pubkey.toLowerCase()) + .toSet(); + selectedUsers.value = [ + for (final user in submittedUsers) + if (failedPubkeys.contains(user.pubkey.toLowerCase())) user, + ]; submitError.value = error.message; } catch (error) { submitError.value = error.toString(); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 3ebc66c84e..f7f3a46d55 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1140,6 +1140,96 @@ void main() { ); }); + testWidgets( + 'keeps only rejected members selected after a partial failure', + (tester) async { + var attempts = 0; + final submittedPubkeys = >[]; + await tester.pumpWidget( + _buildTestable( + messages: const [], + members: [ + ChannelMember( + pubkey: 'self', + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + directoryUsers: const [ + DirectoryUser(pubkey: 'alice', displayName: 'Alice'), + DirectoryUser(pubkey: 'bob', displayName: 'Bob'), + ], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onAddMembers: (_, pubkeys) async { + submittedPubkeys.add(pubkeys); + attempts += 1; + if (attempts == 1) { + throw const AddMembersException({'bob': 'rejected'}); + } + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('channel-details-add-members-row')), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('add-channel-member-alice')), + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('add-channel-member-bob'))); + await tester.pump(); + expect( + find.byKey(const ValueKey('add-channel-member-selected-alice')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('add-channel-member-selected-bob')), + findsOneWidget, + ); + await tester.tap( + find.byKey(const ValueKey('add-channel-members-submit')), + ); + await tester.pumpAndSettle(); + + expect(attempts, 1); + expect( + find.byKey(const ValueKey('add-channel-member-selected-alice')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('add-channel-member-selected-bob')), + findsOneWidget, + ); + // The successful add must not remain selected for a retry. + expect( + tester + .widgetList(find.byType(InputChip)) + .map((chip) => chip.key) + .toList(), + [const ValueKey('add-channel-member-selected-bob')], + ); + await tester.tap( + find.byKey(const ValueKey('add-channel-members-submit')), + ); + await tester.pumpAndSettle(); + expect(attempts, 2); + expect(submittedPubkeys, [ + ['alice', 'bob'], + ['bob'], + ]); + }, + ); + testWidgets('hides composer for archived channels', (tester) async { final archivedChannel = _testChannel.copyWith( archivedAt: DateTime.utc(2025, 1, 2),