diff --git a/stylesheets/components/Lightbox.scss b/stylesheets/components/Lightbox.scss index 3a7b18eda..809c8c135 100644 --- a/stylesheets/components/Lightbox.scss +++ b/stylesheets/components/Lightbox.scss @@ -52,10 +52,6 @@ } } - &__thumbnails_placeholder { - height: 44px; - } - &__thumbnail { @include button-reset; position: relative; diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 05d8f6199..d63ca3f98 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -4,7 +4,6 @@ import type { ReactNode } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; -import moment from 'moment'; import { createPortal } from 'react-dom'; import { noop } from 'lodash'; import { useSpring, animated, to } from '@react-spring/web'; @@ -20,9 +19,11 @@ import * as GoogleChrome from '../util/GoogleChrome'; import * as log from '../logging/log'; import { Avatar, AvatarSize } from './Avatar'; import { IMAGE_PNG, isImage, isVideo } from '../types/MIME'; +import { formatDateTimeForAttachment } from '../util/timestamp'; import { formatDuration } from '../util/formatDuration'; import { isGIF } from '../types/Attachment'; import { useRestoreFocus } from '../hooks/useRestoreFocus'; +import { usePrevious } from '../hooks/usePrevious'; export type PropsType = { children?: ReactNode; @@ -56,6 +57,17 @@ const INITIAL_IMAGE_TRANSFORM = { }, }; +const THUMBNAIL_SPRING_CONFIG = { + mass: 1, + tension: 986, + friction: 64, + velocity: 0, +}; + +const THUMBNAIL_WIDTH = 44; +const THUMBNAIL_PADDING = 8; +const THUMBNAIL_FULL_WIDTH = THUMBNAIL_WIDTH + THUMBNAIL_PADDING; + export function Lightbox({ children, closeLightbox, @@ -73,6 +85,9 @@ export function Lightbox({ hasNextMessage, hasPrevMessage, }: PropsType): JSX.Element | null { + const hasThumbnails = media.length > 1; + const hadThumbnails = usePrevious(hasThumbnails, hasThumbnails); + const justGotThumbnails = !hadThumbnails && hasThumbnails; const [root, setRoot] = React.useState(); const [videoElement, setVideoElement] = useState( @@ -271,6 +286,43 @@ export function Lightbox({ () => INITIAL_IMAGE_TRANSFORM ); + const thumbnailsMarginLeft = + 0 - (selectedIndex * THUMBNAIL_FULL_WIDTH + THUMBNAIL_WIDTH / 2); + + const [thumbnailsStyle, thumbnailsAnimation] = useSpring( + { + config: THUMBNAIL_SPRING_CONFIG, + to: { + marginLeft: thumbnailsMarginLeft, + opacity: hasThumbnails ? 1 : 0, + }, + }, + [selectedIndex, hasThumbnails] + ); + + useEffect(() => { + if (!justGotThumbnails) { + return; + } + + thumbnailsAnimation.stop(); + thumbnailsAnimation.set({ + marginLeft: + thumbnailsMarginLeft + + (selectedIndex === 0 ? -1 : 1) * THUMBNAIL_FULL_WIDTH, + opacity: 0, + }); + thumbnailsAnimation.start({ + marginLeft: thumbnailsMarginLeft, + opacity: 1, + }); + }, [ + justGotThumbnails, + selectedIndex, + thumbnailsMarginLeft, + thumbnailsAnimation, + ]); + const maxBoundsLimiter = useCallback( (x: number, y: number): [number, number] => { const zoomCache = zoomCacheRef.current; @@ -643,48 +695,46 @@ export function Lightbox({ {caption ? (
{caption}
) : null} - {media.length > 1 ? ( -
-
- {media.map((item, index) => ( - - ))} -
-
- ) : ( -
- )} + onSelectAttachment(index); + }} + > + {item.thumbnailObjectUrl ? ( + {i18n('lightboxImageAlt')} + ) : ( +
+ )} + + )) + : undefined} + +
, @@ -704,6 +754,8 @@ function LightboxHeader({ }): JSX.Element { const conversation = getConversation(message.conversationId); + const now = Date.now(); + return (
@@ -726,7 +778,7 @@ function LightboxHeader({
{conversation.title}
- {moment(message.received_at_ms).format('L LT')} + {formatDateTimeForAttachment(i18n, message.received_at_ms ?? now)}
diff --git a/ts/util/durations/duration-in-seconds.ts b/ts/util/durations/duration-in-seconds.ts index 3351880e7..282a90190 100644 --- a/ts/util/durations/duration-in-seconds.ts +++ b/ts/util/durations/duration-in-seconds.ts @@ -34,5 +34,6 @@ export namespace DurationInSeconds { export const HOUR = DurationInSeconds.fromHours(1); export const MINUTE = DurationInSeconds.fromMinutes(1); export const DAY = DurationInSeconds.fromDays(1); + export const WEEK = DurationInSeconds.fromWeeks(1); } /* eslint-enable @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare */ diff --git a/ts/util/timestamp.ts b/ts/util/timestamp.ts index efa4d64c3..b7e2ccb43 100644 --- a/ts/util/timestamp.ts +++ b/ts/util/timestamp.ts @@ -74,6 +74,49 @@ export function formatDateTimeShort( }).format(timestamp); } +export function formatDateTimeForAttachment( + i18n: LocalizerType, + rawTimestamp: RawTimestamp +): string { + const timestamp = rawTimestamp.valueOf(); + + const now = Date.now(); + const diff = now - timestamp; + + const locale = window.getPreferredSystemLocales(); + + if (diff < HOUR || isToday(timestamp)) { + return formatTime(i18n, rawTimestamp, now); + } + + const m = moment(timestamp); + + if (diff < WEEK && m.isSame(now, 'month')) { + return new Intl.DateTimeFormat(locale, { + weekday: 'short', + hour: 'numeric', + minute: 'numeric', + }).format(timestamp); + } + + if (m.isSame(now, 'year')) { + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + hour: 'numeric', + minute: 'numeric', + }).format(timestamp); + } + + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + }).format(timestamp); +} + export function formatDateTimeLong( i18n: LocalizerType, rawTimestamp: RawTimestamp