Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 183 additions & 29 deletions src/components/Attachment/ModalGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
import React, { useCallback, useState } from 'react';
import clsx from 'clsx';

import type { GalleryItem } from '../Gallery';
import { BaseImage, Gallery as DefaultGallery, GalleryUI } from '../Gallery';
import type { BaseImageProps, GalleryItem } from '../Gallery';
import {
BaseImage as DefaultBaseImage,
Gallery as DefaultGallery,
GalleryUI,
} from '../Gallery';
import { LoadingIndicator } from '../Loading';
import { GlobalModal } from '../Modal';
import { useComponentContext, useTranslationContext } from '../../context';
import { IconArrowRotateClockwise } from '../Icons';
import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail';
import { CloseButtonOnModalOverlay } from '../Modal/CloseButtonOnModalOverlay';

const MAX_VISIBLE_THUMBNAILS = 4;
const BASE_IMAGE_PROP_KEYS = [
'className',
'crossOrigin',
'decoding',
'draggable',
'fetchPriority',
'height',
'loading',
'onError',
'onLoad',
'ref',
'showDownloadButtonOnError',
'sizes',
'srcSet',
'style',
'title',
'useMap',
'width',
] as const satisfies ReadonlyArray<keyof Omit<BaseImageProps, 'src'>>;
type BaseImagePropsWithoutSrc = Omit<BaseImageProps, 'src'>;
type PartialBaseImagePropMap = Partial<
Record<(typeof BASE_IMAGE_PROP_KEYS)[number], unknown>
>;

