diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 8f32136f5e..de072a828f 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -137,6 +137,7 @@ line-height: 16px; letter-spacing: 0.3px; margin-top: 3px; + text-align: start; white-space: nowrap; } diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index 07ee2e577f..05c08f5f91 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -1,17 +1,26 @@ import classNames from 'classnames'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { isNumber } from 'lodash'; import { useDisableDrag } from '../../hooks/useDisableDrag'; -import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch'; import { AttachmentType, AttachmentTypeWithPath } from '../../types/Attachment'; import { Spinner } from '../loading'; +import { MessageGenericAttachment } from './message/message-content/MessageGenericAttachment'; +import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch'; +import { useMessageIdFromContext } from '../../contexts/MessageIdContext'; +import { + useMessageDirection, + useMessageSelected, + useMessageTimestamp, +} from '../../state/selectors'; type Props = { alt: string; attachment: AttachmentTypeWithPath | AttachmentType; - url: string | undefined; // url is undefined if the message is not visible yet + /** undefined if the message is not visible yet, '' if the attachment is broken */ + url: string | undefined; + imageBroken?: boolean; height?: number | string; width?: number | string; @@ -24,8 +33,8 @@ type Props = { playIconOverlay?: boolean; softCorners: boolean; forceSquare?: boolean; - dropShadow?: boolean; attachmentIndex?: number; + highlight?: boolean; onClick?: (attachment: AttachmentTypeWithPath | AttachmentType) => void; onClickClose?: (attachment: AttachmentTypeWithPath | AttachmentType) => void; @@ -46,6 +55,7 @@ export const Image = (props: Props) => { const { alt, attachment, + imageBroken, closeButton, darkOverlay, height: _height, @@ -56,34 +66,74 @@ export const Image = (props: Props) => { playIconOverlay, softCorners, forceSquare, - dropShadow, attachmentIndex, + highlight, url, width: _width, } = props; - const onErrorUrlFilterering = useCallback(() => { - if (url && onError) { - onError(); - } - }, [url, onError]); + const messageId = useMessageIdFromContext(); + const dropShadow = useMessageSelected(messageId); + const direction = useMessageDirection(messageId); + /** used for debugging */ + const timestamp = useMessageTimestamp(messageId); + const disableDrag = useDisableDrag(); + const { loading, urlToLoad } = useEncryptedFileFetch( + url, + attachment.contentType, + false, + timestamp + ); const { caption } = attachment || { caption: null }; - let { pending } = attachment || { pending: true }; - if (!url) { - // force pending to true if the url is undefined, so we show a loader while decrypting the attachemtn - pending = true; - } + const [pending, setPending] = useState(attachment.pending || true); + const [mounted, setMounted] = useState( + (!loading || !pending) && urlToLoad === undefined + ); + const canClick = onClick && !pending; const role = canClick ? 'button' : undefined; - const { loading, urlToLoad } = useEncryptedFileFetch(url || '', attachment.contentType, false); - // data will be url if loading is finished and '' if not - const srcData = !loading ? urlToLoad : ''; + + const onErrorUrlFilterering = useCallback(() => { + if (mounted && url && urlToLoad === '' && onError) { + onError(); + setPending(false); + } + }, [mounted, onError, url, urlToLoad]); const width = isNumber(_width) ? `${_width}px` : _width; const height = isNumber(_height) ? `${_height}px` : _height; + useEffect(() => { + if (mounted && url === '') { + setPending(false); + onErrorUrlFilterering(); + } + + if (mounted && imageBroken && urlToLoad === '') { + setPending(false); + onErrorUrlFilterering(); + } + + if (url) { + setPending(false); + setMounted(!loading && !pending); + } + }, [imageBroken, loading, mounted, onErrorUrlFilterering, pending, url, urlToLoad]); + + if (mounted && imageBroken) { + return ( + + ); + } + return (
{ }} data-attachmentindex={attachmentIndex} > - {pending || loading ? ( + {!mounted ? (
{ width: forceSquare ? width : '', height: forceSquare ? height : '', }} - src={srcData} + src={urlToLoad} onDragStart={disableDrag} /> )} @@ -166,7 +216,7 @@ export const Image = (props: Props) => { className="module-image__close-button" /> ) : null} - {!(pending || loading) && playIconOverlay ? ( + {mounted && playIconOverlay ? (
diff --git a/ts/components/conversation/ImageGrid.tsx b/ts/components/conversation/ImageGrid.tsx index 7e6120b91d..576765a0e1 100644 --- a/ts/components/conversation/ImageGrid.tsx +++ b/ts/components/conversation/ImageGrid.tsx @@ -10,15 +10,15 @@ import { } from '../../types/Attachment'; import { useIsMessageVisible } from '../../contexts/isMessageVisibleContext'; -import { useMessageSelected } from '../../state/selectors'; import { THUMBNAIL_SIDE } from '../../types/attachments/VisualAttachment'; import { Image } from './Image'; type Props = { attachments: Array; onError: () => void; + imageBroken: boolean; + highlight: boolean; onClickAttachment?: (attachment: AttachmentTypeWithPath | AttachmentType) => void; - messageId?: string; }; const StyledImageGrid = styled.div<{ flexDirection: 'row' | 'column' }>` @@ -33,17 +33,17 @@ const Row = ( renderedSize: number; startIndex: number; totalAttachmentsCount: number; - selected: boolean; } ) => { const { attachments, + imageBroken, + highlight, onError, renderedSize, startIndex, onClickAttachment, totalAttachmentsCount, - selected, } = props; const isMessageVisible = useIsMessageVisible(); const moreMessagesOverlay = totalAttachmentsCount > 3; @@ -64,11 +64,12 @@ const Row = ( url={isMessageVisible ? getThumbnailUrl(attachment) : undefined} attachmentIndex={startIndex + index} onClick={onClickAttachment} + imageBroken={imageBroken} + highlight={highlight} onError={onError} softCorners={true} darkOverlay={showOverlay} overlayText={showOverlay ? moreMessagesOverlayText : undefined} - dropShadow={selected} /> ); })} @@ -77,9 +78,7 @@ const Row = ( }; export const ImageGrid = (props: Props) => { - const { attachments, onError, onClickAttachment, messageId } = props; - - const selected = useMessageSelected(messageId); + const { attachments, imageBroken, highlight, onError, onClickAttachment } = props; if (!attachments || !attachments.length) { return null; @@ -90,12 +89,13 @@ export const ImageGrid = (props: Props) => { ); @@ -107,12 +107,13 @@ export const ImageGrid = (props: Props) => { ); @@ -125,23 +126,25 @@ export const ImageGrid = (props: Props) => { diff --git a/ts/components/conversation/message/message-content/MessageAttachment.tsx b/ts/components/conversation/message/message-content/MessageAttachment.tsx index 6534faed2b..00d32b1a51 100644 --- a/ts/components/conversation/message/message-content/MessageAttachment.tsx +++ b/ts/components/conversation/message/message-content/MessageAttachment.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import { clone } from 'lodash'; import { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -16,21 +15,19 @@ import { import { AttachmentType, AttachmentTypeWithPath, - canDisplayImagePreview, - getExtensionForDisplay, - hasImage, - hasVideoScreenshot, isAudio, isImage, isVideo, } from '../../../../types/Attachment'; import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil'; import { MediaItemType } from '../../../lightbox/LightboxGallery'; -import { Spinner } from '../../../loading'; import { AudioPlayerWithEncryptedFile } from '../../H5AudioPlayer'; import { ImageGrid } from '../../ImageGrid'; import { ClickToTrustSender } from './ClickToTrustSender'; import { MessageHighlighter } from './MessageHighlighter'; +import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; +import { MessageGenericAttachment } from './MessageGenericAttachment'; +import { ContextMessageProvider } from '../../../../contexts/MessageIdContext'; export type MessageAttachmentSelectorProps = Pick< MessageRenderingProps, @@ -61,12 +58,9 @@ const StyledImageGridContainer = styled.div<{ justify-content: ${props => (props.messageDirection === 'incoming' ? 'flex-start' : 'flex-end')}; `; -const StyledGenericAttachmentContainer = styled(MessageHighlighter)<{ selected: boolean }>` - ${props => props.selected && 'box-shadow: var(--drop-shadow);'} -`; - export const MessageAttachment = (props: Props) => { const { messageId, imageBroken, handleImageError, highlight = false } = props; + const isDetailView = useIsDetailMessageView(); const dispatch = useDispatch(); const attachmentProps = useSelector((state: StateType) => @@ -128,29 +122,31 @@ export const MessageAttachment = (props: Props) => { } const firstAttachment = attachments[0]; - const displayImage = canDisplayImagePreview(attachments); if (!isTrustedForAttachmentDownload) { return ; } - if ( - displayImage && - !imageBroken && - ((isImage(attachments) && hasImage(attachments)) || - (isVideo(attachments) && hasVideoScreenshot(attachments))) - ) { + if (isImage(attachments) || isVideo(attachments)) { + // we use the carousel in the detail view + if (isDetailView) { + return null; + } + return ( - - - - - + + + + + + + ); } @@ -175,48 +171,16 @@ export const MessageAttachment = (props: Props) => { ); } - const { pending, fileName, fileSize, contentType } = firstAttachment; - const extension = getExtensionForDisplay({ contentType, fileName }); return ( - - {pending ? ( -
- -
- ) : ( -
-
- {extension ? ( -
{extension}
- ) : null} -
-
- )} -
-
- {fileName} -
-
- {fileSize} -
-
-
+ /> ); }; diff --git a/ts/components/conversation/message/message-content/MessageContent.tsx b/ts/components/conversation/message/message-content/MessageContent.tsx index 1f0c872195..eb09c870aa 100644 --- a/ts/components/conversation/message/message-content/MessageContent.tsx +++ b/ts/components/conversation/message/message-content/MessageContent.tsx @@ -21,7 +21,6 @@ import { getShouldHighlightMessage, } from '../../../../state/selectors/conversations'; import { useSelectedIsPrivate } from '../../../../state/selectors/selectedConversation'; -import { canDisplayImagePreview } from '../../../../types/Attachment'; import { MessageAttachment } from './MessageAttachment'; import { MessageAvatar } from './MessageAvatar'; import { MessageHighlighter } from './MessageHighlighter'; @@ -147,16 +146,12 @@ export const MessageContent = (props: Props) => { return null; } - const { direction, text, timestamp, serverTimestamp, previews, quote, attachments } = - contentProps; + const { direction, text, timestamp, serverTimestamp, previews, quote } = contentProps; const hasContentBeforeAttachment = !isEmpty(previews) || !isEmpty(quote) || !isEmpty(text); const toolTipTitle = moment(serverTimestamp || timestamp).format('llll'); - const isDetailViewAndSupportsAttachmentCarousel = - isDetailView && canDisplayImagePreview(attachments); - return ( { )} - {!isDeleted && isDetailViewAndSupportsAttachmentCarousel && !imageBroken ? null : ( + {!isDeleted ? ( - )} + ) : null} diff --git a/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx b/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx new file mode 100644 index 0000000000..4deaa1c362 --- /dev/null +++ b/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx @@ -0,0 +1,75 @@ +import classNames from 'classnames'; +import styled from 'styled-components'; +import { PropsForAttachment } from '../../../../state/ducks/conversations'; +import { AttachmentTypeWithPath, getExtensionForDisplay } from '../../../../types/Attachment'; +import { Spinner } from '../../../loading'; +import { MessageModelType } from '../../../../models/messageType'; +import { MessageHighlighter } from './MessageHighlighter'; + +const StyledGenericAttachmentContainer = styled(MessageHighlighter)<{ + highlight: boolean; + selected: boolean; +}>` + ${props => props.selected && 'box-shadow: var(--drop-shadow);'} +`; + +export function MessageGenericAttachment({ + attachment, + /** comes from the attachment iself or the component if it needs to be decrypted */ + pending, + selected, + highlight, + direction, + onClick, +}: { + attachment: PropsForAttachment | AttachmentTypeWithPath; + pending: boolean; + selected: boolean; + highlight: boolean; + direction?: MessageModelType; + onClick?: (e: any) => void; +}) { + const { fileName, fileSize, contentType } = attachment; + const extension = getExtensionForDisplay({ contentType, fileName }); + + return ( + + {pending ? ( +
+ +
+ ) : ( +
+
+ {extension ? ( +
{extension}
+ ) : null} +
+
+ )} +
+
+ {fileName} +
+
+ {fileSize} +
+
+
+ ); +} diff --git a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx index d977ea8b80..2cb733322f 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx @@ -85,6 +85,8 @@ const DebugMessageInfo = ({ messageId }: { messageId: string }) => { const expirationType = useMessageExpirationType(messageId); const expirationDurationMs = useMessageExpirationDurationMs(messageId); const expirationTimestamp = useMessageExpirationTimestamp(messageId); + const timestamp = useMessageTimestamp(messageId); + const serverTimestamp = useMessageServerTimestamp(messageId); if (!isDevProd()) { return null; @@ -92,29 +94,25 @@ const DebugMessageInfo = ({ messageId }: { messageId: string }) => { return ( <> - {convoId ? ( - - ) : null} - {messageHash ? ( - - ) : null} - {serverId ? ( - - ) : null} - {expirationType ? ( - + {convoId ? : null} + {messageHash ? : null} + {serverId ? : null} + {timestamp ? : null} + {serverTimestamp ? ( + ) : null} + {expirationType ? : null} {expirationDurationMs ? ( ) : null} {expirationTimestamp ? ( ) : null} diff --git a/ts/contexts/MessageIdContext.tsx b/ts/contexts/MessageIdContext.tsx new file mode 100644 index 0000000000..c1f72b155d --- /dev/null +++ b/ts/contexts/MessageIdContext.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react'; + +/** + * This React context is used to share deep into a node tree the message ID we are currently rendering. + * This is to avoid passing the prop to all the subtree component + */ +const ContextMessageId = createContext(undefined); + +export const ContextMessageProvider = ContextMessageId.Provider; + +export function useMessageIdFromContext() { + const messageId = useContext(ContextMessageId); + return messageId; +} diff --git a/ts/hooks/useEncryptedFileFetch.ts b/ts/hooks/useEncryptedFileFetch.ts index 8274e8d4d7..19af3af172 100644 --- a/ts/hooks/useEncryptedFileFetch.ts +++ b/ts/hooks/useEncryptedFileFetch.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { getAlreadyDecryptedMediaUrl, @@ -6,40 +6,51 @@ import { } from '../session/crypto/DecryptedAttachmentsManager'; import { perfEnd, perfStart } from '../session/utils/Performance'; -export const useEncryptedFileFetch = (url: string, contentType: string, isAvatar: boolean) => { - const [urlToLoad, setUrlToLoad] = useState(''); - const [loading, setLoading] = useState(false); - - const mountedRef = useRef(true); - - const alreadyDecrypted = getAlreadyDecryptedMediaUrl(url); +export const useEncryptedFileFetch = ( + /** undefined if the message is not visible yet, url is '' if something is broken */ + url: string | undefined, + contentType: string, + isAvatar: boolean, + timestamp?: number +) => { + /** undefined if the attachment is not decrypted yet, '' if the attachment fails to decrypt */ + const [urlToLoad, setUrlToLoad] = useState(undefined); + const [loading, setLoading] = useState(true); + + const alreadyDecrypted = url ? getAlreadyDecryptedMediaUrl(url) : ''; + + const fetchUrl = useCallback( + async (mediaUrl: string | undefined) => { + if (alreadyDecrypted || !mediaUrl) { + if (alreadyDecrypted) { + setUrlToLoad(alreadyDecrypted); + setLoading(false); + } + return; + } - useEffect(() => { - async function fetchUrl() { - perfStart(`getDecryptedMediaUrl-${url}`); - const decryptedUrl = await getDecryptedMediaUrl(url, contentType, isAvatar); - perfEnd(`getDecryptedMediaUrl-${url}`, `getDecryptedMediaUrl-${url}`); + setLoading(true); - if (mountedRef.current) { + try { + perfStart(`getDecryptedMediaUrl-${mediaUrl}-${timestamp}`); + const decryptedUrl = await getDecryptedMediaUrl(mediaUrl, contentType, isAvatar); + perfEnd( + `getDecryptedMediaUrl-${mediaUrl}-${timestamp}`, + `getDecryptedMediaUrl-${mediaUrl}-${timestamp}` + ); setUrlToLoad(decryptedUrl); + } catch (error) { + setUrlToLoad(''); + } finally { setLoading(false); } - } - if (alreadyDecrypted) { - return; - } - setLoading(true); - mountedRef.current = true; - void fetchUrl(); - - // eslint-disable-next-line consistent-return - return () => { - mountedRef.current = false; - }; - }, [url, alreadyDecrypted, contentType, isAvatar]); - - if (alreadyDecrypted) { - return { urlToLoad: alreadyDecrypted, loading: false }; - } + }, + [alreadyDecrypted, contentType, isAvatar, timestamp] + ); + + useEffect(() => { + void fetchUrl(url); + }, [fetchUrl, url]); + return { urlToLoad, loading }; }; diff --git a/ts/session/crypto/DecryptedAttachmentsManager.ts b/ts/session/crypto/DecryptedAttachmentsManager.ts index b8567a00e2..abf63ad60a 100644 --- a/ts/session/crypto/DecryptedAttachmentsManager.ts +++ b/ts/session/crypto/DecryptedAttachmentsManager.ts @@ -10,7 +10,6 @@ * */ import path from 'path'; -import { reject } from 'lodash'; import * as fse from 'fs-extra'; @@ -114,7 +113,7 @@ export const getDecryptedMediaUrl = async ( urlToDecryptingPromise.set( url, - new Promise(async resolve => { + new Promise(async (resolve, reject) => { // window.log.debug('about to read and decrypt file :', url, path.isAbsolute(url)); try { const absUrl = path.isAbsolute(url) ? url : getAbsoluteAttachmentPath(url); @@ -149,7 +148,6 @@ export const getDecryptedMediaUrl = async ( } }) ); - return urlToDecryptingPromise.get(url) as Promise; } // Not sure what we got here. Just return the file. diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index ad50645708..3f1a4a96f0 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -25,6 +25,7 @@ import { PropsForCallNotification, PropsForInteractionNotification, } from './types'; +import { AttachmentType } from '../../types/Attachment'; export type MessageModelPropsWithoutConvoProps = { propsForMessage: PropsForMessageWithoutConvoProps; @@ -128,35 +129,13 @@ export type PropsForGroupInvitation = { messageId: string; }; -export type PropsForAttachment = { +export type PropsForAttachment = AttachmentType & { id: number; - contentType: string; - caption?: string; + isVoiceMessage: boolean; size: number; - width?: number; - height?: number; - duration?: string; - url: string; path: string; - fileSize: string | null; - isVoiceMessage: boolean; pending: boolean; - fileName: string; - error?: number; // if the download somhehow failed, this will be set to true and be 0-1 once saved in the db - screenshot: { - contentType: string; - width: number; - height: number; - url?: string; - path?: string; - } | null; - thumbnail: { - contentType: string; - width: number; - height: number; - url?: string; - path?: string; - } | null; + error?: number; // if the download somehow failed, this will be set to true and be 0 or 1 in the db }; export type PropsForQuote = { diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 1154f6b64f..436163e1d6 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -13,35 +13,40 @@ const MAX_HEIGHT = THUMBNAIL_SIDE; const MIN_WIDTH = THUMBNAIL_SIDE; const MIN_HEIGHT = THUMBNAIL_SIDE; -// Used for display +// Used for displaying attachments in the UI +export type AttachmentScreenshot = { + contentType: MIME.MIMEType; + height: number; + width: number; + url?: string; + path?: string; +}; + +export type AttachmentThumbnail = { + contentType: MIME.MIMEType; + height: number; + width: number; + url?: string; + path?: string; +}; export interface AttachmentType { - caption?: string; contentType: MIME.MIMEType; fileName: string; - /** Not included in protobuf, needs to be pulled from flags */ - isVoiceMessage?: boolean; /** For messages not already on disk, this will be a data url */ url: string; - videoUrl?: string; - size?: number; fileSize: string | null; pending?: boolean; + screenshot: AttachmentScreenshot | null; + thumbnail: AttachmentThumbnail | null; + caption?: string; + size?: number; width?: number; height?: number; duration?: string; - screenshot: { - height: number; - width: number; - url?: string; - contentType: MIME.MIMEType; - } | null; - thumbnail: { - height: number; - width: number; - url?: string; - contentType: MIME.MIMEType; - } | null; + videoUrl?: string; + /** Not included in protobuf, needs to be pulled from flags */ + isVoiceMessage?: boolean; } export interface AttachmentTypeWithPath extends AttachmentType { @@ -49,21 +54,6 @@ export interface AttachmentTypeWithPath extends AttachmentType { id: number; flags?: number; error?: any; - - screenshot: { - height: number; - width: number; - url?: string; - contentType: MIME.MIMEType; - path?: string; - } | null; - thumbnail: { - height: number; - width: number; - url?: string; - contentType: MIME.MIMEType; - path?: string; - } | null; } // UI-focused functions @@ -166,7 +156,7 @@ export function isVideoAttachment(attachment?: AttachmentType): boolean { export function hasVideoScreenshot(attachments?: Array): boolean { const firstAttachment = attachments ? attachments[0] : null; - return Boolean(firstAttachment?.screenshot?.url); + return Boolean(firstAttachment?.screenshot?.url || firstAttachment?.pending); } type DimensionsType = {