diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 94b66d4c22..e98769d495 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -1,32 +1,24 @@ <script lang="ts"> - import Icon from '$lib/components/elements/icon.svelte'; - import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants'; - import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { getAssetType } from '$lib/utils/asset-utils'; import { autoGrowHeight } from '$lib/actions/autogrow'; + import { shortcut } from '$lib/actions/shortcut'; + import Icon from '$lib/components/elements/icon.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; + import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants'; + import { activityManager } from '$lib/managers/activity-manager.svelte'; + import { locale } from '$lib/stores/preferences.store'; + import { getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetType } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { isTenMinutesApart } from '$lib/utils/timesince'; - import { - ReactionType, - createActivity, - deleteActivity, - getActivities, - type ActivityResponseDto, - type AssetTypeEnum, - type UserResponseDto, - } from '@immich/sdk'; - import { mdiClose, mdiDotsVertical, mdiHeart, mdiSend, mdiDeleteOutline } from '@mdi/js'; + import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk'; + import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiHeart, mdiSend } from '@mdi/js'; import * as luxon from 'luxon'; - import { onMount } from 'svelte'; + import { t } from 'svelte-i18n'; 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 UserAvatar from '../shared-components/user-avatar.svelte'; - import { locale } from '$lib/stores/preferences.store'; - import { shortcut } from '$lib/actions/shortcut'; - import { t } from 'svelte-i18n'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; @@ -48,34 +40,16 @@ }; interface Props { - reactions: ActivityResponseDto[]; user: UserResponseDto; assetId?: string | undefined; albumId: string; assetType?: AssetTypeEnum | undefined; albumOwnerId: string; disabled: boolean; - isLiked: ActivityResponseDto | null; - onDeleteComment: () => void; - onDeleteLike: () => void; - onAddComment: () => void; onClose: () => void; } - let { - reactions = $bindable(), - user, - assetId = undefined, - albumId, - assetType = undefined, - albumOwnerId, - disabled, - isLiked, - onDeleteComment, - onDeleteLike, - onAddComment, - onClose, - }: Props = $props(); + let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled, onClose }: Props = $props(); let innerHeight: number = $state(0); let activityHeight: number = $state(0); @@ -85,36 +59,18 @@ let message = $state(''); let isSendingMessage = $state(false); - onMount(async () => { - await getReactions(); - }); - - const getReactions = async () => { - try { - reactions = await getActivities({ assetId, albumId }); - } catch (error) { - handleError(error, $t('errors.unable_to_load_asset_activity')); - } - }; - - const timeOptions = { + const timeOptions: Intl.DateTimeFormatOptions = { 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 deleteActivity({ id: reaction.id }); - reactions.splice(index, 1); - if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) { - onDeleteLike(); - } else { - onDeleteComment(); - } + await activityManager.deleteActivity(reaction, index); const deleteMessages: Record<ReactionType, string> = { [ReactionType.Comment]: $t('comment_deleted'), @@ -135,13 +91,9 @@ } const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner); try { - const data = await createActivity({ - activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message }, - }); - reactions.push(data); + await activityManager.addActivity({ albumId, assetId, type: ReactionType.Comment, comment: message }); message = ''; - onAddComment(); } catch (error) { handleError(error, $t('errors.unable_to_add_comment')); } finally { @@ -156,7 +108,6 @@ }); $effect(() => { if (assetId && previousAssetId != assetId) { - handlePromiseError(getReactions()); previousAssetId = assetId; } }); @@ -184,7 +135,7 @@ class="overflow-y-auto immich-scrollbar relative w-full px-2" style="height: {divHeight}px;padding-bottom: {chatHeight}px" > - {#each reactions as reaction, index (reaction.id)} + {#each activityManager.activities as reaction, index (reaction.id)} {#if reaction.type === ReactionType.Comment} <div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start"> <div class="flex items-center"> @@ -221,7 +172,7 @@ {/if} </div> - {#if (index != reactions.length - 1 && !shouldGroup(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} + {#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1} <div class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300" title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)} @@ -273,7 +224,7 @@ </div> {/if} </div> - {#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} + {#if (index != activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1} <div class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300" title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index b05c89f38f..08772bcec4 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,8 +5,8 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; + import { activityManager } from '$lib/managers/activity-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import { updateNumberOfComments } from '$lib/stores/activity.store'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isShowDetail } from '$lib/stores/preferences.store'; @@ -19,15 +19,9 @@ import { AssetJobName, AssetTypeEnum, - ReactionType, - createActivity, - deleteActivity, - getActivities, - getActivityStatistics, getAllAlbums, getStack, runAssetJobs, - type ActivityResponseDto, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, @@ -61,7 +55,6 @@ person?: PersonResponseDto | null; preAction?: PreAction | undefined; onAction?: OnAction | undefined; - reactions?: ActivityResponseDto[]; showCloseButton?: boolean; onClose: (dto: { asset: AssetResponseDto }) => void; onNext: () => Promise<HasAsset>; @@ -80,7 +73,6 @@ person = null, preAction = undefined, onAction = undefined, - reactions = $bindable([]), showCloseButton, onClose, onNext, @@ -107,8 +99,6 @@ let previewStackedAsset: AssetResponseDto | undefined = $state(); let isShowActivity = $state(false); let isShowEditor = $state(false); - let isLiked: ActivityResponseDto | null = $state(null); - let numberOfComments = $state(0); let fullscreenElement = $state<Element>(); let unsubscribes: (() => void)[] = []; let selectedEditType: string = $state(''); @@ -136,59 +126,20 @@ }); }; - const handleAddComment = () => { - numberOfComments++; - updateNumberOfComments(1); - }; - - const handleRemoveComment = () => { - numberOfComments--; - updateNumberOfComments(-1); - }; - const handleFavorite = async () => { if (album && album.isActivityEnabled) { try { - if (isLiked) { - const activityId = isLiked.id; - await deleteActivity({ id: activityId }); - reactions = reactions.filter((reaction) => reaction.id !== activityId); - isLiked = null; - } else { - const data = await createActivity({ - activityCreateDto: { albumId: album.id, assetId: asset.id, type: ReactionType.Like }, - }); - - isLiked = data; - reactions = [...reactions, isLiked]; - } + await activityManager.toggleLike(); } catch (error) { handleError(error, $t('errors.unable_to_change_favorite')); } } }; - const getFavorite = async () => { - if (album && $user) { - try { - const data = await getActivities({ - userId: $user.id, - assetId: asset.id, - albumId: album.id, - $type: ReactionType.Like, - }); - isLiked = data.length > 0 ? data[0] : null; - } catch (error) { - handleError(error, $t('errors.unable_to_load_liked_status')); - } - } - }; - - const getNumberOfComments = async () => { + const updateComments = async () => { if (album) { try { - const { comments } = await getActivityStatistics({ assetId: asset.id, albumId: album.id }); - numberOfComments = comments; + await activityManager.refreshActivities(album.id, asset.id); } catch (error) { handleError(error, $t('errors.unable_to_get_comments_number')); } @@ -227,6 +178,10 @@ if (!sharedLink) { await handleGetAllAlbums(); } + + if (album) { + activityManager.init(album.id, asset.id); + } }); onDestroy(() => { @@ -241,6 +196,8 @@ for (const unsubscribe of unsubscribes) { unsubscribe(); } + + activityManager.reset(); }); const handleGetAllAlbums = async () => { @@ -402,14 +359,13 @@ } }); $effect(() => { - if (album && !album.isActivityEnabled && numberOfComments === 0) { + if (album && !album.isActivityEnabled && activityManager.commentCount === 0) { isShowActivity = false; } }); $effect(() => { if (isShared && asset.id) { - handlePromiseError(getFavorite()); - handlePromiseError(getNumberOfComments()); + handlePromiseError(updateComments()); } }); $effect(() => { @@ -547,12 +503,12 @@ onVideoStarted={handleVideoStarted} /> {/if} - {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)} + {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)} <div class="z-[9999] absolute bottom-0 end-0 mb-20 me-8"> <ActivityStatus disabled={!album?.isActivityEnabled} - {isLiked} - {numberOfComments} + isLiked={activityManager.isLiked} + numberOfComments={activityManager.commentCount} onFavorite={handleFavorite} onOpenActivityTab={handleOpenActivity} /> @@ -642,11 +598,6 @@ albumOwnerId={album.ownerId} albumId={album.id} assetId={asset.id} - {isLiked} - bind:reactions - onAddComment={handleAddComment} - onDeleteComment={handleRemoveComment} - onDeleteLike={() => (isLiked = null)} onClose={() => (isShowActivity = false)} /> </div> diff --git a/web/src/lib/managers/activity-manager.svelte.ts b/web/src/lib/managers/activity-manager.svelte.ts new file mode 100644 index 0000000000..a527778bb1 --- /dev/null +++ b/web/src/lib/managers/activity-manager.svelte.ts @@ -0,0 +1,113 @@ +import { user } from '$lib/stores/user.store'; +import { handlePromiseError } from '$lib/utils'; +import { + createActivity, + deleteActivity, + getActivities, + getActivityStatistics, + ReactionLevel, + ReactionType, + type ActivityCreateDto, + type ActivityResponseDto, +} from '@immich/sdk'; +import { get } from 'svelte/store'; + +class ActivityManager { + #albumId = $state<string | undefined>(); + #assetId = $state<string | undefined>(); + #activities = $state<ActivityResponseDto[]>([]); + #commentCount = $state(0); + #isLiked = $state<ActivityResponseDto | null>(null); + + get activities() { + return this.#activities; + } + + get commentCount() { + return this.#commentCount; + } + + get isLiked() { + return this.#isLiked; + } + + init(albumId: string, assetId?: string) { + this.#albumId = albumId; + this.#assetId = assetId; + } + + async addActivity(dto: ActivityCreateDto) { + if (this.#albumId === undefined) { + return; + } + + const activity = await createActivity({ activityCreateDto: dto }); + this.#activities = [...this.#activities, activity]; + + if (activity.type === ReactionType.Comment) { + this.#commentCount++; + } + + handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId)); + return activity; + } + + async deleteActivity(activity: ActivityResponseDto, index?: number) { + if (!this.#albumId) { + return; + } + + if (activity.type === ReactionType.Comment) { + this.#commentCount--; + } + + this.#activities = index + ? this.#activities.splice(index, 1) + : this.#activities.filter(({ id }) => id !== activity.id); + + await deleteActivity({ id: activity.id }); + handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId)); + } + + async toggleLike() { + if (!this.#albumId) { + return; + } + + if (this.#isLiked) { + await this.deleteActivity(this.#isLiked); + this.#isLiked = null; + } else { + this.#isLiked = (await this.addActivity({ + albumId: this.#albumId, + assetId: this.#assetId, + type: ReactionType.Like, + }))!; + } + } + + async refreshActivities(albumId: string, assetId?: string) { + this.#activities = await getActivities({ albumId, assetId }); + + const [liked] = await getActivities({ + albumId, + assetId, + userId: get(user).id, + $type: ReactionType.Like, + level: assetId ? undefined : ReactionLevel.Album, + }); + this.#isLiked = liked ?? null; + + const { comments } = await getActivityStatistics({ albumId, assetId }); + this.#commentCount = comments; + } + + reset() { + this.#albumId = undefined; + this.#assetId = undefined; + this.#activities = []; + this.#commentCount = 0; + } +} + +export const activityManager = new ActivityManager(); diff --git a/web/src/lib/stores/activity.store.ts b/web/src/lib/stores/activity.store.ts deleted file mode 100644 index 897f835063..0000000000 --- a/web/src/lib/stores/activity.store.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { writable } from 'svelte/store'; - -export const numberOfComments = writable<number>(0); - -export const setNumberOfComments = (number: number) => { - numberOfComments.set(number); -}; - -export const updateNumberOfComments = (addOrRemove: 1 | -1) => { - numberOfComments.update((n) => n + addOrRemove); -}; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 767902bd94..e10fbae139 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -22,9 +22,10 @@ import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; @@ -33,14 +34,16 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; - import { AppRoute, AlbumPageViewMode } from '$lib/constants'; - import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; + import { AlbumPageViewMode, AppRoute } from '$lib/constants'; + import { activityManager } from '$lib/managers/activity-manager.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets-store.svelte'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { preferences, user } from '$lib/stores/user.store'; import { handlePromiseError } from '$lib/utils'; - import { downloadAlbum, cancelMultiselect } from '$lib/utils/asset-utils'; + import { confirmAlbumDelete } from '$lib/utils/album-utils'; + import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { @@ -53,18 +56,11 @@ import { AlbumUserRole, AssetOrder, - ReactionLevel, - ReactionType, addAssetsToAlbum, addUsersToAlbum, - createActivity, - deleteActivity, deleteAlbum, - getActivities, - getActivityStatistics, getAlbumInfo, updateAlbumInfo, - type ActivityResponseDto, type AlbumUserAddDto, } from '@immich/sdk'; import { @@ -80,13 +76,10 @@ mdiPresentationPlay, mdiShareVariantOutline, } from '@mdi/js'; + import { onDestroy } from 'svelte'; + import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import type { PageData } from './$types'; - import { t } from 'svelte-i18n'; - import { onDestroy } from 'svelte'; - import { confirmAlbumDelete } from '$lib/utils/album-utils'; - import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; - import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -103,8 +96,6 @@ let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW); let isCreatingSharedAlbum = $state(false); let isShowActivity = $state(false); - let isLiked: ActivityResponseDto | null = $state(null); - let reactions: ActivityResponseDto[] = $state([]); let albumOrder: AssetOrder | undefined = $state(data.album.order); const assetInteraction = new AssetInteraction(); @@ -154,44 +145,15 @@ const handleFavorite = async () => { try { - if (isLiked) { - const activityId = isLiked.id; - await deleteActivity({ id: activityId }); - reactions = reactions.filter((reaction) => reaction.id !== activityId); - isLiked = null; - } else { - isLiked = await createActivity({ - activityCreateDto: { albumId: album.id, type: ReactionType.Like }, - }); - reactions = [...reactions, isLiked]; - } + await activityManager.toggleLike(); } catch (error) { handleError(error, $t('errors.cant_change_asset_favorite')); } }; - const getFavorite = async () => { - if ($user) { - try { - const data = await getActivities({ - userId: $user.id, - albumId: album.id, - $type: ReactionType.Like, - level: ReactionLevel.Album, - }); - if (data.length > 0) { - isLiked = data[0]; - } - } catch (error) { - handleError(error, $t('errors.unable_to_load_liked_status')); - } - } - }; - - const getNumberOfComments = async () => { + const updateComments = async () => { try { - const { comments } = await getActivityStatistics({ albumId: album.id }); - setNumberOfComments(comments); + await activityManager.refreshActivities(album.id); } catch (error) { handleError(error, $t('errors.cant_get_number_of_comments')); } @@ -398,7 +360,7 @@ let albumId = $derived(album.id); $effect(() => { - if (!album.isActivityEnabled && $numberOfComments === 0) { + if (!album.isActivityEnabled && activityManager.commentCount === 0) { isShowActivity = false; } }); @@ -412,7 +374,16 @@ void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }); } }); - onDestroy(() => assetStore.destroy()); + + $effect(() => { + activityManager.reset(); + activityManager.init(album.id); + }); + + onDestroy(() => { + activityManager.reset(); + assetStore.destroy(); + }); // let timelineStore = new AssetStore(); // $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId })); // onDestroy(() => timelineStore.destroy()); @@ -420,7 +391,7 @@ let isOwned = $derived($user.id == album.ownerId); let showActivityStatus = $derived( - album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0), + album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || activityManager.commentCount > 0), ); let isEditor = $derived( album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor || @@ -430,8 +401,7 @@ let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer)); $effect(() => { if (album.albumUsers.length > 0) { - handlePromiseError(getFavorite()); - handlePromiseError(getNumberOfComments()); + handlePromiseError(updateComments()); } }); const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0); @@ -711,8 +681,8 @@ <div class="absolute z-[2] bottom-0 end-0 mb-6 me-6 justify-self-end"> <ActivityStatus disabled={!album.isActivityEnabled} - {isLiked} - numberOfComments={$numberOfComments} + isLiked={activityManager.isLiked} + numberOfComments={activityManager.commentCount} onFavorite={handleFavorite} onOpenActivityTab={handleOpenAndCloseActivityTab} /> @@ -733,11 +703,6 @@ disabled={!album.isActivityEnabled} albumOwnerId={album.ownerId} albumId={album.id} - {isLiked} - bind:reactions - onAddComment={() => updateNumberOfComments(1)} - onDeleteComment={() => updateNumberOfComments(-1)} - onDeleteLike={() => (isLiked = null)} onClose={handleOpenAndCloseActivityTab} /> </div>