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: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);
}

View file

@ -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, BotCommandDraft?, ComposerParams>(
BotCommandDraftController.new,
);
static final provider =
NotifierProvider.family<
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,
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)!,
),
);

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_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<int?> relatedEvent,
required final ValueNotifier<RelationType> relationType,
required final int? relatedEvent,
required final RelationType relationType,
required final ComposerDraftController notifier,
required final FocusNode node,
}) {
factory use() {
final relatedEvent = useState<int?>(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());
}
}

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_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<void> 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<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 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<String?>(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<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]);
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.:+-]*$"),

View file

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

View file

@ -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<int?>(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(

View file

@ -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 = (