This commit is contained in:
Scott Nonnenberg 2023-07-06 09:55:35 -07:00
commit b292568604
3 changed files with 234 additions and 198 deletions

View file

@ -193,6 +193,7 @@ export enum GiftBadgeStates {
Opened = 'Opened', Opened = 'Opened',
Redeemed = 'Redeemed', Redeemed = 'Redeemed',
} }
export type GiftBadgeType = { export type GiftBadgeType = {
expiration: number; expiration: number;
id: string | undefined; id: string | undefined;
@ -390,6 +391,8 @@ export class Message extends React.PureComponent<Props, State> {
current: false, current: false,
}; };
private metadataRef: React.RefObject<HTMLDivElement> = React.createRef();
public reactionsContainerRefMerger = createRefMerger(); public reactionsContainerRefMerger = createRefMerger();
public expirationCheckInterval: NodeJS.Timeout | undefined; public expirationCheckInterval: NodeJS.Timeout | undefined;
@ -823,6 +826,7 @@ export class Message extends React.PureComponent<Props, State> {
isTapToViewExpired={isTapToViewExpired} isTapToViewExpired={isTapToViewExpired}
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined} onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
ref={this.metadataRef}
retryMessageSend={retryMessageSend} retryMessageSend={retryMessageSend}
showEditHistoryModal={showEditHistoryModal} showEditHistoryModal={showEditHistoryModal}
status={status} status={status}
@ -1779,7 +1783,7 @@ export class Message extends React.PureComponent<Props, State> {
} }
return ( return (
<div <div // eslint-disable-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
className={classNames( className={classNames(
'module-message__text', 'module-message__text',
`module-message__text--${direction}`, `module-message__text--${direction}`,
@ -1790,6 +1794,18 @@ export class Message extends React.PureComponent<Props, State> {
? 'module-message__text--delete-for-everyone' ? 'module-message__text--delete-for-everyone'
: null : 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) => { onDoubleClick={(event: React.MouseEvent) => {
// Prevent double-click interefering with interactions _inside_ // Prevent double-click interefering with interactions _inside_
// the bubble. // the bubble.

View file

@ -1,8 +1,8 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactChild, ReactElement } from 'react'; import type { ReactChild } from 'react';
import React, { useCallback, useState } from 'react'; import React, { forwardRef, useCallback, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ContentRect } from 'react-measure'; import type { ContentRect } from 'react-measure';
import Measure from 'react-measure'; import Measure from 'react-measure';
@ -16,6 +16,7 @@ import { MessageTimestamp } from './MessageTimestamp';
import { PanelType } from '../../types/Panels'; import { PanelType } from '../../types/Panels';
import { Spinner } from '../Spinner'; import { Spinner } from '../Spinner';
import { ConfirmationDialog } from '../ConfirmationDialog'; import { ConfirmationDialog } from '../ConfirmationDialog';
import { refMerger } from '../../util/refMerger';
type PropsType = { type PropsType = {
deletedForEveryone?: boolean; deletedForEveryone?: boolean;
@ -43,46 +44,72 @@ enum ConfirmationType {
EditError = 'EditError', EditError = 'EditError',
} }
export function MessageMetadata({ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
deletedForEveryone, function MessageMetadataInner(
direction, {
expirationLength, deletedForEveryone,
expirationTimestamp, direction,
hasText, expirationLength,
i18n, expirationTimestamp,
id, hasText,
isEditedMessage, i18n,
isInline, id,
isShowingImage, isEditedMessage,
isSticker, isInline,
isTapToViewExpired, isShowingImage,
onWidthMeasured, isSticker,
pushPanelForConversation, isTapToViewExpired,
retryMessageSend, onWidthMeasured,
showEditHistoryModal, pushPanelForConversation,
status, retryMessageSend,
textPending, showEditHistoryModal,
timestamp, status,
}: Readonly<PropsType>): ReactElement { textPending,
const [confirmationType, setConfirmationType] = useState< timestamp,
ConfirmationType | undefined },
>(); ref
const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage); ) {
const metadataDirection = isSticker ? undefined : direction; const [confirmationType, setConfirmationType] = useState<
ConfirmationType | undefined
>();
const withImageNoCaption = Boolean(
!isSticker && !hasText && isShowingImage
);
const metadataDirection = isSticker ? undefined : direction;
let timestampNode: ReactChild; let timestampNode: ReactChild;
{ {
const isError = status === 'error' && direction === 'outgoing'; const isError = status === 'error' && direction === 'outgoing';
const isPartiallySent = const isPartiallySent =
status === 'partial-sent' && direction === 'outgoing'; status === 'partial-sent' && direction === 'outgoing';
const isPaused = status === 'paused'; const isPaused = status === 'paused';
if (isError || isPartiallySent || isPaused) { if (isError || isPartiallySent || isPaused) {
let statusInfo: React.ReactChild; let statusInfo: React.ReactChild;
if (isError) { if (isError) {
if (deletedForEveryone) { if (deletedForEveryone) {
statusInfo = i18n('icu:deleteFailed'); statusInfo = i18n('icu:deleteFailed');
} else if (isEditedMessage) { } else if (isEditedMessage) {
statusInfo = (
<button
type="button"
className="module-message__metadata__tapable"
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
setConfirmationType(ConfirmationType.EditError);
}}
>
{i18n('icu:editFailed')}
</button>
);
} else {
statusInfo = i18n('icu:sendFailed');
}
} else if (isPaused) {
statusInfo = i18n('icu:sendPaused');
} else {
statusInfo = ( statusInfo = (
<button <button
type="button" type="button"
@ -91,179 +118,164 @@ export function MessageMetadata({
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
setConfirmationType(ConfirmationType.EditError); pushPanelForConversation({
type: PanelType.MessageDetails,
args: { messageId: id },
});
}} }}
> >
{i18n('icu:editFailed')} {deletedForEveryone
? i18n('icu:partiallyDeleted')
: i18n('icu:partiallySent')}
</button> </button>
); );
} else {
statusInfo = i18n('icu:sendFailed');
} }
} else if (isPaused) {
statusInfo = i18n('icu:sendPaused');
} else {
statusInfo = (
<button
type="button"
className="module-message__metadata__tapable"
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
pushPanelForConversation({ timestampNode = (
type: PanelType.MessageDetails, <span
args: { messageId: id }, className={classNames({
}); 'module-message__metadata__date': true,
}} 'module-message__metadata__date--with-sticker': isSticker,
'module-message__metadata__date--deleted-for-everyone':
deletedForEveryone,
[`module-message__metadata__date--${direction}`]: !isSticker,
'module-message__metadata__date--with-image-no-caption':
withImageNoCaption,
})}
> >
{deletedForEveryone {statusInfo}
? i18n('icu:partiallyDeleted') </span>
: i18n('icu:partiallySent')} );
</button> } else {
timestampNode = (
<MessageTimestamp
deletedForEveryone={deletedForEveryone}
direction={metadataDirection}
i18n={i18n}
module="module-message__metadata__date"
timestamp={timestamp}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
withTapToViewExpired={isTapToViewExpired}
/>
); );
} }
}
timestampNode = ( let confirmation: JSX.Element | undefined;
<span if (confirmationType === undefined) {
className={classNames({ // no-op
'module-message__metadata__date': true, } else if (confirmationType === ConfirmationType.EditError) {
'module-message__metadata__date--with-sticker': isSticker, confirmation = (
'module-message__metadata__date--deleted-for-everyone': <ConfirmationDialog
deletedForEveryone, dialogName="MessageMetadata.confirmEditResend"
[`module-message__metadata__date--${direction}`]: !isSticker, actions={[
'module-message__metadata__date--with-image-no-caption': {
withImageNoCaption, action: () => {
})} retryMessageSend(id);
setConfirmationType(undefined);
},
style: 'negative',
text: i18n('icu:ResendMessageEdit__button'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmationType(undefined);
}}
> >
{statusInfo} {i18n('icu:ResendMessageEdit__body')}
</span> </ConfirmationDialog>
); );
} else { } else {
timestampNode = ( throw missingCaseError(confirmationType);
<MessageTimestamp }
deletedForEveryone={deletedForEveryone}
direction={metadataDirection} const className = classNames(
i18n={i18n} 'module-message__metadata',
module="module-message__metadata__date" isInline && 'module-message__metadata--inline',
timestamp={timestamp} withImageNoCaption && 'module-message__metadata--with-image-no-caption',
withImageNoCaption={withImageNoCaption} deletedForEveryone && 'module-message__metadata--deleted-for-everyone'
withSticker={isSticker} );
withTapToViewExpired={isTapToViewExpired} const children = (
/> <>
{isEditedMessage && showEditHistoryModal && (
<button
className="module-message__metadata__edited"
onClick={() => showEditHistoryModal(id)}
type="button"
>
{i18n('icu:MessageMetadata__edited')}
</button>
)}
{timestampNode}
{expirationLength ? (
<ExpireTimer
direction={metadataDirection}
deletedForEveryone={deletedForEveryone}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
withTapToViewExpired={isTapToViewExpired}
/>
) : null}
{textPending ? (
<div className="module-message__metadata__spinner-container">
<Spinner svgSize="small" size="14px" direction={direction} />
</div>
) : null}
{(!deletedForEveryone || status === 'sending') &&
!textPending &&
direction === 'outgoing' &&
status !== 'error' &&
status !== 'partial-sent' ? (
<div
className={classNames(
'module-message__metadata__status-icon',
`module-message__metadata__status-icon--${status}`,
isSticker
? 'module-message__metadata__status-icon--with-sticker'
: null,
withImageNoCaption
? 'module-message__metadata__status-icon--with-image-no-caption'
: null,
deletedForEveryone
? 'module-message__metadata__status-icon--deleted-for-everyone'
: null,
isTapToViewExpired
? 'module-message__metadata__status-icon--with-tap-to-view-expired'
: null
)}
/>
) : null}
{confirmation}
</>
);
const onResize = useCallback(
({ bounds }: ContentRect) => {
onWidthMeasured?.(bounds?.width || 0);
},
[onWidthMeasured]
);
if (onWidthMeasured) {
return (
<Measure bounds onResize={onResize}>
{({ measureRef }) => (
<div className={className} ref={refMerger(measureRef, ref)}>
{children}
</div>
)}
</Measure>
); );
} }
}
let confirmation: JSX.Element | undefined;
if (confirmationType === undefined) {
// no-op
} else if (confirmationType === ConfirmationType.EditError) {
confirmation = (
<ConfirmationDialog
dialogName="MessageMetadata.confirmEditResend"
actions={[
{
action: () => {
retryMessageSend(id);
setConfirmationType(undefined);
},
style: 'negative',
text: i18n('icu:ResendMessageEdit__button'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmationType(undefined);
}}
>
{i18n('icu:ResendMessageEdit__body')}
</ConfirmationDialog>
);
} 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 && (
<button
className="module-message__metadata__edited"
onClick={() => showEditHistoryModal(id)}
type="button"
>
{i18n('icu:MessageMetadata__edited')}
</button>
)}
{timestampNode}
{expirationLength ? (
<ExpireTimer
direction={metadataDirection}
deletedForEveryone={deletedForEveryone}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
withTapToViewExpired={isTapToViewExpired}
/>
) : null}
{textPending ? (
<div className="module-message__metadata__spinner-container">
<Spinner svgSize="small" size="14px" direction={direction} />
</div>
) : null}
{(!deletedForEveryone || status === 'sending') &&
!textPending &&
direction === 'outgoing' &&
status !== 'error' &&
status !== 'partial-sent' ? (
<div
className={classNames(
'module-message__metadata__status-icon',
`module-message__metadata__status-icon--${status}`,
isSticker
? 'module-message__metadata__status-icon--with-sticker'
: null,
withImageNoCaption
? 'module-message__metadata__status-icon--with-image-no-caption'
: null,
deletedForEveryone
? 'module-message__metadata__status-icon--deleted-for-everyone'
: null,
isTapToViewExpired
? 'module-message__metadata__status-icon--with-tap-to-view-expired'
: null
)}
/>
) : null}
{confirmation}
</>
);
const onResize = useCallback(
({ bounds }: ContentRect) => {
onWidthMeasured?.(bounds?.width || 0);
},
[onWidthMeasured]
);
if (onWidthMeasured) {
return ( return (
<Measure bounds onResize={onResize}> <div className={className} ref={ref}>
{({ measureRef }) => ( {children}
<div className={className} ref={measureRef}> </div>
{children}
</div>
)}
</Measure>
); );
} }
);
return <div className={className}>{children}</div>;
}

View file

@ -2391,6 +2391,14 @@
"updated": "2021-03-05T19:57:01.431Z", "updated": "2021-03-05T19:57:01.431Z",
"reasonDetail": "Used for propagating click from the Message to MessageAudio's button" "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<HTMLDivElement> = 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", "rule": "React-useRef",
"path": "ts/components/conversation/MessageDetail.tsx", "path": "ts/components/conversation/MessageDetail.tsx",