diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 3406f71fe..1cd4efe75 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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; diff --git a/stylesheets/components/Lightbox.scss b/stylesheets/components/Lightbox.scss index 1c57e1abd..6b6587115 100644 --- a/stylesheets/components/Lightbox.scss +++ b/stylesheets/components/Lightbox.scss @@ -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); diff --git a/ts/background.ts b/ts/background.ts index 8c11d7b6d..88ab29c0c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -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 { 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 { shiftKey && (key === 'm' || key === 'M') ) { - conversation.trigger('open-all-media'); + window.reduxActions.conversations.pushPanelForConversation( + conversation.id, + { type: PanelType.AllMedia } + ); event.preventDefault(); event.stopPropagation(); return; diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 357495d04..2a123e4ab 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -47,7 +47,6 @@ const commonProps = { 'onOutgoingVideoCallInConversation' ), - onShowAllMedia: action('onShowAllMedia'), onGoBack: action('onGoBack'), onArchive: action('onArchive'), diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 5f596ff15..9e09d0c1c 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -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 { onArchive, onMarkUnread, onMoveToInbox, - onShowAllMedia, pushPanelForConversation, setDisappearingMessages, setMuteExpiration, @@ -494,7 +492,13 @@ export class ConversationHeader extends React.Component { {i18n('showMembers')} ) : null} - {i18n('viewRecentMedia')} + + pushPanelForConversation(id, { type: PanelType.AllMedia }) + } + > + {i18n('viewRecentMedia')} + {!markedUnread ? ( {i18n('markUnread')} diff --git a/ts/components/conversation/media-gallery/AttachmentSection.stories.tsx b/ts/components/conversation/media-gallery/AttachmentSection.stories.tsx index ee2d1d6d7..5d30e734c 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.stories.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.stories.tsx @@ -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 => ({ overrideProps.type || 'media' ), mediaItems: overrideProps.mediaItems || [], + onItemClick: action('onItemClick'), }); export function Documents() { diff --git a/ts/components/conversation/media-gallery/AttachmentSection.tsx b/ts/components/conversation/media-gallery/AttachmentSection.tsx index 2374c04ed..3a13a59d4 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.tsx @@ -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; - onItemClick?: (event: ItemClickEvent) => void; + onItemClick: (event: ItemClickEvent) => unknown; + type: 'media' | 'documents'; }; -export class AttachmentSection extends React.Component { - public override render(): JSX.Element { - const { header } = this.props; +export function AttachmentSection({ + i18n, + header, + type, + mediaItems, + onItemClick, +}: Props): JSX.Element { + return ( +
+

{header}

+
+ {mediaItems.map((mediaItem, position, array) => { + const shouldShowSeparator = position < array.length - 1; + const { message, index, attachment } = mediaItem; - return ( -
-

{header}

-
- {this.renderItems()} -
+ const onClick = () => { + onItemClick({ type, message, attachment }); + }; + + switch (type) { + case 'media': + return ( + + ); + case 'documents': + return ( + + ); + default: + return missingCaseError(type); + } + })}
- ); - } - - 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 ( - - ); - case 'documents': - return ( - - ); - 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 }); - }; +
+ ); } diff --git a/ts/components/conversation/media-gallery/MediaGallery.stories.tsx b/ts/components/conversation/media-gallery/MediaGallery.stories.tsx index f3ce1f782..5ef5e3c89 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.stories.tsx @@ -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 => ({ - 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 { diff --git a/ts/components/conversation/media-gallery/MediaGallery.tsx b/ts/components/conversation/media-gallery/MediaGallery.tsx index 699aa919e..0a9de9229 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.tsx @@ -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; i18n: LocalizerType; + loadMediaItems: (id: string) => unknown; media: Array; - - onItemClick?: (event: ItemClickEvent) => void; -}; - -type State = { - selectedTab: 'media' | 'documents'; + saveAttachment: SaveAttachmentActionCreatorType; + showLightboxWithMedia: ( + selectedAttachmentPath: string | undefined, + media: Array + ) => 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 ; + } + + 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 ( + { + 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
{sections}
; +} + +export function MediaGallery({ + conversationId, + documents, + i18n, + loadMediaItems, + media, + saveAttachment, + showLightboxWithMedia, +}: Props): JSX.Element { + const focusRef = useRef(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 -
- {label} +
+ + {({ selectedTab }) => ( +
+ {selectedTab === TabViews.Media && ( + + )} + {selectedTab === TabViews.Documents && ( + + )} +
+ )} +
); } - -export class MediaGallery extends React.Component { - public readonly focusRef: React.RefObject = 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 ( -
-
- - -
-
- {this.renderSections()} -
-
- ); - } - - 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 ; - } - - 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 ( - - ); - }); - - return
{sections}
; - } -} diff --git a/ts/services/expiringMessagesDeletion.ts b/ts/services/expiringMessagesDeletion.ts index 90155146e..36430b38f 100644 --- a/ts/services/expiringMessagesDeletion.ts +++ b/ts/services/expiringMessagesDeletion.ts @@ -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 diff --git a/ts/services/tapToViewMessagesDeletionService.ts b/ts/services/tapToViewMessagesDeletionService.ts index f2dd4b752..13d9a2222 100644 --- a/ts/services/tapToViewMessagesDeletionService.ts +++ b/ts/services/tapToViewMessagesDeletionService.ts @@ -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(); }) diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 2862d8571..4ca6b6e0d 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -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, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index d465114ca..bb8e26cdc 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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; diff --git a/ts/state/ducks/lightbox.ts b/ts/state/ducks/lightbox.ts index b95dcd317..a1e4d4ddf 100644 --- a/ts/state/ducks/lightbox.ts +++ b/ts/state/ducks/lightbox.ts @@ -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 { - 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 @@ -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; } diff --git a/ts/state/ducks/mediaGallery.ts b/ts/state/ducks/mediaGallery.ts new file mode 100644 index 000000000..deb45b761 --- /dev/null +++ b/ts/state/ducks/mediaGallery.ts @@ -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; + conversationId: string; + id: string; + received_at: number; + received_at_ms: number; + sent_at: number; + }; +}; + +export type MediaGalleryStateType = { + documents: Array; + media: Array; +}; + +const LOAD_MEDIA_ITEMS = 'mediaGallery/LOAD_MEDIA_ITEMS'; + +type LoadMediaItemslActionType = { + type: typeof LOAD_MEDIA_ITEMS; + payload: { + documents: Array; + media: Array; + }; +}; + +type MediaGalleryActionType = + | ConversationUnloadedActionType + | LoadMediaItemslActionType + | MessageDeletedActionType + | MessageExpiredActionType; + +function loadMediaItems( + conversationId: string +): ThunkAction { + return async dispatch => { + const { getAbsoluteAttachmentPath, upgradeMessageSchema } = + window.Signal.Migrations; + + // We fetch more documents than media as they don’t 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 = 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 = 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 = getEmptyState(), + action: Readonly +): 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; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index db9f4db21..61cf1e4a3 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -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(), diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 957af6f37..cc9a125a7 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -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, diff --git a/ts/state/selectors/mediaGallery.ts b/ts/state/selectors/mediaGallery.ts new file mode 100644 index 000000000..440de1eb0 --- /dev/null +++ b/ts/state/selectors/mediaGallery.ts @@ -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; diff --git a/ts/state/smart/AllMedia.tsx b/ts/state/smart/AllMedia.tsx new file mode 100644 index 000000000..60aa86d4f --- /dev/null +++ b/ts/state/smart/AllMedia.tsx @@ -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 ( + + ); +} diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 5483695aa..94af999ba 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -35,7 +35,6 @@ export type OwnProps = { onMarkUnread: () => void; onMoveToInbox: () => void; onSearchInConversation: () => void; - onShowAllMedia: () => void; }; const getOutgoingCallButtonStyle = ( diff --git a/ts/state/smart/ConversationView.tsx b/ts/state/smart/ConversationView.tsx index 88466c103..d25b9ad79 100644 --- a/ts/state/smart/ConversationView.tsx +++ b/ts/state/smart/ConversationView.tsx @@ -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 ( +
+ +
+ ); + } + if (topPanel.type === PanelType.ChatColorEditor) { return (
diff --git a/ts/state/types.ts b/ts/state/types.ts index fb7e5ee4a..49ff1f2ef 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -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; diff --git a/ts/types/Panels.ts b/ts/types/Panels.ts index 11f42db51..3ceadc6ef 100644 --- a/ts/types/Panels.ts +++ b/ts/types/Panels.ts @@ -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 || diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 69f884766..490cd7c29 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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 = React.createRef();", + "line": " const focusRef = useRef(null);", "reasonCategory": "usageTrusted", "updated": "2019-11-01T22:46:33.013Z", "reasonDetail": "Used for setting focus only" diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 30717a5e1..9edeabeec 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -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; - conversationId: string; - id: string; - received_at: number; - received_at_ms: number; - sent_at: number; - }; -}; - export class ConversationView extends window.Backbone.View { // Sub-views private contactModalView?: Backbone.View; @@ -101,7 +70,6 @@ export class ConversationView extends window.Backbone.View { ); // 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 { 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 { 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 don’t 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 = 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 = []; - 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; - }, - }); - }); - - 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 | 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 | 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(); - }; - - 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 { 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); }