mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
## Summary Refresh mobile conversations with left-aligned grouped bubbles without tails, tighter consecutive-message spacing, distinct sender colors, theme-aware incoming backgrounds, and compact overlapping reaction trays. Attach reply counts beside reactions with up to three participant avatars; keep outgoing mentions and links readable in both themes. Coordinate a 500 ms composer-to-message transition with spring contraction and an anchored avatar. Message ownership follows the signing identity so profile reloads cannot recolor outgoing bubbles. Center channel activities without timestamps or icons, keep names tappable, and show two affected members plus a **See all** sheet. Hide legacy purpose activities, match media corners to bubble groups, and add iOS status-bar scroll-to-top. This is an independent mobile PR; no desktop changes or color picker. ## Validation - Full `just mobile-check` passed; `just mobile-test` passed 3,275 tests with 4 skips, plus 3 unconfigured-gateway checks. - Fixed message-widget lint warnings and a stale notification fixture that blocked analysis. Made the video-controller disposal test wait for lifecycle events instead of fixed delays. Mobile pre-push checks are enabled. - Physical iPhone previews were installed throughout development. The latest bubble, reply-avatar, contrast, and spacing updates were installed and launched on the physical iPhone. Final motion and status-bar behavior still need device visual verification; Android has automated coverage but no physical-device verification. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
1120 lines
39 KiB
Dart
1120 lines
39 KiB
Dart
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 =
|
|
channelsAsync.asData?.value.any((c) => c.id == channelId && c.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?.showSnackBar(SnackBar(content: Text(error.message)));
|
|
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.
|
|
messenger?.showSnackBar(
|
|
SnackBar(
|
|
content: const Text(
|
|
'Message not sent: could not check who is in this channel',
|
|
),
|
|
action: SnackBarAction(
|
|
label: 'Retry',
|
|
onPressed: () => 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: 'Unable to open your 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|