浏览代码

feat(web): global activity (#4796)

* feat: global activity

* fix: tests

* pr feedback

* use flexbox

* fix: deleted control actions

* fix: flex box

* fix: do not show activity tab by default

* feat: better grouping

* fix: set isShared default value to false

* fix: prevent re-rendering the asset grid

* fix: activity status above the scrollbar

* fix: prevent re-rendering the asset grid

* fix: prevent re-rendering the asset grid

* pr feedback

* pr feedback

* pr feedback

* styling and better thumbnail

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
martin 1 年之前
父节点
当前提交
a0743d8b7d

+ 33 - 0
web/src/lib/components/asset-viewer/activity-status.svelte

@@ -0,0 +1,33 @@
+<script lang="ts">
+  import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
+  import { createEventDispatcher } from 'svelte';
+  import Icon from '../elements/icon.svelte';
+  import type { ActivityResponseDto } from '@api';
+
+  export let isLiked: ActivityResponseDto | null;
+  export let numberOfComments: number | undefined;
+  export let isShowActivity: boolean | undefined;
+
+  const dispatch = createEventDispatcher();
+</script>
+
+<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')}>
+    <!-- svelte-ignore missing-declaration -->
+    <div class="items-center justify-center">
+      <Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
+    </div>
+  </button>
+  <button on:click={() => dispatch('openActivityTab')}>
+    <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}
+        <div class="text-lg">Say something</div>
+      {/if}
+    </div>
+  </button>
+</div>

+ 47 - 18
web/src/lib/components/asset-viewer/activity-viewer.svelte

@@ -1,9 +1,9 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
+  import { createEventDispatcher, onMount } 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 { ActivityResponseDto, api, AssetTypeEnum, ReactionType, ThumbnailFormat, type UserResponseDto } from '@api';
   import { handleError } from '$lib/utils/handle-error';
   import { isTenMinutesApart } from '$lib/utils/timesince';
   import { clickOutside } from '$lib/utils/click-outside';
@@ -15,6 +15,13 @@
 
   const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
 
