Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion mobile/lib/features/activity/compose_drafts_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,13 +23,15 @@ class ComposeDraft {
final String channelId;
final String? threadHeadId;
final String text;
final Map<String, DraftMention> mentions;
final int updatedAt; // unix seconds

const ComposeDraft({
required this.key,
required this.channelId,
required this.threadHeadId,
required this.text,
this.mentions = const {},
required this.updatedAt,
});

Expand All @@ -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,
};

Expand All @@ -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,
);
}
Expand Down Expand Up @@ -108,18 +114,24 @@ class ComposeDraftsNotifier extends Notifier<List<ComposeDraft>> {
required String channelId,
String? threadHeadId,
required String text,
Map<String, DraftMention> 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)];
Expand All @@ -131,6 +143,9 @@ class ComposeDraftsNotifier extends Notifier<List<ComposeDraft>> {
_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;

Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<String, MentionCandidate>{});
final draftIdentity = _composerDraftIdentity(ref);
final isComposerExpanded = useState(false);
final androidImeTransitionStarted = useState(
Expand Down Expand Up @@ -94,6 +95,7 @@ class ComposeBar extends HookConsumerWidget {
threadHeadId: threadHeadId,
draftIdentity: draftIdentity,
draftRevision: draftRevision,
mentionMap: mentionMap,
attachments: attachments,
uploadGeneration: uploadGeneration,
activeUploadCancellation: activeUploadCancellation,
Expand Down Expand Up @@ -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(<String, MentionCandidate>{});
useEffect(() {
mentionMap.value.clear();
return null;
}, [draftIdentity, draftKey]);

// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
Expand Down Expand Up @@ -413,6 +410,14 @@ class ComposeBar extends HookConsumerWidget {
} finally {
isModifyingText.value = false;
}
_persistComposeDraft(
ref,
controller,
mentionMap.value,
draftKey,
channelId,
threadHeadId,
);
mentionQuery.value = null;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
80 changes: 67 additions & 13 deletions mobile/lib/features/channels/compose_bar/draft_lifecycle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ Future<void> _sendTextOnlyDraft({
draftRevision.value != clearedDraftRevision) {
return;
}
controller.value = clearedDraftText;
mentionMap.value
..clear()
..addAll(clearedDraftMentions);
controller.value = clearedDraftText;
focusNode.requestFocus();
}

Expand Down Expand Up @@ -70,6 +70,7 @@ void _useComposeDraftLifecycle({
required String? threadHeadId,
required String draftIdentity,
required ObjectRef<int> draftRevision,
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
required ValueNotifier<List<_PendingAttachment>> attachments,
required ObjectRef<int> uploadGeneration,
required ObjectRef<UploadCancellationToken?> activeUploadCancellation,
Expand All @@ -80,13 +81,34 @@ 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<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;
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 <String, DraftMention>{}).entries)
e.key: MentionCandidate(
pubkey: e.value.pubkey,
displayName: e.key,
isAgent: e.value.isAgent,
),
});
}

if (identityChanged) {
draftRevision.value += 1;
onDraftIdentityChanged();
Expand All @@ -101,28 +123,60 @@ 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;
void persistDraft() {
if (!identical(currentOwner.value, owner)) return;
final text = controller.text;
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<String, MentionCandidate> 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,
),
},
);
}
29 changes: 29 additions & 0 deletions mobile/lib/shared/mentions/draft_mention.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object> toJson() => {'pubkey': pubkey, 'is_agent': isAgent};

/// Ignore malformed legacy entries rather than guessing an identity.
static Map<String, DraftMention> decode(Object? raw) {
if (raw is! Map) return const {};
final result = <String, DraftMention>{};
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;
}
}
52 changes: 52 additions & 0 deletions mobile/test/features/activity/compose_drafts_provider_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading