Files
buzz/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart
klopez4212 6f5ec4a731 Add mobile voice notes (#7121)
## Summary
- add iOS and Android voice-note recording and preview directly in the
mobile composer
- add waveform playback, scrubbing, speed controls, haptics, and
one-shot playback in chat
- package recordings in a canonical H.264/AAC MP4 envelope on both
platforms so existing relays accept them
- preserve the shared composer interaction and attachment-card treatment
across mobile platforms

Mobile counterpart to #6978.

## Testing
- `just ci`
- `just mobile-check`
- `just mobile-test` (2,026 tests)
- Android debug build compiled, installed, launched, and Voice note
verified in the attachment menu on Pixel 10
- signed iOS device build installed on iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
2026-09-03 08:07:04 +00:00

129 lines
4.5 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;
}
controller.value = clearedDraftText;
mentionMap.value
..clear()
..addAll(clearedDraftMentions);
focusNode.requestFocus();
}
try {
await addMentionedNonMembers();
// Clear before optimistic insertion so the outgoing row and draft never
// appear simultaneously during the send transition. If the user edited
// while membership changes were pending, preserve that newer draft.
if (context.mounted && draftRevision.value == submittedDraftRevision) {
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 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,
}) {
final lastDraftIdentity = useRef<String?>(null);
useEffect(() {
final identityChanged =
lastDraftIdentity.value != null &&
lastDraftIdentity.value != draftIdentity;
lastDraftIdentity.value = draftIdentity;
final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey);
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 ?? '';
} else if (saved != null && controller.text.isEmpty) {
controller.text = saved;
}
var lastPersistedText = controller.text;
void persistDraft() {
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,
);
}
controller.addListener(persistDraft);
return () => controller.removeListener(persistDraft);
}, [controller, draftKey, draftIdentity, onDraftIdentityChanged]);
}