[mob][photos] Resolve merge conflicts and merge main

This commit is contained in:
ashilkn 2024-05-22 18:33:43 +05:30
commit 8caa559812
35 changed files with 1528 additions and 1680 deletions

View file

@ -189,7 +189,7 @@ class _AppState extends State<App> with WindowListener, TrayListener {
windowManager.show();
break;
case 'exit_app':
windowManager.close();
windowManager.destroy();
break;
}
}

View file

@ -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;

View file

@ -240,7 +240,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
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,

View file

@ -48,7 +48,6 @@ class _CodeWidgetState extends State<CodeWidget> {
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<CodeWidget> {
@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<CodeWidget> {
_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<CodeWidget> {
],
padding: const EdgeInsets.all(8.0),
),
child: _clippedCard(l10n),
child: clippedCard(l10n),
);
}
@ -216,7 +310,7 @@ class _CodeWidgetState extends State<CodeWidget> {
],
),
child: Builder(
builder: (context) => _clippedCard(l10n),
builder: (context) => clippedCard(l10n),
),
);
},
@ -224,98 +318,6 @@ class _CodeWidgetState extends State<CodeWidget> {
);
}
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<CodeWidget> {
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)}";

View file

@ -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(

View file

@ -242,8 +242,6 @@ Future<void> _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());

View file

