mirror of
https://github.com/block/buzz.git
synced 2026-11-14 21:31:47 +00:00
fix(mobile): cancel remaining invitations with media send attempt
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -479,13 +479,18 @@ class ComposeBar extends HookConsumerWidget {
|
||||
void Function()? checkPreparationCurrent;
|
||||
try {
|
||||
final submittedDraftRevision = draftRevision.value;
|
||||
final submittedUploadGeneration = uploadGeneration.value;
|
||||
var authorizationRevision = submittedDraftRevision;
|
||||
final visit = authorizationVisit.value;
|
||||
final config = ref.read(relayConfigProvider);
|
||||
final readAuthorization = ref.read(agentAuthorizationReaderProvider);
|
||||
bool isAuthorizationCurrent() =>
|
||||
bool ownsSource() =>
|
||||
context.mounted &&
|
||||
visit == authorizationVisit.value &&
|
||||
identical(config, ref.read(relayConfigProvider));
|
||||
bool isAuthorizationCurrent() =>
|
||||
ownsSource() &&
|
||||
submittedUploadGeneration == uploadGeneration.value &&
|
||||
authorizationRevision == draftRevision.value &&
|
||||
identical(config, ref.read(relayConfigProvider));
|
||||
void ensureAuthorizationCurrent() {
|
||||
@@ -493,7 +498,8 @@ class ComposeBar extends HookConsumerWidget {
|
||||
if (!identical(config, ref.read(relayConfigProvider))) {
|
||||
throw StateError('Community changed during authorization');
|
||||
}
|
||||
if (visit != authorizationVisit.value ||
|
||||
if (submittedUploadGeneration != uploadGeneration.value ||
|
||||
visit != authorizationVisit.value ||
|
||||
authorizationRevision != draftRevision.value) {
|
||||
throw const _ComposeAuthorizationCancelled();
|
||||
}
|
||||
@@ -586,6 +592,12 @@ class ComposeBar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
await authorize(keys);
|
||||
if (queuedAttachments.isEmpty ||
|
||||
(intendedAgentKeys.isNotEmpty ||
|
||||
scan.humans.isNotEmpty ||
|
||||
scan.agentPubkeys.isNotEmpty)) {
|
||||
ensureAuthorizationCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
if (queuedAttachments.isEmpty) {
|
||||
@@ -596,6 +608,7 @@ class ComposeBar extends HookConsumerWidget {
|
||||
mentionMap: mentionMap,
|
||||
draftRevision: draftRevision,
|
||||
submittedDraftRevision: submittedDraftRevision,
|
||||
ownsSource: ownsSource,
|
||||
focusNode: focusNode,
|
||||
clearComposer: clearComposer,
|
||||
addMentionedNonMembers: addMentionedNonMembers,
|
||||
|
||||
@@ -10,6 +10,7 @@ Future<void> _sendTextOnlyDraft({
|
||||
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,
|
||||
@@ -23,7 +24,7 @@ Future<void> _sendTextOnlyDraft({
|
||||
int? clearedDraftRevision;
|
||||
|
||||
void restoreClearedDraft() {
|
||||
if (!context.mounted ||
|
||||
if (!ownsSource() ||
|
||||
clearedDraftText == null ||
|
||||
clearedDraftMentions == null ||
|
||||
clearedDraftRevision == null ||
|
||||
@@ -39,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.
|
||||
@@ -86,12 +88,18 @@ void _useComposeDraftLifecycle({
|
||||
required _IOSAttachmentPopoverController iosAttachmentPopover,
|
||||
required VoidCallback onDraftIdentityChanged,
|
||||
}) {
|
||||
// Fence old listeners before effects restore an incoming draft.
|
||||
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;
|
||||
@@ -114,6 +122,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;
|
||||
|
||||
@@ -475,7 +475,7 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
|
||||
role: role,
|
||||
);
|
||||
ensureCurrent();
|
||||
} on _ComposeSendCancelled {
|
||||
} on _ComposeAuthorizationCancelled {
|
||||
rethrow;
|
||||
} on StateError {
|
||||
rethrow;
|
||||
|
||||
@@ -42,7 +42,6 @@ void sendLifecycleTests() {
|
||||
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(
|
||||
@@ -85,73 +84,102 @@ void sendLifecycleTests() {
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
for (final action in ['revisit', 'cancel', 'partial refusal']) {
|
||||
testWidgets('$action 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 = _FakeVideoUploadService(
|
||||
XFile.fromData(_pngBytes, name: 'clip.mp4', mimeType: 'video/mp4'),
|
||||
);
|
||||
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 _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();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('Invite'));
|
||||
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);
|
||||
await tester.tap(find.text('@Helper Bot @Other Bot'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(events.where((e) => e['kind'] == 9000), hasLength(1));
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
'@Helper Bot @Other Bot ',
|
||||
);
|
||||
});
|
||||
);
|
||||
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;
|
||||
},
|
||||
onEventAcknowledged: (event) {
|
||||
if (action == 'partial refusal' && event['kind'] == 9000) {
|
||||
session.debugAttachSocketForTest(
|
||||
_RecordingRelaySocket(
|
||||
events,
|
||||
session.debugHandleSocketMessageForTest,
|
||||
rejectAdds: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
if (action == 'cancel') {
|
||||
await _openAttachmentMenu(tester);
|
||||
await tester.tap(find.text('Video'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
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();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('Invite'));
|
||||
await tester.pump();
|
||||
expect(events.where((e) => e['kind'] == 9000), hasLength(1));
|
||||
if (action == 'cancel') {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('compose-upload-cancel')));
|
||||
} else if (action == 'revisit') {
|
||||
await tester.pumpWidget(build(thread: 'other'));
|
||||
await tester.pumpWidget(build());
|
||||
}
|
||||
gate.complete();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sends, 0);
|
||||
await tester.tap(find.text('@Helper Bot @Other Bot'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
events.where((e) => e['kind'] == 9000),
|
||||
hasLength(action == 'partial refusal' ? 2 : 1),
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
'@Helper Bot @Other Bot ',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user