1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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 252 additions and 20 deletions

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -138,10 +138,7 @@ export class AccessCore {
switch (permission) {
// uses album id
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
case Permission.ACTIVITY_DELETE:

View File

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

View File

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

View File

@ -125,12 +125,12 @@ export class AlbumService {
throw new BadRequestException('Invalid album thumbnail');
}
}
const updatedAlbum = await this.albumRepository.update({
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
});
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';
export class UpdateAlbumDto {
@ -12,4 +12,8 @@ export class UpdateAlbumDto {
@ValidateUUID({ optional: true })
albumThumbnailAssetId?: string;
@Optional()
@IsBoolean()
isActivityEnabled?: boolean;
}

View File

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

View File

@ -56,4 +56,7 @@ export class AlbumEntity {
@OneToMany(() => SharedLinkEntity, (link) => link.album)
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 = {
hasOwnerAccess: (userId: string, libraryId: string): Promise<boolean> => {

View File

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

View File

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

View File

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

View File

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

View File

@ -331,6 +331,12 @@ export interface AlbumResponseDto {
* @memberof AlbumResponseDto
*/
'id': string;
/**
*
* @type {boolean}
* @memberof AlbumResponseDto
*/
'isActivityEnabled': boolean;
/**
*
* @type {string}
@ -4160,6 +4166,12 @@ export interface UpdateAlbumDto {
* @memberof UpdateAlbumDto
*/
'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 numberOfComments: number | undefined;
export let isShowActivity: boolean | undefined;
export let disabled: boolean;
const dispatch = createEventDispatcher();
</script>
@ -14,7 +15,7 @@
<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"
>
<button on:click={() => dispatch('favorite')}>
<button class={disabled ? 'cursor-not-allowed' : ''} on:click={() => dispatch('favorite')} {disabled}>
<!-- svelte-ignore missing-declaration -->
<div class="items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />

View File

@ -38,6 +38,7 @@
export let albumId: string;
export let assetType: AssetTypeEnum | undefined = undefined;
export let albumOwnerId: string;
export let disabled: boolean;
let textArea: HTMLTextAreaElement;
let innerHeight: number;
@ -280,12 +281,15 @@
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
<div class="flex w-full items-center gap-4">
<textarea
{disabled}
bind:this={textArea}
bind:value={message}
placeholder="Say something"
placeholder={disabled ? 'Comments are disabled' : 'Say something'}
on:input={autoGrow}
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>
{#if isSendingMessage}

View File

@ -104,6 +104,12 @@
}
}
$: {
if (album && !album.isActivityEnabled && numberOfComments === 0) {
isShowActivity = false;
}
}
const handleAddComment = () => {
numberOfComments++;
updateNumberOfComments(1);
@ -115,7 +121,7 @@
};
const handleFavorite = async () => {
if (album) {
if (album && album.isActivityEnabled) {
try {
if (isLiked) {
const activityId = isLiked.id;
@ -661,9 +667,10 @@
on:onVideoStarted={handleVideoStarted}
/>
{/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">
<ActivityStatus
disabled={!album?.isActivityEnabled}
{isLiked}
{numberOfComments}
{isShowActivity}
@ -744,6 +751,7 @@
>
<ActivityViewer
{user}
disabled={!album.isActivityEnabled}
assetType={asset.type}
albumOwnerId={album.ownerId}
albumId={album.id}

View File

@ -1,11 +1,11 @@
import { writable } from 'svelte/store';
export const numberOfComments = writable<number | undefined>(undefined);
export const numberOfComments = writable<number>(0);
export const setNumberOfComments = (number: number) => {
numberOfComments.set(number);
};
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 ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
export let data: PageData;
@ -64,6 +65,12 @@
let album = data.album;
$: album = data.album;
$: {
if (!album.isActivityEnabled && $numberOfComments === 0) {
isShowActivity = false;
}
}
enum ViewMode {
CONFIRM_DELETE = 'confirm-delete',
LINK_SHARING = 'link-sharing',
@ -73,6 +80,7 @@
ALBUM_OPTIONS = 'album-options',
VIEW_USERS = 'view-users',
VIEW = 'view',
OPTIONS = 'options',
}
let backUrl: string = AppRoute.ALBUMS;
@ -107,6 +115,8 @@
assetGridWidth = globalWidth;
}
}
$: showActivityStatus =
album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
afterNavigate(({ from }) => {
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 () => {
try {
if (isLiked) {
@ -374,6 +402,7 @@
},
});
currentAlbumName = album.albumName;
notificationController.show({ type: NotificationType.Info, message: 'New album name has been saved' });
} catch (error) {
handleError(error, 'Unable to update album name');
}
@ -455,6 +484,7 @@
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
{/if}
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
<MenuOption on:click={() => (viewMode = ViewMode.OPTIONS)} text="Options" />
</ContextMenu>
{/if}
</CircleIconButton>
@ -630,9 +660,10 @@
</AssetGrid>
{/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">
<ActivityStatus
disabled={!album.isActivityEnabled}
{isLiked}
numberOfComments={$numberOfComments}
{isShowActivity}
@ -648,11 +679,12 @@
<div
transition:fly={{ duration: 150 }}
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"
>
<ActivityViewer
{user}
disabled={!album.isActivityEnabled}
albumOwnerId={album.ownerId}
albumId={album.id}
bind:reactions
@ -700,6 +732,16 @@
</ConfirmDialogue>
{/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}
<EditDescriptionModal
{album}

View File

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