[mob][photos] Resolve merge conflicts and merge main
This commit is contained in:
commit
8caa559812
35 changed files with 1528 additions and 1680 deletions
|
@ -189,7 +189,7 @@ class _AppState extends State<App> with WindowListener, TrayListener {
|
|||
windowManager.show();
|
||||
break;
|
||||
case 'exit_app':
|
||||
windowManager.close();
|
||||
windowManager.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)}";
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -208,7 +208,7 @@ class _MachineLearningSettingsPageState
|
|||
if (isEnabled) {
|
||||
unawaited(FaceMlService.instance.ensureInitialized());
|
||||
} else {
|
||||
FaceMlService.instance.pauseIndexing();
|
||||
FaceMlService.instance.pauseIndexingAndClustering();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
226
web/apps/photos/src/components/Sidebar/MapSetting.tsx
Normal file
226
web/apps/photos/src/components/Sidebar/MapSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
`;
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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/"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
|
Loading…
Add table
Reference in a new issue