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>
This commit is contained in:
Fast Fizz
2026-08-17 18:44:43 +01:00
parent e13e9cfd99
commit a8e6aee019
4 changed files with 83 additions and 10 deletions
@@ -47,10 +47,12 @@ class AddChannelMembersSheet extends HookConsumerWidget {
final selectedPubkeys = selectedUsers.value
.map((user) => user.pubkey.toLowerCase())
.toSet();
final successfulPubkeys = useState<Set<String>>(<String>{});
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 <DirectoryUser>[];
@@ -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,
@@ -59,7 +59,7 @@ Future<bool?> 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',
@@ -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<TextField>(
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<void> 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<void> updateChannel({
required String channelId,
String? name,
String? description,
}) async {
await onUpdateChannel?.call(channelId, name, description);
}
String? unarchivedChannelId;
@override
@@ -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