Add user avatars to web timelines

This commit is contained in:
Christopher Makarem 2024-09-06 15:25:18 -07:00
parent 8ab33b40da
commit 6f0a7d0638
12 changed files with 107 additions and 11 deletions

View File

@ -43,6 +43,7 @@
} from '@mdi/js'; } from '@mdi/js';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
export let album: AlbumResponseDto | null = null; export let album: AlbumResponseDto | null = null;
@ -83,6 +84,11 @@
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white" class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
data-testid="asset-viewer-navbar-actions" data-testid="asset-viewer-navbar-actions"
> >
{#if asset.owner && asset.owner.id != $user.id}
<div class="p-3 margin:auto">
<UserAvatar user={asset.owner} size="xs"></UserAvatar>
</div>
{/if}
{#if !asset.isTrashed && $user} {#if !asset.isTrashed && $user}
<ShareAction {asset} /> <ShareAction {asset} />
{/if} {/if}
@ -125,7 +131,6 @@
title={$t('editor')} title={$t('editor')}
/> />
{/if} --> {/if} -->
{#if isOwner} {#if isOwner}
<DeleteAction {asset} {onAction} /> <DeleteAction {asset} {onAction} />

View File

@ -455,9 +455,13 @@
</div> </div>
{/if} {/if}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner} {#if (currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner && asset.ownerId != $user.id) || (asset.ownerId != $user.id && asset.owner)}
<section class="px-6 dark:text-immich-dark-fg mt-4"> <section class="px-6 dark:text-immich-dark-fg mt-4">
<p class="text-sm">{$t('shared_by').toUpperCase()}</p> {#if currentAlbum}
<p class="text-sm">{$t('shared_by').toUpperCase()}</p>
{:else}
<p class="text-sm">{$t('partner_sharing').toUpperCase()}</p>
{/if}
<div class="flex gap-4 pt-4"> <div class="flex gap-4 pt-4">
<div> <div>
<UserAvatar user={asset.owner} size="md" /> <UserAvatar user={asset.owner} size="md" />

View File

@ -2,12 +2,14 @@
import { intersectionObserver } from '$lib/actions/intersection-observer'; import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
import { getAssetThumbnailUrl, getUserInfo, isSharedLink } from '$lib/utils'; import { getAssetThumbnailUrl, isSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { timeToSeconds } from '$lib/utils/date-time'; import { timeToSeconds } from '$lib/utils/date-time';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type UserResponseDto } from '@immich/sdk';
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store'; import { locale, playVideoThumbnailOnHover, showUserThumbnails } from '$lib/stores/preferences.store';
import { getUserAndCacheResult } from '$lib/utils/users';
import { getAssetPlaybackUrl } from '$lib/utils'; import { getAssetPlaybackUrl } from '$lib/utils';
import { import {
mdiArchiveArrowDownOutline, mdiArchiveArrowDownOutline,
@ -20,6 +22,7 @@
} from '@mdi/js'; } from '@mdi/js';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n';
import ImageThumbnail from './image-thumbnail.svelte'; import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
@ -46,6 +49,7 @@
export let readonly = false; export let readonly = false;
export let showArchiveIcon = false; export let showArchiveIcon = false;
export let showStackedIcon = true; export let showStackedIcon = true;
export let showUserThumbnailsinViewer = true;
export let intersectionConfig: { export let intersectionConfig: {
root?: HTMLElement; root?: HTMLElement;
bottom?: string; bottom?: string;
@ -75,6 +79,7 @@
let intersecting = false; let intersecting = false;
let lastRetrievedElement: HTMLElement | undefined; let lastRetrievedElement: HTMLElement | undefined;
let loaded = false; let loaded = false;
let shareUser: UserResponseDto | undefined;
$: if (!retrieveElement) { $: if (!retrieveElement) {
lastRetrievedElement = undefined; lastRetrievedElement = undefined;
@ -83,6 +88,9 @@
lastRetrievedElement = element; lastRetrievedElement = element;
onRetrieveElement?.(element); onRetrieveElement?.(element);
} }
$: if ($showUserThumbnails && showUserThumbnailsinViewer && (isSharedLink() || asset.ownerId != $user.id)) {
handlePromiseError(getShareUser());
}
$: width = thumbnailSize || thumbnailWidth || 235; $: width = thumbnailSize || thumbnailWidth || 235;
$: height = thumbnailSize || thumbnailHeight || 235; $: height = thumbnailSize || thumbnailHeight || 235;
@ -160,6 +168,14 @@
} }
}; };
const getShareUser = async () => {
try {
shareUser = await getUserAndCacheResult(asset.ownerId);
} catch (error) {
handleError(error, $t('errors.unable_to_load_liked_status'));
}
};
onDestroy(() => { onDestroy(() => {
assetStore?.taskManager.removeAllTasksForComponent(componentId); assetStore?.taskManager.removeAllTasksForComponent(componentId);
}); });
@ -269,9 +285,9 @@
</div> </div>
{/if} {/if}
{#if isSharedLink() || asset.ownerId != user.userId} {#if shareUser && showUserThumbnailsinViewer}
<div class="absolute bottom-2 left-2 z-10"> <div class="absolute bottom-2 left-2 z-10">
<UserAvatar user={$user} size="sm" /> <UserAvatar user={shareUser} size="sm" />
</div> </div>
{/if} {/if}

View File

@ -20,6 +20,7 @@
export let singleSelect = false; export let singleSelect = false;
export let withStacked = false; export let withStacked = false;
export let showArchiveIcon = false; export let showArchiveIcon = false;
export let showUserThumbnailsinViewer = true;
export let assetGridElement: HTMLElement | undefined = undefined; export let assetGridElement: HTMLElement | undefined = undefined;
export let renderThumbsAtBottomMargin: string | undefined = undefined; export let renderThumbsAtBottomMargin: string | undefined = undefined;
export let renderThumbsAtTopMargin: string | undefined = undefined; export let renderThumbsAtTopMargin: string | undefined = undefined;
@ -209,6 +210,7 @@
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)} onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
showStackedIcon={withStacked} showStackedIcon={withStacked}
{showArchiveIcon} {showArchiveIcon}
{showUserThumbnailsinViewer}
{asset} {asset}
{groupIndex} {groupIndex}
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}

