Watch
1
0
Fork
You've already forked nexus
0
forked from Nexus/nexus

keep message drafts on room switches

this is for you courge
This commit is contained in:
Henry Hiles 2026-11-05 20:44:13 -05:00
commit e899bb3929
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
11 changed files with 357 additions and 194 deletions

View file

@ -7,7 +7,9 @@ import "package:nexus/models/content/message.dart";
import "package:nexus/models/composer_params.dart"; import "package:nexus/models/composer_params.dart";
import "package:path/path.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?)?> { extends Notifier<(String, MessageContent?)?> {
@override @override
Null build() => null; Null build() => null;
@ -19,7 +21,7 @@ class AttachmentController(final ComposerParams composer)
final isEncrypted = ref.read( final isEncrypted = ref.read(
RoomsController.provider.select( RoomsController.provider.select(
(value) => (value) =>
value[composer.roomId] value[params.composer.roomId]
?.state[EventType.encryption.type] ?.state[EventType.encryption.type]
?.isNotEmpty == ?.isNotEmpty ==
true, true,
@ -35,10 +37,10 @@ class AttachmentController(final ComposerParams composer)
void set((String, MessageContent?)? attachment) => state = attachment; void set((String, MessageContent?)? attachment) => state = attachment;
static final provider = NotifierProvider.family static final provider =
.autoDispose< NotifierProvider.family<
AttachmentController, AttachmentController,
(String, MessageContent?)?, (String, MessageContent?)?,
ComposerParams AttachmentParams
>(AttachmentController.new); >(AttachmentController.new);
} }

View file

@ -95,10 +95,14 @@ class BotCommandDraftController(final ComposerParams composer)
} }
} }
void set(BotCommandDraft? draft) => state = draft;
void clear() => state = null; void clear() => state = null;
static final provider = NotifierProvider.autoDispose static final provider =
.family<BotCommandDraftController, BotCommandDraft?, ComposerParams>( NotifierProvider.family<
BotCommandDraftController.new, BotCommandDraftController,
); BotCommandDraft?,
ComposerParams
>(BotCommandDraftController.new);
} }

View file

@ -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<ComposerDraft> {
@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);
}

View file

@ -404,7 +404,10 @@ class RoomChatController(final ChatParams info)
roomId: info.roomId, roomId: info.roomId,
threadRoot: info.threadRoot, 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; final attachment = ref.read(attachmentProvider)?.$2;
ref.invalidate(attachmentProvider); ref.invalidate(attachmentProvider);
@ -418,11 +421,13 @@ class RoomChatController(final ChatParams info)
? latestContent ? latestContent
: null); : 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( final taggedMessage = tags.fold(
text, text,
(message, tag) => message.replaceAllMapped( (message, tag) => message.replaceAllMapped(
RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"), RegExp(
"${RegExp.escape(tag.triggerCharacter)}(${RegExp.escape(tag.id)})(#[^#]*#)?",
),
(match) => match.group(1)!, (match) => match.group(1)!,
), ),
); );

View file

@ -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,
);
}
}

View file

@ -1,40 +1,50 @@
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.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"; import "package:nexus/models/relation_type.dart";
final class ChatComposer({ final class ChatComposer({
required final ValueNotifier<int?> relatedEvent, required final int? relatedEvent,
required final ValueNotifier<RelationType> relationType, required final RelationType relationType,
required final ComposerDraftController notifier,
required final FocusNode node, required final FocusNode node,
}) { }) {
factory use() { factory use(WidgetRef ref, ComposerParams composer) {
final relatedEvent = useState<int?>(null); final provider = ComposerDraftController.provider(composer);
final relationType = useState(RelationType.reply);
final node = useFocusNode( final node = useFocusNode(
onKeyEvent: (_, event) { onKeyEvent: (node, event) {
if (event is KeyDownEvent && event.logicalKey == .escape) { 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 .handled;
} }
return .ignored; return .ignored;
}, },
); );
final (relatedEvent, relationType) = ref.watch(
provider.select((draft) => (draft.relatedEvent, draft.relationType)),
);
return .new( return .new(
relatedEvent: relatedEvent, relatedEvent: relatedEvent,
relationType: relationType, relationType: relationType,
notifier: ref.watch(provider.notifier),
node: node, node: node,
); );
} }
int? get repliedEventRowId => int? get repliedEventRowId => relationType == .reply ? relatedEvent : null;
relationType.value == .reply ? relatedEvent.value : null;
void relate(int rowId, RelationType type) { void relate(int rowId, RelationType type) {
relatedEvent.value = rowId; notifier.relate(rowId, type);
relationType.value = type; // Starting an edit replaces the text field, losing its focus
node.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) => node.requestFocus());
} }
} }

View file

@ -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<Tag> 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;
}

View file

@ -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_argument_suggestions.dart";
import "package:nexus/controllers/bot_command_draft.dart"; import "package:nexus/controllers/bot_command_draft.dart";
import "package:nexus/controllers/bot_commands.dart"; import "package:nexus/controllers/bot_commands.dart";
import "package:nexus/controllers/composer_draft.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/show_error_snack_bar.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.dart";
import "package:nexus/models/bot_command_draft.dart"; import "package:nexus/models/bot_command_draft.dart";
import "package:nexus/models/composer_params.dart"; import "package:nexus/models/composer_params.dart";
import "package:nexus/widgets/composer/bot_command_form.dart"; import "package:nexus/widgets/composer/bot_command_form.dart";
import "package:nexus/widgets/composer/bot_command_panel.dart"; import "package:nexus/widgets/composer/bot_command_panel.dart";
import "package:nexus/widgets/emoji_picker.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:material_ui/material_ui.dart";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.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/create_poll_sheet.dart";
import "package:nexus/widgets/location_picker_sheet.dart"; import "package:nexus/widgets/location_picker_sheet.dart";
import "package:nexus/models/location.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/overlays/tagger_overlay.dart";
import "package:nexus/widgets/composer/paste_files_action.dart"; import "package:nexus/widgets/composer/paste_files_action.dart";
import "package:pasteboard/pasteboard.dart"; import "package:pasteboard/pasteboard.dart";
@ -50,9 +49,6 @@ import "package:nexus/main.dart";
class const Composer( class const Composer(
final String roomId, { final String roomId, {
required final int? relatedEvent,
required final RelationType relationType,
required final VoidCallback onDismiss,
required final Future<void> Function( required final Future<void> Function(
String text, { String text, {
required bool shouldMention, required bool shouldMention,
@ -83,7 +79,76 @@ class const Composer(
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); 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<num>((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 triggerCharacter = useState("");
final shouldMention = useState(true); final shouldMention = useState(true);
final query = useState(""); final query = useState("");
@ -103,151 +168,59 @@ class const Composer(
.new(eventType: .pollStart, roomId: roomId), .new(eventType: .pollStart, roomId: roomId),
), ),
); );
final relatedEvent = this.relatedEvent == null final relatedEvent = relatedEventRowId == null
? null ? null
: ref.watch( : ref.watch(
RoomsController.provider.select((rooms) { RoomsController.provider.select(
final event = rooms[roomId]?.events[this.relatedEvent!]; (rooms) => rooms[roomId]?.withLatestEdit(relatedEventRowId),
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,
);
}),
); );
final textBefore = useState<String?>(null); void dismiss() {
// Wrapped, since no attachment is a draft too draftNotifier.dismiss();
final attachmentBefore = useState<((String, MessageContent?)?,)?>(null); // Ending an edit replaces the text field, losing its focus
WidgetsBinding.instance.addPostFrameCallback((_) => node?.requestFocus());
/// 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<PollStart?>(null);
final location = useState<LocationMessageContent?>(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]);
final sinceTypingSent = useMemoized(Stopwatch.new); final sinceTypingSent = useMemoized(Stopwatch.new);
final client = ref.watch(ClientController.provider.notifier);
useEffect(() { void stopTyping() {
final client = ref.read(ClientController.provider.notifier); if (!sinceTypingSent.isRunning) return;
sinceTypingSent
..stop()
..reset();
client.setTyping(roomId, .zero).ignore();
}
void stopTyping() { // Stops typing in the previous room, or the message before an edit
if (!sinceTypingSent.isRunning) return; useEffect(() => stopTyping, [controller]);
sinceTypingSent
..stop() // Called by FlutterTagger for changes once attached, unlike a listener on
..reset(); // the controller, which is also told about the draft being restored
client.setTyping(roomId, .zero).ignore(); // 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() { sinceTypingSent
final shouldSend = ..reset()
ref.read(SettingsController.provider).value?.typingIndicators == ..start();
.showAndSend; client.setTyping(roomId, const .new(seconds: 10)).ignore();
}
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]);
final attachment = ref.watch(attachmentProvider); final attachment = ref.watch(attachmentProvider);
@ -322,10 +295,10 @@ class const Composer(
} }
void removeBotCommand({required bool keepText}) { 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) { if (botCommand?.command.prefix case final prefix? when keepText) {
controller.value = .new( draftNotifier.update(
text: prefix, (draft) => draft.copyWith(text: prefix, tags: const .empty()),
selection: .collapsed(offset: prefix.length),
); );
} }
ref.read(botCommandProvider.notifier).clear(); 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); onSendSticker(shortcode, image, shouldMention: shouldMention.value);
} else if (poll.value case final draft?) { } else if (poll case final draft?) {
onSendPoll(draft, shouldMention: shouldMention.value); onSendPoll(draft, shouldMention: shouldMention.value);
} else if (location.value case final draft?) { } else if (location case final draft?) {
onSendLocation(draft, shouldMention: shouldMention.value); onSendLocation(draft, shouldMention: shouldMention.value);
} else if (botCommand != null) { } else if (botCommand != null) {
onSendBotCommand(botCommand); onSendBotCommand(botCommand);
} }
onDismiss(); dismiss();
// The draft is kept, so it can still be sent after the replacement // The draft is kept, so it can still be sent after the replacement
sticker.value = null; draftNotifier.update(
poll.value = null; (draft) => draft.copyWith(sticker: null, poll: null, location: null),
location.value = null; );
ref.read(botCommandProvider.notifier).clear(); ref.read(botCommandProvider.notifier).clear();
// Once the text field is back // Once the text field is back
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
@ -395,8 +368,9 @@ class const Composer(
tags: .new(controller.tags), tags: .new(controller.tags),
); );
onDismiss(); // Before dismissing, which restores the draft from before an edit
controller.text = ""; controller.text = "";
dismiss();
} }
KeyEventResult sendOnEnter(FocusNode _, KeyEvent event) { KeyEventResult sendOnEnter(FocusNode _, KeyEvent event) {
@ -457,7 +431,7 @@ class const Composer(
toggleShouldMention: () => toggleShouldMention: () =>
shouldMention.value = !shouldMention.value, shouldMention.value = !shouldMention.value,
relationType: relationType, relationType: relationType,
onDismiss: onDismiss, onDismiss: dismiss,
), ),
Container( Container(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,
@ -560,7 +534,11 @@ class const Composer(
roomId: roomId, roomId: roomId,
onSelection: (shortcode, image) { onSelection: (shortcode, image) {
Navigator.of(context).pop(); 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( (context) => CreatePollSheet(
onCreate: (created) { onCreate: (created) {
Navigator.of(context).pop(); Navigator.of(context).pop();
poll.value = created; draftNotifier.update(
(draft) =>
draft.copyWith(poll: created),
);
}, },
), ),
), ),
@ -592,7 +573,10 @@ class const Composer(
(context) => LocationPickerSheet( (context) => LocationPickerSheet(
onPick: (picked) { onPick: (picked) {
Navigator.of(context).pop(); Navigator.of(context).pop();
location.value = picked; draftNotifier.update(
(draft) =>
draft.copyWith(location: picked),
);
}, },
), ),
), ),
@ -610,16 +594,18 @@ class const Composer(
duration: Durations.short4, duration: Durations.short4,
alignment: .bottomCenter, alignment: .bottomCenter,
child: switch (( child: switch ((
sticker.value, sticker,
poll.value, poll,
location.value, location,
botCommand, botCommand,
)) { )) {
(_, final draft?, _, _) => (_, final draft?, _, _) =>
ComposerReplacement( ComposerReplacement(
removeTooltip: "Remove poll", removeTooltip: "Remove poll",
onRemove: () { onRemove: () {
poll.value = null; draftNotifier.update(
(draft) => draft.copyWith(poll: null),
);
node?.requestFocus(); node?.requestFocus();
}, },
onKeyEvent: sendOnEnter, onKeyEvent: sendOnEnter,
@ -642,7 +628,10 @@ class const Composer(
ComposerReplacement( ComposerReplacement(
removeTooltip: "Remove sticker", removeTooltip: "Remove sticker",
onRemove: () { onRemove: () {
sticker.value = null; draftNotifier.update(
(draft) =>
draft.copyWith(sticker: null),
);
node?.requestFocus(); node?.requestFocus();
}, },
onKeyEvent: sendOnEnter, onKeyEvent: sendOnEnter,
@ -661,7 +650,10 @@ class const Composer(
ComposerReplacement( ComposerReplacement(
removeTooltip: "Remove location", removeTooltip: "Remove location",
onRemove: () { onRemove: () {
location.value = null; draftNotifier.update(
(draft) =>
draft.copyWith(location: null),
);
node?.requestFocus(); node?.requestFocus();
}, },
onKeyEvent: sendOnEnter, onKeyEvent: sendOnEnter,
@ -701,7 +693,18 @@ class const Composer(
), ),
), ),
(null, null, null, null) => FlutterTagger( (null, null, null, null) => FlutterTagger(
// Attaches each new controller
key: ObjectKey(controller),
triggerStrategy: .eager, 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 // Characters that continue a search, for
// e.g. :+1, :custom_emoji or @user:server // e.g. :+1, :custom_emoji or @user:server
searchRegex: .new(r"^[\w.:+-]*$"), searchRegex: .new(r"^[\w.:+-]*$"),

View file

@ -122,9 +122,9 @@ final class const ChatBody({
.send( .send(
text, text,
tags: tags, tags: tags,
relationType: composer.relationType.value, relationType: composer.relationType,
shouldMention: shouldMention, shouldMention: shouldMention,
relatedEventRowId: composer.relatedEvent.value, relatedEventRowId: composer.relatedEvent,
) )
.onError(showError), .onError(showError),
onSendSticker: (shortcode, image, {required shouldMention}) => onSendSticker: (shortcode, image, {required shouldMention}) =>
@ -153,12 +153,6 @@ final class const ChatBody({
onSendBotCommand: (draft) => notifier onSendBotCommand: (draft) => notifier
.sendBotCommand(draft, repliedEventRowId: repliedEventRowId) .sendBotCommand(draft, repliedEventRowId: repliedEventRowId)
.onError(showError), .onError(showError),
relationType: composer.relationType.value,
relatedEvent: composer.relatedEvent.value,
onDismiss: () {
composer.relatedEvent.value = null;
composer.node.requestFocus();
},
), ),
}, },
), ),

View file

@ -29,7 +29,6 @@ final class const RoomChat({
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final composer = ChatComposer.use();
final pendingJump = ref.read(JumpRequestController.provider); final pendingJump = ref.read(JumpRequestController.provider);
final contextualEvent = useState<int?>(switch (pendingJump) { final contextualEvent = useState<int?>(switch (pendingJump) {
// Threaded events open their thread instead, leaving the timeline // Threaded events open their thread instead, leaving the timeline
@ -76,6 +75,7 @@ final class const RoomChat({
} }
final roomId = this.roomId!; final roomId = this.roomId!;
final composer = ChatComposer.use(ref, (roomId: roomId, threadRoot: null));
final jumpRowId = ref.watch( final jumpRowId = ref.watch(
JumpRequestController.provider.select( JumpRequestController.provider.select(

View file

@ -24,7 +24,10 @@ final class const ThreadDrawer(
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final composer = ChatComposer.use(); final composer = ChatComposer.use(ref, (
roomId: roomId,
threadRoot: threadRoot,
));
final contextualEvent = useState(initialEvent); final contextualEvent = useState(initialEvent);
final ChatParams chat = ( final ChatParams chat = (