diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 42865fb456..7b1770ccf6 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -226,6 +226,7 @@ "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", "profile_drawer_app_logs": "Logs", + "profile_drawer_trash": "Trash", "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", diff --git a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart index 5fce82054c..affa7cb64a 100644 --- a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart +++ b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart @@ -106,7 +106,7 @@ class ProfileDrawer extends HookConsumerWidget { ), ), title: Text( - "Trash", + "profile_drawer_trash", style: Theme.of(context) .textTheme .labelLarge diff --git a/mobile/lib/modules/trash/providers/trashed_asset.provider.dart b/mobile/lib/modules/trash/providers/trashed_asset.provider.dart index 38629f7c49..1f34741df1 100644 --- a/mobile/lib/modules/trash/providers/trashed_asset.provider.dart +++ b/mobile/lib/modules/trash/providers/trashed_asset.provider.dart @@ -3,7 +3,6 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu import 'package:immich_mobile/modules/trash/services/trash.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/exif_info.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:isar/isar.dart'; @@ -42,12 +41,6 @@ class TrashNotifier extends StateNotifier { await _db.exifInfos.deleteAll(dbIds); await _db.assets.deleteAll(dbIds); }); - - // Refresh assets in background - Future.delayed( - const Duration(seconds: 4), - () async => await _ref.read(assetProvider.notifier).getAllAsset(), - ); } catch (error, stack) { _log.severe("Cannot empty trash ${error.toString()}", error, stack); } @@ -68,12 +61,6 @@ class TrashNotifier extends StateNotifier { await _db.writeTxn(() async { await _db.assets.putAll(updatedAssets); }); - - // Refresh assets in background - Future.delayed( - const Duration(seconds: 4), - () async => await _ref.read(assetProvider.notifier).getAllAsset(), - ); return true; } } catch (error, stack) { @@ -106,12 +93,6 @@ class TrashNotifier extends StateNotifier { await _db.writeTxn(() async { await _db.assets.putAll(updatedAssets); }); - - // Refresh assets in background - Future.delayed( - const Duration(seconds: 4), - () async => await _ref.read(assetProvider.notifier).getAllAsset(), - ); } catch (error, stack) { _log.severe("Cannot restore trash ${error.toString()}", error, stack); } diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index acc3fd87b9..ed529a6d8d 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -7,26 +7,43 @@ import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; +import 'package:immich_mobile/shared/services/sync.service.dart'; +import 'package:immich_mobile/utils/debounce.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:socket_io_client/socket_io_client.dart'; +enum PendingAction { + assetDelete, +} + +class PendingChange { + final PendingAction action; + final dynamic value; + + const PendingChange(this.action, this.value); +} + class WebsocketState { final Socket? socket; final bool isConnected; + final List pendingChanges; WebsocketState({ this.socket, required this.isConnected, + required this.pendingChanges, }); WebsocketState copyWith({ Socket? socket, bool? isConnected, + List? pendingChanges, }) { return WebsocketState( socket: socket ?? this.socket, isConnected: isConnected ?? this.isConnected, + pendingChanges: pendingChanges ?? this.pendingChanges, ); } @@ -49,10 +66,17 @@ class WebsocketState { class WebsocketNotifier extends StateNotifier { WebsocketNotifier(this.ref) - : super(WebsocketState(socket: null, isConnected: false)); + : super( + WebsocketState(socket: null, isConnected: false, pendingChanges: []), + ) { + debounce = Debounce( + const Duration(milliseconds: 500), + ); + } final log = Logger('WebsocketNotifier'); final Ref ref; + late final Debounce debounce; connect() { var authenticationState = ref.read(authenticationProvider); @@ -79,21 +103,36 @@ class WebsocketNotifier extends StateNotifier { socket.onConnect((_) { debugPrint("Established Websocket Connection"); - state = WebsocketState(isConnected: true, socket: socket); + state = WebsocketState( + isConnected: true, + socket: socket, + pendingChanges: state.pendingChanges, + ); }); socket.onDisconnect((_) { debugPrint("Disconnect to Websocket Connection"); - state = WebsocketState(isConnected: false, socket: null); + state = WebsocketState( + isConnected: false, + socket: null, + pendingChanges: state.pendingChanges, + ); }); socket.on('error', (errorMessage) { log.severe("Websocket Error - $errorMessage"); - state = WebsocketState(isConnected: false, socket: null); + state = WebsocketState( + isConnected: false, + socket: null, + pendingChanges: state.pendingChanges, + ); }); socket.on('on_upload_success', _handleOnUploadSuccess); socket.on('on_config_update', _handleOnConfigUpdate); + socket.on('on_asset_delete', _handleOnAssetDelete); + socket.on('on_asset_trash', _handleServerUpdates); + socket.on('on_asset_restore', _handleServerUpdates); } catch (e) { debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); } @@ -106,7 +145,11 @@ class WebsocketNotifier extends StateNotifier { var socket = state.socket?.disconnect(); if (socket?.disconnected == true) { - state = WebsocketState(isConnected: false, socket: null); + state = WebsocketState( + isConnected: false, + socket: null, + pendingChanges: state.pendingChanges, + ); } } @@ -120,6 +163,29 @@ class WebsocketNotifier extends StateNotifier { state.socket?.on('on_upload_success', _handleOnUploadSuccess); } + addPendingChange(PendingAction action, dynamic value) { + state = state.copyWith( + pendingChanges: [...state.pendingChanges, PendingChange(action, value)], + ); + } + + handlePendingChanges() { + final deleteChanges = state.pendingChanges + .where((c) => c.action == PendingAction.assetDelete) + .toList(); + if (deleteChanges.isNotEmpty) { + List remoteIds = deleteChanges + .map((a) => jsonDecode(a.value.toString()).toString()) + .toList(); + ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds); + state = state.copyWith( + pendingChanges: state.pendingChanges + .where((c) => c.action != PendingAction.assetDelete) + .toList(), + ); + } + } + _handleOnUploadSuccess(dynamic data) { final jsonString = jsonDecode(data.toString()); final dto = AssetResponseDto.fromJson(jsonString); @@ -133,6 +199,16 @@ class WebsocketNotifier extends StateNotifier { ref.read(serverInfoProvider.notifier).getServerFeatures(); ref.read(serverInfoProvider.notifier).getServerConfig(); } + + // Refresh updated assets + _handleServerUpdates(dynamic data) { + ref.read(assetProvider.notifier).getAllAsset(); + } + + _handleOnAssetDelete(dynamic data) { + addPendingChange(PendingAction.assetDelete, data); + debounce(handlePendingChanges); + } } final websocketProvider = diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart index b3a00429a3..b38b666331 100644 --- a/mobile/lib/shared/services/sync.service.dart +++ b/mobile/lib/shared/services/sync.service.dart @@ -153,7 +153,7 @@ class SyncService { if (toUpsert == null || toDelete == null) return null; try { if (toDelete.isNotEmpty) { - await _handleRemoteAssetRemoval(toDelete); + await handleRemoteAssetRemoval(toDelete); } if (toUpsert.isNotEmpty) { final (_, updated) = await _linkWithExistingFromDb(toUpsert); @@ -171,7 +171,7 @@ class SyncService { } /// Deletes remote-only assets, updates merged assets to be local-only - Future _handleRemoteAssetRemoval(List idsToDelete) { + Future handleRemoteAssetRemoval(List idsToDelete) { return _db.writeTxn(() async { await _db.assets.remote(idsToDelete).filter().localIdIsNull().deleteAll(); final onlyLocal = await _db.assets.remote(idsToDelete).findAll(); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 3f7a9e33ab..abd0dbe0d9 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -431,6 +431,7 @@ export class AssetService { const ids = assets.map((a) => a.id); await this.assetRepository.restoreAll(ids); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); + this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids); } return; } @@ -450,6 +451,7 @@ export class AssetService { await this.access.requirePermission(authUser, Permission.ASSET_RESTORE, ids); await this.assetRepository.restoreAll(ids); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); + this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids); } async run(authUser: AuthUserDto, dto: AssetJobsDto) { diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index b379c7a62b..f49beeb502 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -4,6 +4,7 @@ export enum CommunicationEvent { UPLOAD_SUCCESS = 'on_upload_success', ASSET_DELETE = 'on_asset_delete', ASSET_TRASH = 'on_asset_trash', + ASSET_RESTORE = 'on_asset_restore', PERSON_THUMBNAIL = 'on_person_thumbnail', SERVER_VERSION = 'on_server_version', CONFIG_UPDATE = 'on_config_update',