@ -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<void> 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<MachineLearningControlEvent>().listen((event) {
if (LocalSettings.instance.isFaceIndexingEnabled == false) {
return;
}
canRunMLController = event.shouldRun;
if (canRunMLController) {
Bus.instance.on<MachineLearningControlEvent>().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<DiffSyncCompleteEvent>().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<PeopleChangedEvent>().listen((event) {
shouldSyncPeople = true;
_shouldSyncPeople = true;
});
}
@ -218,9 +215,9 @@ class FaceMlService {
});
}
Future<void> initIsolate() async {
Future<void> _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<void> ensureSpawnedIsolate() async {
if (!isIsolateSpawned) {
await initIsolate();
Future<void> _ensureSpawnedIsolate() async {
if (!_isIsolateSpawned) {
await _initIsolate();
}
}
@ -286,11 +283,11 @@ class FaceMlService {
Future<dynamic> _runInIsolate(
(FaceMlOperation, Map<String, dynamic>) 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<void> indexAndClusterAll() async {
if (isClusteringRunning || isImageIndexRunning) {
_logger.info("indexing or clustering is already running, skipping");
Future<void> 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<void> 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<void> 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<int, int> alreadyIndexedFiles =
await FaceMLDataDB.instance.getIndexedFileIds();
w?.log('getIndexedFileIds');
final List<EnteFile> 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<EnteFile> filesWithLocalID = <EnteFile>[];
final List<EnteFile> filesWithoutLocalID = <EnteFile>[];
final List<EnteFile> hiddenFilesToIndex = <EnteFile>[];
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<EnteFile> 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 = <EnteFile>[];
sortedBylocalID.addAll(filesWithLocalID);
sortedBylocalID.addAll(filesWithoutLocalID);
sortedBylocalID.addAll(hiddenFilesToIndex);
w?.log('preparing all files to index');
final List<List<EnteFile>> chunks =
sortedBylocalID.chunks(_embeddingFetchLimit);
int fetchedCount = 0;
outerLoop:
for (final chunk in chunks) {
final futures = <Future<bool>>[];
if (LocalSettings.instance.remoteFetchEnabled) {
try {
final List<int> 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<Face> faces = [];
final remoteFileIdToVersion = <int, int>{};
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<int>(
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<void> 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<void> 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<int, int> alreadyIndexedFiles =
await FaceMLDataDB.instance.getIndexedFileIds();
w?.log('getIndexedFileIds');
final List<EnteFile> 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<EnteFile> filesWithLocalID = <EnteFile>[];
final List<EnteFile> filesWithoutLocalID = <EnteFile>[];
final List<EnteFile> hiddenFilesToIndex = <EnteFile>[];
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<EnteFile> 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 = <EnteFile>[];
sortedBylocalID.addAll(filesWithLocalID);
sortedBylocalID.addAll(filesWithoutLocalID);
sortedBylocalID.addAll(hiddenFilesToIndex);
w?.log('preparing all files to index');
final List<List<EnteFile>> chunks =
sortedBylocalID.chunks(_embeddingFetchLimit);
outerLoop:
for (final chunk in chunks) {
final futures = <Future<bool>>[];
if (LocalSettings.instance.remoteFetchEnabled) {
try {
final List<int> 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<Face> faces = [];
final remoteFileIdToVersion = <int, int>{};
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<int>(
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<FaceMlResult?> analyzeImageInSingleIsolate(EnteFile enteFile) async {
Future<FaceMlResult?> _analyzeImageInSingleIsolate(EnteFile enteFile) async {
_checkEnteFileForID(enteFile);
await ensureInitialized();
@ -1057,94 +1036,6 @@ class FaceMlService {
return imagePath;
}
@Deprecated('Deprecated in favor of `_getImagePathForML`')
Future<Uint8List?> _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<List<FaceDetectionRelative>> _detectFacesIsolate(
String imagePath,
// Uint8List fileData,
{
FaceMlResultBuilder? resultBuilder,
}) async {
try {
// Get the bounding boxes of the faces
final (List<FaceDetectionRelative> 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<Float32List> _alignFaces(
String imagePath,
List<FaceDetectionRelative> 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<List<List<double>>> _embedFaces(
Float32List facesList, {
FaceMlResultBuilder? resultBuilder,
}) async {
try {
// Get the embedding of the faces
final List<List<double>> 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<List<List<double>>> 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<int, int> 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);
}
}

View file

@ -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;
}

View file

@ -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<MachineLearningControlEvent>().listen((event) {
if (event.shouldRun) {
_startIndexing();
} else {
_pauseIndexing();
}
});
} else {
_startIndexing();
}
Bus.instance.on<MachineLearningControlEvent>().listen((event) {
if (event.shouldRun) {
_startIndexing();
} else {
_pauseIndexing();
}
});
}
Future<void> release() async {

View file

@ -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 &&

View file

@ -79,7 +79,7 @@ class _FaceDebugSectionWidgetState extends State<FaceDebugSectionWidget> {
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<FaceDebugSectionWidget> {
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<FaceDebugSectionWidget> {
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<FaceDebugSectionWidget> {
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<FaceDebugSectionWidget> {
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<FaceDebugSectionWidget> {
onTap: () async {
try {
await PersonService.instance.storeRemoteFeedback();
FaceMlService.instance.debugIndexingDisabled = false;
await FaceMlService.instance
.clusterAllImages(clusterInBuckets: true);
Bus.instance.fire(PeopleChangedEvent());

View file

@ -208,7 +208,7 @@ class _MachineLearningSettingsPageState
if (isEnabled) {
unawaited(FaceMlService.instance.ensureInitialized());
} else {
FaceMlService.instance.pauseIndexing();
FaceMlService.instance.pauseIndexingAndClustering();
}
if (mounted) {
setState(() {});

View file

@ -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:

View file

@ -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<string | undefined>();
const electron = globalThis.electron;
useEffect(() => {
electron?.appVersion().then((v) => setAppVersion(v));
});
const confirmLogDownload = () =>
appContext.setDialogMessage({
title: t("DOWNLOAD_LOGS"),
content: <Trans i18nKey={"DOWNLOAD_LOGS_MESSAGE"} />,
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 (
<>
<EnteMenuItem
onClick={confirmLogDownload}
variant="mini"
label={t("DOWNLOAD_UPLOAD_LOGS")}
/>
{appVersion && (
<Typography
py={"14px"}
px={"16px"}
color="text.muted"
variant="mini"
>
{appVersion}
</Typography>
)}
{isInternalUser() && (
<EnteMenuItem
variant="secondary"
onClick={testUpload}
label={"Test Upload"}
/>
)}
</>
);
}

View file

@ -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 (
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={t("DISABLE_MAPS")}
onRootClose={onRootClose}
/>
<Stack py={"20px"} px={"8px"} spacing={"32px"}>
<Box px={"8px"}>
<Typography color="text.muted">
<Trans i18nKey={"DISABLE_MAP_DESCRIPTION"} />
</Typography>
</Box>
<Stack px={"8px"} spacing={"8px"}>
<Button
color={"critical"}
size="large"
onClick={disableMap}
>
{t("DISABLE")}
</Button>
<Button color={"secondary"} size="large" onClick={onClose}>
{t("CANCEL")}
</Button>
</Stack>
</Stack>
</Stack>
);
}

View file

@ -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 (
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={t("ENABLE_MAPS")}
onRootClose={onRootClose}
/>
<Stack py={"20px"} px={"8px"} spacing={"32px"}>
<Box px={"8px"}>
{" "}
<Typography color="text.muted">
<Trans
i18nKey={"ENABLE_MAP_DESCRIPTION"}
components={{
a: (
<Link
target="_blank"
href={OPEN_STREET_MAP_LINK}
/>
),
}}
/>
</Typography>
</Box>
<Stack px={"8px"} spacing={"8px"}>
<Button color={"accent"} size="large" onClick={enableMap}>
{t("ENABLE")}
</Button>
<Button color={"secondary"} size="large" onClick={onClose}>
{t("CANCEL")}
</Button>
</Stack>
</Stack>
</Stack>
);
}

View file

@ -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 (
<>
<EnteMenuItem
onClick={confirmLogout}
color="critical"
label={t("LOGOUT")}
variant="secondary"
/>
<EnteMenuItem
onClick={openDeleteAccountModal}
color="critical"
variant="secondary"
label={t("DELETE_ACCOUNT")}
/>
<DeleteAccountModal
open={deleteAccountModalView}
onClose={closeDeleteAccountModal}
/>
</>
);
}

View file

@ -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 (
<SpaceBetweenFlex mt={0.5} mb={1} pl={1.5}>
<EnteLogo />
<IconButton
aria-label="close"
onClick={closeSidebar}
color="secondary"
>
<CloseIcon fontSize="small" />
</IconButton>
</SpaceBetweenFlex>
);
}

View file

@ -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 (
<>
<EnteMenuItem
onClick={openRoadmap}
label={t("REQUEST_FEATURE")}
variant="secondary"
/>
<EnteMenuItem
onClick={contactSupport}
labelComponent={
<NoStyleAnchor href="mailto:support@ente.io">
<Typography fontWeight={"bold"}>
{t("SUPPORT")}
</Typography>
</NoStyleAnchor>
}
variant="secondary"
/>
<EnteMenuItem
onClick={openExport}
label={t("EXPORT")}
endIcon={
exportService.isExportInProgress() && (
<EnteSpinner size="20px" />
)
}
variant="secondary"
/>
</>
);
}

View file

@ -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 (
<EnteDrawer
transitionDuration={0}
open={open}
onClose={handleDrawerClose}
BackdropProps={{
sx: { "&&&": { backgroundColor: "transparent" } },
}}
>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={t("MAP")}
onRootClose={handleRootClose}
/>
<Box px={"8px"}>
<Stack py="20px" spacing="24px">
<Box>
<MenuItemGroup>
<EnteMenuItem
onClick={openModifyMapEnabled}
variant="toggle"
checked={mapEnabled}
label={t("MAP_SETTINGS")}
/>
</MenuItemGroup>
</Box>
</Stack>
</Box>
</Stack>
<ModifyMapEnabled
open={modifyMapEnabledView}
mapEnabled={mapEnabled}
onClose={closeModifyMapEnabled}
onRootClose={handleRootClose}
/>
</EnteDrawer>
);
}
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 (
<Box>
<EnteDrawer
anchor="left"
transitionDuration={0}
open={open}
onClose={handleDrawerClose}
slotProps={{
backdrop: {
sx: { "&&&": { backgroundColor: "transparent" } },
},
}}
>
{mapEnabled ? (
<DisableMap
onClose={onClose}
disableMap={disableMap}
onRootClose={handleRootClose}
/>
) : (
<EnableMap
onClose={onClose}
enableMap={enableMap}
onRootClose={handleRootClose}
/>
)}
</EnteDrawer>
</Box>
);
};
function EnableMap({ onClose, enableMap, onRootClose }) {
return (
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={t("ENABLE_MAPS")}
onRootClose={onRootClose}
/>
<Stack py={"20px"} px={"8px"} spacing={"32px"}>
<Box px={"8px"}>
{" "}
<Typography color="text.muted">
<Trans
i18nKey={"ENABLE_MAP_DESCRIPTION"}
components={{
a: (
<Link
target="_blank"
href="https://www.openstreetmap.org/"
/>
),
}}
/>
</Typography>
</Box>
<Stack px={"8px"} spacing={"8px"}>
<Button color={"accent"} size="large" onClick={enableMap}>
{t("ENABLE")}
</Button>
<Button color={"secondary"} size="large" onClick={onClose}>
{t("CANCEL")}
</Button>
</Stack>
</Stack>
</Stack>
);
}
function DisableMap({ onClose, disableMap, onRootClose }) {
return (
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={t("DISABLE_MAPS")}
onRootClose={onRootClose}
/>
<Stack py={"20px"} px={"8px"} spacing={"32px"}>
<Box px={"8px"}>
<Typography color="text.muted">
<Trans i18nKey={"DISABLE_MAP_DESCRIPTION"} />
</Typography>
</Box>
<Stack px={"8px"} spacing={"8px"}>
<Button
color={"critical"}
size="large"
onClick={disableMap}
>
{t("DISABLE")}
</Button>
<Button color={"secondary"} size="large" onClick={onClose}>
{t("CANCEL")}
</Button>
</Stack>
</Stack>
</Stack>
);
}

View file

@ -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 (
<Box>
<EnteDrawer
anchor="left"
transitionDuration={0}
open={open}
onClose={handleDrawerClose}
slotProps={{
backdrop: {
sx: { "&&&": { backgroundColor: "transparent" } },
},
}}
>
{mapEnabled ? (
<DisableMap
onClose={onClose}
disableMap={disableMap}
onRootClose={handleRootClose}
/>
) : (
<EnableMap
onClose={onClose}
enableMap={enableMap}
onRootClose={handleRootClose}
/>
)}
</EnteDrawer>
</Box>
);
};
export default ModifyMapEnabled;

View file

@ -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 (
<EnteDrawer
transitionDuration={0}
open={open}
onClose={handleDrawerClose}
BackdropProps={{
sx: { "&&&": { backgroundColor: "transparent" } },
}}
>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={t("MAP")}
onRootClose={handleRootClose}
/>
<Box px={"8px"}>
<Stack py="20px" spacing="24px">
<Box>
<MenuItemGroup>
<EnteMenuItem
onClick={openModifyMapEnabled}
variant="toggle"
checked={mapEnabled}
label={t("MAP_SETTINGS")}
/>
</MenuItemGroup>
</Box>
</Stack>
</Box>
</Stack>
<ModifyMapEnabled
open={modifyMapEnabledView}
mapEnabled={mapEnabled}
onClose={closeModifyMapEnabled}
onRootClose={handleRootClose}
/>
</EnteDrawer>
);
}

View file

@ -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 }) {
</EnteDrawer>
);
}
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 (
<DropdownInput
options={options}
label={t("LANGUAGE")}
labelProps={{ color: "text.muted" }}
selected={locale}
setSelected={updateCurrentLocale}
/>
);
};
/**
* 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";
}
};

View file

@ -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<SupportedLocale>[] => {
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 (
<DropdownInput
options={getLanguageOptions()}
label={t("LANGUAGE")}
labelProps={{ color: "text.muted" }}
selected={locale}
setSelected={updateCurrentLocale}
/>
);
};

View file

@ -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<number>();
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 (
<>
<EnteMenuItem
startIcon={<CategoryIcon />}
onClick={openUncategorizedSection}
variant="captioned"
label={t("UNCATEGORIZED")}
subText={collectionSummaries
.get(uncategorizedCollectionId)
?.fileCount.toString()}
/>
<EnteMenuItem
startIcon={<ArchiveOutlined />}
onClick={openArchiveSection}
variant="captioned"
label={t("ARCHIVE_SECTION_NAME")}
subText={collectionSummaries
.get(ARCHIVE_SECTION)
?.fileCount.toString()}
/>
<EnteMenuItem
startIcon={<VisibilityOff />}
onClick={openHiddenSection}
variant="captioned"
label={t("HIDDEN")}
subIcon={<LockOutlined />}
/>
<EnteMenuItem
startIcon={<DeleteOutline />}
onClick={openTrashSection}
variant="captioned"
label={t("TRASH")}
subText={collectionSummaries
.get(TRASH_SECTION)
?.fileCount.toString()}
/>
</>
);
}

View file

@ -1,11 +0,0 @@
export function BackgroundOverlay() {
return (
<img
style={{ aspectRatio: "2/1" }}
width="100%"
src="/images/subscription-card-background/1x.png"
srcSet="/images/subscription-card-background/2x.png 2x,
/images/subscription-card-background/3x.png 3x"
/>
);
}

View file

@ -1,15 +0,0 @@
import { FlexWrapper, Overlay } from "@ente/shared/components/Container";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
export function ClickOverlay({ onClick }) {
return (
<Overlay display="flex">
<FlexWrapper
onClick={onClick}
justifyContent={"flex-end"}
sx={{ cursor: "pointer" }}
>
<ChevronRightIcon />
</FlexWrapper>
</Overlay>
);
}

View file

@ -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) {
</Box>
);
}
function BackgroundOverlay() {
return (
<img
style={{ aspectRatio: "2/1" }}
width="100%"
src="/images/subscription-card-background/1x.png"
srcSet="/images/subscription-card-background/2x.png 2x,
/images/subscription-card-background/3x.png 3x"
/>
);
}
function ClickOverlay({ onClick }) {
return (
<Overlay display="flex">
<FlexWrapper
onClick={onClick}
justifyContent={"flex-end"}
sx={{ cursor: "pointer" }}
>
<ChevronRightIcon />
</FlexWrapper>
</Overlay>
);
}

View file

@ -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;

View file

@ -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<HTMLSpanElement> = (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(
<Trans
i18nKey={"FREE_SUBSCRIPTION_INFO"}
values={{
date: userDetails.subscription?.expiryTime,
}}
/>,
);
} else if (isSubscriptionCancelled(userDetails.subscription)) {
messages.push(
t("RENEWAL_CANCELLED_SUBSCRIPTION_INFO", {
date: userDetails.subscription?.expiryTime,
}),
);
}
} else {
messages.push(
<Trans
i18nKey={"SUBSCRIPTION_EXPIRED_MESSAGE"}
components={{
a: <LinkButton onClick={handleClick} />,
}}
/>,
);
}
}
if (hasExceededStorageQuota(userDetails) && messages.length === 0) {
messages.push(
<Trans
i18nKey={"STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO"}
components={{
a: <LinkButton onClick={handleClick} />,
}}
/>,
);
}
return (
<Box px={1} pt={0.5}>
<Typography
variant="small"
color={"text.muted"}
onClick={handleClick && handleClick}
sx={{ cursor: handleClick && "pointer" }}
>
{messages}
</Typography>
</Box>
);
}

View file

@ -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() && (
<EnteMenuItem
onClick={openWatchFolder}
variant="secondary"
label={t("WATCH_FOLDERS")}
/>
)}
<EnteMenuItem
variant="secondary"
onClick={openRecoveryKeyModal}
label={t("RECOVERY_KEY")}
/>
{isInternalUser() && (
<EnteMenuItem
onClick={toggleTheme}
variant="secondary"
label={t("CHOSE_THEME")}
endIcon={
<ThemeSwitcher
themeColor={themeColor}
setThemeColor={setThemeColor}
/>
}
/>
)}
<EnteMenuItem
variant="secondary"
onClick={openTwoFactorModal}
label={t("TWO_FACTOR")}
/>
{isInternalUser() && (
<EnteMenuItem
variant="secondary"
onClick={redirectToAccountsPage}
label={t("PASSKEYS")}
/>
)}
<EnteMenuItem
variant="secondary"
onClick={redirectToChangePasswordPage}
label={t("CHANGE_PASSWORD")}
/>
<EnteMenuItem
variant="secondary"
onClick={redirectToChangeEmailPage}
label={t("CHANGE_EMAIL")}
/>
<EnteMenuItem
variant="secondary"
onClick={redirectToDeduplicatePage}
label={t("DEDUPLICATE_FILES")}
/>
<EnteMenuItem
variant="secondary"
onClick={openPreferencesOptions}
label={t("PREFERENCES")}
/>
<RecoveryKey
appContext={appContext}
show={recoverModalView}
onHide={closeRecoveryKeyModal}
somethingWentWrong={somethingWentWrong}
/>
<TwoFactorModal
show={twoFactorModalView}
onHide={closeTwoFactorModal}
closeSidebar={closeSidebar}
setLoading={startLoading}
/>
{isElectron() && (
<WatchFolder
open={watchFolderView}
onClose={closeWatchFolder}
/>
)}
<Preferences
open={preferencesView}
onClose={closePreferencesOptions}
onRootClose={closeSidebar}
/>
</>
);
}

View file

@ -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({
</DrawerSidebar>
);
}
const DrawerSidebar = styled(EnteDrawer)(({ theme }) => ({
"& .MuiPaper-root": {
padding: theme.spacing(1.5),
},
}));
DrawerSidebar.defaultProps = { anchor: "left" };
interface HeaderSectionProps {
closeSidebar: () => void;
}
const HeaderSection: React.FC<HeaderSectionProps> = ({ closeSidebar }) => {
return (
<SpaceBetweenFlex mt={0.5} mb={1} pl={1.5}>
<EnteLogo />
<IconButton
aria-label="close"
onClick={closeSidebar}
color="secondary"
>
<CloseIcon fontSize="small" />
</IconButton>
</SpaceBetweenFlex>
);
};
interface UserDetailsSectionProps {
sidebarView: boolean;
}
const UserDetailsSection: React.FC<UserDetailsSectionProps> = ({
sidebarView,
}) => {
const galleryContext = useContext(GalleryContext);
const [userDetails, setUserDetails] = useLocalState<UserDetails>(
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 (
<>
<Box px={0.5} mt={2} pb={1.5} mb={1}>
<Typography px={1} pb={1} color="text.muted">
{userDetails ? (
userDetails.email
) : (
<Skeleton animation="wave" />
)}
</Typography>
<SubscriptionCard
userDetails={userDetails}
onClick={handleSubscriptionCardClick}
/>
<SubscriptionStatus userDetails={userDetails} />
</Box>
{isMemberSubscription && (
<MemberSubscriptionManage
userDetails={userDetails}
open={memberSubscriptionManageView}
onClose={closeMemberSubscriptionManage}
/>
)}
</>
);
};
interface SubscriptionStatusProps {
userDetails: UserDetails;
}
const SubscriptionStatus: React.FC<SubscriptionStatusProps> = ({
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<HTMLSpanElement> = (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 = (
<Trans
i18nKey={"FREE_SUBSCRIPTION_INFO"}
values={{
date: userDetails.subscription?.expiryTime,
}}
/>
);
} else if (isSubscriptionCancelled(userDetails.subscription)) {
message = t("RENEWAL_CANCELLED_SUBSCRIPTION_INFO", {
date: userDetails.subscription?.expiryTime,
});
}
} else {
message = (
<Trans
i18nKey={"SUBSCRIPTION_EXPIRED_MESSAGE"}
components={{
a: <LinkButton onClick={handleClick} />,
}}
/>
);
}
}
if (!message && hasExceededStorageQuota(userDetails)) {
message = (
<Trans
i18nKey={"STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO"}
components={{
a: <LinkButton onClick={handleClick} />,
}}
/>
);
}
if (!message) return <></>;
return (
<Box px={1} pt={0.5}>
<Typography
variant="small"
color={"text.muted"}
onClick={handleClick && handleClick}
sx={{ cursor: handleClick && "pointer" }}
>
{message}
</Typography>
</Box>
);
};
interface ShortcutSectionProps {
closeSidebar: () => void;
collectionSummaries: CollectionSummaries;
}
const ShortcutSection: React.FC<ShortcutSectionProps> = ({
closeSidebar,
collectionSummaries,
}) => {
const galleryContext = useContext(GalleryContext);
const [uncategorizedCollectionId, setUncategorizedCollectionID] =
useState<number>();
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 (
<>
<EnteMenuItem
startIcon={<CategoryIcon />}
onClick={openUncategorizedSection}
variant="captioned"
label={t("UNCATEGORIZED")}
subText={collectionSummaries
.get(uncategorizedCollectionId)
?.fileCount.toString()}
/>
<EnteMenuItem
startIcon={<ArchiveOutlined />}
onClick={openArchiveSection}
variant="captioned"
label={t("ARCHIVE_SECTION_NAME")}
subText={collectionSummaries
.get(ARCHIVE_SECTION)
?.fileCount.toString()}
/>
<EnteMenuItem
startIcon={<VisibilityOff />}
onClick={openHiddenSection}
variant="captioned"
label={t("HIDDEN")}
subIcon={<LockOutlined />}
/>
<EnteMenuItem
startIcon={<DeleteOutline />}
onClick={openTrashSection}
variant="captioned"
label={t("TRASH")}
subText={collectionSummaries
.get(TRASH_SECTION)
?.fileCount.toString()}
/>
</>
);
};
interface UtilitySectionProps {
closeSidebar: () => void;
}
const UtilitySection: React.FC<UtilitySectionProps> = ({ 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() && (
<EnteMenuItem
onClick={openWatchFolder}
variant="secondary"
label={t("WATCH_FOLDERS")}
/>
)}
<EnteMenuItem
variant="secondary"
onClick={openRecoveryKeyModal}
label={t("RECOVERY_KEY")}
/>
{isInternalUser() && (
<EnteMenuItem
onClick={toggleTheme}
variant="secondary"
label={t("CHOSE_THEME")}
endIcon={
<ThemeSwitcher
themeColor={themeColor}
setThemeColor={setThemeColor}
/>
}
/>
)}
<EnteMenuItem
variant="secondary"
onClick={openTwoFactorModal}
label={t("TWO_FACTOR")}
/>
{isInternalUser() && (
<EnteMenuItem
variant="secondary"
onClick={redirectToAccountsPage}
label={t("PASSKEYS")}
/>
)}
<EnteMenuItem
variant="secondary"
onClick={redirectToChangePasswordPage}
label={t("CHANGE_PASSWORD")}
/>
<EnteMenuItem
variant="secondary"
onClick={redirectToChangeEmailPage}
label={t("CHANGE_EMAIL")}
/>
<EnteMenuItem
variant="secondary"
onClick={redirectToDeduplicatePage}
label={t("DEDUPLICATE_FILES")}
/>
<EnteMenuItem
variant="secondary"
onClick={openPreferencesOptions}
label={t("PREFERENCES")}
/>
<RecoveryKey
appContext={appContext}
show={recoverModalView}
onHide={closeRecoveryKeyModal}
somethingWentWrong={somethingWentWrong}
/>
<TwoFactorModal
show={twoFactorModalView}
onHide={closeTwoFactorModal}
closeSidebar={closeSidebar}
setLoading={startLoading}
/>
{isElectron() && (
<WatchFolder
open={watchFolderView}
onClose={closeWatchFolder}
/>
)}
<Preferences
open={preferencesView}
onClose={closePreferencesOptions}
onRootClose={closeSidebar}
/>
</>
);
};
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 (
<>
<EnteMenuItem
onClick={openRoadmap}
label={t("REQUEST_FEATURE")}
variant="secondary"
/>
<EnteMenuItem
onClick={contactSupport}
labelComponent={
<NoStyleAnchor href="mailto:support@ente.io">
<Typography fontWeight={"bold"}>
{t("SUPPORT")}
</Typography>
</NoStyleAnchor>
}
variant="secondary"
/>
<EnteMenuItem
onClick={openExport}
label={t("EXPORT")}
endIcon={
exportService.isExportInProgress() && (
<EnteSpinner size="20px" />
)
}
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 (
<>
<EnteMenuItem
onClick={confirmLogout}
color="critical"
label={t("LOGOUT")}
variant="secondary"
/>
<EnteMenuItem
onClick={openDeleteAccountModal}
color="critical"
variant="secondary"
label={t("DELETE_ACCOUNT")}
/>
<DeleteAccountModal
open={deleteAccountModalView}
onClose={closeDeleteAccountModal}
/>
</>
);
};
const DebugSection: React.FC = () => {
const appContext = useContext(AppContext);
const [appVersion, setAppVersion] = useState<string | undefined>();
const electron = globalThis.electron;
useEffect(() => {
electron?.appVersion().then((v) => setAppVersion(v));
});
const confirmLogDownload = () =>
appContext.setDialogMessage({
title: t("DOWNLOAD_LOGS"),
content: <Trans i18nKey={"DOWNLOAD_LOGS_MESSAGE"} />,
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 (
<>
<EnteMenuItem
onClick={confirmLogDownload}
variant="mini"
label={t("DOWNLOAD_UPLOAD_LOGS")}
/>
{appVersion && (
<Typography
py={"14px"}
px={"16px"}
color="text.muted"
variant="mini"
>
{appVersion}
</Typography>
)}
{isInternalUser() && (
<EnteMenuItem
variant="secondary"
onClick={testUpload}
label={"Test Upload"}
/>
)}
</>
);
};

View file

@ -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;
`;

View file

@ -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<UserDetails>(
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 (
<>
<Box px={0.5} mt={2} pb={1.5} mb={1}>
<Typography px={1} pb={1} color="text.muted">
{userDetails ? (
userDetails.email
) : (
<Skeleton animation="wave" />
)}
</Typography>
<SubscriptionCard
userDetails={userDetails}
onClick={handleSubscriptionCardClick}
/>
<SubscriptionStatus userDetails={userDetails} />
</Box>
{isMemberSubscription && (
<MemberSubscriptionManage
userDetails={userDetails}
open={memberSubscriptionManageView}
onClose={closeMemberSubscriptionManage}
/>
)}
</>
);
}

View file

@ -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 = (
<Trans
i18nKey={"ENABLE_MAP_DESCRIPTION"}
components={{
a: <Link target="_blank" href={OPEN_STREET_MAP_LINK} />,
a: (
<Link
target="_blank"
href="https://www.openstreetmap.org/"
/>
),
}}
/>
),