diff --git a/.github/workflows/main_deploy.yaml b/.github/workflows/main_deploy.yaml index 46f909e1f..9439b75ad 100644 --- a/.github/workflows/main_deploy.yaml +++ b/.github/workflows/main_deploy.yaml @@ -3,13 +3,23 @@ name: Main Deploy Workflow on: push: branches: - - main + - development workflow_dispatch: env: WEB_APP_ENV: ${{ vars.WEB_APP_ENV }} jobs: + switch-branch: + runs-on: ubuntu-latest + + steps: + - name: Checkout main branch + uses: actions/checkout@v3 + + - name: Checkout different branch + run: git checkout development + build_web: runs-on: ubuntu-latest environment: staging diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 930cd20f7..d02b12f1a 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3065,7 +3065,7 @@ "type": "text", "placeholders": {} }, - "toggleToolSettingsDescription": "Here you can toggle your individual language tool settings. For chats within a space, the space settings will take precedence and may override these settings.", + "toggleToolSettingsDescription": "Here you can toggle your individual language tool settings.", "connectedToStaging": "You are connected to the staging server.", "@connectedToStaging": { "type": "text", diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 9cd0d9696..c0a444c4b 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -15,12 +15,15 @@ import 'package:fluffychat/pages/chat/event_info_dialog.dart'; import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; +import 'package:fluffychat/pangea/models/game_state_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; +import 'package:fluffychat/pangea/pages/games/story_game/round_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/utils/report_message.dart'; @@ -112,9 +115,12 @@ class ChatController extends State with WidgetsBindingObserver { // #Pangea final PangeaController pangeaController = MatrixState.pangeaController; - late Choreographer choreographer = Choreographer(pangeaController, this); + + /// Model of the current story game round + GameRoundModel? currentRound; // Pangea# + Room get room => sendingClient.getRoomById(roomId) ?? widget.room; late Client sendingClient; @@ -294,6 +300,27 @@ class ChatController extends State } } + // #Pangea + /// Recursive function that sets the current round, waits for it to + /// finish, sets it, etc. until the chat view is no longer mounted. + void setRound() { + currentRound?.dispose(); + currentRound = GameRoundModel(room: room); + room.client.onRoomState.stream.firstWhere((update) { + if (update.roomId != roomId) return false; + if (update.state is! Event) return false; + if ((update.state as Event).type != PangeaEventTypes.storyGame) { + return false; + } + + final game = GameModel.fromJson((update.state as Event).content); + return game.previousRoundEndTime != null; + }).then((_) { + if (mounted) setRound(); + }); + } + // Pangea# + @override void initState() { scrollController.addListener(_updateScrollController); @@ -309,6 +336,7 @@ class ChatController extends State sendingClient = Matrix.of(context).client; WidgetsBinding.instance.addObserver(this); // #Pangea + setRound(); if (!mounted) return; Future.delayed(const Duration(seconds: 1), () async { if (!mounted) return; @@ -533,6 +561,7 @@ class ChatController extends State //#Pangea choreographer.stateListener.close(); choreographer.dispose(); + currentRound?.dispose(); //Pangea# super.dispose(); } diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 9bca32169..1e216094a 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/pages/chat/typing_indicators.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/pangea/enum/instructions_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/widgets/chat/locked_chat_message.dart'; import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; @@ -27,7 +28,21 @@ class ChatEventList extends StatelessWidget { final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0; final events = controller.timeline!.events - .where((event) => event.isVisibleInGui) + .where( + (event) => + event.isVisibleInGui + // #Pangea + && + // In story game, hide messages sent by non-bot users in previous round + (event.type != EventTypes.Message || + event.senderId == BotName.byEnvironment || + controller.currentRound?.previousRoundEnd == null || + event.originServerTs.isAfter( + controller.currentRound!.previousRoundEnd!, + )) + // Pangea# + , + ) .toList(); final animateInEventIndex = controller.animateInEventIndex; diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index ea48c202d..689ac4e70 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; import 'package:fluffychat/pangea/widgets/chat/chat_floating_action_button.dart'; +import 'package:fluffychat/pangea/widgets/chat/round_timer.dart'; import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/widgets/chat_settings_popup_menu.dart'; import 'package:fluffychat/widgets/connection_status_header.dart'; @@ -120,6 +121,10 @@ class ChatView extends StatelessWidget { // #Pangea } else { return [ + RoundTimer(controller: controller), + const SizedBox( + width: 10, + ), ChatSettingsPopupMenu( controller.room, (!controller.room.isDirectChat && !controller.room.isArchived), diff --git a/lib/pages/chat/event_info_dialog.dart b/lib/pages/chat/event_info_dialog.dart index 38acdc84c..8b8f1e703 100644 --- a/lib/pages/chat/event_info_dialog.dart +++ b/lib/pages/chat/event_info_dialog.dart @@ -1,14 +1,12 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; extension EventInfoDialogExtension on Event { void showInfoDialog(BuildContext context) => showAdaptiveBottomSheet( @@ -49,15 +47,16 @@ class EventInfoDialog extends StatelessWidget { children: [ ListTile( leading: Avatar( - mxContent: event.senderFromMemoryOrFallback.avatarUrl, - name: event.senderFromMemoryOrFallback.calcDisplayname(), + // mxContent: event.senderFromMemoryOrFallback.avatarUrl, + // name: event.senderFromMemoryOrFallback.calcDisplayname(), + name: "?", client: event.room.client, - presenceUserId: event.senderId, + // presenceUserId: event.senderId, ), title: Text(L10n.of(context)!.sender), - subtitle: Text( - '${event.senderFromMemoryOrFallback.calcDisplayname()} [${event.senderId}]', - ), + // subtitle: Text( + // '${event.senderFromMemoryOrFallback.calcDisplayname()} [${event.senderId}]', + // ), ), ListTile( title: Text(L10n.of(context)!.time), diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 3a6b7030c..5c0a3ae29 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -263,9 +263,10 @@ class Message extends StatelessWidget { final user = snapshot.data ?? event.senderFromMemoryOrFallback; return Avatar( - mxContent: user.avatarUrl, - name: user.calcDisplayname(), - presenceUserId: user.stateKey, + // mxContent: user.avatarUrl, + // name: user.calcDisplayname(), + // presenceUserId: user.stateKey, + name: "?", presenceBackgroundColor: avatarPresenceBackgroundColor, onTap: () => onAvatarTab(event), @@ -288,10 +289,11 @@ class Message extends StatelessWidget { : FutureBuilder( future: event.fetchSenderUser(), builder: (context, snapshot) { - final displayname = snapshot.data - ?.calcDisplayname() ?? - event.senderFromMemoryOrFallback - .calcDisplayname(); + // final displayname = snapshot.data + // ?.calcDisplayname() ?? + // event.senderFromMemoryOrFallback + // .calcDisplayname(); + const displayname = "?"; return Text( displayname, style: TextStyle( diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 5468b59e8..2010e47f2 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -96,12 +96,16 @@ class MessageContent extends StatelessWidget { ListTile( contentPadding: EdgeInsets.zero, leading: Avatar( - mxContent: sender.avatarUrl, - name: sender.calcDisplayname(), - presenceUserId: sender.stateKey, + // mxContent: sender.avatarUrl, + // name: sender.calcDisplayname(), + // presenceUserId: sender.stateKey, + name: "?", client: event.room.client, ), - title: Text(sender.calcDisplayname()), + title: const Text( + // sender.calcDisplayname(), + "?", + ), subtitle: Text(event.originServerTs.localizedTime(context)), trailing: const Icon(Icons.lock_outlined), ), @@ -265,9 +269,10 @@ class MessageContent extends StatelessWidget { builder: (context, snapshot) { final reason = event.redactedBecause?.content.tryGet('reason'); - final redactedBy = snapshot.data?.calcDisplayname() ?? - event.redactedBecause?.senderId.localpart ?? - L10n.of(context)!.user; + // final redactedBy = snapshot.data?.calcDisplayname() ?? + // event.redactedBecause?.senderId.localpart ?? + // L10n.of(context)!.user; + const redactedBy = "?"; return _ButtonContent( label: reason == null ? L10n.of(context)!.redactedBy(redactedBy) @@ -385,8 +390,9 @@ class MessageContent extends StatelessWidget { builder: (context, snapshot) { return _ButtonContent( label: L10n.of(context)!.userSentUnknownEvent( - snapshot.data?.calcDisplayname() ?? - event.senderFromMemoryOrFallback.calcDisplayname(), + // snapshot.data?.calcDisplayname() ?? + // event.senderFromMemoryOrFallback.calcDisplayname(), + "?", event.type, ), icon: 'ℹ️', diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index b48f16cd9..a31765d38 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -59,7 +59,8 @@ class ReplyContent extends StatelessWidget { future: displayEvent.fetchSenderUser(), builder: (context, snapshot) { return Text( - '${snapshot.data?.calcDisplayname() ?? displayEvent.senderFromMemoryOrFallback.calcDisplayname()}:', + // '${snapshot.data?.calcDisplayname() ?? displayEvent.senderFromMemoryOrFallback.calcDisplayname()}:', + '?:', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( diff --git a/lib/pages/chat/seen_by_row.dart b/lib/pages/chat/seen_by_row.dart index af8b82069..21fd6794e 100644 --- a/lib/pages/chat/seen_by_row.dart +++ b/lib/pages/chat/seen_by_row.dart @@ -37,9 +37,10 @@ class SeenByRow extends StatelessWidget { ? seenByUsers.sublist(0, maxAvatars) : seenByUsers) .map( - (user) => Avatar( - mxContent: user.avatarUrl, - name: user.calcDisplayname(), + (user) => const Avatar( + // mxContent: user.avatarUrl, + // name: user.calcDisplayname(), + name: "?", size: 16, ), ), diff --git a/lib/pages/chat/typing_indicators.dart b/lib/pages/chat/typing_indicators.dart index 35fbf5d25..e02d59d1e 100644 --- a/lib/pages/chat/typing_indicators.dart +++ b/lib/pages/chat/typing_indicators.dart @@ -1,12 +1,11 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; class TypingIndicators extends StatelessWidget { final ChatController controller; @@ -56,10 +55,11 @@ class TypingIndicators extends StatelessWidget { child: Stack( children: [ if (typingUsers.isNotEmpty) - Avatar( + const Avatar( size: avatarSize, - mxContent: typingUsers.first.avatarUrl, - name: typingUsers.first.calcDisplayname(), + // mxContent: typingUsers.first.avatarUrl, + // name: typingUsers.first.calcDisplayname(), + name: "?", ), if (typingUsers.length == 2) Padding( @@ -69,9 +69,10 @@ class TypingIndicators extends StatelessWidget { mxContent: typingUsers.length == 2 ? typingUsers.last.avatarUrl : null, - name: typingUsers.length == 2 - ? typingUsers.last.calcDisplayname() - : '+${typingUsers.length - 1}', + // name: typingUsers.length == 2 + // ? typingUsers.last.calcDisplayname() + // : '+${typingUsers.length - 1}', + name: "?", ), ), ], diff --git a/lib/pages/chat_details/participant_list_item.dart b/lib/pages/chat_details/participant_list_item.dart index 9b5580692..4f63a6238 100644 --- a/lib/pages/chat_details/participant_list_item.dart +++ b/lib/pages/chat_details/participant_list_item.dart @@ -39,9 +39,10 @@ class ParticipantListItem extends StatelessWidget { ), title: Row( children: [ - Expanded( + const Expanded( child: Text( - user.calcDisplayname(), + // user.calcDisplayname(), + "?", overflow: TextOverflow.ellipsis, ), ), @@ -88,8 +89,9 @@ class ParticipantListItem extends StatelessWidget { subtitle: Text(user.id), leading: Avatar( mxContent: user.avatarUrl, - name: user.calcDisplayname(), - presenceUserId: user.stateKey, + // name: user.calcDisplayname(), + // presenceUserId: user.stateKey, + name: "?", ), ), ); diff --git a/lib/pages/chat_list/chat_list_item.dart b/lib/pages/chat_list/chat_list_item.dart index 76c75e83b..54995d31d 100644 --- a/lib/pages/chat_list/chat_list_item.dart +++ b/lib/pages/chat_list/chat_list_item.dart @@ -1,11 +1,9 @@ import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/utils/get_chat_list_item_subtitle.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/room_status_extension.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; @@ -240,51 +238,52 @@ class ChatListItem extends StatelessWidget { softWrap: false, ) // #Pangea - : FutureBuilder( - future: room.lastEvent != null - ? GetChatListItemSubtitle().getSubtitle( - L10n.of(context)!, - room.lastEvent, - MatrixState.pangeaController, - ) - : Future.value(L10n.of(context)!.emptyChat), - builder: (context, snapshot) { - // Pangea# - return Text( - room.membership == Membership.invite - ? isDirectChat - ? L10n.of(context)!.invitePrivateChat - : L10n.of(context)!.inviteGroupChat - // #Pangea - : snapshot.data ?? - // Pangea# - room.lastEvent - ?.calcLocalizedBodyFallback( - MatrixLocals(L10n.of(context)!), - hideReply: true, - hideEdit: true, - plaintextBody: true, - removeMarkdown: true, - withSenderNamePrefix: !isDirectChat || - directChatMatrixId != - room.lastEvent?.senderId, - ) ?? - L10n.of(context)!.emptyChat, - softWrap: false, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: unread || room.hasNewMessages - ? FontWeight.bold - : null, - color: theme.colorScheme.onSurfaceVariant, - decoration: room.lastEvent?.redacted == true - ? TextDecoration.lineThrough - : null, - ), - ); - }, - ), + : const SizedBox(), + // FutureBuilder( + // future: room.lastEvent != null + // ? GetChatListItemSubtitle().getSubtitle( + // L10n.of(context)!, + // room.lastEvent, + // MatrixState.pangeaController, + // ) + // : Future.value(L10n.of(context)!.emptyChat), + // builder: (context, snapshot) { + // // Pangea# + // return Text( + // room.membership == Membership.invite + // ? isDirectChat + // ? L10n.of(context)!.invitePrivateChat + // : L10n.of(context)!.inviteGroupChat + // // #Pangea + // : snapshot.data ?? + // // Pangea# + // room.lastEvent + // ?.calcLocalizedBodyFallback( + // MatrixLocals(L10n.of(context)!), + // hideReply: true, + // hideEdit: true, + // plaintextBody: true, + // removeMarkdown: true, + // withSenderNamePrefix: !isDirectChat || + // directChatMatrixId != + // room.lastEvent?.senderId, + // ) ?? + // L10n.of(context)!.emptyChat, + // softWrap: false, + // maxLines: 1, + // overflow: TextOverflow.ellipsis, + // style: TextStyle( + // fontWeight: unread || room.hasNewMessages + // ? FontWeight.bold + // : null, + // color: theme.colorScheme.onSurfaceVariant, + // decoration: room.lastEvent?.redacted == true + // ? TextDecoration.lineThrough + // : null, + // ), + // ); + // }, + // ), ), const SizedBox(width: 8), // #Pangea diff --git a/lib/pages/chat_search/chat_search_message_tab.dart b/lib/pages/chat_search/chat_search_message_tab.dart index 7542d6ae9..68e627282 100644 --- a/lib/pages/chat_search/chat_search_message_tab.dart +++ b/lib/pages/chat_search/chat_search_message_tab.dart @@ -1,14 +1,12 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; class ChatSearchMessageTab extends StatelessWidget { final String searchQuery; @@ -98,9 +96,10 @@ class ChatSearchMessageTab extends StatelessWidget { } final event = events[i]; final sender = event.senderFromMemoryOrFallback; - final displayname = sender.calcDisplayname( - i18n: MatrixLocals(L10n.of(context)!), - ); + // final displayname = sender.calcDisplayname( + // i18n: MatrixLocals(L10n.of(context)!), + // ); + const displayname = "?"; return _MessageSearchResultListTile( sender: sender, displayname: displayname, diff --git a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart index ef79c0e31..7bd189288 100644 --- a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart +++ b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart @@ -22,9 +22,10 @@ class UserBottomSheetView extends StatelessWidget { Widget build(BuildContext context) { final user = controller.widget.user; final userId = (user?.id ?? controller.widget.profile?.userId)!; - final displayname = (user?.calcDisplayname() ?? - controller.widget.profile?.displayName ?? - controller.widget.profile?.userId.localpart)!; + // final displayname = (user?.calcDisplayname() ?? + // controller.widget.profile?.displayName ?? + // controller.widget.profile?.userId.localpart)!; + const displayname = "?"; final avatarUrl = user?.avatarUrl ?? controller.widget.profile?.avatarUrl; final client = Matrix.of(controller.widget.outerContext).client; @@ -39,7 +40,7 @@ class UserBottomSheetView extends StatelessWidget { title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(displayname), + const Text(displayname), PresenceBuilder( userId: userId, client: client, @@ -213,7 +214,7 @@ class UserBottomSheetView extends StatelessWidget { foregroundColor: Theme.of(context).colorScheme.onSurface, ), - label: Text( + label: const Text( displayname, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/pangea/choreographer/widgets/has_error_button.dart b/lib/pangea/choreographer/widgets/has_error_button.dart index 2cc6320f7..007820139 100644 --- a/lib/pangea/choreographer/widgets/has_error_button.dart +++ b/lib/pangea/choreographer/widgets/has_error_button.dart @@ -15,24 +15,21 @@ class ChoreographerHasErrorButton extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 56.0), - child: FloatingActionButton( - onPressed: () { - if (error.type == ChoreoErrorType.unknown) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - duration: const Duration(seconds: 5), - content: Text( - "${error.title(context)} ${error.description(context)}", - ), + return FloatingActionButton( + onPressed: () { + if (error.type == ChoreoErrorType.unknown) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: Text( + "${error.title(context)} ${error.description(context)}", ), - ); - } - }, - mini: true, - child: Icon(error.icon), - ), + ), + ); + } + }, + mini: true, + child: Icon(error.icon), ); } } diff --git a/lib/pangea/constants/game_constants.dart b/lib/pangea/constants/game_constants.dart new file mode 100644 index 000000000..6b0b22fbb --- /dev/null +++ b/lib/pangea/constants/game_constants.dart @@ -0,0 +1,3 @@ +class GameConstants { + static const int timerMaxSeconds = 120; +} diff --git a/lib/pangea/constants/model_keys.dart b/lib/pangea/constants/model_keys.dart index b42061446..e427cf098 100644 --- a/lib/pangea/constants/model_keys.dart +++ b/lib/pangea/constants/model_keys.dart @@ -119,4 +119,8 @@ class ModelKey { static const String prevEventId = "prev_event_id"; static const String prevLastUpdated = "prev_last_updated"; + + static const String gameState = "game_state"; + static const String currentRoundStartTime = "start_time"; + static const String previousRoundEndTime = "message_visible_from"; } diff --git a/lib/pangea/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart index 9ca975dc0..ab5d655a7 100644 --- a/lib/pangea/constants/pangea_event_types.dart +++ b/lib/pangea/constants/pangea_event_types.dart @@ -35,4 +35,6 @@ class PangeaEventTypes { /// A record of completion of an activity. There /// can be one per user per activity. static const activityRecord = "pangea.activity_completion"; + + static const storyGame = "p.game.story"; } diff --git a/lib/pangea/extensions/sync_update_extension.dart b/lib/pangea/extensions/sync_update_extension.dart index 6adb55c69..68c1f684a 100644 --- a/lib/pangea/extensions/sync_update_extension.dart +++ b/lib/pangea/extensions/sync_update_extension.dart @@ -1,85 +1,20 @@ import 'package:matrix/matrix.dart'; extension MembershipUpdate on SyncUpdate { - bool isMembershipUpdate(String userId) { - return isMembershipUpdateByType(Membership.join, userId) || - isMembershipUpdateByType(Membership.leave, userId) || - isMembershipUpdateByType(Membership.invite, userId); - } - - bool isMembershipUpdateByType(Membership type, String userId) { - final List? updates = getRoomUpdates(type); - if (updates?.isEmpty ?? true) { - return false; + List messages(Room chat) { + if (rooms?.join == null || + !rooms!.join!.containsKey(chat.id) || + rooms!.join![chat.id]!.timeline?.events == null) { + return []; } - for (final SyncRoomUpdate update in updates!) { - final List? events = getRoomUpdateEvents(type, update); - if (hasMembershipUpdate( - events, - type.name, - userId, - )) { - return true; - } - } - return false; - } - - List? getRoomUpdates(Membership type) { - switch (type) { - case Membership.join: - return rooms?.join?.values.toList(); - case Membership.leave: - return rooms?.leave?.values.toList(); - case Membership.invite: - return rooms?.invite?.values.toList(); - default: - return null; - } - } - - bool isSpaceChildUpdate(String activeSpaceId) { - if (rooms?.join?.isEmpty ?? true) { - return false; - } - for (final update in rooms!.join!.entries) { - final String spaceId = update.key; - final List? timelineEvents = update.value.timeline?.events; - final bool isUpdate = timelineEvents != null && - spaceId == activeSpaceId && - timelineEvents.any((event) => event.type == EventTypes.SpaceChild); - if (isUpdate) return true; - } - return false; + return rooms!.join![chat.id]!.timeline!.events! + .where( + (event) => + event.type == EventTypes.Message && + !event.eventId.startsWith("Pangea Chat"), + ) + .map((event) => Event.fromMatrixEvent(event, chat)) + .toList(); } } - -List? getRoomUpdateEvents(Membership type, SyncRoomUpdate update) { - switch (type) { - case Membership.join: - return (update as JoinedRoomUpdate).timeline?.events; - case Membership.leave: - return (update as LeftRoomUpdate).timeline?.events; - case Membership.invite: - return (update as InvitedRoomUpdate).inviteState; - default: - return null; - } -} - -bool hasMembershipUpdate( - List? events, - String membershipType, - String userId, -) { - if (events == null) { - return false; - } - return events.any( - (event) => - event.type == EventTypes.RoomMember && - event.stateKey == userId && - event.content['membership'] == membershipType, - ); -} diff --git a/lib/pangea/models/game_state_model.dart b/lib/pangea/models/game_state_model.dart new file mode 100644 index 000000000..12e1bb695 --- /dev/null +++ b/lib/pangea/models/game_state_model.dart @@ -0,0 +1,48 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/constants/model_keys.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix_api_lite/generated/model.dart'; + +class GameModel { + DateTime? currentRoundStartTime; + DateTime? previousRoundEndTime; + + GameModel({ + this.currentRoundStartTime, + this.previousRoundEndTime, + }); + + factory GameModel.fromJson(json) { + return GameModel( + currentRoundStartTime: json[ModelKey.currentRoundStartTime] != null + ? DateTime.parse(json[ModelKey.currentRoundStartTime]) + : null, + previousRoundEndTime: json[ModelKey.previousRoundEndTime] != null + ? DateTime.parse(json[ModelKey.previousRoundEndTime]) + : null, + ); + } + + Map toJson() { + final data = {}; + try { + data[ModelKey.currentRoundStartTime] = + currentRoundStartTime?.toIso8601String(); + data[ModelKey.previousRoundEndTime] = + previousRoundEndTime?.toIso8601String(); + return data; + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: e, s: s); + return data; + } + } + + StateEvent get toStateEvent => StateEvent( + content: toJson(), + type: PangeaEventTypes.storyGame, + ); +} diff --git a/lib/pangea/pages/games/story_game/round_model.dart b/lib/pangea/pages/games/story_game/round_model.dart new file mode 100644 index 000000000..3b43b86b5 --- /dev/null +++ b/lib/pangea/pages/games/story_game/round_model.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:fluffychat/pangea/constants/game_constants.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/extensions/sync_update_extension.dart'; +import 'package:fluffychat/pangea/models/game_state_model.dart'; +import 'package:fluffychat/pangea/utils/bot_name.dart'; +import 'package:matrix/matrix.dart'; + +/// A model of a game round. Manages the round's state and duration. +class GameRoundModel { + final Duration roundDuration = const Duration( + seconds: GameConstants.timerMaxSeconds, + ); + + final Room room; + + // All the below state variables are used for sending and managing + // round start and end times. Once the bot starts doing that, they should be removed. + late DateTime createdAt; + Timer? timer; + StreamSubscription? syncSubscription; + final List userMessageIDs = []; + final List botMessageIDs = []; + + GameRoundModel({ + required this.room, + }) { + createdAt = DateTime.now(); + + // if, on creation, the current round is already ongoing, + // start the timer (or reset it if the round went over) + if (currentRoundStart != null) { + final currentRoundDuration = DateTime.now().difference( + currentRoundStart!, + ); + final roundFinished = currentRoundDuration > roundDuration; + + if (roundFinished) { + endRound(); + } + } + + // listen to syncs for new bot messages to start and stop rounds + syncSubscription ??= room.client.onSync.stream.listen(_handleSync); + } + + GameModel get gameState => GameModel.fromJson( + room.getState(PangeaEventTypes.storyGame)?.content ?? {}, + ); + + DateTime? get currentRoundStart => gameState.currentRoundStartTime; + DateTime? get previousRoundEnd => gameState.previousRoundEndTime; + + void _handleSync(SyncUpdate update) { + final newMessages = update + .messages(room) + .where((msg) => msg.originServerTs.isAfter(createdAt)) + .toList(); + + final botMessages = newMessages + .where((msg) => msg.senderId == BotName.byEnvironment) + .toList(); + final userMessages = newMessages + .where((msg) => msg.senderId != BotName.byEnvironment) + .toList(); + + final hasNewBotMessage = botMessages.any( + (msg) => !botMessageIDs.contains(msg.eventId), + ); + + if (hasNewBotMessage) { + if (currentRoundStart == null) { + startRound(); + } else { + endRound(); + return; + } + } + + if (currentRoundStart != null) { + for (final message in botMessages) { + if (!botMessageIDs.contains(message.eventId)) { + botMessageIDs.add(message.eventId); + } + } + + for (final message in userMessages) { + if (!userMessageIDs.contains(message.eventId)) { + userMessageIDs.add(message.eventId); + } + } + } + } + + /// Set the start and end times of the current and previous rounds. + Future setRoundTimes({ + DateTime? currentRoundStart, + DateTime? previousRoundEnd, + }) async { + final game = GameModel.fromJson( + room.getState(PangeaEventTypes.storyGame)?.content ?? {}, + ); + + game.currentRoundStartTime = currentRoundStart; + game.previousRoundEndTime = previousRoundEnd; + + await room.client.setRoomStateWithKey( + room.id, + PangeaEventTypes.storyGame, + '', + game.toJson(), + ); + } + + /// Start a new round. + void startRound() { + setRoundTimes( + currentRoundStart: DateTime.now(), + previousRoundEnd: null, + ).then((_) => timer = Timer(roundDuration, endRound)); + } + + /// End and cleanup after the current round. + void endRound() { + syncSubscription?.cancel(); + syncSubscription = null; + + timer?.cancel(); + timer = null; + + setRoundTimes( + currentRoundStart: null, + previousRoundEnd: DateTime.now(), + ); + } + + void dispose() { + syncSubscription?.cancel(); + syncSubscription = null; + + timer?.cancel(); + timer = null; + } +} diff --git a/lib/pangea/widgets/chat/round_timer.dart b/lib/pangea/widgets/chat/round_timer.dart new file mode 100644 index 000000000..5153fee12 --- /dev/null +++ b/lib/pangea/widgets/chat/round_timer.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/constants/game_constants.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/models/game_state_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +/// Create a timer that counts down to the given time +/// Default duration is 180 seconds +class RoundTimer extends StatefulWidget { + final ChatController controller; + const RoundTimer({ + super.key, + required this.controller, + }); + + @override + RoundTimerState createState() => RoundTimerState(); +} + +class RoundTimerState extends State { + int currentSeconds = 0; + Timer? timer; + StreamSubscription? stateSubscription; + + @override + void initState() { + super.initState(); + + final roundStartTime = widget.controller.currentRound?.currentRoundStart; + if (roundStartTime != null) { + final roundDuration = DateTime.now().difference(roundStartTime).inSeconds; + if (roundDuration > GameConstants.timerMaxSeconds) return; + + currentSeconds = roundDuration; + timer = Timer.periodic(const Duration(seconds: 1), (Timer t) { + currentSeconds++; + if (currentSeconds >= GameConstants.timerMaxSeconds) { + t.cancel(); + } + setState(() {}); + }); + } + + stateSubscription = Matrix.of(context) + .client + .onRoomState + .stream + .where(isRoundUpdate) + .listen(onRoundUpdate); + } + + bool isRoundUpdate(update) { + return update.roomId == widget.controller.room.id && + update.state is Event && + (update.state as Event).type == PangeaEventTypes.storyGame; + } + + void onRoundUpdate(update) { + final GameModel gameState = GameModel.fromJson( + (update.state as Event).content, + ); + final startTime = gameState.currentRoundStartTime; + final endTime = gameState.previousRoundEndTime; + + if (startTime == null && endTime == null) return; + timer?.cancel(); + timer = null; + + // if this update is the start of a round + if (startTime != null) { + timer = Timer.periodic(const Duration(seconds: 1), (Timer t) { + currentSeconds++; + if (currentSeconds >= GameConstants.timerMaxSeconds) { + t.cancel(); + } + setState(() {}); + }); + return; + } + + // if this update is the end of a round + currentSeconds = 0; + setState(() {}); + } + + @override + void dispose() { + super.dispose(); + + stateSubscription?.cancel(); + stateSubscription = null; + + timer?.cancel(); + timer = null; + } + + int get remainingTime => GameConstants.timerMaxSeconds - currentSeconds; + + String get timerText => + '${(remainingTime ~/ 60).toString().padLeft(2, '0')}: ${(remainingTime % 60).toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color.fromARGB(255, 126, 22, 14), + child: Padding( + padding: const EdgeInsets.all(5), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(timerText), + const Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // IconButton( + // onPressed: widget.currentRound.timer == null + // ? widget.currentRound.startRound + // : null, + // icon: Icon( + // widget.currentRound.timer != null + // ? Icons.pause_circle + // : Icons.play_circle, + // ), + // ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/utils/matrix_sdk_extensions/matrix_locals.dart b/lib/utils/matrix_sdk_extensions/matrix_locals.dart index 333993442..ee0b12b02 100644 --- a/lib/utils/matrix_sdk_extensions/matrix_locals.dart +++ b/lib/utils/matrix_sdk_extensions/matrix_locals.dart @@ -184,9 +184,9 @@ class MatrixLocals extends MatrixLocalizations { @override String redactedAnEvent(Event redactedEvent) { return l10n.redactedAnEvent( - redactedEvent.redactedBecause?.senderFromMemoryOrFallback - .calcDisplayname() ?? - l10n.user, + // redactedEvent.redactedBecause?.senderFromMemoryOrFallback + // .calcDisplayname() ?? + l10n.user, ); } @@ -198,7 +198,8 @@ class MatrixLocals extends MatrixLocalizations { @override String removedBy(Event redactedEvent) { return l10n.redactedBy( - redactedEvent.senderFromMemoryOrFallback.calcDisplayname(), + // redactedEvent.senderFromMemoryOrFallback.calcDisplayname(), + "?", ); } diff --git a/lib/utils/room_status_extension.dart b/lib/utils/room_status_extension.dart index 02c70b088..277894473 100644 --- a/lib/utils/room_status_extension.dart +++ b/lib/utils/room_status_extension.dart @@ -1,5 +1,4 @@ import 'package:flutter/widgets.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; @@ -20,17 +19,21 @@ extension RoomStatusExtension on Room { } else if (typingUsers.length == 1) { typingText = L10n.of(context)!.isTyping; if (typingUsers.first.id != directChatMatrixID) { - typingText = - L10n.of(context)!.userIsTyping(typingUsers.first.calcDisplayname()); + typingText = L10n.of(context)!.userIsTyping( + // typingUsers.first.calcDisplayname(), + "?", + ); } } else if (typingUsers.length == 2) { typingText = L10n.of(context)!.userAndUserAreTyping( - typingUsers.first.calcDisplayname(), - typingUsers[1].calcDisplayname(), + // typingUsers.first.calcDisplayname(), + // typingUsers[1].calcDisplayname(), + "?", "?", ); } else if (typingUsers.length > 2) { typingText = L10n.of(context)!.userAndOthersAreTyping( - typingUsers.first.calcDisplayname(), + // typingUsers.first.calcDisplayname(), + "?", (typingUsers.length - 1).toString(), ); }