diff --git a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart index 38208e88c..adf863360 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart @@ -68,46 +68,46 @@ class AlbumThumbnailListTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: const BorderRadius.all(Radius.circular(8)), child: album.thumbnail.value == null ? buildEmptyThumbnail() : buildAlbumThumbnail(), ), - Padding( - padding: const EdgeInsets.only( - left: 8.0, - right: 8.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - album.name, - style: const TextStyle( - fontWeight: FontWeight.bold, + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + album.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - album.assetCount == 1 - ? 'album_thumbnail_card_item' - : 'album_thumbnail_card_items', - style: const TextStyle( - fontSize: 12, - ), - ).tr(args: ['${album.assetCount}']), - if (album.shared) - const Text( - 'album_thumbnail_card_shared', - style: TextStyle( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + album.assetCount == 1 + ? 'album_thumbnail_card_item' + : 'album_thumbnail_card_items', + style: const TextStyle( fontSize: 12, ), - ).tr(), - ], - ), - ], + ).tr(args: ['${album.assetCount}']), + if (album.shared) + const Text( + 'album_thumbnail_card_shared', + style: TextStyle( + fontSize: 12, + ), + ).tr(), + ], + ), + ], + ), ), ), ], diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index 018f7ea7a..ebe69b814 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -1,10 +1,13 @@ +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/server_info/server_version.model.dart'; import 'package:immich_mobile/shared/models/store.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/server_info.provider.dart'; import 'package:immich_mobile/shared/services/sync.service.dart'; import 'package:immich_mobile/utils/debounce.dart'; @@ -14,13 +17,33 @@ import 'package:socket_io_client/socket_io_client.dart'; enum PendingAction { assetDelete, + assetUploaded, + assetHidden, } class PendingChange { + final String id; final PendingAction action; final dynamic value; - const PendingChange(this.action, this.value); + const PendingChange( + this.id, + this.action, + this.value, + ); + + @override + String toString() => 'PendingChange(id: $id, action: $action, value: $value)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is PendingChange && other.id == id && other.action == action; + } + + @override + int get hashCode => id.hashCode ^ action.hashCode; } class WebsocketState { @@ -131,6 +154,7 @@ class WebsocketNotifier extends StateNotifier { socket.on('on_asset_trash', _handleServerUpdates); socket.on('on_asset_restore', _handleServerUpdates); socket.on('on_asset_update', _handleServerUpdates); + socket.on('on_asset_hidden', _handleOnAssetHidden); socket.on('on_new_release', _handleReleaseUpdates); } catch (e) { debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); @@ -163,35 +187,78 @@ class WebsocketNotifier extends StateNotifier { } void addPendingChange(PendingAction action, dynamic value) { + final now = DateTime.now(); state = state.copyWith( - pendingChanges: [...state.pendingChanges, PendingChange(action, value)], + pendingChanges: [ + ...state.pendingChanges, + PendingChange(now.millisecondsSinceEpoch.toString(), action, value), + ], ); + _debounce(handlePendingChanges); } - void handlePendingChanges() { + Future _handlePendingDeletes() async { final deleteChanges = state.pendingChanges .where((c) => c.action == PendingAction.assetDelete) .toList(); if (deleteChanges.isNotEmpty) { List remoteIds = deleteChanges.map((a) => a.value.toString()).toList(); - _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds); + await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds); state = state.copyWith( pendingChanges: state.pendingChanges - .where((c) => c.action != PendingAction.assetDelete) + .whereNot((c) => deleteChanges.contains(c)) .toList(), ); } } - void _handleOnUploadSuccess(dynamic data) { - final dto = AssetResponseDto.fromJson(data); - if (dto != null) { - final newAsset = Asset.remote(dto); - _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); + Future _handlePendingUploaded() async { + final uploadedChanges = state.pendingChanges + .where((c) => c.action == PendingAction.assetUploaded) + .toList(); + if (uploadedChanges.isNotEmpty) { + List remoteAssets = uploadedChanges + .map((a) => AssetResponseDto.fromJson(a.value)) + .toList(); + for (final dto in remoteAssets) { + if (dto != null) { + final newAsset = Asset.remote(dto); + await _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); + } + } + state = state.copyWith( + pendingChanges: state.pendingChanges + .whereNot((c) => uploadedChanges.contains(c)) + .toList(), + ); } } + Future _handlingPendingHidden() async { + final hiddenChanges = state.pendingChanges + .where((c) => c.action == PendingAction.assetHidden) + .toList(); + if (hiddenChanges.isNotEmpty) { + List remoteIds = + hiddenChanges.map((a) => a.value.toString()).toList(); + final db = _ref.watch(dbProvider); + await db.writeTxn(() => db.assets.deleteAllByRemoteId(remoteIds)); + + state = state.copyWith( + pendingChanges: state.pendingChanges + .whereNot((c) => hiddenChanges.contains(c)) + .toList(), + ); + } + } + + void handlePendingChanges() async { + await _handlePendingUploaded(); + await _handlePendingDeletes(); + await _handlingPendingHidden(); + } + void _handleOnConfigUpdate(dynamic _) { _ref.read(serverInfoProvider.notifier).getServerFeatures(); _ref.read(serverInfoProvider.notifier).getServerConfig(); @@ -202,10 +269,14 @@ class WebsocketNotifier extends StateNotifier { _ref.read(assetProvider.notifier).getAllAsset(); } - void _handleOnAssetDelete(dynamic data) { - addPendingChange(PendingAction.assetDelete, data); - _debounce(handlePendingChanges); - } + void _handleOnUploadSuccess(dynamic data) => + addPendingChange(PendingAction.assetUploaded, data); + + void _handleOnAssetDelete(dynamic data) => + addPendingChange(PendingAction.assetDelete, data); + + void _handleOnAssetHidden(dynamic data) => + addPendingChange(PendingAction.assetHidden, data); _handleReleaseUpdates(dynamic data) { // Json guard diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 1eefc5eba..b3e3ec927 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -3,6 +3,7 @@ import { assetStub, newAlbumRepositoryMock, newAssetRepositoryMock, + newCommunicationRepositoryMock, newCryptoRepositoryMock, newJobRepositoryMock, newMediaRepositoryMock, @@ -19,8 +20,10 @@ import { constants } from 'fs/promises'; import { when } from 'jest-when'; import { JobName } from '../job'; import { + CommunicationEvent, IAlbumRepository, IAssetRepository, + ICommunicationRepository, ICryptoRepository, IJobRepository, IMediaRepository, @@ -46,6 +49,7 @@ describe(MetadataService.name, () => { let mediaMock: jest.Mocked; let personMock: jest.Mocked; let storageMock: jest.Mocked; + let communicationMock: jest.Mocked; let sut: MetadataService; beforeEach(async () => { @@ -57,6 +61,7 @@ describe(MetadataService.name, () => { metadataMock = newMetadataRepositoryMock(); moveMock = newMoveRepositoryMock(); personMock = newPersonRepositoryMock(); + communicationMock = newCommunicationRepositoryMock(); storageMock = newStorageRepositoryMock(); mediaMock = newMediaRepositoryMock(); @@ -70,6 +75,7 @@ describe(MetadataService.name, () => { configMock, mediaMock, moveMock, + communicationMock, personMock, ); }); @@ -172,6 +178,23 @@ describe(MetadataService.name, () => { expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); }); + + it('should notify clients on live photo link', async () => { + assetMock.getByIds.mockResolvedValue([ + { + ...assetStub.livePhotoStillAsset, + exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, + }, + ]); + assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + + await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); + expect(communicationMock.send).toHaveBeenCalledWith( + CommunicationEvent.ASSET_HIDDEN, + assetStub.livePhotoMotionAsset.ownerId, + assetStub.livePhotoMotionAsset.id, + ); + }); }); describe('handleQueueMetadataExtraction', () => { diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 9c8f887dc..8b6cff4ec 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -9,9 +9,11 @@ import { Subscription } from 'rxjs'; import { usePagination } from '../domain.util'; import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { + CommunicationEvent, ExifDuration, IAlbumRepository, IAssetRepository, + ICommunicationRepository, ICryptoRepository, IJobRepository, IMediaRepository, @@ -104,6 +106,7 @@ export class MetadataService { @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, + @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(IPersonRepository) personRepository: IPersonRepository, ) { this.configCore = SystemConfigCore.create(configRepository); @@ -167,6 +170,9 @@ export class MetadataService { await this.assetRepository.save({ id: motionAsset.id, isVisible: false }); await this.albumRepository.removeAsset(motionAsset.id); + // Notify clients to hide the linked live photo asset + this.communicationRepository.send(CommunicationEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id); + return true; } diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index 958adb803..86397d5cd 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -5,6 +5,7 @@ export enum CommunicationEvent { ASSET_DELETE = 'on_asset_delete', ASSET_TRASH = 'on_asset_trash', ASSET_UPDATE = 'on_asset_update', + ASSET_HIDDEN = 'on_asset_hidden', ASSET_RESTORE = 'on_asset_restore', PERSON_THUMBNAIL = 'on_person_thumbnail', SERVER_VERSION = 'on_server_version',