From 3526023c59af7fc1320ac3017ae5b90df7d043c7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 5 Sep 2026 16:18:08 -0400 Subject: [PATCH 1/2] fix(mobile): persist exact mention bindings with composer drafts Signed-off-by: Logan Johnson --- .../activity/compose_drafts_provider.dart | 17 +++- mobile/lib/features/channels/compose_bar.dart | 1 + .../compose_bar/compose_bar_widget.dart | 23 +++--- .../channels/compose_bar/draft_lifecycle.dart | 75 ++++++++++++++--- mobile/lib/shared/mentions/draft_mention.dart | 29 +++++++ .../compose_drafts_provider_test.dart | 52 ++++++++++++ .../features/channels/compose_bar_test.dart | 2 + .../durable_mention_tests.dart | 80 +++++++++++++++++++ 8 files changed, 256 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/shared/mentions/draft_mention.dart create mode 100644 mobile/test/features/channels/compose_bar_test/durable_mention_tests.dart diff --git a/mobile/lib/features/activity/compose_drafts_provider.dart b/mobile/lib/features/activity/compose_drafts_provider.dart index b5755f19c8d..4a9c3711a39 100644 --- a/mobile/lib/features/activity/compose_drafts_provider.dart +++ b/mobile/lib/features/activity/compose_drafts_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/mentions/draft_mention.dart'; import '../../shared/theme/theme_provider.dart'; const _draftsPrefsKey = 'compose_drafts_v1'; @@ -22,6 +23,7 @@ class ComposeDraft { final String channelId; final String? threadHeadId; final String text; + final Map mentions; final int updatedAt; // unix seconds const ComposeDraft({ @@ -29,6 +31,7 @@ class ComposeDraft { required this.channelId, required this.threadHeadId, required this.text, + this.mentions = const {}, required this.updatedAt, }); @@ -37,6 +40,8 @@ class ComposeDraft { 'channel_id': channelId, if (threadHeadId != null) 'thread_head_id': threadHeadId, 'text': text, + if (mentions.isNotEmpty) + 'mentions': {for (final e in mentions.entries) e.key: e.value.toJson()}, 'updated_at': updatedAt, }; @@ -53,6 +58,7 @@ class ComposeDraft { channelId: channelId, threadHeadId: raw['thread_head_id'] as String?, text: text, + mentions: DraftMention.decode(raw['mentions']), updatedAt: updatedAt is int ? updatedAt : 0, ); } @@ -108,18 +114,24 @@ class ComposeDraftsNotifier extends Notifier> { required String channelId, String? threadHeadId, required String text, + Map mentions = const {}, }) { if (text.trim().isEmpty) { remove(key); return; } final existing = state.where((d) => d.key == key).firstOrNull; - if (existing?.text == text) return; + if (existing?.text == text && + jsonEncode(existing?.mentions.map((k, v) => MapEntry(k, v.toJson()))) == + jsonEncode(mentions.map((k, v) => MapEntry(k, v.toJson())))) { + return; + } final draft = ComposeDraft( key: key, channelId: channelId, threadHeadId: threadHeadId, text: text, + mentions: Map.unmodifiable(mentions), updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); final next = [draft, ...state.where((d) => d.key != key)]; @@ -131,6 +143,9 @@ class ComposeDraftsNotifier extends Notifier> { _persist([...state.where((d) => d.key != key)]); } + ComposeDraft? draftFor(String key) => + state.where((d) => d.key == key).firstOrNull; + String? textFor(String key) => state.where((d) => d.key == key).firstOrNull?.text; diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 71ba39d14ec..930e4e9ac07 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -19,6 +19,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/mentions/mention_bindings.dart'; +import '../../shared/mentions/draft_mention.dart'; import '../../shared/huddle/huddle_session.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 75be337051f..f69f9ec7724 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -40,6 +40,7 @@ class ComposeBar extends HookConsumerWidget { useEffect(() => controller.dispose, [controller]); final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId); final draftRevision = useRef(0); + final mentionMap = useRef({}); final draftIdentity = _composerDraftIdentity(ref); final isComposerExpanded = useState(false); final androidImeTransitionStarted = useState( @@ -94,6 +95,7 @@ class ComposeBar extends HookConsumerWidget { threadHeadId: threadHeadId, draftIdentity: draftIdentity, draftRevision: draftRevision, + mentionMap: mentionMap, attachments: attachments, uploadGeneration: uploadGeneration, activeUploadCancellation: activeUploadCancellation, @@ -219,11 +221,6 @@ class ComposeBar extends HookConsumerWidget { // Map of displayName → selected mention candidate built as the user selects // mentions. Used to pass resolved pubkeys directly to onSend and to attach // selected non-member agents before the message is published. - final mentionMap = useRef({}); - useEffect(() { - mentionMap.value.clear(); - return null; - }, [draftIdentity, draftKey]); // Channel autocomplete state ---------------------------------------------- final channelQuery = useState(null); @@ -413,6 +410,14 @@ class ComposeBar extends HookConsumerWidget { } finally { isModifyingText.value = false; } + _persistComposeDraft( + ref, + controller, + mentionMap.value, + draftKey, + channelId, + threadHeadId, + ); mentionQuery.value = null; } @@ -455,9 +460,9 @@ class ComposeBar extends HookConsumerWidget { void clearComposer() { draftRevision.value += 1; + mentionMap.value.clear(); controller.clear(); attachments.value = []; - mentionMap.value.clear(); mentionQuery.value = null; channelQuery.value = null; attachmentSurface.value = _AttachmentSurface.closed; @@ -620,12 +625,12 @@ class ComposeBar extends HookConsumerWidget { if (context.mounted && queueGeneration == uploadGeneration.value && draftRevision.value == clearedDraftRevision) { - controller.value = draftText; - attachments.value = draftAttachments; - retainedForRetry = true; mentionMap.value ..clear() ..addAll(draftMentions); + controller.value = draftText; + attachments.value = draftAttachments; + retainedForRetry = true; focusNode.requestFocus(); } } finally { diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index b712091b716..f08ee8f6195 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -26,10 +26,10 @@ Future _sendTextOnlyDraft({ draftRevision.value != clearedDraftRevision) { return; } - controller.value = clearedDraftText; mentionMap.value ..clear() ..addAll(clearedDraftMentions); + controller.value = clearedDraftText; focusNode.requestFocus(); } @@ -70,6 +70,7 @@ void _useComposeDraftLifecycle({ required String? threadHeadId, required String draftIdentity, required ObjectRef draftRevision, + required ObjectRef> mentionMap, required ValueNotifier> attachments, required ObjectRef uploadGeneration, required ObjectRef activeUploadCancellation, @@ -81,12 +82,29 @@ void _useComposeDraftLifecycle({ required VoidCallback onDraftIdentityChanged, }) { final lastDraftIdentity = useRef(null); + final lastDraftKey = useRef(null); useEffect(() { final identityChanged = lastDraftIdentity.value != null && - lastDraftIdentity.value != draftIdentity; + (lastDraftIdentity.value != draftIdentity || + lastDraftKey.value != draftKey); lastDraftIdentity.value = draftIdentity; - final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey); + lastDraftKey.value = draftKey; + final saved = ref.read(composeDraftsProvider.notifier).draftFor(draftKey); + void restoreBindings() { + mentionMap.value + ..clear() + ..addAll({ + for (final e + in (saved?.mentions ?? const {}).entries) + e.key: MentionCandidate( + pubkey: e.value.pubkey, + displayName: e.key, + isAgent: e.value.isAgent, + ), + }); + } + if (identityChanged) { draftRevision.value += 1; onDraftIdentityChanged(); @@ -101,9 +119,11 @@ void _useComposeDraftLifecycle({ final staleAttachments = attachments.value; attachments.value = const []; unawaited(_deleteOwnedAttachments(staleAttachments)); - controller.text = saved ?? ''; + restoreBindings(); + controller.text = saved?.text ?? ''; } else if (saved != null && controller.text.isEmpty) { - controller.text = saved; + restoreBindings(); + controller.text = saved.text; } var lastPersistedText = controller.text; @@ -112,17 +132,46 @@ void _useComposeDraftLifecycle({ if (text == lastPersistedText) return; lastPersistedText = text; draftRevision.value += 1; - ref - .read(composeDraftsProvider.notifier) - .save( - key: draftKey, - channelId: channelId, - threadHeadId: threadHeadId, - text: text, - ); + _persistComposeDraft( + ref, + controller, + mentionMap.value, + draftKey, + channelId, + threadHeadId, + ); } controller.addListener(persistDraft); return () => controller.removeListener(persistDraft); }, [controller, draftKey, draftIdentity, onDraftIdentityChanged]); } + +void _persistComposeDraft( + WidgetRef ref, + TextEditingController controller, + Map mentions, + String key, + String channelId, + String? threadHeadId, +) { + final text = controller.text; + ref + .read(composeDraftsProvider.notifier) + .save( + key: key, + channelId: channelId, + threadHeadId: threadHeadId, + text: text, + mentions: { + for (final range in mentionOccurrences( + text.replaceAll('`', ' '), + mentions.keys, + )) + range.label: DraftMention( + pubkey: mentions[range.label]!.pubkey, + isAgent: mentions[range.label]!.isAgent, + ), + }, + ); +} diff --git a/mobile/lib/shared/mentions/draft_mention.dart b/mobile/lib/shared/mentions/draft_mention.dart new file mode 100644 index 00000000000..c7cd48321b7 --- /dev/null +++ b/mobile/lib/shared/mentions/draft_mention.dart @@ -0,0 +1,29 @@ +/// A device-local exact selection. Display classification is not authorization; +/// membership and owner policy must still be checked when sending. +class DraftMention { + final String pubkey; + final bool isAgent; + const DraftMention({required this.pubkey, this.isAgent = false}); + Map toJson() => {'pubkey': pubkey, 'is_agent': isAgent}; + + /// Ignore malformed legacy entries rather than guessing an identity. + static Map decode(Object? raw) { + if (raw is! Map) return const {}; + final result = {}; + for (final entry in raw.entries) { + if (entry.key is! String || entry.value is! Map) continue; + final label = entry.key as String; + final key = entry.value['pubkey']; + if (label.isEmpty || + key is! String || + !RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(key)) { + continue; + } + result[label] = DraftMention( + pubkey: key.toLowerCase(), + isAgent: entry.value['is_agent'] == true, + ); + } + return result; + } +} diff --git a/mobile/test/features/activity/compose_drafts_provider_test.dart b/mobile/test/features/activity/compose_drafts_provider_test.dart index 15899e6597e..1928c8cdc5c 100644 --- a/mobile/test/features/activity/compose_drafts_provider_test.dart +++ b/mobile/test/features/activity/compose_drafts_provider_test.dart @@ -1,4 +1,5 @@ import 'package:buzz/features/activity/compose_drafts_provider.dart'; +import 'package:buzz/shared/mentions/draft_mention.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme_provider.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -81,6 +82,57 @@ void main() { expect(notifier.textFor('ch1'), 'hello there'); }); + test( + 'exact bindings persist atomically with text and binding-only replacements', + () async { + SharedPreferences.setMockInitialValues({}); + final container = await containerWithPrefs(); + final notifier = container.read(composeDraftsProvider.notifier); + final first = 'a' * 64; + final second = 'b' * 64; + notifier.save( + key: 'ch1', + channelId: 'ch1', + text: '@Scout', + mentions: {'Scout': DraftMention(pubkey: first)}, + ); + notifier.save( + key: 'ch1', + channelId: 'ch1', + text: '@Scout', + mentions: {'Scout': DraftMention(pubkey: second, isAgent: true)}, + ); + final restarted = await containerWithPrefs(); + final draft = restarted.read(composeDraftsProvider).single; + expect(draft.text, '@Scout'); + expect(draft.mentions['Scout']!.pubkey, second); + expect(draft.mentions['Scout']!.isAgent, isTrue); + final other = await containerWithPrefs(pubkey: 'different'); + expect(other.read(composeDraftsProvider), isEmpty); + }, + ); + + test('malformed binding data never guesses keys or discards legacy text', () { + final draft = ComposeDraft.fromJson({ + 'key': 'c', + 'channel_id': 'c', + 'text': '@Scout', + 'mentions': { + 'Scout': {'pubkey': 'not-a-key'}, + }, + }); + expect(draft!.text, '@Scout'); + expect(draft.mentions, isEmpty); + expect( + ComposeDraft.fromJson({ + 'key': 'c', + 'channel_id': 'c', + 'text': '@Scout', + })!.mentions, + isEmpty, + ); + }); + test('empty text removes the draft', () async { SharedPreferences.setMockInitialValues({}); final container = await containerWithPrefs(); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 8f05172c966..6ab8cc4ed57 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -31,6 +31,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, @@ -644,6 +645,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..cccdba7080e --- /dev/null +++ b/mobile/test/features/channels/compose_bar_test/durable_mention_tests.dart @@ -0,0 +1,80 @@ +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() => _buildComposeBar( + 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) '); + + 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]); + }, + ); +} From 8a781108507dc4501192b0101185decc1ed6f118 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 5 Sep 2026 16:30:24 -0400 Subject: [PATCH 2/2] fix(mobile): retire draft listeners before restoring another scope Signed-off-by: Logan Johnson --- .../channels/compose_bar/draft_lifecycle.dart | 5 +++++ mobile/test/features/channels/compose_bar_test.dart | 2 ++ .../compose_bar_test/durable_mention_tests.dart | 13 ++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index f08ee8f6195..6ef21f7f130 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); final lastDraftKey = useRef(null); useEffect(() { @@ -128,6 +132,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; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 6ab8cc4ed57..221ada5b75f 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -193,6 +193,7 @@ Widget _buildComposeBar({ ValueChanged? onFocusRestorerChanged, AppLifecycleNotifier Function()? appLifecycle, String composeBarKey = 'compose-bar', + String? threadHeadId, VoiceNoteRecorder Function()? voiceNoteRecorderFactory, VoiceNotePlayerController Function()? voiceNotePlayerFactory, }) { @@ -256,6 +257,7 @@ Widget _buildComposeBar({ final composeBar = ComposeBar( key: ValueKey(composeBarKey), channelId: 'channel-1', + threadHeadId: threadHeadId, focusNode: focusNode, onFocusRestorerChanged: onFocusRestorerChanged, onFocusRequested: onFocusRequested, 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 index cccdba7080e..52c0ce8b63d 100644 --- a/mobile/test/features/channels/compose_bar_test/durable_mention_tests.dart +++ b/mobile/test/features/channels/compose_bar_test/durable_mention_tests.dart @@ -10,7 +10,8 @@ void durableMentionTests() { var renamed = false; var fail = true; List? sent; - Widget build() => _buildComposeBar( + Widget build({String? thread}) => _buildComposeBar( + threadHeadId: thread, uploadService: _testUploadService(keys.nsec), relayConfig: () => _SwitchableRelayConfigNotifier( RelayConfig(baseUrl: 'http://localhost:3000', nsec: keys.nsec), @@ -46,6 +47,16 @@ void durableMentionTests() { .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());