diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 5bcfe0f07..798d388d7 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -2570,6 +2570,20 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const ReactionLevel = { + Album: 'album', + Asset: 'asset' +} as const; + +export type ReactionLevel = typeof ReactionLevel[keyof typeof ReactionLevel]; + + /** * * @export @@ -5065,11 +5079,12 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat * @param {string} albumId * @param {string} [assetId] * @param {ReactionType} [type] + * @param {ReactionLevel} [level] * @param {string} [userId] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getActivities: async (albumId: string, assetId?: string, type?: ReactionType, userId?: string, options: AxiosRequestConfig = {}): Promise => { + getActivities: async (albumId: string, assetId?: string, type?: ReactionType, level?: ReactionLevel, userId?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'albumId' is not null or undefined assertParamExists('getActivities', 'albumId', albumId) const localVarPath = `/activity`; @@ -5105,6 +5120,10 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat localVarQueryParameter['type'] = type; } + if (level !== undefined) { + localVarQueryParameter['level'] = level; + } + if (userId !== undefined) { localVarQueryParameter['userId'] = userId; } @@ -5205,12 +5224,13 @@ export const ActivityApiFp = function(configuration?: Configuration) { * @param {string} albumId * @param {string} [assetId] * @param {ReactionType} [type] + * @param {ReactionLevel} [level] * @param {string} [userId] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getActivities(albumId: string, assetId?: string, type?: ReactionType, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, userId, options); + async getActivities(albumId: string, assetId?: string, type?: ReactionType, level?: ReactionLevel, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, level, userId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -5259,7 +5279,7 @@ export const ActivityApiFactory = function (configuration?: Configuration, baseP * @throws {RequiredError} */ getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(axios, basePath)); + return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.level, requestParameters.userId, options).then((request) => request(axios, basePath)); }, /** * @@ -5328,6 +5348,13 @@ export interface ActivityApiGetActivitiesRequest { */ readonly type?: ReactionType + /** + * + * @type {ReactionLevel} + * @memberof ActivityApiGetActivities + */ + readonly level?: ReactionLevel + /** * * @type {string} @@ -5394,7 +5421,7 @@ export class ActivityApi extends BaseAPI { * @memberof ActivityApi */ public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) { - return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(this.axios, this.basePath)); + return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.level, requestParameters.userId, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 18dc395b5..57854e1b7 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -101,6 +101,7 @@ doc/PersonResponseDto.md doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md doc/QueueStatusDto.md +doc/ReactionLevel.md doc/ReactionType.md doc/RecognitionConfig.md doc/ScanLibraryDto.md @@ -281,6 +282,7 @@ lib/model/person_response_dto.dart lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart +lib/model/reaction_level.dart lib/model/reaction_type.dart lib/model/recognition_config.dart lib/model/scan_library_dto.dart @@ -440,6 +442,7 @@ test/person_response_dto_test.dart test/person_statistics_response_dto_test.dart test/person_update_dto_test.dart test/queue_status_dto_test.dart +test/reaction_level_test.dart test/reaction_type_test.dart test/recognition_config_test.dart test/scan_library_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d62d95cb5..0bc2cc1e3 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/ActivityApi.md b/mobile/openapi/doc/ActivityApi.md index 1af3f1f49..6221ef5e4 100644 Binary files a/mobile/openapi/doc/ActivityApi.md and b/mobile/openapi/doc/ActivityApi.md differ diff --git a/mobile/openapi/doc/ReactionLevel.md b/mobile/openapi/doc/ReactionLevel.md new file mode 100644 index 000000000..a53955cb0 Binary files /dev/null and b/mobile/openapi/doc/ReactionLevel.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4d6bd8c41..7e4ed3a7b 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart index 458538a5d..8e2354e20 100644 Binary files a/mobile/openapi/lib/api/activity_api.dart and b/mobile/openapi/lib/api/activity_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index e3b881ccd..dfdd5b4ef 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 632069e8c..f39131448 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ diff --git a/mobile/openapi/lib/model/reaction_level.dart b/mobile/openapi/lib/model/reaction_level.dart new file mode 100644 index 000000000..5c8b8e62b Binary files /dev/null and b/mobile/openapi/lib/model/reaction_level.dart differ diff --git a/mobile/openapi/test/activity_api_test.dart b/mobile/openapi/test/activity_api_test.dart index 9b6fe1a6c..7353c6ea3 100644 Binary files a/mobile/openapi/test/activity_api_test.dart and b/mobile/openapi/test/activity_api_test.dart differ diff --git a/mobile/openapi/test/reaction_level_test.dart b/mobile/openapi/test/reaction_level_test.dart new file mode 100644 index 000000000..6fcba58b1 Binary files /dev/null and b/mobile/openapi/test/reaction_level_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 53d1e962e..193586cbd 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -31,6 +31,14 @@ "$ref": "#/components/schemas/ReactionType" } }, + { + "name": "level", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/ReactionLevel" + } + }, { "name": "userId", "required": false, @@ -7728,6 +7736,13 @@ ], "type": "object" }, + "ReactionLevel": { + "enum": [ + "album", + "asset" + ], + "type": "string" + }, "ReactionType": { "enum": [ "comment", diff --git a/server/src/domain/activity/activity.dto.ts b/server/src/domain/activity/activity.dto.ts index e1a163b81..a5a5bd3df 100644 --- a/server/src/domain/activity/activity.dto.ts +++ b/server/src/domain/activity/activity.dto.ts @@ -9,6 +9,11 @@ export enum ReactionType { LIKE = 'like', } +export enum ReactionLevel { + ALBUM = 'album', + ASSET = 'asset', +} + export type MaybeDuplicate = { duplicate: boolean; value: T }; export class ActivityResponseDto { @@ -39,6 +44,11 @@ export class ActivitySearchDto extends ActivityDto { @ApiProperty({ enumName: 'ReactionType', enum: ReactionType }) type?: ReactionType; + @IsEnum(ReactionLevel) + @Optional() + @ApiProperty({ enumName: 'ReactionLevel', enum: ReactionLevel }) + level?: ReactionLevel; + @ValidateUUID({ optional: true }) userId?: string; } diff --git a/server/src/domain/activity/activity.service.ts b/server/src/domain/activity/activity.service.ts index 44362f3fc..8b601558d 100644 --- a/server/src/domain/activity/activity.service.ts +++ b/server/src/domain/activity/activity.service.ts @@ -10,6 +10,7 @@ import { ActivitySearchDto, ActivityStatisticsResponseDto, MaybeDuplicate, + ReactionLevel, ReactionType, mapActivity, } from './activity.dto'; @@ -30,7 +31,7 @@ export class ActivityService { const activities = await this.repository.search({ userId: dto.userId, albumId: dto.albumId, - assetId: dto.assetId, + assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId, isLiked: dto.type && dto.type === ReactionType.LIKE, }); @@ -54,11 +55,12 @@ export class ActivityService { let activity: ActivityEntity | null = null; let duplicate = false; - if (dto.type === 'like') { + if (dto.type === ReactionType.LIKE) { delete dto.comment; [activity] = await this.repository.search({ ...common, - isGlobal: !dto.assetId, + // `null` will search for an album like + assetId: dto.assetId ?? null, isLiked: true, }); duplicate = !!activity; diff --git a/server/src/infra/repositories/activity.repository.ts b/server/src/infra/repositories/activity.repository.ts index 138d96381..25fd5fa7a 100644 --- a/server/src/infra/repositories/activity.repository.ts +++ b/server/src/infra/repositories/activity.repository.ts @@ -6,10 +6,9 @@ import { ActivityEntity } from '../entities/activity.entity'; export interface ActivitySearch { albumId?: string; - assetId?: string; + assetId?: string | null; userId?: string; isLiked?: boolean; - isGlobal?: boolean; } @Injectable() @@ -17,11 +16,11 @@ export class ActivityRepository implements IActivityRepository { constructor(@InjectRepository(ActivityEntity) private repository: Repository) {} search(options: ActivitySearch): Promise { - const { userId, assetId, albumId, isLiked, isGlobal } = options; + const { userId, assetId, albumId, isLiked } = options; return this.repository.find({ where: { userId, - assetId: isGlobal ? IsNull() : assetId, + assetId: assetId === null ? IsNull() : assetId, albumId, isLiked, }, diff --git a/server/test/e2e/activity.e2e-spec.ts b/server/test/e2e/activity.e2e-spec.ts index 5cc86fc6a..0bb8aa2c9 100644 --- a/server/test/e2e/activity.e2e-spec.ts +++ b/server/test/e2e/activity.e2e-spec.ts @@ -247,6 +247,20 @@ describe(`${ActivityController.name} (e2e)`, () => { expect(body).toEqual(reaction); }); + it('should not confuse an album like with an asset like', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, type: 'like' }); + expect(status).toEqual(201); + expect(body.id).not.toEqual(reaction.id); + }); + it('should add a comment to an asset', async () => { const { status, body } = await request(server) .post('/activity') diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 5bcfe0f07..798d388d7 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -2570,6 +2570,20 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const ReactionLevel = { + Album: 'album', + Asset: 'asset' +} as const; + +export type ReactionLevel = typeof ReactionLevel[keyof typeof ReactionLevel]; + + /** * * @export @@ -5065,11 +5079,12 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat * @param {string} albumId * @param {string} [assetId] * @param {ReactionType} [type] + * @param {ReactionLevel} [level] * @param {string} [userId] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getActivities: async (albumId: string, assetId?: string, type?: ReactionType, userId?: string, options: AxiosRequestConfig = {}): Promise => { + getActivities: async (albumId: string, assetId?: string, type?: ReactionType, level?: ReactionLevel, userId?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'albumId' is not null or undefined assertParamExists('getActivities', 'albumId', albumId) const localVarPath = `/activity`; @@ -5105,6 +5120,10 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat localVarQueryParameter['type'] = type; } + if (level !== undefined) { + localVarQueryParameter['level'] = level; + } + if (userId !== undefined) { localVarQueryParameter['userId'] = userId; } @@ -5205,12 +5224,13 @@ export const ActivityApiFp = function(configuration?: Configuration) { * @param {string} albumId * @param {string} [assetId] * @param {ReactionType} [type] + * @param {ReactionLevel} [level] * @param {string} [userId] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getActivities(albumId: string, assetId?: string, type?: ReactionType, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, userId, options); + async getActivities(albumId: string, assetId?: string, type?: ReactionType, level?: ReactionLevel, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, level, userId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -5259,7 +5279,7 @@ export const ActivityApiFactory = function (configuration?: Configuration, baseP * @throws {RequiredError} */ getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(axios, basePath)); + return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.level, requestParameters.userId, options).then((request) => request(axios, basePath)); }, /** * @@ -5328,6 +5348,13 @@ export interface ActivityApiGetActivitiesRequest { */ readonly type?: ReactionType + /** + * + * @type {ReactionLevel} + * @memberof ActivityApiGetActivities + */ + readonly level?: ReactionLevel + /** * * @type {string} @@ -5394,7 +5421,7 @@ export class ActivityApi extends BaseAPI { * @memberof ActivityApi */ public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) { - return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(this.axios, this.basePath)); + return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.level, requestParameters.userId, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index dfe285b3e..f8663eceb 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -39,6 +39,7 @@ export let assetType: AssetTypeEnum | undefined = undefined; export let albumOwnerId: string; export let disabled: boolean; + export let isLiked: ActivityResponseDto | null; let textArea: HTMLTextAreaElement; let innerHeight: number; @@ -105,7 +106,7 @@ reactions.splice(index, 1); showDeleteReaction.splice(index, 1); reactions = reactions; - if (reaction.type === 'like' && reaction.user.id === user.id) { + if (isLiked && reaction.type === 'like' && reaction.id == isLiked.id) { dispatch('deleteLike'); } else { dispatch('deleteComment'); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 86a86c9ae..0194596f6 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -756,6 +756,7 @@ albumOwnerId={album.ownerId} albumId={album.id} assetId={asset.id} + {isLiked} bind:reactions on:addComment={handleAddComment} on:deleteComment={handleRemoveComment} diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 5525156bf..09eb66e95 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 { ActivityResponseDto, ReactionType, UserResponseDto, api } from '@api'; + import { ActivityResponseDto, ReactionLevel, 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'; @@ -167,7 +167,6 @@ const { data } = await api.activityApi.createActivity({ activityCreateDto: { albumId: album.id, type: ReactionType.Like }, }); - isLiked = data; reactions = [...reactions, isLiked]; } @@ -183,6 +182,7 @@ userId: user.id, albumId: album.id, type: ReactionType.Like, + level: ReactionLevel.Album, }); if (data.length > 0) { isLiked = data[0]; @@ -687,6 +687,7 @@ disabled={!album.isActivityEnabled} albumOwnerId={album.ownerId} albumId={album.id} + {isLiked} bind:reactions on:addComment={() => updateNumberOfComments(1)} on:deleteComment={() => updateNumberOfComments(-1)}