signal-desktop/ts/components/conversation/media-gallery/MediaGallery.tsx

291 lines
7.5 KiB
TypeScript

// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useRef } from 'react';
import moment from 'moment';
import type { ItemClickEvent } from './types/ItemClickEvent';
import type { LocalizerType } from '../../../types/Util';
import type { MediaItemType } from '../../../types/MediaItem';
import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations';
import { AttachmentSection } from './AttachmentSection';
import { EmptyState } from './EmptyState';
import { Tabs } from '../../Tabs';
import { groupMediaItemsByDate } from './groupMediaItemsByDate';
import { missingCaseError } from '../../../util/missingCaseError';
import { usePrevious } from '../../../hooks/usePrevious';
import type { AttachmentType } from '../../../types/Attachment';
enum TabViews {
Media = 'Media',
Documents = 'Documents',
}
export type Props = {
conversationId: string;
documents: ReadonlyArray<MediaItemType>;
i18n: LocalizerType;
haveOldestMedia: boolean;
haveOldestDocument: boolean;
loading: boolean;
initialLoad: (id: string) => unknown;
loadMoreMedia: (id: string) => unknown;
loadMoreDocuments: (id: string) => unknown;
media: ReadonlyArray<MediaItemType>;
saveAttachment: SaveAttachmentActionCreatorType;
showLightbox: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
};
const MONTH_FORMAT = 'MMMM YYYY';
function MediaSection({
documents,
i18n,
loading,
media,
saveAttachment,
showLightbox,
type,
}: Pick<
Props,
'documents' | 'i18n' | 'loading' | 'media' | 'saveAttachment' | 'showLightbox'
> & { type: 'media' | 'documents' }): JSX.Element {
const mediaItems = type === 'media' ? media : documents;
if (!mediaItems || mediaItems.length === 0) {
if (loading) {
return <div />;
}
const label = (() => {
switch (type) {
case 'media':
return i18n('icu:mediaEmptyState');
case 'documents':
return i18n('icu:documentsEmptyState');
default:
throw missingCaseError(type);
}
})();
return <EmptyState data-test="EmptyState" label={label} />;
}
const now = Date.now();
const sections = groupMediaItemsByDate(now, mediaItems).map(section => {
const first = section.mediaItems[0];
const { message } = first;
const date = moment(message.receivedAtMs || message.receivedAt);
function getHeader(): string {
switch (section.type) {
case 'yearMonth':
return date.format(MONTH_FORMAT);
case 'today':
return i18n('icu:today');
case 'yesterday':
return i18n('icu:yesterday');
case 'thisWeek':
return i18n('icu:thisWeek');
case 'thisMonth':
return i18n('icu:thisMonth');
default:
throw missingCaseError(section);
}
}
const header = getHeader();
return (
<AttachmentSection
key={header}
header={header}
i18n={i18n}
type={type}
mediaItems={section.mediaItems}
onItemClick={(event: ItemClickEvent) => {
switch (event.type) {
case 'documents': {
saveAttachment(event.attachment, event.message.sentAt);
break;
}
case 'media': {
showLightbox({
attachment: event.attachment,
messageId: event.message.id,
});
break;
}
default:
throw new TypeError(`Unknown attachment type: '${event.type}'`);
}
}}
/>
);
});
return <div className="module-media-gallery__sections">{sections}</div>;
}
export function MediaGallery({
conversationId,
documents,
haveOldestDocument,
haveOldestMedia,
i18n,
initialLoad,
loading,
loadMoreDocuments,
loadMoreMedia,
media,
saveAttachment,
showLightbox,
}: Props): JSX.Element {
const focusRef = useRef<HTMLDivElement | null>(null);
const scrollObserverRef = useRef<HTMLDivElement | null>(null);
const intersectionObserver = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<boolean>(false);
const tabViewRef = useRef<TabViews>(TabViews.Media);
useEffect(() => {
focusRef.current?.focus();
}, []);
useEffect(() => {
if (
media.length > 0 ||
documents.length > 0 ||
haveOldestDocument ||
haveOldestMedia
) {
return;
}
initialLoad(conversationId);
loadingRef.current = true;
}, [
conversationId,
haveOldestDocument,
haveOldestMedia,
initialLoad,
media,
documents,
]);
const previousLoading = usePrevious(loading, loading);
if (previousLoading && !loading) {
loadingRef.current = false;
}
useEffect(() => {
if (loading || !scrollObserverRef.current) {
return;
}
intersectionObserver.current?.disconnect();
intersectionObserver.current = null;
intersectionObserver.current = new IntersectionObserver(
(entries: ReadonlyArray<IntersectionObserverEntry>) => {
if (loadingRef.current) {
return;
}
const entry = entries.find(
item => item.target === scrollObserverRef.current
);
if (entry && entry.intersectionRatio > 0) {
if (tabViewRef.current === TabViews.Media) {
if (!haveOldestMedia) {
loadMoreMedia(conversationId);
loadingRef.current = true;
}
} else {
// eslint-disable-next-line no-lonely-if
if (!haveOldestDocument) {
loadMoreDocuments(conversationId);
loadingRef.current = true;
}
}
}
}
);
intersectionObserver.current.observe(scrollObserverRef.current);
return () => {
intersectionObserver.current?.disconnect();
intersectionObserver.current = null;
};
}, [
conversationId,
haveOldestDocument,
haveOldestMedia,
loading,
loadMoreDocuments,
loadMoreMedia,
]);
return (
<div className="module-media-gallery" tabIndex={-1} ref={focusRef}>
<Tabs
initialSelectedTab={TabViews.Media}
tabs={[
{
id: TabViews.Media,
label: i18n('icu:media'),
},
{
id: TabViews.Documents,
label: i18n('icu:documents'),
},
]}
>
{({ selectedTab }) => {
tabViewRef.current =
selectedTab === TabViews.Media
? TabViews.Media
: TabViews.Documents;
return (
<div className="module-media-gallery__content">
{selectedTab === TabViews.Media && (
<MediaSection
documents={documents}
i18n={i18n}
loading={loading}
media={media}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
type="media"
/>
)}
{selectedTab === TabViews.Documents && (
<MediaSection
documents={documents}
i18n={i18n}
loading={loading}
media={media}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
type="documents"
/>
)}
</div>
);
}}
</Tabs>
<div
ref={scrollObserverRef}
className="module-media-gallery__scroll-observer"
/>
</div>
);
}