mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
Merge 62b63638d9 into e7d6a066f8
This commit is contained in:
commit
4bf5c8fab5
20 changed files with 361 additions and 10 deletions
|
|
@ -5,6 +5,7 @@ import {
|
||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
ReactionType,
|
ReactionType,
|
||||||
|
addAssetsToAlbum,
|
||||||
createActivity as create,
|
createActivity as create,
|
||||||
createAlbum,
|
createAlbum,
|
||||||
removeAssetFromAlbum,
|
removeAssetFromAlbum,
|
||||||
|
|
@ -158,6 +159,59 @@ describe('/activities', () => {
|
||||||
expect(body.length).toBe(1);
|
expect(body.length).toBe(1);
|
||||||
expect(body[0]).toEqual(reaction);
|
expect(body[0]).toEqual(reaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('asset activity: add 2 assets to album, get activity with both asset ids', async () => {
|
||||||
|
const asset1 = await utils.createAsset(admin.accessToken);
|
||||||
|
const asset2 = await utils.createAsset(admin.accessToken);
|
||||||
|
|
||||||
|
await addAssetsToAlbum(
|
||||||
|
{
|
||||||
|
id: album.id,
|
||||||
|
bulkIdsDto: { ids: [asset1.id, asset2.id] },
|
||||||
|
},
|
||||||
|
{ headers: asBearerAuth(admin.accessToken) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/activities')
|
||||||
|
.query({ albumId: album.id })
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.length).toBe(1);
|
||||||
|
expect(body[0].type).toBe('asset');
|
||||||
|
expect(body[0].assetIds).toEqual(expect.arrayContaining([asset1.id, asset2.id]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asset activity: add 2 assets and remove 1 asset, get activity with remaining asset id', async () => {
|
||||||
|
const asset1 = await utils.createAsset(admin.accessToken);
|
||||||
|
const asset2 = await utils.createAsset(admin.accessToken);
|
||||||
|
|
||||||
|
await addAssetsToAlbum(
|
||||||
|
{
|
||||||
|
id: album.id,
|
||||||
|
bulkIdsDto: { ids: [asset1.id, asset2.id] },
|
||||||
|
},
|
||||||
|
{ headers: asBearerAuth(admin.accessToken) },
|
||||||
|
);
|
||||||
|
await removeAssetFromAlbum(
|
||||||
|
{
|
||||||
|
id: album.id,
|
||||||
|
bulkIdsDto: {
|
||||||
|
ids: [asset1.id],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ headers: asBearerAuth(admin.accessToken) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/activities')
|
||||||
|
.query({ albumId: album.id })
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.length).toBe(1);
|
||||||
|
expect(body[0].type).toBe('asset');
|
||||||
|
expect(body[0].assetIds).toEqual(expect.arrayContaining([asset2.id]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /activities', () => {
|
describe('POST /activities', () => {
|
||||||
|
|
@ -174,6 +228,7 @@ describe('/activities', () => {
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
assetId: null,
|
assetId: null,
|
||||||
|
assetIds: null,
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
type: 'comment',
|
type: 'comment',
|
||||||
comment: 'This is my first comment',
|
comment: 'This is my first comment',
|
||||||
|
|
@ -190,6 +245,7 @@ describe('/activities', () => {
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
assetId: null,
|
assetId: null,
|
||||||
|
assetIds: null,
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
type: 'like',
|
type: 'like',
|
||||||
comment: null,
|
comment: null,
|
||||||
|
|
@ -235,6 +291,7 @@ describe('/activities', () => {
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
assetIds: null,
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
type: 'comment',
|
type: 'comment',
|
||||||
comment: 'This is my first comment',
|
comment: 'This is my first comment',
|
||||||
|
|
@ -251,6 +308,7 @@ describe('/activities', () => {
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
assetIds: null,
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
type: 'like',
|
type: 'like',
|
||||||
comment: null,
|
comment: null,
|
||||||
|
|
|
||||||
11
mobile/openapi/lib/model/activity_create_dto.dart
generated
11
mobile/openapi/lib/model/activity_create_dto.dart
generated
|
|
@ -15,6 +15,7 @@ class ActivityCreateDto {
|
||||||
ActivityCreateDto({
|
ActivityCreateDto({
|
||||||
required this.albumId,
|
required this.albumId,
|
||||||
this.assetId,
|
this.assetId,
|
||||||
|
this.assetIds = const [],
|
||||||
this.comment,
|
this.comment,
|
||||||
required this.type,
|
required this.type,
|
||||||
});
|
});
|
||||||
|
|
@ -29,6 +30,8 @@ class ActivityCreateDto {
|
||||||
///
|
///
|
||||||
String? assetId;
|
String? assetId;
|
||||||
|
|
||||||
|
List<String> assetIds;
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
/// 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
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
|
@ -43,6 +46,7 @@ class ActivityCreateDto {
|
||||||
bool operator ==(Object other) => identical(this, other) || other is ActivityCreateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is ActivityCreateDto &&
|
||||||
other.albumId == albumId &&
|
other.albumId == albumId &&
|
||||||
other.assetId == assetId &&
|
other.assetId == assetId &&
|
||||||
|
_deepEquality.equals(other.assetIds, assetIds) &&
|
||||||
other.comment == comment &&
|
other.comment == comment &&
|
||||||
other.type == type;
|
other.type == type;
|
||||||
|
|
||||||
|
|
@ -51,11 +55,12 @@ class ActivityCreateDto {
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(albumId.hashCode) +
|
(albumId.hashCode) +
|
||||||
(assetId == null ? 0 : assetId!.hashCode) +
|
(assetId == null ? 0 : assetId!.hashCode) +
|
||||||
|
(assetIds.hashCode) +
|
||||||
(comment == null ? 0 : comment!.hashCode) +
|
(comment == null ? 0 : comment!.hashCode) +
|
||||||
(type.hashCode);
|
(type.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'ActivityCreateDto[albumId=$albumId, assetId=$assetId, comment=$comment, type=$type]';
|
String toString() => 'ActivityCreateDto[albumId=$albumId, assetId=$assetId, assetIds=$assetIds, comment=$comment, type=$type]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
|
@ -65,6 +70,7 @@ class ActivityCreateDto {
|
||||||
} else {
|
} else {
|
||||||
// json[r'assetId'] = null;
|
// json[r'assetId'] = null;
|
||||||
}
|
}
|
||||||
|
json[r'assetIds'] = this.assetIds;
|
||||||
if (this.comment != null) {
|
if (this.comment != null) {
|
||||||
json[r'comment'] = this.comment;
|
json[r'comment'] = this.comment;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -85,6 +91,9 @@ class ActivityCreateDto {
|
||||||
return ActivityCreateDto(
|
return ActivityCreateDto(
|
||||||
albumId: mapValueOfType<String>(json, r'albumId')!,
|
albumId: mapValueOfType<String>(json, r'albumId')!,
|
||||||
assetId: mapValueOfType<String>(json, r'assetId'),
|
assetId: mapValueOfType<String>(json, r'assetId'),
|
||||||
|
assetIds: json[r'assetIds'] is Iterable
|
||||||
|
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
comment: mapValueOfType<String>(json, r'comment'),
|
comment: mapValueOfType<String>(json, r'comment'),
|
||||||
type: ReactionType.fromJson(json[r'type'])!,
|
type: ReactionType.fromJson(json[r'type'])!,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
16
mobile/openapi/lib/model/activity_response_dto.dart
generated
16
mobile/openapi/lib/model/activity_response_dto.dart
generated
|
|
@ -14,6 +14,7 @@ class ActivityResponseDto {
|
||||||
/// Returns a new [ActivityResponseDto] instance.
|
/// Returns a new [ActivityResponseDto] instance.
|
||||||
ActivityResponseDto({
|
ActivityResponseDto({
|
||||||
required this.assetId,
|
required this.assetId,
|
||||||
|
this.assetIds = const [],
|
||||||
this.comment,
|
this.comment,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -23,6 +24,8 @@ class ActivityResponseDto {
|
||||||
|
|
||||||
String? assetId;
|
String? assetId;
|
||||||
|
|
||||||
|
List<String>? assetIds;
|
||||||
|
|
||||||
String? comment;
|
String? comment;
|
||||||
|
|
||||||
DateTime createdAt;
|
DateTime createdAt;
|
||||||
|
|
@ -36,6 +39,7 @@ class ActivityResponseDto {
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is ActivityResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is ActivityResponseDto &&
|
||||||
other.assetId == assetId &&
|
other.assetId == assetId &&
|
||||||
|
_deepEquality.equals(other.assetIds, assetIds) &&
|
||||||
other.comment == comment &&
|
other.comment == comment &&
|
||||||
other.createdAt == createdAt &&
|
other.createdAt == createdAt &&
|
||||||
other.id == id &&
|
other.id == id &&
|
||||||
|
|
@ -46,6 +50,7 @@ class ActivityResponseDto {
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(assetId == null ? 0 : assetId!.hashCode) +
|
(assetId == null ? 0 : assetId!.hashCode) +
|
||||||
|
(assetIds == null ? 0 : assetIds!.hashCode) +
|
||||||
(comment == null ? 0 : comment!.hashCode) +
|
(comment == null ? 0 : comment!.hashCode) +
|
||||||
(createdAt.hashCode) +
|
(createdAt.hashCode) +
|
||||||
(id.hashCode) +
|
(id.hashCode) +
|
||||||
|
|
@ -53,7 +58,7 @@ class ActivityResponseDto {
|
||||||
(user.hashCode);
|
(user.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'ActivityResponseDto[assetId=$assetId, comment=$comment, createdAt=$createdAt, id=$id, type=$type, user=$user]';
|
String toString() => 'ActivityResponseDto[assetId=$assetId, assetIds=$assetIds, comment=$comment, createdAt=$createdAt, id=$id, type=$type, user=$user]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
|
@ -62,6 +67,11 @@ class ActivityResponseDto {
|
||||||
} else {
|
} else {
|
||||||
// json[r'assetId'] = null;
|
// json[r'assetId'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.assetIds != null) {
|
||||||
|
json[r'assetIds'] = this.assetIds;
|
||||||
|
} else {
|
||||||
|
// json[r'assetIds'] = null;
|
||||||
|
}
|
||||||
if (this.comment != null) {
|
if (this.comment != null) {
|
||||||
json[r'comment'] = this.comment;
|
json[r'comment'] = this.comment;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -84,6 +94,9 @@ class ActivityResponseDto {
|
||||||
|
|
||||||
return ActivityResponseDto(
|
return ActivityResponseDto(
|
||||||
assetId: mapValueOfType<String>(json, r'assetId'),
|
assetId: mapValueOfType<String>(json, r'assetId'),
|
||||||
|
assetIds: json[r'assetIds'] is Iterable
|
||||||
|
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
comment: mapValueOfType<String>(json, r'comment'),
|
comment: mapValueOfType<String>(json, r'comment'),
|
||||||
createdAt: mapDateTime(json, r'createdAt', r'')!,
|
createdAt: mapDateTime(json, r'createdAt', r'')!,
|
||||||
id: mapValueOfType<String>(json, r'id')!,
|
id: mapValueOfType<String>(json, r'id')!,
|
||||||
|
|
@ -137,6 +150,7 @@ class ActivityResponseDto {
|
||||||
/// 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>{
|
||||||
'assetId',
|
'assetId',
|
||||||
|
'assetIds',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'id',
|
'id',
|
||||||
'type',
|
'type',
|
||||||
|
|
|
||||||
3
mobile/openapi/lib/model/reaction_type.dart
generated
3
mobile/openapi/lib/model/reaction_type.dart
generated
|
|
@ -25,11 +25,13 @@ class ReactionType {
|
||||||
|
|
||||||
static const comment = ReactionType._(r'comment');
|
static const comment = ReactionType._(r'comment');
|
||||||
static const like = ReactionType._(r'like');
|
static const like = ReactionType._(r'like');
|
||||||
|
static const asset = ReactionType._(r'asset');
|
||||||
|
|
||||||
/// List of all possible values in this [enum][ReactionType].
|
/// List of all possible values in this [enum][ReactionType].
|
||||||
static const values = <ReactionType>[
|
static const values = <ReactionType>[
|
||||||
comment,
|
comment,
|
||||||
like,
|
like,
|
||||||
|
asset,
|
||||||
];
|
];
|
||||||
|
|
||||||
static ReactionType? fromJson(dynamic value) => ReactionTypeTypeTransformer().decode(value);
|
static ReactionType? fromJson(dynamic value) => ReactionTypeTypeTransformer().decode(value);
|
||||||
|
|
@ -70,6 +72,7 @@ class ReactionTypeTypeTransformer {
|
||||||
switch (data) {
|
switch (data) {
|
||||||
case r'comment': return ReactionType.comment;
|
case r'comment': return ReactionType.comment;
|
||||||
case r'like': return ReactionType.like;
|
case r'like': return ReactionType.like;
|
||||||
|
case r'asset': return ReactionType.asset;
|
||||||
default:
|
default:
|
||||||
if (!allowNull) {
|
if (!allowNull) {
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
|
|
|
||||||
|
|
@ -9977,6 +9977,12 @@
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"assetIds": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -10000,6 +10006,13 @@
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"assetIds": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nullable": true,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
@ -10024,6 +10037,7 @@
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"assetId",
|
"assetId",
|
||||||
|
"assetIds",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"id",
|
"id",
|
||||||
"type",
|
"type",
|
||||||
|
|
@ -13710,7 +13724,8 @@
|
||||||
"ReactionType": {
|
"ReactionType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"comment",
|
"comment",
|
||||||
"like"
|
"like",
|
||||||
|
"asset"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export type UserResponseDto = {
|
||||||
};
|
};
|
||||||
export type ActivityResponseDto = {
|
export type ActivityResponseDto = {
|
||||||
assetId: string | null;
|
assetId: string | null;
|
||||||
|
assetIds: string[] | null;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -33,6 +34,7 @@ export type ActivityResponseDto = {
|
||||||
export type ActivityCreateDto = {
|
export type ActivityCreateDto = {
|
||||||
albumId: string;
|
albumId: string;
|
||||||
assetId?: string;
|
assetId?: string;
|
||||||
|
assetIds?: string[];
|
||||||
comment?: string;
|
comment?: string;
|
||||||
"type": ReactionType;
|
"type": ReactionType;
|
||||||
};
|
};
|
||||||
|
|
@ -4632,7 +4634,8 @@ export enum ReactionLevel {
|
||||||
}
|
}
|
||||||
export enum ReactionType {
|
export enum ReactionType {
|
||||||
Comment = "comment",
|
Comment = "comment",
|
||||||
Like = "like"
|
Like = "like",
|
||||||
|
Asset = "asset"
|
||||||
}
|
}
|
||||||
export enum UserAvatarColor {
|
export enum UserAvatarColor {
|
||||||
Primary = "primary",
|
Primary = "primary",
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export type Activity = {
|
||||||
userId: string;
|
userId: string;
|
||||||
user: User;
|
user: User;
|
||||||
assetId: string | null;
|
assetId: string | null;
|
||||||
|
assetIds: string[] | null;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
isLiked: boolean;
|
isLiked: boolean;
|
||||||
updateId: string;
|
updateId: string;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||||
export enum ReactionType {
|
export enum ReactionType {
|
||||||
COMMENT = 'comment',
|
COMMENT = 'comment',
|
||||||
LIKE = 'like',
|
LIKE = 'like',
|
||||||
|
ASSET = 'asset',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReactionLevel {
|
export enum ReactionLevel {
|
||||||
|
|
@ -23,6 +24,7 @@ export class ActivityResponseDto {
|
||||||
type!: ReactionType;
|
type!: ReactionType;
|
||||||
user!: UserResponseDto;
|
user!: UserResponseDto;
|
||||||
assetId!: string | null;
|
assetId!: string | null;
|
||||||
|
assetIds!: string[] | null;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,15 +65,18 @@ export class ActivityCreateDto extends ActivityDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
assetIds?: string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapActivity = (activity: Activity): ActivityResponseDto => {
|
export const mapActivity = (activity: Activity): ActivityResponseDto => {
|
||||||
return {
|
return {
|
||||||
id: activity.id,
|
id: activity.id,
|
||||||
assetId: activity.assetId,
|
assetId: activity.assetId,
|
||||||
|
assetIds: activity.assetIds,
|
||||||
createdAt: activity.createdAt,
|
createdAt: activity.createdAt,
|
||||||
comment: activity.comment,
|
comment: activity.comment,
|
||||||
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
|
type: activity.assetIds ? ReactionType.ASSET : activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
|
||||||
user: mapUser(activity.user),
|
user: mapUser(activity.user),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ select
|
||||||
count(*) filter (
|
count(*) filter (
|
||||||
where
|
where
|
||||||
"activity"."isLiked" = $1
|
"activity"."isLiked" = $1
|
||||||
|
and "activity"."assetIds" is null
|
||||||
) as "comments",
|
) as "comments",
|
||||||
count(*) filter (
|
count(*) filter (
|
||||||
where
|
where
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,25 @@ export class ActivityRepository {
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateAssetIds(id: string, assetIds: string[]) {
|
||||||
|
await this.db.updateTable('activity').set({ assetIds }).where('id', '=', id).execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRecentAssetIdsActivity(albumId: string, userId: string, minutes = 15) {
|
||||||
|
const since = new Date(Date.now() - minutes * 60 * 1000);
|
||||||
|
return this.db
|
||||||
|
.selectFrom('activity')
|
||||||
|
.selectAll()
|
||||||
|
.where('albumId', '=', albumId)
|
||||||
|
.where('userId', '=', userId)
|
||||||
|
.where('assetId', 'is', null)
|
||||||
|
.where('isLiked', '=', false)
|
||||||
|
.where('assetIds', 'is not', null)
|
||||||
|
.where(({ eb }) => eb('createdAt', '>=', since))
|
||||||
|
.orderBy('createdAt', 'desc')
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
await this.db.deleteFrom('activity').where('id', '=', asUuid(id)).execute();
|
await this.db.deleteFrom('activity').where('id', '=', asUuid(id)).execute();
|
||||||
|
|
@ -81,7 +100,11 @@ export class ActivityRepository {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('activity')
|
.selectFrom('activity')
|
||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
eb.fn.countAll<number>().filterWhere('activity.isLiked', '=', false).as('comments'),
|
eb.fn
|
||||||
|
.countAll<number>()
|
||||||
|
.filterWhere('activity.isLiked', '=', false)
|
||||||
|
.filterWhere('activity.assetIds', 'is', null)
|
||||||
|
.as('comments'),
|
||||||
eb.fn.countAll<number>().filterWhere('activity.isLiked', '=', true).as('likes'),
|
eb.fn.countAll<number>().filterWhere('activity.isLiked', '=', true).as('likes'),
|
||||||
])
|
])
|
||||||
.innerJoin('user', (join) => join.onRef('user.id', '=', 'activity.userId').on('user.deletedAt', 'is', null))
|
.innerJoin('user', (join) => join.onRef('user.id', '=', 'activity.userId').on('user.deletedAt', 'is', null))
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ type EventMap = {
|
||||||
// album events
|
// album events
|
||||||
AlbumUpdate: [{ id: string; recipientId: string }];
|
AlbumUpdate: [{ id: string; recipientId: string }];
|
||||||
AlbumInvite: [{ id: string; userId: string }];
|
AlbumInvite: [{ id: string; userId: string }];
|
||||||
|
AlbumAssets: [{ id: string; assetIds: string[]; userId: string }];
|
||||||
|
|
||||||
// asset events
|
// asset events
|
||||||
AssetTag: [{ assetId: string }];
|
AssetTag: [{ assetId: string }];
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,23 @@ export const album_asset_delete_audit = registerFunction({
|
||||||
END`,
|
END`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const activity_assetids_update_on_albumasset_delete = registerFunction({
|
||||||
|
name: 'activity_assetids_update_on_albumasset_delete',
|
||||||
|
returnType: 'TRIGGER',
|
||||||
|
language: 'PLPGSQL',
|
||||||
|
body: `
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT "albumsId", "assetsId" FROM old LOOP
|
||||||
|
UPDATE activity
|
||||||
|
SET "assetIds" = array_remove("assetIds", r."assetsId")
|
||||||
|
WHERE "albumId" = r."albumsId" AND "assetIds" IS NOT NULL AND r."assetsId" = ANY("assetIds");
|
||||||
|
END LOOP;
|
||||||
|
RETURN NULL;
|
||||||
|
END;`,
|
||||||
|
});
|
||||||
|
|
||||||
export const album_user_delete_audit = registerFunction({
|
export const album_user_delete_audit = registerFunction({
|
||||||
name: 'album_user_delete_audit',
|
name: 'album_user_delete_audit',
|
||||||
returnType: 'TRIGGER',
|
returnType: 'TRIGGER',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`CREATE OR REPLACE FUNCTION activity_assetids_update_on_albumasset_delete()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE PLPGSQL
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT "albumsId", "assetsId" FROM old LOOP
|
||||||
|
UPDATE activity
|
||||||
|
SET "assetIds" = array_remove("assetIds", r."assetsId")
|
||||||
|
WHERE "albumId" = r."albumsId" AND "assetIds" IS NOT NULL AND r."assetsId" = ANY("assetIds");
|
||||||
|
END LOOP;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;`.execute(db);
|
||||||
|
await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_like_check";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "activity" ADD "assetIds" uuid[];`.execute(db);
|
||||||
|
await sql`CREATE OR REPLACE TRIGGER "activity_assetids_update_on_albumasset_delete"
|
||||||
|
AFTER DELETE ON "album_asset"
|
||||||
|
REFERENCING OLD TABLE AS "old"
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
WHEN (pg_trigger_depth() <= 1)
|
||||||
|
EXECUTE FUNCTION activity_assetids_update_on_albumasset_delete();`.execute(db);
|
||||||
|
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_activity_assetids_update_on_albumasset_delete', '{"type":"function","name":"activity_assetids_update_on_albumasset_delete","sql":"CREATE OR REPLACE FUNCTION activity_assetids_update_on_albumasset_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n DECLARE\\n r RECORD;\\n BEGIN\\n FOR r IN SELECT \\"albumsId\\", \\"assetsId\\" FROM old LOOP\\n UPDATE activity\\n SET \\"assetIds\\" = array_remove(\\"assetIds\\", r.\\"assetsId\\")\\n WHERE \\"albumId\\" = r.\\"albumsId\\" AND \\"assetIds\\" IS NOT NULL AND r.\\"assetsId\\" = ANY(\\"assetIds\\");\\n END LOOP;\\n RETURN NULL;\\n END;\\n $$;"}'::jsonb);`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_activity_assetids_update_on_albumasset_delete', '{"type":"trigger","name":"activity_assetids_update_on_albumasset_delete","sql":"CREATE OR REPLACE TRIGGER \\"activity_assetids_update_on_albumasset_delete\\"\\n AFTER DELETE ON \\"album_asset\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION activity_assetids_update_on_albumasset_delete();"}'::jsonb);`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_check" CHECK ((comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false) OR (comment IS NULL AND "isLiked" = false AND "assetIds" IS NOT NULL));`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_check";`.execute(db);
|
||||||
|
await sql`DROP TRIGGER "activity_assetids_update_on_albumasset_delete" ON "album_asset";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_like_check" CHECK (((((comment IS NULL) AND ("isLiked" = true)) OR ((comment IS NOT NULL) AND ("isLiked" = false)))));`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`ALTER TABLE "activity" DROP COLUMN "assetIds";`.execute(db);
|
||||||
|
await sql`DROP FUNCTION activity_assetids_update_on_albumasset_delete;`.execute(db);
|
||||||
|
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_activity_assetids_update_on_albumasset_delete';`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_activity_assetids_update_on_albumasset_delete';`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,8 +26,8 @@ import {
|
||||||
where: '("isLiked" = true)',
|
where: '("isLiked" = true)',
|
||||||
})
|
})
|
||||||
@Check({
|
@Check({
|
||||||
name: 'activity_like_check',
|
name: 'activity_check',
|
||||||
expression: `(comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false)`,
|
expression: `(comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false) OR (comment IS NULL AND "isLiked" = false AND "assetIds" IS NOT NULL)`,
|
||||||
})
|
})
|
||||||
@ForeignKeyConstraint({
|
@ForeignKeyConstraint({
|
||||||
columns: ['albumId', 'assetId'],
|
columns: ['albumId', 'assetId'],
|
||||||
|
|
@ -55,6 +55,9 @@ export class ActivityTable {
|
||||||
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
|
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
|
||||||
assetId!: string | null;
|
assetId!: string | null;
|
||||||
|
|
||||||
|
@Column({ array: true, type: 'uuid', default: null })
|
||||||
|
assetIds!: string[] | null;
|
||||||
|
|
||||||
@Column({ type: 'text', default: null })
|
@Column({ type: 'text', default: null })
|
||||||
comment!: string | null;
|
comment!: string | null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||||
import { album_asset_delete_audit } from 'src/schema/functions';
|
import { activity_assetids_update_on_albumasset_delete, album_asset_delete_audit } from 'src/schema/functions';
|
||||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,6 +20,12 @@ import {
|
||||||
referencingOldTableAs: 'old',
|
referencingOldTableAs: 'old',
|
||||||
when: 'pg_trigger_depth() <= 1',
|
when: 'pg_trigger_depth() <= 1',
|
||||||
})
|
})
|
||||||
|
@AfterDeleteTrigger({
|
||||||
|
scope: 'statement',
|
||||||
|
function: activity_assetids_update_on_albumasset_delete,
|
||||||
|
referencingOldTableAs: 'old',
|
||||||
|
when: 'pg_trigger_depth() <= 1',
|
||||||
|
})
|
||||||
export class AlbumAssetTable {
|
export class AlbumAssetTable {
|
||||||
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
|
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
|
||||||
albumsId!: string;
|
albumsId!: string;
|
||||||
|
|
|
||||||
|
|
@ -164,4 +164,46 @@ describe(ActivityService.name, () => {
|
||||||
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('upsertAssetIds', () => {
|
||||||
|
it('should update recent activity assetIds if recent exists', async () => {
|
||||||
|
const [albumId, userId] = newUuids();
|
||||||
|
const recent = factory.activity({ albumId, userId, assetIds: ['a', 'b'] });
|
||||||
|
const dto = { albumId, assetIds: ['b', 'c'], type: ReactionType.ASSET };
|
||||||
|
|
||||||
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
|
mocks.activity.findRecentAssetIdsActivity.mockResolvedValue(recent);
|
||||||
|
mocks.activity.updateAssetIds.mockResolvedValue();
|
||||||
|
mocks.activity.search.mockResolvedValue([{ ...recent, assetIds: ['a', 'b', 'c'] }]);
|
||||||
|
|
||||||
|
const result = await sut.upsertAssetIds(factory.auth({ user: { id: userId } }), dto);
|
||||||
|
|
||||||
|
expect(mocks.activity.updateAssetIds).toHaveBeenCalledWith(recent.id, ['a', 'b', 'c']);
|
||||||
|
expect(result.duplicate).toBe(true);
|
||||||
|
expect(result.value.assetIds).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create new activity if no recent exists', async () => {
|
||||||
|
const [albumId, userId] = newUuids();
|
||||||
|
const dto = { albumId, assetIds: ['x', 'y'], type: ReactionType.ASSET };
|
||||||
|
const created = factory.activity({ albumId, userId, assetIds: ['x', 'y'] });
|
||||||
|
|
||||||
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
|
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||||
|
mocks.activity.findRecentAssetIdsActivity.mockResolvedValue(undefined);
|
||||||
|
mocks.activity.create.mockResolvedValue(created);
|
||||||
|
|
||||||
|
const result = await sut.upsertAssetIds(factory.auth({ user: { id: userId } }), dto);
|
||||||
|
|
||||||
|
expect(mocks.activity.create).toHaveBeenCalledWith({
|
||||||
|
userId,
|
||||||
|
albumId,
|
||||||
|
assetIds: ['x', 'y'],
|
||||||
|
isLiked: false,
|
||||||
|
comment: undefined,
|
||||||
|
});
|
||||||
|
expect(result.duplicate).toBe(false);
|
||||||
|
expect(result.value.assetIds).toEqual(['x', 'y']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Activity } from 'src/database';
|
import { Activity } from 'src/database';
|
||||||
|
import { OnEvent } from 'src/decorators';
|
||||||
import {
|
import {
|
||||||
ActivityCreateDto,
|
ActivityCreateDto,
|
||||||
ActivityDto,
|
ActivityDto,
|
||||||
|
|
@ -26,7 +27,7 @@ export class ActivityService extends BaseService {
|
||||||
isLiked: dto.type && dto.type === ReactionType.LIKE,
|
isLiked: dto.type && dto.type === ReactionType.LIKE,
|
||||||
});
|
});
|
||||||
|
|
||||||
return activities.map((activity) => mapActivity(activity));
|
return activities.filter((a) => !a.assetIds || a.assetIds?.length > 0).map((activity) => mapActivity(activity));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
|
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
|
||||||
|
|
@ -40,6 +41,7 @@ export class ActivityService extends BaseService {
|
||||||
const common = {
|
const common = {
|
||||||
userId: auth.user.id,
|
userId: auth.user.id,
|
||||||
assetId: dto.assetId,
|
assetId: dto.assetId,
|
||||||
|
assetIds: dto.assetIds,
|
||||||
albumId: dto.albumId,
|
albumId: dto.albumId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -68,8 +70,58 @@ export class ActivityService extends BaseService {
|
||||||
return { duplicate, value: mapActivity(activity) };
|
return { duplicate, value: mapActivity(activity) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertAssetIds(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
|
||||||
|
await this.requireAccess({ auth, permission: Permission.ActivityCreate, ids: [dto.albumId] });
|
||||||
|
|
||||||
|
const recent = await this.activityRepository.findRecentAssetIdsActivity(dto.albumId, auth.user.id, 15);
|
||||||
|
this.logger.debug(
|
||||||
|
`Recent activity for user ${auth.user.id} in album ${dto.albumId}: ${recent ? recent.id : 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let activity: Activity;
|
||||||
|
let duplicate = false;
|
||||||
|
|
||||||
|
if (recent) {
|
||||||
|
// Merge assetIds (remove duplicates)
|
||||||
|
const oldIds: string[] = Array.isArray(recent.assetIds) ? recent.assetIds : [];
|
||||||
|
const newIds: string[] = Array.isArray(dto.assetIds) ? dto.assetIds : [];
|
||||||
|
const merged = [...new Set([...oldIds, ...newIds])];
|
||||||
|
await this.activityRepository.updateAssetIds(recent.id, merged);
|
||||||
|
// get the updated record (including user info)
|
||||||
|
const [updated] = await this.activityRepository.search({
|
||||||
|
userId: auth.user.id,
|
||||||
|
albumId: dto.albumId,
|
||||||
|
assetId: null,
|
||||||
|
isLiked: false,
|
||||||
|
});
|
||||||
|
activity = updated ?? { ...recent, assetIds: merged };
|
||||||
|
duplicate = true;
|
||||||
|
|
||||||
|
return { duplicate, value: mapActivity(activity) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.create({ user: { id: auth.user.id } } as AuthDto, {
|
||||||
|
assetIds: dto.assetIds,
|
||||||
|
albumId: dto.albumId,
|
||||||
|
type: ReactionType.ASSET,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||||
await this.requireAccess({ auth, permission: Permission.ActivityDelete, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.ActivityDelete, ids: [id] });
|
||||||
await this.activityRepository.delete(id);
|
await this.activityRepository.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'AlbumAssets' })
|
||||||
|
async handleAlbumAssetsEvent(payload: { id: string; assetIds: string[]; userId: string }) {
|
||||||
|
this.logger.debug(
|
||||||
|
`AlbumAssets event received: albumId=${payload.id}, userId=${payload.userId}, assetIds=${payload.assetIds.join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.upsertAssetIds({ user: { id: payload.userId } } as AuthDto, {
|
||||||
|
assetIds: payload.assetIds,
|
||||||
|
albumId: payload.id,
|
||||||
|
type: ReactionType.ASSET,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,9 @@ export class AlbumService extends BaseService {
|
||||||
for (const recipientId of allUsersExceptUs) {
|
for (const recipientId of allUsersExceptUs) {
|
||||||
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
|
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
|
||||||
|
await this.eventRepository.emit('AlbumAssets', { id, assetIds: newAssetIds, userId: auth.user.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -227,6 +230,12 @@ export class AlbumService extends BaseService {
|
||||||
results.error = undefined;
|
results.error = undefined;
|
||||||
results.success = true;
|
results.success = true;
|
||||||
|
|
||||||
|
await this.eventRepository.emit('AlbumAssets', {
|
||||||
|
id: albumId,
|
||||||
|
assetIds: notPresentAssetIds,
|
||||||
|
userId: auth.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
for (const assetId of notPresentAssetIds) {
|
for (const assetId of notPresentAssetIds) {
|
||||||
albumAssetValues.push({ albumsId: albumId, assetsId: assetId });
|
albumAssetValues.push({ albumsId: albumId, assetsId: assetId });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ const activityFactory = (activity: Partial<Activity> = {}) => {
|
||||||
userId,
|
userId,
|
||||||
user: userFactory({ id: userId }),
|
user: userFactory({ id: userId }),
|
||||||
assetId: newUuid(),
|
assetId: newUuid(),
|
||||||
|
assetIds: null,
|
||||||
albumId: newUuid(),
|
albumId: newUuid(),
|
||||||
createdAt: newDate(),
|
createdAt: newDate(),
|
||||||
updatedAt: newDate(),
|
updatedAt: newDate(),
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@
|
||||||
const deleteMessages: Record<ReactionType, string> = {
|
const deleteMessages: Record<ReactionType, string> = {
|
||||||
[ReactionType.Comment]: $t('comment_deleted'),
|
[ReactionType.Comment]: $t('comment_deleted'),
|
||||||
[ReactionType.Like]: $t('like_deleted'),
|
[ReactionType.Like]: $t('like_deleted'),
|
||||||
|
[ReactionType.Asset]: '', // can not delete asset activity
|
||||||
};
|
};
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: deleteMessages[reaction.type],
|
message: deleteMessages[reaction.type],
|
||||||
|
|
@ -239,6 +240,41 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if reaction.type === ReactionType.Asset}
|
||||||
|
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UserAvatar user={reaction.user} size="sm" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full leading-4 overflow-hidden self-center break-words text-sm">
|
||||||
|
{$t('asset_added_to_album')}
|
||||||
|
</div>
|
||||||
|
{#if assetId === undefined && reaction.assetIds && reaction.assetIds.length > 0}
|
||||||
|
<div class="relative aspect-square w-[75px] h-[75px]">
|
||||||
|
<a href={resolve(`${AppRoute.ALBUMS}/${albumId}/photos/${reaction.assetIds[0]}`)}>
|
||||||
|
<img
|
||||||
|
class="rounded-lg w-[75px] h-[75px] object-cover"
|
||||||
|
src={getAssetThumbnailUrl(reaction.assetIds[0])}
|
||||||
|
alt="Profile picture of {reaction.user.name}, who added this asset"
|
||||||
|
/>
|
||||||
|
{#if reaction.assetIds.length > 1}
|
||||||
|
<span
|
||||||
|
class="absolute bottom-1 right-1 bg-black bg-opacity-60 text-white text-xs rounded px-2 py-0.5 pointer-events-none select-none"
|
||||||
|
>
|
||||||
|
+{reaction.assetIds.length - 1}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
|
||||||
|
<div
|
||||||
|
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
||||||
|
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
|
||||||
|
>
|
||||||
|
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue