This commit is contained in:
commit
b292568604
3 changed files with 234 additions and 198 deletions
|
@ -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.
|
||||||
|
|
|
@ -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>;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue