diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 3b5d6a630..a780a58b6 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -193,6 +193,7 @@ export enum GiftBadgeStates { Opened = 'Opened', Redeemed = 'Redeemed', } + export type GiftBadgeType = { expiration: number; id: string | undefined; @@ -390,6 +391,8 @@ export class Message extends React.PureComponent { current: false, }; + private metadataRef: React.RefObject = React.createRef(); + public reactionsContainerRefMerger = createRefMerger(); public expirationCheckInterval: NodeJS.Timeout | undefined; @@ -823,6 +826,7 @@ export class Message extends React.PureComponent { isTapToViewExpired={isTapToViewExpired} onWidthMeasured={isInline ? this.updateMetadataWidth : undefined} pushPanelForConversation={pushPanelForConversation} + ref={this.metadataRef} retryMessageSend={retryMessageSend} showEditHistoryModal={showEditHistoryModal} status={status} @@ -1779,7 +1783,7 @@ export class Message extends React.PureComponent { } return ( -
{ ? 'module-message__text--delete-for-everyone' : null )} + onClick={e => { + // Prevent metadata from being selected on triple clicks. + const clickCount = e.detail; + const range = window.getSelection()?.getRangeAt(0); + if ( + clickCount === 3 && + this.metadataRef.current && + range?.intersectsNode(this.metadataRef.current) + ) { + range.setEndBefore(this.metadataRef.current); + } + }} onDoubleClick={(event: React.MouseEvent) => { // Prevent double-click interefering with interactions _inside_ // the bubble. diff --git a/ts/components/conversation/MessageMetadata.tsx b/ts/components/conversation/MessageMetadata.tsx index 89c3039ac..9064e49ee 100644 --- a/ts/components/conversation/MessageMetadata.tsx +++ b/ts/components/conversation/MessageMetadata.tsx @@ -1,8 +1,8 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReactChild, ReactElement } from 'react'; -import React, { useCallback, useState } from 'react'; +import type { ReactChild } from 'react'; +import React, { forwardRef, useCallback, useState } from 'react'; import classNames from 'classnames'; import type { ContentRect } from 'react-measure'; import Measure from 'react-measure'; @@ -16,6 +16,7 @@ import { MessageTimestamp } from './MessageTimestamp'; import { PanelType } from '../../types/Panels'; import { Spinner } from '../Spinner'; import { ConfirmationDialog } from '../ConfirmationDialog'; +import { refMerger } from '../../util/refMerger'; type PropsType = { deletedForEveryone?: boolean; @@ -43,46 +44,72 @@ enum ConfirmationType { EditError = 'EditError', } -export function MessageMetadata({ - deletedForEveryone, - direction, - expirationLength, - expirationTimestamp, - hasText, - i18n, - id, - isEditedMessage, - isInline, - isShowingImage, - isSticker, - isTapToViewExpired, - onWidthMeasured, - pushPanelForConversation, - retryMessageSend, - showEditHistoryModal, - status, - textPending, - timestamp, -}: Readonly): ReactElement { - const [confirmationType, setConfirmationType] = useState< - ConfirmationType | undefined - >(); - const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage); - const metadataDirection = isSticker ? undefined : direction; +export const MessageMetadata = forwardRef>( + function MessageMetadataInner( + { + deletedForEveryone, + direction, + expirationLength, + expirationTimestamp, + hasText, + i18n, + id, + isEditedMessage, + isInline, + isShowingImage, + isSticker, + isTapToViewExpired, + onWidthMeasured, + pushPanelForConversation, + retryMessageSend, + showEditHistoryModal, + status, + textPending, + timestamp, + }, + ref + ) { + const [confirmationType, setConfirmationType] = useState< + ConfirmationType | undefined + >(); + const withImageNoCaption = Boolean( + !isSticker && !hasText && isShowingImage + ); + const metadataDirection = isSticker ? undefined : direction; - let timestampNode: ReactChild; - { - const isError = status === 'error' && direction === 'outgoing'; - const isPartiallySent = - status === 'partial-sent' && direction === 'outgoing'; - const isPaused = status === 'paused'; + let timestampNode: ReactChild; + { + const isError = status === 'error' && direction === 'outgoing'; + const isPartiallySent = + status === 'partial-sent' && direction === 'outgoing'; + const isPaused = status === 'paused'; - if (isError || isPartiallySent || isPaused) { - let statusInfo: React.ReactChild; - if (isError) { - if (deletedForEveryone) { - statusInfo = i18n('icu:deleteFailed'); - } else if (isEditedMessage) { + if (isError || isPartiallySent || isPaused) { + let statusInfo: React.ReactChild; + if (isError) { + if (deletedForEveryone) { + statusInfo = i18n('icu:deleteFailed'); + } else if (isEditedMessage) { + statusInfo = ( + + ); + } else { + statusInfo = i18n('icu:sendFailed'); + } + } else if (isPaused) { + statusInfo = i18n('icu:sendPaused'); + } else { statusInfo = ( ); - } else { - statusInfo = i18n('icu:sendFailed'); } - } else if (isPaused) { - statusInfo = i18n('icu:sendPaused'); - } else { - statusInfo = ( - + {statusInfo} + + ); + } else { + timestampNode = ( + ); } + } - timestampNode = ( - { + retryMessageSend(id); + setConfirmationType(undefined); + }, + style: 'negative', + text: i18n('icu:ResendMessageEdit__button'), + }, + ]} + i18n={i18n} + onClose={() => { + setConfirmationType(undefined); + }} > - {statusInfo} - + {i18n('icu:ResendMessageEdit__body')} + ); } else { - timestampNode = ( - + throw missingCaseError(confirmationType); + } + + const className = classNames( + 'module-message__metadata', + isInline && 'module-message__metadata--inline', + withImageNoCaption && 'module-message__metadata--with-image-no-caption', + deletedForEveryone && 'module-message__metadata--deleted-for-everyone' + ); + const children = ( + <> + {isEditedMessage && showEditHistoryModal && ( + + )} + {timestampNode} + {expirationLength ? ( + + ) : null} + {textPending ? ( +
+ +
+ ) : null} + {(!deletedForEveryone || status === 'sending') && + !textPending && + direction === 'outgoing' && + status !== 'error' && + status !== 'partial-sent' ? ( +
+ ) : null} + {confirmation} + + ); + + const onResize = useCallback( + ({ bounds }: ContentRect) => { + onWidthMeasured?.(bounds?.width || 0); + }, + [onWidthMeasured] + ); + + if (onWidthMeasured) { + return ( + + {({ measureRef }) => ( +
+ {children} +
+ )} +
); } - } - let confirmation: JSX.Element | undefined; - if (confirmationType === undefined) { - // no-op - } else if (confirmationType === ConfirmationType.EditError) { - confirmation = ( - { - retryMessageSend(id); - setConfirmationType(undefined); - }, - style: 'negative', - text: i18n('icu:ResendMessageEdit__button'), - }, - ]} - i18n={i18n} - onClose={() => { - setConfirmationType(undefined); - }} - > - {i18n('icu:ResendMessageEdit__body')} - - ); - } else { - throw missingCaseError(confirmationType); - } - - const className = classNames( - 'module-message__metadata', - isInline && 'module-message__metadata--inline', - withImageNoCaption && 'module-message__metadata--with-image-no-caption', - deletedForEveryone && 'module-message__metadata--deleted-for-everyone' - ); - const children = ( - <> - {isEditedMessage && showEditHistoryModal && ( - - )} - {timestampNode} - {expirationLength ? ( - - ) : null} - {textPending ? ( -
- -
- ) : null} - {(!deletedForEveryone || status === 'sending') && - !textPending && - direction === 'outgoing' && - status !== 'error' && - status !== 'partial-sent' ? ( -
- ) : null} - {confirmation} - - ); - - const onResize = useCallback( - ({ bounds }: ContentRect) => { - onWidthMeasured?.(bounds?.width || 0); - }, - [onWidthMeasured] - ); - - if (onWidthMeasured) { return ( - - {({ measureRef }) => ( -
- {children} -
- )} -
+
+ {children} +
); } - - return
{children}
; -} +); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 5291b3238..6b2dac2d9 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2391,6 +2391,14 @@ "updated": "2021-03-05T19:57:01.431Z", "reasonDetail": "Used for propagating click from the Message to MessageAudio's button" }, + { + "rule": "React-createRef", + "path": "ts/components/conversation/Message.tsx", + "line": " private metadataRef: React.RefObject = React.createRef();", + "reasonCategory": "usageTrusted", + "updated": "2023-06-30T22:12:49.259Z", + "reasonDetail": "Used for excluding the message metadata from triple-click selections." + }, { "rule": "React-useRef", "path": "ts/components/conversation/MessageDetail.tsx",