Moves show all media to react pane
This commit is contained in:
parent
cdb779508b
commit
d8ea9856ec
25 changed files with 572 additions and 543 deletions
|
@ -47,7 +47,6 @@ const commonProps = {
|
|||
'onOutgoingVideoCallInConversation'
|
||||
),
|
||||
|
||||
onShowAllMedia: action('onShowAllMedia'),
|
||||
onGoBack: action('onGoBack'),
|
||||
|
||||
onArchive: action('onArchive'),
|
||||
|
|
|
@ -92,7 +92,6 @@ export type PropsActionsType = {
|
|||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
onSearchInConversation: () => void;
|
||||
onShowAllMedia: () => void;
|
||||
pushPanelForConversation: PushPanelForConversationActionType;
|
||||
setDisappearingMessages: (
|
||||
conversationId: string,
|
||||
|
@ -350,7 +349,6 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
|||
onArchive,
|
||||
onMarkUnread,
|
||||
onMoveToInbox,
|
||||
onShowAllMedia,
|
||||
pushPanelForConversation,
|
||||
setDisappearingMessages,
|
||||
setMuteExpiration,
|
||||
|
@ -494,7 +492,13 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
|||
{i18n('showMembers')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={onShowAllMedia}>{i18n('viewRecentMedia')}</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
pushPanelForConversation(id, { type: PanelType.AllMedia })
|
||||
}
|
||||
>
|
||||
{i18n('viewRecentMedia')}
|
||||
</MenuItem>
|
||||
<MenuItem divider />
|
||||
{!markedUnread ? (
|
||||
<MenuItem onClick={onMarkUnread}>{i18n('markUnread')}</MenuItem>
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
import * as React from 'react';
|
||||
import { select, text } from '@storybook/addon-knobs';
|
||||
import { random, range, sample, sortBy } from 'lodash';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setupI18n } from '../../../util/setupI18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
|
@ -106,6 +107,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
overrideProps.type || 'media'
|
||||
),
|
||||
mediaItems: overrideProps.mediaItems || [],
|
||||
onItemClick: action('onItemClick'),
|
||||
});
|
||||
|
||||
export function Documents() {
|
||||
|
|
|
@ -1,81 +1,69 @@
|
|||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// Copyright 2018-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
import type { ItemClickEvent } from './types/ItemClickEvent';
|
||||
import { MediaGridItem } from './MediaGridItem';
|
||||
import type { MediaItemType } from '../../../types/MediaItem';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
import type { LocalizerType } from '../../../types/Util';
|
||||
import type { MediaItemType } from '../../../types/MediaItem';
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
import { MediaGridItem } from './MediaGridItem';
|
||||
import { getMessageTimestamp } from '../../../util/getMessageTimestamp';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
|
||||
export type Props = {
|
||||
i18n: LocalizerType;
|
||||
header?: string;
|
||||
type: 'media' | 'documents';
|
||||
i18n: LocalizerType;
|
||||
mediaItems: Array<MediaItemType>;
|
||||
onItemClick?: (event: ItemClickEvent) => void;
|
||||
onItemClick: (event: ItemClickEvent) => unknown;
|
||||
type: 'media' | 'documents';
|
||||
};
|
||||
|
||||
export class AttachmentSection extends React.Component<Props> {
|
||||
public override render(): JSX.Element {
|
||||
const { header } = this.props;
|
||||
export function AttachmentSection({
|
||||
i18n,
|
||||
header,
|
||||
type,
|
||||
mediaItems,
|
||||
onItemClick,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="module-attachment-section">
|
||||
<h2 className="module-attachment-section__header">{header}</h2>
|
||||
<div className="module-attachment-section__items">
|
||||
{mediaItems.map((mediaItem, position, array) => {
|
||||
const shouldShowSeparator = position < array.length - 1;
|
||||
const { message, index, attachment } = mediaItem;
|
||||
|
||||
return (
|
||||
<div className="module-attachment-section">
|
||||
<h2 className="module-attachment-section__header">{header}</h2>
|
||||
<div className="module-attachment-section__items">
|
||||
{this.renderItems()}
|
||||
</div>
|
||||
const onClick = () => {
|
||||
onItemClick({ type, message, attachment });
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'media':
|
||||
return (
|
||||
<MediaGridItem
|
||||
key={`${message.id}-${index}`}
|
||||
mediaItem={mediaItem}
|
||||
onClick={onClick}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
case 'documents':
|
||||
return (
|
||||
<DocumentListItem
|
||||
key={`${message.id}-${index}`}
|
||||
fileName={attachment.fileName}
|
||||
fileSize={attachment.size}
|
||||
shouldShowSeparator={shouldShowSeparator}
|
||||
onClick={onClick}
|
||||
timestamp={getMessageTimestamp(message)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return missingCaseError(type);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderItems() {
|
||||
const { i18n, mediaItems, type } = this.props;
|
||||
|
||||
return mediaItems.map((mediaItem, position, array) => {
|
||||
const shouldShowSeparator = position < array.length - 1;
|
||||
const { message, index, attachment } = mediaItem;
|
||||
|
||||
const onClick = this.createClickHandler(mediaItem);
|
||||
switch (type) {
|
||||
case 'media':
|
||||
return (
|
||||
<MediaGridItem
|
||||
key={`${message.id}-${index}`}
|
||||
mediaItem={mediaItem}
|
||||
onClick={onClick}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
case 'documents':
|
||||
return (
|
||||
<DocumentListItem
|
||||
key={`${message.id}-${index}`}
|
||||
fileName={attachment.fileName}
|
||||
fileSize={attachment.size}
|
||||
shouldShowSeparator={shouldShowSeparator}
|
||||
onClick={onClick}
|
||||
timestamp={getMessageTimestamp(message)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return missingCaseError(type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private readonly createClickHandler = (mediaItem: MediaItemType) => () => {
|
||||
const { onItemClick, type } = this.props;
|
||||
const { message, attachment } = mediaItem;
|
||||
|
||||
if (!onItemClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
onItemClick({ type, message, attachment });
|
||||
};
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -24,10 +24,13 @@ export default {
|
|||
};
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
i18n,
|
||||
onItemClick: action('onItemClick'),
|
||||
conversationId: '123',
|
||||
documents: overrideProps.documents || [],
|
||||
i18n,
|
||||
loadMediaItems: action('loadMediaItems'),
|
||||
media: overrideProps.media || [],
|
||||
saveAttachment: action('saveAttachment'),
|
||||
showLightboxWithMedia: action('showLightboxWithMedia'),
|
||||
});
|
||||
|
||||
export function Populated(): JSX.Element {
|
||||
|
|
|
@ -1,170 +1,170 @@
|
|||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// Copyright 2018-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
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 { groupMediaItemsByDate } from './groupMediaItemsByDate';
|
||||
import type { ItemClickEvent } from './types/ItemClickEvent';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
import type { LocalizerType } from '../../../types/Util';
|
||||
import { Tabs } from '../../Tabs';
|
||||
import { getMessageTimestamp } from '../../../util/getMessageTimestamp';
|
||||
import { groupMediaItemsByDate } from './groupMediaItemsByDate';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
|
||||
import type { MediaItemType } from '../../../types/MediaItem';
|
||||
enum TabViews {
|
||||
Media = 'Media',
|
||||
Documents = 'Documents',
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
conversationId: string;
|
||||
documents: Array<MediaItemType>;
|
||||
i18n: LocalizerType;
|
||||
loadMediaItems: (id: string) => unknown;
|
||||
media: Array<MediaItemType>;
|
||||
|
||||
onItemClick?: (event: ItemClickEvent) => void;
|
||||
};
|
||||
|
||||
type State = {
|
||||
selectedTab: 'media' | 'documents';
|
||||
saveAttachment: SaveAttachmentActionCreatorType;
|
||||
showLightboxWithMedia: (
|
||||
selectedAttachmentPath: string | undefined,
|
||||
media: Array<MediaItemType>
|
||||
) => void;
|
||||
};
|
||||
|
||||
const MONTH_FORMAT = 'MMMM YYYY';
|
||||
|
||||
type TabSelectEvent = {
|
||||
type: 'media' | 'documents';
|
||||
};
|
||||
|
||||
function Tab({
|
||||
isSelected,
|
||||
label,
|
||||
onSelect,
|
||||
function MediaSection({
|
||||
type,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
label: string;
|
||||
onSelect?: (event: TabSelectEvent) => void;
|
||||
type: 'media' | 'documents';
|
||||
}) {
|
||||
const handleClick = onSelect
|
||||
? () => {
|
||||
onSelect({ type });
|
||||
i18n,
|
||||
media,
|
||||
documents,
|
||||
saveAttachment,
|
||||
showLightboxWithMedia,
|
||||
}: Pick<
|
||||
Props,
|
||||
'i18n' | 'media' | 'documents' | 'showLightboxWithMedia' | 'saveAttachment'
|
||||
> & { type: 'media' | 'documents' }): JSX.Element {
|
||||
const mediaItems = type === 'media' ? media : documents;
|
||||
|
||||
if (!mediaItems || mediaItems.length === 0) {
|
||||
const label = (() => {
|
||||
switch (type) {
|
||||
case 'media':
|
||||
return i18n('mediaEmptyState');
|
||||
|
||||
case 'documents':
|
||||
return i18n('documentsEmptyState');
|
||||
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
: undefined;
|
||||
})();
|
||||
|
||||
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(getMessageTimestamp(message));
|
||||
const header =
|
||||
section.type === 'yearMonth'
|
||||
? date.format(MONTH_FORMAT)
|
||||
: i18n(section.type);
|
||||
|
||||
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.sent_at);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'media': {
|
||||
showLightboxWithMedia(event.attachment.path, media);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new TypeError(`Unknown attachment type: '${event.type}'`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="module-media-gallery__sections">{sections}</div>;
|
||||
}
|
||||
|
||||
export function MediaGallery({
|
||||
conversationId,
|
||||
documents,
|
||||
i18n,
|
||||
loadMediaItems,
|
||||
media,
|
||||
saveAttachment,
|
||||
showLightboxWithMedia,
|
||||
}: Props): JSX.Element {
|
||||
const focusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
focusRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadMediaItems(conversationId);
|
||||
}, [conversationId, loadMediaItems]);
|
||||
|
||||
return (
|
||||
// Has key events handled elsewhere
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-gallery__tab',
|
||||
isSelected ? 'module-media-gallery__tab--active' : null
|
||||
)}
|
||||
onClick={handleClick}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
>
|
||||
{label}
|
||||
<div className="module-media-gallery" tabIndex={-1} ref={focusRef}>
|
||||
<Tabs
|
||||
initialSelectedTab={TabViews.Media}
|
||||
tabs={[
|
||||
{
|
||||
id: TabViews.Media,
|
||||
label: i18n('media'),
|
||||
},
|
||||
{
|
||||
id: TabViews.Documents,
|
||||
label: i18n('documents'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{({ selectedTab }) => (
|
||||
<div className="module-media-gallery__content">
|
||||
{selectedTab === TabViews.Media && (
|
||||
<MediaSection
|
||||
documents={documents}
|
||||
i18n={i18n}
|
||||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightboxWithMedia={showLightboxWithMedia}
|
||||
type="media"
|
||||
/>
|
||||
)}
|
||||
{selectedTab === TabViews.Documents && (
|
||||
<MediaSection
|
||||
documents={documents}
|
||||
i18n={i18n}
|
||||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightboxWithMedia={showLightboxWithMedia}
|
||||
type="documents"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export class MediaGallery extends React.Component<Props, State> {
|
||||
public readonly focusRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectedTab: 'media',
|
||||
};
|
||||
}
|
||||
|
||||
public override componentDidMount(): void {
|
||||
// When this component is created, it's initially not part of the DOM, and then it's
|
||||
// added off-screen and animated in. This ensures that the focus takes.
|
||||
setTimeout(() => {
|
||||
if (this.focusRef.current) {
|
||||
this.focusRef.current.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const { i18n } = this.props;
|
||||
const { selectedTab } = this.state;
|
||||
|
||||
return (
|
||||
<div className="module-media-gallery" tabIndex={-1} ref={this.focusRef}>
|
||||
<div className="module-media-gallery__tab-container">
|
||||
<Tab
|
||||
label={i18n('media')}
|
||||
type="media"
|
||||
isSelected={selectedTab === 'media'}
|
||||
onSelect={this.handleTabSelect}
|
||||
/>
|
||||
<Tab
|
||||
label={i18n('documents')}
|
||||
type="documents"
|
||||
isSelected={selectedTab === 'documents'}
|
||||
onSelect={this.handleTabSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-media-gallery__content">
|
||||
{this.renderSections()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private readonly handleTabSelect = (event: TabSelectEvent): void => {
|
||||
this.setState({ selectedTab: event.type });
|
||||
};
|
||||
|
||||
private renderSections() {
|
||||
const { i18n, media, documents, onItemClick } = this.props;
|
||||
const { selectedTab } = this.state;
|
||||
|
||||
const mediaItems = selectedTab === 'media' ? media : documents;
|
||||
const type = selectedTab;
|
||||
|
||||
if (!mediaItems || mediaItems.length === 0) {
|
||||
const label = (() => {
|
||||
switch (type) {
|
||||
case 'media':
|
||||
return i18n('mediaEmptyState');
|
||||
|
||||
case 'documents':
|
||||
return i18n('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(getMessageTimestamp(message));
|
||||
const header =
|
||||
section.type === 'yearMonth'
|
||||
? date.format(MONTH_FORMAT)
|
||||
: i18n(section.type);
|
||||
|
||||
return (
|
||||
<AttachmentSection
|
||||
key={header}
|
||||
header={header}
|
||||
i18n={i18n}
|
||||
type={type}
|
||||
mediaItems={section.mediaItems}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="module-media-gallery__sections">{sections}</div>;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue