Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

load and save composer draft for chat room input text #2077

Merged
merged 11 commits into from
Aug 26, 2024
1 change: 1 addition & 0 deletions .changes/chat-compose-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Now Chat supports storing unsent composed message in improved way .i.e. can persist between app restarts and reply/edit views.
13 changes: 11 additions & 2 deletions app/lib/features/chat/providers/chat_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ final autoDownloadMediaProvider =

// keep track of text controller values across rooms.
final chatInputProvider =
StateNotifierProvider<ChatInputNotifier, ChatInputState>(
StateNotifierProvider.autoDispose<ChatInputNotifier, ChatInputState>(
(ref) => ChatInputNotifier(),
);

Expand All @@ -44,6 +44,15 @@ final chatStateProvider =
(ref, roomId) => ChatRoomNotifier(ref: ref, roomId: roomId),
);

final chatComposerDraftProvider = FutureProvider.autoDispose
.family<ComposeDraft?, String>((ref, roomId) async {
final chat = await ref.watch(chatProvider(roomId).future);
if (chat == null) {
return null;
}
return (await chat.msgDraft().then((val) => val.draft()));
});

final chatTopic =
FutureProvider.autoDispose.family<String?, String>((ref, roomId) async {
final c = await ref.watch(chatProvider(roomId).future);
Expand Down Expand Up @@ -128,7 +137,7 @@ final chatMessagesProvider =
return [...messages, ...moreMessages];
});

