From 308bd9ee498ca07ea455b2401d13b2e0fe7a2a8c Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 14 May 2024 11:12:23 -0400 Subject: [PATCH] ensure that users' analytics rooms are consistently made for users and that teachers are added to analytics rooms are soon as possible --- assets/l10n/intl_en.arb | 4 +- lib/pages/chat/chat.dart | 6 +- lib/pages/chat/chat_input_row.dart | 8 +- lib/pages/chat_list/chat_list.dart | 45 ++- .../chat_list/client_chooser_button.dart | 2 +- lib/pages/chat_list/space_view.dart | 34 +- .../invitation_selection.dart | 4 +- .../controllers/choreographer.dart | 3 +- lib/pangea/controllers/class_controller.dart | 30 +- .../controllers/my_analytics_controller.dart | 2 +- lib/pangea/controllers/pangea_controller.dart | 11 + lib/pangea/extensions/client_extension.dart | 95 ++++- .../extensions/pangea_room_extension.dart | 324 ++++++++++++++++-- .../pages/analytics/analytics_list_tile.dart | 29 +- .../pages/analytics/base_analytics.dart | 26 +- .../pages/analytics/base_analytics_view.dart | 9 + .../class_analytics/class_analytics.dart | 7 +- .../class_analytics/class_analytics_view.dart | 2 +- .../pages/analytics/construct_list.dart | 53 +-- .../utils/chat_list_handle_space_tap.dart | 3 + lib/pangea/widgets/chat/message_toolbar.dart | 12 +- 21 files changed, 598 insertions(+), 111 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 21874cc9e..8e01db0a6 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3944,5 +3944,7 @@ "score": "Score", "accuracy": "Accuracy", "points": "Points", - "noPaymentInfo": "No payment info necessary!" + "noPaymentInfo": "No payment info necessary!", + "studentAnalyticsNotAvailable": "Student data not currently available", + "roomDataMissing": "Some data may be missing from rooms in which you are not a member." } \ No newline at end of file diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 40701e742..82a366008 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -613,14 +613,14 @@ class ChatController extends State useType: useType, ) .then( - (String? msgEventId) { + (String? msgEventId) async { // #Pangea setState(() { if (previousEdit != null) { edittingEvents.add(previousEdit.eventId); } }); - // Pangea# + GoogleAnalytics.sendMessage( room.id, room.classCode, @@ -635,6 +635,8 @@ class ChatController extends State return; } + // ensure that analytics room exists / is created for the active langCode + await room.ensureAnalyticsRoomExists(); pangeaController.myAnalytics.handleMessage( room, RecentMessageRecord( diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 0af014aef..fd8d95b5b 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -2,6 +2,7 @@ import 'package:animations/animations.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/choreographer/widgets/it_bar.dart'; import 'package:fluffychat/pangea/choreographer/widgets/send_button.dart'; +import 'package:fluffychat/pangea/constants/language_keys.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -330,7 +331,12 @@ class ChatInputRow extends StatelessWidget { bottom: 6.0, top: 3.0, ), - hintText: activel1 != null && activel2 != null + hintText: activel1 != null && + activel2 != null && + activel1.langCode != + LanguageKeys.unknownLanguage && + activel2.langCode != + LanguageKeys.unknownLanguage ? L10n.of(context)!.writeAMessageFlag( activel1.languageEmoji ?? activel1.getDisplayName(context) ?? diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index c39298d6c..f96baa040 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -6,7 +6,9 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; +import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/extensions/client_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/utils/add_to_space.dart'; import 'package:fluffychat/pangea/utils/chat_list_handle_space_tap.dart'; @@ -521,7 +523,7 @@ class ChatListController extends State _invitedSpaceSubscription = pangeaController .matrixState.client.onSync.stream .where((event) => event.rooms?.invite != null) - .listen((event) { + .listen((event) async { for (final inviteEntry in event.rooms!.invite!.entries) { if (inviteEntry.value.inviteState == null) continue; final bool isSpace = inviteEntry.value.inviteState!.any( @@ -529,17 +531,39 @@ class ChatListController extends State event.type == EventTypes.RoomCreate && event.content['type'] == 'm.space', ); - if (!isSpace) continue; - final String spaceId = inviteEntry.key; - final Room? space = pangeaController.matrixState.client.getRoomById( - spaceId, + final bool isAnalytics = inviteEntry.value.inviteState!.any( + (event) => + event.type == EventTypes.RoomCreate && + event.content['type'] == PangeaRoomTypes.analytics, ); - if (space != null) { - chatListHandleSpaceTap( - context, - this, - space, + + if (isSpace) { + final String spaceId = inviteEntry.key; + final Room? space = pangeaController.matrixState.client.getRoomById( + spaceId, ); + if (space != null) { + chatListHandleSpaceTap( + context, + this, + space, + ); + } + } + + if (isAnalytics) { + final Room? analyticsRoom = + pangeaController.matrixState.client.getRoomById(inviteEntry.key); + try { + await analyticsRoom?.join(); + } catch (err, s) { + ErrorHandler.logError( + m: "Failed to join analytics room", + e: err, + s: s, + ); + } + return; } } }); @@ -819,6 +843,7 @@ class ChatListController extends State pangeaController.afterSyncAndFirstLoginInitialization(context); await pangeaController.inviteBotToExistingSpaces(); await pangeaController.setPangeaPushRules(); + await client.migrateAnalyticsRooms(); } else { ErrorHandler.logError( m: "didn't run afterSyncAndFirstLoginInitialization because not mounted", diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 896b6dc36..966fa0789 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -69,7 +69,7 @@ class ClientChooserButton extends StatelessWidget { ), ), PopupMenuItem( - enabled: matrix.client.classesAndExchangesImIn.isNotEmpty, + enabled: matrix.client.allMyAnalyticsRooms.isNotEmpty, value: SettingsAction.myAnalytics, child: Row( children: [ diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index e7d793e3d..1d333228d 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -7,6 +7,7 @@ import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; +import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/extensions/sync_update_extension.dart'; import 'package:fluffychat/pangea/utils/archive_space.dart'; @@ -411,6 +412,18 @@ class _SpaceViewState extends State { } setState(() => refreshing = false); } + + bool includeSpaceChild(sc, matchingSpaceChildren) { + final bool isAnalyticsRoom = sc.roomType == PangeaRoomTypes.analytics; + final bool isMember = [Membership.join, Membership.invite] + .contains(Matrix.of(context).client.getRoomById(sc.roomId)?.membership); + final bool isSuggested = matchingSpaceChildren.any( + (matchingSpaceChild) => + matchingSpaceChild.roomId == sc.roomId && + matchingSpaceChild.suggested == true, + ); + return !isAnalyticsRoom && (isMember || isSuggested); + } // Pangea# @override @@ -479,7 +492,7 @@ class _SpaceViewState extends State { ) : L10n.of(context)!.youreInvited, ), - if (rootSpace.locked ?? false) + if (rootSpace.locked) const Padding( padding: EdgeInsets.only(left: 4.0), child: Icon( @@ -618,24 +631,17 @@ class _SpaceViewState extends State { .contains(spaceChild.roomId), ) .toList(); + spaceChildren = spaceChildren .where( - (spaceChild) => - matchingSpaceChildren.any( - (matchingSpaceChild) => - matchingSpaceChild.roomId == - spaceChild.roomId && - matchingSpaceChild.suggested == true, - ) || - [Membership.join, Membership.invite].contains( - Matrix.of(context) - .client - .getRoomById(spaceChild.roomId) - ?.membership, - ), + (sc) => includeSpaceChild( + sc, + matchingSpaceChildren, + ), ) .toList(); } + spaceChildren.sort((a, b) { final bool aIsSpace = a.roomType == 'm.space'; final bool bIsSpace = b.roomType == 'm.space'; diff --git a/lib/pages/invitation_selection/invitation_selection.dart b/lib/pages/invitation_selection/invitation_selection.dart index 52e128ddc..5f2bd5027 100644 --- a/lib/pages/invitation_selection/invitation_selection.dart +++ b/lib/pages/invitation_selection/invitation_selection.dart @@ -157,7 +157,6 @@ class InvitationSelectionController extends State { //#Pangea // future: () => room.invite(id), future: () async { - await room.invite(id); if (mode == InvitationSelectionMode.admin) { await inviteTeacherAction(room, id); } @@ -175,7 +174,8 @@ class InvitationSelectionController extends State { // #Pangea Future inviteTeacherAction(Room room, String id) async { - room.setPower(id, ClassDefaultValues.powerLevelOfAdmin); + await room.invite(id); + await room.setPower(id, ClassDefaultValues.powerLevelOfAdmin); if (room.isSpace) { for (final spaceChild in room.spaceChildren) { if (spaceChild.roomId == null) continue; diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 156f36cc8..3a26676c6 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -211,7 +211,8 @@ class Choreographer { final CanSendStatus canSendStatus = pangeaController.subscriptionController.canSendStatus; - if (canSendStatus != CanSendStatus.subscribed) { + if (canSendStatus != CanSendStatus.subscribed || + (!igcEnabled && !itEnabled)) { return; } diff --git a/lib/pangea/controllers/class_controller.dart b/lib/pangea/controllers/class_controller.dart index 7706d2194..c0a8e6093 100644 --- a/lib/pangea/controllers/class_controller.dart +++ b/lib/pangea/controllers/class_controller.dart @@ -120,21 +120,41 @@ class ClassController extends BaseController { if (classChunk == null) { ClassCodeUtil.messageSnack( - context, L10n.of(context)!.unableToFindClass); + context, + L10n.of(context)!.unableToFindClass, + ); return; } - if (Matrix.of(context) - .client - .rooms + if (_pangeaController.matrixState.client.rooms .any((room) => room.id == classChunk.roomId)) { setActiveSpaceIdInChatListController(classChunk.roomId); ClassCodeUtil.messageSnack(context, L10n.of(context)!.alreadyInClass); return; } await _pangeaController.matrixState.client.joinRoom(classChunk.roomId); - setActiveSpaceIdInChatListController(classChunk.roomId); + if (_pangeaController.matrixState.client.getRoomById(classChunk.roomId) == + null) { + await _pangeaController.matrixState.client.waitForRoomInSync( + classChunk.roomId, + join: true, + ); + } + + // add the user's analytics room to this joined space + // so their teachers can join them via the space hierarchy + final Room? joinedSpace = + _pangeaController.matrixState.client.getRoomById(classChunk.roomId); + + // ensure that the user has an analytics room for this space's language + await joinedSpace?.ensureAnalyticsRoomExists(); + + // when possible, add user's analytics room the to space they joined + await joinedSpace?.addAnalyticsRoomsToSpace(); + + // and invite the space's teachers to the user's analytics rooms + await joinedSpace?.inviteSpaceTeachersToAnalyticsRooms(); GoogleAnalytics.joinClass(classCode); return; } catch (err) { diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 4b7c8cc96..808355b47 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -90,7 +90,7 @@ class MyAnalyticsController { } final Room analyticsRoom = await _pangeaController.matrixState.client .getMyAnalyticsRoom(langCode); - analyticsRoom.makeSureTeachersAreInvitedToAnalyticsRoom(); + final List> saveFutures = []; for (final uses in aggregatedVocabUse.entries) { debugPrint("saving of type ${uses.value.first.constructType}"); diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart index e2ddc6714..204ef9307 100644 --- a/lib/pangea/controllers/pangea_controller.dart +++ b/lib/pangea/controllers/pangea_controller.dart @@ -17,6 +17,7 @@ import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart'; import 'package:fluffychat/pangea/controllers/user_controller.dart'; import 'package:fluffychat/pangea/controllers/word_net_controller.dart'; import 'package:fluffychat/pangea/extensions/client_extension.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/guard/p_vguard.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -272,6 +273,16 @@ class PangeaController { } Future setPangeaPushRules() async { + final List analyticsRooms = + matrixState.client.rooms.where((room) => room.isAnalyticsRoom).toList(); + + for (final Room room in analyticsRooms) { + final pushRule = room.pushRuleState; + if (pushRule != PushRuleState.dontNotify) { + await room.setPushRuleState(PushRuleState.dontNotify); + } + } + if (!(matrixState.client.globalPushRules?.override?.any( (element) => element.ruleId == PangeaEventTypes.textToSpeechRule, ) ?? diff --git a/lib/pangea/extensions/client_extension.dart b/lib/pangea/extensions/client_extension.dart index 6a6c3359d..b259d0c9e 100644 --- a/lib/pangea/extensions/client_extension.dart +++ b/lib/pangea/extensions/client_extension.dart @@ -88,7 +88,7 @@ extension PangeaClient on Client { for (final classRoom in classesAndExchangesImIn) { for (final teacher in await classRoom.teachers) { // If person requesting list of teachers is a teacher in another classroom, don't add them to the list - if (!teachers.any((e) => e.id == teacher.id) && userID != teacher.id) { + if (!teachers.any((e) => e.id == teacher.id) && userID != teacher.id) { teachers.add(teacher); } } @@ -123,7 +123,7 @@ extension PangeaClient on Client { for (final room in rooms) { if (room.partial) await room.postLoad(); } - + final Room? analyticsRoom = analyticsRoomLocal(langCode); if (analyticsRoom != null) return analyticsRoom; @@ -168,14 +168,20 @@ extension PangeaClient on Client { // BotName.localBot, BotName.byEnvironment, ], - visibility: Visibility.private, - roomAliasName: "${userID!.localpart}_${langCode}_analytics", ); if (getRoomById(roomID) == null) { // Wait for room actually appears in sync await waitForRoomInSync(roomID, join: true); } + final Room? analyticsRoom = getRoomById(roomID); + + // add this analytics room to all spaces so teachers can join them + // via the space hierarchy + await analyticsRoom?.addAnalyticsRoomToSpaces(); + + // and invite all teachers to new analytics room + await analyticsRoom?.inviteTeachersToAnalyticsRoom(); return getRoomById(roomID)!; } @@ -245,4 +251,85 @@ extension PangeaClient on Client { editEvents.add(originalEvent); return editEvents.slice(1).map((e) => e.eventId).toList(); } + + // Get all my analytics rooms + List get allMyAnalyticsRooms => rooms + .where( + (e) => e.isAnalyticsRoomOfUser(userID!), + ) + .toList(); + + // migration function to change analytics rooms' vsibility to public + // so they will appear in the space hierarchy + Future updateAnalyticsRoomVisibility() async { + final List makePublicFutures = []; + for (final Room room in allMyAnalyticsRooms) { + final visability = await getRoomVisibilityOnDirectory(room.id); + if (visability != Visibility.public) { + await setRoomVisibilityOnDirectory( + room.id, + visibility: Visibility.public, + ); + } + } + await Future.wait(makePublicFutures); + } + + // Add all the users' analytics room to all the spaces the student studies in + // So teachers can join them via space hierarchy + // Will not always work, as there may be spaces where students don't have permission to add chats + // But allows teachers to join analytics rooms without being invited + Future addAnalyticsRoomsToAllSpaces() async { + final List addFutures = []; + for (final Room room in allMyAnalyticsRooms) { + addFutures.add(room.addAnalyticsRoomToSpaces()); + } + await Future.wait(addFutures); + } + + // Invite teachers to all my analytics room + // Handles case when students cannot add analytics room to space(s) + // So teacher is still able to get analytics data for this student + Future inviteAllTeachersToAllAnalyticsRooms() async { + final List inviteFutures = []; + for (final Room analyticsRoom in allMyAnalyticsRooms) { + inviteFutures.add(analyticsRoom.inviteTeachersToAnalyticsRoom()); + } + await Future.wait(inviteFutures); + } + + // Join all analytics rooms in all spaces + // Allows teachers to join analytics rooms without being invited + Future joinAnalyticsRoomsInAllSpaces() async { + final List joinFutures = []; + for (final Room space in (await classesAndExchangesImTeaching)) { + joinFutures.add(space.joinAnalyticsRoomsInSpace()); + } + await Future.wait(joinFutures); + } + + // Join invited analytics rooms + // Checks for invites to any student analytics rooms + // Handles case of analytics rooms that can't be added to some space(s) + Future joinInvitedAnalyticsRooms() async { + for (final Room room in rooms) { + if (room.membership == Membership.invite && room.isAnalyticsRoom) { + try { + await room.join(); + } catch (err) { + debugPrint("Failed to join analytics room ${room.id}"); + } + } + } + } + + // helper function to join all relevant analytics rooms + // and set up those rooms to be joined by relevant teachers + Future migrateAnalyticsRooms() async { + await updateAnalyticsRoomVisibility(); + await addAnalyticsRoomsToAllSpaces(); + await inviteAllTeachersToAllAnalyticsRooms(); + await joinInvitedAnalyticsRooms(); + await joinAnalyticsRoomsInAllSpaces(); + } } diff --git a/lib/pangea/extensions/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension.dart index 4e4f773db..1dec2bcbb 100644 --- a/lib/pangea/extensions/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer'; +import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; @@ -442,6 +443,7 @@ extension PangeaRoom on Room { /// save RoomAnalytics object to PangeaEventTypes.analyticsSummary event Future _createStudentAnalyticsEvent() async { try { + await postLoad(); if (!pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) { ErrorHandler.logError( m: "null powerLevels in createStudentAnalytics", @@ -453,7 +455,7 @@ extension PangeaRoom on Room { debugger(when: kDebugMode); throw Exception("null userId in createStudentAnalytics"); } - await postLoad(); + final String eventId = await client.setRoomStateWithKey( id, PangeaEventTypes.studentAnalyticsSummary, @@ -791,31 +793,6 @@ extension PangeaRoom on Room { } } - Future makeSureTeachersAreInvitedToAnalyticsRoom() async { - try { - if (!isAnalyticsRoom) { - throw Exception("not an analytics room"); - } - if (!participantListComplete) { - await requestParticipants(); - } - final toAdd = [ - ...getParticipants([Membership.invite, Membership.join]) - .map((e) => e.id), - BotName.byEnvironment, - ]; - for (final teacher in (await client.myTeachers)) { - if (!toAdd.contains(teacher.id)) { - debugPrint("inviting ${teacher.id} to analytics room"); - await invite(teacher.id); - } - } - } catch (err, stack) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: stack); - } - } - /// update state event and return eventId Future updateStateEvent(Event stateEvent) { if (stateEvent.stateKey == null) { @@ -1059,4 +1036,299 @@ extension PangeaRoom on Room { getState(PangeaEventTypes.botOptions)?.content ?? {}, ); } + + // Add analytics room to all spaces the user is a student in (1 analytics room to all spaces) + // So teachers can join them via space hierarchy + // Will not always work, as there may be spaces where students don't have permission to add chats + // But allows teachers to join analytics rooms without being invited + Future addAnalyticsRoomToSpaces() async { + if (!isAnalyticsRoomOfUser(client.userID!)) { + debugPrint("addAnalyticsRoomToSpaces called on non-analytics room"); + Sentry.addBreadcrumb( + Breadcrumb( + message: "addAnalyticsRoomToSpaces called on non-analytics room", + ), + ); + return; + } + + for (final Room space in (await client.classesAndExchangesImStudyingIn)) { + if (space.spaceChildren.any((sc) => sc.roomId == id)) continue; + if (space.canIAddSpaceChild(null)) { + try { + await space.setSpaceChild(id); + } catch (err) { + debugPrint( + "Failed to add analytics room for student ${client.userID} to space ${space.id}", + ); + Sentry.addBreadcrumb( + Breadcrumb( + message: "Failed to add analytics room to space ${space.id}", + ), + ); + } + } + } + } + + // Add all analytics rooms to space + // Similar to addAnalyticsRoomToSpaces, but all analytics room to 1 space + Future addAnalyticsRoomsToSpace() async { + if (!isSpace) { + debugPrint("addAnalyticsRoomsToSpace called on non-space room"); + Sentry.addBreadcrumb( + Breadcrumb( + message: "addAnalyticsRoomsToSpace called on non-space room", + ), + ); + return; + } + + await postLoad(); + if (!canIAddSpaceChild(null)) { + debugPrint( + "addAnalyticsRoomsToSpace called on space without add permission", + ); + Sentry.addBreadcrumb( + Breadcrumb( + message: + "addAnalyticsRoomsToSpace called on space without add permission", + ), + ); + return; + } + + final List allMyAnalyticsRooms = client.allMyAnalyticsRooms; + for (final Room analyticsRoom in allMyAnalyticsRooms) { + // add analytics room to space if it hasn't already been added + if (spaceChildren.any((sc) => sc.roomId == analyticsRoom.id)) continue; + try { + await setSpaceChild(analyticsRoom.id); + } catch (err) { + debugPrint( + "Failed to add analytics room ${analyticsRoom.id} to space $id", + ); + Sentry.addBreadcrumb( + Breadcrumb( + message: "Failed to add analytics room to space $id", + ), + ); + } + } + } + + // Invite all teachers to 1 analytics room + // Handles case when students cannot add analytics room to space + // So teacher is still able to get analytics data for this student + Future inviteTeachersToAnalyticsRoom() async { + if (client.userID == null) { + debugPrint("inviteTeachersToAnalyticsRoom called with null userId"); + Sentry.addBreadcrumb( + Breadcrumb( + message: "inviteTeachersToAnalyticsRoom called with null userId", + ), + ); + return; + } + + if (!isAnalyticsRoomOfUser(client.userID!)) { + debugPrint("inviteTeachersToAnalyticsRoom called on non-analytics room"); + Sentry.addBreadcrumb( + Breadcrumb( + message: "inviteTeachersToAnalyticsRoom called on non-analytics room", + ), + ); + return; + } + + // load all participants of analytics room + if (!participantListComplete) { + await requestParticipants(); + } + final List participants = getParticipants(); + + // invite any teachers who are not already in the room + for (final teacher in (await client.myTeachers)) { + if (!participants.any((p) => p.id == teacher.id)) { + try { + await invite(teacher.id); + } catch (err, s) { + debugPrint( + "Failed to invite teacher ${teacher.id} to analytics room $id", + ); + ErrorHandler.logError( + e: err, + m: "Failed to invite teacher ${teacher.id} to analytics room $id", + s: s, + ); + } + } + } + } + + // Invite teachers of 1 space to all users' analytics rooms + Future inviteSpaceTeachersToAnalyticsRooms() async { + if (!isSpace) { + debugPrint( + "inviteSpaceTeachersToAllAnalyticsRoom called on non-space room", + ); + Sentry.addBreadcrumb( + Breadcrumb( + message: + "inviteSpaceTeachersToAllAnalyticsRoom called on non-space room", + ), + ); + return; + } + + for (final Room analyticsRoom in client.allMyAnalyticsRooms) { + if (!analyticsRoom.participantListComplete) { + await analyticsRoom.requestParticipants(); + } + final List participants = analyticsRoom.getParticipants(); + for (final User teacher in (await teachers)) { + if (!participants.any((p) => p.id == teacher.id)) { + try { + await analyticsRoom.invite(teacher.id); + } catch (err, s) { + debugPrint( + "Failed to invite teacher ${teacher.id} to analytics room ${analyticsRoom.id}", + ); + ErrorHandler.logError( + e: err, + m: "Failed to invite teacher ${teacher.id} to analytics room ${analyticsRoom.id}", + s: s, + ); + } + } + } + } + } + + // Join analytics rooms in space + // Allows teachers to join analytics rooms without being invited + Future joinAnalyticsRoomsInSpace() async { + if (!isSpace) { + debugPrint("joinAnalyticsRoomsInSpace called on non-space room"); + Sentry.addBreadcrumb( + Breadcrumb( + message: "joinAnalyticsRoomsInSpace called on non-space room", + ), + ); + return; + } + + // added delay because without it power levels don't load and user is not + // recognized as admin + await Future.delayed(const Duration(milliseconds: 500)); + await postLoad(); + + if (!isRoomAdmin) { + debugPrint("joinAnalyticsRoomsInSpace called by non-admin"); + Sentry.addBreadcrumb( + Breadcrumb( + message: "joinAnalyticsRoomsInSpace called by non-admin", + ), + ); + return; + } + + final spaceHierarchy = await client.getSpaceHierarchy( + id, + maxDepth: 1, + ); + + final List analyticsRoomIds = spaceHierarchy.rooms + .where( + (r) => r.roomType == PangeaRoomTypes.analytics, + ) + .map((r) => r.roomId) + .toList(); + + for (final String roomID in analyticsRoomIds) { + try { + await joinSpaceChild(roomID); + } catch (err, s) { + debugPrint("Failed to join analytics room $roomID in space $id"); + ErrorHandler.logError( + e: err, + m: "Failed to join analytics room $roomID in space $id", + s: s, + ); + } + } + } + + Future joinSpaceChild(String roomID) async { + final Room? child = client.getRoomById(roomID); + if (child == null) { + await client.joinRoom( + roomID, + serverName: spaceChildren + .firstWhereOrNull((child) => child.roomId == roomID) + ?.via, + ); + if (client.getRoomById(roomID) == null) { + await client.waitForRoomInSync(roomID, join: true); + } + return; + } + + if (![Membership.invite, Membership.join].contains(child.membership)) { + final waitForRoom = client.waitForRoomInSync( + roomID, + join: true, + ); + await child.join(); + await waitForRoom; + } + } + + // check if analytics room exists for a given language code + // and if not, create it + Future ensureAnalyticsRoomExists() async { + await postLoad(); + if (firstLanguageSettings?.targetLanguage == null) return; + await client.getMyAnalyticsRoom(firstLanguageSettings!.targetLanguage); + } + + // Check if teacher is in students' analytics rooms + // To warn teachers if some data might be missing because they have + // not yet joined a students' analytics room + // Future areAllStudentAnalyticsAvailable() async { + // if (!isSpace) { + // debugPrint("areAllStudentAnalyticsAvailable called on non-space room"); + // Sentry.addBreadcrumb( + // Breadcrumb( + // message: "areAllStudentAnalyticsAvailable called on non-space room", + // ), + // ); + // return false; + // } + + // final String? spaceLangCode = firstLanguageSettings?.targetLanguage; + // if (spaceLangCode == null) { + // debugPrint( + // "areAllStudentAnalyticsAvailable called on space without language settings", + // ); + // Sentry.addBreadcrumb( + // Breadcrumb( + // message: + // "areAllStudentAnalyticsAvailable called on space without language settings", + // ), + // ); + // return false; + // } + + // for (final User student in students) { + // final Room? studentAnalyticsRoom = client.analyticsRoomLocal( + // spaceLangCode, + // student.id, + // ); + // if (studentAnalyticsRoom == null) { + // return false; + // } + // } + // return true; + // } } diff --git a/lib/pangea/pages/analytics/analytics_list_tile.dart b/lib/pangea/pages/analytics/analytics_list_tile.dart index ae81e957c..ab35b2610 100644 --- a/lib/pangea/pages/analytics/analytics_list_tile.dart +++ b/lib/pangea/pages/analytics/analytics_list_tile.dart @@ -52,7 +52,11 @@ class AnalyticsListTileState extends State { child: Opacity( opacity: widget.enabled ? 1 : 0.5, child: Tooltip( - message: widget.enabled ? "" : L10n.of(context)!.joinToView, + message: widget.enabled + ? "" + : widget.type == AnalyticsEntryType.room + ? L10n.of(context)!.joinToView + : L10n.of(context)!.studentAnalyticsNotAvailable, child: ListTile( leading: widget.type == AnalyticsEntryType.privateChats ? CircleAvatar( @@ -101,18 +105,19 @@ class AnalyticsListTileState extends State { : null, selected: widget.selected, enabled: widget.enabled, - onTap: () => - (room?.isSpace ?? false) && widget.allowNavigateOnSelect - ? context.go( - '/rooms/analytics/${room!.id}', - ) - : widget.onTap( - AnalyticsSelected( - widget.id, - widget.type, - widget.displayName, - ), + onTap: () { + (room?.isSpace ?? false) && widget.allowNavigateOnSelect + ? context.go( + '/rooms/analytics/${room!.id}', + ) + : widget.onTap( + AnalyticsSelected( + widget.id, + widget.type, + widget.displayName, ), + ); + }, trailing: (room?.isSpace ?? false) && widget.type != AnalyticsEntryType.privateChats && widget.allowNavigateOnSelect diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index 7182bc6ab..634a39980 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/extensions/client_extension.dart'; import 'package:fluffychat/pangea/pages/analytics/base_analytics_view.dart'; import 'package:fluffychat/pangea/pages/analytics/student_analytics/student_analytics.dart'; import 'package:flutter/material.dart'; @@ -142,14 +143,29 @@ class BaseAnalyticsController extends State { } bool enableSelection(AnalyticsSelected? selectedParam) { - return selectedView == BarChartViewSelection.grammar && - selectedParam?.type == AnalyticsEntryType.room - ? Matrix.of(context) + if (selectedView == BarChartViewSelection.grammar) { + if (selectedParam?.type == AnalyticsEntryType.room) { + return Matrix.of(context) .client .getRoomById(selectedParam!.id) ?.membership == - Membership.join - : true; + Membership.join; + } + + if (selectedParam?.type == AnalyticsEntryType.student) { + final String? langCode = + pangeaController.languageController.activeL2Code( + roomID: widget.defaultSelected.id, + ); + if (langCode == null) return false; + return Matrix.of(context).client.analyticsRoomLocal( + langCode, + selectedParam?.id, + ) != + null; + } + } + return true; } @override diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 1f331d2d5..86f179829 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -246,6 +246,15 @@ class BaseAnalyticsView extends StatelessWidget { .widget .tabs[1] .allowNavigateOnSelect, + enabled: + controller.enableSelection( + AnalyticsSelected( + item.id, + controller + .widget.tabs[1].type, + "", + ), + ), ), ) .toList(), diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart index 0316d02cd..877ae1788 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:developer'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/models/chart_analytics_model.dart'; @@ -103,7 +104,11 @@ class ClassAnalyticsV2Controller extends State { students = classRoom!.students; chats = response.rooms - .where((room) => room.roomId != classRoom!.id) + .where( + (room) => + room.roomId != classRoom!.id && + room.roomType != PangeaRoomTypes.analytics, + ) .toList(); chats.sort((a, b) => a.roomType == 'm.space' ? -1 : 1); } diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart index 0c0550172..dfb44e106 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart @@ -33,7 +33,7 @@ class ClassAnalyticsView extends StatelessWidget { .map( (s) => TabItem( avatar: s.avatarUrl, - displayName: s.displayName ?? "unknown", + displayName: s.calcDisplayname(), id: s.id, ), ) diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index dd6c26618..838f766a3 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -11,6 +11,7 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_ev import 'package:fluffychat/pangea/models/constructs_analytics_model.dart'; import 'package:fluffychat/pangea/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -54,7 +55,7 @@ class ConstructListState extends State { selected: widget.selected, forceUpdate: true, ) - .then((_) => setState(() => initialized = true)); + .whenComplete(() => setState(() => initialized = true)); } @override @@ -160,11 +161,11 @@ class ConstructListViewState extends State { stateSub?.cancel(); } - @override - void didUpdateWidget(ConstructListView oldWidget) { - super.didUpdateWidget(oldWidget); - fetchUses(); - } + // @override + // void didUpdateWidget(ConstructListView oldWidget) { + // super.didUpdateWidget(oldWidget); + // fetchUses(); + // } int get lemmaIndex => constructs?.indexWhere( @@ -215,19 +216,29 @@ class ConstructListViewState extends State { } setState(() => fetchingUses = true); - final List uses = currentConstruct!.content.uses; - _msgEvents.clear(); - - for (final OneConstructUse use in uses) { - final PangeaMessageEvent? msgEvent = await getMessageEvent(use); - final RepresentationEvent? repEvent = - msgEvent?.originalSent ?? msgEvent?.originalWritten; - if (repEvent?.choreo == null) { - continue; + try { + final List uses = currentConstruct!.content.uses; + _msgEvents.clear(); + + for (final OneConstructUse use in uses) { + final PangeaMessageEvent? msgEvent = await getMessageEvent(use); + final RepresentationEvent? repEvent = + msgEvent?.originalSent ?? msgEvent?.originalWritten; + if (repEvent?.choreo == null) { + continue; + } + _msgEvents.add(msgEvent!); } - _msgEvents.add(msgEvent!); + setState(() => fetchingUses = false); + } catch (err, s) { + setState(() => fetchingUses = false); + debugPrint("Error fetching uses: $err"); + ErrorHandler.logError( + e: err, + s: s, + m: "Failed to fetch uses for current construct ${currentConstruct?.content.lemma}", + ); } - setState(() => fetchingUses = false); } List? get constructs => @@ -278,12 +289,10 @@ class ConstructListViewState extends State { children: [ if (constructs![lemmaIndex].content.uses.length > _msgEvents.length) - const Center( + Center( child: Padding( - padding: EdgeInsets.all(8.0), - child: Text( - "Some data may be missing from rooms in which you are not a member.", - ), + padding: const EdgeInsets.all(8.0), + child: Text(L10n.of(context)!.roomDataMissing), ), ), Expanded( diff --git a/lib/pangea/utils/chat_list_handle_space_tap.dart b/lib/pangea/utils/chat_list_handle_space_tap.dart index ef0865634..b647458cd 100644 --- a/lib/pangea/utils/chat_list_handle_space_tap.dart +++ b/lib/pangea/utils/chat_list_handle_space_tap.dart @@ -65,6 +65,9 @@ void chatListHandleSpaceTap( context: context, future: () async { await space.join(); + if (space.isSpace) { + await space.joinAnalyticsRoomsInSpace(); + } setActiveSpaceAndCloseChat(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 5c2f083d5..b2c61a354 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -16,6 +16,7 @@ import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart'; import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart'; import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart'; +import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -62,6 +63,10 @@ class ToolbarDisplayController { if (controller.selectMode) { controller.clearSelectedEvents(); } + if (!MatrixState.pangeaController.languageController.languagesSet) { + pLanguageDialog(context, () {}); + return; + } focusNode.requestFocus(); final LayerLinkAndKey layerLinkAndKey = @@ -345,8 +350,11 @@ class MessageToolbarState extends State { Row( mainAxisSize: MainAxisSize.min, children: MessageMode.values.map((mode) { - if ([MessageMode.definition, MessageMode.textToSpeech, MessageMode.translation] - .contains(mode) && + if ([ + MessageMode.definition, + MessageMode.textToSpeech, + MessageMode.translation, + ].contains(mode) && widget.pangeaMessageEvent.isAudioMessage) { return const SizedBox.shrink(); }