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>
This commit is contained in:
martin 2023-11-05 18:24:43 +01:00 committed by GitHub
parent 68000c21a8
commit a0743d8b7d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 450 additions and 272 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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