fix: notify mobile app when live photos are linked (#5504)

* fix(mobile): album thumbnail list tile overflow on large album title

* fix: notify clients about live photo linked event

* refactor: notify clients during meta extraction

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong 2023-12-06 14:56:09 +00:00 committed by GitHub
parent 2814de4420
commit f53b70571b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 148 additions and 47 deletions

View file

@ -68,21 +68,20 @@ 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,
),
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,
),
@ -110,6 +109,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
],
),
),
),
],
),
),

View file

@ -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<WebsocketState> {
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,34 +187,77 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
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<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete)
.toList();
if (deleteChanges.isNotEmpty) {
List<String> 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);
Future<void> _handlePendingUploaded() async {
final uploadedChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetUploaded)
.toList();
if (uploadedChanges.isNotEmpty) {
List<AssetResponseDto?> remoteAssets = uploadedChanges
.map((a) => AssetResponseDto.fromJson(a.value))
.toList();
for (final dto in remoteAssets) {
if (dto != null) {
final newAsset = Asset.remote(dto);
_ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
await _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
}
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => uploadedChanges.contains(c))
.toList(),
);
}
}
Future<void> _handlingPendingHidden() async {
final hiddenChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetHidden)
.toList();
if (hiddenChanges.isNotEmpty) {
List<String> 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();
@ -202,10 +269,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_ref.read(assetProvider.notifier).getAllAsset();
}
void _handleOnAssetDelete(dynamic data) {
void _handleOnUploadSuccess(dynamic data) =>
addPendingChange(PendingAction.assetUploaded, data);
void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data);
_debounce(handlePendingChanges);
}
void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data);
_handleReleaseUpdates(dynamic data) {
// Json guard

View file

@ -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<IMediaRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
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', () => {

View file

@ -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;
}

View file

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