mirror of
https://github.com/immich-app/immich
synced 2025-06-07 18:11:52 +00:00
feat(web): display number of likes in asset viewer (#18911)
* feat: display number of likes * fix: properly decrement like count on unlike Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * chore: pr feedback * chore: updated related test * chore: formatter run * chore: force numberOfLikes to null in album context to pass lint * chore: open-api updated * fix: use undefined, not null * styling tweaks * chore: updated sql --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
5d0ad853f4
commit
a26d703335
@ -14,25 +14,31 @@ class ActivityStatisticsResponseDto {
|
|||||||
/// Returns a new [ActivityStatisticsResponseDto] instance.
|
/// Returns a new [ActivityStatisticsResponseDto] instance.
|
||||||
ActivityStatisticsResponseDto({
|
ActivityStatisticsResponseDto({
|
||||||
required this.comments,
|
required this.comments,
|
||||||
|
required this.likes,
|
||||||
});
|
});
|
||||||
|
|
||||||
int comments;
|
int comments;
|
||||||
|
|
||||||
|
int likes;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is ActivityStatisticsResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is ActivityStatisticsResponseDto &&
|
||||||
other.comments == comments;
|
other.comments == comments &&
|
||||||
|
other.likes == likes;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(comments.hashCode);
|
(comments.hashCode) +
|
||||||
|
(likes.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'ActivityStatisticsResponseDto[comments=$comments]';
|
String toString() => 'ActivityStatisticsResponseDto[comments=$comments, likes=$likes]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'comments'] = this.comments;
|
json[r'comments'] = this.comments;
|
||||||
|
json[r'likes'] = this.likes;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,6 +52,7 @@ class ActivityStatisticsResponseDto {
|
|||||||
|
|
||||||
return ActivityStatisticsResponseDto(
|
return ActivityStatisticsResponseDto(
|
||||||
comments: mapValueOfType<int>(json, r'comments')!,
|
comments: mapValueOfType<int>(json, r'comments')!,
|
||||||
|
likes: mapValueOfType<int>(json, r'likes')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -94,6 +101,7 @@ class ActivityStatisticsResponseDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'comments',
|
'comments',
|
||||||
|
'likes',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8482,10 +8482,14 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"comments": {
|
"comments": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"likes": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"comments"
|
"comments",
|
||||||
|
"likes"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
@ -38,6 +38,7 @@ export type ActivityCreateDto = {
|
|||||||
};
|
};
|
||||||
export type ActivityStatisticsResponseDto = {
|
export type ActivityStatisticsResponseDto = {
|
||||||
comments: number;
|
comments: number;
|
||||||
|
likes: number;
|
||||||
};
|
};
|
||||||
export type NotificationCreateDto = {
|
export type NotificationCreateDto = {
|
||||||
data?: object;
|
data?: object;
|
||||||
|
@ -29,6 +29,9 @@ export class ActivityResponseDto {
|
|||||||
export class ActivityStatisticsResponseDto {
|
export class ActivityStatisticsResponseDto {
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
comments!: number;
|
comments!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
likes!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActivityDto {
|
export class ActivityDto {
|
||||||
|
@ -62,15 +62,21 @@ where
|
|||||||
|
|
||||||
-- ActivityRepository.getStatistics
|
-- ActivityRepository.getStatistics
|
||||||
select
|
select
|
||||||
count(*) as "count"
|
count(*) filter (
|
||||||
|
where
|
||||||
|
"activity"."isLiked" = $1
|
||||||
|
) as "comments",
|
||||||
|
count(*) filter (
|
||||||
|
where
|
||||||
|
"activity"."isLiked" = $2
|
||||||
|
) as "likes"
|
||||||
from
|
from
|
||||||
"activity"
|
"activity"
|
||||||
inner join "users" on "users"."id" = "activity"."userId"
|
inner join "users" on "users"."id" = "activity"."userId"
|
||||||
and "users"."deletedAt" is null
|
and "users"."deletedAt" is null
|
||||||
left join "assets" on "assets"."id" = "activity"."assetId"
|
left join "assets" on "assets"."id" = "activity"."assetId"
|
||||||
where
|
where
|
||||||
"activity"."assetId" = $1
|
"activity"."assetId" = $3
|
||||||
and "activity"."albumId" = $2
|
and "activity"."albumId" = $4
|
||||||
and "activity"."isLiked" = $3
|
|
||||||
and "assets"."deletedAt" is null
|
and "assets"."deletedAt" is null
|
||||||
and "assets"."visibility" != 'locked'
|
and "assets"."visibility" != 'locked'
|
||||||
|
@ -67,19 +67,27 @@ export class ActivityRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ albumId: DummyValue.UUID, assetId: DummyValue.UUID }] })
|
@GenerateSql({ params: [{ albumId: DummyValue.UUID, assetId: DummyValue.UUID }] })
|
||||||
async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise<number> {
|
async getStatistics({
|
||||||
const { count } = await this.db
|
albumId,
|
||||||
|
assetId,
|
||||||
|
}: {
|
||||||
|
albumId: string;
|
||||||
|
assetId?: string;
|
||||||
|
}): Promise<{ comments: number; likes: number }> {
|
||||||
|
const result = await this.db
|
||||||
.selectFrom('activity')
|
.selectFrom('activity')
|
||||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
.select((eb) => [
|
||||||
|
eb.fn.countAll<number>().filterWhere('activity.isLiked', '=', false).as('comments'),
|
||||||
|
eb.fn.countAll<number>().filterWhere('activity.isLiked', '=', true).as('likes'),
|
||||||
|
])
|
||||||
.innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null))
|
.innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null))
|
||||||
.leftJoin('assets', 'assets.id', 'activity.assetId')
|
.leftJoin('assets', 'assets.id', 'activity.assetId')
|
||||||
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
|
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
|
||||||
.where('activity.albumId', '=', albumId)
|
.where('activity.albumId', '=', albumId)
|
||||||
.where('activity.isLiked', '=', false)
|
|
||||||
.where('assets.deletedAt', 'is', null)
|
.where('assets.deletedAt', 'is', null)
|
||||||
.where('assets.visibility', '!=', sql.lit(AssetVisibility.LOCKED))
|
.where('assets.visibility', '!=', sql.lit(AssetVisibility.LOCKED))
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return count;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -54,13 +54,13 @@ describe(ActivityService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getStatistics', () => {
|
describe('getStatistics', () => {
|
||||||
it('should get the comment count', async () => {
|
it('should get the comment and like count', async () => {
|
||||||
const [albumId, assetId] = newUuids();
|
const [albumId, assetId] = newUuids();
|
||||||
|
|
||||||
mocks.activity.getStatistics.mockResolvedValue(1);
|
mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 });
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
|
|
||||||
await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1 });
|
await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ export class ActivityService extends BaseService {
|
|||||||
|
|
||||||
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
|
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
|
||||||
await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
|
await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
|
||||||
return { comments: await this.activityRepository.getStatistics({ albumId: dto.albumId, assetId: dto.assetId }) };
|
return await this.activityRepository.getStatistics({ albumId: dto.albumId, assetId: dto.assetId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
|
async create(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
|
||||||
|
@ -7,25 +7,29 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
isLiked: ActivityResponseDto | null;
|
isLiked: ActivityResponseDto | null;
|
||||||
numberOfComments: number | undefined;
|
numberOfComments: number | undefined;
|
||||||
|
numberOfLikes: number | undefined;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onOpenActivityTab: () => void;
|
onOpenActivityTab: () => void;
|
||||||
onFavorite: () => void;
|
onFavorite: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { isLiked, numberOfComments, disabled, onOpenActivityTab, onFavorite }: Props = $props();
|
let { isLiked, numberOfComments, numberOfLikes, disabled, onOpenActivityTab, onFavorite }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full flex p-4 text-white items-center justify-center rounded-full gap-5 bg-immich-dark-bg bg-opacity-60">
|
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
|
||||||
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
|
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
|
||||||
<div class="items-center justify-center">
|
<div class="flex gap-2 items-center justify-center">
|
||||||
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
|
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} class={isLiked ? 'text-red-400' : 'text-fg'} />
|
||||||
|
{#if numberOfLikes}
|
||||||
|
<div class="text-l">{numberOfLikes.toLocaleString($locale)}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick={onOpenActivityTab}>
|
<button type="button" onclick={onOpenActivityTab}>
|
||||||
<div class="flex gap-2 items-center justify-center">
|
<div class="flex gap-2 items-center justify-center">
|
||||||
<Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
|
<Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
|
||||||
{#if numberOfComments}
|
{#if numberOfComments}
|
||||||
<div class="text-xl">{numberOfComments.toLocaleString($locale)}</div>
|
<div class="text-l">{numberOfComments.toLocaleString($locale)}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
@ -118,12 +118,9 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}>
|
<div class="overflow-y-hidden relative h-full border-l border-subtle bg-subtle" bind:offsetHeight={innerHeight}>
|
||||||
<div class="dark:bg-immich-dark-bg dark:text-immich-dark-fg w-full h-full">
|
<div class="w-full h-full">
|
||||||
<div
|
<div class="flex w-full h-fit dark:text-immich-dark-fg p-2 bg-subtle" bind:clientHeight={activityHeight}>
|
||||||
class="flex w-full h-fit dark:bg-immich-dark-bg dark:text-immich-dark-fg p-2 bg-white"
|
|
||||||
bind:clientHeight={activityHeight}
|
|
||||||
>
|
|
||||||
<div class="flex place-items-center gap-2">
|
<div class="flex place-items-center gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
|
@ -513,6 +513,7 @@
|
|||||||
disabled={!album?.isActivityEnabled}
|
disabled={!album?.isActivityEnabled}
|
||||||
isLiked={activityManager.isLiked}
|
isLiked={activityManager.isLiked}
|
||||||
numberOfComments={activityManager.commentCount}
|
numberOfComments={activityManager.commentCount}
|
||||||
|
numberOfLikes={activityManager.likeCount}
|
||||||
onFavorite={handleFavorite}
|
onFavorite={handleFavorite}
|
||||||
onOpenActivityTab={handleOpenActivity}
|
onOpenActivityTab={handleOpenActivity}
|
||||||
/>
|
/>
|
||||||
|
@ -17,6 +17,7 @@ class ActivityManager {
|
|||||||
#assetId = $state<string | undefined>();
|
#assetId = $state<string | undefined>();
|
||||||
#activities = $state<ActivityResponseDto[]>([]);
|
#activities = $state<ActivityResponseDto[]>([]);
|
||||||
#commentCount = $state(0);
|
#commentCount = $state(0);
|
||||||
|
#likeCount = $state(0);
|
||||||
#isLiked = $state<ActivityResponseDto | null>(null);
|
#isLiked = $state<ActivityResponseDto | null>(null);
|
||||||
|
|
||||||
get activities() {
|
get activities() {
|
||||||
@ -27,6 +28,10 @@ class ActivityManager {
|
|||||||
return this.#commentCount;
|
return this.#commentCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get likeCount() {
|
||||||
|
return this.#likeCount;
|
||||||
|
}
|
||||||
|
|
||||||
get isLiked() {
|
get isLiked() {
|
||||||
return this.#isLiked;
|
return this.#isLiked;
|
||||||
}
|
}
|
||||||
@ -48,6 +53,10 @@ class ActivityManager {
|
|||||||
this.#commentCount++;
|
this.#commentCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activity.type === ReactionType.Like) {
|
||||||
|
this.#likeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
|
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
|
||||||
return activity;
|
return activity;
|
||||||
}
|
}
|
||||||
@ -61,6 +70,10 @@ class ActivityManager {
|
|||||||
this.#commentCount--;
|
this.#commentCount--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activity.type === ReactionType.Like) {
|
||||||
|
this.#likeCount--;
|
||||||
|
}
|
||||||
|
|
||||||
this.#activities = index
|
this.#activities = index
|
||||||
? this.#activities.splice(index, 1)
|
? this.#activities.splice(index, 1)
|
||||||
: this.#activities.filter(({ id }) => id !== activity.id);
|
: this.#activities.filter(({ id }) => id !== activity.id);
|
||||||
@ -98,8 +111,9 @@ class ActivityManager {
|
|||||||
});
|
});
|
||||||
this.#isLiked = liked ?? null;
|
this.#isLiked = liked ?? null;
|
||||||
|
|
||||||
const { comments } = await getActivityStatistics({ albumId, assetId });
|
const { comments, likes } = await getActivityStatistics({ albumId, assetId });
|
||||||
this.#commentCount = comments;
|
this.#commentCount = comments;
|
||||||
|
this.#likeCount = likes;
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
@ -107,6 +121,7 @@ class ActivityManager {
|
|||||||
this.#assetId = undefined;
|
this.#assetId = undefined;
|
||||||
this.#activities = [];
|
this.#activities = [];
|
||||||
this.#commentCount = 0;
|
this.#commentCount = 0;
|
||||||
|
this.#likeCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -576,6 +576,7 @@
|
|||||||
disabled={!album.isActivityEnabled}
|
disabled={!album.isActivityEnabled}
|
||||||
isLiked={activityManager.isLiked}
|
isLiked={activityManager.isLiked}
|
||||||
numberOfComments={activityManager.commentCount}
|
numberOfComments={activityManager.commentCount}
|
||||||
|
numberOfLikes={undefined}
|
||||||
onFavorite={handleFavorite}
|
onFavorite={handleFavorite}
|
||||||
onOpenActivityTab={handleOpenAndCloseActivityTab}
|
onOpenActivityTab={handleOpenAndCloseActivityTab}
|
||||||
/>
|
/>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user