import 'dart:async'; import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart' as matrix; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/languages/language_constants.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/languages/language_service.dart'; import 'package:fluffychat/pangea/languages/p_language_store.dart'; import 'package:fluffychat/pangea/learning_settings/tool_settings_enum.dart'; import 'package:fluffychat/pangea/user/analytics_profile_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'user_model.dart'; class LanguageUpdate { final LanguageModel? prevBaseLang; final LanguageModel? prevTargetLang; final LanguageModel baseLang; final LanguageModel targetLang; LanguageUpdate({ required this.baseLang, required this.targetLang, this.prevBaseLang, this.prevTargetLang, }); } /// Controller that manages saving and reading of user/profile information class UserController { final StreamController languageStream = StreamController.broadcast(); final StreamController settingsUpdateStream = StreamController.broadcast(); /// Cached version of the user profile, so it doesn't have /// to be read in from client's account data each time it is accessed. Profile? _cachedProfile; AnalyticsProfileModel? analyticsProfile; /// Listens for account updates and updates the cached profile StreamSubscription? _profileListener; matrix.Client get client => MatrixState.pangeaController.matrixState.client; void _onProfileUpdate(matrix.SyncUpdate sync) { final profileData = client.accountData[ModelKey.userProfile]?.content; final Profile? fromAccountData = Profile.fromAccountData(profileData); if (fromAccountData != null) { _cachedProfile = fromAccountData; } } /// The user's profile. Will be empty if the client's accountData hasn't /// been loaded yet (if the first sync hasn't gone through yet) /// or if the user hasn't yer set their date of birth. Profile get profile { /// if the profile is cached, return it if (_cachedProfile != null) return _cachedProfile!; /// if account data is empty, return an empty profile if (client.accountData.isEmpty) { return Profile.emptyProfile; } /// try to get the account data in the up-to-date format final Profile? fromAccountData = Profile.fromAccountData( client.accountData[ModelKey.userProfile]?.content, ); if (fromAccountData != null) { _cachedProfile = fromAccountData; return fromAccountData; } _cachedProfile = Profile.migrateFromAccountData(); _cachedProfile?.saveProfileData(); return _cachedProfile ?? Profile.emptyProfile; } /// Updates the user's profile with the given [update] function and saves it. Future updateProfile( Profile Function(Profile) update, { waitForDataInSync = false, }) async { await initialize(); final prevTargetLang = userL2; final prevBaseLang = userL1; final prevHash = profile.hashCode; final Profile updatedProfile = update(profile); if (updatedProfile.hashCode == prevHash) { // no changes were made, so don't save return; } await updatedProfile.saveProfileData(waitForDataInSync: waitForDataInSync); if ((prevTargetLang != userL2) || (prevBaseLang != userL1)) { languageStream.add( LanguageUpdate( baseLang: userL1!, targetLang: userL2!, prevBaseLang: prevBaseLang, prevTargetLang: prevTargetLang, ), ); } else { settingsUpdateStream.add(updatedProfile); } } /// A completer for the profile model of a user. Completer initCompleter = Completer(); bool _initializing = false; /// Initializes the user's profile. Runs a function to wait for account data to load, /// read account data into profile, and migrate any missing info from the pangea profile. /// Finally, it adds a listen to update the profile data when new account data comes in. Future initialize() async { if (_initializing || initCompleter.isCompleted) { return initCompleter.future; } _initializing = true; try { await _initialize(); _profileListener ??= client.onSync.stream .where((sync) => sync.accountData != null) .listen(_onProfileUpdate); _addAnalyticsRoomIdsToPublicProfile(); if (profile.userSettings.targetLanguage != null && profile.userSettings.targetLanguage!.isNotEmpty && userL2 == null) { // update the language list and send an update to refresh analytics summary await PLanguageStore.initialize(forceRefresh: true); } } catch (err, s) { ErrorHandler.logError( e: err, s: s, data: {}, ); } finally { if (!initCompleter.isCompleted) { initCompleter.complete(); } _initializing = false; } return initCompleter.future; } /// Initializes the user's profile by waiting for account data to load, reading in account /// data to profile, and migrating from the pangea profile if the account data is not present. Future _initialize() async { // wait for account data to load // as long as it's not null, then this we've already migrated the profile if (client.prevBatch == null) { await client.onSync.stream.first; } if (client.userID == null) return; try { final resp = await client.getUserProfile(client.userID!); analyticsProfile = AnalyticsProfileModel.fromJson(resp.additionalProperties); } catch (e) { // getting a 404 error for some users without pre-existing profile // still want to set other properties, so catch this error analyticsProfile = AnalyticsProfileModel(); } // Do not await. This function pulls level from analytics, // so it waits for analytics to finish initializing. Analytics waits for user controller to // finish initializing, so this would cause a deadlock. if (analyticsProfile!.isEmpty) { final analyticsService = MatrixState.pangeaController.matrixState.analyticsDataService; final data = await analyticsService.derivedData; updateAnalyticsProfile(level: data.level); } } void clear() { _initializing = false; initCompleter = Completer(); _cachedProfile = null; _profileListener?.cancel(); _profileListener = null; } /// Reinitializes the user's profile /// This method should be called whenever the user's login status changes Future reinitialize() async { clear(); await initialize(); } /// Retrieves matrix access token. String get accessToken { final token = client.accessToken; if (token == null) { throw ("Trying to get accessToken with null token. User is not logged in."); } return token; } /// Checks if user data is available and the user's l2 is set. Future get isUserL2Set async { try { // the function fetchUserModel() uses a completer, so it shouldn't // re-call the endpoint if it has already been called await initialize(); return profile.userSettings.targetLanguage != null; } catch (err, s) { ErrorHandler.logError( e: err, s: s, data: {}, ); return false; } } /// Returns a boolean value indicating whether the user is currently in the trial window. bool inTrialWindow({int trialDays = 7}) { final DateTime? createdAt = profile.userSettings.createdAt; if (createdAt == null) { return false; } return createdAt.isAfter( DateTime.now().subtract(Duration(days: trialDays)), ); } /// Retrieves the user's email address. /// /// This method fetches the user's email address by making a request to the /// Matrix server. It uses the `_pangeaController` instance to access the /// Matrix client and retrieve the account's third-party identifiers. It then /// filters the identifiers to find the first one with the medium set to /// `ThirdPartyIdentifierMedium.email`. Finally, it returns the email address /// associated with the identifier, or `null` if no email address is found. /// /// Returns: /// - The user's email address as a [String], or `null` if no email address /// is found. Future get userEmail async { final List? identifiers = await client.getAccount3PIDs(); final matrix.ThirdPartyIdentifier? email = identifiers?.firstWhereOrNull( (identifier) => identifier.medium == matrix.ThirdPartyIdentifierMedium.email, ); return email?.address; } Future _savePublicProfileUpdate( String type, Map content, ) async => client.setUserProfile( client.userID!, type, content, ); Future updateAnalyticsProfile({ required int level, LanguageModel? baseLanguage, LanguageModel? targetLanguage, }) async { targetLanguage ??= userL2; baseLanguage ??= userL1; if (targetLanguage == null || analyticsProfile == null) return; final analyticsRoom = client.analyticsRoomLocal(targetLanguage); if (analyticsProfile!.targetLanguage == targetLanguage && analyticsProfile!.baseLanguage == baseLanguage && analyticsProfile!.languageAnalytics?[targetLanguage]?.level == level && analyticsProfile!.analyticsRoomIdByLanguage(targetLanguage) == analyticsRoom?.id) { return; } analyticsProfile!.baseLanguage = baseLanguage; analyticsProfile!.targetLanguage = targetLanguage; analyticsProfile!.setLanguageInfo( targetLanguage, level, analyticsRoom?.id, ); await _savePublicProfileUpdate( PangeaEventTypes.profileAnalytics, analyticsProfile!.toJson(), ); } Future _addAnalyticsRoomIdsToPublicProfile() async { if (analyticsProfile?.languageAnalytics == null) return; final analyticsRooms = client.allMyAnalyticsRooms; if (analyticsRooms.isEmpty) return; for (final analyticsRoom in analyticsRooms) { final lang = analyticsRoom.madeForLang?.split("-").first; if (lang == null || analyticsProfile?.languageAnalytics == null) continue; final langKey = analyticsProfile!.languageAnalytics!.keys.firstWhereOrNull( (l) => l.langCodeShort == lang, ); if (langKey == null) continue; if (analyticsProfile!.languageAnalytics![langKey]!.analyticsRoomId == analyticsRoom.id) { continue; } analyticsProfile!.setLanguageInfo( langKey, analyticsProfile!.languageAnalytics![langKey]!.level, analyticsRoom.id, ); } await _savePublicProfileUpdate( PangeaEventTypes.profileAnalytics, analyticsProfile!.toJson(), ); } Future addXPOffset(int offset) async { final targetLanguage = userL2; if (targetLanguage == null || analyticsProfile == null) return; analyticsProfile!.addXPOffset( targetLanguage, offset, client.analyticsRoomLocal(targetLanguage)?.id, ); await _savePublicProfileUpdate( PangeaEventTypes.profileAnalytics, analyticsProfile!.toJson(), ); } Future getPublicAnalyticsProfile( String userId, ) async { try { if (userId == BotName.byEnvironment) { return AnalyticsProfileModel(); } final resp = await client.getUserProfile(userId); return AnalyticsProfileModel.fromJson(resp.additionalProperties); } catch (e, s) { ErrorHandler.logError( e: e, s: s, data: { userId: userId, }, ); return AnalyticsProfileModel(); } } bool isToolEnabled(ToolSetting setting) { return userToolSetting(setting); } bool userToolSetting(ToolSetting setting) { switch (setting) { case ToolSetting.interactiveTranslator: return profile.toolSettings.interactiveTranslator; case ToolSetting.interactiveGrammar: return profile.toolSettings.interactiveGrammar; case ToolSetting.immersionMode: return profile.toolSettings.immersionMode; case ToolSetting.definitions: return profile.toolSettings.definitions; case ToolSetting.autoIGC: return profile.toolSettings.autoIGC; case ToolSetting.enableAutocorrect: return profile.toolSettings.enableAutocorrect; default: return false; } } String? get userL1Code { final source = profile.userSettings.sourceLanguage; return source == null || source.isEmpty ? LanguageService.systemLanguage?.langCode : source; } String? get userL2Code { final target = profile.userSettings.targetLanguage; return target == null || target.isEmpty ? null : target; } LanguageModel? get userL1 { if (userL1Code == null) return null; final langModel = PLanguageStore.byLangCode(userL1Code!); return langModel?.langCode == LanguageKeys.unknownLanguage ? null : langModel; } LanguageModel? get userL2 { if (userL2Code == null) return null; final langModel = PLanguageStore.byLangCode(userL2Code!); return langModel?.langCode == LanguageKeys.unknownLanguage ? null : langModel; } bool get languagesSet => userL1Code != null && userL2Code != null && userL1Code!.isNotEmpty && userL2Code!.isNotEmpty && userL1Code != LanguageKeys.unknownLanguage && userL2Code != LanguageKeys.unknownLanguage; bool get showTranscription => (userL1 != null && userL2 != null && userL1?.script != userL2?.script) || (userL1?.script != LanguageKeys.unknownLanguage || userL2?.script == LanguageKeys.unknownLanguage); }