+  const shouldGroup = (currentDate: string, nextDate: string): boolean => {
+    const currentDateTime = luxon.DateTime.fromISO(currentDate);
+    const nextDateTime = luxon.DateTime.fromISO(nextDate);
+
+    return currentDateTime.hasSame(nextDateTime, 'hour') || currentDateTime.toRelative() === nextDateTime.toRelative();
+  };
+
   const timeSince = (dateTime: luxon.DateTime) => {
     const diff = dateTime.diffNow().shiftTo(...units);
     const unit = units.find((unit) => diff.get(unit) !== 0) || 'second';
@@ -27,9 +34,9 @@
 
   export let reactions: ActivityResponseDto[];
   export let user: UserResponseDto;
-  export let assetId: string;
+  export let assetId: string | undefined = undefined;
   export let albumId: string;
-  export let assetType: AssetTypeEnum;
+  export let assetType: AssetTypeEnum | undefined = undefined;
   export let albumOwnerId: string;
 
   let textArea: HTMLTextAreaElement;
@@ -37,7 +44,7 @@
   let activityHeight: number;
   let chatHeight: number;
   let divHeight: number;
-  let previousAssetId: string | null;
+  let previousAssetId: string | undefined = assetId;
   let message = '';
   let isSendingMessage = false;
 
@@ -51,11 +58,14 @@
   }
 
   $: {
-    if (previousAssetId != assetId) {
+    if (assetId && previousAssetId != assetId) {
       getReactions();
       previousAssetId = assetId;
     }
   }
+  onMount(async () => {
+    await getReactions();
+  });
 
   const getReactions = async () => {
     try {
@@ -161,11 +171,20 @@
         {#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>
+              <div class="flex items-center">
                 <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 assetId === undefined && reaction.assetId}
+                <div class="aspect-square w-[75px] h-[75px]">
+                  <img
+                    class="rounded-lg w-[75px] h-[75px] object-cover"
+                    src={api.getAssetThumbnailUrl(reaction.assetId, ThumbnailFormat.Webp)}
+                    alt="comment-thumbnail"
+                  />
+                </div>
+              {/if}
               {#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) : '')}>
@@ -176,17 +195,18 @@
               <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"
+                    class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-3 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-100 transition-colors"
                     use:clickOutside
                     on:outclick={() => (showDeleteReaction[index] = false)}
                     on:click={() => handleDeleteReaction(reaction, index)}
                   >
-                    Delete
+                    Remove
                   </button>
                 {/if}
               </div>
             </div>
-            {#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
+
+            {#if (index != reactions.length - 1 && !shouldGroup(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)}
@@ -196,17 +216,26 @@
             {/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="flex p-3 mx-2 mt-3 rounded-full gap-4 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()}`}
+                  {`${reaction.user.firstName} ${reaction.user.lastName} liked ${
+                    assetType ? `this ${getAssetType(assetType).toLowerCase()}` : 'it'
+                  }`}
                 </div>
+                {#if assetId === undefined && reaction.assetId}
+                  <div class="aspect-square w-[75px] h-[75px]">
+                    <img
+                      class="rounded-lg w-[75px] h-[75px] object-cover"
+                      src={api.getAssetThumbnailUrl(reaction.assetId, ThumbnailFormat.Webp)}
+                      alt="like-thumbnail"
+                    />
+                  </div>
+                {/if}
                 {#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) : '')}>
@@ -217,12 +246,12 @@
                 <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"
+                      class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-3 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-100 transition-colors"
                       use:clickOutside
                       on:outclick={() => (showDeleteReaction[index] = false)}
                       on:click={() => handleDeleteReaction(reaction, index)}
                     >
-                      Delete Like
+                      Remove
                     </button>
                   {/if}
                 </div>
@@ -266,8 +295,8 @@
               </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 class="flex items-end w-fit ml-0">
+              <CircleIconButton size="15" icon={mdiSend} iconColor={'dark'} hoverColor={'rgb(173,203,250)'} />
             </div>
           {/if}
         </form>

+ 24 - 31
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -33,18 +33,13 @@
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { SlideshowHistory } from '$lib/utils/slideshow-history';
   import { featureFlags } from '$lib/stores/server-config.store';
-  import {
-    mdiHeartOutline,
-    mdiHeart,
-    mdiCommentOutline,
-    mdiChevronLeft,
-    mdiChevronRight,
-    mdiImageBrokenVariant,
-  } from '@mdi/js';
+  import { mdiChevronLeft, mdiChevronRight, mdiImageBrokenVariant } 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';
+  import ActivityStatus from './activity-status.svelte';
+  import { updateNumberOfComments } from '$lib/stores/activity.store';
   import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
   import SlideshowBar from './slideshow-bar.svelte';
 
@@ -55,7 +50,7 @@
   $: isTrashEnabled = $featureFlags.trash;
   export let force = false;
   export let withStacked = false;
-  export let isShared = true;
+  export let isShared = false;
   export let user: UserResponseDto | null = null;
   export let album: AlbumResponseDto | null = null;
 
@@ -109,6 +104,16 @@
     }
   }
 
+  const handleAddComment = () => {
+    numberOfComments++;
+    updateNumberOfComments(1);
+  };
+
+  const handleRemoveComment = () => {
+    numberOfComments--;
+    updateNumberOfComments(-1);
+  };
+
   const handleFavorite = async () => {
     if (album) {
       try {
@@ -658,25 +663,13 @@
         {/if}
         {#if $slideshowState === SlideshowState.None && 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>
+            <ActivityStatus
+              {isLiked}
+              {numberOfComments}
+              {isShowActivity}
+              on:favorite={handleFavorite}
+              on:openActivityTab={handleOpenActivity}
+            />
           </div>
         {/if}
       {/key}
@@ -746,7 +739,7 @@
     <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"
+      class="z-[1002] row-start-1 row-span-5 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
@@ -756,8 +749,8 @@
         albumId={album.id}
         assetId={asset.id}
         bind:reactions
-        on:addComment={() => numberOfComments++}
-        on:deleteComment={() => numberOfComments--}
+        on:addComment={handleAddComment}
+        on:deleteComment={handleRemoveComment}
         on:deleteLike={() => (isLiked = null)}
         on:close={() => (isShowActivity = false)}
       />

+ 2 - 1
web/src/lib/components/elements/buttons/circle-icon-button.svelte

@@ -10,6 +10,7 @@
   export let isOpacity = false;
   export let forceDark = false;
   export let hideMobile = false;
+  export let iconColor = 'currentColor';
 </script>
 
 <button
@@ -23,7 +24,7 @@
   {hideMobile && 'hidden sm:flex'}"
   on:click
 >
-  <Icon path={icon} {size} />
+  <Icon path={icon} {size} color={iconColor} />
   <slot />
 </button>
 

+ 1 - 1
web/src/lib/components/shared-components/control-app-bar.svelte

@@ -40,7 +40,7 @@
   });
 </script>
 
-<div in:fly={{ y: 10, duration: 200 }} class="fixed top-0 z-[100] w-full bg-transparent">
+<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
   <div
     id="asset-selection-app-bar"
     class={`grid grid-cols-[10%_80%_10%] justify-between md:grid-cols-[20%_60%_20%] lg:grid-cols-3 ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${

+ 1 - 1
web/src/lib/components/shared-components/scrollbar/scrollbar.svelte

@@ -93,7 +93,7 @@
 {#if $assetStore.timelineHeight > height}
   <div
     id="immich-scrubbable-scrollbar"
-    class="fixed right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
+    class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
     style:width={isDragging ? '100vw' : '60px'}
     style:height={height + 'px'}
     style:background-color={isDragging ? 'transparent' : 'transparent'}

+ 11 - 0
web/src/lib/stores/activity.store.ts

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

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

@@ -35,7 +35,7 @@
   import { downloadArchive } from '$lib/utils/asset-utils';
   import { openFileUploadDialog } from '$lib/utils/file-uploader';
   import { handleError } from '$lib/utils/handle-error';
-  import { UserResponseDto, api } from '@api';
+  import { ActivityResponseDto, ReactionType, UserResponseDto, api } from '@api';
   import Icon from '$lib/components/elements/icon.svelte';
   import type { PageData } from './$types';
   import { clickOutside } from '$lib/utils/click-outside';
@@ -45,11 +45,16 @@
     mdiDotsVertical,
     mdiArrowLeft,
     mdiFileImagePlusOutline,
-    mdiShareVariantOutline,
-    mdiDeleteOutline,
     mdiFolderDownloadOutline,
     mdiLink,
+    mdiShareVariantOutline,
+    mdiDeleteOutline,
   } from '@mdi/js';
+  import { onMount } from 'svelte';
+  import { fly } from 'svelte/transition';
+  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';
 
   export let data: PageData;
 
@@ -77,6 +82,12 @@
   let isCreatingSharedAlbum = false;
   let currentAlbumName = '';
   let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
+  let isShowActivity = false;
+  let isLiked: ActivityResponseDto | null = null;
+  let reactions: ActivityResponseDto[] = [];
+  let user = data.user;
+  let globalWidth: number;
+  let assetGridWidth: number;
 
   const assetStore = new AssetStore({ albumId: album.id });
   const assetInteractionStore = createAssetInteractionStore();
@@ -89,6 +100,13 @@
   $: isOwned = data.user.id == album.ownerId;
   $: isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === data.user.id);
   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
+  $: {
+    if (isShowActivity) {
+      assetGridWidth = globalWidth - (globalWidth < 768 ? 360 : 460);
+    } else {
+      assetGridWidth = globalWidth;
+    }
+  }
 
   afterNavigate(({ from }) => {
     assetViewingStore.showAssetViewer(false);
@@ -110,6 +128,63 @@
     }
   });
 
+  const handleFavorite = async () => {
+    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, type: ReactionType.Like },
+        });
+
+        isLiked = data;
+        reactions = [...reactions, isLiked];
+      }
+    } catch (error) {
+      handleError(error, "Can't change favorite for asset");
+    }
+  };
+
+  const getFavorite = async () => {
+    if (user) {
+      try {
+        const { data } = await api.activityApi.getActivities({
+          userId: user.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 () => {
+    try {
+      const { data } = await api.activityApi.getActivityStatistics({ albumId: album.id });
+      setNumberOfComments(data.comments);
+    } catch (error) {
+      handleError(error, "Can't get number of comments");
+    }
+  };
+
+  const handleOpenAndCloseActivityTab = () => {
+    isShowActivity = !isShowActivity;
+  };
+
+  onMount(async () => {
+    if (album.sharedUsers.length > 0) {
+      getFavorite();
+      getNumberOfComments();
+    }
+  });
+
   const handleStartSlideshow = async () => {
     const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
     if (asset) {
@@ -321,239 +396,275 @@
   };
 </script>
 
-<header>
-  {#if $isMultiSelectState}
-    <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
-      <CreateSharedLink />
-      <SelectAllAssets {assetStore} {assetInteractionStore} />
-      <AssetSelectContextMenu icon={mdiPlus} title="Add">
-        <AddToAlbum />
-        <AddToAlbum shared />
-      </AssetSelectContextMenu>
-      <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
-        {#if isAllUserOwned}
-          <FavoriteAction menuItem removeFavorite={isAllFavorite} />
-        {/if}
-        <ArchiveAction menuItem />
-        <DownloadAction menuItem filename="{album.albumName}.zip" />
-        {#if isOwned || isAllUserOwned}
-          <RemoveFromAlbum menuItem bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
-        {/if}
-        {#if isAllUserOwned}
-          <DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
-        {/if}
-      </AssetSelectContextMenu>
-    </AssetSelectControlBar>
-  {:else}
-    {#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
-      <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close-button-click={() => goto(backUrl)}>
-        <svelte:fragment slot="trailing">
-          <CircleIconButton
-            title="Add Photos"
-            on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
-            icon={mdiFileImagePlusOutline}
-          />
-
-          {#if isOwned}
-            <CircleIconButton
-              title="Share"
-              on:click={() => (viewMode = ViewMode.SELECT_USERS)}
-              icon={mdiShareVariantOutline}
-            />
+<div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
+  <div class="relative w-full shrink">
+    {#if $isMultiSelectState}
+      <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
+        <CreateSharedLink />
+        <SelectAllAssets {assetStore} {assetInteractionStore} />
+        <AssetSelectContextMenu icon={mdiPlus} title="Add">
+          <AddToAlbum />
+          <AddToAlbum shared />
+        </AssetSelectContextMenu>
+        <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
+          {#if isAllUserOwned}
+            <FavoriteAction menuItem removeFavorite={isAllFavorite} />
+          {/if}
+          <ArchiveAction menuItem />
+          <DownloadAction menuItem filename="{album.albumName}.zip" />
+          {#if isOwned || isAllUserOwned}
+            <RemoveFromAlbum menuItem bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
+          {/if}
+          {#if isAllUserOwned}
+            <DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
+          {/if}
+        </AssetSelectContextMenu>
+      </AssetSelectControlBar>
+    {:else}
+      {#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
+        <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close-button-click={() => goto(backUrl)}>
+          <svelte:fragment slot="trailing">
             <CircleIconButton
-              title="Delete album"
-              on:click={() => (viewMode = ViewMode.CONFIRM_DELETE)}
-              icon={mdiDeleteOutline}
+              title="Add Photos"
+              on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
+              icon={mdiFileImagePlusOutline}
             />
-          {/if}
-
-          {#if album.assetCount > 0}
-            <CircleIconButton title="Download" on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
 
             {#if isOwned}
-              <div use:clickOutside on:outclick={() => (viewMode = ViewMode.VIEW)}>
-                <CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
-                  {#if viewMode === ViewMode.ALBUM_OPTIONS}
-                    <ContextMenu {...contextMenuPosition}>
-                      {#if album.assetCount !== 0}
-                        <MenuOption on:click={handleStartSlideshow} text="Slideshow" />
-                      {/if}
-                      <MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
-                    </ContextMenu>
-                  {/if}
-                </CircleIconButton>
-              </div>
+              <CircleIconButton
+                title="Share"
+                on:click={() => (viewMode = ViewMode.SELECT_USERS)}
+                icon={mdiShareVariantOutline}
+              />
+              <CircleIconButton
+                title="Delete album"
+                on:click={() => (viewMode = ViewMode.CONFIRM_DELETE)}
+                icon={mdiDeleteOutline}
+              />
             {/if}
-          {/if}
 
-          {#if isCreatingSharedAlbum && album.sharedUsers.length === 0}
-            <Button
-              size="sm"
-              rounded="lg"
-              disabled={album.assetCount == 0}
-              on:click={() => (viewMode = ViewMode.SELECT_USERS)}
-            >
-              Share
-            </Button>
-          {/if}
-        </svelte:fragment>
-      </ControlAppBar>
-    {/if}
+            {#if album.assetCount > 0}
+              <CircleIconButton title="Download" on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
 
-    {#if viewMode === ViewMode.SELECT_ASSETS}
-      <ControlAppBar on:close-button-click={handleCloseSelectAssets}>
-        <svelte:fragment slot="leading">
-          <p class="text-lg dark:text-immich-dark-fg">
-            {#if $timelineSelected.size == 0}
-              Add to album
-            {:else}
-              {$timelineSelected.size.toLocaleString($locale)} selected
+              {#if isOwned}
+                <div use:clickOutside on:outclick={() => (viewMode = ViewMode.VIEW)}>
+                  <CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
+                    {#if viewMode === ViewMode.ALBUM_OPTIONS}
+                      <ContextMenu {...contextMenuPosition}>
+                        {#if album.assetCount !== 0}
+                          <MenuOption on:click={handleStartSlideshow} text="Slideshow" />
+                        {/if}
+                        <MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
+                      </ContextMenu>
+                    {/if}
+                  </CircleIconButton>
+                </div>
+              {/if}
             {/if}
-          </p>
-        </svelte:fragment>
-
-        <svelte:fragment slot="trailing">
-          <button
-            on:click={handleSelectFromComputer}
-            class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25"
-          >
-            Select from computer
-          </button>
-          <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}>Done</Button
-          >
-        </svelte:fragment>
-      </ControlAppBar>
-    {/if}
 
-    {#if viewMode === ViewMode.SELECT_THUMBNAIL}
-      <ControlAppBar on:close-button-click={() => (viewMode = ViewMode.VIEW)}>
-        <svelte:fragment slot="leading">Select Album Cover</svelte:fragment>
-      </ControlAppBar>
+            {#if isCreatingSharedAlbum && album.sharedUsers.length === 0}
+              <Button
+                size="sm"
+                rounded="lg"
+                disabled={album.assetCount == 0}
+                on:click={() => (viewMode = ViewMode.SELECT_USERS)}
+              >
+                Share
+              </Button>
+            {/if}
+          </svelte:fragment>
+        </ControlAppBar>
+      {/if}
+
+      {#if viewMode === ViewMode.SELECT_ASSETS}
+        <ControlAppBar on:close-button-click={handleCloseSelectAssets}>
+          <svelte:fragment slot="leading">
+            <p class="text-lg dark:text-immich-dark-fg">
+              {#if $timelineSelected.size === 0}
+                Add to album
+              {:else}
+                {$timelineSelected.size.toLocaleString($locale)} selected
+              {/if}
+            </p>
+          </svelte:fragment>
+
+          <svelte:fragment slot="trailing">
+            <button
+              on:click={handleSelectFromComputer}
+              class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25"
+            >
+              Select from computer
+            </button>
+            <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}
+              >Done</Button
+            >
+          </svelte:fragment>
+        </ControlAppBar>
+      {/if}
+
+      {#if viewMode === ViewMode.SELECT_THUMBNAIL}
+        <ControlAppBar on:close-button-click={() => (viewMode = ViewMode.VIEW)}>
+          <svelte:fragment slot="leading">Select Album Cover</svelte:fragment>
+        </ControlAppBar>
+      {/if}
     {/if}
-  {/if}
-</header>
-
-<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"
->
-  {#if viewMode === ViewMode.SELECT_ASSETS}
-    <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)}
-      on:escape={handleEscape}
+
+    <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"
+      style={`width:${assetGridWidth}px`}
     >
-      {#if viewMode !== ViewMode.SELECT_THUMBNAIL}
-        <!-- ALBUM TITLE -->
-        <section class="pt-24">
-          <input
-            on:keydown={(e) => e.key == 'Enter' && titleInput.blur()}
-            on:blur={handleUpdateName}
-            class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
-              ? 'hover:border-gray-400'
-              : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"
-            type="text"
-            bind:value={album.albumName}
-            disabled={!isOwned}
-            bind:this={titleInput}
-            title="Edit Title"
-          />
+      {#if viewMode === ViewMode.SELECT_ASSETS}
+        <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)}
+          on:escape={handleEscape}
+        >
+          {#if viewMode !== ViewMode.SELECT_THUMBNAIL}
+            <!-- ALBUM TITLE -->
+            <section class="pt-24">
+              <input
+                on:keydown={(e) => e.key === 'Enter' && titleInput.blur()}
+                on:blur={handleUpdateName}
+                class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
+                  ? 'hover:border-gray-400'
+                  : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"
+                type="text"
+                bind:value={album.albumName}
+                disabled={!isOwned}
+                bind:this={titleInput}
+                title="Edit Title"
+              />
+
+              <!-- ALBUM SUMMARY -->
+              {#if album.assetCount > 0}
+                <span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
+                  <p class="">{getDateRange()}</p>
+                  <p>·</p>
+                  <p>{album.assetCount} items</p>
+                </span>
+              {/if}
 
-          <!-- ALBUM SUMMARY -->
-          {#if album.assetCount > 0}
-            <span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
-              <p class="">{getDateRange()}</p>
-              <p>·</p>
-              <p>{album.assetCount} items</p>
-            </span>
-          {/if}
+              <!-- ALBUM SHARING -->
+              {#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)}
+                <div class="my-6 flex gap-x-1">
+                  <!-- link -->
+                  {#if album.hasSharedLink && isOwned}
+                    <CircleIconButton
+                      backgroundColor="#d3d3d3"
+                      forceDark
+                      size="20"
+                      icon={mdiLink}
+                      on:click={() => (viewMode = ViewMode.LINK_SHARING)}
+                    />
+                  {/if}
 
-          <!-- ALBUM SHARING -->
-          {#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)}
-            <div class="my-6 flex gap-x-1">
-              <!-- link -->
-              {#if album.hasSharedLink && isOwned}
-                <CircleIconButton
-                  backgroundColor="#d3d3d3"
-                  forceDark
-                  size="20"
-                  icon={mdiLink}
-                  on:click={() => (viewMode = ViewMode.LINK_SHARING)}
-                />
+                  <!-- owner -->
+                  <button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
+                    <UserAvatar user={album.owner} size="md" autoColor />
+                  </button>
+
+                  <!-- users -->
+                  {#each album.sharedUsers as user (user.id)}
+                    <button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
+                      <UserAvatar {user} size="md" autoColor />
+                    </button>
+                  {/each}
+
+                  {#if isOwned}
+                    <CircleIconButton
+                      backgroundColor="#d3d3d3"
+                      forceDark
+                      size="20"
+                      icon={mdiPlus}
+                      on:click={() => (viewMode = ViewMode.SELECT_USERS)}
+                      title="Add more users"
+                    />
+                  {/if}
+                </div>
               {/if}
 
-              <!-- owner -->
-              <button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
-                <UserAvatar user={album.owner} size="md" autoColor />
-              </button>
-
-              <!-- users -->
-              {#each album.sharedUsers as user (user.id)}
-                <button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
-                  <UserAvatar {user} size="md" autoColor />
+              <!-- ALBUM DESCRIPTION -->
+              {#if isOwned || album.description}
+                <button
+                  class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300"
+                  on:click={() => (isEditingDescription = true)}
+                  class:hover:border-gray-400={isOwned}
+                  disabled={!isOwned}
+                  title="Edit description"
+                >
+                  {album.description || 'Add description'}
                 </button>
-              {/each}
-
-              {#if isOwned}
-                <CircleIconButton
-                  backgroundColor="#d3d3d3"
-                  forceDark
-                  size="20"
-                  icon={mdiPlus}
-                  on:click={() => (viewMode = ViewMode.SELECT_USERS)}
-                  title="Add more users"
-                />
               {/if}
-            </div>
+            </section>
           {/if}
 
-          <!-- ALBUM DESCRIPTION -->
-          {#if isOwned || album.description}
-            <button
-              class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300"
-              on:click={() => (isEditingDescription = true)}
-              class:hover:border-gray-400={isOwned}
-              disabled={!isOwned}
-              title="Edit description"
-            >
-              {album.description || 'Add description'}
-            </button>
+          {#if album.assetCount === 0}
+            <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
+              <div class="w-[300px]">
+                <p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p>
+                <button
+                  on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
+                  class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
+                >
+                  <span class="text-text-immich-primary dark:text-immich-dark-primary"
+                    ><Icon path={mdiPlus} size="24" />
+                  </span>
+                  <span class="text-lg">Select photos</span>
+                </button>
+              </div>
+            </section>
           {/if}
-        </section>
+        </AssetGrid>
       {/if}
 
-      {#if album.assetCount === 0}
-        <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
-          <div class="w-[300px]">
-            <p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p>
-            <button
-              on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
-              class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
-            >
-              <span class="text-text-immich-primary dark:text-immich-dark-primary"
-                ><Icon path={mdiPlus} size="24" />
-              </span>
-              <span class="text-lg">Select photos</span>
-            </button>
-          </div>
-        </section>
+      {#if album.sharedUsers.length > 0 && !$showAssetViewer}
+        <div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
+          <ActivityStatus
+            {isLiked}
+            numberOfComments={$numberOfComments}
+            {isShowActivity}
+            on:favorite={handleFavorite}
+            on:openActivityTab={handleOpenAndCloseActivityTab}
+          />
+        </div>
       {/if}
-    </AssetGrid>
+    </main>
+  </div>
+  {#if album.sharedUsers.length > 0 && album && isShowActivity && user && !$showAssetViewer}
+    <div class="flex">
+      <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"
+        translate="yes"
+      >
+        <ActivityViewer
+          {user}
+          albumOwnerId={album.ownerId}
+          albumId={album.id}
+          bind:reactions
+          on:addComment={() => updateNumberOfComments(1)}
+          on:deleteComment={() => updateNumberOfComments(-1)}
+          on:deleteLike={() => (isLiked = null)}
+          on:close={handleOpenAndCloseActivityTab}
+        />
+      </div>
+    </div>
   {/if}
-</main>
-
+</div>
 {#if viewMode === ViewMode.SELECT_USERS}
   <UserSelectionModal
     {album}