Files
buzz/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
klopez4212 cfcaef4e9f Update mobile toast animations (#8855)
Updates mobile toast animations and styling.



| Sign in | Retry | Copied |
| --- | --- | --- |
| ![Reconnect to
Builderlab](https://github.com/user-attachments/assets/5298b507-df5b-49da-a021-03f337e997fa)
| ![Save error with
Retry](https://github.com/user-attachments/assets/492c8929-4f91-4342-943b-a72b96547992)
| ![Copied
confirmation](https://github.com/user-attachments/assets/864fd629-7414-415e-81ef-5f7d3d5333a4)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-31 07:50:00 -07:00

1119 lines
39 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;
/// 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.channelName = '',
this.hintText,
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 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);
}, [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,
));
// 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 = ref.watch(channelMembersProvider(channelId));
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 isDmChannel =
ref.watch(channelByIdProvider(channelId))?.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 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 (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 ((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,
// 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 = 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,
}) => _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}) =>
_queueComposerImages(
images,
voiceNote,
attachments,
uploadError,
draftRevision,
deleteAfterUse,
);
Future<void> retainAndQueueImages(List<XFile> images) =>
_retainAndQueueImages(context, images, queueImages);
final pasteClipboardImage = useCallback(() {
ContextMenuController.removeAny();
unawaited(() 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,
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,
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(
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: 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: _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,
),
),
],
),
);
}
}