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 <alex.tran1502@gmail.com>
This commit is contained in:
martin 2023-11-07 05:37:21 +01:00 committed by GitHub
parent ace0a5911c
commit 9d01885b58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 293 additions and 24 deletions

View File

@ -331,6 +331,12 @@ export interface AlbumResponseDto {
* @memberof AlbumResponseDto * @memberof AlbumResponseDto
*/ */
'id': string; 'id': string;
/**
*
* @type {boolean}
* @memberof AlbumResponseDto
*/
'isActivityEnabled': boolean;
/** /**
* *
* @type {string} * @type {string}
@ -4160,6 +4166,12 @@ export interface UpdateAlbumDto {
* @memberof UpdateAlbumDto * @memberof UpdateAlbumDto
*/ */
'description'?: string; 'description'?: string;
/**
*
* @type {boolean}
* @memberof UpdateAlbumDto
*/
'isActivityEnabled'?: boolean;
} }
/** /**
* *

View File

@ -17,6 +17,7 @@ Name | Type | Description | Notes
**endDate** | [**DateTime**](DateTime.md) | | [optional] **endDate** | [**DateTime**](DateTime.md) | | [optional]
**hasSharedLink** | **bool** | | **hasSharedLink** | **bool** | |
**id** | **String** | | **id** | **String** | |
**isActivityEnabled** | **bool** | |
**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional] **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional]
**owner** | [**UserResponseDto**](UserResponseDto.md) | | **owner** | [**UserResponseDto**](UserResponseDto.md) | |
**ownerId** | **String** | | **ownerId** | **String** | |

View File

@ -11,6 +11,7 @@ Name | Type | Description | Notes
**albumName** | **String** | | [optional] **albumName** | **String** | | [optional]
**albumThumbnailAssetId** | **String** | | [optional] **albumThumbnailAssetId** | **String** | | [optional]
**description** | **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) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -22,6 +22,7 @@ class AlbumResponseDto {
this.endDate, this.endDate,
required this.hasSharedLink, required this.hasSharedLink,
required this.id, required this.id,
required this.isActivityEnabled,
this.lastModifiedAssetTimestamp, this.lastModifiedAssetTimestamp,
required this.owner, required this.owner,
required this.ownerId, required this.ownerId,
@ -55,6 +56,8 @@ class AlbumResponseDto {
String id; String id;
bool isActivityEnabled;
/// ///
/// 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
@ -92,6 +95,7 @@ class AlbumResponseDto {
other.endDate == endDate && other.endDate == endDate &&
other.hasSharedLink == hasSharedLink && other.hasSharedLink == hasSharedLink &&
other.id == id && other.id == id &&
other.isActivityEnabled == isActivityEnabled &&
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
other.owner == owner && other.owner == owner &&
other.ownerId == ownerId && other.ownerId == ownerId &&
@ -112,6 +116,7 @@ class AlbumResponseDto {
(endDate == null ? 0 : endDate!.hashCode) + (endDate == null ? 0 : endDate!.hashCode) +
(hasSharedLink.hashCode) + (hasSharedLink.hashCode) +
(id.hashCode) + (id.hashCode) +
(isActivityEnabled.hashCode) +
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
(owner.hashCode) + (owner.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
@ -121,7 +126,7 @@ class AlbumResponseDto {
(updatedAt.hashCode); (updatedAt.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -142,6 +147,7 @@ class AlbumResponseDto {
} }
json[r'hasSharedLink'] = this.hasSharedLink; json[r'hasSharedLink'] = this.hasSharedLink;
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'isActivityEnabled'] = this.isActivityEnabled;
if (this.lastModifiedAssetTimestamp != null) { if (this.lastModifiedAssetTimestamp != null) {
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
} else { } else {
@ -177,6 +183,7 @@ class AlbumResponseDto {
endDate: mapDateTime(json, r'endDate', ''), endDate: mapDateTime(json, r'endDate', ''),
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!, hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''), lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''),
owner: UserResponseDto.fromJson(json[r'owner'])!, owner: UserResponseDto.fromJson(json[r'owner'])!,
ownerId: mapValueOfType<String>(json, r'ownerId')!, ownerId: mapValueOfType<String>(json, r'ownerId')!,
@ -239,6 +246,7 @@ class AlbumResponseDto {
'description', 'description',
'hasSharedLink', 'hasSharedLink',
'id', 'id',
'isActivityEnabled',
'owner', 'owner',
'ownerId', 'ownerId',
'shared', 'shared',

View File

@ -16,6 +16,7 @@ class UpdateAlbumDto {
this.albumName, this.albumName,
this.albumThumbnailAssetId, this.albumThumbnailAssetId,
this.description, this.description,
this.isActivityEnabled,
}); });
/// ///
@ -42,21 +43,31 @@ class UpdateAlbumDto {
/// ///
String? description; 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 @override
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto && bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
other.albumName == albumName && other.albumName == albumName &&
other.albumThumbnailAssetId == albumThumbnailAssetId && other.albumThumbnailAssetId == albumThumbnailAssetId &&
other.description == description; other.description == description &&
other.isActivityEnabled == isActivityEnabled;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(albumName == null ? 0 : albumName!.hashCode) + (albumName == null ? 0 : albumName!.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(description == null ? 0 : description!.hashCode); (description == null ? 0 : description!.hashCode) +
(isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode);
@override @override
String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]'; String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -75,6 +86,11 @@ class UpdateAlbumDto {
} else { } else {
// json[r'description'] = null; // json[r'description'] = null;
} }
if (this.isActivityEnabled != null) {
json[r'isActivityEnabled'] = this.isActivityEnabled;
} else {
// json[r'isActivityEnabled'] = null;
}
return json; return json;
} }
@ -89,6 +105,7 @@ class UpdateAlbumDto {
albumName: mapValueOfType<String>(json, r'albumName'), albumName: mapValueOfType<String>(json, r'albumName'),
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'), albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled'),
); );
} }
return null; return null;

View File

@ -61,6 +61,11 @@ void main() {
// TODO // TODO
}); });
// bool isActivityEnabled
test('to test the property `isActivityEnabled`', () async {
// TODO
});
// DateTime lastModifiedAssetTimestamp // DateTime lastModifiedAssetTimestamp
test('to test the property `lastModifiedAssetTimestamp`', () async { test('to test the property `lastModifiedAssetTimestamp`', () async {
// TODO // TODO

View File

@ -31,6 +31,11 @@ void main() {
// TODO // TODO
}); });
// bool isActivityEnabled
test('to test the property `isActivityEnabled`', () async {
// TODO
});
}); });

View File

@ -5894,6 +5894,9 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"isActivityEnabled": {
"type": "boolean"
},
"lastModifiedAssetTimestamp": { "lastModifiedAssetTimestamp": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
@ -5935,7 +5938,8 @@
"sharedUsers", "sharedUsers",
"hasSharedLink", "hasSharedLink",
"assets", "assets",
"owner" "owner",
"isActivityEnabled"
], ],
"type": "object" "type": "object"
}, },
@ -8910,6 +8914,9 @@
}, },
"description": { "description": {
"type": "string" "type": "string"
},
"isActivityEnabled": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"

View File

@ -138,10 +138,7 @@ export class AccessCore {
switch (permission) { switch (permission) {
// uses album id // uses album id
case Permission.ACTIVITY_CREATE: case Permission.ACTIVITY_CREATE:
return ( return await this.repository.activity.hasCreateAccess(authUser.id, id);
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
);
// uses activity id // uses activity id
case Permission.ACTIVITY_DELETE: case Permission.ACTIVITY_DELETE:

View File

@ -94,7 +94,7 @@ describe(ActivityService.name, () => {
}); });
it('should create a comment', async () => { it('should create a comment', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.activity.hasCreateAccess.mockResolvedValue(true);
activityMock.create.mockResolvedValue(activityStub.oneComment); activityMock.create.mockResolvedValue(activityStub.oneComment);
await sut.create(authStub.admin, { 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.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.create.mockResolvedValue(activityStub.liked);
activityMock.search.mockResolvedValue([]); activityMock.search.mockResolvedValue([]);
@ -134,6 +149,7 @@ describe(ActivityService.name, () => {
it('should skip if like exists', async () => { it('should skip if like exists', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
activityMock.search.mockResolvedValue([activityStub.liked]); activityMock.search.mockResolvedValue([activityStub.liked]);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {

View File

@ -21,6 +21,7 @@ export class AlbumResponseDto {
lastModifiedAssetTimestamp?: Date; lastModifiedAssetTimestamp?: Date;
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
isActivityEnabled!: boolean;
} }
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => {
@ -61,6 +62,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons
endDate, endDate,
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)), assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)),
assetCount: entity.assets?.length || 0, assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled,
}; };
}; };

View File

@ -125,12 +125,12 @@ export class AlbumService {
throw new BadRequestException('Invalid album thumbnail'); throw new BadRequestException('Invalid album thumbnail');
} }
} }
const updatedAlbum = await this.albumRepository.update({ const updatedAlbum = await this.albumRepository.update({
id: album.id, id: album.id,
albumName: dto.albumName, albumName: dto.albumName,
description: dto.description, description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId, albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
}); });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } });

View File

@ -1,4 +1,4 @@
import { IsString } from 'class-validator'; import { IsBoolean, IsString } from 'class-validator';
import { Optional, ValidateUUID } from '../../domain.util'; import { Optional, ValidateUUID } from '../../domain.util';
export class UpdateAlbumDto { export class UpdateAlbumDto {
@ -12,4 +12,8 @@ export class UpdateAlbumDto {
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true })
albumThumbnailAssetId?: string; albumThumbnailAssetId?: string;
@Optional()
@IsBoolean()
isActivityEnabled?: boolean;
} }

View File

@ -2,8 +2,9 @@ export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository { export interface IAccessRepository {
activity: { activity: {
hasOwnerAccess(userId: string, albumId: string): Promise<boolean>; hasOwnerAccess(userId: string, activityId: string): Promise<boolean>;
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>; hasAlbumOwnerAccess(userId: string, activityId: string): Promise<boolean>;
hasCreateAccess(userId: string, albumId: string): Promise<boolean>;
}; };
asset: { asset: {
hasOwnerAccess(userId: string, assetId: string): Promise<boolean>; hasOwnerAccess(userId: string, assetId: string): Promise<boolean>;

View File

@ -56,4 +56,7 @@ export class AlbumEntity {
@OneToMany(() => SharedLinkEntity, (link) => link.album) @OneToMany(() => SharedLinkEntity, (link) => link.album)
sharedLinks!: SharedLinkEntity[]; sharedLinks!: SharedLinkEntity[];
@Column({ default: true })
isActivityEnabled!: boolean;
} }

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DisableActivity1699268680508 implements MigrationInterface {
name = 'DisableActivity1699268680508'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" ADD "isActivityEnabled" boolean NOT NULL DEFAULT true`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "isActivityEnabled"`);
}
}

View File

@ -43,6 +43,24 @@ export class AccessRepository implements IAccessRepository {
}, },
}); });
}, },
hasCreateAccess: (userId: string, albumId: string): Promise<boolean> => {
return this.albumRepository.exist({
where: [
{
id: albumId,
isActivityEnabled: true,
sharedUsers: {
id: userId,
},
},
{
id: albumId,
isActivityEnabled: true,
ownerId: userId,
},
],
});
},
}; };
library = { library = {
hasOwnerAccess: (userId: string, libraryId: string): Promise<boolean> => { hasOwnerAccess: (userId: string, libraryId: string): Promise<boolean> => {

View File

@ -226,6 +226,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
assets: [], assets: [],
assetCount: 0, assetCount: 0,
owner: expect.objectContaining({ email: user1.userEmail }), owner: expect.objectContaining({ email: user1.userEmail }),
isActivityEnabled: true,
}); });
}); });
}); });

View File

@ -18,6 +18,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true,
}), }),
sharedWithUser: Object.freeze<AlbumEntity>({ sharedWithUser: Object.freeze<AlbumEntity>({
id: 'album-2', id: 'album-2',
@ -33,6 +34,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.user1], sharedUsers: [userStub.user1],
isActivityEnabled: true,
}), }),
sharedWithMultiple: Object.freeze<AlbumEntity>({ sharedWithMultiple: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
@ -48,6 +50,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.user1, userStub.user2], sharedUsers: [userStub.user1, userStub.user2],
isActivityEnabled: true,
}), }),
sharedWithAdmin: Object.freeze<AlbumEntity>({ sharedWithAdmin: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
@ -63,6 +66,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.admin], sharedUsers: [userStub.admin],
isActivityEnabled: true,
}), }),
oneAsset: Object.freeze<AlbumEntity>({ oneAsset: Object.freeze<AlbumEntity>({
id: 'album-4', id: 'album-4',
@ -78,6 +82,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true,
}), }),
twoAssets: Object.freeze<AlbumEntity>({ twoAssets: Object.freeze<AlbumEntity>({
id: 'album-4a', id: 'album-4a',
@ -93,6 +98,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true,
}), }),
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({ emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
@ -108,6 +114,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true,
}), }),
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({ emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
@ -123,6 +130,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true,
}), }),
oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({ oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6', id: 'album-6',
@ -138,6 +146,7 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true,
}), }),
oneAssetValidThumbnail: Object.freeze<AlbumEntity>({ oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6', id: 'album-6',
@ -153,5 +162,6 @@ export const albumStub = {
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true,
}), }),
}; };

View File

@ -100,6 +100,7 @@ const albumResponse: AlbumResponseDto = {
hasSharedLink: false, hasSharedLink: false,
assets: [], assets: [],
assetCount: 1, assetCount: 1,
isActivityEnabled: true,
}; };
export const sharedLinkStub = { export const sharedLinkStub = {
@ -179,6 +180,7 @@ export const sharedLinkStub = {
albumThumbnailAssetId: null, albumThumbnailAssetId: null,
sharedUsers: [], sharedUsers: [],
sharedLinks: [], sharedLinks: [],
isActivityEnabled: true,
assets: [ assets: [
{ {
id: 'id_1', id: 'id_1',

View File

@ -19,6 +19,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
activity: { activity: {
hasOwnerAccess: jest.fn(), hasOwnerAccess: jest.fn(),
hasAlbumOwnerAccess: jest.fn(), hasAlbumOwnerAccess: jest.fn(),
hasCreateAccess: jest.fn(),
}, },
asset: { asset: {
hasOwnerAccess: jest.fn(), hasOwnerAccess: jest.fn(),

View File

@ -331,6 +331,12 @@ export interface AlbumResponseDto {
* @memberof AlbumResponseDto * @memberof AlbumResponseDto
*/ */
'id': string; 'id': string;
/**
*
* @type {boolean}
* @memberof AlbumResponseDto
*/
'isActivityEnabled': boolean;
/** /**
* *
* @type {string} * @type {string}
@ -4160,6 +4166,12 @@ export interface UpdateAlbumDto {
* @memberof UpdateAlbumDto * @memberof UpdateAlbumDto
*/ */
'description'?: string; 'description'?: string;
/**
*
* @type {boolean}
* @memberof UpdateAlbumDto
*/
'isActivityEnabled'?: boolean;
} }
/** /**
* *

View File

@ -0,0 +1,76 @@
<script lang="ts">
import { mdiClose, mdiPlus } from '@mdi/js';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { createEventDispatcher } from 'svelte';
import type { AlbumResponseDto, UserResponseDto } from '../../../api/open-api';
import Icon from '$lib/components/elements/icon.svelte';
export let album: AlbumResponseDto;
export let user: UserResponseDto;
const dispatch = createEventDispatcher<{
close: void;
toggleEnableActivity: void;
showSelectSharedUser: void;
}>();
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden p-2 md:p-0">
<div
class="w-[550px] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="px-2 pt-2">
<div class="flex items-center">
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary">Options</h1>
<div>
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
</div>
</div>
<div class=" items-center justify-center p-4">
<div class="py-2">
<h2 class="text-gray text-sm mb-3">SHARING</h2>
<div class="p-2">
<SettingSwitch
title="Comments & likes"
subtitle="Let others respond"
checked={album.isActivityEnabled}
on:toggle={() => dispatch('toggleEnableActivity')}
/>
</div>
</div>
<div class="py-2">
<div class="text-gray text-sm mb-3">PEOPLE</div>
<div class="p-2">
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>
<div>Invite People</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{`${user.firstName} ${user.lastName}`}</div>
<div>Owner</div>
</div>
{#each album.sharedUsers as user (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{`${user.firstName} ${user.lastName}`}</div>
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
</div>
</FullScreenModal>

View File

@ -7,6 +7,7 @@
export let isLiked: ActivityResponseDto | null; export let isLiked: ActivityResponseDto | null;
export let numberOfComments: number | undefined; export let numberOfComments: number | undefined;
export let isShowActivity: boolean | undefined; export let isShowActivity: boolean | undefined;
export let disabled: boolean;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
</script> </script>
@ -14,7 +15,7 @@
<div <div
class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60" class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60"
> >
<button on:click={() => dispatch('favorite')}> <button class={disabled ? 'cursor-not-allowed' : ''} on:click={() => dispatch('favorite')} {disabled}>
<!-- svelte-ignore missing-declaration --> <!-- svelte-ignore missing-declaration -->
<div class="items-center justify-center"> <div class="items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} /> <Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />

View File

@ -38,6 +38,7 @@
export let albumId: string; export let albumId: string;
export let assetType: AssetTypeEnum | undefined = undefined; export let assetType: AssetTypeEnum | undefined = undefined;
export let albumOwnerId: string; export let albumOwnerId: string;
export let disabled: boolean;
let textArea: HTMLTextAreaElement; let textArea: HTMLTextAreaElement;
let innerHeight: number; let innerHeight: number;
@ -280,12 +281,15 @@
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}> <form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
<div class="flex w-full items-center gap-4"> <div class="flex w-full items-center gap-4">
<textarea <textarea
{disabled}
bind:this={textArea} bind:this={textArea}
bind:value={message} bind:value={message}
placeholder="Say something" placeholder={disabled ? 'Comments are disabled' : 'Say something'}
on:input={autoGrow} on:input={autoGrow}
on:keypress={handleEnter} on:keypress={handleEnter}
class="h-[18px] w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200" class="h-[18px] {disabled
? 'cursor-not-allowed'
: ''} w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
/> />
</div> </div>
{#if isSendingMessage} {#if isSendingMessage}

View File

@ -104,6 +104,12 @@
} }
} }
$: {
if (album && !album.isActivityEnabled && numberOfComments === 0) {
isShowActivity = false;
}
}
const handleAddComment = () => { const handleAddComment = () => {
numberOfComments++; numberOfComments++;
updateNumberOfComments(1); updateNumberOfComments(1);
@ -115,7 +121,7 @@
}; };
const handleFavorite = async () => { const handleFavorite = async () => {
if (album) { if (album && album.isActivityEnabled) {
try { try {
if (isLiked) { if (isLiked) {
const activityId = isLiked.id; const activityId = isLiked.id;
@ -661,9 +667,10 @@
on:onVideoStarted={handleVideoStarted} on:onVideoStarted={handleVideoStarted}
/> />
{/if} {/if}
{#if $slideshowState === SlideshowState.None && isShared} {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end"> <div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
<ActivityStatus <ActivityStatus
disabled={!album?.isActivityEnabled}
{isLiked} {isLiked}
{numberOfComments} {numberOfComments}
{isShowActivity} {isShowActivity}
@ -744,6 +751,7 @@
> >
<ActivityViewer <ActivityViewer
{user} {user}
disabled={!album.isActivityEnabled}
assetType={asset.type} assetType={asset.type}
albumOwnerId={album.ownerId} albumOwnerId={album.ownerId}
albumId={album.id} albumId={album.id}

View File

@ -1,11 +1,11 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
export const numberOfComments = writable<number | undefined>(undefined); export const numberOfComments = writable<number>(0);
export const setNumberOfComments = (number: number) => { export const setNumberOfComments = (number: number) => {
numberOfComments.set(number); numberOfComments.set(number);
}; };
export const updateNumberOfComments = (addOrRemove: 1 | -1) => { export const updateNumberOfComments = (addOrRemove: 1 | -1) => {
numberOfComments.update((n) => (n ? n + addOrRemove : undefined)); numberOfComments.update((n) => n + addOrRemove);
}; };

View File

@ -55,6 +55,7 @@
import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte'; import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte';
import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte'; import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
export let data: PageData; export let data: PageData;
@ -64,6 +65,12 @@
let album = data.album; let album = data.album;
$: album = data.album; $: album = data.album;
$: {
if (!album.isActivityEnabled && $numberOfComments === 0) {
isShowActivity = false;
}
}
enum ViewMode { enum ViewMode {
CONFIRM_DELETE = 'confirm-delete', CONFIRM_DELETE = 'confirm-delete',
LINK_SHARING = 'link-sharing', LINK_SHARING = 'link-sharing',
@ -73,6 +80,7 @@
ALBUM_OPTIONS = 'album-options', ALBUM_OPTIONS = 'album-options',
VIEW_USERS = 'view-users', VIEW_USERS = 'view-users',
VIEW = 'view', VIEW = 'view',
OPTIONS = 'options',
} }
let backUrl: string = AppRoute.ALBUMS; let backUrl: string = AppRoute.ALBUMS;
@ -107,6 +115,8 @@
assetGridWidth = globalWidth; assetGridWidth = globalWidth;
} }
} }
$: showActivityStatus =
album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
afterNavigate(({ from }) => { afterNavigate(({ from }) => {
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);
@ -128,6 +138,24 @@
} }
}); });
const handleToggleEnableActivity = async () => {
try {
const { data } = await api.albumApi.updateAlbumInfo({
id: album.id,
updateAlbumDto: {
isActivityEnabled: !album.isActivityEnabled,
},
});
album = data;
notificationController.show({
type: NotificationType.Info,
message: `Activity is ${album.isActivityEnabled ? 'enabled' : 'disabled'}`,
});
} catch (error) {
handleError(error, `Can't ${!album.isActivityEnabled ? 'enable' : 'disable'} activity`);
}
};
const handleFavorite = async () => { const handleFavorite = async () => {
try { try {
if (isLiked) { if (isLiked) {
@ -374,6 +402,7 @@
}, },
}); });
currentAlbumName = album.albumName; currentAlbumName = album.albumName;
notificationController.show({ type: NotificationType.Info, message: 'New album name has been saved' });
} catch (error) { } catch (error) {
handleError(error, 'Unable to update album name'); handleError(error, 'Unable to update album name');
} }
@ -455,6 +484,7 @@
<MenuOption on:click={handleStartSlideshow} text="Slideshow" /> <MenuOption on:click={handleStartSlideshow} text="Slideshow" />
{/if} {/if}
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" /> <MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
<MenuOption on:click={() => (viewMode = ViewMode.OPTIONS)} text="Options" />
</ContextMenu> </ContextMenu>
{/if} {/if}
</CircleIconButton> </CircleIconButton>
@ -630,9 +660,10 @@
</AssetGrid> </AssetGrid>
{/if} {/if}
{#if album.sharedUsers.length > 0 && !$showAssetViewer} {#if showActivityStatus}
<div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end"> <div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
<ActivityStatus <ActivityStatus
disabled={!album.isActivityEnabled}
{isLiked} {isLiked}
numberOfComments={$numberOfComments} numberOfComments={$numberOfComments}
{isShowActivity} {isShowActivity}
@ -648,11 +679,12 @@
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="activity-panel" id="activity-panel"
class="z-[1002] w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4" class="z-[2] w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4"
translate="yes" translate="yes"
> >
<ActivityViewer <ActivityViewer
{user} {user}
disabled={!album.isActivityEnabled}
albumOwnerId={album.ownerId} albumOwnerId={album.ownerId}
albumId={album.id} albumId={album.id}
bind:reactions bind:reactions
@ -700,6 +732,16 @@
</ConfirmDialogue> </ConfirmDialogue>
{/if} {/if}
{#if viewMode === ViewMode.OPTIONS}
<AlbumOptions
{album}
{user}
on:close={() => (viewMode = ViewMode.VIEW)}
on:toggleEnableActivity={handleToggleEnableActivity}
on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}
/>
{/if}
{#if isEditingDescription} {#if isEditingDescription}
<EditDescriptionModal <EditDescriptionModal
{album} {album}

View File

@ -17,4 +17,5 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
shared: false, shared: false,
sharedUsers: [], sharedUsers: [],
hasSharedLink: false, hasSharedLink: false,
isActivityEnabled: true,
}); });