mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
🤖 ## Summary The mobile app's @mention chooser now follows the same rules as the desktop app (buzz-app). The same typed text finds the same people in the same order on both apps, and sending works the same way. - **Same matching and order.** Mobile uses the shared mention rules and runs their shared test cases. A multi-word search such as `@Mary J` still searches the community directory, so it can find "Mary Jane" even when she is not in the channel. - **DMs can mention people outside the DM.** The chooser marks them "not in DM". Nobody can be added to a DM, so the message sends without a prompt. Outside people go out as references, so they are not notified. This matches buzz-app #510. - **Channels still ask.** In a stream or forum, mentioning someone outside the channel asks "Invite" or "Do nothing" before the message sends. A channel with no type tag counts as a stream. - **Sessions do not search the directory.** Only session members appear. - **A failed directory search stays visible.** The chooser shows the error and a Retry button. Before, it closed. - **Search results stay in their community.** If you switch communities while a search runs, its people and its error do not show in the new community. The search starts again there. - **A failed send check keeps the draft.** Before a message with a mention sends, the app checks who is in the channel. If that check fails, the message stays in the compose bar and a notice offers Retry. Before, nothing was sent and nothing was shown. - **Invite stays in its community.** If you switch communities while the "Invite" question is open, Invite adds nobody. ### Screenshots The real compose bar widget, rendered by Flutter's test renderer with the app's fonts and theme. The data is sample data. These are not device captures. | DM: outside person is "not in DM" | Stream: outside person is "not in channel" | |---|---| | <img width="320" alt="DM chooser" src="https://github.com/user-attachments/assets/0e884518-2386-4cd1-9f19-16661aaa194e" /> | <img width="320" alt="Stream chooser" src="https://github.com/user-attachments/assets/5395e4e1-032e-459b-98c6-0126c060c4d9" /> | | Stream: send asks first | Session: members only | Search failed: Retry | |---|---|---| | <img width="260" alt="Stream ask on send" src="https://github.com/user-attachments/assets/2b988257-3c6a-445d-acfa-fca47b9752fe" /> | <img width="260" alt="Session chooser" src="https://github.com/user-attachments/assets/e193551f-f5bf-41d9-aa29-bc8665c643f9" /> | <img width="260" alt="Search failed with Retry" src="https://github.com/user-attachments/assets/e8c4b9bb-067e-4fe9-9fdd-4e5691bc66be" /> | ### Details - `mobile/lib/shared/mentions/mention_rules.dart` holds the rules: query syntax, match tiers, sort order, Space selection of an exact unique name, stable rows while results arrive, and `p` and `mention` tags. A `p` tag notifies the person. A `mention` tag is a reference and does not notify. - `mobile/test/shared/mentions/mention-rules.fixtures.json` is an exact copy of the buzz-app fixture file. `mention_rules_test.dart` runs every case. - Known difference: desktop gives the directory to a channel with an unknown explicit type (not stream, forum, DM or session). Mobile does not. No such type exists today. ### Related issue None found. Desktop counterpart: block/buzz-app#510. #7513 also edits `send_message_provider.dart` and may conflict. ### Testing `just mobile-install mobile-check mobile-test` passes: analyze clean, 2998 tests pass on `49c44653` (before rebase). The pre-push hook ran `just mobile-check && just mobile-test` again on the rebased head `de1ca080` and passed. Each review fix was mutation-checked: removing the fix makes its new test fail. --------- Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
182 lines
6.6 KiB
Dart
182 lines
6.6 KiB
Dart
part of '../compose_bar.dart';
|
|
|
|
Future<void> _sendTextOnlyDraft({
|
|
required BuildContext context,
|
|
required _MarkdownEditingController controller,
|
|
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
|
|
required ObjectRef<int> draftRevision,
|
|
required int submittedDraftRevision,
|
|
required FocusNode focusNode,
|
|
required VoidCallback clearComposer,
|
|
required Future<void> Function() addMentionedNonMembers,
|
|
required _ComposeDraftPayload payload,
|
|
required _OutgoingMentions outgoing,
|
|
required ComposeBarOnSend onSend,
|
|
required ScaffoldMessengerState? messenger,
|
|
}) async {
|
|
TextEditingValue? clearedDraftText;
|
|
Map<String, MentionCandidate>? clearedDraftMentions;
|
|
int? clearedDraftRevision;
|
|
|
|
void restoreClearedDraft() {
|
|
if (!context.mounted ||
|
|
clearedDraftText == null ||
|
|
clearedDraftMentions == null ||
|
|
clearedDraftRevision == null ||
|
|
draftRevision.value != clearedDraftRevision) {
|
|
return;
|
|
}
|
|
mentionMap.value
|
|
..clear()
|
|
..addAll(clearedDraftMentions);
|
|
controller.value = clearedDraftText;
|
|
focusNode.requestFocus();
|
|
}
|
|
|
|
try {
|
|
await addMentionedNonMembers();
|
|
// A draft that changed while adding ran is not the draft the user sent.
|
|
// Keep it and send nothing.
|
|
if (context.mounted && draftRevision.value != submittedDraftRevision) {
|
|
messenger?.showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Not sent: the message changed while adding people.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
// Clear before optimistic insertion so the outgoing row and draft never
|
|
// appear simultaneously during the send transition.
|
|
if (context.mounted) {
|
|
clearedDraftText = controller.value;
|
|
clearedDraftMentions = Map<String, MentionCandidate>.of(mentionMap.value);
|
|
clearComposer();
|
|
clearedDraftRevision = draftRevision.value;
|
|
}
|
|
await onSend(
|
|
payload.content,
|
|
outgoing.pubkeys,
|
|
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
|
|
);
|
|
} on StateError {
|
|
restoreClearedDraft();
|
|
_reportSendCancelledByCommunitySwitch(messenger);
|
|
} catch (error) {
|
|
// The caller runs unawaited, so surface publish failures and restore the
|
|
// sent draft unless the user has already started a new one.
|
|
restoreClearedDraft();
|
|
messenger?.showSnackBar(
|
|
SnackBar(content: Text(_composeSendErrorMessage(error))),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _useComposeDraftLifecycle({
|
|
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
|
|
required WidgetRef ref,
|
|
required _MarkdownEditingController controller,
|
|
required String draftKey,
|
|
required String channelId,
|
|
required String? threadHeadId,
|
|
required String draftIdentity,
|
|
required ObjectRef<int> draftRevision,
|
|
required ValueNotifier<List<_PendingAttachment>> attachments,
|
|
required ObjectRef<int> uploadGeneration,
|
|
required ObjectRef<UploadCancellationToken?> activeUploadCancellation,
|
|
required ValueNotifier<int> uploadingCount,
|
|
required ValueNotifier<bool> isSending,
|
|
required ValueNotifier<_AttachmentSurface> attachmentSurface,
|
|
required ValueNotifier<String?> uploadError,
|
|
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);
|
|
useEffect(() {
|
|
final identity = '$draftIdentity\u0000$draftKey';
|
|
final shouldHydrate = lastDraftIdentity.value != identity;
|
|
final identityChanged =
|
|
lastDraftIdentity.value != null && lastDraftIdentity.value != identity;
|
|
lastDraftIdentity.value = identity;
|
|
final saved = ref.read(composeDraftsProvider.notifier).draftFor(draftKey);
|
|
if (shouldHydrate) {
|
|
mentionMap.value
|
|
..clear()
|
|
..addAll({
|
|
for (final entry
|
|
in (saved?.mentionKeys ?? <String, String>{}).entries)
|
|
entry.key: MentionCandidate(
|
|
pubkey: entry.value,
|
|
displayName: entry.key,
|
|
requiresRevalidation: true,
|
|
),
|
|
});
|
|
}
|
|
if (identityChanged) {
|
|
draftRevision.value += 1;
|
|
onDraftIdentityChanged();
|
|
uploadGeneration.value += 1;
|
|
activeUploadCancellation.value?.cancel();
|
|
activeUploadCancellation.value = null;
|
|
uploadingCount.value = 0;
|
|
isSending.value = false;
|
|
attachmentSurface.value = _AttachmentSurface.closed;
|
|
uploadError.value = null;
|
|
unawaited(iosAttachmentPopover.dispose());
|
|
final staleAttachments = attachments.value;
|
|
attachments.value = const [];
|
|
unawaited(_deleteOwnedAttachments(staleAttachments));
|
|
controller.text = saved?.text ?? '';
|
|
} else if (saved != null && controller.text.isEmpty) {
|
|
controller.text = saved.text;
|
|
}
|
|
|
|
var lastPersistedText = controller.text;
|
|
var lastBindings = <String, String>{
|
|
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.
|
|
// Code formatting hides a binding without deleting its literal.
|
|
final retained = mentionOccurrences(
|
|
text.replaceAll('`', ' '),
|
|
mentionMap.value.keys,
|
|
).map((range) => range.label).toSet();
|
|
// Whole-record taint has no literal to prune. Removing every @ is a
|
|
// safe reset; unrelated edits must not turn lost keys into name lookup.
|
|
mentionMap.value.removeWhere(
|
|
(label, _) =>
|
|
label.isEmpty ? !text.contains('@') : !retained.contains(label),
|
|
);
|
|
}
|
|
final bindings = <String, String>{
|
|
for (final e in mentionMap.value.entries) e.key: e.value.pubkey,
|
|
};
|
|
if (text == lastPersistedText && mapEquals(bindings, lastBindings)) {
|
|
return;
|
|
}
|
|
lastBindings = bindings;
|
|
lastPersistedText = text;
|
|
draftRevision.value += 1;
|
|
ref
|
|
.read(composeDraftsProvider.notifier)
|
|
.save(
|
|
key: draftKey,
|
|
channelId: channelId,
|
|
threadHeadId: threadHeadId,
|
|
text: text,
|
|
mentionKeys: bindings,
|
|
);
|
|
}
|
|
|
|
controller.addListener(persistDraft);
|
|
return () => controller.removeListener(persistDraft);
|
|
}, [controller, draftKey, draftIdentity, onDraftIdentityChanged]);
|
|
}
|