Ver Fonte

fix(mobile): handle asset trash, restore and delete ws events (#4482)

* server: add ASSET_RESTORE ws event

* mobile: handle ASSET_TRASH, ASSET_RESTORE and ASSET_DELETE ws events

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
shenlong há 1 ano atrás
pai
commit
a78e08bac1

+ 1 - 0
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",

+ 1 - 1
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

+ 0 - 19
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<bool> {
         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<bool> {
         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<bool> {
       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);
     }

+ 81 - 5
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<PendingChange> pendingChanges;
 
   WebsocketState({
     this.socket,
     required this.isConnected,
+    required this.pendingChanges,
   });
 
   WebsocketState copyWith({
     Socket? socket,
     bool? isConnected,
+    List<PendingChange>? 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<WebsocketState> {
   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<WebsocketState> {
 
         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<WebsocketState> {
     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<WebsocketState> {
     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<String> 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<WebsocketState> {
     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 =

+ 2 - 2
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<void> _handleRemoteAssetRemoval(List<String> idsToDelete) {
+  Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
     return _db.writeTxn(() async {
       await _db.assets.remote(idsToDelete).filter().localIdIsNull().deleteAll();
       final onlyLocal = await _db.assets.remote(idsToDelete).findAll();

+ 2 - 0
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) {

+ 1 - 0
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',