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_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",

View file

@ -106,7 +106,7 @@ class ProfileDrawer extends HookConsumerWidget {
),
),
title: Text(
"Trash",
"profile_drawer_trash",
style: Theme.of(context)
.textTheme
.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/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);
}

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/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 =

View file

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

View file

@ -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) {

View file

@ -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',