diff --git a/lib/pages/chat_details/chat_details_view.dart b/lib/pages/chat_details/chat_details_view.dart index 8b496afab..24db0f582 100644 --- a/lib/pages/chat_details/chat_details_view.dart +++ b/lib/pages/chat_details/chat_details_view.dart @@ -436,7 +436,9 @@ class ChatDetailsView extends StatelessWidget { onTap: () => context.go('/rooms/${room.id}/invite'), ), - if (room.showClassEditOptions && room.isSpace) + if (room.showClassEditOptions && + room.isSpace && + !room.isSubspace) SpaceDetailsToggleAddStudentsTile( controller: controller, ), diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 803b773e1..ba350ee05 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -908,13 +908,14 @@ class ChatListController extends State // #Pangea if (mounted) { + // TODO try not to await so much GoogleAnalytics.analyticsUserUpdate(client.userID); await pangeaController.subscriptionController.initialize(); await pangeaController.myAnalytics.initialize(); pangeaController.afterSyncAndFirstLoginInitialization(context); await pangeaController.inviteBotToExistingSpaces(); await pangeaController.setPangeaPushRules(); - await client.migrateAnalyticsRooms(); + client.migrateAnalyticsRooms(); } else { ErrorHandler.logError( m: "didn't run afterSyncAndFirstLoginInitialization because not mounted", diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 2e775b8f7..27cd48616 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/extensions/sync_update_extension.dart'; import 'package:fluffychat/pangea/utils/chat_list_handle_space_tap.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:flutter/material.dart'; @@ -83,92 +84,167 @@ class _SpaceViewState extends State { // Pangea# } - Future loadHierarchy([ - String? prevBatch, - // #Pangea + // #Pangea + // Future loadHierarchy([String? prevBatch]) async { + // final activeSpaceId = widget.controller.activeSpaceId; + // if (activeSpaceId == null) return null; + // final client = Matrix.of(context).client; + + // final activeSpace = client.getRoomById(activeSpaceId); + // await activeSpace?.postLoad(); + + // setState(() { + // error = null; + // loading = true; + // }); + + // try { + // final response = await client.getSpaceHierarchy( + // activeSpaceId, + // maxDepth: 1, + // from: prevBatch, + // ); + + // if (prevBatch != null) { + // response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []); + // } + // setState(() { + // _lastResponse[activeSpaceId] = response; + // }); + // return _lastResponse[activeSpaceId]!; + // } catch (e) { + // setState(() { + // error = e; + // }); + // rethrow; + // } finally { + // setState(() { + // loading = false; + // }); + // } + // } + + /// Loads the hierarchy of the active space (or the given spaceId) and stores + /// it in _lastResponse map. If there's already a response in that map for the + /// spaceId, it will try to load the next batch and add the new rooms to the + /// already loaded ones. Displays a loading indicator while loading, and an error + /// message if an error occurs. + Future loadHierarchy({ String? spaceId, - // Pangea# - ]) async { - // #Pangea + }) async { if ((widget.controller.activeSpaceId == null && spaceId == null) || loading) { - return GetSpaceHierarchyResponse( - rooms: [], - nextBatch: null, - ); + return; } - setState(() { - error = null; - loading = true; - }); - // Pangea# + loading = true; + error = null; + setState(() {}); - // #Pangea - // final activeSpaceId = widget.controller.activeSpaceId!; - final activeSpaceId = (widget.controller.activeSpaceId ?? spaceId)!; - // Pangea# - final client = Matrix.of(context).client; + try { + await _loadHierarchy(spaceId: spaceId); + } catch (e, s) { + if (mounted) { + setState(() => error = e); + } + ErrorHandler.logError(e: e, s: s); + } finally { + if (mounted) { + setState(() => loading = false); + } + } + } + /// Internal logic of loadHierarchy. It will load the hierarchy of + /// the active space id (or specified spaceId). + Future _loadHierarchy({ + String? spaceId, + }) async { + final client = Matrix.of(context).client; + final activeSpaceId = (widget.controller.activeSpaceId ?? spaceId)!; final activeSpace = client.getRoomById(activeSpaceId); - await activeSpace?.postLoad(); - // #Pangea - // setState(() { - // error = null; - // loading = true; - // }); - // Pangea# + if (activeSpace == null) { + ErrorHandler.logError( + e: Exception('Space not found in loadHierarchy'), + data: {'spaceId': activeSpaceId}, + ); + return; + } - try { + // Load all of the space's state events. Space Child events + // are used to filtering out unsuggested, unjoined rooms. + await activeSpace.postLoad(); + + // The current number of rooms loaded for this space that are visible in the UI + final int prevLength = _lastResponse[activeSpaceId] != null + ? filterHierarchyResponse( + activeSpace, + _lastResponse[activeSpaceId]!.rooms, + ).length + : 0; + + // Failsafe to prevent too many calls to the server in a row + int callsToServer = 0; + + // Makes repeated calls to the server until 10 new visible rooms have + // been loaded, or there are no rooms left to load. Using a loop here, + // rather than one single call to the endpoint, because some spaces have + // so many invisible rooms (analytics rooms) that it might look like + // pressing the 'load more' button does nothing (Because the only rooms + // coming through from those calls are analytics rooms). + while (callsToServer < 5) { + // if this space has been loaded and there are no more rooms to load, break + if (_lastResponse[activeSpaceId] != null && + _lastResponse[activeSpaceId]!.nextBatch == null) { + break; + } + + // if this space has been loaded and 10 new rooms have been loaded, break + if (_lastResponse[activeSpaceId] != null) { + final int currentLength = filterHierarchyResponse( + activeSpace, + _lastResponse[activeSpaceId]!.rooms, + ).length; + + if (currentLength - prevLength >= 10) { + break; + } + } + + // make the call to the server final response = await client.getSpaceHierarchy( activeSpaceId, maxDepth: 1, - from: prevBatch, - // #Pangea + from: _lastResponse[activeSpaceId]?.nextBatch, limit: 100, - // Pangea# ); + callsToServer++; - if (prevBatch != null) { - response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []); - } - // #Pangea - if (mounted) { - // Pangea# - setState(() { - _lastResponse[activeSpaceId] = response; - }); - } - return _lastResponse[activeSpaceId]!; - } catch (e) { - // #Pangea - if (mounted) { - // Pangea# - setState(() { - error = e; - }); - } - rethrow; - } finally { - // #Pangea - if (activeSpace != null) { - setChatCount( - activeSpace, - _lastResponse[activeSpaceId] ?? - GetSpaceHierarchyResponse( - rooms: [], - ), + // if rooms have earlier been loaded for this space, add those + // previously loaded rooms to the front of the response list + if (_lastResponse[activeSpaceId] != null) { + response.rooms.insertAll( + 0, + _lastResponse[activeSpaceId]?.rooms ?? [], ); } - if (mounted) { - // Pangea# - setState(() { - loading = false; - }); - } + + // finally, set the response to the last response for this space + _lastResponse[activeSpaceId] = response; } + + // After making those calls to the server, set the chat count for + // this space. Used for the UI of the 'All Spaces' view + setChatCount( + activeSpace, + _lastResponse[activeSpaceId] ?? + GetSpaceHierarchyResponse( + rooms: [], + ), + ); } + // Pangea# void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async { final client = Matrix.of(context).client; @@ -479,12 +555,12 @@ class _SpaceViewState extends State { // if it's visible, and it hasn't been loaded yet, load chat count if (isRootSpace && !chatCounts.containsKey(space.id)) { - await loadHierarchy(null, space.id); + loadHierarchy(spaceId: space.id); } } } - Future refreshOnUpdate(SyncUpdate event) async { + void refreshOnUpdate(SyncUpdate event) { /* refresh on leave, invite, and space child update not join events, because there's already a listener on onTapSpaceChild, and they interfere with each other */ @@ -506,44 +582,46 @@ class _SpaceViewState extends State { widget.controller.activeSpaceId!, )) { debugPrint("refresh on update"); - await loadHierarchy(); + loadHierarchy().whenComplete(() { + if (mounted) setState(() => refreshing = false); + }); } - setState(() => refreshing = false); } - bool includeSpaceChild(sc, matchingSpaceChildren) { + bool includeSpaceChild( + Room space, + SpaceRoomsChunk hierarchyMember, + ) { if (!mounted) return false; - 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, + final bool isAnalyticsRoom = + hierarchyMember.roomType == PangeaRoomTypes.analytics; + + final bool isMember = [Membership.join, Membership.invite].contains( + Matrix.of(context).client.getRoomById(hierarchyMember.roomId)?.membership, ); + + final bool isSuggested = + space.spaceChildSuggestionStatus[hierarchyMember.roomId] ?? true; + return !isAnalyticsRoom && (isMember || isSuggested); } - List filterSpaceChildren( + List filterHierarchyResponse( Room space, - List spaceChildren, + List hierarchyResponse, ) { - final childIds = - spaceChildren.map((hierarchyMember) => hierarchyMember.roomId); - - final matchingSpaceChildren = space.spaceChildren - .where((spaceChild) => childIds.contains(spaceChild.roomId)) - .toList(); + final List filteredChildren = []; + for (final child in hierarchyResponse) { + final isDuplicate = filteredChildren.any( + (filtered) => filtered.roomId == child.roomId, + ); + if (isDuplicate) continue; - final filteredSpaceChildren = spaceChildren - .where( - (sc) => includeSpaceChild( - sc, - matchingSpaceChildren, - ), - ) - .toList(); - return filteredSpaceChildren; + if (includeSpaceChild(space, child)) { + filteredChildren.add(child); + } + } + return filteredChildren; } int sortSpaceChildren( @@ -567,7 +645,7 @@ class _SpaceViewState extends State { ) async { final Map updatedChatCounts = Map.from(chatCounts); final List spaceChildren = response?.rooms ?? []; - final filteredChildren = filterSpaceChildren(space, spaceChildren) + final filteredChildren = filterHierarchyResponse(space, spaceChildren) .where((sc) => sc.roomId != space.id) .toList(); updatedChatCounts[space.id] = filteredChildren.length; @@ -799,7 +877,7 @@ class _SpaceViewState extends State { final space = Matrix.of(context).client.getRoomById(activeSpaceId); if (space != null) { - spaceChildren = filterSpaceChildren(space, spaceChildren); + spaceChildren = filterHierarchyResponse(space, spaceChildren); } spaceChildren.sort(sortSpaceChildren); // Pangea# @@ -818,7 +896,10 @@ class _SpaceViewState extends State { onPressed: loading ? null : () { - loadHierarchy(response.nextBatch); + // #Pangea + // loadHierarchy(response.nextBatch); + loadHierarchy(); + // Pangea# }, ), ); diff --git a/lib/pages/new_space/new_space.dart b/lib/pages/new_space/new_space.dart index d76a016aa..a9a051063 100644 --- a/lib/pages/new_space/new_space.dart +++ b/lib/pages/new_space/new_space.dart @@ -173,15 +173,17 @@ class NewSpaceController extends State { addToSpaceKey.currentState!.parent, ) : null, - // initialState: [ - // if (avatar != null) - // sdk.StateEvent( - // type: sdk.EventTypes.RoomAvatar, - // content: {'url': avatarUrl.toString()}, - // ), - // ], - initialState: initialState, // Pangea# + initialState: [ + if (avatar != null) + sdk.StateEvent( + type: sdk.EventTypes.RoomAvatar, + content: {'url': avatarUrl.toString()}, + ), + // #Pangea + ...initialState, + // Pangea# + ], ); // #Pangea final List> futures = [ diff --git a/lib/pangea/controllers/class_controller.dart b/lib/pangea/controllers/class_controller.dart index 5fa59622d..84f17a5e4 100644 --- a/lib/pangea/controllers/class_controller.dart +++ b/lib/pangea/controllers/class_controller.dart @@ -31,18 +31,16 @@ class ClassController extends BaseController { setState(data: {"activeSpaceId": classId}); } - Future fixClassPowerLevels() async { - try { - final teacherSpaces = - await _pangeaController.matrixState.client.spacesImTeaching; - final List> classFixes = List.from(teacherSpaces) - .map((adminSpace) => adminSpace.setClassPowerLevels()) - .toList(); - await Future.wait(classFixes); - } catch (err, stack) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: stack); - } + /// For all the spaces that the user is teaching, set the power levels + /// to enable all other users to add child rooms to the space. + void fixClassPowerLevels() { + Future.wait( + _pangeaController.matrixState.client.spacesImTeaching.map( + (space) => space.setClassPowerLevels().catchError((err, s) { + ErrorHandler.logError(e: err, s: s); + }), + ), + ); } Future checkForClassCodeAndSubscription(BuildContext context) async { @@ -131,10 +129,10 @@ class ClassController extends BaseController { _pangeaController.matrixState.client.getRoomById(classChunk.roomId); // when possible, add user's analytics room the to space they joined - await joinedSpace?.addAnalyticsRoomsToSpace(); + joinedSpace?.addAnalyticsRoomsToSpace(); // and invite the space's teachers to the user's analytics rooms - await joinedSpace?.inviteSpaceTeachersToAnalyticsRooms(); + joinedSpace?.inviteSpaceTeachersToAnalyticsRooms(); GoogleAnalytics.joinClass(classCode); return; } catch (err) { diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index f58a56698..32aaf66fc 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -198,9 +198,7 @@ class AnalyticsController extends BaseController { // gets all the summary analytics events for the students // in a space since the current timespace's cut off date - // ensure that all the space's events are loaded (mainly the for langCode) - // and that the participants are loaded - await space.postLoad(); + // ensure that the participants of the space are loaded await space.requestParticipants(); // TODO switch to using list of futures @@ -439,7 +437,6 @@ class AnalyticsController extends BaseController { timeSpan: currentAnalyticsTimeSpan, ); } - await space.postLoad(); } DateTime? lastUpdated; @@ -545,7 +542,6 @@ class AnalyticsController extends BaseController { Future> allSpaceMemberConstructs( Room space, ) async { - await space.postLoad(); await space.requestParticipants(); final List constructEvents = []; for (final student in space.students) { @@ -788,7 +784,6 @@ class AnalyticsController extends BaseController { ); return []; } - await space.postLoad(); } DateTime? lastUpdated; diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart index 7e24fa955..79222bbed 100644 --- a/lib/pangea/controllers/pangea_controller.dart +++ b/lib/pangea/controllers/pangea_controller.dart @@ -81,8 +81,7 @@ class PangeaController { BuildContext context, ) async { await classController.checkForClassCodeAndSubscription(context); - // startChatWithBotIfNotPresent(); - await classController.fixClassPowerLevels(); + classController.fixClassPowerLevels(); } /// Initialize controllers diff --git a/lib/pangea/extensions/client_extension/client_analytics_extension.dart b/lib/pangea/extensions/client_extension/client_analytics_extension.dart index fde4d8e54..396e09f8d 100644 --- a/lib/pangea/extensions/client_extension/client_analytics_extension.dart +++ b/lib/pangea/extensions/client_extension/client_analytics_extension.dart @@ -1,48 +1,40 @@ part of "client_extension.dart"; extension AnalyticsClientExtension on Client { - // get analytics room matching targetlanguage - // if not present, create it and invite teachers of that language - // set description to let people know what the hell it is + /// Get the logged in user's analytics room matching + /// a given langCode. If not present, create it. Future _getMyAnalyticsRoom(String langCode) async { - await roomsLoading; - // ensure room state events (room create, - // to check for analytics type) are loaded - for (final room in rooms) { - if (room.partial) await room.postLoad(); - } - - final Room? analyticsRoom = analyticsRoomLocal(langCode); - + final Room? analyticsRoom = _analyticsRoomLocal(langCode); if (analyticsRoom != null) return analyticsRoom; - return _makeAnalyticsRoom(langCode); } - //note: if langCode is null and user has >1 analyticsRooms then this could - //return the wrong one. this is to account for when an exchange might not - //be in a class. - Room? _analyticsRoomLocal(String? langCode, [String? userIdParam]) { + /// Get local analytics room for a given langCode and + /// optional userId (if not specified, uses current user). + /// If user is invited to the room, joins the room. + Room? _analyticsRoomLocal(String langCode, [String? userIdParam]) { final Room? analyticsRoom = rooms.firstWhereOrNull((e) { return e.isAnalyticsRoom && e.isAnalyticsRoomOfUser(userIdParam ?? userID!) && - (langCode != null ? e.isMadeForLang(langCode) : true); + e.isMadeForLang(langCode); }); if (analyticsRoom != null && analyticsRoom.membership == Membership.invite) { debugger(when: kDebugMode); - analyticsRoom - .join() - .onError( + analyticsRoom.join().onError( (error, stackTrace) => ErrorHandler.logError(e: error, s: stackTrace), - ) - .then((value) => analyticsRoom.postLoad()); + ); return analyticsRoom; } return analyticsRoom; } + /// Creates an analytics room with the specified language code and returns the created room. + /// Additionally, the room is added to the user's spaces and all teachers are invited to the room. + /// + /// If the room does not appear immediately after creation, this method waits for it to appear in sync. + /// Returns the created [Room] object. Future _makeAnalyticsRoom(String langCode) async { final String roomID = await createRoom( creationContent: { @@ -53,7 +45,6 @@ extension AnalyticsClientExtension on Client { topic: "This room stores learning analytics for $userID.", invite: [ ...(await myTeachers).map((e) => e.id), - // BotName.localBot, BotName.byEnvironment, ], ); @@ -66,14 +57,14 @@ extension AnalyticsClientExtension on Client { // add this analytics room to all spaces so teachers can join them // via the space hierarchy - await analyticsRoom?.addAnalyticsRoomToSpaces(); + analyticsRoom?.addAnalyticsRoomToSpaces(); // and invite all teachers to new analytics room - await analyticsRoom?.inviteTeachersToAnalyticsRoom(); + analyticsRoom?.inviteTeachersToAnalyticsRoom(); return getRoomById(roomID)!; } - // Get all my analytics rooms + /// Get all my analytics rooms List get _allMyAnalyticsRooms => rooms .where( (e) => e.isAnalyticsRoomOfUser(userID!), @@ -83,76 +74,77 @@ extension AnalyticsClientExtension on Client { // 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); + await Future.wait( + allMyAnalyticsRooms.map((room) async { + final visability = await getRoomVisibilityOnDirectory(room.id); + if (visability != Visibility.public) { + await setRoomVisibilityOnDirectory( + room.id, + visibility: Visibility.public, + ); + } + }), + ); } - // 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 = []; + /// Add all the users' analytics room to all the spaces the user is studying in + /// so teachers can join them via space hierarchy. + /// Allows teachers to join analytics rooms without being invited. + void _addAnalyticsRoomsToAllSpaces() { for (final Room room in allMyAnalyticsRooms) { - addFutures.add(room.addAnalyticsRoomToSpaces()); + 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()); + /// 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 + void _inviteAllTeachersToAllAnalyticsRooms() { + for (final Room room in allMyAnalyticsRooms) { + room.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 _spacesImTeaching)) { - joinFutures.add(space.joinAnalyticsRoomsInSpace()); + for (final Room space in _spacesImTeaching) { + // Each call to joinAnalyticsRoomsInSpace calls getSpaceHierarchy, which has a + // strict rate limit. So we wait a second between each call to prevent a 429 error. + await Future.delayed( + const Duration(seconds: 1), + () => 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 { - final List allRooms = List.from(rooms); - for (final Room room in allRooms) { - if (room.membership == Membership.invite && room.isAnalyticsRoom) { - try { - await room.join(); - } catch (err) { - debugPrint("Failed to join analytics room ${room.id}"); - } - } - } + /// 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). + void _joinInvitedAnalyticsRooms() { + Future.wait( + rooms + .where( + (room) => + room.membership == Membership.invite && room.isAnalyticsRoom, + ) + .map( + (room) => room.join().catchError((err, s) { + ErrorHandler.logError(e: err, s: s); + }), + ), + ); } - // 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(); + /// Helper function to join all relevant analytics rooms + /// and set up those rooms to be joined by other users. + void _migrateAnalyticsRooms() { + _updateAnalyticsRoomVisibility().then((_) { + _addAnalyticsRoomsToAllSpaces(); + _inviteAllTeachersToAllAnalyticsRooms(); + _joinInvitedAnalyticsRooms(); + _joinAnalyticsRoomsInAllSpaces(); + }); } Future> _allAnalyticsRoomsLastUpdated() async { diff --git a/lib/pangea/extensions/client_extension/client_extension.dart b/lib/pangea/extensions/client_extension/client_extension.dart index bef384f6a..af66c7d1f 100644 --- a/lib/pangea/extensions/client_extension/client_extension.dart +++ b/lib/pangea/extensions/client_extension/client_extension.dart @@ -1,7 +1,6 @@ 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_event_types.dart'; import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; @@ -20,10 +19,15 @@ part "space_extension.dart"; extension PangeaClient on Client { // analytics + /// Get the logged in user's analytics room matching + /// a given langCode. If not present, create it. Future getMyAnalyticsRoom(String langCode) async => await _getMyAnalyticsRoom(langCode); - Room? analyticsRoomLocal(String? langCode, [String? userIdParam]) => + /// Get local analytics room for a given langCode and + /// optional userId (if not specified, uses current user). + /// If user is invited to the room, joins the room. + Room? analyticsRoomLocal(String langCode, [String? userIdParam]) => _analyticsRoomLocal(langCode, userIdParam); List get allMyAnalyticsRooms => _allMyAnalyticsRooms; @@ -31,35 +35,24 @@ extension PangeaClient on Client { Future updateAnalyticsRoomVisibility() async => await _updateAnalyticsRoomVisibility(); - Future addAnalyticsRoomsToAllSpaces() async => - await _addAnalyticsRoomsToAllSpaces(); - - Future inviteAllTeachersToAllAnalyticsRooms() async => - await _inviteAllTeachersToAllAnalyticsRooms(); - - Future joinAnalyticsRoomsInAllSpaces() async => - await _joinAnalyticsRoomsInAllSpaces(); - - Future joinInvitedAnalyticsRooms() async => - await _joinInvitedAnalyticsRooms(); - - Future migrateAnalyticsRooms() async => await _migrateAnalyticsRooms(); + /// Helper function to join all relevant analytics rooms + /// and set up those rooms to be joined by other users. + void migrateAnalyticsRooms() => _migrateAnalyticsRooms(); Future> allAnalyticsRoomsLastUpdated() async => await _allAnalyticsRoomsLastUpdated(); // spaces - Future> get spacesImTeaching async => await _spacesImTeaching; + List get spacesImTeaching => _spacesImTeaching; Future> get chatsImAStudentIn async => await _chatsImAStudentIn; - Future> get spaceImAStudentIn async => await _spacesImStudyingIn; + List get spacesImAStudentIn => _spacesImStudyingIn; List get spacesImIn => _spacesImIn; - Future get lastUpdatedRoomRules async => - await _lastUpdatedRoomRules; + PangeaRoomRules? get lastUpdatedRoomRules => _lastUpdatedRoomRules; // general_info diff --git a/lib/pangea/extensions/client_extension/space_extension.dart b/lib/pangea/extensions/client_extension/space_extension.dart index 0adc70469..89a09a7cd 100644 --- a/lib/pangea/extensions/client_extension/space_extension.dart +++ b/lib/pangea/extensions/client_extension/space_extension.dart @@ -1,23 +1,8 @@ part of "client_extension.dart"; extension SpaceClientExtension on Client { - Future> get _spacesImTeaching async { - final allSpaces = rooms.where((room) => room.isSpace); - for (final Room space in allSpaces) { - if (space.getState(EventTypes.RoomPowerLevels) == null) { - await space.postLoad(); - } - } - - final spaces = rooms - .where( - (e) => - (e.isSpace) && - e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin, - ) - .toList(); - return spaces; - } + List get _spacesImTeaching => + rooms.where((e) => e.isSpace && e.isRoomAdmin).toList(); Future> get _chatsImAStudentIn async { final List nowteacherRoomIds = await teacherRoomIds; @@ -31,39 +16,18 @@ extension SpaceClientExtension on Client { .toList(); } - Future> get _spacesImStudyingIn async { - final List joinedSpaces = rooms - .where( - (room) => room.isSpace && room.membership == Membership.join, - ) - .toList(); - - for (final Room space in joinedSpaces) { - if (space.getState(EventTypes.RoomPowerLevels) == null) { - await space.postLoad(); - } - } - - final spaces = rooms - .where( - (e) => - e.isSpace && - e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin, - ) - .toList(); - return spaces; - } + List get _spacesImStudyingIn => + rooms.where((e) => e.isSpace && !e.isRoomAdmin).toList(); List get _spacesImIn => rooms.where((e) => e.isSpace).toList(); - Future get _lastUpdatedRoomRules async => - (await _spacesImTeaching) - .where((space) => space.rulesUpdatedAt != null) - .sorted( - (a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!), - ) - .firstOrNull - ?.pangeaRoomRules; + PangeaRoomRules? get _lastUpdatedRoomRules => _spacesImTeaching + .where((space) => space.rulesUpdatedAt != null) + .sorted( + (a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!), + ) + .firstOrNull + ?.pangeaRoomRules; // LanguageSettingsModel? get _lastUpdatedLanguageSettings => rooms // .where((room) => room.isSpace && room.languageSettingsUpdatedAt != null) diff --git a/lib/pangea/extensions/pangea_room_extension/children_and_parents_extension.dart b/lib/pangea/extensions/pangea_room_extension/children_and_parents_extension.dart index e89740788..2f0596908 100644 --- a/lib/pangea/extensions/pangea_room_extension/children_and_parents_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/children_and_parents_extension.dart @@ -1,6 +1,8 @@ part of "pangea_room_extension.dart"; extension ChildrenAndParentsRoomExtension on Room { + bool get _isSubspace => _pangeaSpaceParents.isNotEmpty; + //note this only will return rooms that the user has joined or been invited to List get _joinedChildren { if (!isSpace) return []; @@ -91,7 +93,7 @@ extension ChildrenAndParentsRoomExtension on Room { String _nameIncludingParents(BuildContext context) { String nameSoFar = getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)); Room currentRoom = this; - if (currentRoom.pangeaSpaceParents.isEmpty) { + if (!currentRoom._isSubspace) { return nameSoFar; } currentRoom = currentRoom.pangeaSpaceParents.first; @@ -100,7 +102,7 @@ extension ChildrenAndParentsRoomExtension on Room { nameToAdd = nameToAdd.length <= 10 ? nameToAdd : "${nameToAdd.substring(0, 10)}..."; nameSoFar = '$nameToAdd > $nameSoFar'; - if (currentRoom.pangeaSpaceParents.isEmpty) { + if (!currentRoom._isSubspace) { return nameSoFar; } return "... > $nameSoFar"; @@ -161,4 +163,14 @@ extension ChildrenAndParentsRoomExtension on Room { await setSpaceChild(roomId, suggested: suggested); } } + + /// A map of child suggestion status for a space. + Map get _spaceChildSuggestionStatus { + if (!isSpace) return {}; + final Map suggestionStatus = {}; + for (final child in spaceChildren) { + suggestionStatus[child.roomId!] = child.suggested ?? true; + } + return suggestionStatus; + } } diff --git a/lib/pangea/extensions/pangea_room_extension/events_extension.dart b/lib/pangea/extensions/pangea_room_extension/events_extension.dart index ce9d3451c..bc820998c 100644 --- a/lib/pangea/extensions/pangea_room_extension/events_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/events_extension.dart @@ -2,7 +2,6 @@ part of "pangea_room_extension.dart"; extension EventsRoomExtension on Room { Future _leaveIfFull() async { - await postLoad(); if (!isRoomAdmin && (_capacity != null) && (await _numNonAdmins) > (_capacity!)) { diff --git a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart index 8632f5e1e..2ff1bf57d 100644 --- a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart @@ -49,26 +49,35 @@ part "user_permissions_extension.dart"; extension PangeaRoom on Room { // analytics + /// Join analytics rooms in space. + /// Allows teachers to join analytics rooms without being invited. Future joinAnalyticsRoomsInSpace() async => await _joinAnalyticsRoomsInSpace(); Future addAnalyticsRoomToSpace(Room analyticsRoom) async => await _addAnalyticsRoomToSpace(analyticsRoom); - Future addAnalyticsRoomToSpaces() async => - await _addAnalyticsRoomToSpaces(); + /// Add analytics room to all spaces the user is a student in (1 analytics room to all spaces). + /// Enables teachers to join student analytics rooms 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. + void addAnalyticsRoomToSpaces() => _addAnalyticsRoomToSpaces(); - Future addAnalyticsRoomsToSpace() async => - await _addAnalyticsRoomsToSpace(); + /// Add all the user's analytics rooms to 1 space. + void addAnalyticsRoomsToSpace() => _addAnalyticsRoomsToSpace(); + /// Invite teachers of 1 space to 1 analytics room Future inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async => await _inviteSpaceTeachersToAnalyticsRoom(analyticsRoom); - Future inviteTeachersToAnalyticsRoom() async => - await _inviteTeachersToAnalyticsRoom(); + /// Invite all the user's 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. + void inviteTeachersToAnalyticsRoom() => _inviteTeachersToAnalyticsRoom(); - Future inviteSpaceTeachersToAnalyticsRooms() async => - await _inviteSpaceTeachersToAnalyticsRooms(); + /// Invite teachers of 1 space to all users' analytics rooms + void inviteSpaceTeachersToAnalyticsRooms() => + _inviteSpaceTeachersToAnalyticsRooms(); Future getLastAnalyticsEvent( String type, @@ -122,6 +131,19 @@ extension PangeaRoom on Room { }) async => await _pangeaSetSpaceChild(roomId, suggested: suggested); + /// Returns a map of child suggestion status for a space. + /// + /// If the current object is not a space, an empty map is returned. + /// Otherwise, it iterates through each child in the `spaceChildren` list + /// and adds their suggestion status to the `suggestionStatus` map. + /// The suggestion status is determined by the `suggested` property of each child. + /// If the `suggested` property is `null`, it defaults to `true`. + Map get spaceChildSuggestionStatus => + _spaceChildSuggestionStatus; + + /// Checks if this space has a parent space + bool get isSubspace => _isSubspace; + // class_and_exchange_settings DateTime? get rulesUpdatedAt => _rulesUpdatedAt; @@ -134,6 +156,12 @@ extension PangeaRoom on Room { Future> get teachers async => await _teachers; + /// Synchronous version of teachers getter. Does not request + /// participants, so this list may not be complete. + List get teachersLocal => _teachersLocal; + + /// If the user is an admin of this space, and the space's + /// m.space.child power level hasn't yet been set, so it to 0 Future setClassPowerLevels() async => await _setClassPowerLevels(); Event? get pangeaRoomRulesStateEvent => _pangeaRoomRulesStateEvent; diff --git a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart index a27526a2b..fd1070e67 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart @@ -1,58 +1,45 @@ part of "pangea_room_extension.dart"; extension AnalyticsRoomExtension on Room { - // Join analytics rooms in space - // Allows teachers to join analytics rooms without being invited + /// 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; - } + try { + if (!isSpace) { + debugger(when: kDebugMode); + 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) return; + final spaceHierarchy = await client.getSpaceHierarchy( + id, + maxDepth: 1, + ); - if (!isRoomAdmin) { - debugPrint("joinAnalyticsRoomsInSpace called by non-admin"); - Sentry.addBreadcrumb( - Breadcrumb( - message: "joinAnalyticsRoomsInSpace called by non-admin", + final List analyticsRoomIds = spaceHierarchy.rooms + .where((r) => r.roomType == PangeaRoomTypes.analytics) + .map((r) => r.roomId) + .toList(); + + await Future.wait( + analyticsRoomIds.map( + (roomID) => joinSpaceChild(roomID).catchError((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, + ); + }), ), ); + } catch (err, s) { + ErrorHandler.logError( + e: err, + s: s, + ); 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, - ); - } - } } // add 1 analytics room to 1 space @@ -84,107 +71,70 @@ extension AnalyticsRoomExtension on Room { } } - // 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.spaceImAStudentIn)) { - if (space.spaceChildren.any((sc) => sc.roomId == id)) continue; - await space.addAnalyticsRoomToSpace(this); - } + /// Add analytics room to all spaces the user is a student in (1 analytics room to all spaces). + /// Enables teachers to join student analytics rooms 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. + void _addAnalyticsRoomToSpaces() { + if (!isAnalyticsRoomOfUser(client.userID!)) return; + Future.wait( + client.spacesImAStudentIn + .where((space) => !space.spaceChildren.any((sc) => sc.roomId == id)) + .map((space) => space.addAnalyticsRoomToSpace(this)), + ); } - // Add all analytics rooms to space - // Similar to addAnalyticsRoomToSpaces, but all analytics room to 1 space - Future _addAnalyticsRoomsToSpace() async { - await postLoad(); - final List allMyAnalyticsRooms = client.allMyAnalyticsRooms; - for (final Room analyticsRoom in allMyAnalyticsRooms) { - await addAnalyticsRoomToSpace(analyticsRoom); - } + /// Add all the user's analytics rooms to 1 space. + void _addAnalyticsRoomsToSpace() { + Future.wait( + client.allMyAnalyticsRooms.map((room) => addAnalyticsRoomToSpace(room)), + ); } - // invite teachers of 1 space to 1 analytics room + /// Invite teachers of 1 space to 1 analytics room Future _inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async { - if (!isSpace) { - debugPrint( - "inviteSpaceTeachersToAnalyticsRoom called on non-space room", - ); - Sentry.addBreadcrumb( - Breadcrumb( - message: - "inviteSpaceTeachersToAnalyticsRoom called on non-space room", - ), - ); - return; - } + if (!isSpace) return; 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}", - ); + final List uninvitedTeachers = teachersLocal + .where((teacher) => !participants.contains(teacher)) + .toList(); + + Future.wait( + uninvitedTeachers.map( + (teacher) => analyticsRoom.invite(teacher.id).catchError((err, s) { ErrorHandler.logError( e: err, m: "Failed to invite teacher ${teacher.id} to analytics room ${analyticsRoom.id}", s: s, ); - } - } - } + }), + ), + ); } - // 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; - } - - for (final Room space in (await client.spaceImAStudentIn)) { - await space.inviteSpaceTeachersToAnalyticsRoom(this); - } + /// Invite all the user's 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. + void _inviteTeachersToAnalyticsRoom() { + if (client.userID == null || !isAnalyticsRoomOfUser(client.userID!)) return; + Future.wait( + client.spacesImAStudentIn.map( + (space) => inviteSpaceTeachersToAnalyticsRoom(this), + ), + ); } - // Invite teachers of 1 space to all users' analytics rooms - Future _inviteSpaceTeachersToAnalyticsRooms() async { - for (final Room analyticsRoom in client.allMyAnalyticsRooms) { - await inviteSpaceTeachersToAnalyticsRoom(analyticsRoom); - } + /// Invite teachers of 1 space to all users' analytics rooms + void _inviteSpaceTeachersToAnalyticsRooms() { + Future.wait( + client.allMyAnalyticsRooms.map( + (room) => inviteSpaceTeachersToAnalyticsRoom(room), + ), + ); } Future _getLastAnalyticsEvent( diff --git a/lib/pangea/extensions/pangea_room_extension/space_settings_extension.dart b/lib/pangea/extensions/pangea_room_extension/space_settings_extension.dart index 5799631b1..6354de96e 100644 --- a/lib/pangea/extensions/pangea_room_extension/space_settings_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/space_settings_extension.dart @@ -55,27 +55,39 @@ extension SpaceRoomExtension on Room { : participants; } + /// Synchronous version of _teachers. Does not request participants, so this list may not be complete. + List get _teachersLocal { + if (!isSpace) return []; + return getParticipants() + .where( + (e) => + e.powerLevel == ClassDefaultValues.powerLevelOfAdmin && + e.id != BotName.byEnvironment, + ) + .toList(); + } + + /// If the user is an admin of this space, and the space's + /// m.space.child power level hasn't yet been set, so it to 0 Future _setClassPowerLevels() async { try { - if (ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin) { - return; - } + if (!isRoomAdmin) return; final dynamic currentPower = getState(EventTypes.RoomPowerLevels); - if (currentPower is! Event?) { - return; - } - final Map? currentPowerContent = + if (currentPower is! Event?) return; + + final currentPowerContent = currentPower?.content["events"] as Map?; final spaceChildPower = currentPowerContent?[EventTypes.SpaceChild]; if (spaceChildPower == null && currentPowerContent != null) { - currentPowerContent["events"][EventTypes.SpaceChild] = 0; + currentPowerContent[EventTypes.SpaceChild] = 0; + currentPower!.content["events"] = currentPowerContent; await client.setRoomStateWithKey( id, EventTypes.RoomPowerLevels, - currentPower?.stateKey ?? "", - currentPowerContent, + currentPower.stateKey ?? "", + currentPower.content, ); } } catch (err, s) { diff --git a/lib/pangea/pages/analytics/space_analytics/space_analytics.dart b/lib/pangea/pages/analytics/space_analytics/space_analytics.dart index 2db1acb4c..50a8cb7c2 100644 --- a/lib/pangea/pages/analytics/space_analytics/space_analytics.dart +++ b/lib/pangea/pages/analytics/space_analytics/space_analytics.dart @@ -64,7 +64,6 @@ class SpaceAnalyticsV2Controller extends State { Future getChatAndStudents() async { try { - await spaceRoom?.postLoad(); await spaceRoom?.requestParticipants(); if (spaceRoom != null) { diff --git a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart index e06c6d0ba..007a04d93 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart @@ -49,15 +49,8 @@ class StudentAnalyticsController extends State { return _chats; } - List _spaces = []; - List get spaces { - if (_spaces.isEmpty) { - _pangeaController.matrixState.client.spaceImAStudentIn.then((result) { - setState(() => _spaces = result); - }); - } - return _spaces; - } + List get spaces => + _pangeaController.matrixState.client.spacesImAStudentIn; String? get userId { final id = _pangeaController.matrixState.client.userID; diff --git a/lib/pangea/utils/chat_list_handle_space_tap.dart b/lib/pangea/utils/chat_list_handle_space_tap.dart index 9b950e15b..f6605d03f 100644 --- a/lib/pangea/utils/chat_list_handle_space_tap.dart +++ b/lib/pangea/utils/chat_list_handle_space_tap.dart @@ -36,7 +36,6 @@ void chatListHandleSpaceTap( if (await space.leaveIfFull()) { throw L10n.of(context)!.roomFull; } - await space.postLoad(); setActiveSpaceAndCloseChat(); }, onError: (exception) { @@ -72,7 +71,7 @@ void chatListHandleSpaceTap( throw L10n.of(context)!.roomFull; } if (space.isSpace) { - await space.joinAnalyticsRoomsInSpace(); + space.joinAnalyticsRoomsInSpace(); } setActiveSpaceAndCloseChat(); ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 6058eecf9..808dfd9fc 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -111,12 +111,12 @@ abstract class ClientManager { // To make room emotes work 'im.ponies.room_emotes', // #Pangea - PangeaEventTypes.languageSettings, + // The things in this list will be loaded in the first sync, without having + // to postLoad to confirm that these state events are completely loaded PangeaEventTypes.rules, PangeaEventTypes.botOptions, - EventTypes.RoomTopic, - EventTypes.RoomAvatar, PangeaEventTypes.capacity, + EventTypes.RoomPowerLevels, // Pangea# }, logLevel: kReleaseMode ? Level.warning : Level.verbose,