Moves show all media to react pane

This commit is contained in:
Josh Perez 2022-12-20 12:50:23 -05:00 committed by GitHub
parent cdb779508b
commit d8ea9856ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 572 additions and 543 deletions

View file

@ -2232,42 +2232,6 @@ button.ConversationDetails__action-button {
outline: none;
}
.module-media-gallery__tab-container {
display: flex;
flex-grow: 0;
flex-shrink: 0;
cursor: pointer;
width: 100%;
}
.module-media-gallery__tab {
width: 100%;
padding: 20px;
text-align: center;
@include light-theme {
background-color: $color-gray-02;
}
@include dark-theme {
background-color: $color-gray-90;
}
outline: none;
&:focus {
@include keyboard-mode {
background-color: $color-gray-15;
}
@include dark-keyboard-mode {
background-color: $color-gray-75;
}
}
}
.module-media-gallery__tab--active {
border-bottom: 2px solid $color-ultramarine;
}
.module-media-gallery__content {
display: flex;
flex-grow: 1;

View file

@ -84,6 +84,7 @@
display: inline-flex;
flex-grow: 1;
justify-content: center;
margin-bottom: 12px;
overflow: hidden;
position: relative;
// Using this so that the zoom cleanly goes over the footer
@ -197,6 +198,7 @@
height: 56px;
justify-content: space-between;
margin-top: 24px;
min-height: 56px;
opacity: 1;
padding: 0 16px;
transition: opacity 150ms cubic-bezier(0.17, 0.17, 0, 1);

View file

@ -160,6 +160,7 @@ import { StoryViewModeType, StoryViewTargetType } from './types/Stories';
import { downloadOnboardingStory } from './util/downloadOnboardingStory';
import { clearConversationDraftAttachments } from './util/clearConversationDraftAttachments';
import { removeLinkPreview } from './services/LinkPreview';
import { PanelType } from './types/Panels';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -1121,6 +1122,10 @@ export async function startApp(): Promise<void> {
actionCreators.linkPreviews,
store.dispatch
),
mediaGallery: bindActionCreators(
actionCreators.mediaGallery,
store.dispatch
),
network: bindActionCreators(actionCreators.network, store.dispatch),
safetyNumber: bindActionCreators(
actionCreators.safetyNumber,
@ -1522,7 +1527,10 @@ export async function startApp(): Promise<void> {
shiftKey &&
(key === 'm' || key === 'M')
) {
conversation.trigger('open-all-media');
window.reduxActions.conversations.pushPanelForConversation(
conversation.id,
{ type: PanelType.AllMedia }
);
event.preventDefault();
event.stopPropagation();
return;

View file

@ -47,7 +47,6 @@ const commonProps = {
'onOutgoingVideoCallInConversation'
),
onShowAllMedia: action('onShowAllMedia'),
onGoBack: action('onGoBack'),
onArchive: action('onArchive'),

View file

@ -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>

View file

@ -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() {

View file

@ -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>
);
}

View file

@ -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 {

View file

@ -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>;
}
}

View file

@ -56,9 +56,7 @@ class ExpiringMessagesDeletionService {
// We do this to update the UI, if this message is being displayed somewhere
message.trigger('expired');
window.reduxActions.lightbox.closeLightboxIfViewingExpiredMessage(
message.id
);
window.reduxActions.conversations.messageExpired(message.id);
if (conversation) {
// An expired message only counts as decrementing the message count, not

View file

@ -24,9 +24,7 @@ async function eraseTapToViewMessages() {
// We do this to update the UI, if this message is being displayed somewhere
message.trigger('expired');
window.reduxActions.lightbox.closeLightboxIfViewingExpiredMessage(
message.id
);
window.reduxActions.conversations.messageExpired(message.id);
await message.eraseContents();
})

View file

@ -16,6 +16,7 @@ import { actions as globalModals } from './ducks/globalModals';
import { actions as items } from './ducks/items';
import { actions as lightbox } from './ducks/lightbox';
import { actions as linkPreviews } from './ducks/linkPreviews';
import { actions as mediaGallery } from './ducks/mediaGallery';
import { actions as network } from './ducks/network';
import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
@ -44,6 +45,7 @@ export const actionCreators: ReduxActions = {
items,
lightbox,
linkPreviews,
mediaGallery,
network,
safetyNumber,
search,
@ -72,6 +74,7 @@ export const mapDispatchToProps = {
...items,
...lightbox,
...linkPreviews,
...mediaGallery,
...network,
...safetyNumber,
...search,

View file

@ -469,8 +469,11 @@ export const SELECTED_CONVERSATION_CHANGED =
'conversations/SELECTED_CONVERSATION_CHANGED';
const PUSH_PANEL = 'conversations/PUSH_PANEL';
const POP_PANEL = 'conversations/POP_PANEL';
export const MESSAGE_EXPIRED = 'conversations/MESSAGE_EXPIRED';
export const MESSAGE_DELETED = 'MESSAGE_DELETED';
export const SET_VOICE_NOTE_PLAYBACK_RATE =
'conversations/SET_VOICE_NOTE_PLAYBACK_RATE';
export const CONVERSATION_UNLOADED = 'CONVERSATION_UNLOADED';
export type CancelVerificationDataByConversationActionType = {
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
@ -581,7 +584,7 @@ export type ConversationRemovedActionType = {
};
};
export type ConversationUnloadedActionType = {
type: 'CONVERSATION_UNLOADED';
type: typeof CONVERSATION_UNLOADED;
payload: {
id: string;
};
@ -626,7 +629,7 @@ export type MessageChangedActionType = {
};
};
export type MessageDeletedActionType = {
type: 'MESSAGE_DELETED';
type: typeof MESSAGE_DELETED;
payload: {
id: string;
conversationId: string;
@ -651,6 +654,13 @@ export type MessagesAddedActionType = {
};
};
export type MessageExpiredActionType = {
type: typeof MESSAGE_EXPIRED;
payload: {
id: string;
};
};
export type RepairNewestMessageActionType = {
type: 'REPAIR_NEWEST_MESSAGE';
payload: {
@ -906,6 +916,7 @@ export const actions = {
messageChanged,
messageDeleted,
messageExpanded,
messageExpired,
messagesAdded,
messagesReset,
myProfileChanged,
@ -1979,7 +1990,7 @@ function conversationRemoved(id: string): ConversationRemovedActionType {
}
function conversationUnloaded(id: string): ConversationUnloadedActionType {
return {
type: 'CONVERSATION_UNLOADED',
type: CONVERSATION_UNLOADED,
payload: {
id,
},
@ -2118,7 +2129,7 @@ function messageDeleted(
conversationId: string
): MessageDeletedActionType {
return {
type: 'MESSAGE_DELETED',
type: MESSAGE_DELETED,
payload: {
id,
conversationId,
@ -2137,6 +2148,14 @@ function messageExpanded(
},
};
}
function messageExpired(id: string): MessageExpiredActionType {
return {
type: MESSAGE_EXPIRED,
payload: {
id,
},
};
}
function messagesAdded({
conversationId,
isActive,
@ -3707,7 +3726,7 @@ export function reducer(
...updateConversationLookups(undefined, existing, state),
};
}
if (action.type === 'CONVERSATION_UNLOADED') {
if (action.type === CONVERSATION_UNLOADED) {
const { payload } = action;
const { id } = payload;
const existingConversation = state.messagesByConversation[id];
@ -4185,7 +4204,7 @@ export function reducer(
},
};
}
if (action.type === 'MESSAGE_DELETED') {
if (action.type === MESSAGE_DELETED) {
const { id, conversationId } = action.payload;
const { messagesByConversation, messagesLookup } = state;

View file

@ -6,9 +6,10 @@ import type { ThunkAction } from 'redux-thunk';
import type { AttachmentType } from '../../types/Attachment';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type { MediaItemType } from '../../types/MediaItem';
import type { StateType as RootStateType } from '../reducer';
import type { MessageExpiredActionType } from './conversations';
import type { ShowStickerPackPreviewActionType } from './globalModals';
import type { ShowToastActionType } from './toast';
import type { StateType as RootStateType } from '../reducer';
import * as log from '../../logging/log';
import { getMessageById } from '../../messages/getMessageById';
@ -20,7 +21,7 @@ import {
import { isTapToView } from '../selectors/message';
import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast';
import { saveAttachmentFromMessage } from './conversations';
import { MESSAGE_EXPIRED, saveAttachmentFromMessage } from './conversations';
import { showStickerPackPreview } from './globalModals';
import { useBoundActions } from '../../hooks/useBoundActions';
@ -51,7 +52,10 @@ type ShowLightboxActionType = {
};
};
type LightboxActionType = CloseLightboxActionType | ShowLightboxActionType;
type LightboxActionType =
| CloseLightboxActionType
| MessageExpiredActionType
| ShowLightboxActionType;
function closeLightbox(): ThunkAction<
void,
@ -83,34 +87,6 @@ function closeLightbox(): ThunkAction<
};
}
function closeLightboxIfViewingExpiredMessage(
messageId: string
): ThunkAction<void, RootStateType, unknown, CloseLightboxActionType> {
return (dispatch, getState) => {
const { lightbox } = getState();
if (!lightbox.isShowingLightbox) {
return;
}
const { isViewOnce, media } = lightbox;
if (!isViewOnce) {
return;
}
const hasExpiredMedia = media.some(item => item.message.id === messageId);
if (!hasExpiredMedia) {
return;
}
dispatch({
type: CLOSE_LIGHTBOX,
});
};
}
function showLightboxWithMedia(
selectedAttachmentPath: string | undefined,
media: Array<MediaItemType>
@ -309,7 +285,6 @@ function showLightbox(opts: {
export const actions = {
closeLightbox,
closeLightboxIfViewingExpiredMessage,
showLightbox,
showLightboxForViewOnceMedia,
showLightboxWithMedia,
@ -340,5 +315,25 @@ export function reducer(
};
}
if (action.type === MESSAGE_EXPIRED) {
if (!state.isShowingLightbox) {
return state;
}
if (!state.isViewOnce) {
return state;
}
const hasExpiredMedia = state.media.some(
item => item.message.id === action.payload.id
);
if (!hasExpiredMedia) {
return state;
}
return getEmptyState();
}
return state;
}

View file

@ -0,0 +1,225 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import type { AttachmentType } from '../../types/Attachment';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type {
ConversationUnloadedActionType,
MessageDeletedActionType,
MessageExpiredActionType,
} from './conversations';
import type { MIMEType } from '../../types/MIME';
import type { MediaItemType } from '../../types/MediaItem';
import type { StateType as RootStateType } from '../reducer';
import dataInterface from '../../sql/Client';
import {
CONVERSATION_UNLOADED,
MESSAGE_DELETED,
MESSAGE_EXPIRED,
} from './conversations';
import { VERSION_NEEDED_FOR_DISPLAY } from '../../types/Message2';
import { isDownloading, hasFailed } from '../../types/Attachment';
import { isNotNil } from '../../util/isNotNil';
import { useBoundActions } from '../../hooks/useBoundActions';
type MediaType = {
path: string;
objectURL: string;
thumbnailObjectUrl?: string;
contentType: MIMEType;
index: number;
attachment: AttachmentType;
message: {
attachments: Array<AttachmentType>;
conversationId: string;
id: string;
received_at: number;
received_at_ms: number;
sent_at: number;
};
};
export type MediaGalleryStateType = {
documents: Array<MediaItemType>;
media: Array<MediaType>;
};
const LOAD_MEDIA_ITEMS = 'mediaGallery/LOAD_MEDIA_ITEMS';
type LoadMediaItemslActionType = {
type: typeof LOAD_MEDIA_ITEMS;
payload: {
documents: Array<MediaItemType>;
media: Array<MediaType>;
};
};
type MediaGalleryActionType =
| ConversationUnloadedActionType
| LoadMediaItemslActionType
| MessageDeletedActionType
| MessageExpiredActionType;
function loadMediaItems(
conversationId: string
): ThunkAction<void, RootStateType, unknown, LoadMediaItemslActionType> {
return async dispatch => {
const { getAbsoluteAttachmentPath, upgradeMessageSchema } =
window.Signal.Migrations;
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const DEFAULT_MEDIA_FETCH_COUNT = 50;
const DEFAULT_DOCUMENTS_FETCH_COUNT = 150;
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const rawMedia = await dataInterface.getMessagesWithVisualMediaAttachments(
conversationId,
{
limit: DEFAULT_MEDIA_FETCH_COUNT,
}
);
const rawDocuments = await dataInterface.getMessagesWithFileAttachments(
conversationId,
{
limit: DEFAULT_DOCUMENTS_FETCH_COUNT,
}
);
// First we upgrade these messages to ensure that they have thumbnails
await Promise.all(
rawMedia.map(async message => {
const { schemaVersion } = message;
const model = window.MessageController.register(message.id, message);
if (schemaVersion && schemaVersion < VERSION_NEEDED_FOR_DISPLAY) {
const upgradedMsgAttributes = await upgradeMessageSchema(message);
model.set(upgradedMsgAttributes);
await dataInterface.saveMessage(upgradedMsgAttributes, { ourUuid });
}
})
);
const media: Array<MediaType> = rawMedia
.flatMap(message => {
return (message.attachments || []).map(
(
attachment: AttachmentType,
index: number
): MediaType | undefined => {
if (
!attachment.path ||
!attachment.thumbnail ||
isDownloading(attachment) ||
hasFailed(attachment)
) {
return;
}
const { thumbnail } = attachment;
return {
path: attachment.path,
objectURL: getAbsoluteAttachmentPath(attachment.path),
thumbnailObjectUrl: thumbnail?.path
? getAbsoluteAttachmentPath(thumbnail.path)
: undefined,
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.lookupOrCreate({
uuid: message.sourceUuid,
e164: message.source,
reason: 'conversation_view.showAllMedia',
})?.id || message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
);
})
.filter(isNotNil);
// Unlike visual media, only one non-image attachment is supported
const documents: Array<MediaItemType> = rawDocuments
.map(message => {
const attachments = message.attachments || [];
const attachment = attachments[0];
if (!attachment) {
return;
}
return {
contentType: attachment.contentType,
index: 0,
attachment,
message: {
...message,
attachments: [attachment],
},
};
})
.filter(isNotNil);
dispatch({
type: LOAD_MEDIA_ITEMS,
payload: {
documents,
media,
},
});
};
}
export const actions = {
loadMediaItems,
};
export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
export function getEmptyState(): MediaGalleryStateType {
return {
documents: [],
media: [],
};
}
export function reducer(
state: Readonly<MediaGalleryStateType> = getEmptyState(),
action: Readonly<MediaGalleryActionType>
): MediaGalleryStateType {
if (action.type === LOAD_MEDIA_ITEMS) {
return {
...state,
...action.payload,
};
}
if (action.type === MESSAGE_DELETED || action.type === MESSAGE_EXPIRED) {
return {
...state,
media: state.media.filter(item => item.message.id !== action.payload.id),
documents: state.documents.filter(
item => item.message.id !== action.payload.id
),
};
}
if (action.type === CONVERSATION_UNLOADED) {
return getEmptyState();
}
return state;
}

View file

@ -13,6 +13,7 @@ import { getEmptyState as expiration } from './ducks/expiration';
import { getEmptyState as globalModals } from './ducks/globalModals';
import { getEmptyState as lightbox } from './ducks/lightbox';
import { getEmptyState as linkPreviews } from './ducks/linkPreviews';
import { getEmptyState as mediaGallery } from './ducks/mediaGallery';
import { getEmptyState as network } from './ducks/network';
import { getEmptyState as preferredReactions } from './ducks/preferredReactions';
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
@ -106,6 +107,7 @@ export function getInitialState({
items,
lightbox: lightbox(),
linkPreviews: linkPreviews(),
mediaGallery: mediaGallery(),
network: network(),
preferredReactions: preferredReactions(),
safetyNumber: safetyNumber(),

View file

@ -18,6 +18,7 @@ import { reducer as globalModals } from './ducks/globalModals';
import { reducer as items } from './ducks/items';
import { reducer as lightbox } from './ducks/lightbox';
import { reducer as linkPreviews } from './ducks/linkPreviews';
import { reducer as mediaGallery } from './ducks/mediaGallery';
import { reducer as network } from './ducks/network';
import { reducer as preferredReactions } from './ducks/preferredReactions';
import { reducer as safetyNumber } from './ducks/safetyNumber';
@ -46,6 +47,7 @@ export const reducer = combineReducers({
items,
lightbox,
linkPreviews,
mediaGallery,
network,
preferredReactions,
safetyNumber,

View file

@ -0,0 +1,8 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { StateType } from '../reducer';
import type { MediaGalleryStateType } from '../ducks/mediaGallery';
export const getMediaGalleryState = (state: StateType): MediaGalleryStateType =>
state.mediaGallery;

View file

@ -0,0 +1,34 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { useSelector } from 'react-redux';
import { MediaGallery } from '../../components/conversation/media-gallery/MediaGallery';
import { getMediaGalleryState } from '../selectors/mediaGallery';
import { useConversationsActions } from '../ducks/conversations';
import { useLightboxActions } from '../ducks/lightbox';
import { useMediaGalleryActions } from '../ducks/mediaGallery';
export type PropsType = {
conversationId: string;
};
export function SmartAllMedia({ conversationId }: PropsType): JSX.Element {
const { media, documents } = useSelector(getMediaGalleryState);
const { loadMediaItems } = useMediaGalleryActions();
const { saveAttachment } = useConversationsActions();
const { showLightboxWithMedia } = useLightboxActions();
return (
<MediaGallery
conversationId={conversationId}
i18n={window.i18n}
loadMediaItems={loadMediaItems}
media={media}
documents={documents}
showLightboxWithMedia={showLightboxWithMedia}
saveAttachment={saveAttachment}
/>
);
}

View file

@ -35,7 +35,6 @@ export type OwnProps = {
onMarkUnread: () => void;
onMoveToInbox: () => void;
onSearchInConversation: () => void;
onShowAllMedia: () => void;
};
const getOutgoingCallButtonStyle = (

View file

@ -12,6 +12,7 @@ import * as log from '../../logging/log';
import { ContactDetail } from '../../components/conversation/ContactDetail';
import { ConversationView } from '../../components/conversation/ConversationView';
import { PanelType } from '../../types/Panels';
import { SmartAllMedia } from './AllMedia';
import { SmartChatColorPicker } from './ChatColorPicker';
import { SmartCompositionArea } from './CompositionArea';
import { SmartConversationDetails } from './ConversationDetails';
@ -73,6 +74,14 @@ export function SmartConversationView({
return;
}
if (topPanel.type === PanelType.AllMedia) {
return (
<div className="panel">
<SmartAllMedia conversationId={conversationId} />
</div>
);
}
if (topPanel.type === PanelType.ChatColorEditor) {
return (
<div className="panel">

View file

@ -16,6 +16,7 @@ import type { actions as globalModals } from './ducks/globalModals';
import type { actions as items } from './ducks/items';
import type { actions as lightbox } from './ducks/lightbox';
import type { actions as linkPreviews } from './ducks/linkPreviews';
import type { actions as mediaGallery } from './ducks/mediaGallery';
import type { actions as network } from './ducks/network';
import type { actions as safetyNumber } from './ducks/safetyNumber';
import type { actions as search } from './ducks/search';
@ -43,6 +44,7 @@ export type ReduxActions = {
items: typeof items;
lightbox: typeof lightbox;
linkPreviews: typeof linkPreviews;
mediaGallery: typeof mediaGallery;
network: typeof network;
safetyNumber: typeof safetyNumber;
search: typeof search;

View file

@ -19,6 +19,7 @@ export enum PanelType {
}
export type ReactPanelRenderType =
| { type: PanelType.AllMedia }
| { type: PanelType.ChatColorEditor }
| {
type: PanelType.ContactDetails;
@ -38,9 +39,10 @@ export type ReactPanelRenderType =
| { type: PanelType.NotificationSettings }
| { type: PanelType.StickerManager };
export type BackbonePanelRenderType =
| { type: PanelType.AllMedia }
| { type: PanelType.MessageDetails; args: { messageId: string } };
export type BackbonePanelRenderType = {
type: PanelType.MessageDetails;
args: { messageId: string };
};
export type PanelRenderType = ReactPanelRenderType | BackbonePanelRenderType;
@ -52,6 +54,7 @@ export function isPanelHandledByReact(
}
return (
panel.type === PanelType.AllMedia ||
panel.type === PanelType.ChatColorEditor ||
panel.type === PanelType.ContactDetails ||
panel.type === PanelType.ConversationDetails ||

View file

@ -9222,9 +9222,9 @@
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "React-createRef",
"rule": "React-useRef",
"path": "ts/components/conversation/media-gallery/MediaGallery.tsx",
"line": " public readonly focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
"line": " const focusRef = useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2019-11-01T22:46:33.013Z",
"reasonDetail": "Used for setting focus only"

View file

@ -4,15 +4,9 @@
/* eslint-disable camelcase */
import type * as Backbone from 'backbone';
import * as React from 'react';
import { flatten } from 'lodash';
import { render } from 'mustache';
import type { AttachmentType } from '../types/Attachment';
import type { MIMEType } from '../types/MIME';
import type { ConversationModel } from '../models/conversations';
import type { MessageAttributesType } from '../model-types.d';
import type { MediaItemType } from '../types/MediaItem';
import { getMessageById } from '../messages/getMessageById';
import { getContactId } from '../messages/helpers';
import { strictAssert } from '../util/assert';
@ -27,13 +21,10 @@ import { ToastConversationMarkedUnread } from '../components/ToastConversationMa
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
import { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong';
import { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound';
import { isNotNil } from '../util/isNotNil';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
import { showToast } from '../util/showToast';
import { UUIDKind } from '../types/UUID';
import type { UUIDStringType } from '../types/UUID';
import { MediaGallery } from '../components/conversation/media-gallery/MediaGallery';
import type { ItemClickEvent } from '../components/conversation/media-gallery/types/ItemClickEvent';
import {
removeLinkPreview,
suspendLinkPreviews,
@ -45,13 +36,8 @@ import { clearConversationDraftAttachments } from '../util/clearConversationDraf
import type { BackbonePanelRenderType, PanelRenderType } from '../types/Panels';
import { PanelType, isPanelHandledByReact } from '../types/Panels';
const { Message } = window.Signal.Types;
type BackbonePanelType = { panelType: PanelType; view: Backbone.View };
const { getAbsoluteAttachmentPath, upgradeMessageSchema } =
window.Signal.Migrations;
const { getMessagesBySentAt } = window.Signal.Data;
type MessageActionsType = {
@ -59,23 +45,6 @@ type MessageActionsType = {
startConversation: (e164: string, uuid: UUIDStringType) => unknown;
};
type MediaType = {
path: string;
objectURL: string;
thumbnailObjectUrl?: string;
contentType: MIMEType;
index: number;
attachment: AttachmentType;
message: {
attachments: Array<AttachmentType>;
conversationId: string;
id: string;
received_at: number;
received_at_ms: number;
sent_at: number;
};
};
export class ConversationView extends window.Backbone.View<ConversationModel> {
// Sub-views
private contactModalView?: Backbone.View;
@ -101,7 +70,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
);
// These are triggered by background.ts for keyboard handling
this.listenTo(this.model, 'open-all-media', this.showAllMedia);
this.listenTo(this.model, 'escape-pressed', () => {
window.reduxActions.conversations.popPanelForConversation(this.model.id);
});
@ -156,9 +124,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const { searchInConversation } = window.reduxActions.search;
searchInConversation(this.model.id);
},
onShowAllMedia: () => {
this.showAllMedia();
},
onGoBack: () => {
window.reduxActions.conversations.popPanelForConversation(
this.model.id
@ -480,207 +445,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.model.updateVerified();
}
showAllMedia(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.AllMedia,
});
}
getAllMedia(): Backbone.View | undefined {
if (document.querySelectorAll('.module-media-gallery').length) {
return;
}
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const DEFAULT_MEDIA_FETCH_COUNT = 50;
const DEFAULT_DOCUMENTS_FETCH_COUNT = 150;
const conversationId = this.model.get('id');
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const getProps = async () => {
const rawMedia =
await window.Signal.Data.getMessagesWithVisualMediaAttachments(
conversationId,
{
limit: DEFAULT_MEDIA_FETCH_COUNT,
}
);
const rawDocuments =
await window.Signal.Data.getMessagesWithFileAttachments(
conversationId,
{
limit: DEFAULT_DOCUMENTS_FETCH_COUNT,
}
);
// First we upgrade these messages to ensure that they have thumbnails
for (let max = rawMedia.length, i = 0; i < max; i += 1) {
const message = rawMedia[i];
const { schemaVersion } = message;
// We want these message to be cached in memory for other operations like
// listening to 'expired' events when showing the lightbox, and so any other
// code working with this message has the latest updates.
const model = window.MessageController.register(message.id, message);
if (
schemaVersion &&
schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY
) {
// Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop
rawMedia[i] = await upgradeMessageSchema(message);
model.set(rawMedia[i]);
// eslint-disable-next-line no-await-in-loop
await window.Signal.Data.saveMessage(rawMedia[i], { ourUuid });
}
}
const media: Array<MediaType> = flatten(
rawMedia.map(message => {
return (message.attachments || []).map(
(
attachment: AttachmentType,
index: number
): MediaType | undefined => {
if (
!attachment.path ||
!attachment.thumbnail ||
attachment.pending ||
attachment.error
) {
return;
}
const { thumbnail } = attachment;
return {
path: attachment.path,
objectURL: getAbsoluteAttachmentPath(attachment.path),
thumbnailObjectUrl: thumbnail?.path
? getAbsoluteAttachmentPath(thumbnail.path)
: undefined,
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.lookupOrCreate({
uuid: message.sourceUuid,
e164: message.source,
reason: 'conversation_view.showAllMedia',
})?.id || message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
);
})
).filter(isNotNil);
// Unlike visual media, only one non-image attachment is supported
const documents: Array<MediaItemType> = [];
rawDocuments.forEach(message => {
const attachments = message.attachments || [];
const attachment = attachments[0];
if (!attachment) {
return;
}
documents.push({
contentType: attachment.contentType,
index: 0,
attachment,
// We do this cast because we know there attachments (see the checks above).
message: message as MessageAttributesType & {
attachments: Array<AttachmentType>;
},
});
});
const onItemClick = async ({
message,
attachment,
type,
}: ItemClickEvent) => {
switch (type) {
case 'documents': {
window.reduxActions.conversations.saveAttachment(
attachment,
message.sent_at
);
break;
}
case 'media': {
window.reduxActions.lightbox.showLightboxWithMedia(
attachment.path,
media
);
break;
}
default:
throw new TypeError(`Unknown attachment type: '${type}'`);
}
};
return {
documents,
media,
onItemClick,
};
};
function getMessageIds(): Array<string | undefined> | undefined {
const state = window.reduxStore.getState();
const byConversation = state?.conversations?.messagesByConversation;
const messages = byConversation && byConversation[conversationId];
if (!messages || !messages.messageIds) {
return undefined;
}
return messages.messageIds;
}
// Detect message changes in the current conversation
let previousMessageList: Array<string | undefined> | undefined;
previousMessageList = getMessageIds();
const unsubscribe = window.reduxStore.subscribe(() => {
const currentMessageList = getMessageIds();
if (currentMessageList !== previousMessageList) {
update();
previousMessageList = currentMessageList;
}
});
const view = new ReactWrapperView({
className: 'panel',
// We present an empty panel briefly, while we wait for props to load.
// eslint-disable-next-line react/jsx-no-useless-fragment
JSX: <></>,
onClose: () => {
unsubscribe();
},
});
const update = async () => {
const props = await getProps();
view.update(<MediaGallery i18n={window.i18n} {...props} />);
};
update();
return view;
}
showMessageDetail(messageId: string): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.MessageDetails,
@ -753,9 +517,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const { type } = panel as BackbonePanelRenderType;
let view: Backbone.View | undefined;
if (type === PanelType.AllMedia) {
view = this.getAllMedia();
} else if (panel.type === PanelType.MessageDetails) {
if (panel.type === PanelType.MessageDetails) {
view = this.getMessageDetail(panel.args);
}