mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
Desktop prefills an empty composer with the agents your latest message in that conversation notified. This brings the same behavior to mobile for channels and threads. ### Behavior - An empty channel or thread composer starts with `@Agent ` for each agent that the viewer's latest loaded message in that conversation notified (its `p` tags). It reads what the view displays: the main timeline in a channel (top-level messages and broadcast replies), and the head plus its direct replies in a thread. Deleted messages don't count, and same-second messages follow the display order. - People are never carried over, and neither are agents that are no longer channel members (agent here means a member with the bot role or an owned agent, the same classification the mention picker uses). - The prefill comes from messages already loaded, with no extra relay reads, so it follows sends from other devices. - It isn't a draft: nothing is saved (no Drafts entry) until the viewer edits it, and it sends no typing indicator. - Removing it lasts until the composer is shown again (leave and reopen) or the viewer sends another message there. Sending without the agent ends the chain. - An untouched prefill is taken back once it's out of date: when the viewer's latest message (even one sent from another device) tags no agent, or its agents have all left the channel. Text the viewer wrote is never cleared. - DMs and Solo chats are unchanged. There's no settings toggle. Also moves the composer's keyboard-metrics observer effect into a `helpers.dart` hook, unchanged, to keep `compose_bar_widget.dart` under the 1200-line limit. ### Validation Added tests: a compose bar widget journey covering prefill from the latest own message (ignoring people and others' messages), no saved draft until edited, send then re-prefill, removal sticking through a member/name refresh and returning on remount, and no prefill for an agent that left. A second test covers taking back an untouched prefill after a newer agent-less message or the agent leaving, while keeping text the viewer wrote. A unit test checks same-second display order. Breaking the draft skip, the removal guard or the take-back fails them. Full mobile suite: all pass except 4 `swipe_reply_tests` "right drag … replies" cases, which fail identically on the base commit (stale after the swipe-left-to-reply change). Simulator (iPhone 17 Pro, debug build, test community, run on the first commit; the take-back follow-up is covered by the widget tests): after mentioning an agent in a channel, the composer refilled with it; sending again refilled it; deleting it kept the composer empty until leaving and reopening the channel, which brought it back; a thread with no agent-tagged reply had no prefill, and after replying with an agent mention the thread composer prefilled it. Demo video is shared internally rather than here, since it shows a real account. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
8.4 KiB
Dart
236 lines
8.4 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?.showFeedbackSnackBar(
|
|
const SnackBar(content: Text('Not sent. Message changed.')),
|
|
kind: ToastKind.error,
|
|
);
|
|
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?.showFeedbackSnackBar(
|
|
SnackBar(content: Text(_composeSendErrorMessage(error))),
|
|
kind: ToastKind.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 ObjectRef<String?> prefillText,
|
|
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) {
|
|
prefillText.value = null;
|
|
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;
|
|
// An untouched agent prefill is not a draft.
|
|
if (text == prefillText.value) return;
|
|
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]);
|
|
}
|
|
|
|
// Report aggregate work independently of rebuilds, including sub-frame failures.
|
|
void _useComposerBusy({
|
|
required ValueNotifier<bool> isSending,
|
|
required ValueNotifier<int> uploadingCount,
|
|
required ValueNotifier<int> preparingAttachments,
|
|
required bool isDraft,
|
|
required ValueChanged<bool>? onChanged,
|
|
}) {
|
|
final busyCallback = useRef(onChanged);
|
|
busyCallback.value = onChanged;
|
|
useEffect(() {
|
|
var active = true;
|
|
void reportCurrent() {
|
|
if (!active) return;
|
|
busyCallback.value?.call(
|
|
isSending.value ||
|
|
uploadingCount.value > 0 ||
|
|
(isDraft && preparingAttachments.value > 0),
|
|
);
|
|
}
|
|
|
|
void reportBusy() {
|
|
// Scope changes can reset the composer from a build-time effect.
|
|
// Ordinary operation completions still release the parent immediately.
|
|
if (SchedulerBinding.instance.schedulerPhase ==
|
|
SchedulerPhase.persistentCallbacks) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => reportCurrent());
|
|
} else {
|
|
reportCurrent();
|
|
}
|
|
}
|
|
|
|
final sources = [isSending, uploadingCount, preparingAttachments];
|
|
for (final source in sources) {
|
|
source.addListener(reportBusy);
|
|
}
|
|
return () {
|
|
active = false;
|
|
for (final source in sources) {
|
|
source.removeListener(reportBusy);
|
|
}
|
|
// Disposal can happen during a parent build. Release after that frame.
|
|
final callback = busyCallback.value;
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => callback?.call(false),
|
|
);
|
|
};
|
|
}, [isSending, uploadingCount, preparingAttachments, isDraft]);
|
|
}
|