feat(web,server): activity (#4682)

* feat: activity

* regenerate api

* fix: make asset owner unable to delete comment

* fix: merge

* fix: tests

* feat: use textarea instead of input

* fix: do actions only if the album is shared

* fix: placeholder opacity

* fix(web): improve messages UI

* fix(web): improve input message UI

* pr feedback

* fix: tests

* pr feedback

* pr feedback

* pr feedback

* fix permissions

* regenerate api

* pr feedback

* pr feedback

* multiple improvements on web

* fix: ui colors

* WIP

* chore: open api

* pr feedback

* fix: add comment

* chore: clean up

* pr feedback

* refactor: endpoints

* chore: open api

* fix: filter by type

* fix: e2e

* feat: e2e remove own comment

* fix: web tests

* remove console.log

* chore: cleanup

* fix: ui tweaks

* pr feedback

* fix web test

* fix: unit tests

* chore: remove unused code

* revert useless changes

* fix: grouping messages

* fix: remove nullable on updatedAt

* fix: text overflow

* styling

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martin
2023-11-01 04:13:34 +01:00
committed by GitHub
parent 68f6446718
commit ce5966c23d
66 changed files with 4487 additions and 38 deletions

View File

@@ -20,12 +20,14 @@ import {
UserApi,
UserApiFp,
AuditApi,
ActivityApi,
} from './open-api';
import { BASE_PATH } from './open-api/base';
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
import type { ApiParams } from './types';
export class ImmichApi {
public activityApi: ActivityApi;
public albumApi: AlbumApi;
public libraryApi: LibraryApi;
public assetApi: AssetApi;
@@ -52,6 +54,7 @@ export class ImmichApi {
constructor(params: ConfigurationParameters) {
this.config = new Configuration(params);
this.activityApi = new ActivityApi(this.config);
this.albumApi = new AlbumApi(this.config);
this.auditApi = new AuditApi(this.config);
this.libraryApi = new LibraryApi(this.config);

View File

@@ -99,6 +99,103 @@ export interface APIKeyUpdateDto {
*/
'name': string;
}
/**
*
* @export
* @interface ActivityCreateDto
*/
export interface ActivityCreateDto {
/**
*
* @type {string}
* @memberof ActivityCreateDto
*/
'albumId': string;
/**
*
* @type {string}
* @memberof ActivityCreateDto
*/
'assetId'?: string;
/**
*
* @type {string}
* @memberof ActivityCreateDto
*/
'comment'?: string;
/**
*
* @type {ReactionType}
* @memberof ActivityCreateDto
*/
'type': ReactionType;
}
/**
*
* @export
* @interface ActivityResponseDto
*/
export interface ActivityResponseDto {
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'assetId': string | null;
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'comment'?: string | null;
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'createdAt': string;
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'id': string;
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'type': ActivityResponseDtoTypeEnum;
/**
*
* @type {UserDto}
* @memberof ActivityResponseDto
*/
'user': UserDto;
}
export const ActivityResponseDtoTypeEnum = {
Comment: 'comment',
Like: 'like'
} as const;
export type ActivityResponseDtoTypeEnum = typeof ActivityResponseDtoTypeEnum[keyof typeof ActivityResponseDtoTypeEnum];
/**
*
* @export
* @interface ActivityStatisticsResponseDto
*/
export interface ActivityStatisticsResponseDto {
/**
*
* @type {number}
* @memberof ActivityStatisticsResponseDto
*/
'comments': number;
}
/**
*
* @export
@@ -2490,6 +2587,20 @@ export interface QueueStatusDto {
*/
'isPaused': boolean;
}
/**
*
* @export
* @enum {string}
*/
export const ReactionType = {
Comment: 'comment',
Like: 'like'
} as const;
export type ReactionType = typeof ReactionType[keyof typeof ReactionType];
/**
*
* @export
@@ -4248,6 +4359,43 @@ export interface UsageByUserDto {
*/
'videos': number;
}
/**
*
* @export
* @interface UserDto
*/
export interface UserDto {
/**
*
* @type {string}
* @memberof UserDto
*/
'email': string;
/**
*
* @type {string}
* @memberof UserDto
*/
'firstName': string;
/**
*
* @type {string}
* @memberof UserDto
*/
'id': string;
/**
*
* @type {string}
* @memberof UserDto
*/
'lastName': string;
/**
*
* @type {string}
* @memberof UserDto
*/
'profileImagePath': string;
}
/**
*
* @export
@@ -4831,6 +4979,435 @@ export class APIKeyApi extends BaseAPI {
}
/**
* ActivityApi - axios parameter creator
* @export
*/
export const ActivityApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {ActivityCreateDto} activityCreateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createActivity: async (activityCreateDto: ActivityCreateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'activityCreateDto' is not null or undefined
assertParamExists('createActivity', 'activityCreateDto', activityCreateDto)
const localVarPath = `/activity`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(activityCreateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteActivity: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('deleteActivity', 'id', id)
const localVarPath = `/activity/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} albumId
* @param {string} [assetId]
* @param {ReactionType} [type]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivities: async (albumId: string, assetId?: string, type?: ReactionType, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'albumId' is not null or undefined
assertParamExists('getActivities', 'albumId', albumId)
const localVarPath = `/activity`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (albumId !== undefined) {
localVarQueryParameter['albumId'] = albumId;
}
if (assetId !== undefined) {
localVarQueryParameter['assetId'] = assetId;
}
if (type !== undefined) {
localVarQueryParameter['type'] = type;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} albumId
* @param {string} [assetId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivityStatistics: async (albumId: string, assetId?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'albumId' is not null or undefined
assertParamExists('getActivityStatistics', 'albumId', albumId)
const localVarPath = `/activity/statistics`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (albumId !== undefined) {
localVarQueryParameter['albumId'] = albumId;
}
if (assetId !== undefined) {
localVarQueryParameter['assetId'] = assetId;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* ActivityApi - functional programming interface
* @export
*/
export const ActivityApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ActivityApiAxiosParamCreator(configuration)
return {
/**
*
* @param {ActivityCreateDto} activityCreateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createActivity(activityCreateDto: ActivityCreateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ActivityResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createActivity(activityCreateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteActivity(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteActivity(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} albumId
* @param {string} [assetId]
* @param {ReactionType} [type]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getActivities(albumId: string, assetId?: string, type?: ReactionType, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ActivityResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} albumId
* @param {string} [assetId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getActivityStatistics(albumId: string, assetId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ActivityStatisticsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getActivityStatistics(albumId, assetId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* ActivityApi - factory interface
* @export
*/
export const ActivityApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ActivityApiFp(configuration)
return {
/**
*
* @param {ActivityApiCreateActivityRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig): AxiosPromise<ActivityResponseDto> {
return localVarFp.createActivity(requestParameters.activityCreateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteActivity(requestParameters.id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<ActivityResponseDto>> {
return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath));
},
/**
*
* @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise<ActivityStatisticsResponseDto> {
return localVarFp.getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for createActivity operation in ActivityApi.
* @export
* @interface ActivityApiCreateActivityRequest
*/
export interface ActivityApiCreateActivityRequest {
/**
*
* @type {ActivityCreateDto}
* @memberof ActivityApiCreateActivity
*/
readonly activityCreateDto: ActivityCreateDto
}
/**
* Request parameters for deleteActivity operation in ActivityApi.
* @export
* @interface ActivityApiDeleteActivityRequest
*/
export interface ActivityApiDeleteActivityRequest {
/**
*
* @type {string}
* @memberof ActivityApiDeleteActivity
*/
readonly id: string
}
/**
* Request parameters for getActivities operation in ActivityApi.
* @export
* @interface ActivityApiGetActivitiesRequest
*/
export interface ActivityApiGetActivitiesRequest {
/**
*
* @type {string}
* @memberof ActivityApiGetActivities
*/
readonly albumId: string
/**
*
* @type {string}
* @memberof ActivityApiGetActivities
*/
readonly assetId?: string
/**
*
* @type {ReactionType}
* @memberof ActivityApiGetActivities
*/
readonly type?: ReactionType
}
/**
* Request parameters for getActivityStatistics operation in ActivityApi.
* @export
* @interface ActivityApiGetActivityStatisticsRequest
*/
export interface ActivityApiGetActivityStatisticsRequest {
/**
*
* @type {string}
* @memberof ActivityApiGetActivityStatistics
*/
readonly albumId: string
/**
*
* @type {string}
* @memberof ActivityApiGetActivityStatistics
*/
readonly assetId?: string
}
/**
* ActivityApi - object-oriented interface
* @export
* @class ActivityApi
* @extends {BaseAPI}
*/
export class ActivityApi extends BaseAPI {
/**
*
* @param {ActivityApiCreateActivityRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ActivityApi
*/
public createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).createActivity(requestParameters.activityCreateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ActivityApi
*/
public deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).deleteActivity(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ActivityApi
*/
public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ActivityApi
*/
public getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* AlbumApi - axios parameter creator
* @export

View File

@@ -5,7 +5,7 @@
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { locale } from '$lib/stores/preferences.store';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto } from '@api';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@api';
import { onDestroy, onMount } from 'svelte';
import { dateFormats } from '../../constants';
import { createAssetInteractionStore } from '../../stores/asset-interaction.store';
@@ -22,6 +22,7 @@
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
export let sharedLink: SharedLinkResponseDto;
export let user: UserResponseDto | undefined = undefined;
const album = sharedLink.album as AlbumResponseDto;
@@ -138,7 +139,7 @@
<main
class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40"
>
<AssetGrid {assetStore} {assetInteractionStore}>
<AssetGrid {album} {user} {assetStore} {assetInteractionStore}>
<section class="pt-24">
<!-- ALBUM TITLE -->
<p

View File

@@ -0,0 +1,289 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { mdiClose, mdiHeart, mdiSend, mdiDotsVertical } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { ActivityResponseDto, api, AssetTypeEnum, ReactionType, type UserResponseDto } from '@api';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import { clickOutside } from '$lib/utils/click-outside';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { getAssetType } from '$lib/utils/asset-utils';
import * as luxon from 'luxon';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
const timeSince = (dateTime: luxon.DateTime) => {
const diff = dateTime.diffNow().shiftTo(...units);
const unit = units.find((unit) => diff.get(unit) !== 0) || 'second';
const relativeFormatter = new Intl.RelativeTimeFormat('en', {
numeric: 'auto',
});
return relativeFormatter.format(Math.trunc(diff.as(unit)), unit);
};
export let reactions: ActivityResponseDto[];
export let user: UserResponseDto;
export let assetId: string;
export let albumId: string;
export let assetType: AssetTypeEnum;
export let albumOwnerId: string;
let textArea: HTMLTextAreaElement;
let innerHeight: number;
let activityHeight: number;
let chatHeight: number;
let divHeight: number;
let previousAssetId: string | null;
let message = '';
let isSendingMessage = false;
const dispatch = createEventDispatcher();
$: showDeleteReaction = Array(reactions.length).fill(false);
$: {
if (innerHeight && activityHeight) {
divHeight = innerHeight - activityHeight;
}
}
$: {
if (previousAssetId != assetId) {
getReactions();
previousAssetId = assetId;
}
}
const getReactions = async () => {
try {
const { data } = await api.activityApi.getActivities({ assetId, albumId });
reactions = data;
} catch (error) {
handleError(error, 'Error when fetching reactions');
}
};
const handleEnter = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSendComment();
return;
}
};
const autoGrow = () => {
textArea.style.height = '5px';
textArea.style.height = textArea.scrollHeight + 'px';
};
const timeOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
} as Intl.DateTimeFormatOptions;
const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => {
try {
await api.activityApi.deleteActivity({ id: reaction.id });
reactions.splice(index, 1);
showDeleteReaction.splice(index, 1);
reactions = reactions;
if (reaction.type === 'like' && reaction.user.id === user.id) {
dispatch('deleteLike');
} else {
dispatch('deleteComment');
}
notificationController.show({
message: `${reaction.type} deleted`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, `Can't remove ${reaction.type}`);
}
};
const handleSendComment = async () => {
if (!message) {
return;
}
const timeout = setTimeout(() => (isSendingMessage = true), 100);
try {
const { data } = await api.activityApi.createActivity({
activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
});
reactions.push(data);
textArea.style.height = '18px';
message = '';
dispatch('addComment');
// Re-render the activity feed
reactions = reactions;
} catch (error) {
handleError(error, "Can't add your comment");
} finally {
clearTimeout(timeout);
}
isSendingMessage = false;
};
const showOptionsMenu = (index: number) => {
showDeleteReaction[index] = !showDeleteReaction[index];
};
</script>
<div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}>
<div class="dark:bg-immich-dark-bg dark:text-immich-dark-fg w-full h-full">
<div
class="flex w-full h-fit dark:bg-immich-dark-bg dark:text-immich-dark-fg p-2 bg-white"
bind:clientHeight={activityHeight}
>
<div class="flex place-items-center gap-2">
<button
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
on:click={() => dispatch('close')}
>
<Icon path={mdiClose} size="24" />
</button>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">Activity</p>
</div>
</div>
{#if innerHeight}
<div
class="overflow-y-auto immich-scrollbar relative w-full"
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
>
{#each reactions as reaction, index (reaction.id)}
{#if reaction.type === 'comment'}
<div class="flex dark:bg-gray-800 bg-gray-200 p-3 mx-2 mt-3 rounded-lg gap-4 justify-start">
<div>
<UserAvatar user={reaction.user} size="sm" />
</div>
<div class="w-full leading-4 overflow-hidden self-center break-words text-sm">{reaction.comment}</div>
{#if reaction.user.id === user.id || albumOwnerId === user.id}
<div class="flex items-start w-fit pt-[5px]" title="Delete comment">
<button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}>
<Icon path={mdiDotsVertical} />
</button>
</div>
{/if}
<div>
{#if showDeleteReaction[index]}
<button
class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-2 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-300 transition-colors"
use:clickOutside
on:outclick={() => (showDeleteReaction[index] = false)}
on:click={() => handleDeleteReaction(reaction, index)}
>
Delete
</button>
{/if}
</div>
</div>
{#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
<div
class=" px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
>
{timeSince(luxon.DateTime.fromISO(reaction.createdAt))}
</div>
{/if}
{:else if reaction.type === 'like'}
<div class="relative">
<div class="flex p-2 mx-2 mt-2 rounded-full gap-2 items-center text-sm">
<div class="text-red-600"><Icon path={mdiHeart} size={20} /></div>
<div
class="w-full"
title={`${reaction.user.firstName} ${reaction.user.lastName} (${reaction.user.email})`}
>
{`${reaction.user.firstName} ${reaction.user.lastName} liked this ${getAssetType(
assetType,
).toLowerCase()}`}
</div>
{#if reaction.user.id === user.id || albumOwnerId === user.id}
<div class="flex items-start w-fit" title="Delete like">
<button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}>
<Icon path={mdiDotsVertical} />
</button>
</div>
{/if}
<div>
{#if showDeleteReaction[index]}
<button
class="absolute top-2 right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 p-3 text-left text-sm font-medium text-immich-fg hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg"
use:clickOutside
on:outclick={() => (showDeleteReaction[index] = false)}
on:click={() => handleDeleteReaction(reaction, index)}
>
Delete Like
</button>
{/if}
</div>
</div>
{#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
<div
class=" px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}
>
{timeSince(luxon.DateTime.fromISO(reaction.createdAt))}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</div>
<div class="absolute w-full bottom-0">
<div class="flex items-center justify-center p-2 mr-2" bind:clientHeight={chatHeight}>
<div class="flex p-2 gap-4 h-fit bg-gray-200 text-immich-dark-gray rounded-3xl w-full">
<div>
<UserAvatar {user} size="md" showTitle={false} />
</div>
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
<div class="flex w-full items-center gap-4">
<textarea
bind:this={textArea}
bind:value={message}
placeholder="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"
/>
</div>
{#if isSendingMessage}
<div class="flex items-end place-items-center pb-2 ml-0">
<div class="flex w-full place-items-center">
<LoadingSpinner />
</div>
</div>
{:else if message}
<div class="flex items-end w-fit ml-0 text-immich-primary dark:text-white">
<CircleIconButton size="15" icon={mdiSend} />
</div>
{/if}
</form>
</div>
</div>
</div>
</div>
<style>
::placeholder {
color: rgb(60, 60, 60);
opacity: 0.6;
}
::-ms-input-placeholder {
/* Edge 12 -18 */
color: white;
}
</style>

View File

@@ -1,6 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AlbumResponseDto, api, AssetJobName, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api';
import {
ActivityResponseDto,
AlbumResponseDto,
api,
AssetJobName,
AssetResponseDto,
AssetTypeEnum,
ReactionType,
SharedLinkResponseDto,
UserResponseDto,
} from '@api';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { fly } from 'svelte/transition';
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
@@ -14,7 +24,7 @@
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
import { isShowDetail } from '$lib/stores/preferences.store';
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
import { addAssetsToAlbum, downloadFile, getAssetType } from '$lib/utils/asset-utils';
import NavigationArea from './navigation-area.svelte';
import { browser } from '$app/environment';
import { handleError } from '$lib/utils/handle-error';
@@ -23,10 +33,21 @@
import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { featureFlags } from '$lib/stores/server-config.store';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiImageBrokenVariant, mdiPause, mdiPlay } from '@mdi/js';
import {
mdiChevronLeft,
mdiHeartOutline,
mdiHeart,
mdiCommentOutline,
mdiChevronRight,
mdiClose,
mdiImageBrokenVariant,
mdiPause,
mdiPlay,
} from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
import ActivityViewer from './activity-viewer.svelte';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@@ -35,6 +56,11 @@
$: isTrashEnabled = $featureFlags.trash;
export let force = false;
export let withStacked = false;
export let isShared = true;
export let user: UserResponseDto | null = null;
export let album: AlbumResponseDto | null = null;
let reactions: ActivityResponseDto[] = [];
const dispatch = createEventDispatcher<{
archived: AssetResponseDto;
@@ -57,6 +83,9 @@
let shouldShowDetailButton = asset.hasMetadata;
let canCopyImagesToClipboard: boolean;
let previewStackedAsset: AssetResponseDto | undefined;
let isShowActivity = false;
let isLiked: ActivityResponseDto | null = null;
let numberOfComments: number;
$: {
if (asset.stackCount && asset.stack) {
@@ -71,6 +100,62 @@
}
}
const handleFavorite = async () => {
if (album) {
try {
if (isLiked) {
const activityId = isLiked.id;
await api.activityApi.deleteActivity({ id: activityId });
reactions = reactions.filter((reaction) => reaction.id !== activityId);
isLiked = null;
} else {
const { data } = await api.activityApi.createActivity({
activityCreateDto: { albumId: album.id, assetId: asset.id, type: ReactionType.Like },
});
isLiked = data;
reactions = [...reactions, isLiked];
}
} catch (error) {
handleError(error, "Can't change favorite for asset");
}
}
};
const getFavorite = async () => {
if (album) {
try {
const { data } = await api.activityApi.getActivities({
assetId: asset.id,
albumId: album.id,
type: ReactionType.Like,
});
if (data.length > 0) {
isLiked = data[0];
}
} catch (error) {
handleError(error, "Can't get Favorite");
}
}
};
const getNumberOfComments = async () => {
if (album) {
try {
const { data } = await api.activityApi.getActivityStatistics({ assetId: asset.id, albumId: album.id });
numberOfComments = data.comments;
} catch (error) {
handleError(error, "Can't get number of comments");
}
}
};
$: {
if (isShared && asset.id) {
getFavorite();
getNumberOfComments();
}
}
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo);
onMount(async () => {
@@ -116,6 +201,13 @@
}
};
const handleOpenActivity = () => {
if ($isShowDetail) {
$isShowDetail = false;
}
isShowActivity = !isShowActivity;
};
const handleKeyboardPress = (event: KeyboardEvent) => {
if (shouldIgnoreShortcut(event)) {
return;
@@ -157,6 +249,7 @@
toggleFavorite();
return;
case 'i':
isShowActivity = false;
$isShowDetail = !$isShowDetail;
return;
}
@@ -193,6 +286,9 @@
};
const showDetailInfoHandler = () => {
if (isShowActivity) {
isShowActivity = false;
}
$isShowDetail = !$isShowDetail;
};
@@ -317,17 +413,6 @@
}
};
const getAssetType = () => {
switch (asset.type) {
case 'IMAGE':
return 'Photo';
case 'VIDEO':
return 'Video';
default:
return 'Asset';
}
};
const handleRunJob = async (name: AssetJobName) => {
try {
await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@@ -471,7 +556,7 @@
</div>
{/if}
<!-- Asset Viewer -->
<div class="col-span-4 col-start-1 row-span-full row-start-1">
<div class="relative col-span-4 col-start-1 row-span-full row-start-1">
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
@@ -517,6 +602,29 @@
on:onVideoStarted={handleVideoStarted}
/>
{/if}
{#if isShared}
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
<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={handleFavorite}>
<div class="items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
</div>
</button>
<button on:click={handleOpenActivity}>
<div class="flex gap-2 items-center justify-center">
<Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
{#if numberOfComments}
<div class="text-xl">{numberOfComments}</div>
{:else if !isShowActivity && !$isShowDetail}
<div class="text-lg">Say something</div>
{/if}
</div>
</button>
</div>
</div>
{/if}
{/key}
{/if}
@@ -582,6 +690,28 @@
</div>
{/if}
{#if isShared && album && isShowActivity && user}
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
class="z-[1002] row-start-1 row-span-5 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}
assetType={asset.type}
albumOwnerId={album.ownerId}
albumId={album.id}
assetId={asset.id}
bind:reactions
on:addComment={() => numberOfComments++}
on:deleteComment={() => numberOfComments--}
on:deleteLike={() => (isLiked = null)}
on:close={() => (isShowActivity = false)}
/>
</div>
{/if}
{#if isShowAlbumPicker}
<AlbumSelectionModal
shared={addToSharedAlbum}
@@ -594,15 +724,15 @@
{#if isShowDeleteConfirmation}
<ConfirmDialogue
title="Delete {getAssetType()}"
title="Delete {getAssetType(asset.type)}"
confirmText="Delete"
on:confirm={deleteAsset}
on:cancel={() => (isShowDeleteConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove it from its
album(s).
Are you sure you want to delete this {getAssetType(asset.type).toLowerCase()}? This will also remove it from
its album(s).
</p>
<p><b>You cannot undo this action!</b></p>
</svelte:fragment>

View File

@@ -7,6 +7,7 @@
import { useZoomImageWheel } from '@zoom-image/svelte';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
export let asset: AssetResponseDto;
export let element: HTMLDivElement | undefined = undefined;
@@ -54,11 +55,14 @@
}
};
const handleKeypress = async ({ metaKey, ctrlKey, key }: KeyboardEvent) => {
const handleKeypress = async (event: KeyboardEvent) => {
if (shouldIgnoreShortcut(event)) {
return;
}
if (window.getSelection()?.type === 'Range') {
return;
}
if ((metaKey || ctrlKey) && key === 'c') {
if ((event.metaKey || event.ctrlKey) && event.key === 'c') {
await doCopy();
}
};

View File

@@ -8,7 +8,7 @@
import { locale } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@api';
import type { AlbumResponseDto, AssetResponseDto, UserResponseDto } from '@api';
import { DateTime } from 'luxon';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@@ -26,6 +26,9 @@
export let assetInteractionStore: AssetInteractionStore;
export let removeAction: AssetAction | null = null;
export let withStacked = false;
export let isShared = false;
export let user: UserResponseDto | null = null;
export let album: AlbumResponseDto | null = null;
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
export let forceDelete = false;
@@ -391,10 +394,13 @@
<Portal target="body">
{#if $showAssetViewer}
<AssetViewer
{user}
{withStacked}
{assetStore}
asset={$viewingAsset}
force={forceDelete || !isTrashEnabled}
{isShared}
{album}
on:previous={() => handlePrevious()}
on:next={() => handleNext()}
on:close={() => handleClose()}

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { downloadArchive } from '$lib/utils/asset-utils';
import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
import { api, AssetResponseDto, SharedLinkResponseDto, UserResponseDto } from '@api';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte';
@@ -17,6 +17,7 @@
export let sharedLink: SharedLinkResponseDto;
export let isOwned: boolean;
export let user: UserResponseDto | undefined = undefined;
let selectedAssets: Set<AssetResponseDto> = new Set();
@@ -102,6 +103,6 @@
</ControlAppBar>
{/if}
<section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40">
<GalleryViewer {assets} bind:selectedAssets />
<GalleryViewer {user} {assets} bind:selectedAssets />
</section>
</section>

View File

@@ -2,7 +2,7 @@
import { page } from '$app/stores';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AssetResponseDto, ThumbnailFormat } from '@api';
import { AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import { flip } from 'svelte/animate';
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
@@ -12,6 +12,7 @@
export let selectedAssets: Set<AssetResponseDto> = new Set();
export let disableAssetSelect = false;
export let showArchiveIcon = false;
export let user: UserResponseDto | undefined = undefined;
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -103,6 +104,7 @@
<!-- Overlay Asset Viewer -->
{#if $showAssetViewer}
<AssetViewer
{user}
asset={selectedAsset}
on:previous={navigateAssetBackward}
on:next={navigateAssetForward}

View File

@@ -5,9 +5,17 @@
<script lang="ts">
import { imageLoad } from '$lib/utils/image-load';
import { api, UserResponseDto } from '@api';
import { api } from '@api';
export let user: UserResponseDto;
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
profileImagePath: string;
}
export let user: User;
export let color: Color = 'primary';
export let size: Size = 'full';
export let rounded = true;

View File

@@ -1,6 +1,6 @@
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import { downloadManager } from '$lib/stores/download';
import { api, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto, DownloadInfoDto } from '@api';
import { api, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto, DownloadInfoDto, AssetTypeEnum } from '@api';
import { handleError } from './handle-error';
export const addAssetsToAlbum = async (albumId: string, assetIds: Array<string>): Promise<BulkIdResponseDto[]> =>
@@ -192,3 +192,14 @@ export function isWebCompatibleImage(asset: AssetResponseDto): boolean {
return supportedImageExtensions.has(imgExtension);
}
export const getAssetType = (type: AssetTypeEnum) => {
switch (type) {
case 'IMAGE':
return 'Photo';
case 'VIDEO':
return 'Video';
default:
return 'Asset';
}
};

View File

@@ -0,0 +1,9 @@
export const isTenMinutesApart = (date1: string, date2: string): boolean => {
if (!date1 || !date2) {
return false;
}
const diffInMilliseconds = Math.abs(new Date(date1).getTime() - new Date(date2).getTime());
const minutesDifference = diffInMilliseconds / (1000 * 60);
return minutesDifference >= 10;
};

View File

@@ -424,11 +424,19 @@
class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40"
>
{#if viewMode === ViewMode.SELECT_ASSETS}
<AssetGrid assetStore={timelineStore} assetInteractionStore={timelineInteractionStore} isSelectionMode={true} />
<AssetGrid
user={data.user}
assetStore={timelineStore}
assetInteractionStore={timelineInteractionStore}
isSelectionMode={true}
/>
{:else}
<AssetGrid
{album}
user={data.user}
{assetStore}
{assetInteractionStore}
isShared={album.sharedUsers.length > 0}
isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL}
singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL}
on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)}