Files
buzz/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
Tom Brow f09956179d fix(mobile): keep the composer focused through iOS inactive spells (#9055)
On iPhone, opening Control Center or the app switcher while the composer
is focused can leave the keyboard up over a collapsed composer. The
orphaned keyboard then stays put while you navigate, for example back
from a thread to its channel, and it clips the composer pill.

### Cause

iOS reports a zero keyboard inset while the app is inactive and restores
the keyboard natively when the app returns. The composer treated any
inset drop as a user dismissal: it collapsed, unfocused and removed its
text field. When the keyboard came back, it was attached to no Flutter
field, so nothing could dismiss it. Flutter's own report of the native
side is
[flutter/flutter#189051](https://github.com/flutter/flutter/issues/189051).

### Change

The composer's keyboard observer ignores metrics while the app is not
resumed. iOS reports `resumed` with the inset still at zero and brings
the keyboard back a few hundred milliseconds later, so when a keyboard
that was up hasn't returned at resume, the composer waits a one-second
grace before treating it as dismissed. Any nonzero inset in that window
cancels the wait. A restored keyboard leaves the composer expanded and
focused; a keyboard that really stayed down still collapses the composer
once the grace passes.

### Validation

- Extended the existing "returns to the compact capsule when the
keyboard drops" widget journey with both inactive cases in the order iOS
reports them: resumed at zero with the keyboard returning about 400 ms
later (composer stays focused), and resumed with the keyboard staying
down (composer collapses after the grace). It fails without the fix and
against the first revision of this PR.
- Full mobile check and test suite pass.
- iOS 27 simulator (iPhone 17 Pro, debug build of the first revision):
focused the composer, typed, switched to Settings and back, opened and
closed Control Center, sent a message, and left the channel. The
composer stayed focused through each interruption, sending kept focus,
and leaving dismissed the keyboard. Limitation: the simulator keeps the
keyboard inset through these interruptions, so it reproduces neither the
original bug nor the resume grace; the on-device inset drop is from the
reporter's screen recording and the Flutter issue.

Human app testing is waived under the mobile testing exception; the
simulator pass above is the automated full-app run.

buzz-review-completed

🤖 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>
2026-11-04 13:54:12 -08:00

1193 lines
42 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

part of '../compose_bar.dart';
class ComposeBar extends HookConsumerWidget {
final String channelId;
final String channelName;
final String? hintText;
final ComposeBarOnSend onSend;
/// Temporarily freezes composition while its destination is being changed.
final bool interactionLocked;
/// Intended recipients before a new Solo channel exists. Suppresses typing
/// and membership writes; other mentions are references until creation.
final List<ChannelMember>? draftMembers;
/// Reports whether a send or upload is in flight.
final ValueChanged<bool>? onSendingChanged;
/// Lets a parent prepare its layout before the editor requests focus.
final VoidCallback? onFocusRequested;
/// Parent-owned if set; otherwise internally created and disposed.
final FocusNode? focusNode;
/// Receives a restorer which becomes a no-op after replacement/unmount.
final ValueChanged<VoidCallback>? onFocusRestorerChanged;
/// Optional thread IDs for thread-scoped typing indicators.
final String? threadHeadId;
final String? rootId;
const ComposeBar({
super.key,
required this.channelId,
this.interactionLocked = false,
this.channelName = '',
this.hintText,
this.draftMembers,
this.onSendingChanged,
this.threadHeadId,
this.rootId,
this.focusNode,
this.onFocusRestorerChanged,
this.onFocusRequested,
required this.onSend,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sendSurfaceKey = useMemoized(GlobalKey.new);
final controller = useMemoized(_MarkdownEditingController.new);
final composerText = useListenableSelector(
controller,
() => controller.text,
);
useEffect(() => controller.dispose, [controller]);
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
final draftRevision = useRef(0);
final draftIdentity = _composerDraftIdentity(ref);
final isComposerExpanded = useState(false);
final androidImeTransitionStarted = useState(
defaultTargetPlatform != TargetPlatform.android,
);
final androidImeFallbackTimer = useRef<Timer?>(null);
final ownedFocusNode = useFocusNode();
final focusNode = this.focusNode ?? ownedFocusNode;
useEffect(
() => () {
androidImeFallbackTimer.value?.cancel();
_dismissComposerKeyboard(focusNode);
},
[focusNode],
);
final isEmojiPickerOpen = useState(false);
final attachmentSurface = useState(_AttachmentSurface.closed);
final iosAttachmentPopover = useMemoized(
_IOSAttachmentPopoverController.new,
);
useEffect(
() =>
() => unawaited(iosAttachmentPopover.dispose()),
[iosAttachmentPopover],
);
final isSending = useState(false);
final showFormatting = useState(false);
final attachments = useState<List<_PendingAttachment>>([]);
_useOwnedAttachmentCleanup(attachments);
final uploadError = useState<String?>(null);
final uploadingCount = useState(0);
final uploadProgress = useState(0.0);
final preparingAttachments = useState(0);
final lockedNow = useRef(interactionLocked);
lockedNow.value = interactionLocked;
bool rejectsAttachment() =>
draftMembers != null &&
(isSending.value || uploadingCount.value > 0 || lockedNow.value);
Future<T> prepare<T>(Future<T> Function() action) =>
_prepareComposerAttachment(context, preparingAttachments, action);
final sending =
isSending.value ||
uploadingCount.value > 0 ||
(draftMembers != null && preparingAttachments.value > 0);
_useComposerBusy(
isSending: isSending,
uploadingCount: uploadingCount,
preparingAttachments: preparingAttachments,
isDraft: draftMembers != null,
onChanged: onSendingChanged,
);
final uploadGeneration = useRef(0);
final activeUploadCancellation = useRef<UploadCancellationToken?>(null);
final voiceNote = _useComposerVoiceNote(
context: context,
ref: ref,
focusNode: focusNode,
isComposerExpanded: isComposerExpanded,
showFormatting: showFormatting,
attachmentSurface: attachmentSurface,
uploadError: uploadError,
draftRevision: draftRevision,
attachments: attachments,
);
final voiceNoteRef = useRef(voiceNote)..value = voiceNote;
final mentionMap = useRef(<String, MentionCandidate>{});
_useComposeDraftLifecycle(
mentionMap: mentionMap,
ref: ref,
controller: controller,
draftKey: draftKey,
channelId: channelId,
threadHeadId: threadHeadId,
draftIdentity: draftIdentity,
draftRevision: draftRevision,
attachments: attachments,
uploadGeneration: uploadGeneration,
activeUploadCancellation: activeUploadCancellation,
uploadingCount: uploadingCount,
isSending: isSending,
attachmentSurface: attachmentSurface,
uploadError: uploadError,
iosAttachmentPopover: iosAttachmentPopover,
onDraftIdentityChanged: voiceNote.onDraftIdentityChanged,
);
final clipboardHasImage = useState(false);
final hasAttachments = attachments.value.isNotEmpty;
final customEmoji = ref.watch(customEmojiListProvider);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final composerExpansionController = useAnimationController(
initialValue: 0,
upperBound: 1.05,
);
void collapseComposer() {
if (!isComposerExpanded.value) return;
showFormatting.value = false;
isComposerExpanded.value = false;
}
useEffect(() {
void collapseWhenUnfocused() {
if (!focusNode.hasFocus && !isEmojiPickerOpen.value) {
collapseComposer();
}
}
focusNode.addListener(collapseWhenUnfocused);
return () => focusNode.removeListener(collapseWhenUnfocused);
}, [focusNode]);
final appView = View.of(context);
useEffect(() {
final observer = _ComposerKeyboardMetricsObserver(
view: appView,
onKeyboardShown: () {
androidImeFallbackTimer.value?.cancel();
androidImeTransitionStarted.value = true;
},
onKeyboardHidden: () {
androidImeFallbackTimer.value?.cancel();
if (defaultTargetPlatform == TargetPlatform.android) {
androidImeTransitionStarted.value = false;
}
voiceNote.onKeyboardHidden();
collapseComposer();
focusNode.unfocus();
},
);
WidgetsBinding.instance.addObserver(observer);
return () {
WidgetsBinding.instance.removeObserver(observer);
observer.dispose();
};
}, [appView, focusNode, voiceNote.isPreparing]);
final resolvedHint =
hintText ??
(channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026');
useEffect(
() {
final target =
isComposerExpanded.value && androidImeTransitionStarted.value
? 1.0
: 0.0;
if (reducedMotion) {
composerExpansionController.value = target;
} else if ((composerExpansionController.value - target).abs() > 0.001) {
composerExpansionController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 220),
bounce: 0.08,
),
composerExpansionController.value,
target,
0,
snapToEnd: true,
),
);
}
return null;
},
[
isComposerExpanded.value,
androidImeTransitionStarted.value,
reducedMotion,
],
);
useEffect(() {
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
var disposed = false;
Future<void> refreshClipboardAvailability() async {
final hasImage = await ref
.read(mediaUploadServiceProvider)
.clipboardHasImage();
if (!disposed && context.mounted) {
clipboardHasImage.value = hasImage;
}
}
void refreshWhenFocused() {
if (focusNode.hasFocus) refreshClipboardAvailability();
}
final lifecycleListener = AppLifecycleListener(
onResume: refreshClipboardAvailability,
);
focusNode.addListener(refreshWhenFocused);
refreshClipboardAvailability();
return () {
disposed = true;
focusNode.removeListener(refreshWhenFocused);
lifecycleListener.dispose();
};
}, [focusNode]);
// Mention state --------------------------------------------------------
final mentionQuery = useState<String?>(null);
final mentionStartIdx = useState(-1);
// Counts chooser openings: a new `@` starts fresh rows and searches.
final mentionOpening = useRef(0);
final shownMentions = useRef<_MentionRows>((
rows: const [],
unavailable: const {},
searchFailed: false,
reserveHeight: false,
searching: false,
));
// 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.
// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
final channelStartIdx = useState(-1);
final channelsAsync = ref.watch(channelsProvider);
_useComposerChannelNames(controller, channelsAsync);
final membersAsync = draftMembers == null
? ref.watch(channelMembersProvider(channelId))
: AsyncData(draftMembers!);
final sessionStatus = ref.watch(relaySessionProvider).status;
final cachedMembers = channelsAsync.asData == null
? const <ChannelMember>[]
: ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(channelId);
final currentPubkey = ref.watch(currentPubkeyProvider);
final userCache = ref.watch(userCacheProvider);
final composeChannel = ref.watch(channelByIdProvider(channelId));
final isDmChannel = composeChannel?.isDm ?? false;
// Preload profiles for channel members, mentionable agents, and their
// owners so @mention suggestions show names ("managed by …" included).
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
final ownedAgents = ref.watch(ownedAgentDirectoryProvider).asData?.value;
final agentMentionLabels = _agentMentionLabels(bindings: mentionMap.value);
final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join(
'\u0000',
);
useEffect(() {
controller.setAgentMentionNames(agentMentionLabels);
return null;
}, [controller, agentMentionLabelsKey]);
useEffect(
() {
final memberList = channelMembersForAutocomplete(
membersAsync: membersAsync,
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
);
final pubkeys = [
...memberList.map((m) => m.pubkey),
...mentionMap.value.values
.where((c) => c.requiresRevalidation && c.pubkey.isNotEmpty)
.map((c) => c.pubkey),
...?relayAgents?.map((a) => a.pubkey),
...?agentOwners?.values,
];
if (pubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(pubkeys);
}
return null;
},
[
draftIdentity,
draftKey,
membersAsync.asData?.value.length,
cachedMembers.length,
relayAgents?.length,
agentOwners?.length,
],
);
// Typing indicator broadcast — throttled to one event per 3 seconds.
final lastTypingSentMs = useRef(0);
final isModifyingText = useRef(false);
final lastObservedEditingValue = useRef(controller.value);
// Insert a selected mention into the text field.
void insertMention(MentionCandidate candidate) {
final name = selectedMentionLabel(candidate.label, candidate.pubkey, {
for (final entry in mentionMap.value.entries)
entry.key: entry.value.pubkey,
});
// Track the resolved candidate so we can pass its pubkey and prepare
// selected non-member agents at send time.
mentionMap.value[name] = candidate;
ref
.read(mentionHistoryProvider.notifier)
.remember(channelId, candidate.pubkey);
final start = mentionStartIdx.value.clamp(0, controller.text.length);
isModifyingText.value = true;
try {
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '@$name ',
);
} finally {
isModifyingText.value = false;
}
mentionQuery.value = null;
}
// Detect @mention query and broadcast typing on text / selection change.
useEffect(() {
lastObservedEditingValue.value = controller.value;
void listener() {
final editingValue = controller.value;
final previousValue = lastObservedEditingValue.value;
lastObservedEditingValue.value = editingValue;
if (isModifyingText.value || editingValue == previousValue) return;
final text = editingValue.text;
final sel = editingValue.selection;
final textChanged = text != previousValue.text;
// Broadcast typing indicator (throttled).
if (draftMembers == null && textChanged && text.isNotEmpty) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - lastTypingSentMs.value > _typingThrottleMs) {
lastTypingSentMs.value = now;
_sendTypingIndicator(
ref,
channelId: channelId,
threadHeadId: threadHeadId,
rootId: rootId,
);
}
}
if (!sel.isValid || !sel.isCollapsed) {
mentionQuery.value = null;
channelQuery.value = null;
return;
}
final cursor = sel.baseOffset;
if (cursor < 1) {
mentionQuery.value = null;
channelQuery.value = null;
return;
}
var open = findMentionQuery(text, cursor);
if (open != null &&
_insideSelectedMention(
text,
open.start,
cursor,
mentionMap.value.keys,
)) {
open = null;
}
// Space after an exact, unique name selects it (section 5).
final previousQuery = mentionQuery.value;
if (open != null &&
previousQuery != null &&
previousQuery.trim().isNotEmpty &&
open.start == mentionStartIdx.value &&
open.query == '$previousQuery ' &&
text.length == previousValue.text.length + 1) {
final chosen = _spaceMention(
ref,
channelId: channelId,
query: previousQuery,
opening: mentionOpening.value,
shown: shownMentions.value,
viewer: currentPubkey,
);
if (chosen != null) {
insertMention(chosen);
return;
}
}
if (open != null) {
if (previousQuery == null || mentionStartIdx.value != open.start) {
mentionOpening.value++;
}
mentionQuery.value = open.query;
mentionStartIdx.value = open.start;
channelQuery.value = null;
} else {
mentionQuery.value = null;
}
// Channel autocomplete detection — only when no @mention is active.
if (mentionQuery.value == null) {
final hashPos = findTrigger(text, cursor, '#');
if (hashPos != null) {
channelQuery.value = text
.substring(hashPos + 1, cursor)
.toLowerCase();
channelStartIdx.value = hashPos;
} else {
channelQuery.value = null;
}
} else {
channelQuery.value = null;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [controller]);
// Ranked mention rows; shown rows hold still while the query stays.
final mentionRows = _useMentionRows(
ref,
channelId: channelId,
query: mentionQuery.value,
opening: mentionOpening.value,
);
shownMentions.value = mentionRows;
final suggestions = mentionRows.rows;
// Resolve owner names for the visible "managed by …" subtitles.
useEffect(() {
final ownerPubkeys = [for (final s in suggestions) ?s.ownerPubkey];
if (ownerPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(ownerPubkeys);
}
return null;
}, [suggestions.length, mentionQuery.value]);
// Filter channels against the query.
final channels = channelsAsync.asData?.value ?? <Channel>[];
final channelSuggestions = filterChannels(channels, channelQuery.value);
// Insert a selected channel into the text field.
void insertChannel(Channel channel) {
final start = channelStartIdx.value.clamp(0, controller.text.length);
isModifyingText.value = true;
try {
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '#${channel.name} ',
);
} finally {
isModifyingText.value = false;
}
channelQuery.value = null;
}
// Insert `@` at the cursor to manually trigger mention mode.
void triggerMention() => _insertTriggerAtCursor(controller, focusNode, '@');
// Insert `#` at the cursor to manually trigger channel mode.
void triggerChannel() => _insertTriggerAtCursor(controller, focusNode, '#');
// Insert a selected emoji at the cursor without replacing the draft.
void insertEmoji(String emoji) {
final text = controller.text;
final selection = controller.selection;
final cursor = selection.isValid
? selection.baseOffset.clamp(0, text.length)
: text.length;
controller.value = TextEditingValue(
text: text.replaceRange(cursor, cursor, emoji),
selection: TextSelection.collapsed(offset: cursor + emoji.length),
);
focusNode.requestFocus();
}
void clearComposer() {
draftRevision.value += 1;
controller.clear();
attachments.value = [];
mentionMap.value.clear();
mentionQuery.value = null;
channelQuery.value = null;
attachmentSurface.value = _AttachmentSurface.closed;
showFormatting.value = false;
uploadError.value = null;
focusNode.requestFocus();
}
void removeAttachment(int id) {
_removePendingAttachment(attachments, draftRevision, id);
}
// Send the message.
Future<void> send() async {
final text = controller.text.trim();
if ((draftMembers != null && preparingAttachments.value > 0) ||
interactionLocked ||
(text.isEmpty && !hasAttachments) ||
isSending.value ||
uploadingCount.value > 0) {
return;
}
final submittedOrigin = ComposerSendOrigin.capture(sendSurfaceKey);
Future<void> sendWithOrigin(
String content,
List<String> mentionPubkeys, {
List<List<String>> mediaTags = const [],
}) => ComposerSendOrigin.run(
submittedOrigin,
() => onSend(content, mentionPubkeys, mediaTags: mediaTags),
);
final submittedDraftRevision = draftRevision.value;
// Resolved before any await: see
// `_reportSendCancelledByCommunitySwitch`.
final messenger = ScaffoldMessenger.maybeOf(context);
// Extract pubkeys for mentions present in the final text.
List<MentionCandidate> selectedMentions;
try {
selectedMentions = _resolveComposerMentions(
text,
mentionMap.value,
buildMentionCandidates(
members: channelMembersForAutocomplete(
membersAsync: membersAsync,
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
),
relayAgents: const [],
sharedChannelIds: const {},
userCache: userCache,
ownerByAgentPubkey: agentOwners ?? const {},
),
buildMentionCandidates(
members: membersAsync.asData?.value ?? const [],
relayAgents: relayAgents ?? const [],
sharedChannelIds: {
for (final c in channels)
if (c.isMember && !c.isArchived) c.id,
},
userCache: userCache,
ownerByAgentPubkey: agentOwners ?? const {},
currentPubkey: currentPubkey,
ownedAgents: ownedAgents,
offerOwnedAgents:
composeChannel != null &&
(composeChannel.isStream || composeChannel.isForum),
// Reuse ordinary search-result classification, not membership as
// permission. Persisted keys/flags themselves prove no role.
searchResults: [
for (final c in mentionMap.value.values)
if (c.requiresRevalidation && userCache[c.pubkey] != null)
userCache[c.pubkey]!,
],
),
);
} on FormatException catch (error) {
messenger?.showFeedbackSnackBar(
SnackBar(content: Text(error.message)),
kind: ToastKind.error,
);
return;
}
final outgoing = _OutgoingMentions(selectedMentions);
// Read before any await. These actions belong to this community and
// refuse to run after a switch, so an Invite answered after a switch
// cannot add people in the new community.
final channelActions = ref.read(channelActionsProvider);
final _NonMemberMentionScan scan;
try {
scan = draftMembers != null
? _NonMemberMentionScan(
channelId: channelId,
outside: selectedMentions
.where(
(candidate) => !draftMembers!.any(
(member) => member.pubkey == candidate.pubkey,
),
)
.toList(),
canAddMembers: false,
isDm: true,
)
: await _scanNonMemberMentions(
ref,
channelId: channelId,
selectedMentions: selectedMentions,
currentPubkey: currentPubkey,
);
} catch (_) {
// Nothing was sent and the draft is untouched, so Retry sends it again.
if (context.mounted) {
showBuzzToast(
context,
'Not sent. Couldn’t check members.',
kind: ToastKind.error,
onRetry: () => unawaited(send()),
);
}
return;
}
// Mentioning anyone outside the channel, person or agent, prompts
// "Invite" / "Do nothing" (portable mention rules, section 7). Nobody
// can be added to a DM, so a DM sends them as references without asking.
if (scan.outside.isNotEmpty && scan.isDm) {
outgoing.resolveOutsideChoice(
_NonMemberMentionChoice.sendWithoutInviting,
scan.outside,
);
} else if (scan.outside.isNotEmpty) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in scan.outside) candidate.label],
canInvite: scan.canAddMembers,
);
if (choice == null) return; // Dismissed — keep the draft, send nothing.
outgoing.resolveOutsideChoice(choice, scan.outside);
}
final queuedAttachments = List<_PendingAttachment>.of(attachments.value);
// An add that was refused doesn't block the message: it is reported and
// the un-added mentions are demoted to reference tags so the send lands.
Future<void> addMentionedNonMembers() => outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
);
isSending.value = true;
try {
if (queuedAttachments.isEmpty) {
if (!context.mounted) return;
await _sendTextOnlyDraft(
context: context,
controller: controller,
mentionMap: mentionMap,
draftRevision: draftRevision,
submittedDraftRevision: submittedDraftRevision,
focusNode: focusNode,
clearComposer: clearComposer,
addMentionedNonMembers: addMentionedNonMembers,
payload: _ComposeDraftPayload.fromDraft(
text: text,
attachments: const [],
customEmoji: customEmoji,
),
outgoing: outgoing,
onSend: sendWithOrigin,
messenger: messenger,
);
return;
}
final draftText = controller.value;
final draftAttachments = List<_PendingAttachment>.of(attachments.value);
final draftMentions = Map<String, MentionCandidate>.of(
mentionMap.value,
);
clearComposer();
final clearedDraftRevision = draftRevision.value;
uploadingCount.value += 1;
uploadProgress.value = 0;
isSending.value = false;
final queueGeneration = uploadGeneration.value;
final cancellation = UploadCancellationToken();
final uploadService = ref.read(mediaUploadServiceProvider);
activeUploadCancellation.value = cancellation;
final delivery = sendWithOrigin;
unawaited(() async {
var retainedForRetry = false;
try {
final uploaded = <BlobDescriptor>[];
for (var index = 0; index < queuedAttachments.length; index++) {
final attachment = queuedAttachments[index];
final descriptor = await _uploadPendingAttachment(
uploadService,
attachment,
onProgress: (progress) {
if (context.mounted) {
uploadProgress.value =
(index + progress) / queuedAttachments.length;
}
},
cancellationToken: cancellation,
);
if (queueGeneration != uploadGeneration.value) return;
uploaded.add(descriptor);
if (context.mounted) {
uploadProgress.value = (index + 1) / queuedAttachments.length;
}
}
final payload = _ComposeDraftPayload.fromDraft(
text: text,
attachments: uploaded,
customEmoji: customEmoji,
);
if (queueGeneration != uploadGeneration.value) return;
await addMentionedNonMembers();
if (queueGeneration != uploadGeneration.value) return;
await delivery(
payload.content,
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} catch (error) {
if (cancellation.isCancelled) return;
if (context.mounted) uploadError.value = _formatUploadError(error);
if (context.mounted &&
queueGeneration == uploadGeneration.value &&
draftRevision.value == clearedDraftRevision) {
attachments.value = draftAttachments;
retainedForRetry = true;
mentionMap.value
..clear()
..addAll(draftMentions);
controller.value = draftText;
focusNode.requestFocus();
}
} finally {
if (!retainedForRetry) {
await _deleteOwnedAttachments(queuedAttachments);
}
if (activeUploadCancellation.value == cancellation) {
activeUploadCancellation.value = null;
}
if (context.mounted && queueGeneration == uploadGeneration.value) {
uploadingCount.value = math.max(0, uploadingCount.value - 1);
}
}
}());
} finally {
if (context.mounted && isSending.value) isSending.value = false;
}
}
final queueAttachment = useCallback((
XFile file,
_PendingAttachmentKind kind, {
bool deleteAfterUse = false,
}) {
if (rejectsAttachment()) {
if (deleteAfterUse) unawaited(_deleteXFile(file));
return;
}
_queueComposerAttachment(
file,
kind,
voiceNoteRef,
attachments,
uploadError,
draftRevision,
deleteAfterUse: deleteAfterUse,
);
}, [voiceNoteRef, draftRevision, uploadError, attachments]);
Future<void> pickThenQueue({
required Future<XFile?> Function() pick,
required _PendingAttachmentKind kind,
}) async {
uploadError.value = null;
try {
final picked = await pick();
if (picked == null || !context.mounted) return;
queueAttachment(picked, kind);
} catch (error) {
if (context.mounted) {
uploadError.value = _formatUploadError(error);
}
}
}
bool queueImages(List<XFile> images, {bool deleteAfterUse = false}) =>
!rejectsAttachment() &&
_queueComposerImages(
images,
voiceNote,
attachments,
uploadError,
draftRevision,
deleteAfterUse,
);
Future<void> retainAndQueueImages(List<XFile> images) =>
prepare(() => _retainAndQueueImages(context, images, queueImages));
final pasteClipboardImage = useCallback(() {
ContextMenuController.removeAny();
unawaited(
prepare(() async {
try {
final image = await ref
.read(mediaUploadServiceProvider)
.readClipboardImage();
if (image != null && context.mounted) {
queueAttachment(image, _PendingAttachmentKind.image);
} else if (context.mounted) {
uploadError.value = 'Unable to read pasted image';
}
} catch (error) {
if (context.mounted) uploadError.value = _formatUploadError(error);
}
}),
);
}, [context, ref, queueAttachment, uploadError]);
final buildContextMenu = useCallback<EditableTextContextMenuBuilder>((
context,
editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS &&
SystemContextMenu.isSupportedByField(editableTextState)) {
return SystemContextMenu.editableText(
editableTextState: editableTextState,
items: [
if (clipboardHasImage.value)
IOSSystemContextMenuItemCustom(
title: 'Paste Image',
onPressed: pasteClipboardImage,
),
...SystemContextMenu.getDefaultItems(editableTextState),
],
);
}
final buttonItems = [...editableTextState.contextMenuButtonItems];
if (defaultTargetPlatform == TargetPlatform.iOS &&
clipboardHasImage.value) {
buttonItems.insert(
0,
ContextMenuButtonItem(
label: 'Paste Image',
onPressed: pasteClipboardImage,
),
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
}, [clipboardHasImage, pasteClipboardImage]);
void uploadPastedImage(KeyboardInsertedContent content) {
final bytes = content.data;
if (bytes == null || bytes.isEmpty) {
uploadError.value = 'Unable to read pasted image';
return;
}
queueAttachment(
XFile.fromData(bytes, name: 'Pasted image'),
_PendingAttachmentKind.image,
);
}
// Wrap (or insert) markdown formatting around the current selection.
void applyFormat(String prefix, [String? suffix]) {
suffix ??= prefix;
final text = controller.text;
final sel = controller.selection;
if (!sel.isValid) return;
isModifyingText.value = true;
try {
if (sel.isCollapsed) {
final offset = sel.baseOffset;
final updated =
'${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: offset + prefix.length,
);
} else {
final selected = text.substring(sel.start, sel.end);
final updated =
'${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: sel.start + prefix.length + selected.length + suffix.length,
);
}
} finally {
isModifyingText.value = false;
}
focusNode.requestFocus();
}
void chooseAttachment(
Future<void> Function() choose, {
String? errorMessage,
}) => _rejectsNonVoiceAttachment(voiceNote, attachments.value, uploadError)
? attachmentSurface.value = _AttachmentSurface.closed
: _chooseComposerAttachment(
context,
attachmentSurface,
uploadError,
() => prepare(choose),
errorMessage: errorMessage,
);
void toggleAttachments() {
attachmentSurface.value = switch (attachmentSurface.value) {
_AttachmentSurface.closed => _AttachmentSurface.menu,
_AttachmentSurface.menu => _AttachmentSurface.closed,
_AttachmentSurface.camera ||
_AttachmentSurface.photos => _AttachmentSurface.menu,
};
}
void handleAttachmentTap(BuildContext triggerContext) {
if (defaultTargetPlatform != TargetPlatform.iOS ||
attachmentSurface.value != _AttachmentSurface.closed) {
toggleAttachments();
return;
}
unawaited(
iosAttachmentPopover
.present(
sourceContext: triggerContext,
onCapture: (image) => retainAndQueueImages([image]),
onChoosePhotos: retainAndQueueImages,
onAllPhotos: () => chooseAttachment(() async {
final photos = await ref
.read(mediaUploadServiceProvider)
.pickGalleryImages();
queueImages(photos);
}, errorMessage: 'Couldn’t open photo library'),
onVideo: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickGalleryVideo,
kind: _PendingAttachmentKind.video,
);
}),
onVoiceNote: voiceNote.start,
onFiles: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickAttachmentFile,
kind: _PendingAttachmentKind.file,
);
}),
)
.then((didPresent) {
if (!didPresent && context.mounted) {
focusNode.unfocus();
toggleAttachments();
}
}),
);
}
void openCamera() {
focusNode.unfocus();
attachmentSurface.value = _AttachmentSurface.camera;
}
final motionDuration = _composerMotionDuration(
reducedMotion,
attachmentSurface.value,
);
final resizeDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 140);
final suggestionOverlayController = useMemoized(
OverlayPortalController.new,
);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) suggestionOverlayController.show();
});
return null;
}, [suggestionOverlayController]);
void expandComposer() => _expandComposer(
context: context,
isExpanded: isComposerExpanded,
attachmentSurface: attachmentSurface,
onFocusRequested: onFocusRequested,
focusNode: focusNode,
view: appView,
androidImeTransitionStarted: androidImeTransitionStarted,
androidImeFallbackTimer: androidImeFallbackTimer,
);
_useComposerFocusRestorer(
onChanged: onFocusRestorerChanged,
isExpanded: isComposerExpanded,
focusNode: focusNode,
expand: expandComposer,
);
final suggestionPanel = _composerSuggestionPanel(
channelSuggestions: channelSuggestions,
mentionSuggestions: suggestions,
unavailableMentions: mentionRows.unavailable,
mentionSearchFailed: mentionRows.searchFailed,
reserveMentionHeight: mentionRows.reserveHeight,
mentionSearching: mentionRows.searching,
onMentionSearchRetry: () {
final query = mentionQuery.value;
if (query != null) ref.invalidate(mentionUserSearchProvider(query));
},
userCache: userCache,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
onChannelSelect: insertChannel,
onMentionSelect: insertMention,
);
Widget buildOverlayPanel(_AttachmentSurface surface) {
return _composerAttachmentPanel(
preparePhotos: prepare,
surface: surface,
suggestionPanel: suggestionPanel,
onBack: () => attachmentSurface.value = _AttachmentSurface.menu,
onCamera: openCamera,
onPhotos: () {
focusNode.unfocus();
attachmentSurface.value = _AttachmentSurface.photos;
},
onVideo: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickGalleryVideo,
kind: _PendingAttachmentKind.video,
);
}),
onVoiceNote: voiceNote.start,
onFiles: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickAttachmentFile,
kind: _PendingAttachmentKind.file,
);
}),
onCapture: (image) async {
attachmentSurface.value = _AttachmentSurface.closed;
await retainAndQueueImages([image]);
},
onPickAllPhotos: () =>
prepare(ref.read(mediaUploadServiceProvider).pickGalleryImages),
onChoosePhotos: (photos) async {
attachmentSurface.value = _AttachmentSurface.closed;
await retainAndQueueImages(photos);
},
onChooseAllPhotos: (photos) async {
attachmentSurface.value = _AttachmentSurface.closed;
queueImages(photos);
},
);
}
// Suggestions and attachments live in the overlay.
final hasPendingUploads = uploadingCount.value > 0;
return _ComposerDockFrame(
expansionAnimation: composerExpansionController,
forceFullWidth: _voiceNoteFullWidth(voiceNote, attachments.value),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_UploadProgressMotion(
visible: hasPendingUploads,
progress: uploadProgress.value,
reducedMotion: reducedMotion,
onCancel: () {
activeUploadCancellation.value?.cancel();
uploadGeneration.value += 1;
uploadingCount.value = 0;
uploadProgress.value = 0;
},
),
_ComposerOverlayPortal(
controller: suggestionOverlayController,
attachmentSurface: attachmentSurface,
reducedMotion: reducedMotion,
buildOverlayPanel: buildOverlayPanel,
onDismissAttachmentSurface: () {
attachmentSurface.value = _AttachmentSurface.closed;
},
child: AbsorbPointer(
absorbing: interactionLocked || (draftMembers != null && sending),
child: _ComposeBarLayout(
surfaceKey: sendSurfaceKey,
voiceNoteRecorder: voiceNote.recorder,
attachments: attachments.value,
onRemoveAttachment: removeAttachment,
uploadError: uploadError.value,
isExpanded: isComposerExpanded.value,
controller: controller,
focusNode: focusNode,
contextMenuBuilder: buildContextMenu,
onContentInserted: uploadPastedImage,
onSend: () => unawaited(send()),
resolvedHint: resolvedHint,
attachmentSurface: attachmentSurface.value,
onAttachmentTap: handleAttachmentTap,
onExpand: expandComposer,
expansionAnimation: composerExpansionController,
formattingOpen: showFormatting.value,
onCloseFormatting: () => showFormatting.value = false,
motionDuration: motionDuration,
resizeDuration: resizeDuration,
onFormat: applyFormat,
onMention: () {
attachmentSurface.value = _AttachmentSurface.closed;
triggerMention();
},
onChannel: () {
attachmentSurface.value = _AttachmentSurface.closed;
triggerChannel();
},
onEmoji: () {
attachmentSurface.value = _AttachmentSurface.closed;
isEmojiPickerOpen.value = true;
_showComposerEmojiPicker(context, insertEmoji, () {
if (!context.mounted) return;
isEmojiPickerOpen.value = false;
focusNode.requestFocus();
});
},
onOpenFormatting: () {
attachmentSurface.value = _AttachmentSurface.closed;
showFormatting.value = true;
},
hasPendingUploads: hasPendingUploads,
canSend: composerText.trim().isNotEmpty || hasAttachments,
isSending: isSending.value || (draftMembers != null && sending),
readOnly:
interactionLocked || (draftMembers != null && sending),
),
),
),
],
),
);
}
}