final isAuthorOfSelectedMessage = StateProvider<bool>((ref) {
final isAuthorOfSelectedMessage = StateProvider.autoDispose<bool>((ref) {
final chatInputState = ref.watch(chatInputProvider);
final myUserId = ref.watch(myUserIdStrProvider);
return chatInputState.selectedMessage?.author.id == myUserId;
Expand Down
24 changes: 24 additions & 0 deletions app/lib/features/chat/utils.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import 'package:acter/common/providers/chat_providers.dart';
import 'package:acter/common/providers/room_providers.dart';
import 'package:acter/common/toolkit/buttons/primary_action_button.dart';
import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart';
import 'package:acter/features/chat/providers/chat_providers.dart';
import 'package:acter/features/room/actions/join_room.dart';
import 'package:acter/router/utils.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
Expand Down Expand Up @@ -271,3 +274,24 @@ String parseEditMsg(types.Message message) {
}
return '';
}

// save composer draft object handler
Future<void> saveDraft(String text, String roomId, WidgetRef ref) async {
// get the convo object to initiate draft
final chat = await ref.read(chatProvider(roomId).future);
final messageId = ref.read(chatInputProvider).selectedMessage?.id;

if (chat != null) {
if (messageId != null) {
final selectedMessageState =
ref.read(chatInputProvider).selectedMessageState;
if (selectedMessageState == SelectedMessageState.edit) {
await chat.saveMsgDraft(text, null, 'edit', messageId);
} else if (selectedMessageState == SelectedMessageState.replyTo) {
await chat.saveMsgDraft(text, null, 'reply', messageId);
}
} else {
await chat.saveMsgDraft(text, null, 'new', null);
}
}
}
84 changes: 71 additions & 13 deletions app/lib/features/chat/widgets/custom_input.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';

import 'package:acter/common/models/types.dart';
import 'package:acter/common/providers/chat_providers.dart';
import 'package:acter/common/providers/room_providers.dart';
import 'package:acter/common/themes/app_theme.dart';
import 'package:acter/common/widgets/emoji_picker_widget.dart';
Expand Down Expand Up @@ -32,7 +34,7 @@ import 'package:skeletonizer/skeletonizer.dart';

final _log = Logger('a3::chat::custom_input');

final _allowEdit = StateProvider.family<bool, String>(
final _allowEdit = StateProvider.family.autoDispose<bool, String>(
(ref, roomId) => ref.watch(
chatInputProvider
.select((state) => state.sendingState == SendingState.preparing),
Expand Down Expand Up @@ -195,21 +197,34 @@ class __ChatInputState extends ConsumerState<_ChatInput> {
late ActerTriggerAutoCompleteTextController textController;
final FocusNode chatFocus = FocusNode();
final ValueNotifier<bool> _isInputEmptyNotifier = ValueNotifier(true);
Timer? _debounceTimer;

@override
void didChangeDependencies() {
super.didChangeDependencies();
_setController();
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
loadDraft();
});
}

@override
void didUpdateWidget(covariant _ChatInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) {
WidgetsBinding.instance.addPostFrameCallback((_) {
loadDraft();
});
}
}

@override
void dispose() {
_isInputEmptyNotifier.dispose();
super.dispose();
void didChangeDependencies() {
super.didChangeDependencies();
_setController();
}

void _setController() {
final Map<String, TextStyle> triggerStyles = {
gtalha07 marked this conversation as resolved.
Show resolved Hide resolved
final triggerStyles = {
'@': TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
height: 0.5,
Expand All @@ -223,12 +238,44 @@ class __ChatInputState extends ConsumerState<_ChatInput> {
textController =
ActerTriggerAutoCompleteTextController(triggerStyles: triggerStyles);
textController.addListener(_updateInputState);
setState(() {});
}

// composer draft load state handler
Future<void> loadDraft() async {
final draft =
await ref.read(chatComposerDraftProvider(widget.roomId).future);

if (draft != null) {
final inputNotifier = ref.read(chatInputProvider.notifier);
inputNotifier.unsetSelectedMessage();
if (draft.eventId() != null) {
final eventId = draft.eventId()!;
final draftType = draft.draftType();

final m = ref
.read(chatMessagesProvider(widget.roomId))
.firstWhere((x) => x.id == eventId);
if (draftType == 'edit') {
inputNotifier.setEditMessage(m);
} else if (draftType == 'reply') {
inputNotifier.setReplyToMessage(m);
}
}
textController.text = draft.plainText();
_log.info('compose draft loaded for room: ${widget.roomId}');
}
}

// listener for handling send state
void _updateInputState() {
_isInputEmptyNotifier.value = textController.text.trim().isEmpty;
_debounceTimer?.cancel();
// delay operation to avoid excessive re-writes
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
// save composing draft
saveDraft(textController.text, widget.roomId, ref);
_log.info('compose draft saved for room: ${widget.roomId}');
});
}

void handleEmojiSelected(Category? category, Emoji emoji) {
Expand Down Expand Up @@ -567,7 +614,9 @@ class __ChatInputState extends ConsumerState<_ChatInput> {
),
const Spacer(),
GestureDetector(
onTap: () {
onTap: () async {
final convo = await ref.read(chatProvider(widget.roomId).future);
await convo?.saveMsgDraft(textController.text, null, 'new', null);
inputNotifier.unsetSelectedMessage();
// frame delay to keep focus connected with keyboard.
WidgetsBinding.instance.addPostFrameCallback((_) {
Expand Down Expand Up @@ -601,7 +650,9 @@ class __ChatInputState extends ConsumerState<_ChatInput> {
),
const Spacer(),
GestureDetector(
onTap: () {
onTap: () async {
final convo = await ref.read(chatProvider(widget.roomId).future);
await convo?.saveMsgDraft('', null, 'new', null);
textController.clear();
inputNotifier.unsetSelectedMessage();
// frame delay to keep focus connected with keyboard..
Expand Down Expand Up @@ -661,6 +712,9 @@ class __ChatInputState extends ConsumerState<_ChatInput> {
ref.read(chatInputProvider.notifier).messageSent();

textController.clear();
// also clear composed state
final convo = await ref.read(chatProvider(widget.roomId).future);
await convo?.saveMsgDraft(textController.text, null, 'new', null);
} catch (e, s) {
_log.severe('Sending chat message failed', e, s);
EasyLoading.showError(
Expand All @@ -669,6 +723,7 @@ class __ChatInputState extends ConsumerState<_ChatInput> {
);
ref.read(chatInputProvider.notifier).sendingFailed();
}

if (!chatFocus.hasFocus) {
chatFocus.requestFocus();
}
Expand Down Expand Up @@ -701,7 +756,7 @@ class _TextInputWidgetConsumerState extends ConsumerState<_TextInputWidget> {
@override
void initState() {
super.initState();
ref.listenManual(chatInputProvider, (prev, next) {
ref.listenManual(chatInputProvider, (prev, next) async {
if (next.selectedMessageState == SelectedMessageState.edit &&
(prev?.selectedMessageState != next.selectedMessageState ||
next.selectedMessage != prev?.selectedMessage)) {
Expand All @@ -717,6 +772,8 @@ class _TextInputWidgetConsumerState extends ConsumerState<_TextInputWidget> {
} else if (next.selectedMessageState == SelectedMessageState.replyTo &&
(next.selectedMessage != prev?.selectedMessage ||
prev?.selectedMessageState != next.selectedMessageState)) {
// controller doesn't update text so manually save draft state
await saveDraft(widget.controller.text, widget.roomId, ref);
// frame delay to keep focus connected with keyboard..
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.chatFocus.requestFocus();
Expand Down Expand Up @@ -818,7 +875,8 @@ class _TextInputWidgetConsumerState extends ConsumerState<_TextInputWidget> {
controller: widget.controller,
focusNode: chatFocus,
enabled: ref.watch(_allowEdit(widget.roomId)),
onChanged: (val) {
onChanged: (String val) {
// send typing notice
if (widget.onTyping != null) {
widget.onTyping!(val.isNotEmpty);
}
Expand Down
Loading
Loading