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:
Robin Brisa 2025-06-04 19:41:50 +02:00 committed by GitHub
parent 5d0ad853f4
commit a26d703335
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 77 additions and 29 deletions

View File

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

View File

@ -8482,10 +8482,14 @@
"properties": { "properties": {
"comments": { "comments": {
"type": "integer" "type": "integer"
},
"likes": {
"type": "integer"
} }
}, },
"required": [ "required": [
"comments" "comments",
"likes"
], ],
"type": "object" "type": "object"
}, },

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -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>> {

View File

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

View File

@ -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"

View File

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

View File

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

View File

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