From 0c4597226fd3905cb0f2cf6f7728b09aa505af04 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:01:19 -0400 Subject: [PATCH] refactor: improvements to fake message display, allow users to send more than one fake message at a time (#2925) --- lib/pages/chat/chat.dart | 95 +++++++++++++------ lib/pages/chat/chat_input_row.dart | 7 +- lib/pages/chat/input_bar.dart | 21 +--- lib/pangea/chat/widgets/chat_input_bar.dart | 5 +- .../controllers/choreographer.dart | 21 ++-- lib/pangea/common/constants/model_keys.dart | 1 + .../controllers/message_data_controller.dart | 5 +- .../extensions/room_events_extension.dart | 4 + 8 files changed, 102 insertions(+), 57 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 7ee0881e0..d354b0a15 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -39,6 +40,7 @@ import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart'; import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/message_analytics_feedback.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/pangea_text_controller.dart'; +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart'; @@ -515,7 +517,7 @@ class ChatController extends State // #Pangea // If fake event was sent, don't animate in the next event. // It makes the replacement of the fake event jumpy. - if (_fakeEventID != null) { + if (_fakeEventIDs.isNotEmpty) { animateInEventIndex = null; return; } @@ -687,7 +689,6 @@ class ChatController extends State MatrixState.pAnyState.closeAllOverlays(force: true); showToolbarStream.close(); stopMediaStream.close(); - hideTextController.dispose(); _levelSubscription?.cancel(); _analyticsSubscription?.cancel(); _router.routeInformationProvider.removeListener(_onRouteChanged); @@ -720,10 +721,6 @@ class ChatController extends State // TextEditingController sendController = TextEditingController(); PangeaTextController get sendController => choreographer.textController; - - /// used to obscure text in text field after sending fake message without - /// changing the actual text in the sendController - final TextEditingController hideTextController = TextEditingController(); // #Pangea void setSendingClient(Client c) { @@ -758,26 +755,47 @@ class ChatController extends State pangeaEditingEvent = null; } - String? _fakeEventID; - bool get obscureText => _fakeEventID != null; + final List _fakeEventIDs = []; + bool get obscureText => _fakeEventIDs.isNotEmpty; /// Add a fake event to the timeline to visually indicate that a message is being sent. /// Used when tokenizing after message send, specifically because tokenization for some /// languages takes some time. - void sendFakeMessage() { + String? sendFakeMessage() { + if (sendController.text.trim().isEmpty) return null; + final eventID = room.sendFakeMessage( text: sendController.text, inReplyTo: replyEvent, editEventId: editEvent?.eventId, ); - setState(() => _fakeEventID = eventID); + sendController.setSystemText("", EditType.other); + setState(() => _fakeEventIDs.add(eventID)); + + // wait for the next event to come through before clearing any fake event, + // to make the replacement look smooth + room.client.onTimelineEvent.stream + .firstWhere((event) => event.content[ModelKey.tempEventId] == eventID) + .then( + (_) => clearFakeEvent(eventID), + ); + + return eventID; } - void clearFakeEvent() { - if (_fakeEventID == null) return; - timeline?.events.removeWhere((e) => e.eventId == _fakeEventID); + void clearFakeEvent(String? eventId) { + if (eventId == null) return; + + final inTimeline = timeline != null && + timeline!.events.any( + (e) => e.eventId == eventId, + ); + + if (!inTimeline) return; + timeline?.events.removeWhere((e) => e.eventId == eventId); + setState(() { - _fakeEventID = null; + _fakeEventIDs.remove(eventId); }); } @@ -786,20 +804,26 @@ class ChatController extends State // but for choero, the tx id is generated before the message send. // Also, adding PangeaMessageData Future send({ + required String message, PangeaRepresentation? originalSent, PangeaRepresentation? originalWritten, PangeaMessageTokens? tokensSent, PangeaMessageTokens? tokensWritten, ChoreoRecord? choreo, + String? tempEventId, }) async { + if (message.trim().isEmpty) return; + // if (sendController.text.trim().isEmpty) return; // Pangea# - if (sendController.text.trim().isEmpty) return; _storeInputTimeoutTimer?.cancel(); final prefs = await SharedPreferences.getInstance(); prefs.remove('draft_$roomId'); var parseCommands = true; - final commandMatch = RegExp(r'^\/(\w+)').firstMatch(sendController.text); + // #Pangea + // final commandMatch = RegExp(r'^\/(\w+)').firstMatch(sendController.text); + final commandMatch = RegExp(r'^\/(\w+)').firstMatch(message); + // Pangea# if (commandMatch != null && !sendingClient.commands.keys.contains(commandMatch[1]!.toLowerCase())) { final l10n = L10n.of(context); @@ -810,7 +834,13 @@ class ChatController extends State okLabel: l10n.sendAsText, cancelLabel: l10n.cancel, ); - if (dialogResult == OkCancelResult.cancel) return; + // #Pangea + // if (dialogResult == OkCancelResult.cancel) return; + if (dialogResult == OkCancelResult.cancel) { + clearFakeEvent(tempEventId); + return; + } + // Pangea# parseCommands = false; } @@ -822,15 +852,20 @@ class ChatController extends State // editEventId: editEvent?.eventId, // parseCommands: parseCommands, // ); - final previousEdit = editEvent; - // wait for the next event to come through before clearing any fake event, - // to make the replacement look smooth - room.client.onTimelineEvent.stream.first.then((_) => clearFakeEvent()); + // If the message and the sendController text don't match, it's possible + // that there was a delay in tokenization before send, and the user started + // typing a new message. We don't want to erase that, so only reset the input + // bar text if the message is the same as the sendController text. + if (message == sendController.text) { + sendController.setSystemText("", EditType.other); + } + + final previousEdit = editEvent; room .pangeaSendTextEvent( - sendController.text, + message, inReplyTo: replyEvent, editEventId: editEvent?.eventId, parseCommands: parseCommands, @@ -839,6 +874,7 @@ class ChatController extends State tokensSent: tokensSent, tokensWritten: tokensWritten, choreo: choreo, + tempEventId: tempEventId, ) .then( (String? msgEventId) async { @@ -915,7 +951,7 @@ class ChatController extends State s: StackTrace.current, data: { 'roomId': roomId, - 'text': sendController.text, + 'text': message, 'inReplyTo': replyEvent?.eventId, 'editEventId': editEvent?.eventId, }, @@ -924,7 +960,7 @@ class ChatController extends State } }, ).catchError((err, s) { - clearFakeEvent(); + clearFakeEvent(tempEventId); if (err is EventTooLarge) { showAdaptiveDialog( context: context, @@ -937,22 +973,21 @@ class ChatController extends State s: s, data: { 'roomId': roomId, - 'text': sendController.text, + 'text': message, 'inReplyTo': replyEvent?.eventId, 'editEventId': editEvent?.eventId, }, ); }); + // sendController.value = TextEditingValue( + // text: pendingText, + // selection: const TextSelection.collapsed(offset: 0), + // ); // Pangea# - sendController.value = TextEditingValue( - text: pendingText, - selection: const TextSelection.collapsed(offset: 0), - ); setState(() { // #Pangea // sendController.text = pendingText; - sendController.setSystemText(pendingText, EditType.other); // Pangea# _inputTextIsEmpty = pendingText.isEmpty; replyEvent = null; diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 0f6d253ab..c500f251f 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -320,7 +320,12 @@ class ChatInputRow extends StatelessWidget { ) : FloatingActionButton.small( tooltip: L10n.of(context).send, - onPressed: controller.send, + // #Pangea + // onPressed: controller.send, + onPressed: () => controller.send( + message: controller.sendController.text, + ), + // Pangea# elevation: 0, heroTag: null, shape: RoundedRectangleBorder( diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index a0acda3da..d1c4f51fb 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -429,16 +429,7 @@ class InputBar extends StatelessWidget { direction: VerticalDirection.up, hideOnEmpty: true, hideOnLoading: true, - // #Pangea - // if should obscure text (to make it looks that a message has been sent after sending fake message), - // use hideTextController - - // controller: controller, - controller: - (controller?.choreographer.chatController.obscureText) ?? false - ? controller?.choreographer.chatController.hideTextController - : controller, - // Pangea# + controller: controller, focusNode: focusNode, hideOnSelect: false, debounceDuration: const Duration(milliseconds: 50), @@ -447,14 +438,10 @@ class InputBar extends StatelessWidget { builder: (context, _, focusNode) { final textField = TextField( enableSuggestions: enableAutocorrect, - readOnly: controller != null && - (controller!.choreographer.isRunningIT || - controller!.choreographer.chatController.obscureText), + readOnly: + controller != null && (controller!.choreographer.isRunningIT), autocorrect: enableAutocorrect, - controller: - (controller?.choreographer.chatController.obscureText) ?? false - ? controller?.choreographer.chatController.hideTextController - : controller, + controller: controller, focusNode: focusNode, contextMenuBuilder: (c, e) => markdownContextBuilder( c, diff --git a/lib/pangea/chat/widgets/chat_input_bar.dart b/lib/pangea/chat/widgets/chat_input_bar.dart index 37fb3d397..6b7afb825 100644 --- a/lib/pangea/chat/widgets/chat_input_bar.dart +++ b/lib/pangea/chat/widgets/chat_input_bar.dart @@ -74,7 +74,10 @@ class ChatInputBarState extends State { ), child: Column( children: [ - ReplyDisplay(widget.controller), + // #Pangea + if (!widget.controller.obscureText) + // Pangea# + ReplyDisplay(widget.controller), PangeaChatInputRow( controller: widget.controller, ), diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index ef5364270..cadd4c3d0 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -114,7 +114,9 @@ class Choreographer { maxWidth: 325, transformTargetId: inputTransformTargetKey, ) - : chatController.send(); + : chatController.send( + message: chatController.sendController.text, + ); return; } @@ -135,7 +137,12 @@ class Choreographer { return; } - chatController.sendFakeMessage(); + if (chatController.sendController.text.trim().isEmpty) { + return; + } + + final message = chatController.sendController.text; + final fakeEventId = chatController.sendFakeMessage(); final PangeaRepresentation? originalWritten = choreoRecord.includedIT && itController.sourceText != null ? PangeaRepresentation( @@ -156,7 +163,7 @@ class Choreographer { repEventId: null, room: chatController.room, req: TokensRequestModel( - fullText: currentText, + fullText: message, senderL1: l1LangCode!, senderL2: l2LangCode!, ), @@ -167,7 +174,7 @@ class Choreographer { originalSent = PangeaRepresentation( langCode: res?.detections.firstOrNull?.langCode ?? LanguageKeys.unknownLanguage, - text: currentText, + text: message, originalSent: true, originalWritten: originalWritten == null, ); @@ -183,7 +190,7 @@ class Choreographer { e: e, s: s, data: { - "currentText": currentText, + "currentText": message, "l1LangCode": l1LangCode, "l2LangCode": l2LangCode, "choreoRecord": choreoRecord.toJson(), @@ -191,9 +198,11 @@ class Choreographer { ); } finally { chatController.send( + message: message, originalSent: originalSent, tokensSent: tokensSent, choreo: choreoRecord, + tempEventId: fakeEventId, ); clear(); } @@ -558,8 +567,6 @@ class Choreographer { choreoRecord = ChoreoRecord.newRecord; itController.clear(); igc.dispose(); - //@ggurdin - why is this commented out? - // errorService.clear(); _resetDebounceTimer(); } diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index cb565eb06..781667c34 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -90,6 +90,7 @@ class ModelKey { static const String messageTagMorphEdit = "morph_edit"; static const String messageTagLemmaEdit = "lemma_edit"; static const String messageTagActivityPlan = "activity_plan"; + static const String tempEventId = "temporary_event_id"; static const String baseDefinition = "base_definition"; static const String targetDefinition = "target_definition"; diff --git a/lib/pangea/events/controllers/message_data_controller.dart b/lib/pangea/events/controllers/message_data_controller.dart index bfdee9313..bae9f2512 100644 --- a/lib/pangea/events/controllers/message_data_controller.dart +++ b/lib/pangea/events/controllers/message_data_controller.dart @@ -97,7 +97,10 @@ class MessageDataController extends BaseController { repEventId: repEventId, req: req, room: room, - ); + ).catchError((e, s) { + _tokensCache.remove(req.hashCode); + return Future.error(e, s); + }); /////// translation //////// diff --git a/lib/pangea/extensions/room_events_extension.dart b/lib/pangea/extensions/room_events_extension.dart index d587ce849..283993516 100644 --- a/lib/pangea/extensions/room_events_extension.dart +++ b/lib/pangea/extensions/room_events_extension.dart @@ -202,6 +202,7 @@ extension EventsRoomExtension on Room { PangeaMessageTokens? tokensWritten, ChoreoRecord? choreo, String? messageTag, + String? tempEventId, }) { // if (parseCommands) { // return client.parseAndRunCommand(this, message, @@ -233,6 +234,9 @@ extension EventsRoomExtension on Room { if (messageTag != null) { event[ModelKey.messageTags] = messageTag; } + if (tempEventId != null) { + event[ModelKey.tempEventId] = tempEventId; + } if (parseMarkdown) { final html = markdown(