From e671b30aaf3d8aeaaf898767b5ab7f06df258b2a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sun, 5 Nov 2023 11:07:29 -0500 Subject: [PATCH 01/30] fix(server): duplicate faces bug (#4844) --- .../src/domain/person/person.service.spec.ts | 19 ++++++++++++++++++- server/src/domain/person/person.service.ts | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 9d966460e..59b33f4dc 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -1,4 +1,4 @@ -import { Colorspace, SystemConfigKey } from '@app/infra/entities'; +import { AssetFaceEntity, Colorspace, SystemConfigKey } from '@app/infra/entities'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { IAccessRepositoryMock, @@ -449,6 +449,23 @@ describe(PersonService.name, () => { expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); }); + it('should skip it the asset has already been processed', async () => { + assetMock.getByIds.mockResolvedValue([ + { + ...assetStub.noResizePath, + faces: [ + { + id: 'asset-face-1', + assetId: assetStub.noResizePath.id, + personId: faceStub.face1.personId, + } as AssetFaceEntity, + ], + }, + ]); + await sut.handleRecognizeFaces({ id: assetStub.noResizePath.id }); + expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); + }); + it('should handle no results', async () => { machineLearningMock.detectFaces.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([assetStub.image]); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index dcf5dbb78..b6e7bf0b9 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -217,7 +217,7 @@ export class PersonService { } const [asset] = await this.assetRepository.getByIds([id]); - if (!asset || !asset.resizePath) { + if (!asset || !asset.resizePath || asset.faces?.length > 0) { return false; } From 68000c21a8bfc93dc81903982256cdd17c515468 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Sun, 5 Nov 2023 17:07:57 +0100 Subject: [PATCH 02/30] fix(mobile): backup indicator wrong when only background backup is enabled (#4842) Co-authored-by: Fynn Petersen-Frey --- mobile/lib/modules/backup/providers/backup.provider.dart | 6 +++++- mobile/lib/shared/models/store.dart | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index 673991c5d..0df8bc90d 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -40,7 +40,7 @@ class BackupNotifier extends StateNotifier { progressInPercentage: 0, cancelToken: CancellationToken(), autoBackup: Store.get(StoreKey.autoBackup, false), - backgroundBackup: false, + backgroundBackup: Store.get(StoreKey.backgroundBackup, false), backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true), backupRequireCharging: Store.get(StoreKey.backupRequireCharging, false), @@ -171,6 +171,7 @@ class BackupNotifier extends StateNotifier { state.backupRequireCharging, ); await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay); + await Store.put(StoreKey.backgroundBackup, state.backgroundBackup); } else { state = state.copyWith( backgroundBackup: wasEnabled, @@ -383,6 +384,9 @@ class BackupNotifier extends StateNotifier { final isEnabled = await _backgroundService.isBackgroundBackupEnabled(); state = state.copyWith(backgroundBackup: isEnabled); + if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) { + Store.put(StoreKey.backgroundBackup, isEnabled); + } if (state.backupProgress != BackUpProgressEnum.inBackground) { await _getBackupAlbumsInfo(); diff --git a/mobile/lib/shared/models/store.dart b/mobile/lib/shared/models/store.dart index 8f24899f1..40258f304 100644 --- a/mobile/lib/shared/models/store.dart +++ b/mobile/lib/shared/models/store.dart @@ -156,6 +156,7 @@ enum StoreKey { accessToken(11, type: String), serverEndpoint(12, type: String), autoBackup(13, type: bool), + backgroundBackup(14, type: bool), // user settings from [AppSettingsEnum] below: loadPreview(100, type: bool), loadOriginal(101, type: bool), From a0743d8b7d25c124a7880d9534f743bf3ae9e55a Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Sun, 5 Nov 2023 18:24:43 +0100 Subject: [PATCH 03/30] feat(web): global activity (#4796) * feat: global activity * fix: tests * pr feedback * use flexbox * fix: deleted control actions * fix: flex box * fix: do not show activity tab by default * feat: better grouping * fix: set isShared default value to false * fix: prevent re-rendering the asset grid * fix: activity status above the scrollbar * fix: prevent re-rendering the asset grid * fix: prevent re-rendering the asset grid * pr feedback * pr feedback * pr feedback * styling and better thumbnail --------- Co-authored-by: Alex Tran --- .../asset-viewer/activity-status.svelte | 33 ++ .../asset-viewer/activity-viewer.svelte | 65 ++- .../asset-viewer/asset-viewer.svelte | 55 +- .../buttons/circle-icon-button.svelte | 3 +- .../shared-components/control-app-bar.svelte | 2 +- .../scrollbar/scrollbar.svelte | 2 +- web/src/lib/stores/activity.store.ts | 11 + .../(user)/albums/[albumId]/+page.svelte | 551 +++++++++++------- 8 files changed, 450 insertions(+), 272 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/activity-status.svelte create mode 100644 web/src/lib/stores/activity.store.ts diff --git a/web/src/lib/components/asset-viewer/activity-status.svelte b/web/src/lib/components/asset-viewer/activity-status.svelte new file mode 100644 index 000000000..23fdb7a9d --- /dev/null +++ b/web/src/lib/components/asset-viewer/activity-status.svelte @@ -0,0 +1,33 @@ + + +
+ + +
diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 234f708f7..efc53db9c 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -1,9 +1,9 @@ diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index a7c7da6ba..f8d5627fa 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -40,7 +40,7 @@ }); -
+
height}
(undefined); + +export const setNumberOfComments = (number: number) => { + numberOfComments.set(number); +}; + +export const updateNumberOfComments = (addOrRemove: 1 | -1) => { + numberOfComments.update((n) => (n ? n + addOrRemove : undefined)); +}; diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 1697af522..3d0c97510 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -35,7 +35,7 @@ import { downloadArchive } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; - import { UserResponseDto, api } from '@api'; + import { ActivityResponseDto, ReactionType, UserResponseDto, api } from '@api'; import Icon from '$lib/components/elements/icon.svelte'; import type { PageData } from './$types'; import { clickOutside } from '$lib/utils/click-outside'; @@ -45,11 +45,16 @@ mdiDotsVertical, mdiArrowLeft, mdiFileImagePlusOutline, - mdiShareVariantOutline, - mdiDeleteOutline, mdiFolderDownloadOutline, mdiLink, + mdiShareVariantOutline, + mdiDeleteOutline, } from '@mdi/js'; + import { onMount } from 'svelte'; + import { fly } from 'svelte/transition'; + import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte'; + import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte'; + import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; export let data: PageData; @@ -77,6 +82,12 @@ let isCreatingSharedAlbum = false; let currentAlbumName = ''; let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 }; + let isShowActivity = false; + let isLiked: ActivityResponseDto | null = null; + let reactions: ActivityResponseDto[] = []; + let user = data.user; + let globalWidth: number; + let assetGridWidth: number; const assetStore = new AssetStore({ albumId: album.id }); const assetInteractionStore = createAssetInteractionStore(); @@ -89,6 +100,13 @@ $: isOwned = data.user.id == album.ownerId; $: isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === data.user.id); $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); + $: { + if (isShowActivity) { + assetGridWidth = globalWidth - (globalWidth < 768 ? 360 : 460); + } else { + assetGridWidth = globalWidth; + } + } afterNavigate(({ from }) => { assetViewingStore.showAssetViewer(false); @@ -110,6 +128,63 @@ } }); + const handleFavorite = async () => { + try { + if (isLiked) { + const activityId = isLiked.id; + await api.activityApi.deleteActivity({ id: activityId }); + reactions = reactions.filter((reaction) => reaction.id !== activityId); + isLiked = null; + } else { + const { data } = await api.activityApi.createActivity({ + activityCreateDto: { albumId: album.id, type: ReactionType.Like }, + }); + + isLiked = data; + reactions = [...reactions, isLiked]; + } + } catch (error) { + handleError(error, "Can't change favorite for asset"); + } + }; + + const getFavorite = async () => { + if (user) { + try { + const { data } = await api.activityApi.getActivities({ + userId: user.id, + albumId: album.id, + type: ReactionType.Like, + }); + if (data.length > 0) { + isLiked = data[0]; + } + } catch (error) { + handleError(error, "Can't get Favorite"); + } + } + }; + + const getNumberOfComments = async () => { + try { + const { data } = await api.activityApi.getActivityStatistics({ albumId: album.id }); + setNumberOfComments(data.comments); + } catch (error) { + handleError(error, "Can't get number of comments"); + } + }; + + const handleOpenAndCloseActivityTab = () => { + isShowActivity = !isShowActivity; + }; + + onMount(async () => { + if (album.sharedUsers.length > 0) { + getFavorite(); + getNumberOfComments(); + } + }); + const handleStartSlideshow = async () => { const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; if (asset) { @@ -321,239 +396,275 @@ }; -
- {#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> - - - - - - - - {#if isAllUserOwned} - - {/if} - - - {#if isOwned || isAllUserOwned} - handleRemoveAssets(assetIds)} /> - {/if} - {#if isAllUserOwned} - assetStore.removeAsset(assetId)} /> - {/if} - - - {:else} - {#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS} - goto(backUrl)}> - - (viewMode = ViewMode.SELECT_ASSETS)} - icon={mdiFileImagePlusOutline} - /> - - {#if isOwned} - (viewMode = ViewMode.SELECT_USERS)} - icon={mdiShareVariantOutline} - /> - (viewMode = ViewMode.CONFIRM_DELETE)} - icon={mdiDeleteOutline} - /> +
+
+ {#if $isMultiSelectState} + assetInteractionStore.clearMultiselect()}> + + + + + + + + {#if isAllUserOwned} + {/if} - - {#if album.assetCount > 0} - + + + {#if isOwned || isAllUserOwned} + handleRemoveAssets(assetIds)} /> + {/if} + {#if isAllUserOwned} + assetStore.removeAsset(assetId)} /> + {/if} + + + {:else} + {#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS} + goto(backUrl)}> + + (viewMode = ViewMode.SELECT_ASSETS)} + icon={mdiFileImagePlusOutline} + /> {#if isOwned} -
(viewMode = ViewMode.VIEW)}> - - {#if viewMode === ViewMode.ALBUM_OPTIONS} - - {#if album.assetCount !== 0} - - {/if} - (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" /> - - {/if} - -
+ (viewMode = ViewMode.SELECT_USERS)} + icon={mdiShareVariantOutline} + /> + (viewMode = ViewMode.CONFIRM_DELETE)} + icon={mdiDeleteOutline} + /> {/if} - {/if} - {#if isCreatingSharedAlbum && album.sharedUsers.length === 0} - - {/if} -
-
- {/if} - - {#if viewMode === ViewMode.SELECT_ASSETS} - - -

- {#if $timelineSelected.size == 0} - Add to album - {:else} - {$timelineSelected.size.toLocaleString($locale)} selected - {/if} -

-
- - - - - -
- {/if} - - {#if viewMode === ViewMode.SELECT_THUMBNAIL} - (viewMode = ViewMode.VIEW)}> - Select Album Cover - - {/if} - {/if} -
- -
- {#if viewMode === ViewMode.SELECT_ASSETS} - - {:else} - 0} - isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL} - singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL} - on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)} - on:escape={handleEscape} - > - {#if viewMode !== ViewMode.SELECT_THUMBNAIL} - -
- e.key == 'Enter' && titleInput.blur()} - on:blur={handleUpdateName} - class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned - ? 'hover:border-gray-400' - : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" - type="text" - bind:value={album.albumName} - disabled={!isOwned} - bind:this={titleInput} - title="Edit Title" - /> - - - {#if album.assetCount > 0} - -

{getDateRange()}

-

·

-

{album.assetCount} items

-
- {/if} - - - {#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)} -
- - {#if album.hasSharedLink && isOwned} - (viewMode = ViewMode.LINK_SHARING)} - /> - {/if} - - - - - - {#each album.sharedUsers as user (user.id)} - - {/each} + {#if album.assetCount > 0} + {#if isOwned} - (viewMode = ViewMode.SELECT_USERS)} - title="Add more users" - /> +
(viewMode = ViewMode.VIEW)}> + + {#if viewMode === ViewMode.ALBUM_OPTIONS} + + {#if album.assetCount !== 0} + + {/if} + (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" /> + + {/if} + +
{/if} -
- {/if} + {/if} - - {#if isOwned || album.description} - - {/if} -
+ {#if isCreatingSharedAlbum && album.sharedUsers.length === 0} + + {/if} + + {/if} - {#if album.assetCount === 0} -
-
-

ADD PHOTOS

+ {#if viewMode === ViewMode.SELECT_ASSETS} + + +

+ {#if $timelineSelected.size === 0} + Add to album + {:else} + {$timelineSelected.size.toLocaleString($locale)} selected + {/if} +

+
+ + -
-
+ + + {/if} -
+ + {#if viewMode === ViewMode.SELECT_THUMBNAIL} + (viewMode = ViewMode.VIEW)}> + Select Album Cover + + {/if} + {/if} + +
+ {#if viewMode === ViewMode.SELECT_ASSETS} + + {:else} + 0} + isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL} + singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL} + on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)} + on:escape={handleEscape} + > + {#if viewMode !== ViewMode.SELECT_THUMBNAIL} + +
+ e.key === 'Enter' && titleInput.blur()} + on:blur={handleUpdateName} + class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned + ? 'hover:border-gray-400' + : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" + type="text" + bind:value={album.albumName} + disabled={!isOwned} + bind:this={titleInput} + title="Edit Title" + /> + + + {#if album.assetCount > 0} + +

{getDateRange()}

+

·

+

{album.assetCount} items

+
+ {/if} + + + {#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)} +
+ + {#if album.hasSharedLink && isOwned} + (viewMode = ViewMode.LINK_SHARING)} + /> + {/if} + + + + + + {#each album.sharedUsers as user (user.id)} + + {/each} + + {#if isOwned} + (viewMode = ViewMode.SELECT_USERS)} + title="Add more users" + /> + {/if} +
+ {/if} + + + {#if isOwned || album.description} + + {/if} +
+ {/if} + + {#if album.assetCount === 0} +
+
+

ADD PHOTOS

+ +
+
+ {/if} +
+ {/if} + + {#if album.sharedUsers.length > 0 && !$showAssetViewer} +
+ +
+ {/if} +
+
+ {#if album.sharedUsers.length > 0 && album && isShowActivity && user && !$showAssetViewer} +
+
+ updateNumberOfComments(1)} + on:deleteComment={() => updateNumberOfComments(-1)} + on:deleteLike={() => (isLiked = null)} + on:close={handleOpenAndCloseActivityTab} + /> +
+
{/if} - - +
{#if viewMode === ViewMode.SELECT_USERS} Date: Sun, 5 Nov 2023 21:15:12 -0500 Subject: [PATCH 04/30] chore(server): set relations for `getByIds` (#4855) --- server/src/domain/person/person.service.ts | 8 +++++++- server/src/domain/repositories/asset.repository.ts | 3 ++- server/src/infra/repositories/asset.repository.ts | 13 ++++++++----- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index b6e7bf0b9..0d8f9b6b8 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -216,7 +216,13 @@ export class PersonService { return true; } - const [asset] = await this.assetRepository.getByIds([id]); + const relations = { + exifInfo: true, + faces: { + person: true, + }, + }; + const [asset] = await this.assetRepository.getByIds([id], relations); if (!asset || !asset.resizePath || asset.faces?.length > 0) { return false; } diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index bdbdd78f2..4b11cb55a 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,4 +1,5 @@ import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; +import { FindOptionsRelations } from 'typeorm'; import { Paginated, PaginationOptions } from '../domain.util'; export type AssetStats = Record; @@ -99,7 +100,7 @@ export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { create(asset: AssetCreate): Promise; getByDate(ownerId: string, date: Date): Promise; - getByIds(ids: string[]): Promise; + getByIds(ids: string[], relations?: FindOptionsRelations): Promise; getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise; getByChecksum(userId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index c237a3604..199842ed4 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -104,10 +104,9 @@ export class AssetRepository implements IAssetRepository { .getMany(); } - getByIds(ids: string[]): Promise { - return this.repository.find({ - where: { id: In(ids) }, - relations: { + getByIds(ids: string[], relations?: FindOptionsRelations): Promise { + if (!relations) { + relations = { exifInfo: true, smartInfo: true, tags: true, @@ -115,7 +114,11 @@ export class AssetRepository implements IAssetRepository { person: true, }, stack: true, - }, + }; + } + return this.repository.find({ + where: { id: In(ids) }, + relations, withDeleted: true, }); } From 279481ad544d0919113ffdd392863b4d7c67c08e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 6 Nov 2023 09:04:39 -0500 Subject: [PATCH 05/30] feat(server): make is favorite optional on asset upload (#4865) * feat(server): make isFavorite optional * chore: open api * chore: e2e --- cli/src/api/open-api/api.ts | 32 +++++++++---------- mobile/openapi/doc/AssetApi.md | 8 ++--- mobile/openapi/doc/ImportAssetDto.md | 2 +- mobile/openapi/lib/api/asset_api.dart | 14 ++++---- .../openapi/lib/model/import_asset_dto.dart | 19 ++++++++--- mobile/openapi/test/asset_api_test.dart | 2 +- server/immich-openapi-specs.json | 6 ++-- .../api-v1/asset/dto/create-asset.dto.ts | 3 +- server/test/e2e/asset.e2e-spec.ts | 1 - web/src/api/open-api/api.ts | 32 +++++++++---------- 10 files changed, 61 insertions(+), 58 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 9f4d765f2..5a2445846 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -1855,7 +1855,7 @@ export interface ImportAssetDto { * @type {boolean} * @memberof ImportAssetDto */ - 'isFavorite': boolean; + 'isFavorite'?: boolean; /** * * @type {boolean} @@ -7868,11 +7868,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} deviceId * @param {string} fileCreatedAt * @param {string} fileModifiedAt - * @param {boolean} isFavorite * @param {string} [key] * @param {string} [duration] * @param {boolean} [isArchived] * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] * @param {boolean} [isOffline] * @param {boolean} [isReadOnly] * @param {boolean} [isVisible] @@ -7882,7 +7882,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {*} [options] Override http request option. * @throws {RequiredError} */ - uploadFile: async (assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options: AxiosRequestConfig = {}): Promise => { + uploadFile: async (assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isFavorite?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'assetData' is not null or undefined assertParamExists('uploadFile', 'assetData', assetData) // verify required parameter 'deviceAssetId' is not null or undefined @@ -7893,8 +7893,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration assertParamExists('uploadFile', 'fileCreatedAt', fileCreatedAt) // verify required parameter 'fileModifiedAt' is not null or undefined assertParamExists('uploadFile', 'fileModifiedAt', fileModifiedAt) - // verify required parameter 'isFavorite' is not null or undefined - assertParamExists('uploadFile', 'isFavorite', isFavorite) const localVarPath = `/asset/upload`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -8335,11 +8333,11 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} deviceId * @param {string} fileCreatedAt * @param {string} fileModifiedAt - * @param {boolean} isFavorite * @param {string} [key] * @param {string} [duration] * @param {boolean} [isArchived] * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] * @param {boolean} [isOffline] * @param {boolean} [isReadOnly] * @param {boolean} [isVisible] @@ -8349,8 +8347,8 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async uploadFile(assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData, options); + async uploadFile(assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isFavorite?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -8626,7 +8624,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(axios, basePath)); + return localVarFp.uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(axios, basePath)); }, }; }; @@ -9274,13 +9272,6 @@ export interface AssetApiUploadFileRequest { */ readonly fileModifiedAt: string - /** - * - * @type {boolean} - * @memberof AssetApiUploadFile - */ - readonly isFavorite: boolean - /** * * @type {string} @@ -9309,6 +9300,13 @@ export interface AssetApiUploadFileRequest { */ readonly isExternal?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiUploadFile + */ + readonly isFavorite?: boolean + /** * * @type {boolean} @@ -9681,7 +9679,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index cf50f659d..e072c3ded 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -1696,7 +1696,7 @@ void (empty response body) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **uploadFile** -> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData) +> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData) @@ -1724,11 +1724,11 @@ final deviceAssetId = deviceAssetId_example; // String | final deviceId = deviceId_example; // String | final fileCreatedAt = 2013-10-20T19:20:30+01:00; // DateTime | final fileModifiedAt = 2013-10-20T19:20:30+01:00; // DateTime | -final isFavorite = true; // bool | final key = key_example; // String | final duration = duration_example; // String | final isArchived = true; // bool | final isExternal = true; // bool | +final isFavorite = true; // bool | final isOffline = true; // bool | final isReadOnly = true; // bool | final isVisible = true; // bool | @@ -1737,7 +1737,7 @@ final livePhotoData = BINARY_DATA_HERE; // MultipartFile | final sidecarData = BINARY_DATA_HERE; // MultipartFile | try { - final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData); + final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData); print(result); } catch (e) { print('Exception when calling AssetApi->uploadFile: $e\n'); @@ -1753,11 +1753,11 @@ Name | Type | Description | Notes **deviceId** | **String**| | **fileCreatedAt** | **DateTime**| | **fileModifiedAt** | **DateTime**| | - **isFavorite** | **bool**| | **key** | **String**| | [optional] **duration** | **String**| | [optional] **isArchived** | **bool**| | [optional] **isExternal** | **bool**| | [optional] + **isFavorite** | **bool**| | [optional] **isOffline** | **bool**| | [optional] **isReadOnly** | **bool**| | [optional] **isVisible** | **bool**| | [optional] diff --git a/mobile/openapi/doc/ImportAssetDto.md b/mobile/openapi/doc/ImportAssetDto.md index c9a5f25d2..3f2747edc 100644 --- a/mobile/openapi/doc/ImportAssetDto.md +++ b/mobile/openapi/doc/ImportAssetDto.md @@ -16,7 +16,7 @@ Name | Type | Description | Notes **fileModifiedAt** | [**DateTime**](DateTime.md) | | **isArchived** | **bool** | | [optional] **isExternal** | **bool** | | [optional] -**isFavorite** | **bool** | | +**isFavorite** | **bool** | | [optional] **isOffline** | **bool** | | [optional] **isReadOnly** | **bool** | | [optional] [default to true] **isVisible** | **bool** | | [optional] diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index b0e9c822d..4b74d2933 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1660,8 +1660,6 @@ class AssetApi { /// /// * [DateTime] fileModifiedAt (required): /// - /// * [bool] isFavorite (required): - /// /// * [String] key: /// /// * [String] duration: @@ -1670,6 +1668,8 @@ class AssetApi { /// /// * [bool] isExternal: /// + /// * [bool] isFavorite: + /// /// * [bool] isOffline: /// /// * [bool] isReadOnly: @@ -1681,7 +1681,7 @@ class AssetApi { /// * [MultipartFile] livePhotoData: /// /// * [MultipartFile] sidecarData: - Future uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { + Future uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations final path = r'/asset/upload'; @@ -1790,8 +1790,6 @@ class AssetApi { /// /// * [DateTime] fileModifiedAt (required): /// - /// * [bool] isFavorite (required): - /// /// * [String] key: /// /// * [String] duration: @@ -1800,6 +1798,8 @@ class AssetApi { /// /// * [bool] isExternal: /// + /// * [bool] isFavorite: + /// /// * [bool] isOffline: /// /// * [bool] isReadOnly: @@ -1811,8 +1811,8 @@ class AssetApi { /// * [MultipartFile] livePhotoData: /// /// * [MultipartFile] sidecarData: - Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { - final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key: key, duration: duration, isArchived: isArchived, isExternal: isExternal, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, ); + Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { + final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, duration: duration, isArchived: isArchived, isExternal: isExternal, isFavorite: isFavorite, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/import_asset_dto.dart b/mobile/openapi/lib/model/import_asset_dto.dart index 66ee3faee..7ba26da9d 100644 --- a/mobile/openapi/lib/model/import_asset_dto.dart +++ b/mobile/openapi/lib/model/import_asset_dto.dart @@ -21,7 +21,7 @@ class ImportAssetDto { required this.fileModifiedAt, this.isArchived, this.isExternal, - required this.isFavorite, + this.isFavorite, this.isOffline, this.isReadOnly = true, this.isVisible, @@ -63,7 +63,13 @@ class ImportAssetDto { /// bool? isExternal; - bool isFavorite; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -127,7 +133,7 @@ class ImportAssetDto { (fileModifiedAt.hashCode) + (isArchived == null ? 0 : isArchived!.hashCode) + (isExternal == null ? 0 : isExternal!.hashCode) + - (isFavorite.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isOffline == null ? 0 : isOffline!.hashCode) + (isReadOnly.hashCode) + (isVisible == null ? 0 : isVisible!.hashCode) + @@ -159,7 +165,11 @@ class ImportAssetDto { } else { // json[r'isExternal'] = null; } + if (this.isFavorite != null) { json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isOffline != null) { json[r'isOffline'] = this.isOffline; } else { @@ -200,7 +210,7 @@ class ImportAssetDto { fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!, isArchived: mapValueOfType(json, r'isArchived'), isExternal: mapValueOfType(json, r'isExternal'), - isFavorite: mapValueOfType(json, r'isFavorite')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isOffline: mapValueOfType(json, r'isOffline'), isReadOnly: mapValueOfType(json, r'isReadOnly') ?? true, isVisible: mapValueOfType(json, r'isVisible'), @@ -258,7 +268,6 @@ class ImportAssetDto { 'deviceId', 'fileCreatedAt', 'fileModifiedAt', - 'isFavorite', }; } diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 453982cf1..179b23bba 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -172,7 +172,7 @@ void main() { // TODO }); - //Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async + //Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String key, String duration, bool isArchived, bool isExternal, bool isFavorite, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async test('test uploadFile', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 75a57043f..1fb05fbcf 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -6655,8 +6655,7 @@ "deviceAssetId", "deviceId", "fileCreatedAt", - "fileModifiedAt", - "isFavorite" + "fileModifiedAt" ], "type": "object" }, @@ -7143,8 +7142,7 @@ "deviceAssetId", "deviceId", "fileCreatedAt", - "fileModifiedAt", - "isFavorite" + "fileModifiedAt" ], "type": "object" }, diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts index de1f7f1bb..0338fe792 100644 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts @@ -22,9 +22,10 @@ export class CreateAssetBase { @Type(() => Date) fileModifiedAt!: Date; + @Optional() @IsBoolean() @Transform(toBoolean) - isFavorite!: boolean; + isFavorite?: boolean; @Optional() @IsBoolean() diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index b9b10e104..a6f8f48e7 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -146,7 +146,6 @@ describe(`${AssetController.name} (e2e)`, () => { { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } }, { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } }, { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } }, - { should: 'require `isFavorite`', dto: { ...makeUploadDto({ omit: 'isFavorite' }) } }, { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } }, { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } }, { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } }, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 9f4d765f2..5a2445846 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1855,7 +1855,7 @@ export interface ImportAssetDto { * @type {boolean} * @memberof ImportAssetDto */ - 'isFavorite': boolean; + 'isFavorite'?: boolean; /** * * @type {boolean} @@ -7868,11 +7868,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} deviceId * @param {string} fileCreatedAt * @param {string} fileModifiedAt - * @param {boolean} isFavorite * @param {string} [key] * @param {string} [duration] * @param {boolean} [isArchived] * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] * @param {boolean} [isOffline] * @param {boolean} [isReadOnly] * @param {boolean} [isVisible] @@ -7882,7 +7882,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {*} [options] Override http request option. * @throws {RequiredError} */ - uploadFile: async (assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options: AxiosRequestConfig = {}): Promise => { + uploadFile: async (assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isFavorite?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'assetData' is not null or undefined assertParamExists('uploadFile', 'assetData', assetData) // verify required parameter 'deviceAssetId' is not null or undefined @@ -7893,8 +7893,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration assertParamExists('uploadFile', 'fileCreatedAt', fileCreatedAt) // verify required parameter 'fileModifiedAt' is not null or undefined assertParamExists('uploadFile', 'fileModifiedAt', fileModifiedAt) - // verify required parameter 'isFavorite' is not null or undefined - assertParamExists('uploadFile', 'isFavorite', isFavorite) const localVarPath = `/asset/upload`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -8335,11 +8333,11 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} deviceId * @param {string} fileCreatedAt * @param {string} fileModifiedAt - * @param {boolean} isFavorite * @param {string} [key] * @param {string} [duration] * @param {boolean} [isArchived] * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] * @param {boolean} [isOffline] * @param {boolean} [isReadOnly] * @param {boolean} [isVisible] @@ -8349,8 +8347,8 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async uploadFile(assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData, options); + async uploadFile(assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isFavorite?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -8626,7 +8624,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(axios, basePath)); + return localVarFp.uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(axios, basePath)); }, }; }; @@ -9274,13 +9272,6 @@ export interface AssetApiUploadFileRequest { */ readonly fileModifiedAt: string - /** - * - * @type {boolean} - * @memberof AssetApiUploadFile - */ - readonly isFavorite: boolean - /** * * @type {string} @@ -9309,6 +9300,13 @@ export interface AssetApiUploadFileRequest { */ readonly isExternal?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiUploadFile + */ + readonly isFavorite?: boolean + /** * * @type {boolean} @@ -9681,7 +9679,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(this.axios, this.basePath)); } } From c74ea7282aa2908a71e02c12045cf08e0abed513 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 6 Nov 2023 09:08:36 -0500 Subject: [PATCH 06/30] docs: python upload guide (#4867) --- docs/docs/guides/python-file-upload.md | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/docs/guides/python-file-upload.md diff --git a/docs/docs/guides/python-file-upload.md b/docs/docs/guides/python-file-upload.md new file mode 100644 index 000000000..dc1be79e0 --- /dev/null +++ b/docs/docs/guides/python-file-upload.md @@ -0,0 +1,42 @@ +# Python File Upload + +```python +#!/usr/bin/python3 + +import requests +import os +from datetime import datetime + +API_KEY = 'YOUR_API_KEY' # replace with a valid api key +BASE_URL = 'http://127.0.0.1:2283/api' # replace as needed + + +def upload(file): + stats = os.stat(file) + + headers = { + 'Accept': 'application/json', + 'x-api-key': API_KEY + } + + data = { + 'deviceAssetId': f'{file}-{stats.st_mtime}', + 'deviceId': 'python', + 'fileCreatedAt': datetime.fromtimestamp(stats.st_mtime), + 'fileModifiedAt': datetime.fromtimestamp(stats.st_mtime), + 'isFavorite': 'false', + } + + files = { + 'assetData': open(file, 'rb') + } + + response = requests.post( + f'{BASE_URL}/asset/upload', headers=headers, data=data, files=files) + + print(response.json()) + # {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False} + + +upload('./test.jpg') +``` From 26fd9d7e5fc9c1fcb30b42b4d42d52c2615fe22f Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 6 Nov 2023 15:46:26 +0000 Subject: [PATCH 07/30] feat(mobile): shared album activities (#4833) * fix(server): global activity like duplicate search * mobile: user_circle_avatar - fallback to text icon if no profile pic available * mobile: use favourite icon in search "your activity" * feat(mobile): shared album activities * mobile: align hearts with user profile icon * styling * replace bottom sheet with dismissible * add auto focus to the input --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mobile/assets/i18n/en-US.json | 5 +- .../activities/models/activity.model.dart | 90 +++++ .../providers/activity.provider.dart | 130 ++++++++ .../activities/services/activity.service.dart | 85 +++++ .../activities/views/activities_page.dart | 312 ++++++++++++++++++ .../modules/album/ui/album_viewer_appbar.dart | 38 +++ .../album/views/album_viewer_page.dart | 14 + .../asset_viewer/ui/top_control_app_bar.dart | 41 +++ .../asset_viewer/views/gallery_viewer.dart | 17 + .../home/ui/asset_grid/immich_asset_grid.dart | 3 + .../ui/asset_grid/immich_asset_grid_view.dart | 3 + .../home/ui/asset_grid/thumbnail_image.dart | 3 + .../lib/modules/search/views/search_page.dart | 2 +- mobile/lib/routing/router.dart | 7 + mobile/lib/routing/router.gr.dart | 88 ++++- mobile/lib/shared/services/api.service.dart | 2 + mobile/lib/shared/ui/user_circle_avatar.dart | 21 +- mobile/lib/utils/datetime_extensions.dart | 36 ++ .../src/domain/activity/activity.service.ts | 1 + .../infra/repositories/activity.repository.ts | 7 +- 20 files changed, 890 insertions(+), 15 deletions(-) create mode 100644 mobile/lib/modules/activities/models/activity.model.dart create mode 100644 mobile/lib/modules/activities/providers/activity.provider.dart create mode 100644 mobile/lib/modules/activities/services/activity.service.dart create mode 100644 mobile/lib/modules/activities/views/activities_page.dart create mode 100644 mobile/lib/utils/datetime_extensions.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 69700269c..f710bec06 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -373,5 +373,8 @@ "viewer_stack_use_as_main_asset": "Use as Main Asset", "app_bar_signout_dialog_title": "Sign out", "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", - "app_bar_signout_dialog_ok": "Yes" + "app_bar_signout_dialog_ok": "Yes", + "shared_album_activities_input_hint": "Say something", + "shared_album_activity_remove_title": "Delete Activity", + "shared_album_activity_remove_content": "Do you want to delete this activity?" } diff --git a/mobile/lib/modules/activities/models/activity.model.dart b/mobile/lib/modules/activities/models/activity.model.dart new file mode 100644 index 000000000..417ba4a86 --- /dev/null +++ b/mobile/lib/modules/activities/models/activity.model.dart @@ -0,0 +1,90 @@ +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:openapi/api.dart'; + +enum ActivityType { comment, like } + +class Activity { + final String id; + final String? assetId; + final String? comment; + final DateTime createdAt; + final ActivityType type; + final User user; + + const Activity({ + required this.id, + this.assetId, + this.comment, + required this.createdAt, + required this.type, + required this.user, + }); + + Activity copyWith({ + String? id, + String? assetId, + String? comment, + DateTime? createdAt, + ActivityType? type, + User? user, + }) { + return Activity( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + comment: comment ?? this.comment, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + user: user ?? this.user, + ); + } + + Activity.fromDto(ActivityResponseDto dto) + : id = dto.id, + assetId = dto.assetId, + comment = dto.comment, + createdAt = dto.createdAt, + type = dto.type == ActivityResponseDtoTypeEnum.comment + ? ActivityType.comment + : ActivityType.like, + user = User( + email: dto.user.email, + firstName: dto.user.firstName, + lastName: dto.user.lastName, + profileImagePath: dto.user.profileImagePath, + id: dto.user.id, + // Placeholder values + isAdmin: false, + updatedAt: DateTime.now(), + isPartnerSharedBy: false, + isPartnerSharedWith: false, + memoryEnabled: false, + ); + + @override + String toString() { + return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Activity && + other.id == id && + other.assetId == assetId && + other.comment == comment && + other.createdAt == createdAt && + other.type == type && + other.user == user; + } + + @override + int get hashCode { + return id.hashCode ^ + assetId.hashCode ^ + comment.hashCode ^ + createdAt.hashCode ^ + type.hashCode ^ + user.hashCode; + } +} diff --git a/mobile/lib/modules/activities/providers/activity.provider.dart b/mobile/lib/modules/activities/providers/activity.provider.dart new file mode 100644 index 000000000..c0fa5e628 --- /dev/null +++ b/mobile/lib/modules/activities/providers/activity.provider.dart @@ -0,0 +1,130 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/models/activity.model.dart'; +import 'package:immich_mobile/modules/activities/services/activity.service.dart'; + +class ActivityNotifier extends StateNotifier>> { + final Ref _ref; + final ActivityService _activityService; + final String albumId; + final String? assetId; + + ActivityNotifier( + this._ref, + this._activityService, + this.albumId, + this.assetId, + ) : super( + const AsyncData([]), + ) { + fetchActivity(); + } + + Future fetchActivity() async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => _activityService.getAllActivities(albumId, assetId), + ); + } + + Future removeActivity(String id) async { + final activities = state.asData?.value ?? []; + if (await _activityService.removeActivity(id)) { + final removedActivity = activities.firstWhere((a) => a.id == id); + activities.remove(removedActivity); + state = AsyncData(activities); + if (removedActivity.type == ActivityType.comment) { + _ref + .read( + activityStatisticsStateProvider( + (albumId: albumId, assetId: assetId), + ).notifier, + ) + .removeActivity(); + } + } + } + + Future addComment(String comment) async { + final activity = await _activityService.addActivity( + albumId, + ActivityType.comment, + assetId: assetId, + comment: comment, + ); + + if (activity != null) { + final activities = state.asData?.value ?? []; + state = AsyncData([...activities, activity]); + _ref + .read( + activityStatisticsStateProvider( + (albumId: albumId, assetId: assetId), + ).notifier, + ) + .addActivity(); + if (assetId != null) { + // Add a count to the current album's provider as well + _ref + .read( + activityStatisticsStateProvider( + (albumId: albumId, assetId: null), + ).notifier, + ) + .addActivity(); + } + } + } + + Future addLike() async { + final activity = await _activityService + .addActivity(albumId, ActivityType.like, assetId: assetId); + if (activity != null) { + final activities = state.asData?.value ?? []; + state = AsyncData([...activities, activity]); + } + } +} + +class ActivityStatisticsNotifier extends StateNotifier { + final String albumId; + final String? assetId; + final ActivityService _activityService; + ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId) + : super(0) { + fetchStatistics(); + } + + Future fetchStatistics() async { + state = await _activityService.getStatistics(albumId, assetId: assetId); + } + + Future addActivity() async { + state = state + 1; + } + + Future removeActivity() async { + state = state - 1; + } +} + +typedef ActivityParams = ({String albumId, String? assetId}); + +final activityStateProvider = StateNotifierProvider.autoDispose + .family>, ActivityParams>( + (ref, args) { + return ActivityNotifier( + ref, + ref.watch(activityServiceProvider), + args.albumId, + args.assetId, + ); +}); + +final activityStatisticsStateProvider = StateNotifierProvider.autoDispose + .family((ref, args) { + return ActivityStatisticsNotifier( + ref.watch(activityServiceProvider), + args.albumId, + args.assetId, + ); +}); diff --git a/mobile/lib/modules/activities/services/activity.service.dart b/mobile/lib/modules/activities/services/activity.service.dart new file mode 100644 index 000000000..fce77a196 --- /dev/null +++ b/mobile/lib/modules/activities/services/activity.service.dart @@ -0,0 +1,85 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/models/activity.model.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final activityServiceProvider = + Provider((ref) => ActivityService(ref.watch(apiServiceProvider))); + +class ActivityService { + final ApiService _apiService; + final Logger _log = Logger("ActivityService"); + + ActivityService(this._apiService); + + Future> getAllActivities( + String albumId, + String? assetId, + ) async { + try { + final list = await _apiService.activityApi + .getActivities(albumId, assetId: assetId); + return list != null ? list.map(Activity.fromDto).toList() : []; + } catch (e) { + _log.severe( + "failed to fetch activities for albumId - $albumId; assetId - $assetId -> $e", + ); + rethrow; + } + } + + Future getStatistics(String albumId, {String? assetId}) async { + try { + final dto = await _apiService.activityApi + .getActivityStatistics(albumId, assetId: assetId); + return dto?.comments ?? 0; + } catch (e) { + _log.severe( + "failed to fetch activity statistics for albumId - $albumId; assetId - $assetId -> $e", + ); + } + return 0; + } + + Future removeActivity(String id) async { + try { + await _apiService.activityApi.deleteActivity(id); + return true; + } catch (e) { + _log.severe( + "failed to remove activity id - $id -> $e", + ); + } + return false; + } + + Future addActivity( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }) async { + try { + final dto = await _apiService.activityApi.createActivity( + ActivityCreateDto( + albumId: albumId, + type: type == ActivityType.comment + ? ReactionType.comment + : ReactionType.like, + assetId: assetId, + comment: comment, + ), + ); + if (dto != null) { + return Activity.fromDto(dto); + } + } catch (e) { + _log.severe( + "failed to add activity for albumId - $albumId; assetId - $assetId -> $e", + ); + } + return null; + } +} diff --git a/mobile/lib/modules/activities/views/activities_page.dart b/mobile/lib/modules/activities/views/activities_page.dart new file mode 100644 index 000000000..69afe2e5d --- /dev/null +++ b/mobile/lib/modules/activities/views/activities_page.dart @@ -0,0 +1,312 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/models/activity.model.dart'; +import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; +import 'package:immich_mobile/utils/datetime_extensions.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class ActivitiesPage extends HookConsumerWidget { + final String albumId; + final String? assetId; + final bool withAssetThumbs; + final String appBarTitle; + final bool isOwner; + const ActivitiesPage( + this.albumId, { + this.appBarTitle = "", + this.assetId, + this.withAssetThumbs = true, + this.isOwner = false, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = + activityStateProvider((albumId: albumId, assetId: assetId)); + final activities = ref.watch(provider); + final inputController = useTextEditingController(); + final inputFocusNode = useFocusNode(); + final listViewScrollController = useScrollController(); + final currentUser = Store.tryGet(StoreKey.currentUser); + + useEffect( + () { + inputFocusNode.requestFocus(); + return null; + }, + [], + ); + buildTitleWithTimestamp(Activity activity, {bool leftAlign = true}) { + final textColor = Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black; + final textStyle = Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: textColor.withOpacity(0.6)); + + return Row( + mainAxisAlignment: leftAlign + ? MainAxisAlignment.start + : MainAxisAlignment.spaceBetween, + mainAxisSize: leftAlign ? MainAxisSize.min : MainAxisSize.max, + children: [ + Text( + "${activity.user.firstName} ${activity.user.lastName}", + style: textStyle, + overflow: TextOverflow.ellipsis, + ), + if (leftAlign) + Text( + " • ", + style: textStyle, + ), + Expanded( + child: Text( + activity.createdAt.copyWith().timeAgo(), + style: textStyle, + overflow: TextOverflow.ellipsis, + textAlign: leftAlign ? TextAlign.left : TextAlign.right, + ), + ), + ], + ); + } + + buildAssetThumbnail(Activity activity) { + return withAssetThumbs && activity.assetId != null + ? Container( + width: 40, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + image: DecorationImage( + image: CachedNetworkImageProvider( + getThumbnailUrlForRemoteId( + activity.assetId!, + ), + cacheKey: getThumbnailCacheKeyForRemoteId( + activity.assetId!, + ), + headers: { + "Authorization": + 'Bearer ${Store.get(StoreKey.accessToken)}', + }, + ), + fit: BoxFit.cover, + ), + ), + child: const SizedBox.shrink(), + ) + : null; + } + + buildTextField(String? likedId) { + final liked = likedId != null; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: inputController, + focusNode: inputFocusNode, + textInputAction: TextInputAction.send, + autofocus: false, + decoration: InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + prefixIcon: currentUser != null + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: UserCircleAvatar( + user: currentUser, + size: 30, + radius: 15, + ), + ) + : null, + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 10), + child: IconButton( + icon: Icon( + liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + ), + onPressed: () async { + liked + ? await ref + .read(provider.notifier) + .removeActivity(likedId) + : await ref.read(provider.notifier).addLike(); + }, + ), + ), + suffixIconColor: liked ? Colors.red[700] : null, + hintText: 'shared_album_activities_input_hint'.tr(), + hintStyle: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + color: Colors.grey[600], + ), + ), + onEditingComplete: () async { + await ref.read(provider.notifier).addComment(inputController.text); + inputController.clear(); + inputFocusNode.unfocus(); + listViewScrollController.animateTo( + listViewScrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 800), + curve: Curves.fastOutSlowIn, + ); + }, + onTapOutside: (_) => inputFocusNode.unfocus(), + ), + ); + } + + getDismissibleWidget( + Widget widget, + Activity activity, + bool canDelete, + ) { + return Dismissible( + key: Key(activity.id), + dismissThresholds: const { + DismissDirection.horizontal: 0.7, + }, + direction: DismissDirection.horizontal, + confirmDismiss: (direction) => canDelete + ? showDialog( + context: context, + builder: (context) => ConfirmDialog( + onOk: () {}, + title: "shared_album_activity_remove_title", + content: "shared_album_activity_remove_content", + ok: "delete_dialog_ok", + ), + ) + : Future.value(false), + onDismissed: (direction) async => + await ref.read(provider.notifier).removeActivity(activity.id), + background: Container( + color: canDelete ? Colors.red[400] : Colors.grey[600], + alignment: AlignmentDirectional.centerStart, + child: canDelete + ? const Padding( + padding: EdgeInsets.all(15), + child: Icon( + Icons.delete_sweep_rounded, + color: Colors.black, + ), + ) + : null, + ), + secondaryBackground: Container( + color: canDelete ? Colors.red[400] : Colors.grey[600], + alignment: AlignmentDirectional.centerEnd, + child: canDelete + ? const Padding( + padding: EdgeInsets.all(15), + child: Icon( + Icons.delete_sweep_rounded, + color: Colors.black, + ), + ) + : null, + ), + child: widget, + ); + } + + return Scaffold( + appBar: AppBar(title: Text(appBarTitle)), + body: activities.maybeWhen( + orElse: () { + return const Center(child: ImmichLoadingIndicator()); + }, + data: (data) { + final liked = data.firstWhereOrNull( + (a) => + a.type == ActivityType.like && + a.user.id == currentUser?.id && + a.assetId == assetId, + ); + + return Stack( + children: [ + ListView.builder( + controller: listViewScrollController, + itemCount: data.length + 1, + itemBuilder: (context, index) { + // Vertical gap after the last element + if (index == data.length) { + return const SizedBox( + height: 80, + ); + } + + final activity = data[index]; + final canDelete = + activity.user.id == currentUser?.id || isOwner; + + return Padding( + padding: const EdgeInsets.all(5), + child: activity.type == ActivityType.comment + ? getDismissibleWidget( + ListTile( + minVerticalPadding: 15, + leading: UserCircleAvatar(user: activity.user), + title: buildTitleWithTimestamp( + activity, + leftAlign: + withAssetThumbs && activity.assetId != null, + ), + titleAlignment: ListTileTitleAlignment.top, + trailing: buildAssetThumbnail(activity), + subtitle: Text(activity.comment!), + ), + activity, + canDelete, + ) + : getDismissibleWidget( + ListTile( + minVerticalPadding: 15, + leading: Container( + width: 44, + alignment: Alignment.center, + child: Icon( + Icons.favorite_rounded, + color: Colors.red[700], + ), + ), + title: buildTitleWithTimestamp(activity), + trailing: buildAssetThumbnail(activity), + ), + activity, + canDelete, + ), + ); + }, + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: buildTextField(liked?.id), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 221603ed9..05db82e10 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; @@ -26,6 +27,7 @@ class AlbumViewerAppbar extends HookConsumerWidget required this.titleFocusNode, this.onAddPhotos, this.onAddUsers, + required this.onActivities, }) : super(key: key); final Album album; @@ -35,11 +37,19 @@ class AlbumViewerAppbar extends HookConsumerWidget final FocusNode titleFocusNode; final Function(Album album)? onAddPhotos; final Function(Album album)? onAddUsers; + final Function(Album album) onActivities; @override Widget build(BuildContext context, WidgetRef ref) { final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; + final comments = album.shared + ? ref.watch( + activityStatisticsStateProvider( + (albumId: album.remoteId!, assetId: null), + ), + ) + : 0; deleteAlbum() async { ImmichLoadingOverlayController.appLoader.show(); @@ -310,6 +320,33 @@ class AlbumViewerAppbar extends HookConsumerWidget ); } + Widget buildActivitiesButton() { + return IconButton( + onPressed: () { + onActivities(album); + }, + icon: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.mode_comment_outlined, + ), + if (comments != 0) + Padding( + padding: const EdgeInsets.only(left: 5), + child: Text( + comments.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + ), + ], + ), + ); + } + buildLeadingButton() { if (selected.isNotEmpty) { return IconButton( @@ -353,6 +390,7 @@ class AlbumViewerAppbar extends HookConsumerWidget title: selected.isNotEmpty ? Text('${selected.length}') : null, centerTitle: false, actions: [ + if (album.shared) buildActivitiesButton(), if (album.isRemote) IconButton( splashRadius: 25, diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 12ef332f4..dc30b3718 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -232,6 +232,18 @@ class AlbumViewerPage extends HookConsumerWidget { ); } + onActivitiesPressed(Album album) { + if (album.remoteId != null) { + AutoRouter.of(context).push( + ActivitiesRoute( + albumId: album.remoteId!, + appBarTitle: album.name, + isOwner: userId == album.ownerId, + ), + ); + } + } + return Scaffold( appBar: album.when( data: (data) => AlbumViewerAppbar( @@ -242,6 +254,7 @@ class AlbumViewerPage extends HookConsumerWidget { selectionDisabled: disableSelection, onAddPhotos: onAddPhotosPressed, onAddUsers: onAddUsersPressed, + onActivities: onActivitiesPressed, ), error: (error, stackTrace) => AppBar(title: const Text("Error")), loading: () => AppBar(), @@ -266,6 +279,7 @@ class AlbumViewerPage extends HookConsumerWidget { ], ), isOwner: userId == data.ownerId, + sharedAlbumId: data.remoteId, ), ), ), diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 69d2be9f1..95965f6d8 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; @@ -16,6 +17,8 @@ class TopControlAppBar extends HookConsumerWidget { required this.onFavorite, required this.onUploadPressed, required this.isOwner, + required this.shareAlbumId, + required this.onActivitiesPressed, }) : super(key: key); final Asset asset; @@ -24,14 +27,23 @@ class TopControlAppBar extends HookConsumerWidget { final VoidCallback? onDownloadPressed; final VoidCallback onToggleMotionVideo; final VoidCallback onAddToAlbumPressed; + final VoidCallback onActivitiesPressed; final Function(Asset) onFavorite; final bool isPlayingMotionVideo; final bool isOwner; + final String? shareAlbumId; @override Widget build(BuildContext context, WidgetRef ref) { const double iconSize = 22.0; final a = ref.watch(assetWatcher(asset)).value ?? asset; + final comments = shareAlbumId != null + ? ref.watch( + activityStatisticsStateProvider( + (albumId: shareAlbumId!, assetId: asset.remoteId), + ), + ) + : 0; Widget buildFavoriteButton(a) { return IconButton( @@ -94,6 +106,34 @@ class TopControlAppBar extends HookConsumerWidget { ); } + Widget buildActivitiesButton() { + return IconButton( + onPressed: () { + onActivitiesPressed(); + }, + icon: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.mode_comment_outlined, + color: Colors.grey[200], + ), + if (comments != 0) + Padding( + padding: const EdgeInsets.only(left: 5), + child: Text( + comments.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[200], + ), + ), + ), + ], + ), + ); + } + Widget buildUploadButton() { return IconButton( onPressed: onUploadPressed, @@ -130,6 +170,7 @@ class TopControlAppBar extends HookConsumerWidget { if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && isOwner) buildAddToAlbumButtom(), + if (shareAlbumId != null) buildActivitiesButton(), buildMoreInfoButton(), ], ); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 6a355a64e..f8acef588 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -49,6 +49,7 @@ class GalleryViewerPage extends HookConsumerWidget { final int heroOffset; final bool showStack; final bool isOwner; + final String? sharedAlbumId; GalleryViewerPage({ super.key, @@ -58,6 +59,7 @@ class GalleryViewerPage extends HookConsumerWidget { this.heroOffset = 0, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @@ -327,6 +329,19 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + handleActivities() { + if (sharedAlbumId != null) { + AutoRouter.of(context).push( + ActivitiesRoute( + albumId: sharedAlbumId!, + assetId: asset().remoteId, + withAssetThumbs: false, + isOwner: isOwner, + ), + ); + } + } + buildAppBar() { return IgnorePointer( ignoring: !ref.watch(showControlsProvider), @@ -355,6 +370,8 @@ class GalleryViewerPage extends HookConsumerWidget { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), onAddToAlbumPressed: () => addToAlbum(asset()), + shareAlbumId: sharedAlbumId, + onActivitiesPressed: handleActivities, ), ), ), diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 50f6f3f71..2c0f63394 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -34,6 +34,7 @@ class ImmichAssetGrid extends HookConsumerWidget { final bool showDragScroll; final bool showStack; final bool isOwner; + final String? sharedAlbumId; const ImmichAssetGrid({ super.key, @@ -55,6 +56,7 @@ class ImmichAssetGrid extends HookConsumerWidget { this.showDragScroll = true, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, }); @override @@ -120,6 +122,7 @@ class ImmichAssetGrid extends HookConsumerWidget { showDragScroll: showDragScroll, showStack: showStack, isOwner: isOwner, + sharedAlbumId: sharedAlbumId, ), ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 822a6329a..3b900a5f1 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -39,6 +39,7 @@ class ImmichAssetGridView extends StatefulWidget { final bool showDragScroll; final bool showStack; final bool isOwner; + final String? sharedAlbumId; const ImmichAssetGridView({ super.key, @@ -60,6 +61,7 @@ class ImmichAssetGridView extends StatefulWidget { this.showDragScroll = true, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, }); @override @@ -141,6 +143,7 @@ class ImmichAssetGridViewState extends State { heroOffset: widget.heroOffset, showStack: widget.showStack, isOwner: widget.isOwner, + sharedAlbumId: widget.sharedAlbumId, ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 2450e4439..16423b3b4 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -21,6 +21,7 @@ class ThumbnailImage extends StatelessWidget { final Function? onSelect; final Function? onDeselect; final int heroOffset; + final String? sharedAlbumId; const ThumbnailImage({ Key? key, @@ -31,6 +32,7 @@ class ThumbnailImage extends StatelessWidget { this.showStorageIndicator = true, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, this.useGrayBoxPlaceholder = false, this.isSelected = false, this.multiselectEnabled = false, @@ -184,6 +186,7 @@ class ThumbnailImage extends StatelessWidget { heroOffset: heroOffset, showStack: showStack, isOwner: isOwner, + sharedAlbumId: sharedAlbumId, ), ); } diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index 1110555a3..41984106d 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -172,7 +172,7 @@ class SearchPage extends HookConsumerWidget { ), ListTile( leading: Icon( - Icons.star_outline, + Icons.favorite_border_rounded, color: categoryIconColor, ), title: diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index a4cc2401f..01d54082e 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/views/activities_page.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/views/album_options_part.dart'; import 'package:immich_mobile/modules/album/views/album_viewer_page.dart'; @@ -160,6 +161,12 @@ part 'router.gr.dart'; AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]), + CustomRoute( + page: ActivitiesPage, + guards: [AuthGuard, DuplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + durationInMilliseconds: 200, + ), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b5b5b773a..885b55643 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -73,6 +73,7 @@ class _$AppRouter extends RootStackRouter { heroOffset: args.heroOffset, showStack: args.showStack, isOwner: args.isOwner, + sharedAlbumId: args.sharedAlbumId, ), ); }, @@ -337,6 +338,24 @@ class _$AppRouter extends RootStackRouter { ), ); }, + ActivitiesRoute.name: (routeData) { + final args = routeData.argsAs(); + return CustomPage( + routeData: routeData, + child: ActivitiesPage( + args.albumId, + appBarTitle: args.appBarTitle, + assetId: args.assetId, + withAssetThumbs: args.withAssetThumbs, + isOwner: args.isOwner, + key: args.key, + ), + transitionsBuilder: TransitionsBuilders.slideLeft, + durationInMilliseconds: 200, + opaque: true, + barrierDismissible: false, + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( routeData: routeData, @@ -674,6 +693,14 @@ class _$AppRouter extends RootStackRouter { duplicateGuard, ], ), + RouteConfig( + ActivitiesRoute.name, + path: '/activities-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), ]; } @@ -749,6 +776,7 @@ class GalleryViewerRoute extends PageRouteInfo { int heroOffset = 0, bool showStack = false, bool isOwner = true, + String? sharedAlbumId, }) : super( GalleryViewerRoute.name, path: '/gallery-viewer-page', @@ -760,6 +788,7 @@ class GalleryViewerRoute extends PageRouteInfo { heroOffset: heroOffset, showStack: showStack, isOwner: isOwner, + sharedAlbumId: sharedAlbumId, ), ); @@ -775,6 +804,7 @@ class GalleryViewerRouteArgs { this.heroOffset = 0, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, }); final Key? key; @@ -791,9 +821,11 @@ class GalleryViewerRouteArgs { final bool isOwner; + final String? sharedAlbumId; + @override String toString() { - return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner}'; + return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner, sharedAlbumId: $sharedAlbumId}'; } } @@ -1527,6 +1559,60 @@ class SharedLinkEditRouteArgs { } } +/// generated route for +/// [ActivitiesPage] +class ActivitiesRoute extends PageRouteInfo { + ActivitiesRoute({ + required String albumId, + String appBarTitle = "", + String? assetId, + bool withAssetThumbs = true, + bool isOwner = false, + Key? key, + }) : super( + ActivitiesRoute.name, + path: '/activities-page', + args: ActivitiesRouteArgs( + albumId: albumId, + appBarTitle: appBarTitle, + assetId: assetId, + withAssetThumbs: withAssetThumbs, + isOwner: isOwner, + key: key, + ), + ); + + static const String name = 'ActivitiesRoute'; +} + +class ActivitiesRouteArgs { + const ActivitiesRouteArgs({ + required this.albumId, + this.appBarTitle = "", + this.assetId, + this.withAssetThumbs = true, + this.isOwner = false, + this.key, + }); + + final String albumId; + + final String appBarTitle; + + final String? assetId; + + final bool withAssetThumbs; + + final bool isOwner; + + final Key? key; + + @override + String toString() { + return 'ActivitiesRouteArgs{albumId: $albumId, appBarTitle: $appBarTitle, assetId: $assetId, withAssetThumbs: $withAssetThumbs, isOwner: $isOwner, key: $key}'; + } +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index 7c1dfc8fc..2f422515d 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -22,6 +22,7 @@ class ApiService { late PersonApi personApi; late AuditApi auditApi; late SharedLinkApi sharedLinkApi; + late ActivityApi activityApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -47,6 +48,7 @@ class ApiService { personApi = PersonApi(_apiClient); auditApi = AuditApi(_apiClient); sharedLinkApi = SharedLinkApi(_apiClient); + activityApi = ActivityApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/shared/ui/user_circle_avatar.dart b/mobile/lib/shared/ui/user_circle_avatar.dart index b70566d88..df50d5071 100644 --- a/mobile/lib/shared/ui/user_circle_avatar.dart +++ b/mobile/lib/shared/ui/user_circle_avatar.dart @@ -40,19 +40,23 @@ class UserCircleAvatar extends ConsumerWidget { final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}'; + + final textIcon = Text( + user.firstName[0].toUpperCase(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.black + : Colors.white, + ), + ); return CircleAvatar( backgroundColor: useRandomBackgroundColor ? randomColors[Random().nextInt(randomColors.length)] : Theme.of(context).primaryColor, radius: radius, child: user.profileImagePath == "" - ? Text( - user.firstName[0].toUpperCase(), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ) + ? textIcon : ClipRRect( borderRadius: BorderRadius.circular(50), child: CachedNetworkImage( @@ -66,8 +70,7 @@ class UserCircleAvatar extends ConsumerWidget { "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}", }, fadeInDuration: const Duration(milliseconds: 300), - errorWidget: (context, error, stackTrace) => - Image.memory(kTransparentImage), + errorWidget: (context, error, stackTrace) => textIcon, ), ), ); diff --git a/mobile/lib/utils/datetime_extensions.dart b/mobile/lib/utils/datetime_extensions.dart new file mode 100644 index 000000000..d91837771 --- /dev/null +++ b/mobile/lib/utils/datetime_extensions.dart @@ -0,0 +1,36 @@ +extension TimeAgoExtension on DateTime { + String timeAgo({bool numericDates = true}) { + DateTime date = toLocal(); + final date2 = DateTime.now().toLocal(); + final difference = date2.difference(date); + + if (difference.inSeconds < 5) { + return 'Just now'; + } else if (difference.inSeconds < 60) { + return '${difference.inSeconds} seconds ago'; + } else if (difference.inMinutes <= 1) { + return (numericDates) ? '1 minute ago' : 'A minute ago'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes} minutes ago'; + } else if (difference.inHours <= 1) { + return (numericDates) ? '1 hour ago' : 'An hour ago'; + } else if (difference.inHours < 60) { + return '${difference.inHours} hours ago'; + } else if (difference.inDays <= 1) { + return (numericDates) ? '1 day ago' : 'Yesterday'; + } else if (difference.inDays < 6) { + return '${difference.inDays} days ago'; + } else if ((difference.inDays / 7).ceil() <= 1) { + return (numericDates) ? '1 week ago' : 'Last week'; + } else if ((difference.inDays / 7).ceil() < 4) { + return '${(difference.inDays / 7).ceil()} weeks ago'; + } else if ((difference.inDays / 30).ceil() <= 1) { + return (numericDates) ? '1 month ago' : 'Last month'; + } else if ((difference.inDays / 30).ceil() < 30) { + return '${(difference.inDays / 30).ceil()} months ago'; + } else if ((difference.inDays / 365).ceil() <= 1) { + return (numericDates) ? '1 year ago' : 'Last year'; + } + return '${(difference.inDays / 365).floor()} years ago'; + } +} diff --git a/server/src/domain/activity/activity.service.ts b/server/src/domain/activity/activity.service.ts index bacdab317..44362f3fc 100644 --- a/server/src/domain/activity/activity.service.ts +++ b/server/src/domain/activity/activity.service.ts @@ -58,6 +58,7 @@ export class ActivityService { delete dto.comment; [activity] = await this.repository.search({ ...common, + isGlobal: !dto.assetId, isLiked: true, }); duplicate = !!activity; diff --git a/server/src/infra/repositories/activity.repository.ts b/server/src/infra/repositories/activity.repository.ts index 271124db5..138d96381 100644 --- a/server/src/infra/repositories/activity.repository.ts +++ b/server/src/infra/repositories/activity.repository.ts @@ -1,7 +1,7 @@ import { IActivityRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { ActivityEntity } from '../entities/activity.entity'; export interface ActivitySearch { @@ -9,6 +9,7 @@ export interface ActivitySearch { assetId?: string; userId?: string; isLiked?: boolean; + isGlobal?: boolean; } @Injectable() @@ -16,11 +17,11 @@ export class ActivityRepository implements IActivityRepository { constructor(@InjectRepository(ActivityEntity) private repository: Repository) {} search(options: ActivitySearch): Promise { - const { userId, assetId, albumId, isLiked } = options; + const { userId, assetId, albumId, isLiked, isGlobal } = options; return this.repository.find({ where: { userId, - assetId, + assetId: isGlobal ? IsNull() : assetId, albumId, isLiked, }, From 21f2d3058a799d972bc9f218ccc5709334609a89 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:40:43 +0100 Subject: [PATCH 08/30] feat(mobile)!: batched full/initial sync (#4840) * feat(mobile): batched full/initial sync * use OptionalBetween * skip/take as integer --------- Co-authored-by: Fynn Petersen-Frey Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 56 ++++++++++++++----- mobile/lib/shared/services/asset.service.dart | 35 ++++++++---- mobile/lib/shared/services/sync.service.dart | 12 +++- mobile/openapi/doc/AssetApi.md | 12 ++-- mobile/openapi/lib/api/asset_api.dart | 34 +++++++---- mobile/openapi/test/asset_api_test.dart | 2 +- server/immich-openapi-specs.json | 23 +++++++- .../immich/api-v1/asset/asset-repository.ts | 5 +- .../api-v1/asset/dto/asset-search.dto.ts | 17 +++++- web/src/api/open-api/api.ts | 56 ++++++++++++++----- 10 files changed, 189 insertions(+), 63 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 5a2445846..9265d439f 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -6695,16 +6695,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * Get all AssetEntity belong to the user + * @param {number} [skip] + * @param {number} [take] * @param {string} [userId] * @param {boolean} [isFavorite] * @param {boolean} [isArchived] - * @param {number} [skip] * @param {string} [updatedAfter] + * @param {string} [updatedBefore] * @param {string} [ifNoneMatch] ETag of data already cached on the client * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, updatedAfter?: string, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise => { + getAllAssets: async (skip?: number, take?: number, userId?: string, isFavorite?: boolean, isArchived?: boolean, updatedAfter?: string, updatedBefore?: string, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/asset`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6726,6 +6728,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (skip !== undefined) { + localVarQueryParameter['skip'] = skip; + } + + if (take !== undefined) { + localVarQueryParameter['take'] = take; + } + if (userId !== undefined) { localVarQueryParameter['userId'] = userId; } @@ -6738,16 +6748,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isArchived'] = isArchived; } - if (skip !== undefined) { - localVarQueryParameter['skip'] = skip; - } - if (updatedAfter !== undefined) { localVarQueryParameter['updatedAfter'] = (updatedAfter as any instanceof Date) ? (updatedAfter as any).toISOString() : updatedAfter; } + if (updatedBefore !== undefined) { + localVarQueryParameter['updatedBefore'] = (updatedBefore as any instanceof Date) ? + (updatedBefore as any).toISOString() : + updatedBefore; + } + if (ifNoneMatch != null) { localVarHeaderParameter['if-none-match'] = String(ifNoneMatch); } @@ -8066,17 +8078,19 @@ export const AssetApiFp = function(configuration?: Configuration) { }, /** * Get all AssetEntity belong to the user + * @param {number} [skip] + * @param {number} [take] * @param {string} [userId] * @param {boolean} [isFavorite] * @param {boolean} [isArchived] - * @param {number} [skip] * @param {string} [updatedAfter] + * @param {string} [updatedBefore] * @param {string} [ifNoneMatch] ETag of data already cached on the client * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, updatedAfter?: string, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch, options); + async getAllAssets(skip?: number, take?: number, userId?: string, isFavorite?: boolean, isArchived?: boolean, updatedAfter?: string, updatedBefore?: string, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -8421,7 +8435,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.skip, requestParameters.updatedAfter, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath)); + return localVarFp.getAllAssets(requestParameters.skip, requestParameters.take, requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath)); }, /** * Get a single asset\'s information @@ -8719,6 +8733,20 @@ export interface AssetApiDownloadFileRequest { * @interface AssetApiGetAllAssetsRequest */ export interface AssetApiGetAllAssetsRequest { + /** + * + * @type {number} + * @memberof AssetApiGetAllAssets + */ + readonly skip?: number + + /** + * + * @type {number} + * @memberof AssetApiGetAllAssets + */ + readonly take?: number + /** * * @type {string} @@ -8742,17 +8770,17 @@ export interface AssetApiGetAllAssetsRequest { /** * - * @type {number} + * @type {string} * @memberof AssetApiGetAllAssets */ - readonly skip?: number + readonly updatedAfter?: string /** * * @type {string} * @memberof AssetApiGetAllAssets */ - readonly updatedAfter?: string + readonly updatedBefore?: string /** * ETag of data already cached on the client @@ -9430,7 +9458,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.skip, requestParameters.updatedAfter, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getAllAssets(requestParameters.skip, requestParameters.take, requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 488395b16..8b1ee6a33 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -62,20 +62,31 @@ class AssetService { /// Returns `null` if the server state did not change, else list of assets Future?> _getRemoteAssets(User user) async { + const int chunkSize = 5000; try { - final List? assets = - await _apiService.assetApi.getAllAssets( - userId: user.id, - ); - if (assets == null) { - return null; - } else if (assets.isNotEmpty && assets.first.ownerId != user.id) { - log.warning("Make sure that server and app versions match!" - " The server returned assets for user ${assets.first.ownerId}" - " while requesting assets of user ${user.id}"); - return null; + final DateTime now = DateTime.now().toUtc(); + final List allAssets = []; + for (int i = 0;; i += chunkSize) { + final List? assets = + await _apiService.assetApi.getAllAssets( + userId: user.id, + // updatedBefore is important! without it we could + // a) get the same Asset multiple times in different versions (when + // the asset is modified while the chunks are loaded from the server) + // b) miss assets when new assets are inserted in between the calls + updatedBefore: now, + skip: i, + take: chunkSize, + ); + if (assets == null) { + return null; + } + allAssets.addAll(assets.map(Asset.remote)); + if (assets.length < chunkSize) { + break; + } } - return assets.map(Asset.remote).toList(); + return allAssets; } catch (error, stack) { log.severe( 'Error while getting remote assets: ${error.toString()}', diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart index 34d84401a..19fc076e4 100644 --- a/mobile/lib/shared/services/sync.service.dart +++ b/mobile/lib/shared/services/sync.service.dart @@ -197,7 +197,7 @@ class SyncService { User user, FutureOr?> Function(User user) loadAssets, ) async { - final DateTime now = DateTime.now(); + final DateTime now = DateTime.now().toUtc(); final List? remote = await loadAssets(user); if (remote == null) { return false; @@ -210,6 +210,10 @@ class SyncService { assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); remote.sort(Asset.compareByChecksum); + + // filter our duplicates that might be introduced by the chunked retrieval + remote.uniqueConsecutive(compare: Asset.compareByChecksum); + final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true); if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) { await _updateUserAssetsETag(user, now); @@ -759,6 +763,12 @@ class SyncService { final List toAdd = []; final List toUpdate = []; final List toRemove = []; + if (assets.isEmpty || inDb.isEmpty) { + // fast path for trivial cases: halfes memory usage during initial sync + return assets.isEmpty + ? (toAdd, toUpdate, inDb) // remove all from DB + : (assets, toUpdate, toRemove); // add all assets + } diffSortedListsSync( inDb, assets, diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index e072c3ded..811841947 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -374,7 +374,7 @@ void (empty response body) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getAllAssets** -> List getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch) +> List getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch) @@ -399,15 +399,17 @@ import 'package:openapi/api.dart'; //defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = AssetApi(); +final skip = 56; // int | +final take = 56; // int | final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final isFavorite = true; // bool | final isArchived = true; // bool | -final skip = 8.14; // num | final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final updatedBefore = 2013-10-20T19:20:30+01:00; // DateTime | final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client try { - final result = api_instance.getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch); + final result = api_instance.getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch); print(result); } catch (e) { print('Exception when calling AssetApi->getAllAssets: $e\n'); @@ -418,11 +420,13 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- + **skip** | **int**| | [optional] + **take** | **int**| | [optional] **userId** | **String**| | [optional] **isFavorite** | **bool**| | [optional] **isArchived** | **bool**| | [optional] - **skip** | **num**| | [optional] **updatedAfter** | **DateTime**| | [optional] + **updatedBefore** | **DateTime**| | [optional] **ifNoneMatch** | **String**| ETag of data already cached on the client | [optional] ### Return type diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 4b74d2933..0d3c2bfe8 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -309,19 +309,23 @@ class AssetApi { /// /// Parameters: /// + /// * [int] skip: + /// + /// * [int] take: + /// /// * [String] userId: /// /// * [bool] isFavorite: /// /// * [bool] isArchived: /// - /// * [num] skip: - /// /// * [DateTime] updatedAfter: /// + /// * [DateTime] updatedBefore: + /// /// * [String] ifNoneMatch: /// ETag of data already cached on the client - Future getAllAssetsWithHttpInfo({ String? userId, bool? isFavorite, bool? isArchived, num? skip, DateTime? updatedAfter, String? ifNoneMatch, }) async { + Future getAllAssetsWithHttpInfo({ int? skip, int? take, String? userId, bool? isFavorite, bool? isArchived, DateTime? updatedAfter, DateTime? updatedBefore, String? ifNoneMatch, }) async { // ignore: prefer_const_declarations final path = r'/asset'; @@ -332,6 +336,12 @@ class AssetApi { final headerParams = {}; final formParams = {}; + if (skip != null) { + queryParams.addAll(_queryParams('', 'skip', skip)); + } + if (take != null) { + queryParams.addAll(_queryParams('', 'take', take)); + } if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); } @@ -341,12 +351,12 @@ class AssetApi { if (isArchived != null) { queryParams.addAll(_queryParams('', 'isArchived', isArchived)); } - if (skip != null) { - queryParams.addAll(_queryParams('', 'skip', skip)); - } if (updatedAfter != null) { queryParams.addAll(_queryParams('', 'updatedAfter', updatedAfter)); } + if (updatedBefore != null) { + queryParams.addAll(_queryParams('', 'updatedBefore', updatedBefore)); + } if (ifNoneMatch != null) { headerParams[r'if-none-match'] = parameterToString(ifNoneMatch); @@ -370,20 +380,24 @@ class AssetApi { /// /// Parameters: /// + /// * [int] skip: + /// + /// * [int] take: + /// /// * [String] userId: /// /// * [bool] isFavorite: /// /// * [bool] isArchived: /// - /// * [num] skip: - /// /// * [DateTime] updatedAfter: /// + /// * [DateTime] updatedBefore: + /// /// * [String] ifNoneMatch: /// ETag of data already cached on the client - Future?> getAllAssets({ String? userId, bool? isFavorite, bool? isArchived, num? skip, DateTime? updatedAfter, String? ifNoneMatch, }) async { - final response = await getAllAssetsWithHttpInfo( userId: userId, isFavorite: isFavorite, isArchived: isArchived, skip: skip, updatedAfter: updatedAfter, ifNoneMatch: ifNoneMatch, ); + Future?> getAllAssets({ int? skip, int? take, String? userId, bool? isFavorite, bool? isArchived, DateTime? updatedAfter, DateTime? updatedBefore, String? ifNoneMatch, }) async { + final response = await getAllAssetsWithHttpInfo( skip: skip, take: take, userId: userId, isFavorite: isFavorite, isArchived: isArchived, updatedAfter: updatedAfter, updatedBefore: updatedBefore, ifNoneMatch: ifNoneMatch, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 179b23bba..e241c34ca 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -53,7 +53,7 @@ void main() { // Get all AssetEntity belong to the user // - //Future> getAllAssets({ String userId, bool isFavorite, bool isArchived, num skip, DateTime updatedAfter, String ifNoneMatch }) async + //Future> getAllAssets({ int skip, int take, String userId, bool isFavorite, bool isArchived, DateTime updatedAfter, DateTime updatedBefore, String ifNoneMatch }) async test('test getAllAssets', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 1fb05fbcf..05aec878a 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -914,6 +914,22 @@ "description": "Get all AssetEntity belong to the user", "operationId": "getAllAssets", "parameters": [ + { + "name": "skip", + "required": false, + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "schema": { + "type": "integer" + } + }, { "name": "userId", "required": false, @@ -940,15 +956,16 @@ } }, { - "name": "skip", + "name": "updatedAfter", "required": false, "in": "query", "schema": { - "type": "number" + "format": "date-time", + "type": "string" } }, { - "name": "updatedAfter", + "name": "updatedBefore", "required": false, "in": "query", "schema": { diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index 9dac7e604..13cf6bf17 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -1,8 +1,8 @@ import { AssetCreate } from '@app/domain'; import { AssetEntity } from '@app/infra/entities'; +import OptionalBetween from '@app/infra/utils/optional-between.util'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { MoreThan } from 'typeorm'; import { In } from 'typeorm/find-options/operator/In'; import { Repository } from 'typeorm/repository/Repository'; import { AssetSearchDto } from './dto/asset-search.dto'; @@ -129,7 +129,7 @@ export class AssetRepository implements IAssetRepository { isVisible: true, isFavorite: dto.isFavorite, isArchived: dto.isArchived, - updatedAt: dto.updatedAfter ? MoreThan(dto.updatedAfter) : undefined, + updatedAt: OptionalBetween(dto.updatedAfter, dto.updatedBefore), }, relations: { exifInfo: true, @@ -137,6 +137,7 @@ export class AssetRepository implements IAssetRepository { stack: true, }, skip: dto.skip || 0, + take: dto.take, order: { fileCreatedAt: 'DESC', }, diff --git a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts index 3067edebe..d73856ab9 100644 --- a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts +++ b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts @@ -1,7 +1,7 @@ import { Optional, toBoolean } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsDate, IsNotEmpty, IsNumber, IsUUID } from 'class-validator'; +import { IsBoolean, IsDate, IsInt, IsNotEmpty, IsUUID } from 'class-validator'; export class AssetSearchDto { @Optional() @@ -17,9 +17,17 @@ export class AssetSearchDto { isArchived?: boolean; @Optional() - @IsNumber() + @IsInt() + @Type(() => Number) + @ApiProperty({ type: 'integer' }) skip?: number; + @Optional() + @IsInt() + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + take?: number; + @Optional() @IsUUID('4') @ApiProperty({ format: 'uuid' }) @@ -29,4 +37,9 @@ export class AssetSearchDto { @IsDate() @Type(() => Date) updatedAfter?: Date; + + @Optional() + @IsDate() + @Type(() => Date) + updatedBefore?: Date; } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 5a2445846..9265d439f 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -6695,16 +6695,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * Get all AssetEntity belong to the user + * @param {number} [skip] + * @param {number} [take] * @param {string} [userId] * @param {boolean} [isFavorite] * @param {boolean} [isArchived] - * @param {number} [skip] * @param {string} [updatedAfter] + * @param {string} [updatedBefore] * @param {string} [ifNoneMatch] ETag of data already cached on the client * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, updatedAfter?: string, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise => { + getAllAssets: async (skip?: number, take?: number, userId?: string, isFavorite?: boolean, isArchived?: boolean, updatedAfter?: string, updatedBefore?: string, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/asset`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6726,6 +6728,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (skip !== undefined) { + localVarQueryParameter['skip'] = skip; + } + + if (take !== undefined) { + localVarQueryParameter['take'] = take; + } + if (userId !== undefined) { localVarQueryParameter['userId'] = userId; } @@ -6738,16 +6748,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isArchived'] = isArchived; } - if (skip !== undefined) { - localVarQueryParameter['skip'] = skip; - } - if (updatedAfter !== undefined) { localVarQueryParameter['updatedAfter'] = (updatedAfter as any instanceof Date) ? (updatedAfter as any).toISOString() : updatedAfter; } + if (updatedBefore !== undefined) { + localVarQueryParameter['updatedBefore'] = (updatedBefore as any instanceof Date) ? + (updatedBefore as any).toISOString() : + updatedBefore; + } + if (ifNoneMatch != null) { localVarHeaderParameter['if-none-match'] = String(ifNoneMatch); } @@ -8066,17 +8078,19 @@ export const AssetApiFp = function(configuration?: Configuration) { }, /** * Get all AssetEntity belong to the user + * @param {number} [skip] + * @param {number} [take] * @param {string} [userId] * @param {boolean} [isFavorite] * @param {boolean} [isArchived] - * @param {number} [skip] * @param {string} [updatedAfter] + * @param {string} [updatedBefore] * @param {string} [ifNoneMatch] ETag of data already cached on the client * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, updatedAfter?: string, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch, options); + async getAllAssets(skip?: number, take?: number, userId?: string, isFavorite?: boolean, isArchived?: boolean, updatedAfter?: string, updatedBefore?: string, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -8421,7 +8435,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.skip, requestParameters.updatedAfter, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath)); + return localVarFp.getAllAssets(requestParameters.skip, requestParameters.take, requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath)); }, /** * Get a single asset\'s information @@ -8719,6 +8733,20 @@ export interface AssetApiDownloadFileRequest { * @interface AssetApiGetAllAssetsRequest */ export interface AssetApiGetAllAssetsRequest { + /** + * + * @type {number} + * @memberof AssetApiGetAllAssets + */ + readonly skip?: number + + /** + * + * @type {number} + * @memberof AssetApiGetAllAssets + */ + readonly take?: number + /** * * @type {string} @@ -8742,17 +8770,17 @@ export interface AssetApiGetAllAssetsRequest { /** * - * @type {number} + * @type {string} * @memberof AssetApiGetAllAssets */ - readonly skip?: number + readonly updatedAfter?: string /** * * @type {string} * @memberof AssetApiGetAllAssets */ - readonly updatedAfter?: string + readonly updatedBefore?: string /** * ETag of data already cached on the client @@ -9430,7 +9458,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.skip, requestParameters.updatedAfter, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getAllAssets(requestParameters.skip, requestParameters.take, requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath)); } /** From ace0a5911c9c153265c6a78f03b038a0b8de2caf Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 6 Nov 2023 23:02:46 -0500 Subject: [PATCH 09/30] fix(web): optimize deps (#4877) --- web/vite.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/vite.config.js b/web/vite.config.js index 2e9e32a03..4406c0ad0 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -24,7 +24,7 @@ const config = { }, plugins: [sveltekit()], optimizeDeps: { - entries: ['src/**/*.{svelte, ts, html}'], + entries: ['src/**/*.{svelte,ts,html}'], }, }; From 9d01885b584a370b492f22dc7d5e10dca33ef5f8 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 7 Nov 2023 05:37:21 +0100 Subject: [PATCH 10/30] feat(server, web): Album's options (#4870) * feat: disable activity * fix: disable reactions * fix: tests * fix: tests * fix: tests * pr feedback * pr feedback * chore: styling & wording * refactor component --------- Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 12 +++ mobile/openapi/doc/AlbumResponseDto.md | 1 + mobile/openapi/doc/UpdateAlbumDto.md | 1 + .../openapi/lib/model/album_response_dto.dart | 10 ++- .../openapi/lib/model/update_album_dto.dart | 23 +++++- .../openapi/test/album_response_dto_test.dart | 5 ++ .../openapi/test/update_album_dto_test.dart | 5 ++ server/immich-openapi-specs.json | 9 ++- server/src/domain/access/access.core.ts | 5 +- server/src/domain/activity/activity.spec.ts | 20 ++++- server/src/domain/album/album-response.dto.ts | 2 + server/src/domain/album/album.service.ts | 2 +- .../src/domain/album/dto/album-update.dto.ts | 6 +- .../domain/repositories/access.repository.ts | 5 +- server/src/infra/entities/album.entity.ts | 3 + .../1699268680508-DisableActivity.ts | 14 ++++ .../infra/repositories/access.repository.ts | 18 +++++ server/test/e2e/album.e2e-spec.ts | 1 + server/test/fixtures/album.stub.ts | 10 +++ server/test/fixtures/shared-link.stub.ts | 2 + .../repositories/access.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 12 +++ .../album-page/album-options.svelte | 76 +++++++++++++++++++ .../asset-viewer/activity-status.svelte | 3 +- .../asset-viewer/activity-viewer.svelte | 8 +- .../asset-viewer/asset-viewer.svelte | 12 ++- web/src/lib/stores/activity.store.ts | 4 +- .../(user)/albums/[albumId]/+page.svelte | 46 ++++++++++- web/src/test-data/factories/album-factory.ts | 1 + 29 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 server/src/infra/migrations/1699268680508-DisableActivity.ts create mode 100644 web/src/lib/components/album-page/album-options.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 9265d439f..ec6e3f724 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -331,6 +331,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'id': string; + /** + * + * @type {boolean} + * @memberof AlbumResponseDto + */ + 'isActivityEnabled': boolean; /** * * @type {string} @@ -4160,6 +4166,12 @@ export interface UpdateAlbumDto { * @memberof UpdateAlbumDto */ 'description'?: string; + /** + * + * @type {boolean} + * @memberof UpdateAlbumDto + */ + 'isActivityEnabled'?: boolean; } /** * diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index 93620b9fc..bc00d30af 100644 --- a/mobile/openapi/doc/AlbumResponseDto.md +++ b/mobile/openapi/doc/AlbumResponseDto.md @@ -17,6 +17,7 @@ Name | Type | Description | Notes **endDate** | [**DateTime**](DateTime.md) | | [optional] **hasSharedLink** | **bool** | | **id** | **String** | | +**isActivityEnabled** | **bool** | | **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional] **owner** | [**UserResponseDto**](UserResponseDto.md) | | **ownerId** | **String** | | diff --git a/mobile/openapi/doc/UpdateAlbumDto.md b/mobile/openapi/doc/UpdateAlbumDto.md index 283b8bc29..4ded87d1b 100644 --- a/mobile/openapi/doc/UpdateAlbumDto.md +++ b/mobile/openapi/doc/UpdateAlbumDto.md @@ -11,6 +11,7 @@ Name | Type | Description | Notes **albumName** | **String** | | [optional] **albumThumbnailAssetId** | **String** | | [optional] **description** | **String** | | [optional] +**isActivityEnabled** | **bool** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index cf2ad9252..86e009e33 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -22,6 +22,7 @@ class AlbumResponseDto { this.endDate, required this.hasSharedLink, required this.id, + required this.isActivityEnabled, this.lastModifiedAssetTimestamp, required this.owner, required this.ownerId, @@ -55,6 +56,8 @@ class AlbumResponseDto { String id; + bool isActivityEnabled; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -92,6 +95,7 @@ class AlbumResponseDto { other.endDate == endDate && other.hasSharedLink == hasSharedLink && other.id == id && + other.isActivityEnabled == isActivityEnabled && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.owner == owner && other.ownerId == ownerId && @@ -112,6 +116,7 @@ class AlbumResponseDto { (endDate == null ? 0 : endDate!.hashCode) + (hasSharedLink.hashCode) + (id.hashCode) + + (isActivityEnabled.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (owner.hashCode) + (ownerId.hashCode) + @@ -121,7 +126,7 @@ class AlbumResponseDto { (updatedAt.hashCode); @override - String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; + String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -142,6 +147,7 @@ class AlbumResponseDto { } json[r'hasSharedLink'] = this.hasSharedLink; json[r'id'] = this.id; + json[r'isActivityEnabled'] = this.isActivityEnabled; if (this.lastModifiedAssetTimestamp != null) { json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); } else { @@ -177,6 +183,7 @@ class AlbumResponseDto { endDate: mapDateTime(json, r'endDate', ''), hasSharedLink: mapValueOfType(json, r'hasSharedLink')!, id: mapValueOfType(json, r'id')!, + isActivityEnabled: mapValueOfType(json, r'isActivityEnabled')!, lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''), owner: UserResponseDto.fromJson(json[r'owner'])!, ownerId: mapValueOfType(json, r'ownerId')!, @@ -239,6 +246,7 @@ class AlbumResponseDto { 'description', 'hasSharedLink', 'id', + 'isActivityEnabled', 'owner', 'ownerId', 'shared', diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index 6c0bf3eca..32d4d2a60 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -16,6 +16,7 @@ class UpdateAlbumDto { this.albumName, this.albumThumbnailAssetId, this.description, + this.isActivityEnabled, }); /// @@ -42,21 +43,31 @@ class UpdateAlbumDto { /// String? description; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isActivityEnabled; + @override bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto && other.albumName == albumName && other.albumThumbnailAssetId == albumThumbnailAssetId && - other.description == description; + other.description == description && + other.isActivityEnabled == isActivityEnabled; @override int get hashCode => // ignore: unnecessary_parenthesis (albumName == null ? 0 : albumName!.hashCode) + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + - (description == null ? 0 : description!.hashCode); + (description == null ? 0 : description!.hashCode) + + (isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode); @override - String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]'; + String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled]'; Map toJson() { final json = {}; @@ -75,6 +86,11 @@ class UpdateAlbumDto { } else { // json[r'description'] = null; } + if (this.isActivityEnabled != null) { + json[r'isActivityEnabled'] = this.isActivityEnabled; + } else { + // json[r'isActivityEnabled'] = null; + } return json; } @@ -89,6 +105,7 @@ class UpdateAlbumDto { albumName: mapValueOfType(json, r'albumName'), albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), description: mapValueOfType(json, r'description'), + isActivityEnabled: mapValueOfType(json, r'isActivityEnabled'), ); } return null; diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index c84174200..933f77c19 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/mobile/openapi/test/album_response_dto_test.dart @@ -61,6 +61,11 @@ void main() { // TODO }); + // bool isActivityEnabled + test('to test the property `isActivityEnabled`', () async { + // TODO + }); + // DateTime lastModifiedAssetTimestamp test('to test the property `lastModifiedAssetTimestamp`', () async { // TODO diff --git a/mobile/openapi/test/update_album_dto_test.dart b/mobile/openapi/test/update_album_dto_test.dart index 7b8472ad3..67ec80010 100644 --- a/mobile/openapi/test/update_album_dto_test.dart +++ b/mobile/openapi/test/update_album_dto_test.dart @@ -31,6 +31,11 @@ void main() { // TODO }); + // bool isActivityEnabled + test('to test the property `isActivityEnabled`', () async { + // TODO + }); + }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 05aec878a..7e4dc7cdb 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -5894,6 +5894,9 @@ "id": { "type": "string" }, + "isActivityEnabled": { + "type": "boolean" + }, "lastModifiedAssetTimestamp": { "format": "date-time", "type": "string" @@ -5935,7 +5938,8 @@ "sharedUsers", "hasSharedLink", "assets", - "owner" + "owner", + "isActivityEnabled" ], "type": "object" }, @@ -8910,6 +8914,9 @@ }, "description": { "type": "string" + }, + "isActivityEnabled": { + "type": "boolean" } }, "type": "object" diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 414252778..88abd79b1 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -138,10 +138,7 @@ export class AccessCore { switch (permission) { // uses album id case Permission.ACTIVITY_CREATE: - return ( - (await this.repository.album.hasOwnerAccess(authUser.id, id)) || - (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) - ); + return await this.repository.activity.hasCreateAccess(authUser.id, id); // uses activity id case Permission.ACTIVITY_DELETE: diff --git a/server/src/domain/activity/activity.spec.ts b/server/src/domain/activity/activity.spec.ts index 968f7421a..496d8978b 100644 --- a/server/src/domain/activity/activity.spec.ts +++ b/server/src/domain/activity/activity.spec.ts @@ -94,7 +94,7 @@ describe(ActivityService.name, () => { }); it('should create a comment', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.activity.hasCreateAccess.mockResolvedValue(true); activityMock.create.mockResolvedValue(activityStub.oneComment); await sut.create(authStub.admin, { @@ -113,8 +113,23 @@ describe(ActivityService.name, () => { }); }); - it('should create a like', async () => { + it('should fail because activity is disabled for the album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.activity.hasCreateAccess.mockResolvedValue(false); + activityMock.create.mockResolvedValue(activityStub.oneComment); + + await expect( + sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.COMMENT, + comment: 'comment', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should create a like', async () => { + accessMock.activity.hasCreateAccess.mockResolvedValue(true); activityMock.create.mockResolvedValue(activityStub.liked); activityMock.search.mockResolvedValue([]); @@ -134,6 +149,7 @@ describe(ActivityService.name, () => { it('should skip if like exists', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.activity.hasCreateAccess.mockResolvedValue(true); activityMock.search.mockResolvedValue([activityStub.liked]); await sut.create(authStub.admin, { diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index b426bc37d..671922408 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/server/src/domain/album/album-response.dto.ts @@ -21,6 +21,7 @@ export class AlbumResponseDto { lastModifiedAssetTimestamp?: Date; startDate?: Date; endDate?: Date; + isActivityEnabled!: boolean; } export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { @@ -61,6 +62,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons endDate, assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)), assetCount: entity.assets?.length || 0, + isActivityEnabled: entity.isActivityEnabled, }; }; diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index b8e789943..37d44c33a 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -125,12 +125,12 @@ export class AlbumService { throw new BadRequestException('Invalid album thumbnail'); } } - const updatedAlbum = await this.albumRepository.update({ id: album.id, albumName: dto.albumName, description: dto.description, albumThumbnailAssetId: dto.albumThumbnailAssetId, + isActivityEnabled: dto.isActivityEnabled, }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } }); diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts index f574f2c23..3b1858ba1 100644 --- a/server/src/domain/album/dto/album-update.dto.ts +++ b/server/src/domain/album/dto/album-update.dto.ts @@ -1,4 +1,4 @@ -import { IsString } from 'class-validator'; +import { IsBoolean, IsString } from 'class-validator'; import { Optional, ValidateUUID } from '../../domain.util'; export class UpdateAlbumDto { @@ -12,4 +12,8 @@ export class UpdateAlbumDto { @ValidateUUID({ optional: true }) albumThumbnailAssetId?: string; + + @Optional() + @IsBoolean() + isActivityEnabled?: boolean; } diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index 43b53e605..f9ceb6f52 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -2,8 +2,9 @@ export const IAccessRepository = 'IAccessRepository'; export interface IAccessRepository { activity: { - hasOwnerAccess(userId: string, albumId: string): Promise; - hasAlbumOwnerAccess(userId: string, albumId: string): Promise; + hasOwnerAccess(userId: string, activityId: string): Promise; + hasAlbumOwnerAccess(userId: string, activityId: string): Promise; + hasCreateAccess(userId: string, albumId: string): Promise; }; asset: { hasOwnerAccess(userId: string, assetId: string): Promise; diff --git a/server/src/infra/entities/album.entity.ts b/server/src/infra/entities/album.entity.ts index 38ce4310c..fbc125351 100644 --- a/server/src/infra/entities/album.entity.ts +++ b/server/src/infra/entities/album.entity.ts @@ -56,4 +56,7 @@ export class AlbumEntity { @OneToMany(() => SharedLinkEntity, (link) => link.album) sharedLinks!: SharedLinkEntity[]; + + @Column({ default: true }) + isActivityEnabled!: boolean; } diff --git a/server/src/infra/migrations/1699268680508-DisableActivity.ts b/server/src/infra/migrations/1699268680508-DisableActivity.ts new file mode 100644 index 000000000..d860244f6 --- /dev/null +++ b/server/src/infra/migrations/1699268680508-DisableActivity.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DisableActivity1699268680508 implements MigrationInterface { + name = 'DisableActivity1699268680508' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" ADD "isActivityEnabled" boolean NOT NULL DEFAULT true`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "isActivityEnabled"`); + } + +} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 566514796..aff498ac3 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -43,6 +43,24 @@ export class AccessRepository implements IAccessRepository { }, }); }, + hasCreateAccess: (userId: string, albumId: string): Promise => { + return this.albumRepository.exist({ + where: [ + { + id: albumId, + isActivityEnabled: true, + sharedUsers: { + id: userId, + }, + }, + { + id: albumId, + isActivityEnabled: true, + ownerId: userId, + }, + ], + }); + }, }; library = { hasOwnerAccess: (userId: string, libraryId: string): Promise => { diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index e10f5414f..8348eff03 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -226,6 +226,7 @@ describe(`${AlbumController.name} (e2e)`, () => { assets: [], assetCount: 0, owner: expect.objectContaining({ email: user1.userEmail }), + isActivityEnabled: true, }); }); }); diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 48ed92817..fd4464d19 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -18,6 +18,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), sharedWithUser: Object.freeze({ id: 'album-2', @@ -33,6 +34,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [userStub.user1], + isActivityEnabled: true, }), sharedWithMultiple: Object.freeze({ id: 'album-3', @@ -48,6 +50,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [userStub.user1, userStub.user2], + isActivityEnabled: true, }), sharedWithAdmin: Object.freeze({ id: 'album-3', @@ -63,6 +66,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [userStub.admin], + isActivityEnabled: true, }), oneAsset: Object.freeze({ id: 'album-4', @@ -78,6 +82,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), twoAssets: Object.freeze({ id: 'album-4a', @@ -93,6 +98,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', @@ -108,6 +114,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), emptyWithValidThumbnail: Object.freeze({ id: 'album-5', @@ -123,6 +130,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), oneAssetInvalidThumbnail: Object.freeze({ id: 'album-6', @@ -138,6 +146,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), oneAssetValidThumbnail: Object.freeze({ id: 'album-6', @@ -153,5 +162,6 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index dd6eb5233..56a0c1045 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -100,6 +100,7 @@ const albumResponse: AlbumResponseDto = { hasSharedLink: false, assets: [], assetCount: 1, + isActivityEnabled: true, }; export const sharedLinkStub = { @@ -179,6 +180,7 @@ export const sharedLinkStub = { albumThumbnailAssetId: null, sharedUsers: [], sharedLinks: [], + isActivityEnabled: true, assets: [ { id: 'id_1', diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 4f7992e86..6abfc7c9e 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -19,6 +19,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => activity: { hasOwnerAccess: jest.fn(), hasAlbumOwnerAccess: jest.fn(), + hasCreateAccess: jest.fn(), }, asset: { hasOwnerAccess: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 9265d439f..ec6e3f724 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -331,6 +331,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'id': string; + /** + * + * @type {boolean} + * @memberof AlbumResponseDto + */ + 'isActivityEnabled': boolean; /** * * @type {string} @@ -4160,6 +4166,12 @@ export interface UpdateAlbumDto { * @memberof UpdateAlbumDto */ 'description'?: string; + /** + * + * @type {boolean} + * @memberof UpdateAlbumDto + */ + 'isActivityEnabled'?: boolean; } /** * diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte new file mode 100644 index 000000000..1a3e43892 --- /dev/null +++ b/web/src/lib/components/album-page/album-options.svelte @@ -0,0 +1,76 @@ + + + dispatch('close')}> +
+
+
+
+

Options

+
+ dispatch('close')} /> +
+
+ +
+
+

SHARING

+
+ dispatch('toggleEnableActivity')} + /> +
+
+
+
PEOPLE
+
+ +
+
+ +
+
{`${user.firstName} ${user.lastName}`}
+
Owner
+
+ {#each album.sharedUsers as user (user.id)} +
+
+ +
+
{`${user.firstName} ${user.lastName}`}
+
+ {/each} +
+
+
+
+
+
+
diff --git a/web/src/lib/components/asset-viewer/activity-status.svelte b/web/src/lib/components/asset-viewer/activity-status.svelte index 23fdb7a9d..47e29ff97 100644 --- a/web/src/lib/components/asset-viewer/activity-status.svelte +++ b/web/src/lib/components/asset-viewer/activity-status.svelte @@ -7,6 +7,7 @@ export let isLiked: ActivityResponseDto | null; export let numberOfComments: number | undefined; export let isShowActivity: boolean | undefined; + export let disabled: boolean; const dispatch = createEventDispatcher(); @@ -14,7 +15,7 @@
-