diff --git a/auth/lib/app/view/app.dart b/auth/lib/app/view/app.dart index 3bd9d4e73..5053f70f1 100644 --- a/auth/lib/app/view/app.dart +++ b/auth/lib/app/view/app.dart @@ -189,7 +189,7 @@ class _AppState extends State with WindowListener, TrayListener { windowManager.show(); break; case 'exit_app': - windowManager.close(); + windowManager.destroy(); break; } } diff --git a/auth/lib/models/code.dart b/auth/lib/models/code.dart index 7a7daf58d..696d3f2fc 100644 --- a/auth/lib/models/code.dart +++ b/auth/lib/models/code.dart @@ -128,7 +128,7 @@ class Code { final code = Code( _getAccount(uri), issuer, - _getDigits(uri, issuer), + _getDigits(uri), _getPeriod(uri), getSanitizedSecret(uri.queryParameters['secret']!), _getAlgorithm(uri), @@ -201,11 +201,11 @@ class Code { } } - static int _getDigits(Uri uri, String issuer) { + static int _getDigits(Uri uri) { try { return int.parse(uri.queryParameters['digits']!); } catch (e) { - if (issuer.toLowerCase() == "steam") { + if (uri.host == "steam") { return steamDigits; } return defaultDigits; diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index 6741788c3..b4ab8bfd0 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -240,7 +240,7 @@ class _SetupEnterSecretKeyPageState extends State { final account = _accountController.text.trim(); final issuer = _issuerController.text.trim(); final secret = _secretController.text.trim().replaceAll(' ', ''); - final isStreamCode = issuer.toLowerCase() == "steam"; + final isStreamCode = issuer.toLowerCase() == "steam" || issuer.toLowerCase().contains('steampowered.com'); if (widget.code != null && widget.code!.secret != secret) { ButtonResult? result = await showChoiceActionSheet( context, diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index a690d977e..cb8b274ca 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -48,7 +48,6 @@ class _CodeWidgetState extends State { late bool _shouldShowLargeIcon; late bool _hideCode; bool isMaskingEnabled = false; - late final colorScheme = getEnteColorScheme(context); @override void initState() { @@ -78,6 +77,7 @@ class _CodeWidgetState extends State { @override Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); if (isMaskingEnabled != PreferenceService.instance.shouldHideCodes()) { isMaskingEnabled = PreferenceService.instance.shouldHideCodes(); _hideCode = isMaskingEnabled; @@ -91,6 +91,100 @@ class _CodeWidgetState extends State { _isInitialized = true; } final l10n = context.l10n; + + Widget getCardContents(AppLocalizations l10n) { + return Stack( + children: [ + if (widget.code.isPinned) + Align( + alignment: Alignment.topRight, + child: CustomPaint( + painter: PinBgPainter( + color: colorScheme.pinnedBgColor, + ), + size: const Size(39, 39), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (widget.code.type.isTOTPCompatible) + CodeTimerProgress( + period: widget.code.period, + ), + const SizedBox(height: 16), + Row( + children: [ + _shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(), + Expanded( + child: Column( + children: [ + _getTopRow(), + const SizedBox(height: 4), + _getBottomRow(l10n), + ], + ), + ), + ], + ), + const SizedBox( + height: 20, + ), + ], + ), + if (widget.code.isPinned) ...[ + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(right: 6, top: 6), + child: SvgPicture.asset("assets/svg/pin-card.svg"), + ), + ), + ], + ], + ); + } + + Widget clippedCard(AppLocalizations l10n) { + return Container( + height: 132, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.codeCardBackgroundColor, + boxShadow: + widget.code.isPinned ? colorScheme.pinnedCardBoxShadow : [], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Material( + color: Colors.transparent, + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + onTap: () { + _copyCurrentOTPToClipboard(); + }, + onDoubleTap: isMaskingEnabled + ? () { + setState( + () { + _hideCode = !_hideCode; + }, + ); + } + : null, + onLongPress: () { + _copyCurrentOTPToClipboard(); + }, + child: getCardContents(l10n), + ), + ), + ), + ); + } + return Container( margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8), child: Builder( @@ -126,7 +220,7 @@ class _CodeWidgetState extends State { ], padding: const EdgeInsets.all(8.0), ), - child: _clippedCard(l10n), + child: clippedCard(l10n), ); } @@ -216,7 +310,7 @@ class _CodeWidgetState extends State { ], ), child: Builder( - builder: (context) => _clippedCard(l10n), + builder: (context) => clippedCard(l10n), ), ); }, @@ -224,98 +318,6 @@ class _CodeWidgetState extends State { ); } - Widget _clippedCard(AppLocalizations l10n) { - return Container( - height: 132, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).colorScheme.codeCardBackgroundColor, - boxShadow: widget.code.isPinned ? colorScheme.pinnedCardBoxShadow : [], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Material( - color: Colors.transparent, - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - onTap: () { - _copyCurrentOTPToClipboard(); - }, - onDoubleTap: isMaskingEnabled - ? () { - setState( - () { - _hideCode = !_hideCode; - }, - ); - } - : null, - onLongPress: () { - _copyCurrentOTPToClipboard(); - }, - child: _getCardContents(l10n), - ), - ), - ), - ); - } - - Widget _getCardContents(AppLocalizations l10n) { - return Stack( - children: [ - if (widget.code.isPinned) - Align( - alignment: Alignment.topRight, - child: CustomPaint( - painter: PinBgPainter( - color: colorScheme.pinnedBgColor, - ), - size: const Size(39, 39), - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (widget.code.type.isTOTPCompatible) - CodeTimerProgress( - period: widget.code.period, - ), - const SizedBox(height: 16), - Row( - children: [ - _shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(), - Expanded( - child: Column( - children: [ - _getTopRow(), - const SizedBox(height: 4), - _getBottomRow(l10n), - ], - ), - ), - ], - ), - const SizedBox( - height: 20, - ), - ], - ), - if (widget.code.isPinned) ...[ - Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.only(right: 6, top: 6), - child: SvgPicture.asset("assets/svg/pin-card.svg"), - ), - ), - ], - ], - ); - } - Widget _getBottomRow(AppLocalizations l10n) { return Container( padding: const EdgeInsets.only(left: 16, right: 16), @@ -585,7 +587,7 @@ class _CodeWidgetState extends State { String _getFormattedCode(String code) { if (_hideCode) { // replace all digits with • - code = code.replaceAll(RegExp(r'\d'), '•'); + code = code.replaceAll(RegExp(r'\S'), '•'); } if (code.length == 6) { return "${code.substring(0, 3)} ${code.substring(3, 6)}"; diff --git a/auth/lib/utils/totp_util.dart b/auth/lib/utils/totp_util.dart index d0076ed41..61c7f20e9 100644 --- a/auth/lib/utils/totp_util.dart +++ b/auth/lib/utils/totp_util.dart @@ -4,7 +4,7 @@ import 'package:otp/otp.dart' as otp; import 'package:steam_totp/steam_totp.dart'; String getOTP(Code code) { - if (code.issuer.toLowerCase() == 'steam') { + if (code.type == Type.steam) { return _getSteamCode(code); } if (code.type == Type.hotp) { @@ -39,7 +39,7 @@ String _getSteamCode(Code code, [bool isNext = false]) { } String getNextTotp(Code code) { - if (code.issuer.toLowerCase() == 'steam') { + if (code.type == Type.steam) { return _getSteamCode(code, true); } return otp.OTP.generateTOTPCodeString( diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 6a42a0a3b..50de0b9a1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -242,8 +242,6 @@ Future _init(bool isBackground, {String via = ''}) async { // unawaited(ObjectDetectionService.instance.init()); if (flagService.faceSearchEnabled) { unawaited(FaceMlService.instance.init()); - FaceMlService.instance.listenIndexOnDiffSync(); - FaceMlService.instance.listenOnPeopleChangedSync(); } else { if (LocalSettings.instance.isFaceIndexingEnabled) { unawaited(LocalSettings.instance.toggleFaceIndexing()); diff --git a/mobile/lib/services/machine_learning/face_ml/face_ml_service.dart b/mobile/lib/services/machine_learning/face_ml/face_ml_service.dart index 9e72f4c55..165a695ed 100644 --- a/mobile/lib/services/machine_learning/face_ml/face_ml_service.dart +++ b/mobile/lib/services/machine_learning/face_ml/face_ml_service.dart @@ -9,7 +9,6 @@ import "dart:ui" show Image; import "package:computer/computer.dart"; import "package:dart_ui_isolate/dart_ui_isolate.dart"; import "package:flutter/foundation.dart" show debugPrint, kDebugMode; -import "package:flutter_image_compress/flutter_image_compress.dart"; import "package:logging/logging.dart"; import "package:onnxruntime/onnxruntime.dart"; import "package:package_info_plus/package_info_plus.dart"; @@ -74,7 +73,7 @@ class FaceMlService { late ReceivePort _receivePort = ReceivePort(); late SendPort _mainSendPort; - bool isIsolateSpawned = false; + bool _isIsolateSpawned = false; // singleton pattern FaceMlService._privateConstructor(); @@ -91,12 +90,14 @@ class FaceMlService { bool isInitialized = false; late String client; - bool canRunMLController = false; - bool isImageIndexRunning = false; - bool isClusteringRunning = false; - bool shouldSyncPeople = false; + bool debugIndexingDisabled = false; + bool _mlControllerStatus = false; + bool _isIndexingOrClusteringRunning = false; + bool _shouldPauseIndexingAndClustering = false; + bool _shouldSyncPeople = false; + bool _isSyncing = false; - final int _fileDownloadLimit = 15; + final int _fileDownloadLimit = 10; final int _embeddingFetchLimit = 200; Future init({bool initializeImageMlIsolate = false}) async { @@ -133,31 +134,36 @@ class FaceMlService { _logger.info("client: $client"); isInitialized = true; - canRunMLController = !Platform.isAndroid || kDebugMode; + _mlControllerStatus = !Platform.isAndroid; /// hooking FaceML into [MachineLearningController] - if (Platform.isAndroid && !kDebugMode) { - Bus.instance.on().listen((event) { - if (LocalSettings.instance.isFaceIndexingEnabled == false) { - return; - } - canRunMLController = event.shouldRun; - if (canRunMLController) { + Bus.instance.on().listen((event) { + if (LocalSettings.instance.isFaceIndexingEnabled == false) { + return; + } + _mlControllerStatus = event.shouldRun; + if (_mlControllerStatus) { + if (_shouldPauseIndexingAndClustering) { + _shouldPauseIndexingAndClustering = false; + _logger.info( + "MLController allowed running ML, faces indexing undoing previous pause", + ); + } else { _logger.info( "MLController allowed running ML, faces indexing starting", ); unawaited(indexAndClusterAll()); - } else { - _logger - .info("MLController stopped running ML, faces indexing paused"); - pauseIndexing(); } - }); - } else { - if (!kDebugMode) { - unawaited(indexAndClusterAll()); + } else { + _logger.info( + "MLController stopped running ML, faces indexing will be paused (unless it's fetching embeddings)", + ); + pauseIndexingAndClustering(); } - } + }); + + _listenIndexOnDiffSync(); + _listenOnPeopleChangedSync(); }); } @@ -165,24 +171,15 @@ class FaceMlService { OrtEnv.instance.init(); } - void listenIndexOnDiffSync() { + void _listenIndexOnDiffSync() { Bus.instance.on().listen((event) async { - if (LocalSettings.instance.isFaceIndexingEnabled == false || kDebugMode) { - return; - } - // [neeraj] intentional delay in starting indexing on diff sync, this gives time for the user - // to disable face-indexing in case it's causing crash. In the future, we - // should have a better way to handle this. - shouldSyncPeople = true; - Future.delayed(const Duration(seconds: 10), () { - unawaited(indexAndClusterAll()); - }); + unawaited(sync()); }); } - void listenOnPeopleChangedSync() { + void _listenOnPeopleChangedSync() { Bus.instance.on().listen((event) { - shouldSyncPeople = true; + _shouldSyncPeople = true; }); } @@ -218,9 +215,9 @@ class FaceMlService { }); } - Future initIsolate() async { + Future _initIsolate() async { return _initLockIsolate.synchronized(() async { - if (isIsolateSpawned) return; + if (_isIsolateSpawned) return; _logger.info("initIsolate called"); _receivePort = ReceivePort(); @@ -231,19 +228,19 @@ class FaceMlService { _receivePort.sendPort, ); _mainSendPort = await _receivePort.first as SendPort; - isIsolateSpawned = true; + _isIsolateSpawned = true; _resetInactivityTimer(); } catch (e) { _logger.severe('Could not spawn isolate', e); - isIsolateSpawned = false; + _isIsolateSpawned = false; } }); } - Future ensureSpawnedIsolate() async { - if (!isIsolateSpawned) { - await initIsolate(); + Future _ensureSpawnedIsolate() async { + if (!_isIsolateSpawned) { + await _initIsolate(); } } @@ -286,11 +283,11 @@ class FaceMlService { Future _runInIsolate( (FaceMlOperation, Map) message, ) async { - await ensureSpawnedIsolate(); + await _ensureSpawnedIsolate(); return _functionLock.synchronized(() async { _resetInactivityTimer(); - if (isImageIndexRunning == false || canRunMLController == false) { + if (_shouldPauseIndexingAndClustering == false) { return null; } @@ -332,35 +329,42 @@ class FaceMlService { _logger.info( 'Clustering Isolate has been inactive for ${_inactivityDuration.inSeconds} seconds with no tasks running. Killing isolate.', ); - disposeIsolate(); + _disposeIsolate(); } }); } - void disposeIsolate() async { - if (!isIsolateSpawned) return; + void _disposeIsolate() async { + if (!_isIsolateSpawned) return; await release(); - isIsolateSpawned = false; + _isIsolateSpawned = false; _isolate.kill(); _receivePort.close(); _inactivityTimer?.cancel(); } - Future indexAndClusterAll() async { - if (isClusteringRunning || isImageIndexRunning) { - _logger.info("indexing or clustering is already running, skipping"); + Future sync({bool forceSync = true}) async { + if (_isSyncing) { return; } - if (shouldSyncPeople) { + _isSyncing = true; + if (forceSync) { await PersonService.instance.reconcileClusters(); - shouldSyncPeople = false; + _shouldSyncPeople = false; } + _isSyncing = false; + } + + Future indexAndClusterAll() async { + if (_cannotRunMLFunction()) return; + + await sync(forceSync: _shouldSyncPeople); await indexAllImages(); final indexingCompleteRatio = await _getIndexedDoneRatio(); if (indexingCompleteRatio < 0.95) { _logger.info( - "Indexing is not far enough, skipping clustering. Indexing is at $indexingCompleteRatio", + "Indexing is not far enough to start clustering, skipping clustering. Indexing is at $indexingCompleteRatio", ); return; } else { @@ -368,35 +372,195 @@ class FaceMlService { } } + void pauseIndexingAndClustering() { + if (_isIndexingOrClusteringRunning) { + _shouldPauseIndexingAndClustering = true; + } + } + + /// Analyzes all the images in the database with the latest ml version and stores the results in the database. + /// + /// This function first checks if the image has already been analyzed with the lastest faceMlVersion and stored in the database. If so, it skips the image. + Future indexAllImages({int retryFetchCount = 10}) async { + if (_cannotRunMLFunction()) return; + + try { + _isIndexingOrClusteringRunning = true; + _logger.info('starting image indexing'); + + final w = (kDebugMode ? EnteWatch('prepare indexing files') : null) + ?..start(); + final Map alreadyIndexedFiles = + await FaceMLDataDB.instance.getIndexedFileIds(); + w?.log('getIndexedFileIds'); + final List enteFiles = + await SearchService.instance.getAllFiles(); + w?.log('getAllFiles'); + + // Make sure the image conversion isolate is spawned + // await ImageMlIsolate.instance.ensureSpawned(); + await ensureInitialized(); + + int fileAnalyzedCount = 0; + int fileSkippedCount = 0; + final stopwatch = Stopwatch()..start(); + final List filesWithLocalID = []; + final List filesWithoutLocalID = []; + final List hiddenFilesToIndex = []; + w?.log('getIndexableFileIDs'); + + for (final EnteFile enteFile in enteFiles) { + if (_skipAnalysisEnteFile(enteFile, alreadyIndexedFiles)) { + fileSkippedCount++; + continue; + } + if ((enteFile.localID ?? '').isEmpty) { + filesWithoutLocalID.add(enteFile); + } else { + filesWithLocalID.add(enteFile); + } + } + w?.log('sifting through all normal files'); + final List hiddenFiles = + await SearchService.instance.getHiddenFiles(); + w?.log('getHiddenFiles: ${hiddenFiles.length} hidden files'); + for (final EnteFile enteFile in hiddenFiles) { + if (_skipAnalysisEnteFile(enteFile, alreadyIndexedFiles)) { + fileSkippedCount++; + continue; + } + hiddenFilesToIndex.add(enteFile); + } + + // list of files where files with localID are first + final sortedBylocalID = []; + sortedBylocalID.addAll(filesWithLocalID); + sortedBylocalID.addAll(filesWithoutLocalID); + sortedBylocalID.addAll(hiddenFilesToIndex); + w?.log('preparing all files to index'); + final List> chunks = + sortedBylocalID.chunks(_embeddingFetchLimit); + int fetchedCount = 0; + outerLoop: + for (final chunk in chunks) { + final futures = >[]; + + if (LocalSettings.instance.remoteFetchEnabled) { + try { + final List fileIds = []; + // Try to find embeddings on the remote server + for (final f in chunk) { + fileIds.add(f.uploadedFileID!); + } + _logger.info('starting remote fetch for ${fileIds.length} files'); + final res = + await RemoteFileMLService.instance.getFilessEmbedding(fileIds); + _logger.info('fetched ${res.mlData.length} embeddings'); + fetchedCount += res.mlData.length; + final List faces = []; + final remoteFileIdToVersion = {}; + for (FileMl fileMl in res.mlData.values) { + if (_shouldDiscardRemoteEmbedding(fileMl)) continue; + if (fileMl.faceEmbedding.faces.isEmpty) { + faces.add( + Face.empty( + fileMl.fileID, + ), + ); + } else { + for (final f in fileMl.faceEmbedding.faces) { + f.fileInfo = FileInfo( + imageHeight: fileMl.height, + imageWidth: fileMl.width, + ); + faces.add(f); + } + } + remoteFileIdToVersion[fileMl.fileID] = + fileMl.faceEmbedding.version; + } + if (res.noEmbeddingFileIDs.isNotEmpty) { + _logger.info( + 'No embeddings found for ${res.noEmbeddingFileIDs.length} files', + ); + for (final fileID in res.noEmbeddingFileIDs) { + faces.add(Face.empty(fileID, error: false)); + remoteFileIdToVersion[fileID] = faceMlVersion; + } + } + + await FaceMLDataDB.instance.bulkInsertFaces(faces); + _logger.info('stored embeddings'); + for (final entry in remoteFileIdToVersion.entries) { + alreadyIndexedFiles[entry.key] = entry.value; + } + _logger + .info('already indexed files ${remoteFileIdToVersion.length}'); + } catch (e, s) { + _logger.severe("err while getting files embeddings", e, s); + if (retryFetchCount < 1000) { + Future.delayed(Duration(seconds: retryFetchCount), () { + unawaited(indexAllImages(retryFetchCount: retryFetchCount * 2)); + }); + return; + } else { + _logger.severe( + "Failed to fetch embeddings for files after multiple retries", + e, + s, + ); + rethrow; + } + } + } + if (!await canUseHighBandwidth()) { + continue; + } + final smallerChunks = chunk.chunks(_fileDownloadLimit); + for (final smallestChunk in smallerChunks) { + for (final enteFile in smallestChunk) { + if (_shouldPauseIndexingAndClustering) { + _logger.info("indexAllImages() was paused, stopping"); + break outerLoop; + } + if (_skipAnalysisEnteFile( + enteFile, + alreadyIndexedFiles, + )) { + fileSkippedCount++; + continue; + } + futures.add(processImage(enteFile)); + } + final awaitedFutures = await Future.wait(futures); + final sumFutures = awaitedFutures.fold( + 0, + (previousValue, element) => previousValue + (element ? 1 : 0), + ); + fileAnalyzedCount += sumFutures; + } + } + + stopwatch.stop(); + _logger.info( + "`indexAllImages()` finished. Fetched $fetchedCount and analyzed $fileAnalyzedCount images, in ${stopwatch.elapsed.inSeconds} seconds (avg of ${stopwatch.elapsed.inSeconds / fileAnalyzedCount} seconds per image, skipped $fileSkippedCount images. MLController status: $_mlControllerStatus)", + ); + } catch (e, s) { + _logger.severe("indexAllImages failed", e, s); + } finally { + _isIndexingOrClusteringRunning = false; + _shouldPauseIndexingAndClustering = false; + } + } + Future clusterAllImages({ double minFaceScore = kMinimumQualityFaceScore, bool clusterInBuckets = true, }) async { - if (!canRunMLController) { - _logger - .info("MLController does not allow running ML, skipping clustering"); - return; - } - if (isClusteringRunning) { - _logger.info("clusterAllImages is already running, skipping"); - return; - } - // verify faces is enabled - if (LocalSettings.instance.isFaceIndexingEnabled == false) { - _logger.warning("clustering is disabled by user"); - return; - } - - final indexingCompleteRatio = await _getIndexedDoneRatio(); - if (indexingCompleteRatio < 0.95) { - _logger.info( - "Indexing is not far enough, skipping clustering. Indexing is at $indexingCompleteRatio", - ); - return; - } + if (_cannotRunMLFunction()) return; _logger.info("`clusterAllImages()` called"); - isClusteringRunning = true; + _isIndexingOrClusteringRunning = true; final clusterAllImagesTime = DateTime.now(); try { @@ -441,7 +605,7 @@ class FaceMlService { int bucket = 1; while (true) { - if (!canRunMLController) { + if (_shouldPauseIndexingAndClustering) { _logger.info( "MLController does not allow running ML, stopping before clustering bucket $bucket", ); @@ -535,193 +699,12 @@ class FaceMlService { } catch (e, s) { _logger.severe("`clusterAllImages` failed", e, s); } finally { - isClusteringRunning = false; + _isIndexingOrClusteringRunning = false; + _shouldPauseIndexingAndClustering = false; } } - /// Analyzes all the images in the database with the latest ml version and stores the results in the database. - /// - /// This function first checks if the image has already been analyzed with the lastest faceMlVersion and stored in the database. If so, it skips the image. - Future indexAllImages({int retryFetchCount = 10}) async { - if (isImageIndexRunning) { - _logger.warning("indexAllImages is already running, skipping"); - return; - } - // verify faces is enabled - if (LocalSettings.instance.isFaceIndexingEnabled == false) { - _logger.warning("indexing is disabled by user"); - return; - } - try { - isImageIndexRunning = true; - _logger.info('starting image indexing'); - - final w = (kDebugMode ? EnteWatch('prepare indexing files') : null) - ?..start(); - final Map alreadyIndexedFiles = - await FaceMLDataDB.instance.getIndexedFileIds(); - w?.log('getIndexedFileIds'); - final List enteFiles = - await SearchService.instance.getAllFiles(); - w?.log('getAllFiles'); - - // Make sure the image conversion isolate is spawned - // await ImageMlIsolate.instance.ensureSpawned(); - await ensureInitialized(); - - int fileAnalyzedCount = 0; - int fileSkippedCount = 0; - final stopwatch = Stopwatch()..start(); - final List filesWithLocalID = []; - final List filesWithoutLocalID = []; - final List hiddenFilesToIndex = []; - w?.log('getIndexableFileIDs'); - - for (final EnteFile enteFile in enteFiles) { - if (_skipAnalysisEnteFile(enteFile, alreadyIndexedFiles)) { - fileSkippedCount++; - continue; - } - if ((enteFile.localID ?? '').isEmpty) { - filesWithoutLocalID.add(enteFile); - } else { - filesWithLocalID.add(enteFile); - } - } - w?.log('sifting through all normal files'); - final List hiddenFiles = - await SearchService.instance.getHiddenFiles(); - w?.log('getHiddenFiles: ${hiddenFiles.length} hidden files'); - for (final EnteFile enteFile in hiddenFiles) { - if (_skipAnalysisEnteFile(enteFile, alreadyIndexedFiles)) { - fileSkippedCount++; - continue; - } - hiddenFilesToIndex.add(enteFile); - } - - // list of files where files with localID are first - final sortedBylocalID = []; - sortedBylocalID.addAll(filesWithLocalID); - sortedBylocalID.addAll(filesWithoutLocalID); - sortedBylocalID.addAll(hiddenFilesToIndex); - w?.log('preparing all files to index'); - final List> chunks = - sortedBylocalID.chunks(_embeddingFetchLimit); - outerLoop: - for (final chunk in chunks) { - final futures = >[]; - - if (LocalSettings.instance.remoteFetchEnabled) { - try { - final List fileIds = []; - // Try to find embeddings on the remote server - for (final f in chunk) { - fileIds.add(f.uploadedFileID!); - } - final EnteWatch? w = - flagService.internalUser ? EnteWatch("face_em_fetch") : null; - w?.start(); - w?.log('starting remote fetch for ${fileIds.length} files'); - final res = - await RemoteFileMLService.instance.getFilessEmbedding(fileIds); - w?.logAndReset('fetched ${res.mlData.length} embeddings'); - final List faces = []; - final remoteFileIdToVersion = {}; - for (FileMl fileMl in res.mlData.values) { - if (shouldDiscardRemoteEmbedding(fileMl)) continue; - if (fileMl.faceEmbedding.faces.isEmpty) { - faces.add( - Face.empty( - fileMl.fileID, - ), - ); - } else { - for (final f in fileMl.faceEmbedding.faces) { - f.fileInfo = FileInfo( - imageHeight: fileMl.height, - imageWidth: fileMl.width, - ); - faces.add(f); - } - } - remoteFileIdToVersion[fileMl.fileID] = - fileMl.faceEmbedding.version; - } - if (res.noEmbeddingFileIDs.isNotEmpty) { - _logger.info( - 'No embeddings found for ${res.noEmbeddingFileIDs.length} files', - ); - for (final fileID in res.noEmbeddingFileIDs) { - faces.add(Face.empty(fileID, error: false)); - remoteFileIdToVersion[fileID] = faceMlVersion; - } - } - - await FaceMLDataDB.instance.bulkInsertFaces(faces); - w?.logAndReset('stored embeddings'); - for (final entry in remoteFileIdToVersion.entries) { - alreadyIndexedFiles[entry.key] = entry.value; - } - _logger - .info('already indexed files ${remoteFileIdToVersion.length}'); - } catch (e, s) { - _logger.severe("err while getting files embeddings", e, s); - if (retryFetchCount < 1000) { - Future.delayed(Duration(seconds: retryFetchCount), () { - unawaited(indexAllImages(retryFetchCount: retryFetchCount * 2)); - }); - return; - } else { - _logger.severe( - "Failed to fetch embeddings for files after multiple retries", - e, - s, - ); - rethrow; - } - } - } - if (!await canUseHighBandwidth()) { - continue; - } - final smallerChunks = chunk.chunks(_fileDownloadLimit); - for (final smallestChunk in smallerChunks) { - for (final enteFile in smallestChunk) { - if (isImageIndexRunning == false) { - _logger.info("indexAllImages() was paused, stopping"); - break outerLoop; - } - if (_skipAnalysisEnteFile( - enteFile, - alreadyIndexedFiles, - )) { - fileSkippedCount++; - continue; - } - futures.add(processImage(enteFile)); - } - final awaitedFutures = await Future.wait(futures); - final sumFutures = awaitedFutures.fold( - 0, - (previousValue, element) => previousValue + (element ? 1 : 0), - ); - fileAnalyzedCount += sumFutures; - } - } - - stopwatch.stop(); - _logger.info( - "`indexAllImages()` finished. Analyzed $fileAnalyzedCount images, in ${stopwatch.elapsed.inSeconds} seconds (avg of ${stopwatch.elapsed.inSeconds / fileAnalyzedCount} seconds per image, skipped $fileSkippedCount images. MLController status: $canRunMLController)", - ); - } catch (e, s) { - _logger.severe("indexAllImages failed", e, s); - } finally { - isImageIndexRunning = false; - } - } - - bool shouldDiscardRemoteEmbedding(FileMl fileMl) { + bool _shouldDiscardRemoteEmbedding(FileMl fileMl) { if (fileMl.faceEmbedding.version < faceMlVersion) { debugPrint("Discarding remote embedding for fileID ${fileMl.fileID} " "because version is ${fileMl.faceEmbedding.version} and we need $faceMlVersion"); @@ -769,7 +752,7 @@ class FaceMlService { ); try { - final FaceMlResult? result = await analyzeImageInSingleIsolate( + final FaceMlResult? result = await _analyzeImageInSingleIsolate( enteFile, // preferUsingThumbnailForEverything: false, // disposeImageIsolateAfterUse: false, @@ -861,12 +844,8 @@ class FaceMlService { } } - void pauseIndexing() { - isImageIndexRunning = false; - } - /// Analyzes the given image data by running the full pipeline for faces, using [analyzeImageSync] in the isolate. - Future analyzeImageInSingleIsolate(EnteFile enteFile) async { + Future _analyzeImageInSingleIsolate(EnteFile enteFile) async { _checkEnteFileForID(enteFile); await ensureInitialized(); @@ -1057,94 +1036,6 @@ class FaceMlService { return imagePath; } - @Deprecated('Deprecated in favor of `_getImagePathForML`') - Future _getDataForML( - EnteFile enteFile, { - FileDataForML typeOfData = FileDataForML.fileData, - }) async { - Uint8List? data; - - switch (typeOfData) { - case FileDataForML.fileData: - final stopwatch = Stopwatch()..start(); - final File? actualIoFile = await getFile(enteFile, isOrigin: true); - if (actualIoFile != null) { - data = await actualIoFile.readAsBytes(); - } - stopwatch.stop(); - _logger.info( - "Getting file data for uploadedFileID ${enteFile.uploadedFileID} took ${stopwatch.elapsedMilliseconds} ms", - ); - - break; - - case FileDataForML.thumbnailData: - final stopwatch = Stopwatch()..start(); - data = await getThumbnail(enteFile); - stopwatch.stop(); - _logger.info( - "Getting thumbnail data for uploadedFileID ${enteFile.uploadedFileID} took ${stopwatch.elapsedMilliseconds} ms", - ); - break; - - case FileDataForML.compressedFileData: - final stopwatch = Stopwatch()..start(); - final String tempPath = Configuration.instance.getTempDirectory() + - "${enteFile.uploadedFileID!}"; - final File? actualIoFile = await getFile(enteFile); - if (actualIoFile != null) { - final compressResult = await FlutterImageCompress.compressAndGetFile( - actualIoFile.path, - tempPath + ".jpg", - ); - if (compressResult != null) { - data = await compressResult.readAsBytes(); - } - } - stopwatch.stop(); - _logger.info( - "Getting compressed file data for uploadedFileID ${enteFile.uploadedFileID} took ${stopwatch.elapsedMilliseconds} ms", - ); - break; - } - - return data; - } - - /// Detects faces in the given image data. - /// - /// `imageData`: The image data to analyze. - /// - /// Returns a list of face detection results. - /// - /// Throws [CouldNotInitializeFaceDetector], [CouldNotRunFaceDetector] or [GeneralFaceMlException] if something goes wrong. - Future> _detectFacesIsolate( - String imagePath, - // Uint8List fileData, - { - FaceMlResultBuilder? resultBuilder, - }) async { - try { - // Get the bounding boxes of the faces - final (List faces, dataSize) = - await FaceDetectionService.instance.predictInComputer(imagePath); - - // Add detected faces to the resultBuilder - if (resultBuilder != null) { - resultBuilder.addNewlyDetectedFaces(faces, dataSize); - } - - return faces; - } on YOLOFaceInterpreterInitializationException { - throw CouldNotInitializeFaceDetector(); - } on YOLOFaceInterpreterRunException { - throw CouldNotRunFaceDetector(); - } catch (e) { - _logger.severe('Face detection failed: $e'); - throw GeneralFaceMlException('Face detection failed: $e'); - } - } - /// Detects faces in the given image data. /// /// `imageData`: The image data to analyze. @@ -1183,38 +1074,6 @@ class FaceMlService { } } - /// Aligns multiple faces from the given image data. - /// - /// `imageData`: The image data in [Uint8List] that contains the faces. - /// `faces`: The face detection results in a list of [FaceDetectionAbsolute] for the faces to align. - /// - /// Returns a list of the aligned faces as image data. - /// - /// Throws [CouldNotWarpAffine] or [GeneralFaceMlException] if the face alignment fails. - Future _alignFaces( - String imagePath, - List faces, { - FaceMlResultBuilder? resultBuilder, - }) async { - try { - final (alignedFaces, alignmentResults, _, blurValues, _) = - await ImageMlIsolate.instance - .preprocessMobileFaceNetOnnx(imagePath, faces); - - if (resultBuilder != null) { - resultBuilder.addAlignmentResults( - alignmentResults, - blurValues, - ); - } - - return alignedFaces; - } catch (e, s) { - _logger.severe('Face alignment failed: $e', e, s); - throw CouldNotWarpAffine(); - } - } - /// Aligns multiple faces from the given image data. /// /// `imageData`: The image data in [Uint8List] that contains the faces. @@ -1256,45 +1115,6 @@ class FaceMlService { } } - /// Embeds multiple faces from the given input matrices. - /// - /// `facesMatrices`: The input matrices of the faces to embed. - /// - /// Returns a list of the face embeddings as lists of doubles. - /// - /// Throws [CouldNotInitializeFaceEmbeddor], [CouldNotRunFaceEmbeddor], [InputProblemFaceEmbeddor] or [GeneralFaceMlException] if the face embedding fails. - Future>> _embedFaces( - Float32List facesList, { - FaceMlResultBuilder? resultBuilder, - }) async { - try { - // Get the embedding of the faces - final List> embeddings = - await FaceEmbeddingService.instance.predictInComputer(facesList); - - // Add the embeddings to the resultBuilder - if (resultBuilder != null) { - resultBuilder.addEmbeddingsToExistingFaces(embeddings); - } - - return embeddings; - } on MobileFaceNetInterpreterInitializationException { - throw CouldNotInitializeFaceEmbeddor(); - } on MobileFaceNetInterpreterRunException { - throw CouldNotRunFaceEmbeddor(); - } on MobileFaceNetEmptyInput { - throw InputProblemFaceEmbeddor("Input is empty"); - } on MobileFaceNetWrongInputSize { - throw InputProblemFaceEmbeddor("Input size is wrong"); - } on MobileFaceNetWrongInputRange { - throw InputProblemFaceEmbeddor("Input range is wrong"); - // ignore: avoid_catches_without_on_clauses - } catch (e) { - _logger.severe('Face embedding (batch) failed: $e'); - throw GeneralFaceMlException('Face embedding (batch) failed: $e'); - } - } - static Future>> embedFacesSync( Float32List facesList, int interpreterAddress, { @@ -1334,10 +1154,9 @@ class FaceMlService { _logger.warning( '''Skipped analysis of image with enteFile, it might be the wrong format or has no uploadedFileID, or MLController doesn't allow it to run. enteFile: ${enteFile.toString()} - isImageIndexRunning: $isImageIndexRunning - canRunML: $canRunMLController ''', ); + _logStatus(); throw CouldNotRetrieveAnyFileData(); } } @@ -1361,7 +1180,8 @@ class FaceMlService { } bool _skipAnalysisEnteFile(EnteFile enteFile, Map indexedFileIds) { - if (isImageIndexRunning == false || canRunMLController == false) { + if (_isIndexingOrClusteringRunning == false || + _mlControllerStatus == false) { return true; } // Skip if the file is not uploaded or not owned by the user @@ -1378,4 +1198,50 @@ class FaceMlService { return indexedFileIds.containsKey(id) && indexedFileIds[id]! >= faceMlVersion; } + + bool _cannotRunMLFunction({String function = ""}) { + if (_isIndexingOrClusteringRunning) { + _logger.info( + "Cannot run $function because indexing or clustering is already running", + ); + _logStatus(); + return true; + } + if (_mlControllerStatus == false) { + _logger.info( + "Cannot run $function because MLController does not allow it", + ); + _logStatus(); + return true; + } + if (debugIndexingDisabled) { + _logger.info( + "Cannot run $function because debugIndexingDisabled is true", + ); + _logStatus(); + return true; + } + if (_shouldPauseIndexingAndClustering) { + // This should ideally not be triggered, because one of the above should be triggered instead. + _logger.warning( + "Cannot run $function because indexing and clustering is being paused", + ); + _logStatus(); + return true; + } + return false; + } + + void _logStatus() { + final String status = ''' + isInternalUser: ${flagService.internalUser} + isFaceIndexingEnabled: ${LocalSettings.instance.isFaceIndexingEnabled} + canRunMLController: $_mlControllerStatus + isIndexingOrClusteringRunning: $_isIndexingOrClusteringRunning + shouldPauseIndexingAndClustering: $_shouldPauseIndexingAndClustering + debugIndexingDisabled: $debugIndexingDisabled + shouldSyncPeople: $_shouldSyncPeople + '''; + _logger.info(status); + } } diff --git a/mobile/lib/services/machine_learning/machine_learning_controller.dart b/mobile/lib/services/machine_learning/machine_learning_controller.dart index 65daf614c..852ebcd5b 100644 --- a/mobile/lib/services/machine_learning/machine_learning_controller.dart +++ b/mobile/lib/services/machine_learning/machine_learning_controller.dart @@ -3,6 +3,8 @@ import "dart:io"; import "package:battery_info/battery_info_plugin.dart"; import "package:battery_info/model/android_battery_info.dart"; +import "package:battery_info/model/iso_battery_info.dart"; +import "package:flutter/foundation.dart" show kDebugMode; import "package:logging/logging.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/events/machine_learning_control_event.dart"; @@ -17,7 +19,8 @@ class MachineLearningController { static const kMaximumTemperature = 42; // 42 degree celsius static const kMinimumBatteryLevel = 20; // 20% - static const kDefaultInteractionTimeout = Duration(seconds: 15); + static const kDefaultInteractionTimeout = + kDebugMode ? Duration(seconds: 3) : Duration(seconds: 5); static const kUnhealthyStates = ["over_heat", "over_voltage", "dead"]; bool _isDeviceHealthy = true; @@ -31,13 +34,17 @@ class MachineLearningController { BatteryInfoPlugin() .androidBatteryInfoStream .listen((AndroidBatteryInfo? batteryInfo) { - _onBatteryStateUpdate(batteryInfo); + _onAndroidBatteryStateUpdate(batteryInfo); }); - } else { - // Always run Machine Learning on iOS - _canRunML = true; - Bus.instance.fire(MachineLearningControlEvent(true)); } + if (Platform.isIOS) { + BatteryInfoPlugin() + .iosBatteryInfoStream + .listen((IosBatteryInfo? batteryInfo) { + _oniOSBatteryStateUpdate(batteryInfo); + }); + } + _fireControlEvent(); } void onUserInteraction() { @@ -53,7 +60,8 @@ class MachineLearningController { } void _fireControlEvent() { - final shouldRunML = _isDeviceHealthy && !_isUserInteracting; + final shouldRunML = + _isDeviceHealthy && (Platform.isAndroid ? !_isUserInteracting : true); if (shouldRunML != _canRunML) { _canRunML = shouldRunML; _logger.info( @@ -76,18 +84,28 @@ class MachineLearningController { _startInteractionTimer(); } - void _onBatteryStateUpdate(AndroidBatteryInfo? batteryInfo) { + void _onAndroidBatteryStateUpdate(AndroidBatteryInfo? batteryInfo) { _logger.info("Battery info: ${batteryInfo!.toJson()}"); - _isDeviceHealthy = _computeIsDeviceHealthy(batteryInfo); + _isDeviceHealthy = _computeIsAndroidDeviceHealthy(batteryInfo); _fireControlEvent(); } - bool _computeIsDeviceHealthy(AndroidBatteryInfo info) { + void _oniOSBatteryStateUpdate(IosBatteryInfo? batteryInfo) { + _logger.info("Battery info: ${batteryInfo!.toJson()}"); + _isDeviceHealthy = _computeIsiOSDeviceHealthy(batteryInfo); + _fireControlEvent(); + } + + bool _computeIsAndroidDeviceHealthy(AndroidBatteryInfo info) { return _hasSufficientBattery(info.batteryLevel ?? kMinimumBatteryLevel) && _isAcceptableTemperature(info.temperature ?? kMaximumTemperature) && _isBatteryHealthy(info.health ?? ""); } + bool _computeIsiOSDeviceHealthy(IosBatteryInfo info) { + return _hasSufficientBattery(info.batteryLevel ?? kMinimumBatteryLevel); + } + bool _hasSufficientBattery(int batteryLevel) { return batteryLevel >= kMinimumBatteryLevel; } diff --git a/mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart b/mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart index d85b4ceb5..db1713c2c 100644 --- a/mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart +++ b/mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart @@ -1,6 +1,5 @@ import "dart:async"; import "dart:collection"; -import "dart:io"; import "dart:math" show min; import "package:computer/computer.dart"; @@ -103,17 +102,13 @@ class SemanticSearchService { if (shouldSyncImmediately) { unawaited(sync()); } - if (Platform.isAndroid) { - Bus.instance.on().listen((event) { - if (event.shouldRun) { - _startIndexing(); - } else { - _pauseIndexing(); - } - }); - } else { - _startIndexing(); - } + Bus.instance.on().listen((event) { + if (event.shouldRun) { + _startIndexing(); + } else { + _pauseIndexing(); + } + }); } Future release() async { diff --git a/mobile/lib/services/search_service.dart b/mobile/lib/services/search_service.dart index 5e21b0334..1ff73dbc8 100644 --- a/mobile/lib/services/search_service.dart +++ b/mobile/lib/services/search_service.dart @@ -848,8 +848,9 @@ class SearchService { final String clusterName = "$clusterId"; if (clusterIDToPersonID[clusterId] != null) { - throw Exception( - "Cluster $clusterId should not have person id ${clusterIDToPersonID[clusterId]}", + // This should not happen, means a faceID is assigned to multiple persons. + _logger.severe( + "`getAllFace`: Cluster $clusterId should not have person id ${clusterIDToPersonID[clusterId]}", ); } if (files.length < kMinimumClusterSizeSearchResult && diff --git a/mobile/lib/ui/settings/debug/face_debug_section_widget.dart b/mobile/lib/ui/settings/debug/face_debug_section_widget.dart index 01b10ff80..726a9f2ce 100644 --- a/mobile/lib/ui/settings/debug/face_debug_section_widget.dart +++ b/mobile/lib/ui/settings/debug/face_debug_section_widget.dart @@ -79,7 +79,7 @@ class _FaceDebugSectionWidgetState extends State { final isEnabled = await LocalSettings.instance.toggleFaceIndexing(); if (!isEnabled) { - FaceMlService.instance.pauseIndexing(); + FaceMlService.instance.pauseIndexingAndClustering(); } if (mounted) { setState(() {}); @@ -107,7 +107,7 @@ class _FaceDebugSectionWidgetState extends State { setState(() {}); } } catch (e, s) { - _logger.warning('indexing failed ', e, s); + _logger.warning('Remote fetch toggle failed ', e, s); await showGenericErrorDialog(context: context, error: e); } }, @@ -115,22 +115,25 @@ class _FaceDebugSectionWidgetState extends State { sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: FaceMlService.instance.canRunMLController - ? "canRunML enabled" - : "canRunML disabled", + title: FaceMlService.instance.debugIndexingDisabled + ? "Debug enable indexing again" + : "Debug disable indexing", ), pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { try { - FaceMlService.instance.canRunMLController = - !FaceMlService.instance.canRunMLController; + FaceMlService.instance.debugIndexingDisabled = + !FaceMlService.instance.debugIndexingDisabled; + if (FaceMlService.instance.debugIndexingDisabled) { + FaceMlService.instance.pauseIndexingAndClustering(); + } if (mounted) { setState(() {}); } } catch (e, s) { - _logger.warning('canRunML toggle failed ', e, s); + _logger.warning('debugIndexingDisabled toggle failed ', e, s); await showGenericErrorDialog(context: context, error: e); } }, @@ -145,6 +148,7 @@ class _FaceDebugSectionWidgetState extends State { trailingIconIsMuted: true, onTap: () async { try { + FaceMlService.instance.debugIndexingDisabled = false; unawaited(FaceMlService.instance.indexAndClusterAll()); } catch (e, s) { _logger.warning('indexAndClusterAll failed ', e, s); @@ -162,6 +166,7 @@ class _FaceDebugSectionWidgetState extends State { trailingIconIsMuted: true, onTap: () async { try { + FaceMlService.instance.debugIndexingDisabled = false; unawaited(FaceMlService.instance.indexAllImages()); } catch (e, s) { _logger.warning('indexing failed ', e, s); @@ -189,6 +194,7 @@ class _FaceDebugSectionWidgetState extends State { onTap: () async { try { await PersonService.instance.storeRemoteFeedback(); + FaceMlService.instance.debugIndexingDisabled = false; await FaceMlService.instance .clusterAllImages(clusterInBuckets: true); Bus.instance.fire(PeopleChangedEvent()); diff --git a/mobile/lib/ui/settings/machine_learning_settings_page.dart b/mobile/lib/ui/settings/machine_learning_settings_page.dart index 1e63cf645..47e216628 100644 --- a/mobile/lib/ui/settings/machine_learning_settings_page.dart +++ b/mobile/lib/ui/settings/machine_learning_settings_page.dart @@ -208,7 +208,7 @@ class _MachineLearningSettingsPageState if (isEnabled) { unawaited(FaceMlService.instance.ensureInitialized()); } else { - FaceMlService.instance.pauseIndexing(); + FaceMlService.instance.pauseIndexingAndClustering(); } if (mounted) { setState(() {}); diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7b429b457..0c9eba213 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.8.100+620 +version: 0.8.101+624 publish_to: none environment: diff --git a/web/apps/photos/src/components/Sidebar/DebugSection.tsx b/web/apps/photos/src/components/Sidebar/DebugSection.tsx deleted file mode 100644 index e33637403..000000000 --- a/web/apps/photos/src/components/Sidebar/DebugSection.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import log from "@/next/log"; -import { savedLogs } from "@/next/log-web"; -import { downloadAsFile } from "@ente/shared/utils"; -import Typography from "@mui/material/Typography"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext, useEffect, useState } from "react"; -import { Trans } from "react-i18next"; -import { isInternalUser } from "utils/user"; -import { testUpload } from "../../../tests/upload.test"; - -export default function DebugSection() { - const appContext = useContext(AppContext); - const [appVersion, setAppVersion] = useState(); - - const electron = globalThis.electron; - - useEffect(() => { - electron?.appVersion().then((v) => setAppVersion(v)); - }); - - const confirmLogDownload = () => - appContext.setDialogMessage({ - title: t("DOWNLOAD_LOGS"), - content: , - proceed: { - text: t("DOWNLOAD"), - variant: "accent", - action: downloadLogs, - }, - close: { - text: t("CANCEL"), - }, - }); - - const downloadLogs = () => { - log.info("Downloading logs"); - if (electron) electron.openLogDirectory(); - else downloadAsFile(`debug_logs_${Date.now()}.txt`, savedLogs()); - }; - - return ( - <> - - {appVersion && ( - - {appVersion} - - )} - {isInternalUser() && ( - - )} - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/DisableMap.tsx b/web/apps/photos/src/components/Sidebar/DisableMap.tsx deleted file mode 100644 index ef793166e..000000000 --- a/web/apps/photos/src/components/Sidebar/DisableMap.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; -import { Trans } from "react-i18next"; - -export default function EnableMap({ onClose, disableMap, onRootClose }) { - return ( - - - - - - - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/EnableMap.tsx b/web/apps/photos/src/components/Sidebar/EnableMap.tsx deleted file mode 100644 index 868485af0..000000000 --- a/web/apps/photos/src/components/Sidebar/EnableMap.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Box, Button, Link, Stack, Typography } from "@mui/material"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; -import { Trans } from "react-i18next"; - -export const OPEN_STREET_MAP_LINK = "https://www.openstreetmap.org/"; -export default function EnableMap({ onClose, enableMap, onRootClose }) { - return ( - - - - - {" "} - - - ), - }} - /> - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/ExitSection.tsx b/web/apps/photos/src/components/Sidebar/ExitSection.tsx deleted file mode 100644 index 272f2c572..000000000 --- a/web/apps/photos/src/components/Sidebar/ExitSection.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import DeleteAccountModal from "components/DeleteAccountModal"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext, useState } from "react"; - -export default function ExitSection() { - const { setDialogMessage, logout } = useContext(AppContext); - - const [deleteAccountModalView, setDeleteAccountModalView] = useState(false); - - const closeDeleteAccountModal = () => setDeleteAccountModalView(false); - const openDeleteAccountModal = () => setDeleteAccountModalView(true); - - const confirmLogout = () => { - setDialogMessage({ - title: t("LOGOUT_MESSAGE"), - proceed: { - text: t("LOGOUT"), - action: logout, - variant: "critical", - }, - close: { text: t("CANCEL") }, - }); - }; - - return ( - <> - - - - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/Header.tsx b/web/apps/photos/src/components/Sidebar/Header.tsx deleted file mode 100644 index 4adb12fe7..000000000 --- a/web/apps/photos/src/components/Sidebar/Header.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { SpaceBetweenFlex } from "@ente/shared/components/Container"; -import { EnteLogo } from "@ente/shared/components/EnteLogo"; -import CloseIcon from "@mui/icons-material/Close"; -import { IconButton } from "@mui/material"; - -interface IProps { - closeSidebar: () => void; -} - -export default function HeaderSection({ closeSidebar }: IProps) { - return ( - - - - - - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/HelpSection.tsx b/web/apps/photos/src/components/Sidebar/HelpSection.tsx deleted file mode 100644 index 4cc97c414..000000000 --- a/web/apps/photos/src/components/Sidebar/HelpSection.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { t } from "i18next"; -import { useContext } from "react"; - -import EnteSpinner from "@ente/shared/components/EnteSpinner"; -import { Typography } from "@mui/material"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import { NoStyleAnchor } from "components/pages/sharedAlbum/GoToEnte"; -import isElectron from "is-electron"; -import { AppContext } from "pages/_app"; -import { GalleryContext } from "pages/gallery"; -import exportService from "services/export"; -import { openLink } from "utils/common"; -import { getDownloadAppMessage } from "utils/ui"; - -export default function HelpSection() { - const { setDialogMessage } = useContext(AppContext); - const { openExportModal } = useContext(GalleryContext); - - const openRoadmap = () => - openLink("https://github.com/ente-io/ente/discussions", true); - - const contactSupport = () => openLink("mailto:support@ente.io", true); - - function openExport() { - if (isElectron()) { - openExportModal(); - } else { - setDialogMessage(getDownloadAppMessage()); - } - } - - return ( - <> - - - - {t("SUPPORT")} - - - } - variant="secondary" - /> - - ) - } - variant="secondary" - /> - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/MapSetting.tsx b/web/apps/photos/src/components/Sidebar/MapSetting.tsx new file mode 100644 index 000000000..430f7667f --- /dev/null +++ b/web/apps/photos/src/components/Sidebar/MapSetting.tsx @@ -0,0 +1,226 @@ +import log from "@/next/log"; +import { + Box, + Button, + DialogProps, + Link, + Stack, + Typography, +} from "@mui/material"; +import { EnteDrawer } from "components/EnteDrawer"; +import { EnteMenuItem } from "components/Menu/EnteMenuItem"; +import { MenuItemGroup } from "components/Menu/MenuItemGroup"; +import Titlebar from "components/Titlebar"; +import { t } from "i18next"; +import { AppContext } from "pages/_app"; +import { useContext, useEffect, useState } from "react"; +import { Trans } from "react-i18next"; +import { getMapEnabledStatus } from "services/userService"; + +export default function MapSettings({ open, onClose, onRootClose }) { + const { mapEnabled, updateMapEnabled } = useContext(AppContext); + const [modifyMapEnabledView, setModifyMapEnabledView] = useState(false); + + const openModifyMapEnabled = () => setModifyMapEnabledView(true); + const closeModifyMapEnabled = () => setModifyMapEnabledView(false); + + useEffect(() => { + if (!open) { + return; + } + const main = async () => { + const remoteMapValue = await getMapEnabledStatus(); + updateMapEnabled(remoteMapValue); + }; + main(); + }, [open]); + + const handleRootClose = () => { + onClose(); + onRootClose(); + }; + + const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { + if (reason === "backdropClick") { + handleRootClose(); + } else { + onClose(); + } + }; + + return ( + + + + + + + + + + + + + + + + + ); +} + +const ModifyMapEnabled = ({ open, onClose, onRootClose, mapEnabled }) => { + const { somethingWentWrong, updateMapEnabled } = useContext(AppContext); + + const disableMap = async () => { + try { + await updateMapEnabled(false); + onClose(); + } catch (e) { + log.error("Disable Map failed", e); + somethingWentWrong(); + } + }; + + const enableMap = async () => { + try { + await updateMapEnabled(true); + onClose(); + } catch (e) { + log.error("Enable Map failed", e); + somethingWentWrong(); + } + }; + + const handleRootClose = () => { + onClose(); + onRootClose(); + }; + + const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { + if (reason === "backdropClick") { + handleRootClose(); + } else { + onClose(); + } + }; + + return ( + + + {mapEnabled ? ( + + ) : ( + + )} + + + ); +}; + +function EnableMap({ onClose, enableMap, onRootClose }) { + return ( + + + + + {" "} + + + ), + }} + /> + + + + + + + + + ); +} + +function DisableMap({ onClose, disableMap, onRootClose }) { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/web/apps/photos/src/components/Sidebar/MapSetting/ModifyMapEnabled.tsx b/web/apps/photos/src/components/Sidebar/MapSetting/ModifyMapEnabled.tsx deleted file mode 100644 index 0a4c0b9dc..000000000 --- a/web/apps/photos/src/components/Sidebar/MapSetting/ModifyMapEnabled.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import log from "@/next/log"; -import { Box, DialogProps } from "@mui/material"; -import { EnteDrawer } from "components/EnteDrawer"; -import { AppContext } from "pages/_app"; -import { useContext } from "react"; -import DisableMap from "../DisableMap"; -import EnableMap from "../EnableMap"; - -const ModifyMapEnabled = ({ open, onClose, onRootClose, mapEnabled }) => { - const { somethingWentWrong, updateMapEnabled } = useContext(AppContext); - - const disableMap = async () => { - try { - await updateMapEnabled(false); - onClose(); - } catch (e) { - log.error("Disable Map failed", e); - somethingWentWrong(); - } - }; - - const enableMap = async () => { - try { - await updateMapEnabled(true); - onClose(); - } catch (e) { - log.error("Enable Map failed", e); - somethingWentWrong(); - } - }; - - const handleRootClose = () => { - onClose(); - onRootClose(); - }; - - const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { - if (reason === "backdropClick") { - handleRootClose(); - } else { - onClose(); - } - }; - - return ( - - - {mapEnabled ? ( - - ) : ( - - )} - - - ); -}; - -export default ModifyMapEnabled; diff --git a/web/apps/photos/src/components/Sidebar/MapSetting/index.tsx b/web/apps/photos/src/components/Sidebar/MapSetting/index.tsx deleted file mode 100644 index 5832baca5..000000000 --- a/web/apps/photos/src/components/Sidebar/MapSetting/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Box, DialogProps, Stack } from "@mui/material"; -import { EnteDrawer } from "components/EnteDrawer"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import { MenuItemGroup } from "components/Menu/MenuItemGroup"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext, useEffect, useState } from "react"; -import { getMapEnabledStatus } from "services/userService"; -import ModifyMapEnabled from "./ModifyMapEnabled"; - -export default function MapSettings({ open, onClose, onRootClose }) { - const { mapEnabled, updateMapEnabled } = useContext(AppContext); - const [modifyMapEnabledView, setModifyMapEnabledView] = useState(false); - - const openModifyMapEnabled = () => setModifyMapEnabledView(true); - const closeModifyMapEnabled = () => setModifyMapEnabledView(false); - - useEffect(() => { - if (!open) { - return; - } - const main = async () => { - const remoteMapValue = await getMapEnabledStatus(); - updateMapEnabled(remoteMapValue); - }; - main(); - }, [open]); - - const handleRootClose = () => { - onClose(); - onRootClose(); - }; - - const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { - if (reason === "backdropClick") { - handleRootClose(); - } else { - onClose(); - } - }; - - return ( - - - - - - - - - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/Preferences/index.tsx b/web/apps/photos/src/components/Sidebar/Preferences.tsx similarity index 62% rename from web/apps/photos/src/components/Sidebar/Preferences/index.tsx rename to web/apps/photos/src/components/Sidebar/Preferences.tsx index 04dc79a13..8d4ae1058 100644 --- a/web/apps/photos/src/components/Sidebar/Preferences/index.tsx +++ b/web/apps/photos/src/components/Sidebar/Preferences.tsx @@ -1,13 +1,20 @@ +import { + getLocaleInUse, + setLocaleInUse, + supportedLocales, + type SupportedLocale, +} from "@/next/i18n"; import ChevronRight from "@mui/icons-material/ChevronRight"; import { Box, DialogProps, Stack } from "@mui/material"; +import DropdownInput from "components/DropdownInput"; import { EnteDrawer } from "components/EnteDrawer"; import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import Titlebar from "components/Titlebar"; import { t } from "i18next"; +import { useRouter } from "next/router"; import { useState } from "react"; -import AdvancedSettings from "../AdvancedSettings"; -import MapSettings from "../MapSetting"; -import { LanguageSelector } from "./LanguageSelector"; +import AdvancedSettings from "./AdvancedSettings"; +import MapSettings from "./MapSetting"; export default function Preferences({ open, onClose, onRootClose }) { const [advancedSettingsView, setAdvancedSettingsView] = useState(false); @@ -76,3 +83,53 @@ export default function Preferences({ open, onClose, onRootClose }) { ); } + +const LanguageSelector = () => { + const locale = getLocaleInUse(); + // Enhancement: Is this full reload needed? + const router = useRouter(); + + const updateCurrentLocale = (newLocale: SupportedLocale) => { + setLocaleInUse(newLocale); + router.reload(); + }; + + const options = supportedLocales.map((locale) => ({ + label: localeName(locale), + value: locale, + })); + + return ( + + ); +}; + +/** + * Human readable name for each supported locale. + */ +const localeName = (locale: SupportedLocale) => { + switch (locale) { + case "en-US": + return "English"; + case "fr-FR": + return "Français"; + case "de-DE": + return "Deutsch"; + case "zh-CN": + return "中文"; + case "nl-NL": + return "Nederlands"; + case "es-ES": + return "Español"; + case "pt-BR": + return "Brazilian Portuguese"; + case "ru-RU": + return "Russian"; + } +}; diff --git a/web/apps/photos/src/components/Sidebar/Preferences/LanguageSelector.tsx b/web/apps/photos/src/components/Sidebar/Preferences/LanguageSelector.tsx deleted file mode 100644 index 4c4a13a50..000000000 --- a/web/apps/photos/src/components/Sidebar/Preferences/LanguageSelector.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { - getLocaleInUse, - setLocaleInUse, - supportedLocales, - type SupportedLocale, -} from "@/next/i18n"; -import DropdownInput, { DropdownOption } from "components/DropdownInput"; -import { t } from "i18next"; -import { useRouter } from "next/router"; - -/** - * Human readable name for each supported locale. - */ -export const localeName = (locale: SupportedLocale) => { - switch (locale) { - case "en-US": - return "English"; - case "fr-FR": - return "Français"; - case "de-DE": - return "Deutsch"; - case "zh-CN": - return "中文"; - case "nl-NL": - return "Nederlands"; - case "es-ES": - return "Español"; - case "pt-BR": - return "Brazilian Portuguese"; - case "ru-RU": - return "Russian"; - } -}; - -const getLanguageOptions = (): DropdownOption[] => { - return supportedLocales.map((locale) => ({ - label: localeName(locale), - value: locale, - })); -}; - -export const LanguageSelector = () => { - const locale = getLocaleInUse(); - // Enhancement: Is this full reload needed? - const router = useRouter(); - - const updateCurrentLocale = (newLocale: SupportedLocale) => { - setLocaleInUse(newLocale); - router.reload(); - }; - - return ( - - ); -}; diff --git a/web/apps/photos/src/components/Sidebar/ShortcutSection.tsx b/web/apps/photos/src/components/Sidebar/ShortcutSection.tsx deleted file mode 100644 index dce298844..000000000 --- a/web/apps/photos/src/components/Sidebar/ShortcutSection.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { t } from "i18next"; -import { useContext, useEffect, useState } from "react"; - -import ArchiveOutlined from "@mui/icons-material/ArchiveOutlined"; -import CategoryIcon from "@mui/icons-material/Category"; -import DeleteOutline from "@mui/icons-material/DeleteOutline"; -import LockOutlined from "@mui/icons-material/LockOutlined"; -import VisibilityOff from "@mui/icons-material/VisibilityOff"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import { - ARCHIVE_SECTION, - DUMMY_UNCATEGORIZED_COLLECTION, - TRASH_SECTION, -} from "constants/collection"; -import { GalleryContext } from "pages/gallery"; -import { getUncategorizedCollection } from "services/collectionService"; -import { CollectionSummaries } from "types/collection"; -interface Iprops { - closeSidebar: () => void; - collectionSummaries: CollectionSummaries; -} - -export default function ShortcutSection({ - closeSidebar, - collectionSummaries, -}: Iprops) { - const galleryContext = useContext(GalleryContext); - const [uncategorizedCollectionId, setUncategorizedCollectionID] = - useState(); - - useEffect(() => { - const main = async () => { - const unCategorizedCollection = await getUncategorizedCollection(); - if (unCategorizedCollection) { - setUncategorizedCollectionID(unCategorizedCollection.id); - } else { - setUncategorizedCollectionID(DUMMY_UNCATEGORIZED_COLLECTION); - } - }; - main(); - }, []); - - const openUncategorizedSection = () => { - galleryContext.setActiveCollectionID(uncategorizedCollectionId); - closeSidebar(); - }; - - const openTrashSection = () => { - galleryContext.setActiveCollectionID(TRASH_SECTION); - closeSidebar(); - }; - - const openArchiveSection = () => { - galleryContext.setActiveCollectionID(ARCHIVE_SECTION); - closeSidebar(); - }; - - const openHiddenSection = () => { - galleryContext.openHiddenSection(() => { - closeSidebar(); - }); - }; - - return ( - <> - } - onClick={openUncategorizedSection} - variant="captioned" - label={t("UNCATEGORIZED")} - subText={collectionSummaries - .get(uncategorizedCollectionId) - ?.fileCount.toString()} - /> - } - onClick={openArchiveSection} - variant="captioned" - label={t("ARCHIVE_SECTION_NAME")} - subText={collectionSummaries - .get(ARCHIVE_SECTION) - ?.fileCount.toString()} - /> - } - onClick={openHiddenSection} - variant="captioned" - label={t("HIDDEN")} - subIcon={} - /> - } - onClick={openTrashSection} - variant="captioned" - label={t("TRASH")} - subText={collectionSummaries - .get(TRASH_SECTION) - ?.fileCount.toString()} - /> - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard/backgroundOverlay.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard/backgroundOverlay.tsx deleted file mode 100644 index eb9c85f51..000000000 --- a/web/apps/photos/src/components/Sidebar/SubscriptionCard/backgroundOverlay.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export function BackgroundOverlay() { - return ( - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard/clickOverlay.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard/clickOverlay.tsx deleted file mode 100644 index 789055808..000000000 --- a/web/apps/photos/src/components/Sidebar/SubscriptionCard/clickOverlay.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { FlexWrapper, Overlay } from "@ente/shared/components/Container"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -export function ClickOverlay({ onClick }) { - return ( - - - - - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard/index.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard/index.tsx index 848792817..514c43df8 100644 --- a/web/apps/photos/src/components/Sidebar/SubscriptionCard/index.tsx +++ b/web/apps/photos/src/components/Sidebar/SubscriptionCard/index.tsx @@ -1,8 +1,7 @@ +import { FlexWrapper, Overlay } from "@ente/shared/components/Container"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { Box, Skeleton } from "@mui/material"; import { UserDetails } from "types/user"; -import { BackgroundOverlay } from "./backgroundOverlay"; -import { ClickOverlay } from "./clickOverlay"; - import { SubscriptionCardContentOverlay } from "./contentOverlay"; const SUBSCRIPTION_CARD_SIZE = 152; @@ -32,3 +31,29 @@ export default function SubscriptionCard({ userDetails, onClick }: Iprops) { ); } + +function BackgroundOverlay() { + return ( + + ); +} + +function ClickOverlay({ onClick }) { + return ( + + + + + + ); +} diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard/styledComponents.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard/styledComponents.tsx index 4d0a15e9d..90bea72ce 100644 --- a/web/apps/photos/src/components/Sidebar/SubscriptionCard/styledComponents.tsx +++ b/web/apps/photos/src/components/Sidebar/SubscriptionCard/styledComponents.tsx @@ -1,5 +1,5 @@ +import CircleIcon from "@mui/icons-material/Circle"; import { LinearProgress, styled } from "@mui/material"; -import { DotSeparator } from "../styledComponents"; export const Progressbar = styled(LinearProgress)(() => ({ ".MuiLinearProgress-bar": { @@ -13,6 +13,12 @@ Progressbar.defaultProps = { variant: "determinate", }; +const DotSeparator = styled(CircleIcon)` + font-size: 4px; + margin: 0 ${({ theme }) => theme.spacing(1)}; + color: inherit; +`; + export const LegendIndicator = styled(DotSeparator)` font-size: 8.71px; margin: 0; diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionStatus/index.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionStatus/index.tsx deleted file mode 100644 index 9ae19f640..000000000 --- a/web/apps/photos/src/components/Sidebar/SubscriptionStatus/index.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import Box from "@mui/material/Box"; -import { t } from "i18next"; -import { GalleryContext } from "pages/gallery"; -import { MouseEventHandler, useContext, useMemo } from "react"; -import { Trans } from "react-i18next"; -import { UserDetails } from "types/user"; -import { - hasAddOnBonus, - hasExceededStorageQuota, - hasPaidSubscription, - hasStripeSubscription, - isOnFreePlan, - isSubscriptionActive, - isSubscriptionCancelled, - isSubscriptionPastDue, -} from "utils/billing"; - -import { Typography } from "@mui/material"; -import LinkButton from "components/pages/gallery/LinkButton"; -import billingService from "services/billingService"; -import { isFamilyAdmin, isPartOfFamily } from "utils/user/family"; - -export default function SubscriptionStatus({ - userDetails, -}: { - userDetails: UserDetails; -}) { - const { showPlanSelectorModal } = useContext(GalleryContext); - - const hasAMessage = useMemo(() => { - if (!userDetails) { - return false; - } - if ( - isPartOfFamily(userDetails.familyData) && - !isFamilyAdmin(userDetails.familyData) - ) { - return false; - } - if ( - hasPaidSubscription(userDetails.subscription) && - !isSubscriptionCancelled(userDetails.subscription) - ) { - return false; - } - return true; - }, [userDetails]); - - const handleClick = useMemo(() => { - const eventHandler: MouseEventHandler = (e) => { - e.stopPropagation(); - if (userDetails) { - if (isSubscriptionActive(userDetails.subscription)) { - if (hasExceededStorageQuota(userDetails)) { - showPlanSelectorModal(); - } - } else { - if ( - hasStripeSubscription(userDetails.subscription) && - isSubscriptionPastDue(userDetails.subscription) - ) { - billingService.redirectToCustomerPortal(); - } else { - showPlanSelectorModal(); - } - } - } - }; - return eventHandler; - }, [userDetails]); - - if (!hasAMessage) { - return <>; - } - - const messages = []; - if (!hasAddOnBonus(userDetails.bonusData)) { - if (isSubscriptionActive(userDetails.subscription)) { - if (isOnFreePlan(userDetails.subscription)) { - messages.push( - , - ); - } else if (isSubscriptionCancelled(userDetails.subscription)) { - messages.push( - t("RENEWAL_CANCELLED_SUBSCRIPTION_INFO", { - date: userDetails.subscription?.expiryTime, - }), - ); - } - } else { - messages.push( - , - }} - />, - ); - } - } - - if (hasExceededStorageQuota(userDetails) && messages.length === 0) { - messages.push( - , - }} - />, - ); - } - - return ( - - - {messages} - - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/UtilitySection.tsx b/web/apps/photos/src/components/Sidebar/UtilitySection.tsx deleted file mode 100644 index 32f61d976..000000000 --- a/web/apps/photos/src/components/Sidebar/UtilitySection.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import log from "@/next/log"; -import RecoveryKey from "@ente/shared/components/RecoveryKey"; -import { - ACCOUNTS_PAGES, - PHOTOS_PAGES as PAGES, -} from "@ente/shared/constants/pages"; -import TwoFactorModal from "components/TwoFactor/Modal"; -import { t } from "i18next"; -import { useRouter } from "next/router"; -import { AppContext } from "pages/_app"; -import { useContext, useState } from "react"; -// import mlIDbStorage from 'services/ml/db'; -import { - configurePasskeyRecovery, - isPasskeyRecoveryEnabled, -} from "@ente/accounts/services/passkey"; -import { APPS, CLIENT_PACKAGE_NAMES } from "@ente/shared/apps/constants"; -import ThemeSwitcher from "@ente/shared/components/ThemeSwitcher"; -import { getRecoveryKey } from "@ente/shared/crypto/helpers"; -import { - encryptToB64, - generateEncryptionKey, -} from "@ente/shared/crypto/internal/libsodium"; -import { getAccountsURL } from "@ente/shared/network/api"; -import { THEME_COLOR } from "@ente/shared/themes/constants"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import { WatchFolder } from "components/WatchFolder"; -import isElectron from "is-electron"; -import { getAccountsToken } from "services/userService"; -import { getDownloadAppMessage } from "utils/ui"; -import { isInternalUser } from "utils/user"; -import Preferences from "./Preferences"; - -export default function UtilitySection({ closeSidebar }) { - const router = useRouter(); - const appContext = useContext(AppContext); - const { - setDialogMessage, - startLoading, - watchFolderView, - setWatchFolderView, - themeColor, - setThemeColor, - } = appContext; - - const [recoverModalView, setRecoveryModalView] = useState(false); - const [twoFactorModalView, setTwoFactorModalView] = useState(false); - const [preferencesView, setPreferencesView] = useState(false); - - const openPreferencesOptions = () => setPreferencesView(true); - const closePreferencesOptions = () => setPreferencesView(false); - - const openRecoveryKeyModal = () => setRecoveryModalView(true); - const closeRecoveryKeyModal = () => setRecoveryModalView(false); - - const openTwoFactorModal = () => setTwoFactorModalView(true); - const closeTwoFactorModal = () => setTwoFactorModalView(false); - - const openWatchFolder = () => { - if (isElectron()) { - setWatchFolderView(true); - } else { - setDialogMessage(getDownloadAppMessage()); - } - }; - const closeWatchFolder = () => setWatchFolderView(false); - - const redirectToChangePasswordPage = () => { - closeSidebar(); - router.push(PAGES.CHANGE_PASSWORD); - }; - - const redirectToChangeEmailPage = () => { - closeSidebar(); - router.push(PAGES.CHANGE_EMAIL); - }; - - const redirectToAccountsPage = async () => { - closeSidebar(); - - try { - // check if the user has passkey recovery enabled - const recoveryEnabled = await isPasskeyRecoveryEnabled(); - if (!recoveryEnabled) { - // let's create the necessary recovery information - const recoveryKey = await getRecoveryKey(); - - const resetSecret = await generateEncryptionKey(); - - const encryptionResult = await encryptToB64( - resetSecret, - recoveryKey, - ); - - await configurePasskeyRecovery( - resetSecret, - encryptionResult.encryptedData, - encryptionResult.nonce, - ); - } - - const accountsToken = await getAccountsToken(); - - window.open( - `${getAccountsURL()}${ - ACCOUNTS_PAGES.ACCOUNT_HANDOFF - }?package=${CLIENT_PACKAGE_NAMES.get( - APPS.PHOTOS, - )}&token=${accountsToken}`, - ); - } catch (e) { - log.error("failed to redirect to accounts page", e); - } - }; - - const redirectToDeduplicatePage = () => router.push(PAGES.DEDUPLICATE); - - const somethingWentWrong = () => - setDialogMessage({ - title: t("ERROR"), - content: t("RECOVER_KEY_GENERATION_FAILED"), - close: { variant: "critical" }, - }); - - const toggleTheme = () => { - setThemeColor((themeColor) => - themeColor === THEME_COLOR.DARK - ? THEME_COLOR.LIGHT - : THEME_COLOR.DARK, - ); - }; - - return ( - <> - {isElectron() && ( - - )} - - {isInternalUser() && ( - - } - /> - )} - - - {isInternalUser() && ( - - )} - - - - - - - - - - - {isElectron() && ( - - )} - - - ); -} diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index a93eb2387..300d06ed6 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -1,13 +1,93 @@ -import { Divider, Stack } from "@mui/material"; +import log from "@/next/log"; +import { savedLogs } from "@/next/log-web"; +import { + configurePasskeyRecovery, + isPasskeyRecoveryEnabled, +} from "@ente/accounts/services/passkey"; +import { APPS, CLIENT_PACKAGE_NAMES } from "@ente/shared/apps/constants"; +import { SpaceBetweenFlex } from "@ente/shared/components/Container"; +import { EnteLogo } from "@ente/shared/components/EnteLogo"; +import EnteSpinner from "@ente/shared/components/EnteSpinner"; +import RecoveryKey from "@ente/shared/components/RecoveryKey"; +import ThemeSwitcher from "@ente/shared/components/ThemeSwitcher"; +import { + ACCOUNTS_PAGES, + PHOTOS_PAGES as PAGES, +} from "@ente/shared/constants/pages"; +import { getRecoveryKey } from "@ente/shared/crypto/helpers"; +import { + encryptToB64, + generateEncryptionKey, +} from "@ente/shared/crypto/internal/libsodium"; +import { useLocalState } from "@ente/shared/hooks/useLocalState"; +import { getAccountsURL } from "@ente/shared/network/api"; +import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; +import { THEME_COLOR } from "@ente/shared/themes/constants"; +import { downloadAsFile } from "@ente/shared/utils"; +import ArchiveOutlined from "@mui/icons-material/ArchiveOutlined"; +import CategoryIcon from "@mui/icons-material/Category"; +import CloseIcon from "@mui/icons-material/Close"; +import DeleteOutline from "@mui/icons-material/DeleteOutline"; +import LockOutlined from "@mui/icons-material/LockOutlined"; +import VisibilityOff from "@mui/icons-material/VisibilityOff"; +import { + Box, + Divider, + IconButton, + Skeleton, + Stack, + styled, +} from "@mui/material"; +import Typography from "@mui/material/Typography"; +import DeleteAccountModal from "components/DeleteAccountModal"; +import { EnteDrawer } from "components/EnteDrawer"; +import { EnteMenuItem } from "components/Menu/EnteMenuItem"; +import TwoFactorModal from "components/TwoFactor/Modal"; +import { WatchFolder } from "components/WatchFolder"; +import LinkButton from "components/pages/gallery/LinkButton"; +import { NoStyleAnchor } from "components/pages/sharedAlbum/GoToEnte"; +import { + ARCHIVE_SECTION, + DUMMY_UNCATEGORIZED_COLLECTION, + TRASH_SECTION, +} from "constants/collection"; +import { t } from "i18next"; +import isElectron from "is-electron"; +import { useRouter } from "next/router"; +import { AppContext } from "pages/_app"; +import { GalleryContext } from "pages/gallery"; +import { + MouseEventHandler, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Trans } from "react-i18next"; +import billingService from "services/billingService"; +import { getUncategorizedCollection } from "services/collectionService"; +import exportService from "services/export"; +import { getAccountsToken, getUserDetailsV2 } from "services/userService"; import { CollectionSummaries } from "types/collection"; -import DebugSection from "./DebugSection"; -import ExitSection from "./ExitSection"; -import HeaderSection from "./Header"; -import HelpSection from "./HelpSection"; -import ShortcutSection from "./ShortcutSection"; -import UtilitySection from "./UtilitySection"; -import { DrawerSidebar } from "./styledComponents"; -import UserDetailsSection from "./userDetailsSection"; +import { UserDetails } from "types/user"; +import { + hasAddOnBonus, + hasExceededStorageQuota, + hasPaidSubscription, + hasStripeSubscription, + isOnFreePlan, + isSubscriptionActive, + isSubscriptionCancelled, + isSubscriptionPastDue, +} from "utils/billing"; +import { openLink } from "utils/common"; +import { getDownloadAppMessage } from "utils/ui"; +import { isInternalUser } from "utils/user"; +import { isFamilyAdmin, isPartOfFamily } from "utils/user/family"; +import { testUpload } from "../../../tests/upload.test"; +import { MemberSubscriptionManage } from "../MemberSubscriptionManage"; +import Preferences from "./Preferences"; +import SubscriptionCard from "./SubscriptionCard"; interface Iprops { collectionSummaries: CollectionSummaries; @@ -40,3 +120,658 @@ export default function Sidebar({ ); } + +const DrawerSidebar = styled(EnteDrawer)(({ theme }) => ({ + "& .MuiPaper-root": { + padding: theme.spacing(1.5), + }, +})); + +DrawerSidebar.defaultProps = { anchor: "left" }; + +interface HeaderSectionProps { + closeSidebar: () => void; +} + +const HeaderSection: React.FC = ({ closeSidebar }) => { + return ( + + + + + + + ); +}; + +interface UserDetailsSectionProps { + sidebarView: boolean; +} + +const UserDetailsSection: React.FC = ({ + sidebarView, +}) => { + const galleryContext = useContext(GalleryContext); + + const [userDetails, setUserDetails] = useLocalState( + LS_KEYS.USER_DETAILS, + ); + const [memberSubscriptionManageView, setMemberSubscriptionManageView] = + useState(false); + + const openMemberSubscriptionManage = () => + setMemberSubscriptionManageView(true); + const closeMemberSubscriptionManage = () => + setMemberSubscriptionManageView(false); + + useEffect(() => { + if (!sidebarView) { + return; + } + const main = async () => { + const userDetails = await getUserDetailsV2(); + setUserDetails(userDetails); + setData(LS_KEYS.SUBSCRIPTION, userDetails.subscription); + setData(LS_KEYS.FAMILY_DATA, userDetails.familyData); + setData(LS_KEYS.USER, { + ...getData(LS_KEYS.USER), + email: userDetails.email, + }); + }; + main(); + }, [sidebarView]); + + const isMemberSubscription = useMemo( + () => + userDetails && + isPartOfFamily(userDetails.familyData) && + !isFamilyAdmin(userDetails.familyData), + [userDetails], + ); + + const handleSubscriptionCardClick = () => { + if (isMemberSubscription) { + openMemberSubscriptionManage(); + } else { + if ( + hasStripeSubscription(userDetails.subscription) && + isSubscriptionPastDue(userDetails.subscription) + ) { + billingService.redirectToCustomerPortal(); + } else { + galleryContext.showPlanSelectorModal(); + } + } + }; + + return ( + <> + + + {userDetails ? ( + userDetails.email + ) : ( + + )} + + + + + + {isMemberSubscription && ( + + )} + + ); +}; + +interface SubscriptionStatusProps { + userDetails: UserDetails; +} + +const SubscriptionStatus: React.FC = ({ + userDetails, +}) => { + const { showPlanSelectorModal } = useContext(GalleryContext); + + const hasAMessage = useMemo(() => { + if (!userDetails) { + return false; + } + if ( + isPartOfFamily(userDetails.familyData) && + !isFamilyAdmin(userDetails.familyData) + ) { + return false; + } + if ( + hasPaidSubscription(userDetails.subscription) && + !isSubscriptionCancelled(userDetails.subscription) + ) { + return false; + } + return true; + }, [userDetails]); + + const handleClick = useMemo(() => { + const eventHandler: MouseEventHandler = (e) => { + e.stopPropagation(); + if (userDetails) { + if (isSubscriptionActive(userDetails.subscription)) { + if (hasExceededStorageQuota(userDetails)) { + showPlanSelectorModal(); + } + } else { + if ( + hasStripeSubscription(userDetails.subscription) && + isSubscriptionPastDue(userDetails.subscription) + ) { + billingService.redirectToCustomerPortal(); + } else { + showPlanSelectorModal(); + } + } + } + }; + return eventHandler; + }, [userDetails]); + + if (!hasAMessage) { + return <>; + } + + let message: React.ReactNode; + if (!hasAddOnBonus(userDetails.bonusData)) { + if (isSubscriptionActive(userDetails.subscription)) { + if (isOnFreePlan(userDetails.subscription)) { + message = ( + + ); + } else if (isSubscriptionCancelled(userDetails.subscription)) { + message = t("RENEWAL_CANCELLED_SUBSCRIPTION_INFO", { + date: userDetails.subscription?.expiryTime, + }); + } + } else { + message = ( + , + }} + /> + ); + } + } + + if (!message && hasExceededStorageQuota(userDetails)) { + message = ( + , + }} + /> + ); + } + + if (!message) return <>; + + return ( + + + {message} + + + ); +}; + +interface ShortcutSectionProps { + closeSidebar: () => void; + collectionSummaries: CollectionSummaries; +} + +const ShortcutSection: React.FC = ({ + closeSidebar, + collectionSummaries, +}) => { + const galleryContext = useContext(GalleryContext); + const [uncategorizedCollectionId, setUncategorizedCollectionID] = + useState(); + + useEffect(() => { + const main = async () => { + const unCategorizedCollection = await getUncategorizedCollection(); + if (unCategorizedCollection) { + setUncategorizedCollectionID(unCategorizedCollection.id); + } else { + setUncategorizedCollectionID(DUMMY_UNCATEGORIZED_COLLECTION); + } + }; + main(); + }, []); + + const openUncategorizedSection = () => { + galleryContext.setActiveCollectionID(uncategorizedCollectionId); + closeSidebar(); + }; + + const openTrashSection = () => { + galleryContext.setActiveCollectionID(TRASH_SECTION); + closeSidebar(); + }; + + const openArchiveSection = () => { + galleryContext.setActiveCollectionID(ARCHIVE_SECTION); + closeSidebar(); + }; + + const openHiddenSection = () => { + galleryContext.openHiddenSection(() => { + closeSidebar(); + }); + }; + + return ( + <> + } + onClick={openUncategorizedSection} + variant="captioned" + label={t("UNCATEGORIZED")} + subText={collectionSummaries + .get(uncategorizedCollectionId) + ?.fileCount.toString()} + /> + } + onClick={openArchiveSection} + variant="captioned" + label={t("ARCHIVE_SECTION_NAME")} + subText={collectionSummaries + .get(ARCHIVE_SECTION) + ?.fileCount.toString()} + /> + } + onClick={openHiddenSection} + variant="captioned" + label={t("HIDDEN")} + subIcon={} + /> + } + onClick={openTrashSection} + variant="captioned" + label={t("TRASH")} + subText={collectionSummaries + .get(TRASH_SECTION) + ?.fileCount.toString()} + /> + + ); +}; + +interface UtilitySectionProps { + closeSidebar: () => void; +} + +const UtilitySection: React.FC = ({ closeSidebar }) => { + const router = useRouter(); + const appContext = useContext(AppContext); + const { + setDialogMessage, + startLoading, + watchFolderView, + setWatchFolderView, + themeColor, + setThemeColor, + } = appContext; + + const [recoverModalView, setRecoveryModalView] = useState(false); + const [twoFactorModalView, setTwoFactorModalView] = useState(false); + const [preferencesView, setPreferencesView] = useState(false); + + const openPreferencesOptions = () => setPreferencesView(true); + const closePreferencesOptions = () => setPreferencesView(false); + + const openRecoveryKeyModal = () => setRecoveryModalView(true); + const closeRecoveryKeyModal = () => setRecoveryModalView(false); + + const openTwoFactorModal = () => setTwoFactorModalView(true); + const closeTwoFactorModal = () => setTwoFactorModalView(false); + + const openWatchFolder = () => { + if (isElectron()) { + setWatchFolderView(true); + } else { + setDialogMessage(getDownloadAppMessage()); + } + }; + const closeWatchFolder = () => setWatchFolderView(false); + + const redirectToChangePasswordPage = () => { + closeSidebar(); + router.push(PAGES.CHANGE_PASSWORD); + }; + + const redirectToChangeEmailPage = () => { + closeSidebar(); + router.push(PAGES.CHANGE_EMAIL); + }; + + const redirectToAccountsPage = async () => { + closeSidebar(); + + try { + // check if the user has passkey recovery enabled + const recoveryEnabled = await isPasskeyRecoveryEnabled(); + if (!recoveryEnabled) { + // let's create the necessary recovery information + const recoveryKey = await getRecoveryKey(); + + const resetSecret = await generateEncryptionKey(); + + const encryptionResult = await encryptToB64( + resetSecret, + recoveryKey, + ); + + await configurePasskeyRecovery( + resetSecret, + encryptionResult.encryptedData, + encryptionResult.nonce, + ); + } + + const accountsToken = await getAccountsToken(); + + window.open( + `${getAccountsURL()}${ + ACCOUNTS_PAGES.ACCOUNT_HANDOFF + }?package=${CLIENT_PACKAGE_NAMES.get( + APPS.PHOTOS, + )}&token=${accountsToken}`, + ); + } catch (e) { + log.error("failed to redirect to accounts page", e); + } + }; + + const redirectToDeduplicatePage = () => router.push(PAGES.DEDUPLICATE); + + const somethingWentWrong = () => + setDialogMessage({ + title: t("ERROR"), + content: t("RECOVER_KEY_GENERATION_FAILED"), + close: { variant: "critical" }, + }); + + const toggleTheme = () => { + setThemeColor((themeColor) => + themeColor === THEME_COLOR.DARK + ? THEME_COLOR.LIGHT + : THEME_COLOR.DARK, + ); + }; + + return ( + <> + {isElectron() && ( + + )} + + {isInternalUser() && ( + + } + /> + )} + + + {isInternalUser() && ( + + )} + + + + + + + + + + + {isElectron() && ( + + )} + + + ); +}; + +const HelpSection: React.FC = () => { + const { setDialogMessage } = useContext(AppContext); + const { openExportModal } = useContext(GalleryContext); + + const openRoadmap = () => + openLink("https://github.com/ente-io/ente/discussions", true); + + const contactSupport = () => openLink("mailto:support@ente.io", true); + + function openExport() { + if (isElectron()) { + openExportModal(); + } else { + setDialogMessage(getDownloadAppMessage()); + } + } + + return ( + <> + + + + {t("SUPPORT")} + + + } + variant="secondary" + /> + + ) + } + variant="secondary" + /> + + ); +}; + +const ExitSection: React.FC = () => { + const { setDialogMessage, logout } = useContext(AppContext); + + const [deleteAccountModalView, setDeleteAccountModalView] = useState(false); + + const closeDeleteAccountModal = () => setDeleteAccountModalView(false); + const openDeleteAccountModal = () => setDeleteAccountModalView(true); + + const confirmLogout = () => { + setDialogMessage({ + title: t("LOGOUT_MESSAGE"), + proceed: { + text: t("LOGOUT"), + action: logout, + variant: "critical", + }, + close: { text: t("CANCEL") }, + }); + }; + + return ( + <> + + + + + ); +}; + +const DebugSection: React.FC = () => { + const appContext = useContext(AppContext); + const [appVersion, setAppVersion] = useState(); + + const electron = globalThis.electron; + + useEffect(() => { + electron?.appVersion().then((v) => setAppVersion(v)); + }); + + const confirmLogDownload = () => + appContext.setDialogMessage({ + title: t("DOWNLOAD_LOGS"), + content: , + proceed: { + text: t("DOWNLOAD"), + variant: "accent", + action: downloadLogs, + }, + close: { + text: t("CANCEL"), + }, + }); + + const downloadLogs = () => { + log.info("Downloading logs"); + if (electron) electron.openLogDirectory(); + else downloadAsFile(`debug_logs_${Date.now()}.txt`, savedLogs()); + }; + + return ( + <> + + {appVersion && ( + + {appVersion} + + )} + {isInternalUser() && ( + + )} + + ); +}; diff --git a/web/apps/photos/src/components/Sidebar/styledComponents.tsx b/web/apps/photos/src/components/Sidebar/styledComponents.tsx deleted file mode 100644 index d2b2f6b2b..000000000 --- a/web/apps/photos/src/components/Sidebar/styledComponents.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import CircleIcon from "@mui/icons-material/Circle"; -import { styled } from "@mui/material"; -import { EnteDrawer } from "components/EnteDrawer"; - -export const DrawerSidebar = styled(EnteDrawer)(({ theme }) => ({ - "& .MuiPaper-root": { - padding: theme.spacing(1.5), - }, -})); - -DrawerSidebar.defaultProps = { anchor: "left" }; - -export const DotSeparator = styled(CircleIcon)` - font-size: 4px; - margin: 0 ${({ theme }) => theme.spacing(1)}; - color: inherit; -`; diff --git a/web/apps/photos/src/components/Sidebar/userDetailsSection.tsx b/web/apps/photos/src/components/Sidebar/userDetailsSection.tsx deleted file mode 100644 index 4d1bf3cb1..000000000 --- a/web/apps/photos/src/components/Sidebar/userDetailsSection.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useLocalState } from "@ente/shared/hooks/useLocalState"; -import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; -import { Box, Skeleton } from "@mui/material"; -import Typography from "@mui/material/Typography"; -import { GalleryContext } from "pages/gallery"; -import { useContext, useEffect, useMemo, useState } from "react"; -import billingService from "services/billingService"; -import { getUserDetailsV2 } from "services/userService"; -import { UserDetails } from "types/user"; -import { hasStripeSubscription, isSubscriptionPastDue } from "utils/billing"; -import { isFamilyAdmin, isPartOfFamily } from "utils/user/family"; -import { MemberSubscriptionManage } from "../MemberSubscriptionManage"; -import SubscriptionCard from "./SubscriptionCard"; -import SubscriptionStatus from "./SubscriptionStatus"; - -export default function UserDetailsSection({ sidebarView }) { - const galleryContext = useContext(GalleryContext); - - const [userDetails, setUserDetails] = useLocalState( - LS_KEYS.USER_DETAILS, - ); - const [memberSubscriptionManageView, setMemberSubscriptionManageView] = - useState(false); - - const openMemberSubscriptionManage = () => - setMemberSubscriptionManageView(true); - const closeMemberSubscriptionManage = () => - setMemberSubscriptionManageView(false); - - useEffect(() => { - if (!sidebarView) { - return; - } - const main = async () => { - const userDetails = await getUserDetailsV2(); - setUserDetails(userDetails); - setData(LS_KEYS.SUBSCRIPTION, userDetails.subscription); - setData(LS_KEYS.FAMILY_DATA, userDetails.familyData); - setData(LS_KEYS.USER, { - ...getData(LS_KEYS.USER), - email: userDetails.email, - }); - }; - main(); - }, [sidebarView]); - - const isMemberSubscription = useMemo( - () => - userDetails && - isPartOfFamily(userDetails.familyData) && - !isFamilyAdmin(userDetails.familyData), - [userDetails], - ); - - const handleSubscriptionCardClick = () => { - if (isMemberSubscription) { - openMemberSubscriptionManage(); - } else { - if ( - hasStripeSubscription(userDetails.subscription) && - isSubscriptionPastDue(userDetails.subscription) - ) { - billingService.redirectToCustomerPortal(); - } else { - galleryContext.showPlanSelectorModal(); - } - } - }; - - return ( - <> - - - {userDetails ? ( - userDetails.email - ) : ( - - )} - - - - - - {isMemberSubscription && ( - - )} - - ); -} diff --git a/web/apps/photos/src/utils/ui/index.tsx b/web/apps/photos/src/utils/ui/index.tsx index 8ac5f94bf..c930f47c8 100644 --- a/web/apps/photos/src/utils/ui/index.tsx +++ b/web/apps/photos/src/utils/ui/index.tsx @@ -4,7 +4,6 @@ import { DialogBoxAttributes } from "@ente/shared/components/DialogBox/types"; import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined"; import InfoOutlined from "@mui/icons-material/InfoRounded"; import { Link } from "@mui/material"; -import { OPEN_STREET_MAP_LINK } from "components/Sidebar/EnableMap"; import { t } from "i18next"; import { Trans } from "react-i18next"; import { Subscription } from "types/billing"; @@ -143,7 +142,12 @@ export const getMapEnableConfirmationDialog = ( , + a: ( + + ), }} /> ),