1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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>
This commit is contained in:
shenlong 2023-10-16 18:01:38 +00:00 committed by GitHub
parent 634169235a
commit a78e08bac1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 88 additions and 27 deletions

View File

@ -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_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.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.",
"profile_drawer_app_logs": "Logs", "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_client_server_up_to_date": "Client and Server are up-to-date",
"profile_drawer_settings": "Settings", "profile_drawer_settings": "Settings",
"profile_drawer_sign_out": "Sign Out", "profile_drawer_sign_out": "Sign Out",

View File

@ -106,7 +106,7 @@ class ProfileDrawer extends HookConsumerWidget {
), ),
), ),
title: Text( title: Text(
"Trash", "profile_drawer_trash",
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.labelLarge .labelLarge

View File

@ -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/modules/trash/services/trash.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.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/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
@ -42,12 +41,6 @@ class TrashNotifier extends StateNotifier<bool> {
await _db.exifInfos.deleteAll(dbIds); await _db.exifInfos.deleteAll(dbIds);
await _db.assets.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) { } catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", 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.writeTxn(() async {
await _db.assets.putAll(updatedAssets); await _db.assets.putAll(updatedAssets);
}); });
// Refresh assets in background
Future.delayed(
const Duration(seconds: 4),
() async => await _ref.read(assetProvider.notifier).getAllAsset(),
);
return true; return true;
} }
} catch (error, stack) { } catch (error, stack) {
@ -106,12 +93,6 @@ class TrashNotifier extends StateNotifier<bool> {
await _db.writeTxn(() async { await _db.writeTxn(() async {
await _db.assets.putAll(updatedAssets); 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) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore trash ${error.toString()}", error, stack);
} }

View File

@ -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/models/store.dart';
import 'package:immich_mobile/shared/providers/asset.provider.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/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:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:socket_io_client/socket_io_client.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 { class WebsocketState {
final Socket? socket; final Socket? socket;
final bool isConnected; final bool isConnected;
final List<PendingChange> pendingChanges;
WebsocketState({ WebsocketState({
this.socket, this.socket,
required this.isConnected, required this.isConnected,
required this.pendingChanges,
}); });
WebsocketState copyWith({ WebsocketState copyWith({
Socket? socket, Socket? socket,
bool? isConnected, bool? isConnected,
List<PendingChange>? pendingChanges,
}) { }) {
return WebsocketState( return WebsocketState(
socket: socket ?? this.socket, socket: socket ?? this.socket,
isConnected: isConnected ?? this.isConnected, isConnected: isConnected ?? this.isConnected,
pendingChanges: pendingChanges ?? this.pendingChanges,
); );
} }
@ -49,10 +66,17 @@ class WebsocketState {
class WebsocketNotifier extends StateNotifier<WebsocketState> { class WebsocketNotifier extends StateNotifier<WebsocketState> {
WebsocketNotifier(this.ref) 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 log = Logger('WebsocketNotifier');
final Ref ref; final Ref ref;
late final Debounce debounce;
connect() { connect() {
var authenticationState = ref.read(authenticationProvider); var authenticationState = ref.read(authenticationProvider);
@ -79,21 +103,36 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.onConnect((_) { socket.onConnect((_) {
debugPrint("Established Websocket Connection"); debugPrint("Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket); state = WebsocketState(
isConnected: true,
socket: socket,
pendingChanges: state.pendingChanges,
);
}); });
socket.onDisconnect((_) { socket.onDisconnect((_) {
debugPrint("Disconnect to Websocket Connection"); debugPrint("Disconnect to Websocket Connection");
state = WebsocketState(isConnected: false, socket: null); state = WebsocketState(
isConnected: false,
socket: null,
pendingChanges: state.pendingChanges,
);
}); });
socket.on('error', (errorMessage) { socket.on('error', (errorMessage) {
log.severe("Websocket 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_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate); 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) { } catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
} }
@ -106,7 +145,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
var socket = state.socket?.disconnect(); var socket = state.socket?.disconnect();
if (socket?.disconnected == true) { 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); 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) { _handleOnUploadSuccess(dynamic data) {
final jsonString = jsonDecode(data.toString()); final jsonString = jsonDecode(data.toString());
final dto = AssetResponseDto.fromJson(jsonString); final dto = AssetResponseDto.fromJson(jsonString);
@ -133,6 +199,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
ref.read(serverInfoProvider.notifier).getServerFeatures(); ref.read(serverInfoProvider.notifier).getServerFeatures();
ref.read(serverInfoProvider.notifier).getServerConfig(); 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 = final websocketProvider =

View File

@ -153,7 +153,7 @@ class SyncService {
if (toUpsert == null || toDelete == null) return null; if (toUpsert == null || toDelete == null) return null;
try { try {
if (toDelete.isNotEmpty) { if (toDelete.isNotEmpty) {
await _handleRemoteAssetRemoval(toDelete); await handleRemoteAssetRemoval(toDelete);
} }
if (toUpsert.isNotEmpty) { if (toUpsert.isNotEmpty) {
final (_, updated) = await _linkWithExistingFromDb(toUpsert); final (_, updated) = await _linkWithExistingFromDb(toUpsert);
@ -171,7 +171,7 @@ class SyncService {
} }
/// Deletes remote-only assets, updates merged assets to be local-only /// 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 { return _db.writeTxn(() async {
await _db.assets.remote(idsToDelete).filter().localIdIsNull().deleteAll(); await _db.assets.remote(idsToDelete).filter().localIdIsNull().deleteAll();
final onlyLocal = await _db.assets.remote(idsToDelete).findAll(); final onlyLocal = await _db.assets.remote(idsToDelete).findAll();

View File

@ -431,6 +431,7 @@ export class AssetService {
const ids = assets.map((a) => a.id); const ids = assets.map((a) => a.id);
await this.assetRepository.restoreAll(ids); await this.assetRepository.restoreAll(ids);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
} }
return; return;
} }
@ -450,6 +451,7 @@ export class AssetService {
await this.access.requirePermission(authUser, Permission.ASSET_RESTORE, ids); await this.access.requirePermission(authUser, Permission.ASSET_RESTORE, ids);
await this.assetRepository.restoreAll(ids); await this.assetRepository.restoreAll(ids);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { 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) { async run(authUser: AuthUserDto, dto: AssetJobsDto) {

View File

@ -4,6 +4,7 @@ export enum CommunicationEvent {
UPLOAD_SUCCESS = 'on_upload_success', UPLOAD_SUCCESS = 'on_upload_success',
ASSET_DELETE = 'on_asset_delete', ASSET_DELETE = 'on_asset_delete',
ASSET_TRASH = 'on_asset_trash', ASSET_TRASH = 'on_asset_trash',
ASSET_RESTORE = 'on_asset_restore',
PERSON_THUMBNAIL = 'on_person_thumbnail', PERSON_THUMBNAIL = 'on_person_thumbnail',
SERVER_VERSION = 'on_server_version', SERVER_VERSION = 'on_server_version',
CONFIG_UPDATE = 'on_config_update', CONFIG_UPDATE = 'on_config_update',