keep message drafts on room switches
this is for you courge
This commit is contained in:
parent
7f7bdbf525
commit
e899bb3929
11 changed files with 357 additions and 194 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
93
lib/controllers/composer_draft.dart
Normal file
93
lib/controllers/composer_draft.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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)!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
20
lib/helpers/extensions/with_latest_edit.dart
Normal file
20
lib/helpers/extensions/with_latest_edit.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
lib/models/composer_draft.dart
Normal file
29
lib/models/composer_draft.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,118 +168,22 @@ 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);
|
|
||||||
// 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,)) {
|
void dismiss() {
|
||||||
Future.microtask(
|
draftNotifier.dismiss();
|
||||||
() => ref.read(attachmentProvider.notifier).set(draft),
|
// Ending an edit replaces the text field, losing its focus
|
||||||
);
|
WidgetsBinding.instance.addPostFrameCallback((_) => node?.requestFocus());
|
||||||
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(() {
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
|
||||||
|
|
||||||
void stopTyping() {
|
void stopTyping() {
|
||||||
if (!sinceTypingSent.isRunning) return;
|
if (!sinceTypingSent.isRunning) return;
|
||||||
|
|
@ -224,7 +193,18 @@ class const Composer(
|
||||||
client.setTyping(roomId, .zero).ignore();
|
client.setTyping(roomId, .zero).ignore();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onChanged() {
|
// 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 =
|
final shouldSend =
|
||||||
ref.read(SettingsController.provider).value?.typingIndicators ==
|
ref.read(SettingsController.provider).value?.typingIndicators ==
|
||||||
.showAndSend;
|
.showAndSend;
|
||||||
|
|
@ -242,13 +222,6 @@ class const Composer(
|
||||||
client.setTyping(roomId, const .new(seconds: 10)).ignore();
|
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);
|
||||||
|
|
||||||
Future<void> attach(Future<XFile?> pick) async {
|
Future<void> attach(Future<XFile?> pick) async {
|
||||||
|
|
@ -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.:+-]*$"),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue