fix(mobile): fence send preparation by source draft visit

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-05 16:28:57 -04:00
parent 3c7f288c60
commit 1b44dc0fe1
5 changed files with 318 additions and 59 deletions

View File

@@ -41,6 +41,10 @@ class ComposeBar extends HookConsumerWidget {
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
final draftRevision = useRef(0);
final draftIdentity = _composerDraftIdentity(ref);
// A -> B -> A is a new visit, even when the eventual draft key is equal.
final visit = useMemoized(Object.new, [draftIdentity, draftKey]);
final currentVisit = useRef(visit)..value = visit;
final activeSend = useRef<Object?>(null);
final isComposerExpanded = useState(false);
final androidImeTransitionStarted = useState(
defaultTargetPlatform != TargetPlatform.android,
@@ -220,6 +224,10 @@ class ComposeBar extends HookConsumerWidget {
// mentions. Used to pass resolved pubkeys directly to onSend and to attach
// selected non-member agents before the message is published.
final mentionMap = useRef(<String, MentionCandidate>{});
useEffect(() {
mentionMap.value.clear();
return null;
}, [visit]);
// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
@@ -465,50 +473,69 @@ class ComposeBar extends HookConsumerWidget {
return;
}
final submittedDraftRevision = draftRevision.value;
final attempt = Object();
activeSend.value = attempt;
isSending.value = true;
bool ownsSource() =>
context.mounted &&
identical(currentVisit.value, visit) &&
'${ref.read(relayConfigProvider).baseUrl}'
':${ref.read(myPubkeyProvider) ?? 'anon'}' ==
draftIdentity;
void ensureCurrent() {
if (!ownsSource()) throw const _ComposeSendCancelled();
}
// Resolved before any await: see
// `_reportSendCancelledByCommunitySwitch`.
final messenger = ScaffoldMessenger.maybeOf(context);
// Extract pubkeys for mentions present in the final text.
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
final outgoing = _OutgoingMentions(selectedMentions);
final scan = await _scanNonMemberMentions(
ref,
channelId: channelId,
selectedMentions: selectedMentions,
currentPubkey: currentPubkey,
);
// Mentioning humans outside the channel prompts "Invite" / "Do
// nothing" (send without inviting) — mirrors desktop's
// NonMemberMentionDialog. Agents keep the existing silent auto-add.
if (scan.humans.isNotEmpty) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in scan.humans) candidate.label],
canInvite: scan.canAddMembers,
);
if (choice == null) return; // Dismissed — keep the draft, send nothing.
outgoing.resolveHumanChoice(choice, scan.humans);
}
final queuedAttachments = List<_PendingAttachment>.of(attachments.value);
final channelActions = ref.read(channelActionsProvider);
// An add that was refused doesn't block the message: it is reported and
// the un-added mentions are demoted to reference tags so the send lands.
Future<void> addMentionedNonMembers() => outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
);
isSending.value = true;
try {
// Extract pubkeys for mentions present in the final text.
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
final outgoing = _OutgoingMentions(selectedMentions);
final scan = await _scanNonMemberMentions(
ref,
channelId: channelId,
selectedMentions: selectedMentions,
currentPubkey: currentPubkey,
);
ensureCurrent();
if (draftRevision.value != submittedDraftRevision) return;
// Mentioning humans outside the channel prompts "Invite" / "Do
// nothing" (send without inviting) — mirrors desktop's
// NonMemberMentionDialog. Agents keep the existing silent auto-add.
if (scan.humans.isNotEmpty) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in scan.humans) candidate.label],
canInvite: scan.canAddMembers,
);
ensureCurrent();
if (choice == null || draftRevision.value != submittedDraftRevision) {
return; // Dismissal or newer intent keeps the draft, sends nothing.
}
outgoing.resolveHumanChoice(choice, scan.humans);
}
final queuedAttachments = List<_PendingAttachment>.of(
attachments.value,
);
final channelActions = ref.read(channelActionsProvider);
// An add that was refused doesn't block the message: it is reported and
// the un-added mentions are demoted to reference tags so the send lands.
Future<void> addMentionedNonMembers() => outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
ensureCurrent: ensureCurrent,
);
if (queuedAttachments.isEmpty) {
if (!context.mounted) return;
await _sendTextOnlyDraft(
@@ -517,6 +544,7 @@ class ComposeBar extends HookConsumerWidget {
mentionMap: mentionMap,
draftRevision: draftRevision,
submittedDraftRevision: submittedDraftRevision,
ownsSource: ownsSource,
focusNode: focusNode,
clearComposer: clearComposer,
addMentionedNonMembers: addMentionedNonMembers,
@@ -537,8 +565,16 @@ class ComposeBar extends HookConsumerWidget {
final draftMentions = Map<String, MentionCandidate>.of(
mentionMap.value,
);
clearComposer();
final clearedDraftRevision = draftRevision.value;
// Membership preparation is cancellable intent, not a detached send.
// Keep that draft recoverable until preparation succeeds. Ordinary
// member-only media keeps the existing background delivery behavior.
final preparingMembership =
scan.agentPubkeys.isNotEmpty || scan.humans.isNotEmpty;
int? clearedDraftRevision;
if (!preparingMembership) {
clearComposer();
clearedDraftRevision = draftRevision.value;
}
uploadingCount.value += 1;
uploadProgress.value = 0;
isSending.value = false;
@@ -557,7 +593,8 @@ class ComposeBar extends HookConsumerWidget {
uploadService,
attachment,
onProgress: (progress) {
if (context.mounted) {
if (context.mounted &&
queueGeneration == uploadGeneration.value) {
uploadProgress.value =
(index + progress) / queuedAttachments.length;
}
@@ -566,7 +603,8 @@ class ComposeBar extends HookConsumerWidget {
);
if (queueGeneration != uploadGeneration.value) return;
uploaded.add(descriptor);
if (context.mounted) {
if (context.mounted &&
queueGeneration == uploadGeneration.value) {
uploadProgress.value = (index + 1) / queuedAttachments.length;
}
}
@@ -576,8 +614,18 @@ class ComposeBar extends HookConsumerWidget {
customEmoji: customEmoji,
);
if (queueGeneration != uploadGeneration.value) return;
if (preparingMembership) {
ensureCurrent();
if (draftRevision.value != submittedDraftRevision) return;
}
await addMentionedNonMembers();
if (queueGeneration != uploadGeneration.value) return;
if (preparingMembership &&
ownsSource() &&
draftRevision.value == submittedDraftRevision) {
clearComposer();
clearedDraftRevision = draftRevision.value;
}
await delivery(
payload.content,
outgoing.pubkeys,
@@ -585,8 +633,8 @@ class ComposeBar extends HookConsumerWidget {
);
} catch (error) {
if (cancellation.isCancelled) return;
if (context.mounted) uploadError.value = _formatUploadError(error);
if (context.mounted &&
if (ownsSource()) uploadError.value = _formatUploadError(error);
if (ownsSource() &&
queueGeneration == uploadGeneration.value &&
draftRevision.value == clearedDraftRevision) {
controller.value = draftText;
@@ -598,7 +646,12 @@ class ComposeBar extends HookConsumerWidget {
focusNode.requestFocus();
}
} finally {
if (!retainedForRetry) {
// An uncleared source still owns these files (including on error).
final sourceRetainsFiles =
preparingMembership &&
clearedDraftRevision == null &&
ownsSource();
if (!retainedForRetry && !sourceRetainsFiles) {
await _deleteOwnedAttachments(queuedAttachments);
}
if (activeUploadCancellation.value == cancellation) {
@@ -609,8 +662,27 @@ class ComposeBar extends HookConsumerWidget {
}
}
}());
} on _ComposeSendCancelled {
// The source draft was never cleared during preparation.
} on StateError catch (error) {
if (ownsSource()) {
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(error))),
);
} else {
_reportSendCancelledByCommunitySwitch(messenger);
}
} catch (error) {
if (ownsSource()) {
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(error))),
);
}
} finally {
if (context.mounted && isSending.value) isSending.value = false;
if (ownsSource() && identical(activeSend.value, attempt)) {
activeSend.value = null;
isSending.value = false;
}
}
}

View File

@@ -1,11 +1,16 @@
part of '../compose_bar.dart';
class _ComposeSendCancelled implements Exception {
const _ComposeSendCancelled();
}
Future<void> _sendTextOnlyDraft({
required BuildContext context,
required _MarkdownEditingController controller,
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
required ObjectRef<int> draftRevision,
required int submittedDraftRevision,
required bool Function() ownsSource,
required FocusNode focusNode,
required VoidCallback clearComposer,
required Future<void> Function() addMentionedNonMembers,
@@ -19,7 +24,7 @@ Future<void> _sendTextOnlyDraft({
int? clearedDraftRevision;
void restoreClearedDraft() {
if (!context.mounted ||
if (!ownsSource() ||
clearedDraftText == null ||
clearedDraftMentions == null ||
clearedDraftRevision == null ||
@@ -35,6 +40,7 @@ Future<void> _sendTextOnlyDraft({
try {
await addMentionedNonMembers();
if (!ownsSource()) return;
// Clear before optimistic insertion so the outgoing row and draft never
// appear simultaneously during the send transition. If the user edited
// while membership changes were pending, preserve that newer draft.
@@ -49,6 +55,8 @@ Future<void> _sendTextOnlyDraft({
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} on _ComposeSendCancelled {
restoreClearedDraft();
} on StateError {
restoreClearedDraft();
_reportSendCancelledByCommunitySwitch(messenger);
@@ -80,12 +88,19 @@ void _useComposeDraftLifecycle({
required _IOSAttachmentPopoverController iosAttachmentPopover,
required VoidCallback onDraftIdentityChanged,
}) {
// Hook disposal can lag the replacement effect. An old text listener must
// not persist the incoming draft into its previous channel/account scope.
final owner = useMemoized(Object.new, [draftKey, draftIdentity]);
final currentOwner = useRef(owner)..value = owner;
final lastDraftIdentity = useRef<String?>(null);
final lastDraftKey = useRef<String?>(null);
useEffect(() {
final identityChanged =
lastDraftIdentity.value != null &&
lastDraftIdentity.value != draftIdentity;
(lastDraftIdentity.value != draftIdentity ||
lastDraftKey.value != draftKey);
lastDraftIdentity.value = draftIdentity;
lastDraftKey.value = draftKey;
final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey);
if (identityChanged) {
draftRevision.value += 1;
@@ -108,6 +123,7 @@ void _useComposeDraftLifecycle({
var lastPersistedText = controller.text;
void persistDraft() {
if (!identical(currentOwner.value, owner)) return;
final text = controller.text;
if (text == lastPersistedText) return;
lastPersistedText = text;

View File

@@ -447,10 +447,11 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
required List<String> agentPubkeys,
required List<String> humanPubkeys,
required bool canAddMembers,
required VoidCallback ensureCurrent,
}) async {
final pending = [
if (agentPubkeys.isNotEmpty) (agentPubkeys, 'bot'),
if (humanPubkeys.isNotEmpty) (humanPubkeys, 'member'),
for (final pubkey in agentPubkeys) ([pubkey], 'bot'),
for (final pubkey in humanPubkeys) ([pubkey], 'member'),
];
if (pending.isEmpty) return _NonMemberAddOutcome.empty;
@@ -467,11 +468,15 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
final errors = <String>[];
for (final (pubkeys, role) in pending) {
try {
ensureCurrent();
await channelActions.addMembers(
channelId: channelId,
pubkeys: pubkeys,
role: role,
);
ensureCurrent();
} on _ComposeSendCancelled {
rethrow;
} on StateError {
rethrow;
} catch (error) {
@@ -518,12 +523,13 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions(
);
if (selectedMentions.isEmpty) return none;
final channel = (await ref.read(
channelsProvider.future,
)).firstWhere((candidate) => candidate.id == channelId);
// Capture both reads before yielding: WidgetRef may be disposed while waiting.
final (channels, members) = await (
ref.read(channelsProvider.future),
ref.read(channelMembersProvider(channelId).future),
).wait;
final channel = channels.firstWhere((candidate) => candidate.id == channelId);
if (channel.isDm) return none;
final members = await ref.read(channelMembersProvider(channelId).future);
final memberPubkeys = {
for (final member in members) member.pubkey.toLowerCase(),
};
@@ -607,6 +613,7 @@ class _OutgoingMentions {
ChannelActions channelActions, {
required _NonMemberMentionScan scan,
required ScaffoldMessengerState? messenger,
required VoidCallback ensureCurrent,
}) async {
final outcome = await _addMentionedNonMembers(
channelActions,
@@ -614,6 +621,7 @@ class _OutgoingMentions {
agentPubkeys: scan.agentPubkeys,
humanPubkeys: _invitedHumanPubkeys,
canAddMembers: scan.canAddMembers,
ensureCurrent: ensureCurrent,
);
demote(outcome.notAdded);
if (outcome.errors.isNotEmpty) {

View File

@@ -30,6 +30,8 @@ import 'package:buzz/shared/widgets/anchored_popover_menu.dart';
import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'compose_bar_test/send_lifecycle_tests.dart';
final _pngBytes = Uint8List.fromList([
0x89,
0x50,
@@ -174,6 +176,7 @@ Widget _buildComposeBar({
required ComposeBarOnSend onSend,
List<ChannelMember> members = const <ChannelMember>[],
Future<List<ChannelMember>>? membersFuture,
Future<List<ChannelMember>> Function()? membersLoader,
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
List<Channel> channels = const <Channel>[],
List<ChannelMember> cachedMembers = const <ChannelMember>[],
@@ -190,6 +193,7 @@ Widget _buildComposeBar({
ValueChanged<VoidCallback>? onFocusRestorerChanged,
AppLifecycleNotifier Function()? appLifecycle,
String composeBarKey = 'compose-bar',
String? threadHeadId,
VoiceNoteRecorder Function()? voiceNoteRecorderFactory,
VoiceNotePlayerController Function()? voiceNotePlayerFactory,
}) {
@@ -207,9 +211,10 @@ Widget _buildComposeBar({
),
photoLibraryProvider.overrideWithValue(photoLibrary),
currentPubkeyProvider.overrideWith((ref) => currentPubkey),
channelMembersProvider(
'channel-1',
).overrideWith((ref) => membersFuture ?? Future.value(members)),
channelMembersProvider('channel-1').overrideWith(
(ref) =>
membersLoader?.call() ?? membersFuture ?? Future.value(members),
),
agentDirectoryProvider.overrideWith((ref) async => relayAgents),
agentOwnersProvider.overrideWith((ref) async => const <String, String>{}),
relayClientProvider.overrideWithValue(
@@ -253,6 +258,7 @@ Widget _buildComposeBar({
final composeBar = ComposeBar(
key: ValueKey(composeBarKey),
channelId: 'channel-1',
threadHeadId: threadHeadId,
focusNode: focusNode,
onFocusRestorerChanged: onFocusRestorerChanged,
onFocusRequested: onFocusRequested,
@@ -641,6 +647,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier {
}
void main() {
sendLifecycleTests();
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
@@ -3279,6 +3286,10 @@ void main() {
publishedEvents.where((event) => event['kind'] == 9000),
isEmpty,
);
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'hello @Helper Bot',
);
},
);

View File

@@ -0,0 +1,152 @@
part of '../compose_bar_test.dart';
void sendLifecycleTests() {
for (final action in [
'double send',
'edit',
'revisit',
'unmount',
'failure',
]) {
testWidgets('send preparation fences $action', (tester) async {
final gate = Completer<List<ChannelMember>>();
final members = [
ChannelMember(
pubkey: 'a' * 64,
displayName: 'Alice',
role: 'member',
joinedAt: DateTime(2025),
),
];
var pending = false;
var sent = 0;
final service = _testUploadService(nostr.Keys.generate().nsec);
Widget build({String? thread}) => _buildComposeBar(
uploadService: service,
channels: [_makeCurrentChannel()],
membersLoader: () => pending ? gate.future : Future.value(members),
threadHeadId: thread,
onSend: (_, _, {mediaTags = const []}) async {
sent++;
},
);
await tester.pumpWidget(build());
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@ali');
await tester.pumpAndSettle();
await tester.tap(find.text('Alice'));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(ComposeBar)),
);
pending = true;
container.invalidate(channelMembersProvider('channel-1'));
await tester.pump();
// Invoke twice without a frame: an asynchronous latch is too late.
final button = tester.widget<GestureDetector>(
find
.ancestor(
of: find.byIcon(LucideIcons.arrowUp),
matching: find.byType(GestureDetector),
)
.first,
);
button.onTap!();
if (action == 'double send') button.onTap!();
await tester.pump();
if (action == 'edit') {
await tester.enterText(find.byType(TextField), 'new intent');
}
if (action == 'revisit') {
await tester.pumpWidget(build(thread: 'other-thread'));
await tester.pumpWidget(build());
}
if (action == 'unmount') await tester.pumpWidget(const SizedBox());
if (action == 'failure') {
gate.completeError(Exception('membership unavailable'));
} else {
gate.complete(members);
}
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
expect(sent, action == 'double send' ? 1 : 0);
if (action == 'edit') {
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'new intent',
);
}
if (action == 'failure') {
expect(find.textContaining('membership unavailable'), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'@Alice ',
);
}
});
}
testWidgets('a revisited source cannot finish an old membership batch', (
tester,
) async {
final signer = nostr.Keys.generate();
final gate = Completer<void>();
final events = <Map<String, dynamic>>[];
var sends = 0;
final service = _testUploadService(signer.nsec);
Widget build({String? thread}) => _buildComposeBar(
uploadService: service,
currentPubkey: signer.public,
relayAgents: [
_testAgent('b' * 64),
AgentDirectoryEntry(
pubkey: 'c' * 64,
displayName: 'Other Bot',
respondTo: 'anyone',
channelIds: const ['shared-channel'],
),
],
channels: [_makeCurrentChannel(), _makeSharedMemberChannel()],
threadHeadId: thread,
onSend: (_, _, {mediaTags = const []}) async {
sends++;
},
);
await tester.pumpWidget(build());
final container = ProviderScope.containerOf(
tester.element(find.byType(ComposeBar)),
);
final session = container.read(relaySessionProvider.notifier);
session.debugAttachSocketForTest(
_RecordingRelaySocket(
events,
session.debugHandleSocketMessageForTest,
beforeAcknowledged: (event) async {
if (event['kind'] == 9000) await gate.future;
},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@hel');
await tester.pumpAndSettle();
await tester.tap(find.text('Helper Bot'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '@Helper Bot @oth');
await tester.pumpAndSettle();
await tester.tap(find.text('Other Bot'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.arrowUp));
await tester.pump();
expect(events.where((e) => e['kind'] == 9000), hasLength(1));
await tester.pumpWidget(build(thread: 'other'));
await tester.pumpWidget(build());
gate.complete();
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
expect(sends, 0);
expect(events.where((e) => e['kind'] == 9000), hasLength(1));
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'@Helper Bot @Other Bot ',
);
});
}