mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +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:
parent
634169235a
commit
a78e08bac1
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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 =
|
||||||
|
@ -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();
|
||||||
|
@ -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) {
|
||||||
|
@ -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',
|
||||||
|
Loading…
Reference in New Issue
Block a user