Skip to content

Commit

Permalink
Merge pull request #2077 from gtalha07/talha/compose-draft-chat
Browse files Browse the repository at this point in the history
load and save composer draft for chat room input text
  • Loading branch information
gtalha07 authored Aug 26, 2024
2 parents 5b8199a + 469798b commit e4fa6b8
Show file tree
Hide file tree
Showing 9 changed files with 364 additions and 123 deletions.
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 = {
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

0 comments on commit e4fa6b8

Please sign in to comment.