From fc9c175117252eba88527a060fb144efe0bf702f Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 15 May 2025 13:28:56 -0400 Subject: [PATCH 1/6] feat: allow admins to delete rooms --- assets/l10n/intl_en.arb | 4 +- lib/pages/chat_list/chat_list.dart | 61 +++- .../pages/pangea_chat_details.dart | 54 ++++ .../chat_settings/utils/delete_room.dart | 59 ++++ .../widgets/delete_space_dialog.dart | 260 ++++++++++++++++++ .../practice_selection_repo.dart | 2 +- 6 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 lib/pangea/chat_settings/utils/delete_room.dart create mode 100644 lib/pangea/chat_settings/widgets/delete_space_dialog.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 9eaee21fc..c801d94ce 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4936,5 +4936,7 @@ "permissions": "Permissions", "spaceChildPermission": "Who can add new chats and subspaces to this space", "addEnvironmentOverride": "Add environment override", - "defaultOption": "Default" + "defaultOption": "Default", + "deleteChatDesc": "Are you sure you want to delete this chat? It will be deleted for all participants and all messages within the chat will no longer be available for practice or learning analytics.", + "deleteSpaceDesc": "The space and any selected chats and/or subspaces will be deleted for all participants and all messages within the chat will no longer be available for practice or learning analytics. This action cannot be undone." } diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index dcf16676b..df76ce4b0 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -19,6 +19,8 @@ import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/pangea/chat_list/utils/app_version_util.dart'; import 'package:fluffychat/pangea/chat_list/utils/chat_list_handle_space_tap.dart'; import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; @@ -885,7 +887,10 @@ class ChatListController extends State mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.delete_outlined, + // #Pangea + // Icons.delete_outlined, + Icons.logout_outlined, + // Pangea# color: Theme.of(context).colorScheme.onErrorContainer, ), const SizedBox(width: 12), @@ -900,6 +905,28 @@ class ChatListController extends State ], ), ), + // #Pangea + if (room.isRoomAdmin) + PopupMenuItem( + value: ChatContextAction.delete, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete_outlined, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Text( + L10n.of(context).delete, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ], + ), + ), + // Pangea# ], ); @@ -1021,6 +1048,37 @@ class ChatListController extends State }, ); return; + case ChatContextAction.delete: + if (room.isSpace) { + final resp = await showDialog( + context: context, + builder: (_) => DeleteSpaceDialog(space: room), + ); + if (resp == true && mounted) { + context.go("/rooms?spaceId=clear"); + } + } else { + final confirmed = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).delete, + cancelLabel: L10n.of(context).cancel, + isDestructive: true, + message: room.isSpace + ? L10n.of(context).deleteSpaceDesc + : L10n.of(context).deleteChatDesc, + ); + if (confirmed != OkCancelResult.ok) return; + if (!mounted) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: room.delete, + ); + if (resp.isError) return; + if (mounted) context.go("/rooms?spaceId=clear"); + } + return; // Pangea# case ChatContextAction.block: final userId = @@ -1295,5 +1353,6 @@ enum ChatContextAction { block, // #Pangea removeFromSpace, + delete, // Pangea# } diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 00240182f..a28f3abd7 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -8,10 +8,12 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_details/participant_list_item.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart'; import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/class_name_header.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/conversation_bot/conversation_bot_settings.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/download_space_analytics_button.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/room_capacity_button.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/visibility_toggle.dart'; @@ -426,6 +428,58 @@ class PangeaChatDetailsView extends StatelessWidget { }, ), Divider(color: theme.dividerColor, height: 1), + if (room.isRoomAdmin) + ListTile( + title: Text( + L10n.of(context).delete, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.error, + ), + ), + onTap: () async { + if (room.isSpace) { + final resp = await showDialog( + context: context, + builder: (_) => + DeleteSpaceDialog(space: room), + ); + + if (resp == true) { + context.go("/rooms?spaceId=clear"); + } + } else { + final confirmed = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).delete, + cancelLabel: L10n.of(context).cancel, + isDestructive: true, + message: room.isSpace + ? L10n.of(context).deleteSpaceDesc + : L10n.of(context).deleteChatDesc, + ); + if (confirmed != OkCancelResult.ok) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: room.delete, + ); + if (resp.isError) return; + context.go("/rooms?spaceId=clear"); + } + }, + ), + Divider(color: theme.dividerColor, height: 1), ListTile( title: Text( L10n.of(context).countParticipants( diff --git a/lib/pangea/chat_settings/utils/delete_room.dart b/lib/pangea/chat_settings/utils/delete_room.dart new file mode 100644 index 000000000..4cf22806a --- /dev/null +++ b/lib/pangea/chat_settings/utils/delete_room.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:matrix/matrix.dart'; +import 'package:matrix/matrix_api_lite/generated/api.dart'; + +import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; + +extension on Api { + // Send a POST request to /_synapse/client/pangea/v1/delete_room with JSON body {room_id: string}. + // Response 200 OK format: { message: "Deleted" }. + // Requester must be member of the room and have the highest power level of the room to perform this request. + Future delete(String roomId) async { + final requestUri = Uri( + path: '_synapse/client/pangea/v1/delete_room', + ); + final request = Request('POST', baseUri!.resolveUri(requestUri)); + request.headers['content-type'] = 'application/json'; + request.headers['authorization'] = 'Bearer ${bearerToken!}'; + request.bodyBytes = utf8.encode( + jsonEncode({ + 'room_id': roomId, + }), + ); + final response = await httpClient.send(request); + if (response.statusCode != 200) { + throw Exception('http error response'); + } + } +} + +extension DeleteRoom on Room { + Future delete() async { + await client.delete(id); + } + + Future> getSpaceChildrenToDelete() async { + final List rooms = []; + String? nextBatch; + int calls = 0; + + while ((nextBatch != null || calls == 0) && calls < 10) { + final resp = await client.getSpaceHierarchy( + id, + from: nextBatch, + limit: 100, + ); + rooms.addAll(resp.rooms); + nextBatch = resp.nextBatch; + calls++; + } + + return rooms + .where( + (r) => r.roomType != PangeaRoomTypes.analytics && r.roomId != id, + ) + .toList(); + } +} diff --git a/lib/pangea/chat_settings/widgets/delete_space_dialog.dart b/lib/pangea/chat_settings/widgets/delete_space_dialog.dart new file mode 100644 index 000000000..577902c88 --- /dev/null +++ b/lib/pangea/chat_settings/widgets/delete_space_dialog.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; + +class DeleteSpaceDialog extends StatefulWidget { + final Room space; + const DeleteSpaceDialog({ + super.key, + required this.space, + }); + + @override + State createState() => DeleteSpaceDialogState(); +} + +class DeleteSpaceDialogState extends State { + List _rooms = []; + final List _roomsToDelete = []; + + bool _loadingRooms = true; + String? _roomLoadError; + + bool _deleting = false; + String? _deleteError; + + @override + void initState() { + super.initState(); + _getSpaceChildrenToDelete(); + } + + Future _getSpaceChildrenToDelete() async { + setState(() { + _loadingRooms = true; + _roomLoadError = null; + }); + + try { + _rooms = await widget.space.getSpaceChildrenToDelete(); + } catch (e, s) { + _roomLoadError = L10n.of(context).oopsSomethingWentWrong; + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": widget.space.id, + }, + ); + } finally { + setState(() { + _loadingRooms = false; + }); + } + } + + void _onRoomSelected( + bool? selected, + SpaceRoomsChunk room, + ) { + if (selected == null || + (selected && _roomsToDelete.contains(room)) || + (!selected && !_roomsToDelete.contains(room))) { + return; + } + + setState(() { + selected ? _roomsToDelete.add(room) : _roomsToDelete.remove(room); + }); + } + + Future _deleteSpace() async { + setState(() { + _deleting = true; + _deleteError = null; + }); + + try { + final List> deleteFutures = []; + for (final room in _roomsToDelete) { + final roomInstance = widget.space.client.getRoomById(room.roomId); + if (roomInstance != null) { + deleteFutures.add(roomInstance.delete()); + } + } + await Future.wait(deleteFutures); + await widget.space.delete(); + Navigator.of(context).pop(true); + } catch (e, s) { + _deleteError = L10n.of(context).oopsSomethingWentWrong; + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": widget.space.id, + }, + ); + } finally { + if (mounted) { + setState(() { + _deleting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + constraints: const BoxConstraints( + maxWidth: 400, + ), + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: BorderRadius.circular(32.0), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L10n.of(context).areYouSure, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Text( + L10n.of(context).deleteSpaceDesc, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + SizedBox( + height: 300, + child: Builder( + builder: (context) { + if (_loadingRooms) { + return const Center( + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator.adaptive(), + ), + ); + } + + if (_roomLoadError != null) { + return Center( + child: Column( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + ), + Text(L10n.of(context).oopsSomethingWentWrong), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: ListView.builder( + shrinkWrap: true, + itemCount: _rooms.length, + itemBuilder: (context, index) { + final chunk = _rooms[index]; + + final room = + widget.space.client.getRoomById(chunk.roomId); + final isMember = room != null && + room.membership == Membership.join && + room.isRoomAdmin; + + final displayname = chunk.name ?? + chunk.canonicalAlias ?? + L10n.of(context).emptyChat; + + return AnimatedOpacity( + duration: FluffyThemes.animationDuration, + opacity: isMember ? 1 : 0.5, + child: CheckboxListTile( + value: _roomsToDelete.contains(chunk), + onChanged: isMember + ? (value) => _onRoomSelected(value, chunk) + : null, + title: Text(displayname), + controlAffinity: ListTileControlAffinity.leading, + ), + ); + }, + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 8.0), + child: Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: OutlinedButton( + onPressed: _deleting ? null : _deleteSpace, + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + side: BorderSide( + color: _deleting + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.error, + ), + ), + child: _deleting + ? const SizedBox( + height: 10, + width: 100, + child: LinearProgressIndicator(), + ) + : Text(L10n.of(context).delete), + ), + ), + OutlinedButton( + onPressed: Navigator.of(context).pop, + child: Text(L10n.of(context).cancel), + ), + ], + ), + ), + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: _deleteError != null + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Text(L10n.of(context).oopsSomethingWentWrong), + ) + : const SizedBox(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index 3e31eb0f6..fc97ed381 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -48,7 +48,7 @@ class PracticeSelectionRepo { } static void clean() { - final Iterable keys = _storage.getKeys(); + final keys = _storage.getKeys(); if (keys.length > 300) { final entries = keys .map((key) => _parsePracticeSelection(key)) From 2ad57fb69bdb4218ac47c23d18b3d902814f27f2 Mon Sep 17 00:00:00 2001 From: Kelrap Date: Thu, 22 May 2025 12:43:38 -0400 Subject: [PATCH 2/6] For user replies in dark mode, make replied message sender name darker for readability --- lib/pages/chat/events/reply_content.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index 82584bafd..372948cd4 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -1,9 +1,8 @@ +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import '../../../config/app_config.dart'; class ReplyContent extends StatelessWidget { @@ -69,7 +68,9 @@ class ReplyContent extends StatelessWidget { fontWeight: FontWeight.bold, // #Pangea // color: color, - color: theme.colorScheme.onSurface, + color: ownMessage && theme.brightness == Brightness.dark + ? theme.colorScheme.tertiaryContainer + : theme.colorScheme.onSurface, // Pangea# fontSize: fontSize, ), From 9d84082ac85ff814f06f27cb9d2d853e07e81ac1 Mon Sep 17 00:00:00 2001 From: Kelrap Date: Thu, 22 May 2025 12:53:25 -0400 Subject: [PATCH 3/6] Darken decoration bar left of reply on light background --- lib/pages/chat/events/reply_content.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index 372948cd4..24421c9cb 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -31,10 +31,16 @@ class ReplyContent extends StatelessWidget { timeline != null ? replyEvent.getDisplayEvent(timeline) : replyEvent; final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; final color = theme.brightness == Brightness.dark - ? theme.colorScheme.onTertiaryContainer - : ownMessage + // Pangea# + ? ownMessage ? theme.colorScheme.tertiaryContainer - : theme.colorScheme.tertiary; + : theme.colorScheme.onTertiaryContainer + : theme.colorScheme.tertiary; + // ? theme.colorScheme.onTertiaryContainer + // : ownMessage + // ? theme.colorScheme.tertiaryContainer + // : theme.colorScheme.tertiary; + // Pangea# return Material( color: Colors.transparent, From e96a16b297c8d9b69987ce8cac443fa17614cd9c Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 22 May 2025 13:00:42 -0400 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20fully=20update=20match=20info=20af?= =?UTF-8?q?ter=20auto-accepting=20replacement,=20add=20=E2=80=A6=20(#2866)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: fully update match info after auto-accepting replacement, add more error handling in construct token span * bump version * fix: don't stop activity language on fail to fetch image URL * fix: don't show copy class code buttons into class code is null * fix: use activity type enum name in key instead of string --- lib/pages/chat/chat.dart | 11 +- .../invitation_selection_view.dart | 160 +++++++++--------- lib/pages/new_group/new_group.dart | 5 +- .../activity_planner/activity_plan_card.dart | 25 ++- .../widgets/class_invitation_buttons.dart | 5 +- .../controllers/choreographer.dart | 18 +- .../controllers/error_service.dart | 10 ++ .../controllers/igc_controller.dart | 3 + .../models/igc_text_data_model.dart | 65 +++++-- .../widgets/igc/pangea_text_controller.dart | 30 +++- .../room_space_settings_extension.dart | 6 +- .../activity_type_enum.dart | 19 --- .../message_activity_request.dart | 2 +- .../practice_activity_model.dart | 2 +- .../practice_selection_repo.dart | 2 +- .../practice_activities/practice_target.dart | 17 +- pubspec.yaml | 2 +- 17 files changed, 236 insertions(+), 146 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 754a9df95..815682d7c 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -867,10 +867,13 @@ class ChatController extends State pangeaEditingEvent = previousEdit; } - GoogleAnalytics.sendMessage( - room.id, - room.classCode(context), - ); + final spaceCode = room.classCode(context); + if (spaceCode != null) { + GoogleAnalytics.sendMessage( + room.id, + spaceCode, + ); + } if (msgEventId == null) { ErrorHandler.logError( diff --git a/lib/pages/invitation_selection/invitation_selection_view.dart b/lib/pages/invitation_selection/invitation_selection_view.dart index a27148870..0877cc645 100644 --- a/lib/pages/invitation_selection/invitation_selection_view.dart +++ b/lib/pages/invitation_selection/invitation_selection_view.dart @@ -63,98 +63,104 @@ class InvitationSelectionView extends StatelessWidget { child: Column( children: [ // #Pangea - Padding( - padding: const EdgeInsets.all(16.0), - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(99), - ), - onTap: () async { - await Clipboard.setData( - ClipboardData(text: room.classCode(context)), - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).copiedToClipboard)), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 12.0, - horizontal: 16.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, + if (room.isSpace && room.classCode(context) != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: InkWell( + customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(99), ), - child: Row( - spacing: 16.0, - children: [ - const Icon( - Icons.copy_outlined, - size: 20.0, + onTap: () async { + await Clipboard.setData( + ClipboardData(text: room.classCode(context)!), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).copiedToClipboard), ), - Text( - "${L10n.of(context).copyClassCode}: ${room.classCode(context)}", - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - fontSize: 16.0, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 16.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(99), + ), + child: Row( + spacing: 16.0, + children: [ + const Icon( + Icons.copy_outlined, + size: 20.0, ), - ), - ], + Text( + "${L10n.of(context).copyClassCode}: ${room.classCode(context)}", + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + fontSize: 16.0, + ), + ), + ], + ), ), ), ), - ), - Padding( - padding: const EdgeInsets.only( - bottom: 16.0, - left: 16.0, - right: 16.0, - ), - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(99), + if (room.isSpace && room.classCode(context) != null) + Padding( + padding: const EdgeInsets.only( + bottom: 16.0, + left: 16.0, + right: 16.0, ), - onTap: () async { - final String initialUrl = - kIsWeb ? html.window.origin! : Environment.frontendURL; - final link = - "$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode(context)}"; - await Clipboard.setData(ClipboardData(text: link)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).copiedToClipboard)), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 12.0, - horizontal: 16.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, + child: InkWell( + customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(99), ), - child: Row( - spacing: 16.0, - children: [ - const Icon( - Icons.copy_outlined, - size: 20.0, + onTap: () async { + final String initialUrl = + kIsWeb ? html.window.origin! : Environment.frontendURL; + final link = + "$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode(context)}"; + await Clipboard.setData(ClipboardData(text: link)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).copiedToClipboard), ), - Text( - L10n.of(context).copyClassLink, - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - fontSize: 16.0, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 16.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(99), + ), + child: Row( + spacing: 16.0, + children: [ + const Icon( + Icons.copy_outlined, + size: 20.0, ), - ), - ], + Text( + L10n.of(context).copyClassLink, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + fontSize: 16.0, + ), + ), + ], + ), ), ), ), - ), // Pangea# Padding( // #Pangea diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index b9d8467d0..b2b86036e 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -237,7 +237,10 @@ class NewGroupController extends State { room = client.getRoomById(spaceId); } if (room == null) return; - GoogleAnalytics.createClass(room.name, room.classCode(context)); + final spaceCode = room.classCode(context); + if (spaceCode != null) { + GoogleAnalytics.createClass(room.name, spaceCode); + } try { await room.invite(BotName.byEnvironment); } catch (err) { diff --git a/lib/pangea/activity_planner/activity_plan_card.dart b/lib/pangea/activity_planner/activity_plan_card.dart index 0d721e11e..c735f253a 100644 --- a/lib/pangea/activity_planner/activity_plan_card.dart +++ b/lib/pangea/activity_planner/activity_plan_card.dart @@ -174,12 +174,25 @@ class ActivityPlanCardState extends State { } Future _setAvatarByImageURL() async { - if (_avatar != null || _imageURL == null) return; - final resp = await http - .get(Uri.parse(_imageURL!)) - .timeout(const Duration(seconds: 5)); - if (mounted) { - setState(() => _avatar = resp.bodyBytes); + try { + if (_avatar != null || _imageURL == null) return; + final resp = await http + .get(Uri.parse(_imageURL!)) + .timeout(const Duration(seconds: 5)); + if (mounted) { + setState(() => _avatar = resp.bodyBytes); + } + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "imageURL": _imageURL, + }, + ); + if (mounted) { + setState(() => _avatar = null); + } } } diff --git a/lib/pangea/chat_settings/widgets/class_invitation_buttons.dart b/lib/pangea/chat_settings/widgets/class_invitation_buttons.dart index 8f2d6ad03..6e2dac6e5 100644 --- a/lib/pangea/chat_settings/widgets/class_invitation_buttons.dart +++ b/lib/pangea/chat_settings/widgets/class_invitation_buttons.dart @@ -19,6 +19,9 @@ class ClassInvitationButtons extends StatelessWidget { Widget build(BuildContext context) { final Room? room = Matrix.of(context).client.getRoomById(roomId); if (room == null) return Text(L10n.of(context).oopsSomethingWentWrong); + if (room.classCode(context) == null) { + return const SizedBox(); + } final copyClassLinkListTile = ListTile( title: Text( @@ -67,7 +70,7 @@ class ClassInvitationButtons extends StatelessWidget { onTap: () async { //PTODO-Lala: Standarize toast //PTODO - explore using Fluffyshare for this - await Clipboard.setData(ClipboardData(text: room.classCode(context))); + await Clipboard.setData(ClipboardData(text: room.classCode(context)!)); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context).copiedToClipboard)), ); diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index da59e978d..1733716d8 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -456,11 +456,6 @@ class Choreographer { if (!isNormalizationError) continue; final match = igc.igcTextData!.matches[i]; - choreoRecord.addRecord( - _textController.text, - match: match.copyWith..status = PangeaMatchStatus.automatic, - ); - igc.igcTextData!.acceptReplacement( i, match.match.choices!.indexWhere( @@ -468,6 +463,19 @@ class Choreographer { ), ); + final newMatch = match.copyWith; + newMatch.status = PangeaMatchStatus.automatic; + newMatch.match.length = match.match.choices! + .firstWhere((c) => c.isBestCorrection) + .value + .characters + .length; + + choreoRecord.addRecord( + _textController.text, + match: newMatch, + ); + _textController.setSystemText( igc.igcTextData!.originalInput, EditType.igc, diff --git a/lib/pangea/choreographer/controllers/error_service.dart b/lib/pangea/choreographer/controllers/error_service.dart index 94d1c83f3..2021815ff 100644 --- a/lib/pangea/choreographer/controllers/error_service.dart +++ b/lib/pangea/choreographer/controllers/error_service.dart @@ -65,7 +65,17 @@ class ErrorService { return Duration(seconds: coolDownSeconds); } + final List _errorCache = []; + setError(ChoreoError? error, {Duration? duration}) { + if (_errorCache.contains(error?.raw.toString())) { + return; + } + + if (error != null) { + _errorCache.add(error.raw.toString()); + } + _error = error; Future.delayed(duration ?? defaultCooldown, () { clear(); diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index cecf03ea0..03b034283 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -293,6 +293,9 @@ class IgcController { igcTextData = null; spanDataController.clearCache(); spanDataController.dispose(); + MatrixState.pAnyState.closeAllOverlays( + filter: RegExp(r'span_card_overlay_\d+'), + ); } dispose() { diff --git a/lib/pangea/choreographer/models/igc_text_data_model.dart b/lib/pangea/choreographer/models/igc_text_data_model.dart index ac6613b58..3991aa4f7 100644 --- a/lib/pangea/choreographer/models/igc_text_data_model.dart +++ b/lib/pangea/choreographer/models/igc_text_data_model.dart @@ -292,12 +292,45 @@ class IGCTextData { // create a pointer to the current index in the original input // and iterate until the pointer has reached the end of the input int currentIndex = 0; + int loops = 0; + final List addedMatches = []; while (currentIndex < originalInput.characters.length) { + if (loops > 100) { + ErrorHandler.logError( + e: "In constructTokenSpan, infinite loop detected", + data: { + "currentIndex": currentIndex, + "matches": textSpanMatches.map((m) => m.toJson()).toList(), + }, + ); + throw "In constructTokenSpan, infinite loop detected"; + } + // check if the pointer is at a match, and if so, get the index of the match final int matchIndex = matchRanges.indexWhere( (range) => currentIndex >= range[0] && currentIndex < range[1], ); - final bool inMatch = matchIndex != -1; + final bool inMatch = matchIndex != -1 && + !addedMatches.contains( + textSpanMatches[matchIndex], + ); + + if (matchIndex != -1 && + addedMatches.contains( + textSpanMatches[matchIndex], + )) { + ErrorHandler.logError( + e: "In constructTokenSpan, currentIndex is in match that has already been added", + data: { + "currentIndex": currentIndex, + "matchIndex": matchIndex, + "matches": textSpanMatches.map((m) => m.toJson()).toList(), + }, + ); + throw "In constructTokenSpan, currentIndex is in match that has already been added"; + } + + final prevIndex = currentIndex; if (inMatch) { // if the pointer is in a match, then add that match to items @@ -312,13 +345,7 @@ class IGCTextData { final span = originalInput.characters .getRange( match.match.offset, - match.match.offset + - (match.match.choices - ?.firstWhere((c) => c.isBestCorrection) - .value - .characters - .length ?? - match.match.length), + match.match.offset + match.match.length, ) .toString(); @@ -364,12 +391,8 @@ class IGCTextData { ), ); - currentIndex = match.match.offset + - (match.match.choices - ?.firstWhere((c) => c.isBestCorrection) - .value - .length ?? - match.match.length); + addedMatches.add(match); + currentIndex = match.match.offset + match.match.length; } else { items.add( getSpanItem( @@ -400,6 +423,20 @@ class IGCTextData { ); currentIndex = nextIndex; } + + if (prevIndex >= currentIndex) { + ErrorHandler.logError( + e: "In constructTokenSpan, currentIndex is less than prevIndex", + data: { + "currentIndex": currentIndex, + "prevIndex": prevIndex, + "matches": textSpanMatches.map((m) => m.toJson()).toList(), + }, + ); + throw "In constructTokenSpan, currentIndex is less than prevIndex"; + } + + loops++; } return items; diff --git a/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart b/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart index d887eebbf..50ad90e68 100644 --- a/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart +++ b/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart'; import 'package:fluffychat/pangea/choreographer/models/igc_text_data_model.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; @@ -174,18 +175,29 @@ class PangeaTextController extends TextEditingController { final choreoSteps = choreographer.choreoRecord.choreoSteps; + List inlineSpans = []; + try { + inlineSpans = choreographer.igc.igcTextData!.constructTokenSpan( + choreoSteps: choreoSteps.isNotEmpty && + choreoSteps.last.acceptedOrIgnoredMatch?.status == + PangeaMatchStatus.automatic + ? choreoSteps + : [], + defaultStyle: style, + onUndo: choreographer.onUndoReplacement, + ); + } catch (e) { + choreographer.errorService.setError( + ChoreoError(type: ChoreoErrorType.unknown, raw: e), + ); + inlineSpans = [TextSpan(text: text, style: style)]; + choreographer.igc.clear(); + } + return TextSpan( style: style, children: [ - ...choreographer.igc.igcTextData!.constructTokenSpan( - choreoSteps: choreoSteps.isNotEmpty && - choreoSteps.last.acceptedOrIgnoredMatch?.status == - PangeaMatchStatus.automatic - ? choreoSteps - : [], - defaultStyle: style, - onUndo: choreographer.onUndoReplacement, - ), + ...inlineSpans, TextSpan(text: parts[1], style: style), ], ); diff --git a/lib/pangea/extensions/room_space_settings_extension.dart b/lib/pangea/extensions/room_space_settings_extension.dart index 3bd94f106..e5cdf1721 100644 --- a/lib/pangea/extensions/room_space_settings_extension.dart +++ b/lib/pangea/extensions/room_space_settings_extension.dart @@ -1,14 +1,14 @@ part of "pangea_room_extension.dart"; extension SpaceRoomExtension on Room { - String classCode(BuildContext context) { + String? classCode(BuildContext context) { if (!isSpace) { for (final Room potentialClassRoom in pangeaSpaceParents) { if (potentialClassRoom.isSpace) { return SpaceRoomExtension(potentialClassRoom).classCode(context); } } - return L10n.of(context).notInClass; + return null; } final roomJoinRules = getState(EventTypes.RoomJoinRules, ""); if (roomJoinRules != null) { @@ -17,7 +17,7 @@ extension SpaceRoomExtension on Room { return accessCode; } } - return L10n.of(context).noClassCode; + return null; } void checkClass() { diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index 51e8ae106..2fe80c418 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -15,25 +15,6 @@ enum ActivityTypeEnum { } extension ActivityTypeExtension on ActivityTypeEnum { - String get string { - switch (this) { - case ActivityTypeEnum.wordMeaning: - return 'word_meaning'; - case ActivityTypeEnum.wordFocusListening: - return 'word_focus_listening'; - case ActivityTypeEnum.hiddenWordListening: - return 'hidden_word_listening'; - case ActivityTypeEnum.lemmaId: - return 'lemma_id'; - case ActivityTypeEnum.emoji: - return 'emoji'; - case ActivityTypeEnum.morphId: - return 'morph_id'; - case ActivityTypeEnum.messageMeaning: - return 'message_meaning'; // TODO: Add to L10n - } - } - bool get hiddenType { switch (this) { case ActivityTypeEnum.wordMeaning: diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 78907d196..f3427514e 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -83,7 +83,7 @@ class MessageActivityRequest { 'message_tokens': messageTokens.map((e) => e.toJson()).toList(), 'activity_quality_feedback': activityQualityFeedback?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), - 'target_type': targetType.string, + 'target_type': targetType.name, 'target_morph_feature': targetMorphFeature, }; } diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index 5dee155aa..68a0ce6b0 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -326,7 +326,7 @@ class PracticeActivityModel { Map toJson() { return { 'lang_code': langCode, - 'activity_type': activityType.string, + 'activity_type': activityType.name, 'content': multipleChoiceContent?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), 'match_content': matchContent?.toJson(), diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index 3e31eb0f6..fc97ed381 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -48,7 +48,7 @@ class PracticeSelectionRepo { } static void clean() { - final Iterable keys = _storage.getKeys(); + final keys = _storage.getKeys(); if (keys.length > 300) { final entries = keys .map((key) => _parsePracticeSelection(key)) diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index df034d20b..fbb711f2a 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -2,6 +2,8 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:collection/collection.dart'; + import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -60,13 +62,22 @@ class PracticeTarget { userL2.hashCode; static PracticeTarget fromJson(Map json) { + final type = ActivityTypeEnum.values.firstWhereOrNull( + (v) => json['activityType'] == v.name, + ); + if (type == null) { + throw Exception( + "ActivityTypeEnum ${json['activityType']} not found in enum", + ); + } + return PracticeTarget( tokens: (json['tokens'] as List).map((e) => PangeaToken.fromJson(e)).toList(), - activityType: ActivityTypeEnum.values[json['activityType']], + activityType: type, morphFeature: json['morphFeature'] == null ? null - : MorphFeaturesEnum.values[json['morphFeature']], + : MorphFeaturesEnumExtension.fromString(json['morphFeature']), userL2: json['userL2'], ); } @@ -83,7 +94,7 @@ class PracticeTarget { //unique condensed deterministic key for local storage String get storageKey { return tokens.map((e) => e.text.content).join() + - activityType.string + + activityType.name + (morphFeature?.name ?? ""); } diff --git a/pubspec.yaml b/pubspec.yaml index 894a183f4..9b9574d26 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 4.1.10+1 +version: 4.1.10+2 environment: sdk: ">=3.0.0 <4.0.0" From 97876e59183827705a3d5ef937aefa2d8d80ad4a Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 22 May 2025 13:02:57 -0400 Subject: [PATCH 5/6] Merge prod into main (#2867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: fully update match info after auto-accepting replacement, add more error handling in construct token span * bump version * fix: don't stop activity language on fail to fetch image URL * fix: don't show copy class code buttons into class code is null * fix: use activity type enum name in key instead of string * chore: fully update match info after auto-accepting replacement, add … (#2866) * chore: fully update match info after auto-accepting replacement, add more error handling in construct token span * bump version * fix: don't stop activity language on fail to fetch image URL * fix: don't show copy class code buttons into class code is null * fix: use activity type enum name in key instead of string --- .../activity_type_enum.dart | 19 ------------------- .../message_activity_request.dart | 2 +- .../practice_activity_model.dart | 2 +- .../practice_activities/practice_target.dart | 17 ++++++++++++++--- pubspec.yaml | 2 +- 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index 51e8ae106..2fe80c418 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -15,25 +15,6 @@ enum ActivityTypeEnum { } extension ActivityTypeExtension on ActivityTypeEnum { - String get string { - switch (this) { - case ActivityTypeEnum.wordMeaning: - return 'word_meaning'; - case ActivityTypeEnum.wordFocusListening: - return 'word_focus_listening'; - case ActivityTypeEnum.hiddenWordListening: - return 'hidden_word_listening'; - case ActivityTypeEnum.lemmaId: - return 'lemma_id'; - case ActivityTypeEnum.emoji: - return 'emoji'; - case ActivityTypeEnum.morphId: - return 'morph_id'; - case ActivityTypeEnum.messageMeaning: - return 'message_meaning'; // TODO: Add to L10n - } - } - bool get hiddenType { switch (this) { case ActivityTypeEnum.wordMeaning: diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 78907d196..f3427514e 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -83,7 +83,7 @@ class MessageActivityRequest { 'message_tokens': messageTokens.map((e) => e.toJson()).toList(), 'activity_quality_feedback': activityQualityFeedback?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), - 'target_type': targetType.string, + 'target_type': targetType.name, 'target_morph_feature': targetMorphFeature, }; } diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index 5dee155aa..68a0ce6b0 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -326,7 +326,7 @@ class PracticeActivityModel { Map toJson() { return { 'lang_code': langCode, - 'activity_type': activityType.string, + 'activity_type': activityType.name, 'content': multipleChoiceContent?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), 'match_content': matchContent?.toJson(), diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index df034d20b..fbb711f2a 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -2,6 +2,8 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:collection/collection.dart'; + import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -60,13 +62,22 @@ class PracticeTarget { userL2.hashCode; static PracticeTarget fromJson(Map json) { + final type = ActivityTypeEnum.values.firstWhereOrNull( + (v) => json['activityType'] == v.name, + ); + if (type == null) { + throw Exception( + "ActivityTypeEnum ${json['activityType']} not found in enum", + ); + } + return PracticeTarget( tokens: (json['tokens'] as List).map((e) => PangeaToken.fromJson(e)).toList(), - activityType: ActivityTypeEnum.values[json['activityType']], + activityType: type, morphFeature: json['morphFeature'] == null ? null - : MorphFeaturesEnum.values[json['morphFeature']], + : MorphFeaturesEnumExtension.fromString(json['morphFeature']), userL2: json['userL2'], ); } @@ -83,7 +94,7 @@ class PracticeTarget { //unique condensed deterministic key for local storage String get storageKey { return tokens.map((e) => e.text.content).join() + - activityType.string + + activityType.name + (morphFeature?.name ?? ""); } diff --git a/pubspec.yaml b/pubspec.yaml index 7ca3398e2..b8b738d3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 4.1.10+1 +version: 4.1.10+2 environment: sdk: ">=3.0.0 <4.0.0" From b5b06dea4076aa33f3cfb79ea56abcb6418751cd Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 22 May 2025 13:46:18 -0400 Subject: [PATCH 6/6] chore: formatting --- lib/pages/chat/events/reply_content.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index 24421c9cb..be8d67198 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -1,8 +1,9 @@ -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:flutter/material.dart'; + import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import '../../../config/app_config.dart'; class ReplyContent extends StatelessWidget {