mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
Mobile New chat now opens directly with the synced Bestie selected. The first send creates and names the chat; leaving an empty draft creates nothing. New and existing Solo chats can select or clear a workspace from the header popover, with a checkmark for the active choice and a No Workspace option. Workspace and agent controls have separate iOS glass surfaces, Bestie stays at the far right, and Back uses a chevron. The agent sheet fits its list, with scrolling for long lists. Workspace creation stays in the Home New menu. The New menu groups Solo above Team. Android expands across the full control row; iOS keeps its native menu while Search and Bestie merge into New through Liquid Glass, with synchronized icon motion and the residual glass fading away. Empty workspaces use a compact, muted message. Validation: 4,055 mobile tests passed (34 skipped), static analysis passed, and native menu checks passed. Earlier physical-device previews and the iPhone New-menu animation were visually approved before this consolidation. This PR consolidates the controls previously developed in #8891 into the existing draft/first-send implementation. The New menu animations, attachment handling, and durable retries remain in this PR. The combined controls have widget interaction coverage; the combined build has not yet had a new physical-device walkthrough. Current combined controls below are Android Flutter widget renders with fixture data, not device screenshots. Images are GitHub attachments only. | New conversation | Workspace popover | Compact agent sheet | | --- | --- | --- | |  |  |  | Empty workspace widget preview (unchanged):  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
232 lines
8.2 KiB
Dart
232 lines
8.2 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 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]);
|
|
}
|
|
|
|
// 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]);
|
|
}
|