diff --git a/ts/background.ts b/ts/background.ts index 1e2e52464c..1ef22ed5ad 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -158,6 +158,7 @@ import type AccountManager from './textsecure/AccountManager'; import { onStoryRecipientUpdate } from './util/onStoryRecipientUpdate'; import { StoryViewModeType, StoryViewTargetType } from './types/Stories'; import { downloadOnboardingStory } from './util/downloadOnboardingStory'; +import { saveAttachmentFromMessage } from './util/saveAttachment'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -1114,6 +1115,7 @@ export async function startApp(): Promise { store.dispatch ), items: bindActionCreators(actionCreators.items, store.dispatch), + lightbox: bindActionCreators(actionCreators.lightbox, store.dispatch), linkPreviews: bindActionCreators( actionCreators.linkPreviews, store.dispatch @@ -1656,10 +1658,10 @@ export async function startApp(): Promise { const { selectedMessage } = state.conversations; if (selectedMessage) { - conversation.trigger('save-attachment', selectedMessage); - event.preventDefault(); event.stopPropagation(); + + saveAttachmentFromMessage(selectedMessage); return; } } diff --git a/ts/components/App.tsx b/ts/components/App.tsx index cf2d893c71..4ffcdace88 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -31,6 +31,7 @@ type PropsType = { renderStories: (closeView: () => unknown) => JSX.Element; hasSelectedStoryData: boolean; renderStoryViewer: (closeView: () => unknown) => JSX.Element; + renderLightbox: () => JSX.Element | null; requestVerification: ( type: 'sms' | 'voice', number: string, @@ -77,6 +78,7 @@ export function App({ renderCustomizingPreferredReactionsModal, renderGlobalModalContainer, renderLeftPane, + renderLightbox, renderStories, renderStoryViewer, requestVerification, @@ -179,6 +181,7 @@ export function App({ {renderGlobalModalContainer()} {renderCallManager()} + {renderLightbox()} {isShowingStoriesView && renderStories(toggleStoriesView)} {hasSelectedStoryData && renderStoryViewer(() => viewStory({ closeViewer: true }))} diff --git a/ts/components/AvatarLightbox.tsx b/ts/components/AvatarLightbox.tsx index ea2a7e8ab2..70926092f5 100644 --- a/ts/components/AvatarLightbox.tsx +++ b/ts/components/AvatarLightbox.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; +import { noop } from 'lodash'; import type { AvatarColorType } from '../types/Colors'; import { AvatarPreview } from './AvatarPreview'; @@ -26,7 +27,13 @@ export function AvatarLightbox({ onClose, }: PropsType): JSX.Element { return ( - + = {}): PropsType => ({ - close: action('close'), + closeLightbox: action('closeLightbox'), i18n, isViewOnce: Boolean(overrideProps.isViewOnce), media: overrideProps.media || [], - onSave: action('onSave'), selectedIndex: number('selectedIndex', overrideProps.selectedIndex || 0), + toggleForwardMessageModal: action('toggleForwardMessageModal'), }); export function Multimedia(): JSX.Element { @@ -305,10 +305,6 @@ CustomChildren.story = { name: 'Custom children', }; -export function Forwarding(): JSX.Element { - return ; -} - export function ConversationHeader(): JSX.Element { return ( void; + closeLightbox: () => unknown; getConversation?: (id: string) => ConversationType; i18n: LocalizerType; isViewOnce?: boolean; media: Array; - onForward?: (messageId: string) => void; - onSave?: (options: { - attachment: AttachmentType; - message: MediaItemMessageType; - index: number; - }) => void; selectedIndex?: number; + toggleForwardMessageModal: (messageId: string) => unknown; }; const ZOOM_SCALE = 3; @@ -53,14 +48,13 @@ const INITIAL_IMAGE_TRANSFORM = { export function Lightbox({ children, - close, + closeLightbox, getConversation, media, i18n, isViewOnce = false, - onForward, - onSave, selectedIndex: initialSelectedIndex = 0, + toggleForwardMessageModal, }: PropsType): JSX.Element | null { const [root, setRoot] = React.useState(); const [selectedIndex, setSelectedIndex] = @@ -138,31 +132,39 @@ export function Lightbox({ const handleSave = ( event: React.MouseEvent ) => { + if (isViewOnce) { + return; + } + event.stopPropagation(); event.preventDefault(); const mediaItem = media[selectedIndex]; const { attachment, message, index } = mediaItem; - onSave?.({ attachment, message, index }); + saveAttachment(attachment, message.sent_at, index + 1); }; const handleForward = ( event: React.MouseEvent ) => { + if (isViewOnce) { + return; + } + event.preventDefault(); event.stopPropagation(); - close(); + closeLightbox(); const mediaItem = media[selectedIndex]; - onForward?.(mediaItem.message.id); + toggleForwardMessageModal(mediaItem.message.id); }; const onKeyDown = useCallback( (event: KeyboardEvent) => { switch (event.key) { case 'Escape': { - close(); + closeLightbox(); event.preventDefault(); event.stopPropagation(); @@ -181,14 +183,14 @@ export function Lightbox({ default: } }, - [close, onNext, onPrevious] + [closeLightbox, onNext, onPrevious] ); const onClose = (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); - close(); + closeLightbox(); }; const playVideo = useCallback(() => { @@ -521,7 +523,7 @@ export function Lightbox({ event.stopPropagation(); event.preventDefault(); - close(); + closeLightbox(); }} onKeyUp={(event: React.KeyboardEvent) => { if ( @@ -531,7 +533,7 @@ export function Lightbox({ return; } - close(); + closeLightbox(); }} ref={containerRef} role="presentation" @@ -553,7 +555,7 @@ export function Lightbox({
)}
- {onForward ? ( + {!isViewOnce ? (
diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 2e51a935f5..23b1153e40 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -49,9 +49,7 @@ const MESSAGE_DEFAULT_PROPS = { checkForAccount: shouldNeverBeCalled, clearSelectedMessage: shouldNeverBeCalled, containerWidthBreakpoint: WidthBreakpoint.Medium, - displayTapToViewMessage: shouldNeverBeCalled, doubleCheckMissingQuoteReference: shouldNeverBeCalled, - downloadAttachment: shouldNeverBeCalled, isBlocked: false, isMessageRequestAccepted: true, kickOffAttachmentDownload: shouldNeverBeCalled, @@ -69,8 +67,9 @@ const MESSAGE_DEFAULT_PROPS = { showContactModal: shouldNeverBeCalled, showExpiredIncomingTapToViewToast: shouldNeverBeCalled, showExpiredOutgoingTapToViewToast: shouldNeverBeCalled, + showLightbox: shouldNeverBeCalled, + showLightboxForViewOnceMedia: shouldNeverBeCalled, showMessageDetail: shouldNeverBeCalled, - showVisualAttachment: shouldNeverBeCalled, startConversation: shouldNeverBeCalled, theme: ThemeType.dark, viewStory: shouldNeverBeCalled, diff --git a/ts/components/ToastUnableToLoadAttachment.tsx b/ts/components/ToastUnableToLoadAttachment.tsx deleted file mode 100644 index 0ff5fc1633..0000000000 --- a/ts/components/ToastUnableToLoadAttachment.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import type { LocalizerType } from '../types/Util'; -import { Toast } from './Toast'; - -type PropsType = { - i18n: LocalizerType; - onClose: () => unknown; -}; - -export function ToastUnableToLoadAttachment({ - i18n, - onClose, -}: PropsType): JSX.Element { - return {i18n('unableToLoadAttachment')}; -} diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 08b7eae6e3..321b614e5e 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -87,6 +87,7 @@ import { PaymentEventKind } from '../../types/Payment'; import type { AnyPaymentEvent } from '../../types/Payment'; import { Emojify } from './Emojify'; import { getPaymentEventDescription } from '../../messages/helpers'; +import { saveAttachment } from '../../util/saveAttachment'; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; @@ -318,16 +319,11 @@ export type PropsActions = { messageId: string; }) => void; markViewed(messageId: string): void; - showVisualAttachment: (options: { + showLightbox: (options: { attachment: AttachmentType; messageId: string; }) => void; - downloadAttachment: (options: { - attachment: AttachmentType; - timestamp: number; - isDangerous: boolean; - }) => void; - displayTapToViewMessage: (messageId: string) => unknown; + showLightboxForViewOnceMedia: (messageId: string) => unknown; openLink: (url: string) => void; scrollToQuotedMessage: (options: { @@ -847,7 +843,7 @@ export class Message extends React.PureComponent { renderAudioAttachment, renderingContext, showMessageDetail, - showVisualAttachment, + showLightbox, shouldCollapseAbove, shouldCollapseBelow, status, @@ -898,7 +894,7 @@ export class Message extends React.PureComponent { reducedMotion={reducedMotion} onError={this.handleImageError} showVisualAttachment={() => { - showVisualAttachment({ + showLightbox({ attachment: firstAttachment, messageId: id, }); @@ -945,7 +941,7 @@ export class Message extends React.PureComponent { if (!isDownloaded(attachment)) { kickOffAttachmentDownload({ attachment, messageId: id }); } else { - showVisualAttachment({ attachment, messageId: id }); + showLightbox({ attachment, messageId: id }); } }} /> @@ -2240,7 +2236,7 @@ export class Message extends React.PureComponent { const { attachments, contact, - displayTapToViewMessage, + showLightboxForViewOnceMedia, direction, giftBadge, id, @@ -2250,7 +2246,7 @@ export class Message extends React.PureComponent { startConversation, openGiftBadge, showContactDetail, - showVisualAttachment, + showLightbox, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, } = this.props; @@ -2291,7 +2287,7 @@ export class Message extends React.PureComponent { event.preventDefault(); event.stopPropagation(); - displayTapToViewMessage(id); + showLightboxForViewOnceMedia(id); } return; @@ -2328,7 +2324,7 @@ export class Message extends React.PureComponent { const attachment = attachments[0]; - showVisualAttachment({ attachment, messageId: id }); + showLightbox({ attachment, messageId: id }); return; } @@ -2384,13 +2380,8 @@ export class Message extends React.PureComponent { }; public openGenericAttachment = (event?: React.MouseEvent): void => { - const { - id, - attachments, - downloadAttachment, - timestamp, - kickOffAttachmentDownload, - } = this.props; + const { id, attachments, timestamp, kickOffAttachmentDownload } = + this.props; if (event) { event.preventDefault(); @@ -2410,14 +2401,7 @@ export class Message extends React.PureComponent { return; } - const { fileName } = attachment; - const isDangerous = isFileDangerous(fileName || ''); - - downloadAttachment({ - isDangerous, - attachment, - timestamp, - }); + saveAttachment(attachment, timestamp); }; public handleClick = (event: React.MouseEvent): void => { diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index f700f4309c..3973abf9a3 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -73,7 +73,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ checkForAccount: action('checkForAccount'), clearSelectedMessage: action('clearSelectedMessage'), - displayTapToViewMessage: action('displayTapToViewMessage'), + showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), @@ -90,7 +90,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ showExpiredOutgoingTapToViewToast: action( 'showExpiredOutgoingTapToViewToast' ), - showVisualAttachment: action('showVisualAttachment'), + showLightbox: action('showLightbox'), startConversation: action('startConversation'), viewStory: action('viewStory'), }); diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 784f943312..675bb64260 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -79,7 +79,6 @@ export type PropsData = { export type PropsBackboneActions = Pick< MessagePropsType, - | 'displayTapToViewMessage' | 'kickOffAttachmentDownload' | 'markAttachmentAsCorrupted' | 'openConversation' @@ -89,16 +88,17 @@ export type PropsBackboneActions = Pick< | 'showContactDetail' | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' - | 'showVisualAttachment' | 'startConversation' >; export type PropsReduxActions = Pick< MessagePropsType, + | 'checkForAccount' | 'clearSelectedMessage' | 'doubleCheckMissingQuoteReference' - | 'checkForAccount' | 'showContactModal' + | 'showLightbox' + | 'showLightboxForViewOnceMedia' | 'viewStory' > & { toggleSafetyNumberModal: (contactId: string) => void; @@ -280,7 +280,7 @@ export class MessageDetail extends React.Component { checkForAccount, clearSelectedMessage, contactNameColor, - displayTapToViewMessage, + showLightboxForViewOnceMedia, doubleCheckMissingQuoteReference, expirationTimestamp, getPreferredBadge, @@ -297,7 +297,7 @@ export class MessageDetail extends React.Component { showContactModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, - showVisualAttachment, + showLightbox, startConversation, theme, viewStory, @@ -325,10 +325,7 @@ export class MessageDetail extends React.Component { menu={undefined} disableScroll displayLimit={Number.MAX_SAFE_INTEGER} - displayTapToViewMessage={displayTapToViewMessage} - downloadAttachment={() => - log.warn('MessageDetail: downloadAttachment called!') - } + showLightboxForViewOnceMedia={showLightboxForViewOnceMedia} doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference} getPreferredBadge={getPreferredBadge} i18n={i18n} @@ -358,7 +355,7 @@ export class MessageDetail extends React.Component { showMessageDetail={() => { log.warn('MessageDetail: showMessageDetail called!'); }} - showVisualAttachment={showVisualAttachment} + showLightbox={showLightbox} startConversation={startConversation} theme={theme} viewStory={viewStory} diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index f145b756ff..558660ec71 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -97,8 +97,7 @@ const defaultMessageProps: TimelineMessagesProps = { deleteMessage: action('default--deleteMessage'), deleteMessageForEveryone: action('default--deleteMessageForEveryone'), direction: 'incoming', - displayTapToViewMessage: action('default--displayTapToViewMessage'), - downloadAttachment: action('default--downloadAttachment'), + showLightboxForViewOnceMedia: action('default--showLightboxForViewOnceMedia'), doubleCheckMissingQuoteReference: action( 'default--doubleCheckMissingQuoteReference' ), @@ -140,7 +139,7 @@ const defaultMessageProps: TimelineMessagesProps = { ), toggleForwardMessageModal: action('default--toggleForwardMessageModal'), showMessageDetail: action('default--showMessageDetail'), - showVisualAttachment: action('default--showVisualAttachment'), + showLightbox: action('default--showLightbox'), startConversation: action('default--startConversation'), status: 'sent', text: 'This is really interesting.', diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 8690eb7aac..5d3426024a 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -289,9 +289,8 @@ const actions = () => ({ markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markViewed: action('markViewed'), messageExpanded: action('messageExpanded'), - showVisualAttachment: action('showVisualAttachment'), - downloadAttachment: action('downloadAttachment'), - displayTapToViewMessage: action('displayTapToViewMessage'), + showLightbox: action('showLightbox'), + showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), openLink: action('openLink'), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 184df0404f..275255c6b4 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -253,9 +253,8 @@ const getActions = createSelector( 'kickOffAttachmentDownload', 'markAttachmentAsCorrupted', 'messageExpanded', - 'showVisualAttachment', - 'downloadAttachment', - 'displayTapToViewMessage', + 'showLightbox', + 'showLightboxForViewOnceMedia', 'openLink', 'scrollToQuotedMessage', 'showExpiredIncomingTapToViewToast', diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 84a12692e5..67d295082b 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -82,10 +82,9 @@ const getDefaultProps = () => ({ openGiftBadge: action('openGiftBadge'), showContactDetail: action('showContactDetail'), showContactModal: action('showContactModal'), + showLightbox: action('showLightbox'), toggleForwardMessageModal: action('toggleForwardMessageModal'), - showVisualAttachment: action('showVisualAttachment'), - downloadAttachment: action('downloadAttachment'), - displayTapToViewMessage: action('displayTapToViewMessage'), + showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), showExpiredIncomingTapToViewToast: action( 'showExpiredIncomingTapToViewToast' diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index ac70ab48c7..24edf91b21 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -249,9 +249,8 @@ const createProps = (overrideProps: Partial = {}): Props => ({ // disableMenu: overrideProps.disableMenu, disableScroll: overrideProps.disableScroll, direction: overrideProps.direction || 'incoming', - displayTapToViewMessage: action('displayTapToViewMessage'), + showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), - downloadAttachment: action('downloadAttachment'), expirationLength: number('expirationLength', overrideProps.expirationLength || 0) || undefined, @@ -318,7 +317,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ ), toggleForwardMessageModal: action('toggleForwardMessageModal'), showMessageDetail: action('showMessageDetail'), - showVisualAttachment: action('showVisualAttachment'), + showLightbox: action('showLightbox'), startConversation: action('startConversation'), status: overrideProps.status || 'sent', text: overrideProps.text || text('text', ''), diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index 6522f32d34..533a1b45fc 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -12,7 +12,6 @@ import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preve import { isDownloaded } from '../../types/Attachment'; import type { LocalizerType } from '../../types/I18N'; import { handleOutsideClick } from '../../util/handleOutsideClick'; -import { isFileDangerous } from '../../util/isFileDangerous'; import { offsetDistanceModifier } from '../../util/popperUtil'; import { StopPropagation } from '../StopPropagation'; import { WidthBreakpoint } from '../_util'; @@ -28,6 +27,7 @@ import { doesMessageBodyOverflow } from './MessageBodyReadMore'; import type { Props as ReactionPickerProps } from './ReactionPicker'; import { ConfirmationDialog } from '../ConfirmationDialog'; import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts'; +import { saveAttachment } from '../../util/saveAttachment'; export type PropsData = { canDownload: boolean; @@ -172,7 +172,7 @@ export function TimelineMessage(props: Props): JSX.Element { }); const openGenericAttachment = (event?: React.MouseEvent): void => { - const { downloadAttachment, kickOffAttachmentDownload } = props; + const { kickOffAttachmentDownload } = props; if (event) { event.preventDefault(); @@ -192,14 +192,7 @@ export function TimelineMessage(props: Props): JSX.Element { return; } - const { fileName } = attachment; - const isDangerous = isFileDangerous(fileName || ''); - - downloadAttachment({ - isDangerous, - attachment, - timestamp, - }); + saveAttachment(attachment, timestamp); }; const handleContextMenu = (event: React.MouseEvent): void => { diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 7b2265f15b..721a80f8cf 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -88,7 +88,7 @@ const createProps = ( ), showConversation: action('showConversation'), showPendingInvites: action('showPendingInvites'), - showLightboxForMedia: action('showLightboxForMedia'), + showLightboxWithMedia: action('showLightboxWithMedia'), updateGroupAttributes: async () => { action('updateGroupAttributes')(); }, diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index d251df451b..1accd88c98 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -17,7 +17,6 @@ import { assertDev } from '../../../util/assert'; import { getMutedUntilText } from '../../../util/getMutedUntilText'; import type { LocalizerType, ThemeType } from '../../../types/Util'; -import type { MediaItemType } from '../../../types/MediaItem'; import type { BadgeType } from '../../../badges/types'; import { missingCaseError } from '../../../util/missingCaseError'; import { DurationInSeconds } from '../../../util/durations'; @@ -30,6 +29,7 @@ import { AddGroupMembersModal } from './AddGroupMembersModal'; import { ConversationDetailsActions } from './ConversationDetailsActions'; import { ConversationDetailsHeader } from './ConversationDetailsHeader'; import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon'; +import type { Props as ConversationDetailsMediaListPropsType } from './ConversationDetailsMediaList'; import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; import type { GroupV2Membership } from './ConversationDetailsMembershipList'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; @@ -84,10 +84,6 @@ export type StateProps = { showGroupLinkManagement: () => void; showGroupV2Permissions: () => void; showPendingInvites: () => void; - showLightboxForMedia: ( - selectedMediaItem: MediaItemType, - media: Array - ) => void; showConversationNotificationsSettings: () => void; updateGroupAttributes: ( _: Readonly<{ @@ -123,7 +119,7 @@ type ActionProps = { showConversation: ShowConversationType; toggleAddUserToAnotherGroupModal: (contactId?: string) => void; toggleSafetyNumberModal: (conversationId: string) => unknown; -}; +} & Pick; export type Props = StateProps & ActionProps; @@ -167,7 +163,7 @@ export function ConversationDetails({ showConversation, showGroupLinkManagement, showGroupV2Permissions, - showLightboxForMedia, + showLightboxWithMedia, showPendingInvites, theme, toggleSafetyNumberModal, @@ -536,7 +532,7 @@ export function ConversationDetails({ i18n={i18n} loadRecentMediaItems={loadRecentMediaItems} showAllMedia={showAllMedia} - showLightboxForMedia={showLightboxForMedia} + showLightboxWithMedia={showLightboxWithMedia} /> {!isGroup && !conversation.isMe && ( diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx index c72a8343be..41487341be 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx @@ -30,7 +30,7 @@ const createProps = (mediaItems?: Array): Props => ({ i18n, loadRecentMediaItems: action('loadRecentMediaItems'), showAllMedia: action('showAllMedia'), - showLightboxForMedia: action('showLightboxForMedia'), + showLightboxWithMedia: action('showLightboxWithMedia'), }); export function Basic(): JSX.Element { diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx index d316e30311..6ac35d81d7 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx @@ -17,8 +17,8 @@ export type Props = { i18n: LocalizerType; loadRecentMediaItems: (id: string, limit: number) => void; showAllMedia: () => void; - showLightboxForMedia: ( - selectedMediaItem: MediaItemType, + showLightboxWithMedia: ( + selectedAttachmentPath: string | undefined, media: Array ) => void; }; @@ -32,7 +32,7 @@ export function ConversationDetailsMediaList({ i18n, loadRecentMediaItems, showAllMedia, - showLightboxForMedia, + showLightboxWithMedia, }: Props): JSX.Element | null { const mediaItems = conversation.recentMediaItems || []; @@ -65,7 +65,9 @@ export function ConversationDetailsMediaList({ key={`${mediaItem.message.id}-${mediaItem.index}`} mediaItem={mediaItem} i18n={i18n} - onClick={() => showLightboxForMedia(mediaItem, mediaItems)} + onClick={() => + showLightboxWithMedia(mediaItem.attachment.path, mediaItems) + } /> ))}
diff --git a/ts/services/expiringMessagesDeletion.ts b/ts/services/expiringMessagesDeletion.ts index 0859a811dd..90155146ea 100644 --- a/ts/services/expiringMessagesDeletion.ts +++ b/ts/services/expiringMessagesDeletion.ts @@ -56,6 +56,9 @@ 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 + ); 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 fdfd05aacf..f2dd4b752e 100644 --- a/ts/services/tapToViewMessagesDeletionService.ts +++ b/ts/services/tapToViewMessagesDeletionService.ts @@ -24,6 +24,9 @@ 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 + ); await message.eraseContents(); }) diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 21edecd63c..2862d8571c 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -14,6 +14,7 @@ import { actions as emojis } from './ducks/emojis'; import { actions as expiration } from './ducks/expiration'; 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 network } from './ducks/network'; import { actions as safetyNumber } from './ducks/safetyNumber'; @@ -41,6 +42,7 @@ export const actionCreators: ReduxActions = { expiration, globalModals, items, + lightbox, linkPreviews, network, safetyNumber, @@ -68,6 +70,7 @@ export const mapDispatchToProps = { ...expiration, ...globalModals, ...items, + ...lightbox, ...linkPreviews, ...network, ...safetyNumber, diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 9f9b9c6f9f..72988545dc 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -177,7 +177,7 @@ type HideSendAnywayDialogActiontype = { type: typeof HIDE_SEND_ANYWAY_DIALOG; }; -type ShowStickerPackPreviewActionType = { +export type ShowStickerPackPreviewActionType = { type: typeof SHOW_STICKER_PACK_PREVIEW; payload: string; }; @@ -454,7 +454,7 @@ function closeStickerPackPreview(): ThunkAction< }; } -function showStickerPackPreview( +export function showStickerPackPreview( packId: string, packKey: string ): ShowStickerPackPreviewActionType { diff --git a/ts/state/ducks/lightbox.ts b/ts/state/ducks/lightbox.ts new file mode 100644 index 0000000000..5ce415afc4 --- /dev/null +++ b/ts/state/ducks/lightbox.ts @@ -0,0 +1,339 @@ +// 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 { MediaItemType } from '../../types/MediaItem'; +import type { StateType as RootStateType } from '../reducer'; +import type { ShowStickerPackPreviewActionType } from './globalModals'; +import type { ShowToastActionType } from './toast'; + +import * as log from '../../logging/log'; +import { getMessageById } from '../../messages/getMessageById'; +import { isGIF } from '../../types/Attachment'; +import { + isImageTypeSupported, + isVideoTypeSupported, +} from '../../util/GoogleChrome'; +import { isTapToView } from '../selectors/message'; +import { SHOW_TOAST, ToastType } from './toast'; +import { saveAttachmentFromMessage } from '../../util/saveAttachment'; +import { showStickerPackPreview } from './globalModals'; +import { useBoundActions } from '../../hooks/useBoundActions'; + +export type LightboxStateType = + | { + isShowingLightbox: false; + } + | { + isShowingLightbox: true; + isViewOnce: boolean; + media: Array; + selectedAttachmentPath: string | undefined; + }; + +const CLOSE_LIGHTBOX = 'lightbox/CLOSE'; +const SHOW_LIGHTBOX = 'lightbox/SHOW'; + +type CloseLightboxActionType = { + type: typeof CLOSE_LIGHTBOX; +}; + +type ShowLightboxActionType = { + type: typeof SHOW_LIGHTBOX; + payload: { + isViewOnce: boolean; + media: Array; + selectedAttachmentPath: string | undefined; + }; +}; + +type LightboxActionType = CloseLightboxActionType | ShowLightboxActionType; + +function closeLightbox(): ThunkAction< + void, + RootStateType, + unknown, + CloseLightboxActionType +> { + return (dispatch, getState) => { + const { lightbox } = getState(); + + if (!lightbox.isShowingLightbox) { + return; + } + + const { isViewOnce, media } = lightbox; + + if (isViewOnce) { + media.forEach(item => { + if (!item.attachment.path) { + return; + } + window.Signal.Migrations.deleteTempFile(item.attachment.path); + }); + } + + dispatch({ + type: CLOSE_LIGHTBOX, + }); + }; +} + +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 +): ShowLightboxActionType { + return { + type: SHOW_LIGHTBOX, + payload: { + isViewOnce: false, + media, + selectedAttachmentPath, + }, + }; +} + +function showLightboxForViewOnceMedia( + messageId: string +): ThunkAction { + return async dispatch => { + log.info('showLightboxForViewOnceMedia: attempting to display message'); + + const message = await getMessageById(messageId); + if (!message) { + throw new Error( + `showLightboxForViewOnceMedia: Message ${messageId} missing!` + ); + } + + if (!isTapToView(message.attributes)) { + throw new Error( + `showLightboxForViewOnceMedia: Message ${message.idForLogging()} is not a tap to view message` + ); + } + + if (message.isErased()) { + throw new Error( + `showLightboxForViewOnceMedia: Message ${message.idForLogging()} is already erased` + ); + } + + const firstAttachment = (message.get('attachments') || [])[0]; + if (!firstAttachment || !firstAttachment.path) { + throw new Error( + `showLightboxForViewOnceMedia: Message ${message.idForLogging()} had no first attachment with path` + ); + } + + const { + copyIntoTempDirectory, + getAbsoluteAttachmentPath, + getAbsoluteTempPath, + } = window.Signal.Migrations; + + const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path); + const { path: tempPath } = await copyIntoTempDirectory(absolutePath); + const tempAttachment = { + ...firstAttachment, + path: tempPath, + }; + + await message.markViewOnceMessageViewed(); + + const { path, contentType } = tempAttachment; + + const media = [ + { + attachment: tempAttachment, + objectURL: getAbsoluteTempPath(path), + contentType, + index: 0, + // TODO maybe we need to listen for message change? + message: { + attachments: message.get('attachments') || [], + id: message.get('id'), + conversationId: message.get('conversationId'), + received_at: message.get('received_at'), + received_at_ms: Number(message.get('received_at_ms')), + sent_at: message.get('sent_at'), + }, + }, + ]; + + dispatch({ + type: SHOW_LIGHTBOX, + payload: { + isViewOnce: true, + media, + selectedAttachmentPath: undefined, + }, + }); + }; +} + +function showLightbox(opts: { + attachment: AttachmentType; + messageId: string; +}): ThunkAction< + void, + RootStateType, + unknown, + | ShowLightboxActionType + | ShowStickerPackPreviewActionType + | ShowToastActionType +> { + return async dispatch => { + const { attachment, messageId } = opts; + + const message = await getMessageById(messageId); + if (!message) { + throw new Error(`showLightbox: Message ${messageId} missing!`); + } + const sticker = message.get('sticker'); + if (sticker) { + const { packId, packKey } = sticker; + dispatch(showStickerPackPreview(packId, packKey)); + return; + } + + const { contentType } = attachment; + + if ( + !isImageTypeSupported(contentType) && + !isVideoTypeSupported(contentType) + ) { + await saveAttachmentFromMessage(messageId, attachment); + return; + } + + const attachments: Array = message.get('attachments') || []; + + const loop = isGIF(attachments); + + const { getAbsoluteAttachmentPath } = window.Signal.Migrations; + + const media = attachments + .filter(item => item.thumbnail && !item.pending && !item.error) + .map((item, index) => ({ + objectURL: getAbsoluteAttachmentPath(item.path ?? ''), + path: item.path, + contentType: item.contentType, + loop, + index, + message: { + attachments: message.get('attachments') || [], + id: message.get('id'), + conversationId: + window.ConversationController.lookupOrCreate({ + uuid: message.get('sourceUuid'), + e164: message.get('source'), + reason: 'conversation_view.showLightBox', + })?.id || message.get('conversationId'), + received_at: message.get('received_at'), + received_at_ms: Number(message.get('received_at_ms')), + sent_at: message.get('sent_at'), + }, + attachment: item, + thumbnailObjectUrl: + item.thumbnail?.objectUrl || + getAbsoluteAttachmentPath(item.thumbnail?.path ?? ''), + })); + + if (!media.length) { + log.error( + 'showLightbox: unable to load attachment', + attachments.map(x => ({ + contentType: x.contentType, + error: x.error, + flags: x.flags, + path: x.path, + size: x.size, + })) + ); + + dispatch({ + type: SHOW_TOAST, + payload: { + toastType: ToastType.UnableToLoadAttachment, + }, + }); + return; + } + + dispatch({ + type: SHOW_LIGHTBOX, + payload: { + isViewOnce: false, + media, + selectedAttachmentPath: attachment.path, + }, + }); + }; +} + +export const actions = { + closeLightbox, + closeLightboxIfViewingExpiredMessage, + showLightbox, + showLightboxForViewOnceMedia, + showLightboxWithMedia, +}; + +export const useLightboxActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + +export function getEmptyState(): LightboxStateType { + return { + isShowingLightbox: false, + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): LightboxStateType { + if (action.type === CLOSE_LIGHTBOX) { + return getEmptyState(); + } + + if (action.type === SHOW_LIGHTBOX) { + return { + ...action.payload, + isShowingLightbox: true, + }; + } + + return state; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 61836ffb6c..db9f4db21c 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -11,6 +11,7 @@ import { getEmptyState as conversations } from './ducks/conversations'; import { getEmptyState as crashReports } from './ducks/crashReports'; 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 network } from './ducks/network'; import { getEmptyState as preferredReactions } from './ducks/preferredReactions'; @@ -103,6 +104,7 @@ export function getInitialState({ expiration: expiration(), globalModals: globalModals(), items, + lightbox: lightbox(), linkPreviews: linkPreviews(), network: network(), preferredReactions: preferredReactions(), diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 28a3a362c0..957af6f378 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -16,6 +16,7 @@ import { reducer as emojis } from './ducks/emojis'; import { reducer as expiration } from './ducks/expiration'; 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 network } from './ducks/network'; import { reducer as preferredReactions } from './ducks/preferredReactions'; @@ -43,6 +44,7 @@ export const reducer = combineReducers({ expiration, globalModals, items, + lightbox, linkPreviews, network, preferredReactions, diff --git a/ts/state/selectors/lightbox.ts b/ts/state/selectors/lightbox.ts new file mode 100644 index 0000000000..eb6a9f5b27 --- /dev/null +++ b/ts/state/selectors/lightbox.ts @@ -0,0 +1,35 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; +import type { MediaItemType } from '../../types/MediaItem'; +import type { StateType } from '../reducer'; +import type { LightboxStateType } from '../ducks/lightbox'; + +export const getLightboxState = (state: StateType): LightboxStateType => + state.lightbox; + +export const shouldShowLightbox = createSelector( + getLightboxState, + ({ isShowingLightbox }): boolean => isShowingLightbox +); + +export const getIsViewOnce = createSelector( + getLightboxState, + (state): boolean => (state.isShowingLightbox ? state.isViewOnce : false) +); + +export const getSelectedIndex = createSelector( + getLightboxState, + (state): number => + state.isShowingLightbox + ? state.media.findIndex( + item => item.attachment.path === state.selectedAttachmentPath + ) || 0 + : 0 +); + +export const getMedia = createSelector( + getLightboxState, + (state): Array => (state.isShowingLightbox ? state.media : []) +); diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 83b204b247..ae17359bab 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -11,6 +11,7 @@ import { SmartCallManager } from './CallManager'; import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal'; import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartLeftPane } from './LeftPane'; +import { SmartLightbox } from './Lightbox'; import { SmartStories } from './Stories'; import { SmartStoryViewer } from './StoryViewer'; import type { StateType } from '../reducer'; @@ -55,6 +56,7 @@ const mapStateToProps = (state: StateType) => { ), renderGlobalModalContainer: () => , renderLeftPane: () => , + renderLightbox: () => , isShowingStoriesView: shouldShowStoriesView(state), renderStories: (closeView: () => unknown) => ( diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 318786c28f..e7868b3871 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -18,7 +18,6 @@ import { getGroupMemberships } from '../../util/getGroupMemberships'; import { getActiveCallState } from '../selectors/calling'; import { getAreWeASubscriber } from '../selectors/items'; import { getIntl, getTheme } from '../selectors/user'; -import type { MediaItemType } from '../../types/MediaItem'; import { getBadgesSelector, getPreferredBadgeSelector, @@ -44,10 +43,6 @@ export type SmartConversationDetailsProps = { showGroupV2Permissions: () => void; showConversationNotificationsSettings: () => void; showPendingInvites: () => void; - showLightboxForMedia: ( - selectedMediaItem: MediaItemType, - media: Array - ) => void; updateGroupAttributes: ( _: Readonly<{ avatar?: undefined | Uint8Array; diff --git a/ts/state/smart/Lightbox.tsx b/ts/state/smart/Lightbox.tsx new file mode 100644 index 0000000000..90ad34ab8b --- /dev/null +++ b/ts/state/smart/Lightbox.tsx @@ -0,0 +1,52 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { useSelector } from 'react-redux'; + +import type { GetConversationByIdType } from '../selectors/conversations'; +import type { LocalizerType } from '../../types/Util'; +import type { MediaItemType } from '../../types/MediaItem'; +import type { StateType } from '../reducer'; +import { Lightbox } from '../../components/Lightbox'; +import { getConversationSelector } from '../selectors/conversations'; +import { getIntl } from '../selectors/user'; +import { useGlobalModalActions } from '../ducks/globalModals'; +import { useLightboxActions } from '../ducks/lightbox'; +import { + getIsViewOnce, + getMedia, + getSelectedIndex, + shouldShowLightbox, +} from '../selectors/lightbox'; + +export function SmartLightbox(): JSX.Element | null { + const i18n = useSelector(getIntl); + const { closeLightbox } = useLightboxActions(); + const { toggleForwardMessageModal } = useGlobalModalActions(); + + const conversationSelector = useSelector( + getConversationSelector + ); + + const isShowingLightbox = useSelector(shouldShowLightbox); + const isViewOnce = useSelector(getIsViewOnce); + const media = useSelector>(getMedia); + const selectedIndex = useSelector(getSelectedIndex); + + if (!isShowingLightbox) { + return null; + } + + return ( + + ); +} diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index 3264adc4aa..165359fdcf 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -39,7 +39,6 @@ const mapStateToProps = ( receivedAt, sentAt, - displayTapToViewMessage, kickOffAttachmentDownload, markAttachmentAsCorrupted, openConversation, @@ -48,7 +47,6 @@ const mapStateToProps = ( showContactDetail, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, - showVisualAttachment, startConversation, } = props; @@ -75,7 +73,6 @@ const mapStateToProps = ( interactionMode: getInteractionMode(state), theme: getTheme(state), - displayTapToViewMessage, kickOffAttachmentDownload, markAttachmentAsCorrupted, markViewed, @@ -86,7 +83,6 @@ const mapStateToProps = ( showContactDetail, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, - showVisualAttachment, startConversation, }; }; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 323e2b64ff..94efb29e5f 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -66,8 +66,6 @@ export type TimelinePropsType = ExternalProps & | 'contactSupport' | 'blockGroupLinkRequests' | 'deleteMessage' - | 'displayTapToViewMessage' - | 'downloadAttachment' | 'downloadNewVersion' | 'kickOffAttachmentDownload' | 'learnMoreAboutDeliveryIssue' @@ -88,7 +86,6 @@ export type TimelinePropsType = ExternalProps & | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' | 'showMessageDetail' - | 'showVisualAttachment' | 'startConversation' | 'unblurAvatar' | 'updateSharedGroups' diff --git a/ts/state/types.ts b/ts/state/types.ts index f53fd23917..fb7e5ee4ab 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -14,6 +14,7 @@ import type { actions as emojis } from './ducks/emojis'; import type { actions as expiration } from './ducks/expiration'; 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 network } from './ducks/network'; import type { actions as safetyNumber } from './ducks/safetyNumber'; @@ -40,6 +41,7 @@ export type ReduxActions = { expiration: typeof expiration; globalModals: typeof globalModals; items: typeof items; + lightbox: typeof lightbox; linkPreviews: typeof linkPreviews; network: typeof network; safetyNumber: typeof safetyNumber; diff --git a/ts/util/saveAttachment.ts b/ts/util/saveAttachment.ts index 741ef203aa..6f545491fc 100644 --- a/ts/util/saveAttachment.ts +++ b/ts/util/saveAttachment.ts @@ -3,14 +3,26 @@ import type { AttachmentType } from '../types/Attachment'; import * as Attachment from '../types/Attachment'; -import { showToast } from './showToast'; +import { ToastDangerousFileType } from '../components/ToastDangerousFileType'; import { ToastFileSaved } from '../components/ToastFileSaved'; +import { isFileDangerous } from './isFileDangerous'; +import { showToast } from './showToast'; +import { getMessageById } from '../messages/getMessageById'; export async function saveAttachment( attachment: AttachmentType, timestamp = Date.now(), index = 0 ): Promise { + const { fileName = '' } = attachment; + + const isDangerous = isFileDangerous(fileName); + + if (isDangerous) { + showToast(ToastDangerousFileType); + return; + } + const { openFileInFolder, readAttachmentData, saveAttachmentToDisk } = window.Signal.Migrations; @@ -30,3 +42,25 @@ export async function saveAttachment( }); } } + +export async function saveAttachmentFromMessage( + messageId: string, + providedAttachment?: AttachmentType +): Promise { + const message = await getMessageById(messageId); + if (!message) { + throw new Error(`saveAttachmentFromMessage: Message ${messageId} missing!`); + } + + const { attachments, sent_at: timestamp } = message.attributes; + if (!attachments || attachments.length < 1) { + return; + } + + const attachment = + providedAttachment && attachments.includes(providedAttachment) + ? providedAttachment + : attachments[0]; + + return saveAttachment(attachment, timestamp); +} diff --git a/ts/util/showLightbox.tsx b/ts/util/showLightbox.tsx deleted file mode 100644 index 636174436c..0000000000 --- a/ts/util/showLightbox.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import { render } from 'react-dom'; -import type { PropsType } from '../components/Lightbox'; -import { Lightbox } from '../components/Lightbox'; - -// NOTE: This file is temporarily here for convenicence of use by -// conversation_view while it is transitioning from Backbone into pure React. -// Please use directly and DO NOT USE THESE FUNCTIONS. - -let lightboxMountNode: HTMLElement | undefined; - -export function closeLightbox(): void { - if (!lightboxMountNode) { - return; - } - - window.ReactDOM.unmountComponentAtNode(lightboxMountNode); - document.body.removeChild(lightboxMountNode); - lightboxMountNode = undefined; -} - -export function showLightbox(props: PropsType): void { - if (lightboxMountNode) { - closeLightbox(); - } - - lightboxMountNode = document.createElement('div'); - lightboxMountNode.setAttribute('data-id', 'lightbox'); - document.body.appendChild(lightboxMountNode); - - render(, lightboxMountNode); -} diff --git a/ts/util/showToast.tsx b/ts/util/showToast.tsx index 724d9d29c1..30a1a9c8ec 100644 --- a/ts/util/showToast.tsx +++ b/ts/util/showToast.tsx @@ -40,7 +40,6 @@ import type { ToastReactionFailed } from '../components/ToastReactionFailed'; import type { ToastStickerPackInstallFailed } from '../components/ToastStickerPackInstallFailed'; import type { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming'; import type { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing'; -import type { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment'; import type { ToastVoiceNoteLimit } from '../components/ToastVoiceNoteLimit'; import type { ToastVoiceNoteMustBeOnlyAttachment } from '../components/ToastVoiceNoteMustBeOnlyAttachment'; @@ -79,7 +78,6 @@ export function showToast(Toast: typeof ToastReactionFailed): void; export function showToast(Toast: typeof ToastStickerPackInstallFailed): void; export function showToast(Toast: typeof ToastTapToViewExpiredIncoming): void; export function showToast(Toast: typeof ToastTapToViewExpiredOutgoing): void; -export function showToast(Toast: typeof ToastUnableToLoadAttachment): void; export function showToast(Toast: typeof ToastVoiceNoteLimit): void; export function showToast( Toast: typeof ToastVoiceNoteMustBeOnlyAttachment diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 8c47293e42..cb64b6bbfa 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -4,17 +4,15 @@ /* eslint-disable camelcase */ import type * as Backbone from 'backbone'; -import type { ComponentProps } from 'react'; import * as React from 'react'; import { flatten } from 'lodash'; import { render } from 'mustache'; import type { AttachmentType } from '../types/Attachment'; -import { isGIF } 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, MediaItemMessageType } from '../types/MediaItem'; +import type { MediaItemType } from '../types/MediaItem'; import { getMessageById } from '../messages/getMessageById'; import { getContactId } from '../messages/helpers'; import { strictAssert } from '../util/assert'; @@ -22,16 +20,10 @@ import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend'; import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; import { isGroup } from '../util/whatTypeOfConversation'; import { getPreferredBadgeSelector } from '../state/selectors/badges'; -import { - isIncoming, - isOutgoing, - isTapToView, -} from '../state/selectors/message'; -import { getConversationSelector } from '../state/selectors/conversations'; +import { isIncoming, isOutgoing } from '../state/selectors/message'; import { getActiveCallState } from '../state/selectors/calling'; import { getTheme } from '../state/selectors/user'; import { ReactWrapperView } from './ReactWrapperView'; -import type { Lightbox } from '../components/Lightbox'; import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList'; import * as log from '../logging/log'; import type { EmbeddedContactType } from '../types/EmbeddedContact'; @@ -39,13 +31,11 @@ import { createConversationView } from '../state/roots/createConversationView'; import { ToastConversationArchived } from '../components/ToastConversationArchived'; import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; -import { ToastDangerousFileType } from '../components/ToastDangerousFileType'; import { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong'; import { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound'; import { ToastReactionFailed } from '../components/ToastReactionFailed'; import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming'; import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing'; -import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment'; import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge'; import { deleteDraftAttachment } from '../util/deleteDraftAttachment'; import { retryMessageSend } from '../util/retryMessageSend'; @@ -62,7 +52,6 @@ import { removeLinkPreview, suspendLinkPreviews, } from '../services/LinkPreview'; -import { closeLightbox, showLightbox } from '../util/showLightbox'; import { saveAttachment } from '../util/saveAttachment'; import { SECOND } from '../util/durations'; import { startConversation } from '../util/startConversation'; @@ -78,24 +67,13 @@ type PanelType = { view: Backbone.View; headerTitle?: string }; const { Message } = window.Signal.Types; -const { - copyIntoTempDirectory, - deleteTempFile, - getAbsoluteAttachmentPath, - getAbsoluteTempPath, - upgradeMessageSchema, -} = window.Signal.Migrations; +const { getAbsoluteAttachmentPath, upgradeMessageSchema } = + window.Signal.Migrations; const { getMessagesBySentAt } = window.Signal.Data; type MessageActionsType = { deleteMessage: (messageId: string) => unknown; - displayTapToViewMessage: (messageId: string) => unknown; - downloadAttachment: (options: { - attachment: AttachmentType; - timestamp: number; - isDangerous: boolean; - }) => unknown; downloadNewVersion: () => unknown; kickOffAttachmentDownload: ( options: Readonly<{ messageId: string }> @@ -120,11 +98,6 @@ type MessageActionsType = { showExpiredIncomingTapToViewToast: () => unknown; showExpiredOutgoingTapToViewToast: () => unknown; showMessageDetail: (messageId: string) => unknown; - showVisualAttachment: (options: { - attachment: AttachmentType; - messageId: string; - showSingle?: boolean; - }) => unknown; startConversation: (e164: string, uuid: UUIDStringType) => unknown; }; @@ -173,11 +146,6 @@ export class ConversationView extends window.Backbone.View { this.listenTo(this.model, 'open-all-media', this.showAllMedia); this.listenTo(this.model, 'escape-pressed', this.resetPanel); this.listenTo(this.model, 'show-message-details', this.showMessageDetail); - this.listenTo( - this.model, - 'save-attachment', - this.downloadAttachmentWrapper - ); this.listenTo(this.model, 'delete-message', this.deleteMessage); this.listenTo(this.model, 'remove-link-review', removeLinkPreview); this.listenTo( @@ -481,22 +449,6 @@ export class ConversationView extends window.Backbone.View { message.markAttachmentAsCorrupted(options.attachment); }; - const showVisualAttachment = (options: { - attachment: AttachmentType; - messageId: string; - showSingle?: boolean; - }) => { - this.showLightbox(options); - }; - const downloadAttachment = (options: { - attachment: AttachmentType; - timestamp: number; - isDangerous: boolean; - }) => { - this.downloadAttachment(options); - }; - const displayTapToViewMessage = (messageId: string) => - this.displayTapToViewMessage(messageId); const openGiftBadge = (messageId: string): void => { const message = window.MessageController.getById(messageId); if (!message) { @@ -523,8 +475,6 @@ export class ConversationView extends window.Backbone.View { return { deleteMessage, - displayTapToViewMessage, - downloadAttachment, downloadNewVersion, kickOffAttachmentDownload, markAttachmentAsCorrupted, @@ -538,7 +488,6 @@ export class ConversationView extends window.Backbone.View { showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, showMessageDetail, - showVisualAttachment, startConversation, }; } @@ -805,9 +754,10 @@ export class ConversationView extends window.Backbone.View { } case 'media': { - const selectedMedia = - media.find(item => attachment.path === item.path) || media[0]; - this.showLightboxForMedia(selectedMedia, media); + window.reduxActions.lightbox.showLightboxWithMedia( + attachment.path, + media + ); break; } @@ -907,131 +857,6 @@ export class ConversationView extends window.Backbone.View { view.render(); } - downloadAttachmentWrapper( - messageId: string, - providedAttachment?: AttachmentType - ): void { - const message = window.MessageController.getById(messageId); - if (!message) { - throw new Error( - `downloadAttachmentWrapper: Message ${messageId} missing!` - ); - } - - const { attachments, sent_at: timestamp } = message.attributes; - if (!attachments || attachments.length < 1) { - return; - } - - const attachment = - providedAttachment && attachments.includes(providedAttachment) - ? providedAttachment - : attachments[0]; - const { fileName } = attachment; - - const isDangerous = window.Signal.Util.isFileDangerous(fileName || ''); - - this.downloadAttachment({ attachment, timestamp, isDangerous }); - } - - async downloadAttachment({ - attachment, - timestamp, - isDangerous, - }: { - attachment: AttachmentType; - timestamp: number; - isDangerous: boolean; - }): Promise { - if (isDangerous) { - showToast(ToastDangerousFileType); - return; - } - - return saveAttachment(attachment, timestamp); - } - - async displayTapToViewMessage(messageId: string): Promise { - log.info('displayTapToViewMessage: attempting to display message'); - - const message = window.MessageController.getById(messageId); - if (!message) { - throw new Error(`displayTapToViewMessage: Message ${messageId} missing!`); - } - - if (!isTapToView(message.attributes)) { - throw new Error( - `displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message` - ); - } - - if (message.isErased()) { - throw new Error( - `displayTapToViewMessage: Message ${message.idForLogging()} is already erased` - ); - } - - const firstAttachment = (message.get('attachments') || [])[0]; - if (!firstAttachment || !firstAttachment.path) { - throw new Error( - `displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path` - ); - } - - const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path); - const { path: tempPath } = await copyIntoTempDirectory(absolutePath); - const tempAttachment = { - ...firstAttachment, - path: tempPath, - }; - - await message.markViewOnceMessageViewed(); - - const close = (): void => { - try { - this.stopListening(message); - closeLightbox(); - } finally { - deleteTempFile(tempPath); - } - }; - - this.listenTo(message, 'expired', close); - this.listenTo(message, 'change', () => { - showLightbox(getProps()); - }); - - const getProps = (): ComponentProps => { - const { path, contentType } = tempAttachment; - - return { - close, - i18n: window.i18n, - media: [ - { - attachment: tempAttachment, - objectURL: getAbsoluteTempPath(path), - contentType, - index: 0, - message: { - attachments: message.get('attachments') || [], - id: message.get('id'), - conversationId: message.get('conversationId'), - received_at: message.get('received_at'), - received_at_ms: Number(message.get('received_at_ms')), - sent_at: message.get('sent_at'), - }, - }, - ], - isViewOnce: true, - }; - }; - - showLightbox(getProps()); - - log.info('displayTapToViewMessage: showed lightbox'); - } - deleteMessage(messageId: string): void { const message = window.MessageController.getById(messageId); if (!message) { @@ -1055,136 +880,6 @@ export class ConversationView extends window.Backbone.View { }); } - showLightboxForMedia( - selectedMediaItem: MediaItemType, - media: Array = [] - ): void { - const onSave = async ({ - attachment, - message, - index, - }: { - attachment: AttachmentType; - message: MediaItemMessageType; - index: number; - }) => { - return saveAttachment(attachment, message.sent_at, index + 1); - }; - - const selectedIndex = media.findIndex( - mediaItem => - mediaItem.attachment.path === selectedMediaItem.attachment.path - ); - - const mediaMessage = selectedMediaItem.message; - const message = window.MessageController.getById(mediaMessage.id); - if (!message) { - throw new Error( - `showLightboxForMedia: Message ${mediaMessage.id} missing!` - ); - } - - const close = () => { - closeLightbox(); - this.stopListening(message, 'expired', closeLightbox); - }; - - showLightbox({ - close, - i18n: window.i18n, - getConversation: getConversationSelector(window.reduxStore.getState()), - media, - onForward: messageId => { - window.reduxActions.globalModals.toggleForwardMessageModal(messageId); - }, - onSave, - selectedIndex: selectedIndex >= 0 ? selectedIndex : 0, - }); - - this.listenTo(message, 'expired', close); - } - - showLightbox({ - attachment, - messageId, - }: { - attachment: AttachmentType; - messageId: string; - showSingle?: boolean; - }): void { - const message = window.MessageController.getById(messageId); - if (!message) { - throw new Error(`showLightbox: Message ${messageId} missing!`); - } - const sticker = message.get('sticker'); - if (sticker) { - const { packId, packKey } = sticker; - window.reduxActions.globalModals.showStickerPackPreview(packId, packKey); - return; - } - - const { contentType } = attachment; - - if ( - !window.Signal.Util.GoogleChrome.isImageTypeSupported(contentType) && - !window.Signal.Util.GoogleChrome.isVideoTypeSupported(contentType) - ) { - this.downloadAttachmentWrapper(messageId, attachment); - return; - } - - const attachments: Array = message.get('attachments') || []; - - const loop = isGIF(attachments); - - const media = attachments - .filter(item => item.thumbnail && !item.pending && !item.error) - .map((item, index) => ({ - objectURL: getAbsoluteAttachmentPath(item.path ?? ''), - path: item.path, - contentType: item.contentType, - loop, - index, - message: { - attachments: message.get('attachments') || [], - id: message.get('id'), - conversationId: - window.ConversationController.lookupOrCreate({ - uuid: message.get('sourceUuid'), - e164: message.get('source'), - reason: 'conversation_view.showLightBox', - })?.id || message.get('conversationId'), - received_at: message.get('received_at'), - received_at_ms: Number(message.get('received_at_ms')), - sent_at: message.get('sent_at'), - }, - attachment: item, - thumbnailObjectUrl: - item.thumbnail?.objectUrl || - getAbsoluteAttachmentPath(item.thumbnail?.path ?? ''), - })); - - if (!media.length) { - log.error( - 'showLightbox: unable to load attachment', - attachments.map(x => ({ - contentType: x.contentType, - error: x.error, - flags: x.flags, - path: x.path, - size: x.size, - })) - ); - showToast(ToastUnableToLoadAttachment); - return; - } - - const selectedMedia = - media.find(item => attachment.path === item.path) || media[0]; - - this.showLightboxForMedia(selectedMedia, media); - } - showGroupLinkManagement(): void { const view = new ReactWrapperView({ className: 'panel', @@ -1290,7 +985,6 @@ export class ConversationView extends window.Backbone.View { showConversationNotificationsSettings: this.showConversationNotificationsSettings.bind(this), showPendingInvites: this.showPendingInvites.bind(this), - showLightboxForMedia: this.showLightboxForMedia.bind(this), updateGroupAttributes: this.model.updateGroupAttributesV2.bind( this.model ),