Browse Source

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>
martin 1 year ago
parent
commit
9d01885b58
29 changed files with 293 additions and 24 deletions
  1. 12 0
      cli/src/api/open-api/api.ts
  2. 1 0
      mobile/openapi/doc/AlbumResponseDto.md
  3. 1 0
      mobile/openapi/doc/UpdateAlbumDto.md
  4. 9 1
      mobile/openapi/lib/model/album_response_dto.dart
  5. 20 3
      mobile/openapi/lib/model/update_album_dto.dart
  6. 5 0
      mobile/openapi/test/album_response_dto_test.dart
  7. 5 0
      mobile/openapi/test/update_album_dto_test.dart
  8. 8 1
      server/immich-openapi-specs.json
  9. 1 4
      server/src/domain/access/access.core.ts
  10. 18 2
      server/src/domain/activity/activity.spec.ts
  11. 2 0
      server/src/domain/album/album-response.dto.ts
  12. 1 1
      server/src/domain/album/album.service.ts
  13. 5 1
      server/src/domain/album/dto/album-update.dto.ts
  14. 3 2
      server/src/domain/repositories/access.repository.ts
  15. 3 0
      server/src/infra/entities/album.entity.ts
  16. 14 0
      server/src/infra/migrations/1699268680508-DisableActivity.ts
  17. 18 0
      server/src/infra/repositories/access.repository.ts
  18. 1 0
      server/test/e2e/album.e2e-spec.ts
  19. 10 0
      server/test/fixtures/album.stub.ts
  20. 2 0
      server/test/fixtures/shared-link.stub.ts
  21. 1 0
      server/test/repositories/access.repository.mock.ts
  22. 12 0
      web/src/api/open-api/api.ts
  23. 76 0
      web/src/lib/components/album-page/album-options.svelte
  24. 2 1
      web/src/lib/components/asset-viewer/activity-status.svelte
  25. 6 2
      web/src/lib/components/asset-viewer/activity-viewer.svelte
  26. 10 2
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  27. 2 2
      web/src/lib/stores/activity.store.ts
  28. 44 2
      web/src/routes/(user)/albums/[albumId]/+page.svelte
  29. 1 0
      web/src/test-data/factories/album-factory.ts

+ 12 - 0
cli/src/api/open-api/api.ts

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

+ 1 - 0
mobile/openapi/doc/AlbumResponseDto.md

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

+ 1 - 0
mobile/openapi/doc/UpdateAlbumDto.md

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

+ 9 - 1
mobile/openapi/lib/model/album_response_dto.dart

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

+ 20 - 3
mobile/openapi/lib/model/update_album_dto.dart

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

+ 5 - 0
mobile/openapi/test/album_response_dto_test.dart

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

+ 5 - 0
mobile/openapi/test/update_album_dto_test.dart

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

+ 8 - 1
server/immich-openapi-specs.json

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

+ 1 - 4
server/src/domain/access/access.core.ts

@@ -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 (
-          (await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
-          (await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
-        );
+        return await this.repository.activity.hasCreateAccess(authUser.id, id);
 
 
       // uses activity id
       // uses activity id
       case Permission.ACTIVITY_DELETE:
       case Permission.ACTIVITY_DELETE:

+ 18 - 2
server/src/domain/activity/activity.spec.ts

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

+ 2 - 0
server/src/domain/album/album-response.dto.ts

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

+ 1 - 1
server/src/domain/album/album.service.ts

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

+ 5 - 1
server/src/domain/album/dto/album-update.dto.ts

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

+ 3 - 2
server/src/domain/repositories/access.repository.ts

@@ -2,8 +2,9 @@ export const IAccessRepository = 'IAccessRepository';
 
 
 export interface IAccessRepository {
 export interface IAccessRepository {
   activity: {
   activity: {
-    hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
-    hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
+    hasOwnerAccess(userId: string, activityId: 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>;

+ 3 - 0
server/src/infra/entities/album.entity.ts

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

+ 14 - 0
server/src/infra/migrations/1699268680508-DisableActivity.ts

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

+ 18 - 0
server/src/infra/repositories/access.repository.ts

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

+ 1 - 0
server/test/e2e/album.e2e-spec.ts

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

+ 10 - 0
server/test/fixtures/album.stub.ts

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

+ 2 - 0
server/test/fixtures/shared-link.stub.ts

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

+ 1 - 0
server/test/repositories/access.repository.mock.ts

@@ -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(),

+ 12 - 0
web/src/api/open-api/api.ts

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

+ 76 - 0
web/src/lib/components/album-page/album-options.svelte

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

+ 2 - 1
web/src/lib/components/asset-viewer/activity-status.svelte

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

+ 6 - 2
web/src/lib/components/asset-viewer/activity-viewer.svelte

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

+ 10 - 2
web/src/lib/components/asset-viewer/asset-viewer.svelte

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

+ 2 - 2
web/src/lib/stores/activity.store.ts

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

+ 44 - 2
web/src/routes/(user)/albums/[albumId]/+page.svelte

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

+ 1 - 0
web/src/test-data/factories/album-factory.ts

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