From 8a689956085aecd65465a7f46b1634e40c672c50 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 11 Sep 2026 11:31:42 -0400 Subject: [PATCH] fix(mobile): preserve exact draft bindings across scope restoration Recompose the durable-mention child on the approved exact-mention parent. Reuse its atomic key-only draft persistence and current-candidate revalidation instead of restoring the obsolete display-classification authorization path. Retain the previously reviewed owner-token fence so an outgoing draft listener cannot erase bindings while another thread hydrates. Keep the original restart, thread-switch and rejected-send regression. Add production signed-send probes for eligible agent selection followed by provenance withdrawal and restart: refuse unavailable identities or explicitly send a human reference without a bot-membership mutation. A rollback to trusted restored agent classification fails both probes on kind 9000 role=bot. Signed-off-by: Logan Johnson --- .../channels/compose_bar/draft_lifecycle.dart | 5 + .../features/channels/compose_bar_test.dart | 4 + .../durable_mention_tests.dart | 220 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 mobile/test/features/channels/compose_bar_test/durable_mention_tests.dart diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index 5ef69eaa59b..a963df47f59 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -81,6 +81,10 @@ void _useComposeDraftLifecycle({ required _IOSAttachmentPopoverController iosAttachmentPopover, required VoidCallback onDraftIdentityChanged, }) { + // Retire the old listener before restoring another scope's text: replacement + // effects can run before the previous effect's cleanup. + final owner = useMemoized(Object.new, [draftKey, draftIdentity]); + final currentOwner = useRef(owner)..value = owner; final lastDraftIdentity = useRef(null); useEffect(() { final identity = '$draftIdentity\u0000$draftKey'; @@ -126,6 +130,7 @@ void _useComposeDraftLifecycle({ for (final e in mentionMap.value.entries) e.key: e.value.pubkey, }; void persistDraft() { + if (!identical(currentOwner.value, owner)) return; final text = controller.text; if (text != lastPersistedText) { // Prune before the atomic snapshot, not in a later editor listener. diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 34a91e7ea3b..37b6e178cdb 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -36,6 +36,7 @@ import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:shared_preferences/shared_preferences.dart'; part 'compose_bar_test/exact_mention_tests.dart'; +part 'compose_bar_test/durable_mention_tests.dart'; final _pngBytes = Uint8List.fromList([ 0x89, @@ -197,6 +198,7 @@ Widget _buildComposeBar({ ValueChanged? onFocusRestorerChanged, AppLifecycleNotifier Function()? appLifecycle, String composeBarKey = 'compose-bar', + String? threadHeadId, VoiceNoteRecorder Function()? voiceNoteRecorderFactory, VoiceNotePlayerController Function()? voiceNotePlayerFactory, }) { @@ -260,6 +262,7 @@ Widget _buildComposeBar({ final composeBar = ComposeBar( key: ValueKey(composeBarKey), channelId: 'channel-1', + threadHeadId: threadHeadId, focusNode: focusNode, onFocusRestorerChanged: onFocusRestorerChanged, onFocusRequested: onFocusRequested, @@ -649,6 +652,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier { void main() { exactMentionTests(); + durableMentionTests(); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { diff --git a/mobile/test/features/channels/compose_bar_test/durable_mention_tests.dart b/mobile/test/features/channels/compose_bar_test/durable_mention_tests.dart new file mode 100644 index 00000000000..42e0c0c819d --- /dev/null +++ b/mobile/test/features/channels/compose_bar_test/durable_mention_tests.dart @@ -0,0 +1,220 @@ +part of '../compose_bar_test.dart'; + +void durableMentionTests() { + testWidgets( + 'exact draft recipients survive restart and failed-send recovery', + (tester) async { + final keys = nostr.Keys.generate(); + final first = 'a' * 64; + final second = 'b' * 64; + var renamed = false; + var fail = true; + List? sent; + Widget build({String? thread}) => _buildComposeBar( + threadHeadId: thread, + uploadService: _testUploadService(keys.nsec), + relayConfig: () => _SwitchableRelayConfigNotifier( + RelayConfig(baseUrl: 'http://localhost:3000', nsec: keys.nsec), + ), + channels: [_makeCurrentChannel()], + members: [ + for (final key in [first, second]) + ChannelMember( + pubkey: key, + displayName: renamed ? 'Renamed' : 'Scout', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + onSend: (_, mentions, {mediaTags = const []}) async { + sent = mentions; + if (fail) throw Exception('relay rejected'); + }, + ); + await tester.pumpWidget(build()); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Scout').first); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '@Scout @'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Scout').last); + await tester.pumpAndSettle(); + final draft = tester + .widget(find.byType(TextField)) + .controller! + .text; + expect(draft, '@Scout @Scout ($second) '); + + // Same mounted composer, different thread and back: old listeners may + // not erase the original persisted bindings while restoring another key. + await tester.pumpWidget(build(thread: 'other')); + await tester.pumpAndSettle(); + await tester.pumpWidget(build()); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + draft, + ); + await tester.pumpWidget(const SizedBox.shrink()); + renamed = true; + await tester.pumpWidget(build()); + await tester.pumpAndSettle(); + await tester.tap(find.text(draft.trim())); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + draft, + ); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, [first, second]); + expect( + tester.widget(find.byType(TextField)).controller!.text, + draft, + ); + + // Recovery must persist the bindings before notifying text listeners. + await tester.pumpWidget(const SizedBox.shrink()); + fail = false; + sent = null; + await tester.pumpWidget(build()); + await tester.pumpAndSettle(); + await tester.tap(find.text(draft.trim())); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, [first, second]); + }, + ); + + for (final fallback in ['unavailable', 'human']) { + testWidgets('saved agent loses provenance across restart: $fallback', ( + tester, + ) async { + final signer = nostr.Keys.generate(); + final agent = 'a' * 64; + final events = >[]; + var eligible = true; + Future mount() async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayConfig: () => _SwitchableRelayConfigNotifier( + RelayConfig(baseUrl: 'https://relay.example', nsec: signer.nsec), + ), + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + relayAgents: [ + if (eligible) + AgentDirectoryEntry( + pubkey: agent, + displayName: 'Helper Bot', + respondTo: 'anyone', + channelIds: const ['shared-channel'], + ), + ], + onSend: (text, keys, {mediaTags = const []}) async { + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + await SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: signer.nsec, + ), + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + )( + channelId: 'channel-1', + content: text, + mentionPubkeys: keys, + mediaTags: mediaTags, + ); + }, + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + session.debugAttachSocketForTest( + _RecordingRelaySocket( + events, + session.debugHandleSocketMessageForTest, + ), + ); + if (!eligible && fallback == 'human') { + container + .read(userCacheProvider.notifier) + .put(UserProfile(pubkey: agent, displayName: 'Former Helper')); + await tester.pumpAndSettle(); + } + } + + await mount(); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + final prefsKey = + 'compose_drafts_v1:https://relay.example:${signer.public}'; + final saved = jsonDecode(_testPrefs.getString(prefsKey)!) as List; + expect(saved.single['mention_keys'], {'Helper Bot': agent}); + expect(saved.single['text'], '@Helper Bot '); + // The parent deliberately persists no agent bit to trust after restart. + expect(saved.single.containsKey('is_agent'), isFalse); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + eligible = false; + await mount(); + await tester.tap(find.text('@Helper Bot')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(events.where((e) => e['kind'] == 9000), isEmpty); + if (fallback == 'human') { + expect( + find.textContaining('Former Helper is not in this channel'), + findsOneWidget, + ); + await tester.tap(find.text('Do nothing')); + await tester.pumpAndSettle(); + final message = events.singleWhere( + (e) => e['kind'] == EventKind.streamMessage, + ); + expect((message['tags'] as List).where((t) => t[0] == 'p'), isEmpty); + expect( + (message['tags'] as List).where((t) => t[0] == 'mention').toList(), + [ + ['mention', agent], + ], + ); + expect(message['sig'], matches(RegExp(r'^[0-9a-f]{128}$'))); + } else { + expect( + events.where((e) => e['kind'] == EventKind.streamMessage), + isEmpty, + ); + expect( + find.textContaining('Saved mention is no longer available'), + findsOneWidget, + ); + expect( + tester.widget(find.byType(TextField)).controller!.text, + '@Helper Bot ', + ); + } + expect(events.where((e) => e['kind'] == 9000), isEmpty); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 250)); + }); + } +}