diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 67e9358f2..5fe1bc462 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -81,7 +81,7 @@ class ChatEventList extends StatelessWidget { // #Pangea if (i == 1) { - return (controller.room.locked) && !controller.room.isRoomAdmin + return (controller.room.isLocked) && !controller.room.isRoomAdmin ? const LockedChatMessage() : const SizedBox.shrink(); } diff --git a/lib/pages/chat_details/chat_details_view.dart b/lib/pages/chat_details/chat_details_view.dart index 2d46df11d..de93d04b7 100644 --- a/lib/pages/chat_details/chat_details_view.dart +++ b/lib/pages/chat_details/chat_details_view.dart @@ -585,12 +585,12 @@ class ChatDetailsView extends StatelessWidget { Theme.of(context).scaffoldBackgroundColor, foregroundColor: iconColor, child: Icon( - room.locked + room.isLocked ? Icons.lock_outlined : Icons.no_encryption_outlined, ), ), - value: room.locked, + value: room.isLocked, onChanged: (value) => showFutureLoadingDialog( context: context, future: () => value diff --git a/lib/pages/chat_list/chat_list_item.dart b/lib/pages/chat_list/chat_list_item.dart index 9775ec624..a2bd7a012 100644 --- a/lib/pages/chat_list/chat_list_item.dart +++ b/lib/pages/chat_list/chat_list_item.dart @@ -256,7 +256,7 @@ class ChatListItem extends StatelessWidget { ), const SizedBox(width: 8), // #Pangea - if (room.locked) + if (room.isLocked) const Padding( padding: EdgeInsets.only(right: 4.0), child: Icon( diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 48be9eb04..996f5218d 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -603,7 +603,7 @@ class _SpaceViewState extends State { subtitle: Row( children: [ spaceSubtitle(rootSpace), - if (rootSpace.locked) + if (rootSpace.isLocked) const Padding( padding: EdgeInsets.only(left: 4.0), child: Icon( diff --git a/lib/pangea/controllers/class_controller.dart b/lib/pangea/controllers/class_controller.dart index c0a8e6093..d59c5eb52 100644 --- a/lib/pangea/controllers/class_controller.dart +++ b/lib/pangea/controllers/class_controller.dart @@ -36,7 +36,7 @@ class ClassController extends BaseController { final List> classFixes = []; for (final room in (await _pangeaController .matrixState.client.classesAndExchangesImTeaching)) { - classFixes.add(room.setClassPowerlLevels()); + classFixes.add(room.setClassPowerLevels()); } await Future.wait(classFixes); } catch (err, stack) { diff --git a/lib/pangea/extensions/client_extension.dart b/lib/pangea/extensions/client_extension.dart index b259d0c9e..3e2be72f1 100644 --- a/lib/pangea/extensions/client_extension.dart +++ b/lib/pangea/extensions/client_extension.dart @@ -13,323 +13,78 @@ import 'package:matrix/matrix.dart'; import '../utils/p_store.dart'; +part "client_extension/analytics.dart"; +part "client_extension/classes_and_exchanges.dart"; +part "client_extension/general_info.dart"; + extension PangeaClient on Client { - List get classes => rooms.where((e) => e.isPangeaClass).toList(); +// analytics - List get classesImTeaching => rooms - .where( - (e) => - e.isPangeaClass && - e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin, - ) - .toList(); + Future getMyAnalyticsRoom(String langCode) async => + await _getMyAnalyticsRoom(langCode); - Future> get classesAndExchangesImTeaching async { - for (final Room space in rooms.where((room) => room.isSpace)) { - if (space.getState(EventTypes.RoomPowerLevels) == null) { - await space.postLoad(); - } - } + Room? analyticsRoomLocal(String? langCode, [String? userIdParam]) => + _analyticsRoomLocal(langCode, userIdParam); - final spaces = rooms - .where( - (e) => - (e.isPangeaClass || e.isExchange) && - e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin, - ) - .toList(); - return spaces; - } + List get allMyAnalyticsRooms => _allMyAnalyticsRooms; - List get classesImIn => rooms - .where( - (e) => - e.isPangeaClass && - e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin, - ) - .toList(); - - Future> get classesAndExchangesImStudyingIn async { - for (final Room space in rooms.where((room) => room.isSpace)) { - if (space.getState(EventTypes.RoomPowerLevels) == null) { - await space.postLoad(); - } - } - - final spaces = rooms - .where( - (e) => - (e.isPangeaClass || e.isExchange) && - e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin, - ) - .toList(); - return spaces; - } - - List get classesAndExchangesImIn => - rooms.where((e) => e.isPangeaClass || e.isExchange).toList(); - - Future> get teacherRoomIds async { - final List adminRoomIds = []; - for (final Room adminSpace in (await classesAndExchangesImTeaching)) { - adminRoomIds.add(adminSpace.id); - final children = adminSpace.childrenAndGrandChildren; - final List adminSpaceRooms = children - .where((e) => e.roomId != null) - .map((e) => e.roomId!) - .toList(); - adminRoomIds.addAll(adminSpaceRooms); - } - return adminRoomIds; - } - - Future> get myTeachers async { - final List teachers = []; - 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) { - teachers.add(teacher); - } - } - } - return teachers; - } + Future updateAnalyticsRoomVisibility() async => + await _updateAnalyticsRoomVisibility(); Future updateMyLearningAnalyticsForAllClassesImIn([ PLocalStore? storageService, - ]) async { - try { - final List> updateFutures = []; - for (final classRoom in classesAndExchangesImIn) { - updateFutures - .add(classRoom.updateMyLearningAnalyticsForClass(storageService)); - } - await Future.wait(updateFutures); - } catch (err, s) { - if (kDebugMode) rethrow; - // debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s); - } - } + ]) async => + await _updateMyLearningAnalyticsForAllClassesImIn(storageService); - // 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 - 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(); - } + Future addAnalyticsRoomsToAllSpaces() async => + await _addAnalyticsRoomsToAllSpaces(); - final Room? analyticsRoom = analyticsRoomLocal(langCode); + Future inviteAllTeachersToAllAnalyticsRooms() async => + await _inviteAllTeachersToAllAnalyticsRooms(); - if (analyticsRoom != null) return analyticsRoom; + Future joinAnalyticsRoomsInAllSpaces() async => + await _joinAnalyticsRoomsInAllSpaces(); - return _makeAnalyticsRoom(langCode); - } + Future joinInvitedAnalyticsRooms() async => + await _joinInvitedAnalyticsRooms(); - //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]) { - final Room? analyticsRoom = rooms.firstWhereOrNull((e) { - return e.isAnalyticsRoom && - e.isAnalyticsRoomOfUser(userIdParam ?? userID!) && - (langCode != null ? e.isMadeForLang(langCode) : true); - }); - if (analyticsRoom != null && - analyticsRoom.membership == Membership.invite) { - debugger(when: kDebugMode); - analyticsRoom - .join() - .onError( - (error, stackTrace) => - ErrorHandler.logError(e: error, s: stackTrace), - ) - .then((value) => analyticsRoom.postLoad()); - return analyticsRoom; - } - return analyticsRoom; - } + Future migrateAnalyticsRooms() async => await _migrateAnalyticsRooms(); - Future _makeAnalyticsRoom(String langCode) async { - final String roomID = await createRoom( - creationContent: { - 'type': PangeaRoomTypes.analytics, - ModelKey.langCode: langCode, - }, - name: "$userID $langCode Analytics", - topic: "This room stores learning analytics for $userID.", - invite: [ - ...(await myTeachers).map((e) => e.id), - // BotName.localBot, - BotName.byEnvironment, - ], - ); - if (getRoomById(roomID) == null) { - // Wait for room actually appears in sync - await waitForRoomInSync(roomID, join: true); - } + // classes_and_exchanges - final Room? analyticsRoom = getRoomById(roomID); + List get classes => _classes; - // add this analytics room to all spaces so teachers can join them - // via the space hierarchy - await analyticsRoom?.addAnalyticsRoomToSpaces(); + List get classesImTeaching => _classesImTeaching; - // and invite all teachers to new analytics room - await analyticsRoom?.inviteTeachersToAnalyticsRoom(); - return getRoomById(roomID)!; - } + Future> get classesAndExchangesImTeaching async => + await _classesAndExchangesImTeaching; - Future getReportsDM(User teacher, Room space) async { - final String roomId = await teacher.startDirectChat( - enableEncryption: false, - ); - space.setSpaceChild( - roomId, - suggested: false, - ); - return getRoomById(roomId)!; - } + List get classesImIn => _classesImIn; + + Future> get classesAndExchangesImStudyingIn async => + await _classesAndExchangesImStudyingIn; + + List get classesAndExchangesImIn => _classesAndExchangesImIn; Future get lastUpdatedRoomRules async => - (await classesAndExchangesImTeaching) - .where((space) => space.rulesUpdatedAt != null) - .sorted( - (a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!), - ) - .firstOrNull - ?.pangeaRoomRules; + await _lastUpdatedRoomRules; - ClassSettingsModel? get lastUpdatedClassSettings => classesImTeaching - .where((space) => space.classSettingsUpdatedAt != null) - .sorted( - (a, b) => - b.classSettingsUpdatedAt!.compareTo(a.classSettingsUpdatedAt!), - ) - .firstOrNull - ?.classSettings; + ClassSettingsModel? get lastUpdatedClassSettings => _lastUpdatedClassSettings; - Future get hasBotDM async { - final List chats = rooms - .where((room) => !room.isSpace && room.membership == Membership.join) - .toList(); +// general_info - for (final Room chat in chats) { - if (await chat.isBotDM) return true; - } - return false; - } + Future> get teacherRoomIds async => await _teacherRoomIds; + + Future> get myTeachers async => await _myTeachers; + + Future getReportsDM(User teacher, Room space) async => + await _getReportsDM(teacher, space); + + Future get hasBotDM async => await _hasBotDM; Future> getEditHistory( String roomId, String eventId, - ) async { - final Room? room = getRoomById(roomId); - final Event? editEvent = await room?.getEventById(eventId); - final String? edittedEventId = - editEvent?.content.tryGetMap('m.relates_to')?['event_id']; - if (edittedEventId == null) return []; - - final Event? originalEvent = await room!.getEventById(edittedEventId); - if (originalEvent == null) return []; - - final Timeline timeline = await room.getTimeline(); - final List editEvents = originalEvent - .aggregatedEvents( - timeline, - RelationshipTypes.edit, - ) - .sorted( - (a, b) => b.originServerTs.compareTo(a.originServerTs), - ) - .toList(); - 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(); - } + ) async => + await _getEditHistory(roomId, eventId); } diff --git a/lib/pangea/extensions/client_extension/analytics.dart b/lib/pangea/extensions/client_extension/analytics.dart new file mode 100644 index 000000000..6683629f6 --- /dev/null +++ b/lib/pangea/extensions/client_extension/analytics.dart @@ -0,0 +1,173 @@ +part of "../client_extension.dart"; + +extension PangeaClient1 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 + 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); + + 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]) { + final Room? analyticsRoom = rooms.firstWhereOrNull((e) { + return e.isAnalyticsRoom && + e.isAnalyticsRoomOfUser(userIdParam ?? userID!) && + (langCode != null ? e.isMadeForLang(langCode) : true); + }); + if (analyticsRoom != null && + analyticsRoom.membership == Membership.invite) { + debugger(when: kDebugMode); + analyticsRoom + .join() + .onError( + (error, stackTrace) => + ErrorHandler.logError(e: error, s: stackTrace), + ) + .then((value) => analyticsRoom.postLoad()); + return analyticsRoom; + } + return analyticsRoom; + } + + Future _makeAnalyticsRoom(String langCode) async { + final String roomID = await createRoom( + creationContent: { + 'type': PangeaRoomTypes.analytics, + ModelKey.langCode: langCode, + }, + name: "$userID $langCode Analytics", + topic: "This room stores learning analytics for $userID.", + invite: [ + ...(await myTeachers).map((e) => e.id), + // BotName.localBot, + BotName.byEnvironment, + ], + ); + 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)!; + } + + // 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); + } + + Future _updateMyLearningAnalyticsForAllClassesImIn([ + PLocalStore? storageService, + ]) async { + try { + final List> updateFutures = []; + for (final classRoom in classesAndExchangesImIn) { + updateFutures + .add(classRoom.updateMyLearningAnalyticsForClass(storageService)); + } + await Future.wait(updateFutures); + } catch (err, s) { + if (kDebugMode) rethrow; + // debugger(when: kDebugMode); + ErrorHandler.logError(e: err, s: s); + } + } + + // 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/client_extension/classes_and_exchanges.dart b/lib/pangea/extensions/client_extension/classes_and_exchanges.dart new file mode 100644 index 000000000..ec3dd2237 --- /dev/null +++ b/lib/pangea/extensions/client_extension/classes_and_exchanges.dart @@ -0,0 +1,76 @@ +part of "../client_extension.dart"; + +extension PangeaClient2 on Client { + List get _classes => rooms.where((e) => e.isPangeaClass).toList(); + + List get _classesImTeaching => rooms + .where( + (e) => + e.isPangeaClass && + e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin, + ) + .toList(); + + Future> get _classesAndExchangesImTeaching async { + for (final Room space in rooms.where((room) => room.isSpace)) { + if (space.getState(EventTypes.RoomPowerLevels) == null) { + await space.postLoad(); + } + } + + final spaces = rooms + .where( + (e) => + (e.isPangeaClass || e.isExchange) && + e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin, + ) + .toList(); + return spaces; + } + + List get _classesImIn => rooms + .where( + (e) => + e.isPangeaClass && + e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin, + ) + .toList(); + + Future> get _classesAndExchangesImStudyingIn async { + for (final Room space in rooms.where((room) => room.isSpace)) { + if (space.getState(EventTypes.RoomPowerLevels) == null) { + await space.postLoad(); + } + } + + final spaces = rooms + .where( + (e) => + (e.isPangeaClass || e.isExchange) && + e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin, + ) + .toList(); + return spaces; + } + + List get _classesAndExchangesImIn => + rooms.where((e) => e.isPangeaClass || e.isExchange).toList(); + + Future get _lastUpdatedRoomRules async => + (await _classesAndExchangesImTeaching) + .where((space) => space.rulesUpdatedAt != null) + .sorted( + (a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!), + ) + .firstOrNull + ?.pangeaRoomRules; + + ClassSettingsModel? get _lastUpdatedClassSettings => classesImTeaching + .where((space) => space.classSettingsUpdatedAt != null) + .sorted( + (a, b) => + b.classSettingsUpdatedAt!.compareTo(a.classSettingsUpdatedAt!), + ) + .firstOrNull + ?.classSettings; +} diff --git a/lib/pangea/extensions/client_extension/general_info.dart b/lib/pangea/extensions/client_extension/general_info.dart new file mode 100644 index 000000000..810e66328 --- /dev/null +++ b/lib/pangea/extensions/client_extension/general_info.dart @@ -0,0 +1,79 @@ +part of "../client_extension.dart"; + +extension PangeaClient3 on Client { + Future> get _teacherRoomIds async { + final List adminRoomIds = []; + for (final Room adminSpace in (await _classesAndExchangesImTeaching)) { + adminRoomIds.add(adminSpace.id); + final children = adminSpace.childrenAndGrandChildren; + final List adminSpaceRooms = children + .where((e) => e.roomId != null) + .map((e) => e.roomId!) + .toList(); + adminRoomIds.addAll(adminSpaceRooms); + } + return adminRoomIds; + } + + Future> get _myTeachers async { + final List teachers = []; + 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) { + teachers.add(teacher); + } + } + } + return teachers; + } + + Future _getReportsDM(User teacher, Room space) async { + final String roomId = await teacher.startDirectChat( + enableEncryption: false, + ); + space.setSpaceChild( + roomId, + suggested: false, + ); + return getRoomById(roomId)!; + } + + Future get _hasBotDM async { + final List chats = rooms + .where((room) => !room.isSpace && room.membership == Membership.join) + .toList(); + + for (final Room chat in chats) { + if (await chat.isBotDM) return true; + } + return false; + } + + Future> _getEditHistory( + String roomId, + String eventId, + ) async { + final Room? room = getRoomById(roomId); + final Event? editEvent = await room?.getEventById(eventId); + final String? edittedEventId = + editEvent?.content.tryGetMap('m.relates_to')?['event_id']; + if (edittedEventId == null) return []; + + final Event? originalEvent = await room!.getEventById(edittedEventId); + if (originalEvent == null) return []; + + final Timeline timeline = await room.getTimeline(); + final List editEvents = originalEvent + .aggregatedEvents( + timeline, + RelationshipTypes.edit, + ) + .sorted( + (a, b) => b.originServerTs.compareTo(a.originServerTs), + ) + .toList(); + editEvents.add(originalEvent); + return editEvents.slice(1).map((e) => e.eventId).toList(); + } +} diff --git a/lib/pangea/extensions/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension.dart index febd17fa8..ffde0cc6e 100644 --- a/lib/pangea/extensions/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension.dart @@ -33,864 +33,121 @@ import '../models/student_analytics_summary_model.dart'; import '../utils/p_store.dart'; import 'client_extension.dart'; +part "pangea_room_extension/analytics.dart"; +part "pangea_room_extension/children_and_parents.dart"; +part "pangea_room_extension/class_and_exchange_settings.dart"; +part "pangea_room_extension/events.dart"; +part "pangea_room_extension/room_information.dart"; +part "pangea_room_extension/room_settings.dart"; +part "pangea_room_extension/user_permissions.dart"; + extension PangeaRoom on Room { - /// the pangeaClass event is listed an importantStateEvent so, if event exists, - /// it's already local. If it's an old class and doesn't, then the class_controller - /// should automatically migrate during this same session, when the space is first loaded - ClassSettingsModel? get classSettings { - try { - if (!isSpace) { - return null; - } - final Map? content = languageSettingsStateEvent?.content; - if (content != null) { - final ClassSettingsModel classSettings = - ClassSettingsModel.fromJson(content); - return classSettings; - } - return null; - } catch (err, s) { - Sentry.addBreadcrumb( - Breadcrumb( - message: "Error in classSettings", - data: {"room": toJson()}, - ), - ); - ErrorHandler.logError(e: err, s: s); - return null; - } - } +// analytics - PangeaRoomRules? get pangeaRoomRules { - try { - final Map? content = pangeaRoomRulesStateEvent?.content; - if (content != null) { - final PangeaRoomRules roomRules = PangeaRoomRules.fromJson(content); - return roomRules; - } - return null; - } catch (err, s) { - Sentry.addBreadcrumb( - Breadcrumb( - message: "Error in pangeaRoomRules", - data: {"room": toJson()}, - ), - ); - ErrorHandler.logError(e: err, s: s); - return null; - } - } + Future joinAnalyticsRoomsInSpace() async => + await _joinAnalyticsRoomsInSpace(); - String? get creatorId => getState(EventTypes.RoomCreate)?.senderId; + Future ensureAnalyticsRoomExists() async => + await _ensureAnalyticsRoomExists(); - DateTime? get creationTime => getState(EventTypes.RoomCreate)?.originServerTs; + Future addAnalyticsRoomToSpace(Room analyticsRoom) async => + await _addAnalyticsRoomToSpace(analyticsRoom); - ClassSettingsModel? get firstLanguageSettings => - classSettings ?? - firstParentWithState(PangeaEventTypes.classSettings)?.classSettings; + Future addAnalyticsRoomToSpaces() async => + await _addAnalyticsRoomToSpaces(); - PangeaRoomRules? get firstRules => - pangeaRoomRules ?? - firstParentWithState(PangeaEventTypes.rules)?.pangeaRoomRules; - - //resolve somehow if multiple rooms have the state? - //check logic - Room? firstParentWithState(String stateType) { - if (![PangeaEventTypes.classSettings, PangeaEventTypes.rules] - .contains(stateType)) { - return null; - } - - for (final parent in pangeaSpaceParents) { - if (parent.getState(stateType) != null) { - return parent; - } - } - for (final parent in pangeaSpaceParents) { - final parentFirstRoom = parent.firstParentWithState(stateType); - if (parentFirstRoom != null) return parentFirstRoom; - } - return null; - } - - IconData? get roomTypeIcon { - if (membership == Membership.invite) return Icons.add; - if (isPangeaClass) return Icons.school; - if (isExchange) return Icons.connecting_airports; - if (isAnalyticsRoom) return Icons.analytics; - if (isDirectChat) return Icons.forum; - return Icons.group; - } - - Text nameAndRoomTypeIcon([TextStyle? textStyle]) => Text.rich( - style: textStyle, - TextSpan( - children: [ - WidgetSpan( - child: Icon(roomTypeIcon), - ), - TextSpan( - text: ' $name', - ), - ], - ), - ); - - /// find any parents and return the rooms - List get immediateClassParents => pangeaSpaceParents - .where( - (element) => element.isPangeaClass, - ) - .toList(); - - List get pangeaSpaceParents => client.rooms - .where( - (r) => r.isSpace, - ) - .where( - (space) => space.spaceChildren.any( - (room) => room.roomId == id, - ), - ) - .toList(); - - bool isChild(String roomId) => - isSpace && spaceChildren.any((room) => room.roomId == roomId); - - bool isFirstOrSecondChild(String roomId) { - return isSpace && - (spaceChildren.any((room) => room.roomId == roomId) || - spaceChildren - .where((sc) => sc.roomId != null) - .map((sc) => client.getRoomById(sc.roomId!)) - .any( - (room) => - room != null && - room.isSpace && - room.spaceChildren.any((room) => room.roomId == roomId), - )); - } - - //note this only will return rooms that the user has joined or been invited to - List get joinedChildren { - if (!isSpace) return []; - return spaceChildren - .where((child) => child.roomId != null) - .map( - (child) => client.getRoomById(child.roomId!), - ) - .where((child) => child != null) - .cast() - .where( - (child) => child.membership == Membership.join, - ) - .toList(); - } - - List get joinedChildrenRoomIds => - joinedChildren.map((child) => child.id).toList(); - - List get childrenAndGrandChildren { - if (!isSpace) return []; - final List kids = []; - for (final child in spaceChildren) { - kids.add(child); - if (child.roomId != null) { - final Room? childRoom = client.getRoomById(child.roomId!); - if (childRoom != null && childRoom.isSpace) { - kids.addAll(childRoom.spaceChildren); - } - } - } - return kids.where((element) => element.roomId != null).toList(); - } - - //this assumes that a user has been invited to all group chats in a space - //it is a janky workaround for determining whether a spacechild is a direct chat - //since the spaceChild object doesn't contain this info. this info is only accessible - //when the user has joined or been invited to the room. direct chats included in - //a space show up in spaceChildren but the user has not been invited to them. - List get childrenAndGrandChildrenDirectChatIds { - final List nonDirectChatRoomIds = childrenAndGrandChildren - .where((child) => child.roomId != null) - .map((e) => client.getRoomById(e.roomId!)) - .where((r) => r != null && !r.isDirectChat) - .map((e) => e!.id) - .toList(); - - return childrenAndGrandChildren - .where( - (child) => - child.roomId != null && - !nonDirectChatRoomIds.contains(child.roomId), - ) - .map((e) => e.roomId) - .cast() - .toList(); - - // return childrenAndGrandChildren - // .where((element) => element.roomId != null) - // .where( - // (child) { - // final room = client.getRoomById(child.roomId!); - // return room == null || room.isDirectChat; - // }, - // ) - // .map((e) => e.roomId) - // .cast() - // .toList(); - } - - //if the user is an admin of the room or any immediate parent of the room - //Question: check parents of parents? - //check logic - bool get isSpaceAdmin { - if (isSpace) return isRoomAdmin; - - for (final parent in pangeaSpaceParents) { - if (parent.isRoomAdmin) { - return true; - } - } - for (final parent in pangeaSpaceParents) { - for (final parent2 in parent.pangeaSpaceParents) { - if (parent2.isRoomAdmin) { - return true; - } - } - } - return false; - } - - bool isUserRoomAdmin(String userId) => getParticipants().any( - (e) => - e.id == userId && - e.powerLevel == ClassDefaultValues.powerLevelOfAdmin, - ); - - bool isUserSpaceAdmin(String userId) { - if (isSpace) return isUserRoomAdmin(userId); - - for (final parent in pangeaSpaceParents) { - if (parent.isUserRoomAdmin(userId)) { - return true; - } - } - return false; - } - - Event? get languageSettingsStateEvent => - getState(PangeaEventTypes.classSettings); - - Event? get pangeaRoomRulesStateEvent => getState(PangeaEventTypes.rules); - - bool get isPangeaClass => isSpace && languageSettingsStateEvent != null; - - bool get isAnalyticsRoom => - getState(EventTypes.RoomCreate)?.content.tryGet('type') == - PangeaRoomTypes.analytics; - - bool get isExchange => - isSpace && - languageSettingsStateEvent == null && - pangeaRoomRulesStateEvent != null; - - bool get isDirectChatWithoutMe => - isDirectChat && !getParticipants().any((e) => e.id == client.userID); - - bool isMadeByUser(String userId) => - getState(EventTypes.RoomCreate)?.senderId == userId; - - bool isMadeForLang(String langCode) { - final creationContent = getState(EventTypes.RoomCreate)?.content; - return creationContent?.tryGet(ModelKey.langCode) == langCode || - creationContent?.tryGet(ModelKey.oldLangCode) == langCode; - } - - bool isAnalyticsRoomOfUser(String userId) => - isAnalyticsRoom && isMadeByUser(userId); - - String get domainString => - AppConfig.defaultHomeserver.replaceAll("matrix.", ""); - - String get classCode { - if (!isSpace) { - for (final Room potentialClassRoom in pangeaSpaceParents) { - if (potentialClassRoom.isPangeaClass) { - return potentialClassRoom.classCode; - } - } - return "Not in a class!"; - } - - return canonicalAlias.replaceAll(":$domainString", "").replaceAll("#", ""); - } - - StudentAnalyticsEvent? _getStudentAnalyticsLocal(String studentId) { - if (!isSpace) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "calling getStudentAnalyticsLocal on non-space room", - s: StackTrace.current, - ); - return null; - } - - final Event? matrixEvent = getState( - PangeaEventTypes.studentAnalyticsSummary, - studentId, - ); - - return matrixEvent != null - ? StudentAnalyticsEvent(event: matrixEvent) - : null; - } + Future addAnalyticsRoomsToSpace() async => + await _addAnalyticsRoomsToSpace(); Future getStudentAnalytics( String studentId, { bool forcedUpdate = false, - }) async { - try { - if (!isSpace) { - debugger(when: kDebugMode); - throw Exception("calling getStudentAnalyticsLocal on non-space room"); - } - StudentAnalyticsEvent? localEvent = _getStudentAnalyticsLocal(studentId); + }) async => + await _getStudentAnalytics(studentId, forcedUpdate: forcedUpdate); - if (localEvent == null) { - await postLoad(); - localEvent = _getStudentAnalyticsLocal(studentId); - } - - if (studentId == client.userID && localEvent == null) { - final Event? matrixEvent = await _createStudentAnalyticsEvent(); - if (matrixEvent != null) { - localEvent = StudentAnalyticsEvent(event: matrixEvent); - } - } - - return localEvent; - } catch (err) { - debugger(when: kDebugMode); - rethrow; - } - } - - void checkClass() { - if (!isSpace) { - debugger(when: kDebugMode); - Sentry.addBreadcrumb( - Breadcrumb(message: "calling room.students with non-class room"), - ); - } - } - - List get students { - checkClass(); - return isSpace - ? getParticipants() - .where( - (e) => - e.powerLevel < ClassDefaultValues.powerLevelOfAdmin && - e.id != BotName.byEnvironment, - ) - .toList() - : getParticipants(); - } - - Future> get teachers async { - checkClass(); - final List participants = await requestParticipants(); - return isSpace - ? participants - .where( - (e) => - e.powerLevel == ClassDefaultValues.powerLevelOfAdmin && - e.id != BotName.byEnvironment, - ) - .toList() - : participants; - } - - /// if [studentIds] is null, returns all students Future> getClassAnalytics([ List? studentIds, - ]) async { - await postLoad(); - await requestParticipants(); - final List> sassFutures = []; - final List filteredIds = students - .where( - (element) => studentIds == null || studentIds.contains(element.id), - ) - .map((e) => e.id) - .toList(); - for (final id in filteredIds) { - sassFutures.add( - getStudentAnalytics( - id, - ), + ]) async => + await _getClassAnalytics( + studentIds, ); - } - return Future.wait(sassFutures); - } - /// if [isSpace] - /// for all child chats, call _getChatAnalyticsGlobal and merge results - /// else - /// get analytics from pangea chat server - /// do any needed conversion work - /// save RoomAnalytics object to PangeaEventTypes.analyticsSummary event - Future _createStudentAnalyticsEvent() async { - try { - await postLoad(); - if (!pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) { - ErrorHandler.logError( - m: "null powerLevels in createStudentAnalytics", - s: StackTrace.current, - ); - return null; - } - if (client.userID == null) { - debugger(when: kDebugMode); - throw Exception("null userId in createStudentAnalytics"); - } - - final String eventId = await client.setRoomStateWithKey( - id, - PangeaEventTypes.studentAnalyticsSummary, - client.userID!, - StudentAnalyticsSummary( - // studentId: client.userID!, - lastUpdated: DateTime.now(), - messages: [], - ).toJson(), - ); - final Event? event = await getEventById(eventId); - - if (event == null) { - debugger(when: kDebugMode); - throw Exception( - "null event after creation with eventId $eventId in createStudentAnalytics", - ); - } - return event; - } catch (err, stack) { - ErrorHandler.logError(e: err, s: stack, data: powerLevels); - return null; - } - } - - /// for each chat in class - /// get timeline back to january 15 - /// get messages - /// discard timeline - /// save messages to StudentAnalyticsSummary Future updateMyLearningAnalyticsForClass([ PLocalStore? storageService, - ]) async { - try { - final String migratedAnalyticsKey = - "MIGRATED_ANALYTICS_KEY${id.localpart}"; - - if (storageService?.read( - migratedAnalyticsKey, - local: true, - ) ?? - false) return; - - if (!isPangeaClass && !isExchange) { - throw Exception( - "In updateMyLearningAnalyticsForClass with room that is not not a class", - ); - } - - if (client.userID == null) { - debugger(when: kDebugMode); - return; - } - - final StudentAnalyticsEvent? myAnalEvent = - await getStudentAnalytics(client.userID!); - - if (myAnalEvent == null) { - debugPrint("null analytcs event for $id"); - if (pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) { - // debugger(when: kDebugMode); - } - return; - } - - final updateMessages = await _messageListForAllChildChats; - updateMessages.removeWhere( - (element) => myAnalEvent.content.messages.any( - (e) => e.eventId == element.eventId, - ), + ]) async => + await _updateMyLearningAnalyticsForClass( + storageService, ); - myAnalEvent.bulkUpdate(updateMessages); - await storageService?.save( - migratedAnalyticsKey, - true, - local: true, - ); - } catch (err, s) { - if (kDebugMode) rethrow; - // debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s); - } - } + Future inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async => + await _inviteSpaceTeachersToAnalyticsRoom(analyticsRoom); - Future> get _messageListForAllChildChats async { - try { - if (!isSpace) return []; - final List spaceChats = spaceChildren - .where((e) => e.roomId != null) - .map((e) => client.getRoomById(e.roomId!)) - .where((element) => element != null) - .cast() - .where((element) => !element.isSpace) - .toList(); + Future inviteTeachersToAnalyticsRoom() async => + await _inviteTeachersToAnalyticsRoom(); - final List>> msgListFutures = []; - for (final chat in spaceChats) { - msgListFutures.add(chat._messageListForChat); - } - final List> msgLists = - await Future.wait(msgListFutures); + // Invite teachers of 1 space to all users' analytics rooms + Future inviteSpaceTeachersToAnalyticsRooms() async => + await _inviteSpaceTeachersToAnalyticsRooms(); - final List joined = []; - for (final msgList in msgLists) { - joined.addAll(msgList); - } - return joined; - } catch (err) { - // debugger(when: kDebugMode); - rethrow; - } - } + // children_and_parents - Future> get _messageListForChat async { - try { - int numberOfSearches = 0; + List get joinedChildren => _joinedChildren; - if (isSpace) { - throw Exception( - "In messageListForChat with room that is not a chat", - ); - } - final Timeline timeline = await getTimeline(); + List get joinedChildrenRoomIds => _joinedChildrenRoomIds; - while (timeline.canRequestHistory && numberOfSearches < 50) { - await timeline.requestHistory(historyCount: 100); - numberOfSearches += 1; - } - if (timeline.canRequestHistory) { - debugger(when: kDebugMode); - } + List get childrenAndGrandChildren => _childrenAndGrandChildren; - final List msgs = []; - for (final event in timeline.events) { - if (event.senderId == client.userID && - event.type == EventTypes.Message && - event.content['msgtype'] == MessageTypes.Text) { - final PangeaMessageEvent pMsgEvent = PangeaMessageEvent( - event: event, - timeline: timeline, - ownMessage: true, - ); - msgs.add( - RecentMessageRecord( - eventId: event.eventId, - chatId: id, - useType: pMsgEvent.useType, - time: event.originServerTs, - ), - ); - } - } - return msgs; - } catch (err, s) { - if (kDebugMode) rethrow; - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s); - return []; - } - } + List get childrenAndGrandChildrenDirectChatIds => + _childrenAndGrandChildrenDirectChatIds; + + Future> getChildRooms() async => await _getChildRooms(); + + Future joinSpaceChild(String roomID) async => + await _joinSpaceChild(roomID); + + Room? firstParentWithState(String stateType) => + _firstParentWithState(stateType); + + List get immediateClassParents => _immediateClassParents; + + List get pangeaSpaceParents => _pangeaSpaceParents; + +// class_and_exchange_settings + + DateTime? get rulesUpdatedAt => _rulesUpdatedAt; + + String get classCode => _classCode; + + void checkClass() => _checkClass(); + + List get students => _students; + + Future> get teachers async => await _teachers; + + Future setClassPowerLevels() async => await _setClassPowerLevels(); + + DateTime? get classSettingsUpdatedAt => _classSettingsUpdatedAt; + + ClassSettingsModel? get classSettings => _classSettings; + + Event? get languageSettingsStateEvent => _languageSettingsStateEvent; + + Event? get pangeaRoomRulesStateEvent => _pangeaRoomRulesStateEvent; + + ClassSettingsModel? get firstLanguageSettings => _firstLanguageSettings; + +// events Future sendPangeaEvent({ required Map content, required String parentEventId, required String type, - }) async { - try { - debugPrint("creating $type child for $parentEventId"); - Sentry.addBreadcrumb(Breadcrumb.fromJson(content)); - if (parentEventId.contains("web")) { - debugger(when: kDebugMode); - Sentry.addBreadcrumb( - Breadcrumb( - message: - "sendPangeaEvent with likely invalid parentEventId $parentEventId", - ), - ); - } - final Map repContent = { - // what is the functionality of m.reference? - "m.relates_to": {"rel_type": type, "event_id": parentEventId}, - type: content, - }; - - final String? newEventId = await sendEvent(repContent, type: type); - - if (newEventId == null) { - debugger(when: kDebugMode); - return null; - } - - //PTODO - handle the frequent case of a null newEventId - final Event? newEvent = await getEventById(newEventId); - - if (newEvent == null) { - debugger(when: kDebugMode); - } - - return newEvent; - } catch (err, stack) { - // debugger(when: kDebugMode); - ErrorHandler.logError( - e: err, - s: stack, - data: { - "type": type, - "parentEventId": parentEventId, - "content": content, - }, + }) async => + await _sendPangeaEvent( + content: content, + parentEventId: parentEventId, + type: type, ); - return null; - } - } - - ConstructEvent? _vocabEventLocal(String lemma) { - if (!isAnalyticsRoom) throw Exception("not an analytics room"); - - final Event? matrixEvent = getState(PangeaEventTypes.vocab, lemma); - - return matrixEvent != null ? ConstructEvent(event: matrixEvent) : null; - } - - bool get isRoomOwner => - getState(EventTypes.RoomCreate)?.senderId == client.userID; - - Future vocabEvent( - String lemma, - ConstructType type, [ - bool makeIfNull = false, - ]) async { - try { - if (!isAnalyticsRoom) throw Exception("not an analytics room"); - - ConstructEvent? localEvent = _vocabEventLocal(lemma); - - if (localEvent != null) return localEvent; - - await postLoad(); - localEvent = _vocabEventLocal(lemma); - - if (localEvent == null && isRoomOwner && makeIfNull) { - final Event matrixEvent = await _createVocabEvent(lemma, type); - localEvent = ConstructEvent(event: matrixEvent); - } - - return localEvent!; - } catch (err) { - debugger(when: kDebugMode); - rethrow; - } - } - - Future> removeEdittedLemmas( - List lemmaUses, - ) async { - final List removeUses = []; - for (final use in lemmaUses) { - if (use.msgId == null) continue; - final List removeIds = await client.getEditHistory( - use.chatId, - use.msgId!, - ); - removeUses.addAll(removeIds); - } - lemmaUses.removeWhere((use) => removeUses.contains(use.msgId)); - final allEvents = await allConstructEvents; - for (final constructEvent in allEvents) { - await constructEvent.removeEdittedUses(removeUses, client); - } - return lemmaUses; - } - - Future saveConstructUsesSameLemma( - String lemma, - ConstructType type, - List lemmaUses, { - bool isEdit = false, - }) async { - final ConstructEvent? localEvent = _vocabEventLocal(lemma); - - if (isEdit) { - lemmaUses = await removeEdittedLemmas(lemmaUses); - } - - if (localEvent == null) { - await client.setRoomStateWithKey( - id, - PangeaEventTypes.vocab, - lemma, - ConstructUses(lemma: lemma, type: type, uses: lemmaUses).toJson(), - ); - } else { - localEvent.addAll(lemmaUses); - await updateStateEvent(localEvent.event); - } - } - - Future> get allConstructEvents async { - await postLoad(); - return states[PangeaEventTypes.vocab] - ?.values - .map((Event event) => ConstructEvent(event: event)) - .toList() - .cast() ?? - []; - } - - Future _createVocabEvent(String lemma, ConstructType type) async { - try { - if (!isRoomOwner) { - throw Exception( - "Tried to create vocab event in room where user is not owner", - ); - } - final String eventId = await client.setRoomStateWithKey( - id, - PangeaEventTypes.vocab, - lemma, - ConstructUses(lemma: lemma, type: type).toJson(), - ); - final Event? event = await getEventById(eventId); - - if (event == null) { - debugger(when: kDebugMode); - throw Exception( - "null event after creation with eventId $eventId in _createVocabEvent", - ); - } - return event; - } catch (err, stack) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: stack, data: powerLevels); - rethrow; - } - } - - /// update state event and return eventId - Future updateStateEvent(Event stateEvent) { - if (stateEvent.stateKey == null) { - throw Exception("stateEvent.stateKey is null"); - } - return client.setRoomStateWithKey( - id, - stateEvent.type, - stateEvent.stateKey!, - stateEvent.content, - ); - } - - bool canIAddSpaceChild(Room? room) { - if (!isSpace) { - ErrorHandler.logError( - m: "should not call canIAddSpaceChildren on non-space room", - data: toJson(), - s: StackTrace.current, - ); - return false; - } - if (room != null && !room.isRoomAdmin) { - return false; - } - if (!pangeaCanSendEvent(EventTypes.spaceChild) && !isRoomAdmin) { - return false; - } - if (room == null) { - return isRoomAdmin || (pangeaRoomRules?.isCreateRooms ?? false); - } - if (room.isExchange) { - return isRoomAdmin; - } - if (!room.isSpace) { - return pangeaRoomRules?.isCreateRooms ?? false; - } - if (room.isPangeaClass) { - ErrorHandler.logError( - m: "should not call canIAddSpaceChild with class", - data: room.toJson(), - s: StackTrace.current, - ); - return false; - } - return false; - } - - bool get canIAddSpaceParents => - isRoomAdmin || pangeaCanSendEvent(EventTypes.spaceParent); - - bool get showClassEditOptions => isSpace && isRoomAdmin; - - bool get canDelete => isSpaceAdmin; - - bool get isRoomAdmin => ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin; - - //overriding the default canSendEvent to check power levels - bool pangeaCanSendEvent(String eventType) { - final powerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content; - if (powerLevelsMap == null) return 0 <= ownPowerLevel; - final pl = powerLevelsMap - .tryGetMap('events') - ?.tryGet(eventType) ?? - 100; - return ownPowerLevel >= pl; - } - - Future setClassPowerlLevels() async { - try { - if (ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin) { - return; - } - final Event? currentPower = getState(EventTypes.RoomPowerLevels); - final Map? currentPowerContent = - currentPower?.content["events"] as Map?; - final spaceChildPower = currentPowerContent?[EventTypes.spaceChild]; - final studentAnalyticsPower = - currentPowerContent?[PangeaEventTypes.studentAnalyticsSummary]; - - if ((spaceChildPower == null || studentAnalyticsPower == null) && - currentPowerContent != null) { - currentPowerContent["events"][EventTypes.spaceChild] = 0; - currentPowerContent["events"] - [PangeaEventTypes.studentAnalyticsSummary] = 0; - - await client.setRoomStateWithKey( - id, - EventTypes.RoomPowerLevels, - currentPower?.stateKey ?? "", - currentPowerContent, - ); - } - } catch (err, s) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s, data: toJson()); - } - } Future pangeaSendTextEvent( String message, { @@ -908,354 +165,123 @@ extension PangeaRoom on Room { PangeaMessageTokens? tokensWritten, ChoreoRecord? choreo, UseType? useType, - }) { - // if (parseCommands) { - // return client.parseAndRunCommand(this, message, - // inReplyTo: inReplyTo, - // editEventId: editEventId, - // txid: txid, - // threadRootEventId: threadRootEventId, - // threadLastEventId: threadLastEventId); - // } - final event = { - 'msgtype': msgtype, - 'body': message, - ModelKey.choreoRecord: choreo?.toJson(), - ModelKey.originalSent: originalSent?.toJson(), - ModelKey.originalWritten: originalWritten?.toJson(), - ModelKey.tokensSent: tokensSent?.toJson(), - ModelKey.tokensWritten: tokensWritten?.toJson(), - ModelKey.useType: useType?.string, - }; - if (parseMarkdown) { - final html = markdown( - event['body'], - getEmotePacks: () => getImagePacksFlat(ImagePackUsage.emoticon), - getMention: getMention, + }) => + _pangeaSendTextEvent( + message, + txid: txid, + inReplyTo: inReplyTo, + editEventId: editEventId, + parseMarkdown: parseMarkdown, + parseCommands: parseCommands, + msgtype: msgtype, + threadRootEventId: threadRootEventId, + threadLastEventId: threadLastEventId, + originalSent: originalSent, + originalWritten: originalWritten, + tokensSent: tokensSent, + tokensWritten: tokensWritten, + choreo: choreo, + useType: useType, ); - // if the decoded html is the same as the body, there is no need in sending a formatted message - if (HtmlUnescape().convert(html.replaceAll(RegExp(r'
\n?'), '\n')) != - event['body']) { - event['format'] = 'org.matrix.custom.html'; - event['formatted_body'] = html; - } - } - return sendEvent( - event, - txid: txid, - inReplyTo: inReplyTo, - editEventId: editEventId, - threadRootEventId: threadRootEventId, - threadLastEventId: threadLastEventId, - ); - } - int? get eventsDefaultPowerLevel => getState(EventTypes.RoomPowerLevels) - ?.content - .tryGet('events_default'); + Future updateStateEvent(Event stateEvent) => + _updateStateEvent(stateEvent); - bool get locked { - if (isDirectChat) return false; - if (!isSpace) { - if (eventsDefaultPowerLevel == null) return false; - return (eventsDefaultPowerLevel ?? 0) >= - ClassDefaultValues.powerLevelOfAdmin; - } - int joinedRooms = 0; - for (final child in spaceChildren) { - if (child.roomId == null) continue; - final Room? room = client.getRoomById(child.roomId!); - if (room?.locked == false) { - return false; - } - if (room != null) { - joinedRooms += 1; - } - } - return joinedRooms > 0 ? true : false; - } + Future vocabEvent( + String lemma, + ConstructType type, [ + bool makeIfNull = false, + ]) => + _vocabEvent(lemma, type, makeIfNull); - Future suggestedInSpace(Room space) async { - try { - final Map resp = - await client.getRoomStateWithKey(space.id, EventTypes.spaceChild, id); - return resp.containsKey('suggested') ? resp['suggested'] as bool : true; - } catch (err) { - ErrorHandler.logError( - e: "Failed to fetch suggestion status of room $id in space ${space.id}", - s: StackTrace.current, - ); - return true; - } - } + Future> removeEditedLemmas( + List lemmaUses, + ) async => + await _removeEditedLemmas(lemmaUses); - Future setSuggestedInSpace(bool suggest, Room space) async { - try { - await space.setSpaceChild(id, suggested: suggest); - } catch (err) { - ErrorHandler.logError( - e: "Failed to set suggestion status of room $id in space ${space.id}", - s: StackTrace.current, - ); - return; - } - } + Future saveConstructUsesSameLemma( + String lemma, + ConstructType type, + List lemmaUses, { + bool isEdit = false, + }) async => + await _saveConstructUsesSameLemma(lemma, type, lemmaUses, isEdit: isEdit); - Future> getChildRooms() async { - final List children = []; - for (final child in spaceChildren) { - if (child.roomId == null) continue; - final Room? room = client.getRoomById(child.roomId!); - if (room != null) { - children.add(room); - } - } - return children; - } + Future> get allConstructEvents async => + await _allConstructEvents; - DateTime? get classSettingsUpdatedAt { - if (!isSpace) return null; - return languageSettingsStateEvent?.originServerTs ?? creationTime; - } +// room_information - DateTime? get rulesUpdatedAt { - if (!isSpace) return null; - return pangeaRoomRulesStateEvent?.originServerTs ?? creationTime; - } + DateTime? get creationTime => _creationTime; - Future get isBotRoom async { - final List participants = await requestParticipants(); - return participants.any( - (User user) => user.id == BotName.byEnvironment, - ); - } + String? get creatorId => _creatorId; - Future get isBotDM async => - (await isBotRoom) && getParticipants().length == 2; + String get domainString => _domainString; - BotOptionsModel? get botOptions { - if (isSpace) return null; - return BotOptionsModel.fromJson( - getState(PangeaEventTypes.botOptions)?.content ?? {}, - ); - } + bool isChild(String roomId) => _isChild(roomId); - // add 1 analytics room to 1 space - Future addAnalyticsRoomToSpace(Room analyticsRoom) async { - if (!isSpace) { - debugPrint("addAnalyticsRoomToSpace called on non-space room"); - Sentry.addBreadcrumb( - Breadcrumb( - message: "addAnalyticsRoomToSpace called on non-space room", - ), - ); - return Future.value(); - } + bool isFirstOrSecondChild(String roomId) => _isFirstOrSecondChild(roomId); - if (spaceChildren.any((sc) => sc.roomId == analyticsRoom.id)) return; - if (canIAddSpaceChild(null)) { - try { - await setSpaceChild(analyticsRoom.id); - } catch (err) { - debugPrint( - "Failed to add analytics room ${analyticsRoom.id} for student to space $id", - ); - Sentry.addBreadcrumb( - Breadcrumb( - message: "Failed to add analytics room to space $id", - ), - ); - } - } - } + bool get isExchange => _isExchange; - // 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; - } + bool get isDirectChatWithoutMe => _isDirectChatWithoutMe; - for (final Room space in (await client.classesAndExchangesImStudyingIn)) { - if (space.spaceChildren.any((sc) => sc.roomId == id)) continue; - await space.addAnalyticsRoomToSpace(this); - } - } + bool isMadeForLang(String langCode) => _isMadeForLang(langCode); - // 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); - } - } + Future get isBotRoom async => await _isBotRoom; - // 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 (!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, - ); - } - } - } - } + Future get isBotDM async => await _isBotDM; - // 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; - } + bool get isLocked => _isLocked; - if (!isAnalyticsRoomOfUser(client.userID!)) { - debugPrint("inviteTeachersToAnalyticsRoom called on non-analytics room"); - Sentry.addBreadcrumb( - Breadcrumb( - message: "inviteTeachersToAnalyticsRoom called on non-analytics room", - ), - ); - return; - } + bool get isPangeaClass => _isPangeaClass; - for (final Room space in (await client.classesAndExchangesImStudyingIn)) { - await space.inviteSpaceTeachersToAnalyticsRoom(this); - } - } + bool isAnalyticsRoomOfUser(String userId) => _isAnalyticsRoomOfUser(userId); - // Invite teachers of 1 space to all users' analytics rooms - Future inviteSpaceTeachersToAnalyticsRooms() async { - for (final Room analyticsRoom in client.allMyAnalyticsRooms) { - await inviteSpaceTeachersToAnalyticsRoom(analyticsRoom); - } - } + bool get isAnalyticsRoom => _isAnalyticsRoom; - // 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; - } +// room_settings - // 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(); + PangeaRoomRules? get pangeaRoomRules => _pangeaRoomRules; - if (!isRoomAdmin) { - debugPrint("joinAnalyticsRoomsInSpace called by non-admin"); - Sentry.addBreadcrumb( - Breadcrumb( - message: "joinAnalyticsRoomsInSpace called by non-admin", - ), - ); - return; - } + PangeaRoomRules? get firstRules => _firstRules; - final spaceHierarchy = await client.getSpaceHierarchy( - id, - maxDepth: 1, - ); + IconData? get roomTypeIcon => _roomTypeIcon; - final List analyticsRoomIds = spaceHierarchy.rooms - .where( - (r) => r.roomType == PangeaRoomTypes.analytics, - ) - .map((r) => r.roomId) - .toList(); + Text nameAndRoomTypeIcon([TextStyle? textStyle]) => + _nameAndRoomTypeIcon(textStyle); - 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, - ); - } - } - } + BotOptionsModel? get botOptions => _botOptions; - 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; - } + Future suggestedInSpace(Room space) async => + await _suggestedInSpace(space); - if (![Membership.invite, Membership.join].contains(child.membership)) { - final waitForRoom = client.waitForRoomInSync( - roomID, - join: true, - ); - await child.join(); - await waitForRoom; - } - } + Future setSuggestedInSpace(bool suggest, Room space) async => + await _setSuggestedInSpace(suggest, space); - // 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); - } +// user_permissions + + bool isMadeByUser(String userId) => _isMadeByUser(userId); + + bool get isSpaceAdmin => _isSpaceAdmin; + + bool isUserRoomAdmin(String userId) => _isUserRoomAdmin(userId); + + bool isUserSpaceAdmin(String userId) => _isUserSpaceAdmin(userId); + + bool get isRoomOwner => _isRoomOwner; + + bool get isRoomAdmin => _isRoomAdmin; + + bool get showClassEditOptions => _showClassEditOptions; + + bool get canDelete => _canDelete; + + bool canIAddSpaceChild(Room? room) => _canIAddSpaceChild(room); + + bool get canIAddSpaceParents => _canIAddSpaceParents; + + bool pangeaCanSendEvent(String eventType) => _pangeaCanSendEvent(eventType); + + int? get eventsDefaultPowerLevel => _eventsDefaultPowerLevel; } diff --git a/lib/pangea/extensions/pangea_room_extension/analytics.dart b/lib/pangea/extensions/pangea_room_extension/analytics.dart new file mode 100644 index 000000000..3540c1ee1 --- /dev/null +++ b/lib/pangea/extensions/pangea_room_extension/analytics.dart @@ -0,0 +1,376 @@ +part of "../pangea_room_extension.dart"; + +extension PangeaRoom1 on Room { + // 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, + ); + } + } + } + + // 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); + } + + // add 1 analytics room to 1 space + Future _addAnalyticsRoomToSpace(Room analyticsRoom) async { + if (!isSpace) { + debugPrint("addAnalyticsRoomToSpace called on non-space room"); + Sentry.addBreadcrumb( + Breadcrumb( + message: "addAnalyticsRoomToSpace called on non-space room", + ), + ); + return Future.value(); + } + + if (spaceChildren.any((sc) => sc.roomId == analyticsRoom.id)) return; + if (canIAddSpaceChild(null)) { + try { + await setSpaceChild(analyticsRoom.id); + } catch (err) { + debugPrint( + "Failed to add analytics room ${analyticsRoom.id} for student to space $id", + ); + Sentry.addBreadcrumb( + Breadcrumb( + message: "Failed to add analytics room to space $id", + ), + ); + } + } + } + + // 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; + await 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); + } + } + + StudentAnalyticsEvent? _getStudentAnalyticsLocal(String studentId) { + if (!isSpace) { + debugger(when: kDebugMode); + ErrorHandler.logError( + m: "calling getStudentAnalyticsLocal on non-space room", + s: StackTrace.current, + ); + return null; + } + + final Event? matrixEvent = getState( + PangeaEventTypes.studentAnalyticsSummary, + studentId, + ); + + return matrixEvent != null + ? StudentAnalyticsEvent(event: matrixEvent) + : null; + } + + Future _getStudentAnalytics( + String studentId, { + bool forcedUpdate = false, + }) async { + try { + if (!isSpace) { + debugger(when: kDebugMode); + throw Exception("calling getStudentAnalyticsLocal on non-space room"); + } + StudentAnalyticsEvent? localEvent = _getStudentAnalyticsLocal(studentId); + + if (localEvent == null) { + await postLoad(); + localEvent = _getStudentAnalyticsLocal(studentId); + } + + if (studentId == client.userID && localEvent == null) { + final Event? matrixEvent = await _createStudentAnalyticsEvent(); + if (matrixEvent != null) { + localEvent = StudentAnalyticsEvent(event: matrixEvent); + } + } + + return localEvent; + } catch (err) { + debugger(when: kDebugMode); + rethrow; + } + } + + /// if [studentIds] is null, returns all students + Future> _getClassAnalytics([ + List? studentIds, + ]) async { + await postLoad(); + await requestParticipants(); + final List> sassFutures = []; + final List filteredIds = students + .where( + (element) => studentIds == null || studentIds.contains(element.id), + ) + .map((e) => e.id) + .toList(); + for (final id in filteredIds) { + sassFutures.add( + getStudentAnalytics( + id, + ), + ); + } + return Future.wait(sassFutures); + } + + /// if [isSpace] + /// for all child chats, call _getChatAnalyticsGlobal and merge results + /// else + /// get analytics from pangea chat server + /// do any needed conversion work + /// save RoomAnalytics object to PangeaEventTypes.analyticsSummary event + Future _createStudentAnalyticsEvent() async { + try { + await postLoad(); + if (!pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) { + ErrorHandler.logError( + m: "null powerLevels in createStudentAnalytics", + s: StackTrace.current, + ); + return null; + } + if (client.userID == null) { + debugger(when: kDebugMode); + throw Exception("null userId in createStudentAnalytics"); + } + + final String eventId = await client.setRoomStateWithKey( + id, + PangeaEventTypes.studentAnalyticsSummary, + client.userID!, + StudentAnalyticsSummary( + // studentId: client.userID!, + lastUpdated: DateTime.now(), + messages: [], + ).toJson(), + ); + final Event? event = await getEventById(eventId); + + if (event == null) { + debugger(when: kDebugMode); + throw Exception( + "null event after creation with eventId $eventId in createStudentAnalytics", + ); + } + return event; + } catch (err, stack) { + ErrorHandler.logError(e: err, s: stack, data: powerLevels); + return null; + } + } + + /// for each chat in class + /// get timeline back to january 15 + /// get messages + /// discard timeline + /// save messages to StudentAnalyticsSummary + Future _updateMyLearningAnalyticsForClass([ + PLocalStore? storageService, + ]) async { + try { + final String migratedAnalyticsKey = + "MIGRATED_ANALYTICS_KEY${id.localpart}"; + + if (storageService?.read( + migratedAnalyticsKey, + local: true, + ) ?? + false) return; + + if (!isPangeaClass && !isExchange) { + throw Exception( + "In updateMyLearningAnalyticsForClass with room that is not not a class", + ); + } + + if (client.userID == null) { + debugger(when: kDebugMode); + return; + } + + final StudentAnalyticsEvent? myAnalEvent = + await getStudentAnalytics(client.userID!); + + if (myAnalEvent == null) { + debugPrint("null analytcs event for $id"); + if (pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) { + // debugger(when: kDebugMode); + } + return; + } + + final updateMessages = await _messageListForAllChildChats; + updateMessages.removeWhere( + (element) => myAnalEvent.content.messages.any( + (e) => e.eventId == element.eventId, + ), + ); + myAnalEvent.bulkUpdate(updateMessages); + + await storageService?.save( + migratedAnalyticsKey, + true, + local: true, + ); + } catch (err, s) { + if (kDebugMode) rethrow; + // debugger(when: kDebugMode); + ErrorHandler.logError(e: err, s: s); + } + } + + // 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 (!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, + ); + } + } + } + } + + // 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.classesAndExchangesImStudyingIn)) { + await 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); + } + } +} diff --git a/lib/pangea/extensions/pangea_room_extension/children_and_parents.dart b/lib/pangea/extensions/pangea_room_extension/children_and_parents.dart new file mode 100644 index 000000000..e4b5aa265 --- /dev/null +++ b/lib/pangea/extensions/pangea_room_extension/children_and_parents.dart @@ -0,0 +1,148 @@ +part of "../pangea_room_extension.dart"; + +extension PangeaRoom2 on Room { + //note this only will return rooms that the user has joined or been invited to + List get _joinedChildren { + if (!isSpace) return []; + return spaceChildren + .where((child) => child.roomId != null) + .map( + (child) => client.getRoomById(child.roomId!), + ) + .where((child) => child != null) + .cast() + .where( + (child) => child.membership == Membership.join, + ) + .toList(); + } + + List get _joinedChildrenRoomIds => + joinedChildren.map((child) => child.id).toList(); + + List get _childrenAndGrandChildren { + if (!isSpace) return []; + final List kids = []; + for (final child in spaceChildren) { + kids.add(child); + if (child.roomId != null) { + final Room? childRoom = client.getRoomById(child.roomId!); + if (childRoom != null && childRoom.isSpace) { + kids.addAll(childRoom.spaceChildren); + } + } + } + return kids.where((element) => element.roomId != null).toList(); + } + + //this assumes that a user has been invited to all group chats in a space + //it is a janky workaround for determining whether a spacechild is a direct chat + //since the spaceChild object doesn't contain this info. this info is only accessible + //when the user has joined or been invited to the room. direct chats included in + //a space show up in spaceChildren but the user has not been invited to them. + List get _childrenAndGrandChildrenDirectChatIds { + final List nonDirectChatRoomIds = childrenAndGrandChildren + .where((child) => child.roomId != null) + .map((e) => client.getRoomById(e.roomId!)) + .where((r) => r != null && !r.isDirectChat) + .map((e) => e!.id) + .toList(); + + return childrenAndGrandChildren + .where( + (child) => + child.roomId != null && + !nonDirectChatRoomIds.contains(child.roomId), + ) + .map((e) => e.roomId) + .cast() + .toList(); + + // return childrenAndGrandChildren + // .where((element) => element.roomId != null) + // .where( + // (child) { + // final room = client.getRoomById(child.roomId!); + // return room == null || room.isDirectChat; + // }, + // ) + // .map((e) => e.roomId) + // .cast() + // .toList(); + } + + Future> _getChildRooms() async { + final List children = []; + for (final child in spaceChildren) { + if (child.roomId == null) continue; + final Room? room = client.getRoomById(child.roomId!); + if (room != null) { + children.add(room); + } + } + return children; + } + + 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; + } + } + + //resolve somehow if multiple rooms have the state? + //check logic + Room? _firstParentWithState(String stateType) { + if (![PangeaEventTypes.classSettings, PangeaEventTypes.rules] + .contains(stateType)) { + return null; + } + + for (final parent in pangeaSpaceParents) { + if (parent.getState(stateType) != null) { + return parent; + } + } + for (final parent in pangeaSpaceParents) { + final parentFirstRoom = parent.firstParentWithState(stateType); + if (parentFirstRoom != null) return parentFirstRoom; + } + return null; + } + + /// find any parents and return the rooms + List get _immediateClassParents => pangeaSpaceParents + .where( + (element) => element.isPangeaClass, + ) + .toList(); + + List get _pangeaSpaceParents => client.rooms + .where( + (r) => r.isSpace, + ) + .where( + (space) => space.spaceChildren.any( + (room) => room.roomId == id, + ), + ) + .toList(); +} diff --git a/lib/pangea/extensions/pangea_room_extension/class_and_exchange_settings.dart b/lib/pangea/extensions/pangea_room_extension/class_and_exchange_settings.dart new file mode 100644 index 000000000..123793ba1 --- /dev/null +++ b/lib/pangea/extensions/pangea_room_extension/class_and_exchange_settings.dart @@ -0,0 +1,129 @@ +part of "../pangea_room_extension.dart"; + +extension PangeaRoom3 on Room { + DateTime? get _rulesUpdatedAt { + if (!isSpace) return null; + return pangeaRoomRulesStateEvent?.originServerTs ?? creationTime; + } + + String get _classCode { + if (!isSpace) { + for (final Room potentialClassRoom in pangeaSpaceParents) { + if (potentialClassRoom.isPangeaClass) { + return potentialClassRoom.classCode; + } + } + return "Not in a class!"; + } + + return canonicalAlias.replaceAll(":$domainString", "").replaceAll("#", ""); + } + + void _checkClass() { + if (!isSpace) { + debugger(when: kDebugMode); + Sentry.addBreadcrumb( + Breadcrumb(message: "calling room.students with non-class room"), + ); + } + } + + List get _students { + checkClass(); + return isSpace + ? getParticipants() + .where( + (e) => + e.powerLevel < ClassDefaultValues.powerLevelOfAdmin && + e.id != BotName.byEnvironment, + ) + .toList() + : getParticipants(); + } + + Future> get _teachers async { + checkClass(); + final List participants = await requestParticipants(); + return isSpace + ? participants + .where( + (e) => + e.powerLevel == ClassDefaultValues.powerLevelOfAdmin && + e.id != BotName.byEnvironment, + ) + .toList() + : participants; + } + + Future _setClassPowerLevels() async { + try { + if (ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin) { + return; + } + final Event? currentPower = getState(EventTypes.RoomPowerLevels); + final Map? currentPowerContent = + currentPower?.content["events"] as Map?; + final spaceChildPower = currentPowerContent?[EventTypes.spaceChild]; + final studentAnalyticsPower = + currentPowerContent?[PangeaEventTypes.studentAnalyticsSummary]; + + if ((spaceChildPower == null || studentAnalyticsPower == null) && + currentPowerContent != null) { + currentPowerContent["events"][EventTypes.spaceChild] = 0; + currentPowerContent["events"] + [PangeaEventTypes.studentAnalyticsSummary] = 0; + + await client.setRoomStateWithKey( + id, + EventTypes.RoomPowerLevels, + currentPower?.stateKey ?? "", + currentPowerContent, + ); + } + } catch (err, s) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: err, s: s, data: toJson()); + } + } + + DateTime? get _classSettingsUpdatedAt { + if (!isSpace) return null; + return languageSettingsStateEvent?.originServerTs ?? creationTime; + } + + /// the pangeaClass event is listed an importantStateEvent so, if event exists, + /// it's already local. If it's an old class and doesn't, then the class_controller + /// should automatically migrate during this same session, when the space is first loaded + ClassSettingsModel? get _classSettings { + try { + if (!isSpace) { + return null; + } + final Map? content = languageSettingsStateEvent?.content; + if (content != null) { + final ClassSettingsModel classSettings = + ClassSettingsModel.fromJson(content); + return classSettings; + } + return null; + } catch (err, s) { + Sentry.addBreadcrumb( + Breadcrumb( + message: "Error in classSettings", + data: {"room": toJson()}, + ), + ); + ErrorHandler.logError(e: err, s: s); + return null; + } + } + + Event? get _languageSettingsStateEvent => + getState(PangeaEventTypes.classSettings); + + Event? get _pangeaRoomRulesStateEvent => getState(PangeaEventTypes.rules); + + ClassSettingsModel? get _firstLanguageSettings => + classSettings ?? + firstParentWithState(PangeaEventTypes.classSettings)?.classSettings; +} diff --git a/lib/pangea/extensions/pangea_room_extension/events.dart b/lib/pangea/extensions/pangea_room_extension/events.dart new file mode 100644 index 000000000..594cc8fcb --- /dev/null +++ b/lib/pangea/extensions/pangea_room_extension/events.dart @@ -0,0 +1,323 @@ +part of "../pangea_room_extension.dart"; + +extension PangeaRoom4 on Room { + Future _sendPangeaEvent({ + required Map content, + required String parentEventId, + required String type, + }) async { + try { + debugPrint("creating $type child for $parentEventId"); + Sentry.addBreadcrumb(Breadcrumb.fromJson(content)); + if (parentEventId.contains("web")) { + debugger(when: kDebugMode); + Sentry.addBreadcrumb( + Breadcrumb( + message: + "sendPangeaEvent with likely invalid parentEventId $parentEventId", + ), + ); + } + final Map repContent = { + // what is the functionality of m.reference? + "m.relates_to": {"rel_type": type, "event_id": parentEventId}, + type: content, + }; + + final String? newEventId = await sendEvent(repContent, type: type); + + if (newEventId == null) { + debugger(when: kDebugMode); + return null; + } + + //PTODO - handle the frequent case of a null newEventId + final Event? newEvent = await getEventById(newEventId); + + if (newEvent == null) { + debugger(when: kDebugMode); + } + + return newEvent; + } catch (err, stack) { + // debugger(when: kDebugMode); + ErrorHandler.logError( + e: err, + s: stack, + data: { + "type": type, + "parentEventId": parentEventId, + "content": content, + }, + ); + return null; + } + } + + Future _pangeaSendTextEvent( + String message, { + String? txid, + Event? inReplyTo, + String? editEventId, + bool parseMarkdown = true, + bool parseCommands = false, + String msgtype = MessageTypes.Text, + String? threadRootEventId, + String? threadLastEventId, + PangeaRepresentation? originalSent, + PangeaRepresentation? originalWritten, + PangeaMessageTokens? tokensSent, + PangeaMessageTokens? tokensWritten, + ChoreoRecord? choreo, + UseType? useType, + }) { + // if (parseCommands) { + // return client.parseAndRunCommand(this, message, + // inReplyTo: inReplyTo, + // editEventId: editEventId, + // txid: txid, + // threadRootEventId: threadRootEventId, + // threadLastEventId: threadLastEventId); + // } + final event = { + 'msgtype': msgtype, + 'body': message, + ModelKey.choreoRecord: choreo?.toJson(), + ModelKey.originalSent: originalSent?.toJson(), + ModelKey.originalWritten: originalWritten?.toJson(), + ModelKey.tokensSent: tokensSent?.toJson(), + ModelKey.tokensWritten: tokensWritten?.toJson(), + ModelKey.useType: useType?.string, + }; + if (parseMarkdown) { + final html = markdown( + event['body'], + getEmotePacks: () => getImagePacksFlat(ImagePackUsage.emoticon), + getMention: getMention, + ); + // if the decoded html is the same as the body, there is no need in sending a formatted message + if (HtmlUnescape().convert(html.replaceAll(RegExp(r'
\n?'), '\n')) != + event['body']) { + event['format'] = 'org.matrix.custom.html'; + event['formatted_body'] = html; + } + } + return sendEvent( + event, + txid: txid, + inReplyTo: inReplyTo, + editEventId: editEventId, + threadRootEventId: threadRootEventId, + threadLastEventId: threadLastEventId, + ); + } + + /// update state event and return eventId + Future _updateStateEvent(Event stateEvent) { + if (stateEvent.stateKey == null) { + throw Exception("stateEvent.stateKey is null"); + } + return client.setRoomStateWithKey( + id, + stateEvent.type, + stateEvent.stateKey!, + stateEvent.content, + ); + } + + Future> get _messageListForAllChildChats async { + try { + if (!isSpace) return []; + final List spaceChats = spaceChildren + .where((e) => e.roomId != null) + .map((e) => client.getRoomById(e.roomId!)) + .where((element) => element != null) + .cast() + .where((element) => !element.isSpace) + .toList(); + + final List>> msgListFutures = []; + for (final chat in spaceChats) { + msgListFutures.add(chat._messageListForChat); + } + final List> msgLists = + await Future.wait(msgListFutures); + + final List joined = []; + for (final msgList in msgLists) { + joined.addAll(msgList); + } + return joined; + } catch (err) { + // debugger(when: kDebugMode); + rethrow; + } + } + + Future> get _messageListForChat async { + try { + int numberOfSearches = 0; + + if (isSpace) { + throw Exception( + "In messageListForChat with room that is not a chat", + ); + } + final Timeline timeline = await getTimeline(); + + while (timeline.canRequestHistory && numberOfSearches < 50) { + await timeline.requestHistory(historyCount: 100); + numberOfSearches += 1; + } + if (timeline.canRequestHistory) { + debugger(when: kDebugMode); + } + + final List msgs = []; + for (final event in timeline.events) { + if (event.senderId == client.userID && + event.type == EventTypes.Message && + event.content['msgtype'] == MessageTypes.Text) { + final PangeaMessageEvent pMsgEvent = PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: true, + ); + msgs.add( + RecentMessageRecord( + eventId: event.eventId, + chatId: id, + useType: pMsgEvent.useType, + time: event.originServerTs, + ), + ); + } + } + return msgs; + } catch (err, s) { + if (kDebugMode) rethrow; + debugger(when: kDebugMode); + ErrorHandler.logError(e: err, s: s); + return []; + } + } + + ConstructEvent? _vocabEventLocal(String lemma) { + if (!isAnalyticsRoom) throw Exception("not an analytics room"); + + final Event? matrixEvent = getState(PangeaEventTypes.vocab, lemma); + + return matrixEvent != null ? ConstructEvent(event: matrixEvent) : null; + } + + Future _vocabEvent( + String lemma, + ConstructType type, [ + bool makeIfNull = false, + ]) async { + try { + if (!isAnalyticsRoom) throw Exception("not an analytics room"); + + ConstructEvent? localEvent = _vocabEventLocal(lemma); + + if (localEvent != null) return localEvent; + + await postLoad(); + localEvent = _vocabEventLocal(lemma); + + if (localEvent == null && isRoomOwner && makeIfNull) { + final Event matrixEvent = await _createVocabEvent(lemma, type); + localEvent = ConstructEvent(event: matrixEvent); + } + + return localEvent!; + } catch (err) { + debugger(when: kDebugMode); + rethrow; + } + } + + Future> _removeEditedLemmas( + List lemmaUses, + ) async { + final List removeUses = []; + for (final use in lemmaUses) { + if (use.msgId == null) continue; + final List removeIds = await client.getEditHistory( + use.chatId, + use.msgId!, + ); + removeUses.addAll(removeIds); + } + lemmaUses.removeWhere((use) => removeUses.contains(use.msgId)); + final allEvents = await allConstructEvents; + for (final constructEvent in allEvents) { + await constructEvent.removeEdittedUses(removeUses, client); + } + return lemmaUses; + } + + Future _saveConstructUsesSameLemma( + String lemma, + ConstructType type, + List lemmaUses, { + bool isEdit = false, + }) async { + final ConstructEvent? localEvent = _vocabEventLocal(lemma); + + if (isEdit) { + lemmaUses = await removeEditedLemmas(lemmaUses); + } + + if (localEvent == null) { + await client.setRoomStateWithKey( + id, + PangeaEventTypes.vocab, + lemma, + ConstructUses(lemma: lemma, type: type, uses: lemmaUses).toJson(), + ); + } else { + localEvent.addAll(lemmaUses); + await updateStateEvent(localEvent.event); + } + } + + Future> get _allConstructEvents async { + await postLoad(); + return states[PangeaEventTypes.vocab] + ?.values + .map((Event event) => ConstructEvent(event: event)) + .toList() + .cast() ?? + []; + } + + Future _createVocabEvent(String lemma, ConstructType type) async { + try { + if (!isRoomOwner) { + throw Exception( + "Tried to create vocab event in room where user is not owner", + ); + } + final String eventId = await client.setRoomStateWithKey( + id, + PangeaEventTypes.vocab, + lemma, + ConstructUses(lemma: lemma, type: type).toJson(), + ); + final Event? event = await getEventById(eventId); + + if (event == null) { + debugger(when: kDebugMode); + throw Exception( + "null event after creation with eventId $eventId in _createVocabEvent", + ); + } + return event; + } catch (err, stack) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: err, s: stack, data: powerLevels); + rethrow; + } + } +} diff --git a/lib/pangea/extensions/pangea_room_extension/room_information.dart b/lib/pangea/extensions/pangea_room_extension/room_information.dart new file mode 100644 index 000000000..fd2d5a4da --- /dev/null +++ b/lib/pangea/extensions/pangea_room_extension/room_information.dart @@ -0,0 +1,82 @@ +part of "../pangea_room_extension.dart"; + +extension PangeaRoom5 on Room { + DateTime? get _creationTime => + getState(EventTypes.RoomCreate)?.originServerTs; + + String? get _creatorId => getState(EventTypes.RoomCreate)?.senderId; + + String get _domainString => + AppConfig.defaultHomeserver.replaceAll("matrix.", ""); + + bool _isChild(String roomId) => + isSpace && spaceChildren.any((room) => room.roomId == roomId); + + bool _isFirstOrSecondChild(String roomId) { + return isSpace && + (spaceChildren.any((room) => room.roomId == roomId) || + spaceChildren + .where((sc) => sc.roomId != null) + .map((sc) => client.getRoomById(sc.roomId!)) + .any( + (room) => + room != null && + room.isSpace && + room.spaceChildren.any((room) => room.roomId == roomId), + )); + } + + bool get _isExchange => + isSpace && + languageSettingsStateEvent == null && + pangeaRoomRulesStateEvent != null; + + bool get _isDirectChatWithoutMe => + isDirectChat && !getParticipants().any((e) => e.id == client.userID); + + bool _isMadeForLang(String langCode) { + final creationContent = getState(EventTypes.RoomCreate)?.content; + return creationContent?.tryGet(ModelKey.langCode) == langCode || + creationContent?.tryGet(ModelKey.oldLangCode) == langCode; + } + + Future get _isBotRoom async { + final List participants = await requestParticipants(); + return participants.any( + (User user) => user.id == BotName.byEnvironment, + ); + } + + Future get _isBotDM async => + (await isBotRoom) && getParticipants().length == 2; + + bool get _isLocked { + if (isDirectChat) return false; + if (!isSpace) { + if (eventsDefaultPowerLevel == null) return false; + return (eventsDefaultPowerLevel ?? 0) >= + ClassDefaultValues.powerLevelOfAdmin; + } + int joinedRooms = 0; + for (final child in spaceChildren) { + if (child.roomId == null) continue; + final Room? room = client.getRoomById(child.roomId!); + if (room?.isLocked == false) { + return false; + } + if (room != null) { + joinedRooms += 1; + } + } + return joinedRooms > 0 ? true : false; + } + + bool get _isPangeaClass => isSpace && languageSettingsStateEvent != null; + + bool _isAnalyticsRoomOfUser(String userId) => + isAnalyticsRoom && isMadeByUser(userId); + + bool get _isAnalyticsRoom => + getState(EventTypes.RoomCreate)?.content.tryGet('type') == + PangeaRoomTypes.analytics; +} diff --git a/lib/pangea/extensions/pangea_room_extension/room_settings.dart b/lib/pangea/extensions/pangea_room_extension/room_settings.dart new file mode 100644 index 000000000..cd4d2d50b --- /dev/null +++ b/lib/pangea/extensions/pangea_room_extension/room_settings.dart @@ -0,0 +1,83 @@ +part of "../pangea_room_extension.dart"; + +extension PangeaRoom6 on Room { + PangeaRoomRules? get _pangeaRoomRules { + try { + final Map? content = pangeaRoomRulesStateEvent?.content; + if (content != null) { + final PangeaRoomRules roomRules = PangeaRoomRules.fromJson(content); + return roomRules; + } + return null; + } catch (err, s) { + Sentry.addBreadcrumb( + Breadcrumb( + message: "Error in pangeaRoomRules", + data: {"room": toJson()}, + ), + ); + ErrorHandler.logError(e: err, s: s); + return null; + } + } + + PangeaRoomRules? get _firstRules => + pangeaRoomRules ?? + firstParentWithState(PangeaEventTypes.rules)?.pangeaRoomRules; + + IconData? get _roomTypeIcon { + if (membership == Membership.invite) return Icons.add; + if (isPangeaClass) return Icons.school; + if (isExchange) return Icons.connecting_airports; + if (isAnalyticsRoom) return Icons.analytics; + if (isDirectChat) return Icons.forum; + return Icons.group; + } + + Text _nameAndRoomTypeIcon([TextStyle? textStyle]) => Text.rich( + style: textStyle, + TextSpan( + children: [ + WidgetSpan( + child: Icon(roomTypeIcon), + ), + TextSpan( + text: ' $name', + ), + ], + ), + ); + + BotOptionsModel? get _botOptions { + if (isSpace) return null; + return BotOptionsModel.fromJson( + getState(PangeaEventTypes.botOptions)?.content ?? {}, + ); + } + + Future _suggestedInSpace(Room space) async { + try { + final Map resp = + await client.getRoomStateWithKey(space.id, EventTypes.spaceChild, id); + return resp.containsKey('suggested') ? resp['suggested'] as bool : true; + } catch (err) { + ErrorHandler.logError( + e: "Failed to fetch suggestion status of room $id in space ${space.id}", + s: StackTrace.current, + ); + return true; + } + } + + Future _setSuggestedInSpace(bool suggest, Room space) async { + try { + await space.setSpaceChild(id, suggested: suggest); + } catch (err) { + ErrorHandler.logError( + e: "Failed to set suggestion status of room $id in space ${space.id}", + s: StackTrace.current, + ); + return; + } + } +} diff --git a/lib/pangea/extensions/pangea_room_extension/user_permissions.dart b/lib/pangea/extensions/pangea_room_extension/user_permissions.dart new file mode 100644 index 000000000..9e0349ac0 --- /dev/null +++ b/lib/pangea/extensions/pangea_room_extension/user_permissions.dart @@ -0,0 +1,107 @@ +part of "../pangea_room_extension.dart"; + +extension PangeaRoom7 on Room { + bool _isMadeByUser(String userId) => + getState(EventTypes.RoomCreate)?.senderId == userId; + + //if the user is an admin of the room or any immediate parent of the room + //Question: check parents of parents? + //check logic + bool get _isSpaceAdmin { + if (isSpace) return _isRoomAdmin; + + for (final parent in pangeaSpaceParents) { + if (parent._isRoomAdmin) { + return true; + } + } + for (final parent in pangeaSpaceParents) { + for (final parent2 in parent.pangeaSpaceParents) { + if (parent2._isRoomAdmin) { + return true; + } + } + } + return false; + } + + bool _isUserRoomAdmin(String userId) => getParticipants().any( + (e) => + e.id == userId && + e.powerLevel == ClassDefaultValues.powerLevelOfAdmin, + ); + + bool _isUserSpaceAdmin(String userId) { + if (isSpace) return isUserRoomAdmin(userId); + + for (final parent in pangeaSpaceParents) { + if (parent.isUserRoomAdmin(userId)) { + return true; + } + } + return false; + } + + bool get _isRoomOwner => + getState(EventTypes.RoomCreate)?.senderId == client.userID; + + bool get _isRoomAdmin => + ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin; + + bool get _showClassEditOptions => isSpace && isRoomAdmin; + + bool get _canDelete => isSpaceAdmin; + + bool _canIAddSpaceChild(Room? room) { + if (!isSpace) { + ErrorHandler.logError( + m: "should not call canIAddSpaceChildren on non-space room", + data: toJson(), + s: StackTrace.current, + ); + return false; + } + if (room != null && !room._isRoomAdmin) { + return false; + } + if (!pangeaCanSendEvent(EventTypes.spaceChild) && !_isRoomAdmin) { + return false; + } + if (room == null) { + return isRoomAdmin || (pangeaRoomRules?.isCreateRooms ?? false); + } + if (room.isExchange) { + return isRoomAdmin; + } + if (!room.isSpace) { + return pangeaRoomRules?.isCreateRooms ?? false; + } + if (room.isPangeaClass) { + ErrorHandler.logError( + m: "should not call canIAddSpaceChild with class", + data: room.toJson(), + s: StackTrace.current, + ); + return false; + } + return false; + } + + bool get _canIAddSpaceParents => + _isRoomAdmin || pangeaCanSendEvent(EventTypes.spaceParent); + + //overriding the default canSendEvent to check power levels + bool _pangeaCanSendEvent(String eventType) { + final powerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content; + if (powerLevelsMap == null) return 0 <= ownPowerLevel; + final pl = powerLevelsMap + .tryGetMap('events') + ?.tryGet(eventType) ?? + 100; + return ownPowerLevel >= pl; + } + + int? get _eventsDefaultPowerLevel => getState(EventTypes.RoomPowerLevels) + ?.content + .tryGet('events_default'); +}