mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
## Summary This follows up [#8638](https://github.com/block/buzz/pull/8638) and covers the optional review notes from that PR. - **Shared lookup.** A new `channelByIdProvider` returns the channel from the list, or the copy remembered from a fast DM open when the list doesn't have it yet. The mention picker, mention search failure state, compose bar DM check, thread page, and message unread action all use it. Before this change, those surfaces only searched the list, so a DM opened before the list loaded was treated as an unknown channel. For example, the mention picker skipped directory search and never labeled anyone "not in DM". - **Eviction and scope.** Opened-ahead DMs live in a notifier scoped to one relay and identity, and `channelByIdProvider` watches it. Once the list includes a remembered DM, its opened copy is dropped. Remembering also skips a DM the list already has, since the open's background refresh can list it first. A DM hidden or left later, or one from another community or identity, is no longer found through a stale copy. - **Call-site tests.** Search, the profile Message button, and the New message sheet now assert that they open the DM with `waitForChannelList: false`. ## Validation - `just mobile-check` and `just mobile-test` pass. - The journey test "a DM opened before the channel list loads sends mentions" now also checks the outside-person picker in a DM that isn't in the list yet: a failed directory search shows Retry, then the result appears labeled "not in DM". Mutation checks: reverting the lookup in `mentionCandidatesProvider`, `mentionSearchFailedProvider`, or the compose bar fails the test. - New tests "a DM opened ahead of the list is not found after it is left" (including the refresh-before-remember order) and "a DM opened ahead of the list stays in its community" (a mounted lookup across community and identity switches). Mutation checks: removing the eviction, the listed check in `rememberOpened`, the relay or identity watch, or the lookup's reactive watch each fails a test with `Expected: null, Actual: <Instance of 'Channel'>`. - Call-site assertions. Mutation check: flipping any of the three call sites to `waitForChannelList: true` fails its test with `Expected: [false], Actual: [true]`. - Simulator (iPhone 17 Pro, local relay): I started a fresh DM with Sim Tester from the New message sheet. It opened at once, and typing `@Pat` showed Pat Peer from the directory, labeled "not in DM". <img src="https://github.com/user-attachments/assets/0fe68178-00b5-4533-99f8-fe5d03e8ed88" width="300" alt="Fresh DM with Sim Tester: typing @Pat shows Pat Peer labeled not in DM"> buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1119 lines
39 KiB
Dart
1119 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 =
|
|
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?.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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|