fix(mobile): check the current picker selection on a toggle

A second tap could reach Flutter before the rebuild that reflected the
first, and both toggles read the selection captured at build time, so the
same person was added twice. The diffed native table traps on a duplicate
row. Read the current selection instead, and fail loudly if a shown member
row is missing from the state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-07 16:52:01 -08:00
parent 0c8ec394c9
commit c8b7672aa2
4 changed files with 30 additions and 5 deletions

View File

@@ -296,7 +296,8 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
let changed = snapshot.itemIdentifiers.filter { item in
drawn[item].map { $0 != next[item] } ?? false
}
snapshot.reconfigureItems(changed.filter(previous.itemIdentifiers.contains))
let shown = Set(previous.itemIdentifiers)
snapshot.reconfigureItems(changed.filter(shown.contains))
drawn = next
let animate = view.window != nil && wasChosen != chosen.map(Item.member)
&& !UIAccessibility.isReduceMotionEnabled
@@ -327,7 +328,7 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
cell.contentView.accessibilityElementsHidden = false
cell.isUserInteractionEnabled = !busy
var content = UIListContentConfiguration.subtitleCell()
guard case .member(let pubkey) = item, let user = member(pubkey) else {
guard case .member(let pubkey) = item else {
content.text = status
content.textProperties.color = loadError && !atLimit ? Self.errorColor : .secondaryLabel
content.textProperties.numberOfLines = 0
@@ -336,6 +337,7 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
cell.contentConfiguration = content
return
}
guard let user = member(pubkey) else { preconditionFailure("A shown member row must be in the state") }
let chosen = selected.contains { $0["pubkey"] as? String == pubkey }
let key = user["avatarKey"] as? String
content.text = user["name"] as? String
@@ -378,10 +380,11 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard !busy, let item = rows.itemIdentifier(for: indexPath) else { return }
guard case .member(let pubkey) = item, let user = member(pubkey) else {
guard case .member(let pubkey) = item else {
if loadError && !atLimit { send("retry", [:]) }
return
}
guard let user = member(pubkey) else { preconditionFailure("A shown member row must be in the state") }
if user["unavailable"] as? Bool == true { return }
send("toggle", ["pubkey": pubkey])
// Where asked (New DM), a pick ends its query, as an inserted mention

View File

@@ -161,7 +161,11 @@ class AddChannelMembersSheet extends HookConsumerWidget {
void toggleUser(MentionCandidate user) {
if (isSubmitting.value) return;
final normalized = user.pubkey.toLowerCase();
if (selectedPubkeys.contains(normalized)) {
// The current selection, not this build's: a second tap can land
// before the rebuild that reflects the first.
if (selectedUsers.value.any(
(selected) => selected.pubkey.toLowerCase() == normalized,
)) {
selectedUsers.value = [
for (final selected in selectedUsers.value)
if (selected.pubkey.toLowerCase() != normalized) selected,

View File

@@ -461,7 +461,9 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
},
onToggle: (pubkey) {
if (isSubmitting.value) return;
if (selectedPubkeys.contains(pubkey)) {
// The current selection, not this build's: a second tap can land
// before the rebuild that reflects the first.
if (selectedUsers.value.any((user) => user.pubkey == pubkey)) {
selectedUsers.value = selectedUsers.value
.where((user) => user.pubkey != pubkey)
.toList();

View File

@@ -64,6 +64,22 @@ void registerNativeCreationTests(Widget Function(List<Override>) buildPage) {
expect(found['detail'], isNull);
await event('toggle', {'pubkey': _dmKey(2)});
await event('query', {'value': 'Person'});
// Two taps before Flutter rebuilds still never list a person twice.
for (var tap = 0; tap < 2; tap++) {
await messenger.handlePlatformMessage(
bridge.name,
const StandardMethodCodec().encodeMethodCall(
MethodCall('toggle', {'session': session, 'pubkey': _dmKey(3)}),
),
(_) {},
);
}
await tester.pumpAndSettle();
final picked = [
for (final user in states.last['selected'] as List)
(user as Map)['pubkey'],
];
expect(picked.toSet(), hasLength(picked.length));
for (final key in people.keys) {
await event('toggle', {'pubkey': key});
}