export type ModalGalleryProps = {
/** Array of media attachments to display */
Expand All @@ -18,10 +47,14 @@ export type ModalGalleryProps = {
};

export const ModalGallery = ({ className, items, modalClassName }: ModalGalleryProps) => {
const { t } = useTranslationContext();
const { Gallery = DefaultGallery, Modal = GlobalModal } = useComponentContext();
const {
BaseImage = DefaultBaseImage,
Gallery = DefaultGallery,
Modal = GlobalModal,
} = useComponentContext();
const [modalOpen, setModalOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const usesDefaultBaseImage = BaseImage === DefaultBaseImage;

const closeModal = useCallback(() => {
setModalOpen(false);
Expand Down Expand Up @@ -49,33 +82,17 @@ export const ModalGallery = ({ className, items, modalClassName }: ModalGalleryP
const showOverlay = isLastVisible && overflowCount > 0;

return (
<button
aria-label={
items.length === 1
? t('Open image in gallery')
: t('Open gallery at image {{ index }}', {
index: index + 1,
})
}
className='str-chat__modal-gallery__image'
<ThumbnailButton
BaseImage={BaseImage}
baseImageUsesDefaultBehavior={usesDefaultBaseImage}
index={index}
item={item}
itemCount={itemCount}
key={index}
onClick={() => handleThumbnailClick(index)}
type='button'
>
{item.videoThumbnailUrl ? (
<VideoThumbnail
alt={t('User uploaded content')}
src={item.videoThumbnailUrl}
/>
) : (
<BaseImage alt={t('User uploaded content')} src={item.imageUrl} />
)}
{showOverlay && (
<div className='str-chat__modal-gallery__placeholder'>
+{overflowCount}
</div>
)}
</button>
overflowCount={overflowCount}
showOverlay={showOverlay}
/>
);
})}
</div>
Expand All @@ -90,3 +107,140 @@ export const ModalGallery = ({ className, items, modalClassName }: ModalGalleryP
</>
);
};

type ThumbnailButtonProps = {
BaseImage: React.ComponentType<BaseImageProps>;
baseImageUsesDefaultBehavior: boolean;
index: number;
item: GalleryItem;
itemCount: number;
onClick: () => void;
overflowCount: number;
showOverlay: boolean;
};

const ThumbnailButton = ({
BaseImage,
baseImageUsesDefaultBehavior,
index,
item,
itemCount,
onClick,
overflowCount,
showOverlay,
}: ThumbnailButtonProps) => {
const { t } = useTranslationContext();
const imageUrl = item.imageUrl;
const [isLoadFailed, setIsLoadFailed] = useState(false);
const [isImageLoading, setIsImageLoading] = useState(Boolean(imageUrl));
const [retryCount, setRetryCount] = useState(0);

const {
onError: itemOnError,
onLoad: itemOnLoad,
...baseImageProps
} = getBaseImageProps(item);
const showRetryIndicator = isLoadFailed && !showOverlay;
const showLoadingIndicator = isImageLoading && !showRetryIndicator && !showOverlay;

const handleButtonClick = () => {
if (showRetryIndicator) {
setIsLoadFailed(false);
setIsImageLoading(true);
setRetryCount((currentRetryCount) => currentRetryCount + 1);
return;
}

onClick();
};

const buttonLabel = showRetryIndicator
? t('aria/Retry upload')
: itemCountAwareLabel({ imageIndex: index + 1, itemCount, t });

return (
<button
aria-label={buttonLabel}
className={clsx('str-chat__modal-gallery__image', {
'str-chat__modal-gallery__image--load-failed': showRetryIndicator,
'str-chat__modal-gallery__image--loading': showLoadingIndicator,
})}
onClick={handleButtonClick}
type='button'
>
{item.videoThumbnailUrl ? (
<VideoThumbnail alt={t('User uploaded content')} src={item.videoThumbnailUrl} />
) : (
<BaseImage
// Remount the image on retry so the browser gets a fresh load attempt and
// BaseImage clears its local load-failed state.
key={retryCount}
{...baseImageProps}
alt={item.alt ?? t('User uploaded content')}
onError={(event) => {
setIsImageLoading(false);
setIsLoadFailed(true);
itemOnError?.(event);
}}
onLoad={(event) => {
setIsImageLoading(false);
setIsLoadFailed(false);
itemOnLoad?.(event);
}}
src={imageUrl}
{...(baseImageUsesDefaultBehavior ? { showDownloadButtonOnError: false } : {})}
/>
)}
{showLoadingIndicator && (
<div
aria-hidden='true'
className='str-chat__modal-gallery__image-loading-overlay'
data-testid='str-chat__modal-gallery__image-loading-overlay'
>
<LoadingIndicator size={32} />
</div>
)}
{showRetryIndicator && (
<div
aria-hidden='true'
className='str-chat__modal-gallery__image-load-failed-overlay'
data-testid='str-chat__modal-gallery__image-load-failed-overlay'
>
<div className='str-chat__modal-gallery__image-retry-indicator'>
<IconArrowRotateClockwise />
</div>
</div>
)}
{showOverlay && (
<div className='str-chat__modal-gallery__placeholder'>+{overflowCount}</div>
)}
</button>
);
};

const itemCountAwareLabel = ({
imageIndex,
itemCount,
t,
}: {
imageIndex: number;
itemCount: number;
t: ReturnType<typeof useTranslationContext>['t'];
}) =>
itemCount === 1
? t('Open image in gallery')
: t('Open gallery at image {{ index }}', {
index: imageIndex,
});

const getBaseImageProps = (item: GalleryItem): BaseImagePropsWithoutSrc => {
const baseImageProps: PartialBaseImagePropMap = {};
for (const key of BASE_IMAGE_PROP_KEYS) {
const value = item[key];
if (value !== undefined) {
baseImageProps[key] = value;
}
}

return baseImageProps as BaseImagePropsWithoutSrc;
};
97 changes: 95 additions & 2 deletions src/components/Attachment/styling/ModalGallery.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
@use '../../../styling/utils';

.str-chat__message {
--str-chat__modal-gallery-load-failed-indicator-background: var(--accent-error);
--str-chat__modal-gallery-load-failed-indicator-color: var(--text-inverse);
--str-chat__modal-gallery-loading-background: var(--chat-bg-incoming);
--str-chat__modal-gallery-loading-highlight: var(--base-white);
}

.str-chat__message--me {
--str-chat__modal-gallery-loading-background: var(--chat-bg-outgoing);
}

.str-chat__attachment-list {
.str-chat__message-attachment--gallery {
$max-width: var(--str-chat__attachment-max-width);
Expand All @@ -11,13 +22,19 @@
grid-template-rows: 50% 50%;
overflow: hidden;
border-radius: var(--radius-lg);
// Safari needs this
width: fit-content;
gap: var(--str-chat__spacing-0_5);
width: $max-width;
max-width: $max-width;
// CDN resize requires height/max-height to be present on the img element, this rule ensures that
height: var(--str-chat__attachment-max-width);

.str-chat__modal-gallery__image {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}

&.str-chat__modal-gallery--two-images {
grid-template-rows: 1fr;
}
Expand Down Expand Up @@ -74,6 +91,7 @@
align-items: center;
justify-content: center;
margin: 0;
overflow: hidden;
position: relative;
// todo: we do not know the image background color;
background-color: transparent;
Expand All @@ -86,6 +104,71 @@
cursor: zoom-in;
// CDN resize requires max-width to be present on this element
max-width: $max-width;
transition: opacity 150ms ease-in-out;
}

&.str-chat__modal-gallery__image--loading {
img {
opacity: 0;
}
}

&.str-chat__modal-gallery__image--load-failed {
cursor: pointer;

img {
opacity: 0;
cursor: pointer;
}
}

.str-chat__modal-gallery__image-load-failed-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}

.str-chat__modal-gallery__image-loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--str-chat__modal-gallery-loading-background);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
var(--str-chat__modal-gallery-loading-highlight) 50%,
rgba(255, 255, 255, 0) 100%
);
background-repeat: no-repeat;
background-size: 200% 100%;
animation: str-chat__modal-gallery-loading-shimmer 1.2s linear infinite;
pointer-events: none;
}

.str-chat__modal-gallery__image-loading-overlay .str-chat__loading-indicator {
position: relative;
z-index: 1;
}

.str-chat__modal-gallery__image-retry-indicator {
@include utils.flex-row-center;
position: relative;
z-index: 1;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--radius-max);
background-color: var(--str-chat__modal-gallery-load-failed-indicator-background);
color: var(--str-chat__modal-gallery-load-failed-indicator-color);

svg {
width: 1.125rem;
height: 1.125rem;
}
}

&:hover::after {
Expand All @@ -98,3 +181,13 @@
}
}
}

@keyframes str-chat__modal-gallery-loading-shimmer {
from {
background-position: 200% 0;
}

to {
background-position: -200% 0;
}
}
Loading
Loading