diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart index bbe9935..c976684 100644 --- a/lib/controllers/attachment.dart +++ b/lib/controllers/attachment.dart @@ -7,7 +7,9 @@ import "package:nexus/models/content/message.dart"; import "package:nexus/models/composer_params.dart"; import "package:path/path.dart"; -class AttachmentController(final ComposerParams composer) +typedef AttachmentParams = ({ComposerParams composer, int? editingRowId}); + +class AttachmentController(final AttachmentParams params) extends Notifier<(String, MessageContent?)?> { @override Null build() => null; @@ -19,7 +21,7 @@ class AttachmentController(final ComposerParams composer) final isEncrypted = ref.read( RoomsController.provider.select( (value) => - value[composer.roomId] + value[params.composer.roomId] ?.state[EventType.encryption.type] ?.isNotEmpty == true, @@ -35,10 +37,10 @@ class AttachmentController(final ComposerParams composer) void set((String, MessageContent?)? attachment) => state = attachment; - static final provider = NotifierProvider.family - .autoDispose< + static final provider = + NotifierProvider.family< AttachmentController, (String, MessageContent?)?, - ComposerParams + AttachmentParams >(AttachmentController.new); } diff --git a/lib/controllers/bot_command_draft.dart b/lib/controllers/bot_command_draft.dart index e0d67d5..44ee9f5 100644 --- a/lib/controllers/bot_command_draft.dart +++ b/lib/controllers/bot_command_draft.dart @@ -95,10 +95,14 @@ class BotCommandDraftController(final ComposerParams composer) } } + void set(BotCommandDraft? draft) => state = draft; + void clear() => state = null; - static final provider = NotifierProvider.autoDispose - .family( - BotCommandDraftController.new, - ); + static final provider = + NotifierProvider.family< + BotCommandDraftController, + BotCommandDraft?, + ComposerParams + >(BotCommandDraftController.new); } diff --git a/lib/controllers/composer_draft.dart b/lib/controllers/composer_draft.dart new file mode 100644 index 0000000..dc99c8e --- /dev/null +++ b/lib/controllers/composer_draft.dart @@ -0,0 +1,93 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/attachment.dart"; +import "package:nexus/controllers/bot_command_draft.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/get_filename.dart"; +import "package:nexus/helpers/extensions/is_media.dart"; +import "package:nexus/helpers/extensions/with_latest_edit.dart"; +import "package:nexus/models/composer_draft.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/relation_type.dart"; + +class ComposerDraftController(final ComposerParams composer) + extends Notifier { + @override + ComposerDraft build() => const .new(); + + BotCommandDraftController get _botCommand => + ref.read(BotCommandDraftController.provider(composer).notifier); + + void update(ComposerDraft Function(ComposerDraft draft) update) => + state = update(state); + + void relate(int rowId, RelationType type) { + if (type == .reply) { + _restoreBeforeEdit(); + state = state.copyWith(relatedEvent: rowId, relationType: .reply); + return; + } + + // Keeps the changes made so far + if (state.isEditing && state.relatedEvent == rowId) return; + + final event = ref + .read(RoomsController.provider)[composer.roomId] + ?.withLatestEdit(rowId); + final content = event?.content; + final text = + event?.localContent?.editSource ?? + switch (content) { + TextMessageContent(:final body) || + ImageMessageContent(:final body) || + VideoMessageContent(:final body) || + AudioMessageContent(:final body) || + FileMessageContent(:final body) => body, + _ => "", + }; + + state = ComposerDraft( + text: text, + relatedEvent: rowId, + relationType: .edit, + beforeEdit: + state.beforeEdit ?? + ( + draft: state, + botCommand: ref.read(BotCommandDraftController.provider(composer)), + ), + ); + ref + .read( + AttachmentController.provider(( + composer: composer, + editingRowId: rowId, + )).notifier, + ) + .set( + content is MessageContent && content.isMedia + ? (content.filename ?? content.body ?? "Attachment", content) + : null, + ); + _botCommand.clear(); + } + + void dismiss() { + if (state.isEditing) return _restoreBeforeEdit(); + state = state.copyWith(relatedEvent: null); + } + + void _restoreBeforeEdit() { + if (state.beforeEdit case (:final draft, :final botCommand)) { + state = draft; + _botCommand.set(botCommand); + } + } + + static final provider = + NotifierProvider.family< + ComposerDraftController, + ComposerDraft, + ComposerParams + >(ComposerDraftController.new); +} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index c908dd2..773f30d 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -404,7 +404,10 @@ class RoomChatController(final ChatParams info) roomId: info.roomId, threadRoot: info.threadRoot, ); - final attachmentProvider = AttachmentController.provider(composer); + final attachmentProvider = AttachmentController.provider(( + composer: composer, + editingRowId: relationType == .edit ? relatedEventRowId : null, + )); final attachment = ref.read(attachmentProvider)?.$2; ref.invalidate(attachmentProvider); @@ -418,11 +421,13 @@ class RoomChatController(final ChatParams info) ? latestContent : null); - // Replaces each formatted tag, like "@id#name#", with its ID + // Replaces each formatted tag, like ":id#name#", with its ID final taggedMessage = tags.fold( text, (message, tag) => message.replaceAllMapped( - RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"), + RegExp( + "${RegExp.escape(tag.triggerCharacter)}(${RegExp.escape(tag.id)})(#[^#]*#)?", + ), (match) => match.group(1)!, ), ); diff --git a/lib/helpers/extensions/with_latest_edit.dart b/lib/helpers/extensions/with_latest_edit.dart new file mode 100644 index 0000000..6b07313 --- /dev/null +++ b/lib/helpers/extensions/with_latest_edit.dart @@ -0,0 +1,20 @@ +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +extension WithLatestEdit on Room { + Event? withLatestEdit(int rowId) { + final event = events[rowId]; + if (event == null) return null; + + final edited = event.lastEditRowId == 0 + ? null + : events[event.lastEditRowId]; + + return edited == null + ? event + : event.copyWith( + content: edited.content, + localContent: edited.localContent, + ); + } +} diff --git a/lib/helpers/hooks/chat_composer.dart b/lib/helpers/hooks/chat_composer.dart index 1759e25..71262c8 100644 --- a/lib/helpers/hooks/chat_composer.dart +++ b/lib/helpers/hooks/chat_composer.dart @@ -1,40 +1,50 @@ import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/composer_draft.dart"; +import "package:nexus/models/composer_params.dart"; import "package:nexus/models/relation_type.dart"; final class ChatComposer({ - required final ValueNotifier relatedEvent, - required final ValueNotifier relationType, + required final int? relatedEvent, + required final RelationType relationType, + required final ComposerDraftController notifier, required final FocusNode node, }) { - factory use() { - final relatedEvent = useState(null); - final relationType = useState(RelationType.reply); + factory use(WidgetRef ref, ComposerParams composer) { + final provider = ComposerDraftController.provider(composer); final node = useFocusNode( - onKeyEvent: (_, event) { + onKeyEvent: (node, event) { if (event is KeyDownEvent && event.logicalKey == .escape) { - relatedEvent.value = null; + ref.read(provider.notifier).dismiss(); + // Ending an edit replaces the text field, losing its focus + WidgetsBinding.instance.addPostFrameCallback( + (_) => node.requestFocus(), + ); return .handled; } return .ignored; }, ); + final (relatedEvent, relationType) = ref.watch( + provider.select((draft) => (draft.relatedEvent, draft.relationType)), + ); return .new( relatedEvent: relatedEvent, relationType: relationType, + notifier: ref.watch(provider.notifier), node: node, ); } - int? get repliedEventRowId => - relationType.value == .reply ? relatedEvent.value : null; + int? get repliedEventRowId => relationType == .reply ? relatedEvent : null; void relate(int rowId, RelationType type) { - relatedEvent.value = rowId; - relationType.value = type; - node.requestFocus(); + notifier.relate(rowId, type); + // Starting an edit replaces the text field, losing its focus + WidgetsBinding.instance.addPostFrameCallback((_) => node.requestFocus()); } } diff --git a/lib/models/composer_draft.dart b/lib/models/composer_draft.dart new file mode 100644 index 0000000..9f8748f --- /dev/null +++ b/lib/models/composer_draft.dart @@ -0,0 +1,29 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:fluttertagger/fluttertagger.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/relation_type.dart"; + +part "composer_draft.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const ComposerDraft({ + // Formatted like ":id#name#", see the composer's tagTextFormatter + final String text = "", + final IList tags = const .empty(), + final int? relatedEvent, + final RelationType relationType = .reply, + + // Replace the text field until they are sent or removed + final (String, PackImage)? sticker, + final PollStart? poll, + final LocationMessageContent? location, + + // The draft from before an edit, restored once it is sent or cancelled + final ({ComposerDraft draft, BotCommandDraft? botCommand})? beforeEdit, +}) with _$ComposerDraft { + bool get isEditing => beforeEdit != null; +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 940222a..7bb7c0b 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -6,16 +6,16 @@ import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/controllers/bot_argument_suggestions.dart"; import "package:nexus/controllers/bot_command_draft.dart"; import "package:nexus/controllers/bot_commands.dart"; +import "package:nexus/controllers/composer_draft.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/show_error_snack_bar.dart"; +import "package:nexus/helpers/extensions/with_latest_edit.dart"; import "package:nexus/models/bot_command.dart"; import "package:nexus/models/bot_command_draft.dart"; import "package:nexus/models/composer_params.dart"; import "package:nexus/widgets/composer/bot_command_form.dart"; import "package:nexus/widgets/composer/bot_command_panel.dart"; import "package:nexus/widgets/emoji_picker.dart"; -import "package:nexus/helpers/extensions/get_filename.dart"; -import "package:nexus/helpers/extensions/is_media.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -38,7 +38,6 @@ import "package:nexus/widgets/sticker_picker.dart"; import "package:nexus/widgets/create_poll_sheet.dart"; import "package:nexus/widgets/location_picker_sheet.dart"; import "package:nexus/models/location.dart"; -import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/paste_files_action.dart"; import "package:pasteboard/pasteboard.dart"; @@ -50,9 +49,6 @@ import "package:nexus/main.dart"; class const Composer( final String roomId, { - required final int? relatedEvent, - required final RelationType relationType, - required final VoidCallback onDismiss, required final Future Function( String text, { required bool shouldMention, @@ -83,7 +79,76 @@ class const Composer( @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final controller = useMemoized(FlutterTaggerController.new); + final ComposerParams composer = (roomId: roomId, threadRoot: threadRoot); + final draftProvider = ComposerDraftController.provider(composer); + final draftNotifier = ref.watch(draftProvider.notifier); + final ( + :relatedEventRowId, + :relationType, + :editingEvent, + :sticker, + :poll, + :location, + ) = ref.watch( + draftProvider.select( + (draft) => ( + relatedEventRowId: draft.relatedEvent, + relationType: draft.relationType, + editingEvent: draft.isEditing ? draft.relatedEvent : null, + sticker: draft.sticker, + poll: draft.poll, + location: draft.location, + ), + ), + ); + final isEditing = editingEvent != null; + + final botCommandProvider = BotCommandDraftController.provider(composer); + final attachmentProvider = AttachmentController.provider(( + composer: composer, + editingRowId: editingEvent, + )); + final botCommand = ref.watch(botCommandProvider); + + final hasReplacement = + sticker != null || + poll != null || + location != null || + botCommand != null; + + final controller = useMemoized(() { + final draft = ref.read(draftProvider); + final ids = IMap.fromEntries( + draft.tags + .map((tag) => MapEntry("${tag.triggerCharacter}${tag.id}#", tag.id)) + .sortedBy((entry) => -entry.key.length), + ); + + return FlutterTaggerController(text: draft.text)..formatTags( + // Matches tags formatted by tagTextFormatter, like ":id#name#" + pattern: RegExp( + ids.isEmpty + ? "(?!)" + : "((?:${ids.keys.map(RegExp.escape).join("|")})[^#]*#)", + ), + parser: (formatted) { + final prefix = ids.keys.firstWhere(formatted.startsWith); + return [ + ids[prefix]!, + formatted.substring(prefix.length, formatted.length - 1), + ]; + }, + ); + }, [composer, editingEvent, hasReplacement]); + useEffect( + // After the frame, as FlutterTagger and TextField still use it until then + () => + () => WidgetsBinding.instance.addPostFrameCallback( + (_) => controller.dispose(), + ), + [controller], + ); + final triggerCharacter = useState(""); final shouldMention = useState(true); final query = useState(""); @@ -103,151 +168,59 @@ class const Composer( .new(eventType: .pollStart, roomId: roomId), ), ); - final relatedEvent = this.relatedEvent == null + final relatedEvent = relatedEventRowId == null ? null : ref.watch( - RoomsController.provider.select((rooms) { - final event = rooms[roomId]?.events[this.relatedEvent!]; - if (event == null) return null; - - final edited = event.lastEditRowId == 0 - ? null - : rooms[roomId]?.events[event.lastEditRowId]; - - return edited == null - ? event - : event.copyWith( - content: edited.content, - localContent: edited.localContent, - ); - }), + RoomsController.provider.select( + (rooms) => rooms[roomId]?.withLatestEdit(relatedEventRowId), + ), ); - final textBefore = useState(null); - // Wrapped, since no attachment is a draft too - final attachmentBefore = useState<((String, MessageContent?)?,)?>(null); - - /// The sticker to send, which replaces the text field until it is sent or - /// removed, as stickers can't have a caption - final sticker = useState<(String, PackImage)?>(null); - - final poll = useState(null); - final location = useState(null); - final ComposerParams composer = (roomId: roomId, threadRoot: threadRoot); - final botCommandProvider = BotCommandDraftController.provider(composer); - final attachmentProvider = AttachmentController.provider(composer); - final botCommand = ref.watch(botCommandProvider); - useEffect(() { - sticker.value = null; - poll.value = null; - location.value = null; - return null; - }, [roomId]); - - final hasReplacement = - sticker.value != null || - poll.value != null || - location.value != null || - botCommand != null; - - // The relation type stays as edit after cancelling an edit - final isEditing = relationType == .edit && relatedEvent != null; - - useEffect(() { - if (relationType == .edit && relatedEvent != null) { - textBefore.value ??= controller.text; - attachmentBefore.value ??= (ref.read(attachmentProvider),); - sticker.value = null; - poll.value = null; - location.value = null; - Future.microtask(ref.read(botCommandProvider.notifier).clear); - - final content = relatedEvent.content; - final text = - relatedEvent.localContent?.editSource ?? - switch (content) { - TextMessageContent(:final body) || - ImageMessageContent(:final body) || - VideoMessageContent(:final body) || - AudioMessageContent(:final body) || - FileMessageContent(:final body) => body, - _ => "", - }; - - controller.value = .new( - text: text, - selection: .collapsed(offset: text.length), - ); - - Future.microtask( - () => ref - .read(attachmentProvider.notifier) - .set( - content is MessageContent && content.isMedia - ? ( - content.filename ?? content.body ?? "Attachment", - content, - ) - : null, - ), - ); - } else { - if (textBefore.value case final draft?) { - controller.value = .new( - text: draft, - selection: .collapsed(offset: draft.length), - ); - textBefore.value = null; - } - - if (attachmentBefore.value case (final draft,)) { - Future.microtask( - () => ref.read(attachmentProvider.notifier).set(draft), - ); - attachmentBefore.value = null; - } - } - - return null; - }, [relationType, relatedEvent == null]); + void dismiss() { + draftNotifier.dismiss(); + // Ending an edit replaces the text field, losing its focus + WidgetsBinding.instance.addPostFrameCallback((_) => node?.requestFocus()); + } final sinceTypingSent = useMemoized(Stopwatch.new); + final client = ref.watch(ClientController.provider.notifier); - useEffect(() { - final client = ref.read(ClientController.provider.notifier); + void stopTyping() { + if (!sinceTypingSent.isRunning) return; + sinceTypingSent + ..stop() + ..reset(); + client.setTyping(roomId, .zero).ignore(); + } - void stopTyping() { - if (!sinceTypingSent.isRunning) return; - sinceTypingSent - ..stop() - ..reset(); - client.setTyping(roomId, .zero).ignore(); + // Stops typing in the previous room, or the message before an edit + useEffect(() => stopTyping, [controller]); + + // Called by FlutterTagger for changes once attached, unlike a listener on + // the controller, which is also told about the draft being restored + // during the build + void onFormattedTextChanged(String text) { + if (text == ref.read(draftProvider).text) return; + draftNotifier.update( + (draft) => draft.copyWith(text: text, tags: controller.tags.toIList()), + ); + + final shouldSend = + ref.read(SettingsController.provider).value?.typingIndicators == + .showAndSend; + + if (!shouldSend || controller.text.isEmpty) return stopTyping(); + + if (sinceTypingSent.isRunning && + sinceTypingSent.elapsed < const .new(seconds: 5)) { + return; } - void onChanged() { - final shouldSend = - ref.read(SettingsController.provider).value?.typingIndicators == - .showAndSend; - - if (!shouldSend || controller.text.isEmpty) return stopTyping(); - - if (sinceTypingSent.isRunning && - sinceTypingSent.elapsed < const .new(seconds: 5)) { - return; - } - - sinceTypingSent - ..reset() - ..start(); - client.setTyping(roomId, const .new(seconds: 10)).ignore(); - } - - controller.addListener(onChanged); - return () { - controller.removeListener(onChanged); - stopTyping(); - }; - }, [roomId]); + sinceTypingSent + ..reset() + ..start(); + client.setTyping(roomId, const .new(seconds: 10)).ignore(); + } final attachment = ref.watch(attachmentProvider); @@ -322,10 +295,10 @@ class const Composer( } void removeBotCommand({required bool keepText}) { + // The text field gets a new controller from the draft once it's back if (botCommand?.command.prefix case final prefix? when keepText) { - controller.value = .new( - text: prefix, - selection: .collapsed(offset: prefix.length), + draftNotifier.update( + (draft) => draft.copyWith(text: prefix, tags: const .empty()), ); } ref.read(botCommandProvider.notifier).clear(); @@ -355,20 +328,20 @@ class const Composer( } } - if (sticker.value case (final shortcode, final image)?) { + if (sticker case (final shortcode, final image)?) { onSendSticker(shortcode, image, shouldMention: shouldMention.value); - } else if (poll.value case final draft?) { + } else if (poll case final draft?) { onSendPoll(draft, shouldMention: shouldMention.value); - } else if (location.value case final draft?) { + } else if (location case final draft?) { onSendLocation(draft, shouldMention: shouldMention.value); } else if (botCommand != null) { onSendBotCommand(botCommand); } - onDismiss(); + dismiss(); // The draft is kept, so it can still be sent after the replacement - sticker.value = null; - poll.value = null; - location.value = null; + draftNotifier.update( + (draft) => draft.copyWith(sticker: null, poll: null, location: null), + ); ref.read(botCommandProvider.notifier).clear(); // Once the text field is back WidgetsBinding.instance.addPostFrameCallback( @@ -395,8 +368,9 @@ class const Composer( tags: .new(controller.tags), ); - onDismiss(); + // Before dismissing, which restores the draft from before an edit controller.text = ""; + dismiss(); } KeyEventResult sendOnEnter(FocusNode _, KeyEvent event) { @@ -457,7 +431,7 @@ class const Composer( toggleShouldMention: () => shouldMention.value = !shouldMention.value, relationType: relationType, - onDismiss: onDismiss, + onDismiss: dismiss, ), Container( color: theme.colorScheme.surfaceContainerHighest, @@ -560,7 +534,11 @@ class const Composer( roomId: roomId, onSelection: (shortcode, image) { Navigator.of(context).pop(); - sticker.value = (shortcode, image); + draftNotifier.update( + (draft) => draft.copyWith( + sticker: (shortcode, image), + ), + ); }, ), ), @@ -576,7 +554,10 @@ class const Composer( (context) => CreatePollSheet( onCreate: (created) { Navigator.of(context).pop(); - poll.value = created; + draftNotifier.update( + (draft) => + draft.copyWith(poll: created), + ); }, ), ), @@ -592,7 +573,10 @@ class const Composer( (context) => LocationPickerSheet( onPick: (picked) { Navigator.of(context).pop(); - location.value = picked; + draftNotifier.update( + (draft) => + draft.copyWith(location: picked), + ); }, ), ), @@ -610,16 +594,18 @@ class const Composer( duration: Durations.short4, alignment: .bottomCenter, child: switch (( - sticker.value, - poll.value, - location.value, + sticker, + poll, + location, botCommand, )) { (_, final draft?, _, _) => ComposerReplacement( removeTooltip: "Remove poll", onRemove: () { - poll.value = null; + draftNotifier.update( + (draft) => draft.copyWith(poll: null), + ); node?.requestFocus(); }, onKeyEvent: sendOnEnter, @@ -642,7 +628,10 @@ class const Composer( ComposerReplacement( removeTooltip: "Remove sticker", onRemove: () { - sticker.value = null; + draftNotifier.update( + (draft) => + draft.copyWith(sticker: null), + ); node?.requestFocus(); }, onKeyEvent: sendOnEnter, @@ -661,7 +650,10 @@ class const Composer( ComposerReplacement( removeTooltip: "Remove location", onRemove: () { - location.value = null; + draftNotifier.update( + (draft) => + draft.copyWith(location: null), + ); node?.requestFocus(); }, onKeyEvent: sendOnEnter, @@ -701,7 +693,18 @@ class const Composer( ), ), (null, null, null, null) => FlutterTagger( + // Attaches each new controller + key: ObjectKey(controller), triggerStrategy: .eager, + onFormattedTextChanged: + onFormattedTextChanged, + // Keeps the trigger character, so tags + // can be restored from drafts + tagTextFormatter: ( + id, + tag, + triggerCharacter, + ) => "$triggerCharacter$id#$tag#", // Characters that continue a search, for // e.g. :+1, :custom_emoji or @user:server searchRegex: .new(r"^[\w.:+-]*$"), diff --git a/lib/widgets/room_chat/chat_body.dart b/lib/widgets/room_chat/chat_body.dart index 1446f12..239ae4b 100644 --- a/lib/widgets/room_chat/chat_body.dart +++ b/lib/widgets/room_chat/chat_body.dart @@ -122,9 +122,9 @@ final class const ChatBody({ .send( text, tags: tags, - relationType: composer.relationType.value, + relationType: composer.relationType, shouldMention: shouldMention, - relatedEventRowId: composer.relatedEvent.value, + relatedEventRowId: composer.relatedEvent, ) .onError(showError), onSendSticker: (shortcode, image, {required shouldMention}) => @@ -153,12 +153,6 @@ final class const ChatBody({ onSendBotCommand: (draft) => notifier .sendBotCommand(draft, repliedEventRowId: repliedEventRowId) .onError(showError), - relationType: composer.relationType.value, - relatedEvent: composer.relatedEvent.value, - onDismiss: () { - composer.relatedEvent.value = null; - composer.node.requestFocus(); - }, ), }, ), diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 39f2002..0062450 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -29,7 +29,6 @@ final class const RoomChat({ }) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final composer = ChatComposer.use(); final pendingJump = ref.read(JumpRequestController.provider); final contextualEvent = useState(switch (pendingJump) { // Threaded events open their thread instead, leaving the timeline @@ -76,6 +75,7 @@ final class const RoomChat({ } final roomId = this.roomId!; + final composer = ChatComposer.use(ref, (roomId: roomId, threadRoot: null)); final jumpRowId = ref.watch( JumpRequestController.provider.select( diff --git a/lib/widgets/thread_drawer.dart b/lib/widgets/thread_drawer.dart index 88450c9..bc88933 100644 --- a/lib/widgets/thread_drawer.dart +++ b/lib/widgets/thread_drawer.dart @@ -24,7 +24,10 @@ final class const ThreadDrawer( }) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final composer = ChatComposer.use(); + final composer = ChatComposer.use(ref, ( + roomId: roomId, + threadRoot: threadRoot, + )); final contextualEvent = useState(initialEvent); final ChatParams chat = (