View File

@ -62,6 +62,7 @@
export let withStacked = false; export let withStacked = false;
export let showArchiveIcon = false; export let showArchiveIcon = false;
export let isShared = false; export let isShared = false;
export let showUserThumbnailsinViewer = true;
export let album: AlbumResponseDto | null = null; export let album: AlbumResponseDto | null = null;
export let isShowDeleteConfirmation = false; export let isShowDeleteConfirmation = false;
@ -839,6 +840,7 @@
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM} renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
{withStacked} {withStacked}
{showArchiveIcon} {showArchiveIcon}
{showUserThumbnailsinViewer}
{assetStore} {assetStore}
{assetInteractionStore} {assetInteractionStore}
{isSelectionMode} {isSelectionMode}
@ -864,6 +866,7 @@
<AssetViewer <AssetViewer
{withStacked} {withStacked}
{assetStore} {assetStore}
{showUserThumbnailsinViewer}
asset={$viewingAsset} asset={$viewingAsset}
preloadAssets={$preloadAssets} preloadAssets={$preloadAssets}
{isShared} {isShared}

View File

@ -1,5 +1,5 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export type Size = 'full' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'; export type Size = 'full' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl';
</script> </script>
<script lang="ts"> <script lang="ts">
@ -55,6 +55,7 @@
const sizeClasses: Record<Size, string> = { const sizeClasses: Record<Size, string> = {
full: 'w-full h-full', full: 'w-full h-full',
xs: 'w-6 h-6',
sm: 'w-7 h-7', sm: 'w-7 h-7',
md: 'w-10 h-10', md: 'w-10 h-10',
lg: 'w-12 h-12', lg: 'w-12 h-12',
@ -89,7 +90,8 @@
{#if showFallback} {#if showFallback}
<span <span
class="flex h-full w-full select-none items-center justify-center font-medium" class="flex h-full w-full select-none items-center justify-center font-medium"
class:text-xs={size === 'sm'} class:text-xs={size === 'xs'}
class:text-sm={size === 'sm'}
class:text-lg={size === 'lg'} class:text-lg={size === 'lg'}
class:text-xl={size === 'xl'} class:text-xl={size === 'xl'}
class:text-2xl={size === 'xxl'} class:text-2xl={size === 'xxl'}

View File

@ -11,6 +11,7 @@
loopVideo, loopVideo,
playVideoThumbnailOnHover, playVideoThumbnailOnHover,
showDeleteModal, showDeleteModal,
showUserThumbnails,
} from '$lib/stores/preferences.store'; } from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils'; import { findLocale } from '$lib/utils';
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n'; import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
@ -169,6 +170,14 @@
bind:checked={$showDeleteModal} bind:checked={$showDeleteModal}
/> />
</div> </div>
<div class="ml-4">
<SettingSwitch
title={$t('show_user_thumbnails')}
subtitle={$t('show_user_thumbnails_description')}
bind:checked={$showUserThumbnails}
/>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -1137,6 +1137,8 @@
"show_search_options": "Show search options", "show_search_options": "Show search options",
"show_supporter_badge": "Supporter badge", "show_supporter_badge": "Supporter badge",
"show_supporter_badge_description": "Show a supporter badge", "show_supporter_badge_description": "Show a supporter badge",
"show_user_thumbnails": "Show user thumbnails",
"show_user_thumbnails_description": "Show user avatars on timelinle for shared albums and partners",
"shuffle": "Shuffle", "shuffle": "Shuffle",
"sidebar": "Sidebar", "sidebar": "Sidebar",
"sidebar_display_description": "Display a link to the view in the sidebar", "sidebar_display_description": "Display a link to the view in the sidebar",

View File

@ -139,6 +139,8 @@ export const albumViewSettings = persisted<AlbumViewSettings>('album-view-settin
export const showDeleteModal = persisted<boolean>('delete-confirm-dialog', true, {}); export const showDeleteModal = persisted<boolean>('delete-confirm-dialog', true, {});
export const showUserThumbnails = persisted<boolean>('show-user-thumbnails', true, {});
export const alwaysLoadOriginalFile = persisted<boolean>('always-load-original-file', false, {}); export const alwaysLoadOriginalFile = persisted<boolean>('always-load-original-file', false, {});
export const playVideoThumbnailOnHover = persisted<boolean>('play-video-thumbnail-on-hover', true, {}); export const playVideoThumbnailOnHover = persisted<boolean>('play-video-thumbnail-on-hover', true, {});

View File

@ -0,0 +1,35 @@
import { type UserResponseDto } from '@immich/sdk';
import { writable } from 'svelte/store';
export const users = writable<{ [key: string]: UserResponseDto | undefined }>({});
export const userExistsInStore = (userId: string): boolean => {
let exists = false;
users.subscribe((userStore) => {
try {
exists = userId in userStore;
} catch {
exists = false;
}
})();
return exists;
};
export const updateUserInStore = ({ user, userId }: { user?: UserResponseDto; userId?: string }) => {
users.update((userStore) => {
if (user) {
userStore[user.id] = user;
} else if (userId) {
userStore[userId] = undefined;
}
return userStore;
});
};
export const getUserFromStore = (userId: string): UserResponseDto | undefined => {
let userInfo: UserResponseDto | undefined;
users.subscribe((userStore) => {
userInfo = userStore[userId];
})();
return userInfo;
};

View File

@ -0,0 +1,16 @@
import { getUserFromStore, updateUserInStore, userExistsInStore } from '$lib/stores/users.store';
import { getUser, type UserResponseDto } from '@immich/sdk';
export const getUserAndCacheResult = async (userId: string, skipCache: boolean = false): Promise<UserResponseDto> => {
let user: UserResponseDto;
if (!skipCache && userExistsInStore(userId)) {
user = getUserFromStore(userId)!;
} else {
//Add to store indicating a request to server is in-flight
updateUserInStore({ userId });
user = await getUser({ id: userId });
//Update store with results of server request
updateUserInStore({ user });
}
return user;
};

View File

@ -46,5 +46,5 @@
</svelte:fragment> </svelte:fragment>
</ControlAppBar> </ControlAppBar>
{/if} {/if}
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} /> <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} showUserThumbnailsinViewer={false} />
</main> </main>