signal-desktop/ts/components/conversation/Message.tsx

2211 lines
60 KiB
TypeScript
Raw Normal View History

import React from 'react';
2020-01-17 22:23:19 +00:00
import ReactDOM, { createPortal } from 'react-dom';
import classNames from 'classnames';
2020-01-17 22:23:19 +00:00
import Measure from 'react-measure';
import { drop, groupBy, orderBy, take } from 'lodash';
2020-09-14 19:51:27 +00:00
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
2020-01-17 22:23:19 +00:00
import { Manager, Popper, Reference } from 'react-popper';
import { Avatar } from '../Avatar';
import { Spinner } from '../Spinner';
import { MessageBody } from './MessageBody';
2019-01-14 21:49:58 +00:00
import { ExpireTimer } from './ExpireTimer';
import { ImageGrid } from './ImageGrid';
import { Image } from './Image';
import { Timestamp } from './Timestamp';
import { ContactName } from './ContactName';
import { Quote, QuotedAttachmentType } from './Quote';
import { EmbeddedContact } from './EmbeddedContact';
2020-01-17 22:23:19 +00:00
import {
OwnProps as ReactionViewerProps,
ReactionViewer,
} from './ReactionViewer';
import { Props as ReactionPickerProps, ReactionPicker } from './ReactionPicker';
2020-01-17 22:23:19 +00:00
import { Emoji } from '../emoji/Emoji';
2020-09-28 23:46:31 +00:00
import { LinkPreviewDate } from './LinkPreviewDate';
2019-01-14 21:49:58 +00:00
import {
2020-09-14 19:51:27 +00:00
AttachmentType,
2019-01-14 21:49:58 +00:00
canDisplayImage,
getExtensionForDisplay,
getGridDimensions,
2019-01-16 03:03:56 +00:00
getImageDimensions,
hasImage,
hasVideoScreenshot,
2019-01-14 21:49:58 +00:00
isAudio,
isImage,
2019-01-16 03:03:56 +00:00
isImageAttachment,
isVideo,
2020-09-14 19:51:27 +00:00
} from '../../types/Attachment';
import { ContactType } from '../../types/Contact';
2019-01-14 21:49:58 +00:00
import { getIncrement } from '../../util/timer';
2018-10-04 01:12:42 +00:00
import { isFileDangerous } from '../../util/isFileDangerous';
2020-09-16 22:42:48 +00:00
import { BodyRangesType, LocalizerType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
2020-03-23 21:09:12 +00:00
import { createRefMerger } from '../_util';
interface Trigger {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
}
2019-01-16 03:03:56 +00:00
// Same as MIN_WIDTH in ImageGrid.tsx
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
const STICKER_SIZE = 200;
const SELECTED_TIMEOUT = 1000;
const THREE_HOURS = 3 * 60 * 60 * 1000;
2019-01-16 03:03:56 +00:00
interface LinkPreviewType {
title: string;
description?: string;
2019-01-16 03:03:56 +00:00
domain: string;
url: string;
isStickerPack: boolean;
2019-01-16 03:03:56 +00:00
image?: AttachmentType;
date?: number;
2019-01-16 03:03:56 +00:00
}
2020-08-27 18:10:35 +00:00
export const MessageStatuses = [
2020-08-27 16:57:12 +00:00
'delivered',
'error',
'partial-sent',
'read',
'sending',
'sent',
] as const;
2020-08-27 18:10:35 +00:00
export type MessageStatusType = typeof MessageStatuses[number];
export const InteractionModes = ['mouse', 'keyboard'] as const;
export type InteractionModeType = typeof InteractionModes[number];
export const Directions = ['incoming', 'outgoing'] as const;
export type DirectionType = typeof Directions[number];
export const ConversationTypes = ['direct', 'group'] as const;
export type ConversationTypesType = typeof ConversationTypes[number];
2020-08-27 16:57:12 +00:00
export type PropsData = {
id: string;
2019-11-07 21:36:16 +00:00
conversationId: string;
text?: string;
textPending?: boolean;
isSticker?: boolean;
isSelected?: boolean;
isSelectedCounter?: number;
2020-08-27 18:10:35 +00:00
interactionMode: InteractionModeType;
direction: DirectionType;
timestamp: number;
2020-08-27 18:10:35 +00:00
status?: MessageStatusType;
contact?: ContactType;
2020-07-24 01:35:32 +00:00
authorTitle: string;
authorName?: string;
authorProfileName?: string;
2020-07-24 01:35:32 +00:00
authorPhoneNumber?: string;
2019-01-14 21:49:58 +00:00
authorColor?: ColorType;
2020-08-27 18:10:35 +00:00
conversationType: ConversationTypesType;
attachments?: Array<AttachmentType>;
quote?: {
text: string;
attachment?: QuotedAttachmentType;
isFromMe: boolean;
sentAt: number;
authorId: string;
2020-07-24 01:35:32 +00:00
authorPhoneNumber?: string;
authorProfileName?: string;
2020-07-24 01:35:32 +00:00
authorTitle: string;
authorName?: string;
2019-01-14 21:49:58 +00:00
authorColor?: ColorType;
2020-09-16 22:42:48 +00:00
bodyRanges?: BodyRangesType;
referencedMessageNotFound: boolean;
};
2019-01-16 03:03:56 +00:00
previews: Array<LinkPreviewType>;
authorAvatarPath?: string;
isExpired?: boolean;
2019-06-26 19:33:13 +00:00
isTapToView?: boolean;
isTapToViewExpired?: boolean;
isTapToViewError?: boolean;
expirationLength?: number;
expirationTimestamp?: number;
2020-01-17 22:23:19 +00:00
reactions?: ReactionViewerProps['reactions'];
2020-01-23 23:57:37 +00:00
selectedReaction?: string;
2020-04-29 21:24:12 +00:00
deletedForEveryone?: boolean;
canReply: boolean;
canDeleteForEveryone: boolean;
2020-09-16 22:42:48 +00:00
bodyRanges?: BodyRangesType;
};
export type PropsHousekeeping = {
i18n: LocalizerType;
disableMenu?: boolean;
disableScroll?: boolean;
collapseMetadata?: boolean;
};
export type PropsActions = {
clearSelectedMessage: () => unknown;
2020-01-23 23:57:37 +00:00
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
) => void;
replyToMessage: (id: string) => void;
retrySend: (id: string) => void;
deleteMessage: (id: string) => void;
deleteMessageForEveryone: (id: string) => void;
showMessageDetail: (id: string) => void;
openConversation: (conversationId: string, messageId?: string) => void;
2020-01-08 17:44:54 +00:00
showContactDetail: (options: {
contact: ContactType;
signalAccount?: string;
}) => void;
showVisualAttachment: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
downloadAttachment: (options: {
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
}) => void;
2019-06-26 19:33:13 +00:00
displayTapToViewMessage: (messageId: string) => unknown;
openLink: (url: string) => void;
2020-01-08 17:44:54 +00:00
scrollToQuotedMessage: (options: { author: string; sentAt: number }) => void;
selectMessage?: (messageId: string, conversationId: string) => unknown;
showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown;
};
export type Props = PropsData &
PropsHousekeeping &
PropsActions &
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
interface State {
expiring: boolean;
expired: boolean;
imageBroken: boolean;
isSelected?: boolean;
prevSelectedCounter?: number;
2020-01-17 22:23:19 +00:00
reactionViewerRoot: HTMLDivElement | null;
2020-01-23 23:57:37 +00:00
reactionPickerRoot: HTMLDivElement | null;
isWide: boolean;
containerWidth: number;
canDeleteForEveryone: boolean;
}
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
2019-01-14 21:49:58 +00:00
export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined;
2020-09-14 19:51:27 +00:00
2019-11-07 21:36:16 +00:00
public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();
2020-09-14 19:51:27 +00:00
2020-01-17 22:23:19 +00:00
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
2020-09-14 19:51:27 +00:00
2020-01-17 22:23:19 +00:00
public reactionsContainerRef: React.RefObject<
HTMLDivElement
> = React.createRef();
2020-09-14 19:51:27 +00:00
2020-03-23 21:09:12 +00:00
public reactionsContainerRefMerger = createRefMerger();
2019-11-07 21:36:16 +00:00
2020-01-23 23:57:37 +00:00
public wideMl: MediaQueryList;
2020-09-14 19:51:27 +00:00
public expirationCheckInterval: NodeJS.Timeout | undefined;
public expiredTimeout: NodeJS.Timeout | undefined;
public selectedTimeout: NodeJS.Timeout | undefined;
public deleteForEveryoneTimeout: NodeJS.Timeout | undefined;
public constructor(props: Props) {
super(props);
const { canDeleteForEveryone } = props;
2020-01-23 23:57:37 +00:00
this.wideMl = window.matchMedia('(min-width: 926px)');
this.wideMl.addEventListener('change', this.handleWideMlChange);
this.state = {
expiring: false,
expired: false,
imageBroken: false,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
2020-01-17 22:23:19 +00:00
reactionViewerRoot: null,
2020-01-23 23:57:37 +00:00
reactionPickerRoot: null,
isWide: this.wideMl.matches,
containerWidth: 0,
canDeleteForEveryone,
};
}
public static getDerivedStateFromProps(props: Props, state: State): State {
if (!props.isSelected) {
return {
...state,
isSelected: false,
prevSelectedCounter: 0,
};
}
if (
props.isSelected &&
props.isSelectedCounter !== state.prevSelectedCounter
) {
return {
...state,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
};
}
return state;
}
2020-09-14 19:51:27 +00:00
public handleWideMlChange = (event: MediaQueryListEvent): void => {
2020-01-23 23:57:37 +00:00
this.setState({ isWide: event.matches });
};
2020-09-14 19:51:27 +00:00
public captureMenuTrigger = (triggerRef: Trigger): void => {
2019-11-07 21:36:16 +00:00
this.menuTriggerRef = triggerRef;
};
2020-09-14 19:51:27 +00:00
public showMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
2019-11-07 21:36:16 +00:00
if (this.menuTriggerRef) {
this.menuTriggerRef.handleContextClick(event);
}
};
2020-09-14 19:51:27 +00:00
public handleImageError = (): void => {
2019-11-07 21:36:16 +00:00
const { id } = this.props;
2020-09-14 19:51:27 +00:00
window.log.info(
2019-11-07 21:36:16 +00:00
`Message ${id}: Image failed to load; failing over to placeholder`
);
this.setState({
imageBroken: true,
});
};
2020-09-14 19:51:27 +00:00
public handleFocus = (): void => {
const { interactionMode } = this.props;
if (interactionMode === 'keyboard') {
this.setSelected();
}
};
2020-09-14 19:51:27 +00:00
public setSelected = (): void => {
2019-11-07 21:36:16 +00:00
const { id, conversationId, selectMessage } = this.props;
if (selectMessage) {
selectMessage(id, conversationId);
}
2019-11-07 21:36:16 +00:00
};
2020-09-14 19:51:27 +00:00
public setFocus = (): void => {
const container = this.focusRef.current;
if (container && !container.contains(document.activeElement)) {
container.focus();
2019-11-07 21:36:16 +00:00
}
};
2020-09-14 19:51:27 +00:00
public componentDidMount(): void {
this.startSelectedTimer();
this.startDeleteForEveryoneTimer();
2019-11-07 21:36:16 +00:00
const { isSelected } = this.props;
if (isSelected) {
this.setFocus();
}
const { expirationLength } = this.props;
if (!expirationLength) {
return;
}
const increment = getIncrement(expirationLength);
const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
this.checkExpired();
this.expirationCheckInterval = setInterval(() => {
this.checkExpired();
}, checkFrequency);
}
2020-09-14 19:51:27 +00:00
public componentWillUnmount(): void {
if (this.selectedTimeout) {
clearInterval(this.selectedTimeout);
}
if (this.expirationCheckInterval) {
clearInterval(this.expirationCheckInterval);
}
if (this.expiredTimeout) {
clearTimeout(this.expiredTimeout);
}
if (this.deleteForEveryoneTimeout) {
clearTimeout(this.deleteForEveryoneTimeout);
}
2020-01-17 22:23:19 +00:00
this.toggleReactionViewer(true);
2020-01-23 23:57:37 +00:00
this.toggleReactionPicker(true);
this.wideMl.removeEventListener('change', this.handleWideMlChange);
}
2020-09-14 19:51:27 +00:00
public componentDidUpdate(prevProps: Props): void {
const { canDeleteForEveryone, isSelected } = this.props;
2020-09-14 19:51:27 +00:00
this.startSelectedTimer();
2020-09-14 19:51:27 +00:00
if (!prevProps.isSelected && isSelected) {
2019-11-07 21:36:16 +00:00
this.setFocus();
}
2019-11-07 21:36:16 +00:00
this.checkExpired();
if (canDeleteForEveryone !== prevProps.canDeleteForEveryone) {
this.startDeleteForEveryoneTimer();
}
}
2020-09-14 19:51:27 +00:00
public startSelectedTimer(): void {
const { clearSelectedMessage, interactionMode } = this.props;
const { isSelected } = this.state;
if (interactionMode === 'keyboard' || !isSelected) {
return;
}
if (!this.selectedTimeout) {
this.selectedTimeout = setTimeout(() => {
this.selectedTimeout = undefined;
this.setState({ isSelected: false });
2020-09-14 19:51:27 +00:00
clearSelectedMessage();
}, SELECTED_TIMEOUT);
}
}
public startDeleteForEveryoneTimer(): void {
if (this.deleteForEveryoneTimeout) {
clearTimeout(this.deleteForEveryoneTimeout);
}
const { canDeleteForEveryone } = this.props;
if (!canDeleteForEveryone) {
return;
}
const { timestamp } = this.props;
const timeToDeletion = timestamp - Date.now() + THREE_HOURS;
if (timeToDeletion <= 0) {
this.setState({ canDeleteForEveryone: false });
} else {
this.deleteForEveryoneTimeout = setTimeout(() => {
this.setState({ canDeleteForEveryone: false });
}, timeToDeletion);
}
}
2020-09-14 19:51:27 +00:00
public checkExpired(): void {
const now = Date.now();
const { isExpired, expirationTimestamp, expirationLength } = this.props;
if (!expirationTimestamp || !expirationLength) {
return;
}
if (this.expiredTimeout) {
return;
}
if (isExpired || now >= expirationTimestamp) {
this.setState({
expiring: true,
});
const setExpired = () => {
this.setState({
expired: true,
});
};
this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY);
}
}
2020-09-14 19:51:27 +00:00
public renderTimestamp(): JSX.Element {
2020-08-07 01:22:52 +00:00
const {
direction,
i18n,
id,
isSticker,
isTapToViewExpired,
showMessageDetail,
status,
text,
timestamp,
} = this.props;
const isShowingImage = this.isShowingImage();
const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage);
const isError = status === 'error' && direction === 'outgoing';
const isPartiallySent =
status === 'partial-sent' && direction === 'outgoing';
if (isError || isPartiallySent) {
return (
<span
className={classNames({
'module-message__metadata__date': true,
'module-message__metadata__date--with-sticker': isSticker,
[`module-message__metadata__date--${direction}`]: !isSticker,
'module-message__metadata__date--with-image-no-caption': withImageNoCaption,
})}
>
{isError ? (
i18n('sendFailed')
) : (
<button
2020-09-14 19:51:27 +00:00
type="button"
2020-08-07 01:22:52 +00:00
className="module-message__metadata__tapable"
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showMessageDetail(id);
}}
>
{i18n('partiallySent')}
</button>
)}
</span>
);
}
const metadataDirection = isSticker ? undefined : direction;
return (
<Timestamp
i18n={i18n}
timestamp={timestamp}
2020-09-14 19:51:27 +00:00
extended
2020-08-07 01:22:52 +00:00
direction={metadataDirection}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
withTapToViewExpired={isTapToViewExpired}
module="module-message__metadata__date"
/>
);
}
2020-09-14 19:51:27 +00:00
public renderMetadata(): JSX.Element | null {
const {
collapseMetadata,
direction,
expirationLength,
expirationTimestamp,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToViewExpired,
reactions,
status,
text,
textPending,
} = this.props;
if (collapseMetadata) {
return null;
}
2019-01-16 03:03:56 +00:00
const isShowingImage = this.isShowingImage();
const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage);
const withReactions = reactions && reactions.length > 0;
const metadataDirection = isSticker ? undefined : direction;
return (
<div
className={classNames(
'module-message__metadata',
`module-message__metadata--${direction}`,
withReactions ? 'module-message__metadata--with-reactions' : null,
withImageNoCaption
? 'module-message__metadata--with-image-no-caption'
: null
)}
>
2020-08-07 01:22:52 +00:00
{this.renderTimestamp()}
{expirationLength && expirationTimestamp ? (
<ExpireTimer
direction={metadataDirection}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
2019-06-26 19:33:13 +00:00
withTapToViewExpired={isTapToViewExpired}
/>
) : null}
{textPending ? (
<div className="module-message__metadata__spinner-container">
2019-06-26 19:33:13 +00:00
<Spinner svgSize="small" size="14px" direction={direction} />
</div>
) : null}
2020-08-07 01:22:52 +00:00
{!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'
2019-06-26 19:33:13 +00:00
: null,
isTapToViewExpired
? 'module-message__metadata__status-icon--with-tap-to-view-expired'
: null
)}
/>
) : null}
</div>
);
}
2020-09-14 19:51:27 +00:00
public renderAuthor(): JSX.Element | null {
const {
2020-07-24 01:35:32 +00:00
authorTitle,
authorName,
authorPhoneNumber,
authorProfileName,
collapseMetadata,
conversationType,
direction,
2020-07-24 01:35:32 +00:00
i18n,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToView,
isTapToViewExpired,
} = this.props;
if (collapseMetadata) {
2020-09-14 19:51:27 +00:00
return null;
}
2020-07-24 01:35:32 +00:00
if (
direction !== 'incoming' ||
conversationType !== 'group' ||
!authorTitle
) {
return null;
}
2019-06-26 19:33:13 +00:00
const withTapToViewExpired = isTapToView && isTapToViewExpired;
const stickerSuffix = isSticker ? '_with_sticker' : '';
const tapToViewSuffix = withTapToViewExpired
? '--with-tap-to-view-expired'
: '';
const moduleName = `module-message__author${stickerSuffix}${tapToViewSuffix}`;
return (
<div className={moduleName}>
<ContactName
2020-07-24 01:35:32 +00:00
title={authorTitle}
phoneNumber={authorPhoneNumber}
name={authorName}
profileName={authorProfileName}
module={moduleName}
2020-07-24 01:35:32 +00:00
i18n={i18n}
/>
</div>
);
}
2020-09-14 19:51:27 +00:00
public renderAttachment(): JSX.Element | null {
const {
attachments,
collapseMetadata,
conversationType,
direction,
i18n,
id,
quote,
showVisualAttachment,
isSticker,
text,
} = this.props;
2019-11-07 21:36:16 +00:00
const { imageBroken } = this.state;
if (!attachments || !attachments[0]) {
return null;
}
const firstAttachment = attachments[0];
// For attachments which aren't full-frame
const withContentBelow = Boolean(text);
const withContentAbove =
Boolean(quote) ||
(conversationType === 'group' && direction === 'incoming');
const displayImage = canDisplayImage(attachments);
if (
displayImage &&
!imageBroken &&
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
) {
const prefix = isSticker ? 'sticker' : 'attachment';
const bottomOverlay = !isSticker && !collapseMetadata;
2019-11-07 21:36:16 +00:00
// We only want users to tab into this if there's more than one
const tabIndex = attachments.length > 1 ? 0 : -1;
return (
<div
className={classNames(
`module-message__${prefix}-container`,
withContentAbove
? `module-message__${prefix}-container--with-content-above`
: null,
withContentBelow
? 'module-message__attachment-container--with-content-below'
: null,
isSticker && !collapseMetadata
? 'module-message__sticker-container--with-content-below'
: null
)}
>
<ImageGrid
attachments={attachments}
withContentAbove={isSticker || withContentAbove}
withContentBelow={isSticker || withContentBelow}
isSticker={isSticker}
stickerSize={STICKER_SIZE}
bottomOverlay={bottomOverlay}
i18n={i18n}
2019-11-07 21:36:16 +00:00
onError={this.handleImageError}
tabIndex={tabIndex}
onClick={attachment => {
showVisualAttachment({ attachment, messageId: id });
}}
/>
</div>
);
2020-09-14 19:51:27 +00:00
}
if (!firstAttachment.pending && isAudio(attachments)) {
return (
<audio
2019-11-07 21:36:16 +00:00
ref={this.audioRef}
2020-09-14 19:51:27 +00:00
controls
className={classNames(
'module-message__audio-attachment',
withContentBelow
? 'module-message__audio-attachment--with-content-below'
: null,
withContentAbove
? 'module-message__audio-attachment--with-content-above'
: null
)}
key={firstAttachment.url}
>
<source src={firstAttachment.url} />
</audio>
);
2020-09-14 19:51:27 +00:00
}
const { pending, fileName, fileSize, contentType } = firstAttachment;
const extension = getExtensionForDisplay({ contentType, fileName });
const isDangerous = isFileDangerous(fileName || '');
2020-09-14 19:51:27 +00:00
return (
<button
type="button"
className={classNames(
'module-message__generic-attachment',
withContentBelow
? 'module-message__generic-attachment--with-content-below'
: null,
withContentAbove
? 'module-message__generic-attachment--with-content-above'
: null,
!firstAttachment.url
? 'module-message__generic-attachment--not-active'
: null
)}
// There's only ever one of these, so we don't want users to tab into it
tabIndex={-1}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
2019-11-07 21:36:16 +00:00
2020-09-14 19:51:27 +00:00
if (!firstAttachment.url) {
return;
}
2020-05-27 21:37:06 +00:00
2020-09-14 19:51:27 +00:00
this.openGenericAttachment();
}}
>
{pending ? (
<div className="module-message__generic-attachment__spinner-container">
<Spinner svgSize="small" size="24px" direction={direction} />
</div>
) : (
<div className="module-message__generic-attachment__icon-container">
<div className="module-message__generic-attachment__icon">
{extension ? (
<div className="module-message__generic-attachment__icon__extension">
{extension}
2018-10-04 01:12:42 +00:00
</div>
) : null}
</div>
2020-09-14 19:51:27 +00:00
{isDangerous ? (
<div className="module-message__generic-attachment__icon-dangerous-container">
<div className="module-message__generic-attachment__icon-dangerous" />
</div>
) : null}
</div>
2020-09-14 19:51:27 +00:00
)}
<div className="module-message__generic-attachment__text">
<div
className={classNames(
'module-message__generic-attachment__file-name',
`module-message__generic-attachment__file-name--${direction}`
)}
>
{fileName}
</div>
<div
className={classNames(
'module-message__generic-attachment__file-size',
`module-message__generic-attachment__file-size--${direction}`
)}
>
{fileSize}
</div>
</div>
</button>
);
}
2020-09-14 19:51:27 +00:00
public renderPreview(): JSX.Element | null {
2019-01-16 03:03:56 +00:00
const {
attachments,
conversationType,
direction,
i18n,
openLink,
2019-01-16 03:03:56 +00:00
previews,
quote,
} = this.props;
// Attachments take precedence over Link Previews
if (attachments && attachments.length) {
return null;
}
if (!previews || previews.length < 1) {
return null;
}
const first = previews[0];
if (!first) {
return null;
}
const withContentAbove =
Boolean(quote) ||
(conversationType === 'group' && direction === 'incoming');
const previewHasImage = first.image && isImageAttachment(first.image);
const width = first.image && first.image.width;
const isFullSizeImage =
!first.isStickerPack &&
width &&
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
2019-01-16 03:03:56 +00:00
2020-09-28 23:46:31 +00:00
const linkPreviewDate = first.date || null;
2019-01-16 03:03:56 +00:00
return (
2019-11-07 21:36:16 +00:00
<button
2020-09-14 19:51:27 +00:00
type="button"
2019-01-16 03:03:56 +00:00
className={classNames(
'module-message__link-preview',
`module-message__link-preview--${direction}`,
2019-01-16 03:03:56 +00:00
withContentAbove
? 'module-message__link-preview--with-content-above'
: null
)}
2019-11-07 21:36:16 +00:00
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation();
event.preventDefault();
openLink(first.url);
}
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
openLink(first.url);
2019-01-16 03:03:56 +00:00
}}
>
{first.image && previewHasImage && isFullSizeImage ? (
<ImageGrid
attachments={[first.image]}
withContentAbove={withContentAbove}
2020-09-14 19:51:27 +00:00
withContentBelow
2019-11-07 21:36:16 +00:00
onError={this.handleImageError}
2019-01-16 03:03:56 +00:00
i18n={i18n}
/>
) : null}
<div
className={classNames(
'module-message__link-preview__content',
withContentAbove || isFullSizeImage
? 'module-message__link-preview__content--with-content-above'
: null
)}
>
{first.image && previewHasImage && !isFullSizeImage ? (
<div className="module-message__link-preview__icon_container">
<Image
smallCurveTopLeft={!withContentAbove}
2020-09-14 19:51:27 +00:00
noBorder
noBackground
softCorners
2019-01-16 03:03:56 +00:00
alt={i18n('previewThumbnail', [first.domain])}
height={72}
width={72}
url={first.image.url}
attachment={first.image}
2019-11-07 21:36:16 +00:00
onError={this.handleImageError}
2019-01-16 03:03:56 +00:00
i18n={i18n}
/>
</div>
) : null}
<div
className={classNames(
'module-message__link-preview__text',
previewHasImage && !isFullSizeImage
? 'module-message__link-preview__text--with-icon'
: null
)}
>
<div className="module-message__link-preview__title">
{first.title}
</div>
{first.description && (
<div className="module-message__link-preview__description">
{first.description}
</div>
)}
<div className="module-message__link-preview__footer">
<div className="module-message__link-preview__location">
{first.domain}
</div>
2020-09-28 23:46:31 +00:00
<LinkPreviewDate
date={linkPreviewDate}
className="module-message__link-preview__date"
/>
2019-01-16 03:03:56 +00:00
</div>
</div>
</div>
2019-11-07 21:36:16 +00:00
</button>
2019-01-16 03:03:56 +00:00
);
}
2020-09-14 19:51:27 +00:00
public renderQuote(): JSX.Element | null {
const {
conversationType,
authorColor,
direction,
disableScroll,
i18n,
quote,
scrollToQuotedMessage,
} = this.props;
if (!quote) {
return null;
}
const withContentAbove =
conversationType === 'group' && direction === 'incoming';
const quoteColor =
direction === 'incoming' ? authorColor : quote.authorColor;
const { referencedMessageNotFound } = quote;
const clickHandler = disableScroll
? undefined
: () => {
scrollToQuotedMessage({
author: quote.authorId,
sentAt: quote.sentAt,
});
};
return (
<Quote
i18n={i18n}
onClick={clickHandler}
text={quote.text}
attachment={quote.attachment}
isIncoming={direction === 'incoming'}
authorPhoneNumber={quote.authorPhoneNumber}
authorProfileName={quote.authorProfileName}
authorName={quote.authorName}
authorColor={quoteColor}
2020-07-24 01:35:32 +00:00
authorTitle={quote.authorTitle}
2020-09-16 22:42:48 +00:00
bodyRanges={quote.bodyRanges}
referencedMessageNotFound={referencedMessageNotFound}
isFromMe={quote.isFromMe}
withContentAbove={withContentAbove}
/>
);
}
2020-09-14 19:51:27 +00:00
public renderEmbeddedContact(): JSX.Element | null {
const {
collapseMetadata,
contact,
conversationType,
direction,
i18n,
showContactDetail,
text,
} = this.props;
if (!contact) {
return null;
}
const withCaption = Boolean(text);
const withContentAbove =
conversationType === 'group' && direction === 'incoming';
const withContentBelow = withCaption || !collapseMetadata;
const otherContent = (contact && contact.signalAccount) || withCaption;
const tabIndex = otherContent ? 0 : -1;
return (
<EmbeddedContact
contact={contact}
isIncoming={direction === 'incoming'}
i18n={i18n}
onClick={() => {
showContactDetail({ contact, signalAccount: contact.signalAccount });
}}
withContentAbove={withContentAbove}
withContentBelow={withContentBelow}
tabIndex={tabIndex}
/>
);
}
2020-09-14 19:51:27 +00:00
public renderSendMessageButton(): JSX.Element | null {
const { contact, openConversation, i18n } = this.props;
if (!contact || !contact.signalAccount) {
return null;
}
return (
2019-11-07 21:36:16 +00:00
<button
2020-09-14 19:51:27 +00:00
type="button"
onClick={() => {
if (contact.signalAccount) {
openConversation(contact.signalAccount);
}
}}
className="module-message__send-message-button"
>
{i18n('sendMessageToContact')}
2019-11-07 21:36:16 +00:00
</button>
);
}
2020-09-14 19:51:27 +00:00
public renderAvatar(): JSX.Element | undefined {
const {
authorAvatarPath,
authorName,
authorPhoneNumber,
authorProfileName,
2020-07-24 01:35:32 +00:00
authorTitle,
collapseMetadata,
authorColor,
conversationType,
direction,
i18n,
} = this.props;
if (
collapseMetadata ||
conversationType !== 'group' ||
direction === 'outgoing'
) {
return;
}
2020-09-14 19:51:27 +00:00
// eslint-disable-next-line consistent-return
return (
<div className="module-message__author-avatar">
<Avatar
avatarPath={authorAvatarPath}
color={authorColor}
conversationType="direct"
i18n={i18n}
name={authorName}
phoneNumber={authorPhoneNumber}
profileName={authorProfileName}
2020-07-24 01:35:32 +00:00
title={authorTitle}
2019-10-04 18:06:17 +00:00
size={28}
/>
</div>
);
}
2020-09-14 19:51:27 +00:00
public renderText(): JSX.Element | null {
2020-04-29 21:24:12 +00:00
const {
2020-09-16 22:42:48 +00:00
bodyRanges,
2020-04-29 21:24:12 +00:00
deletedForEveryone,
direction,
i18n,
2020-09-16 22:42:48 +00:00
openConversation,
2020-04-29 21:24:12 +00:00
status,
text,
textPending,
} = this.props;
2020-09-14 19:51:27 +00:00
// eslint-disable-next-line no-nested-ternary
2020-04-29 21:24:12 +00:00
const contents = deletedForEveryone
? i18n('message--deletedForEveryone')
: direction === 'incoming' && status === 'error'
? i18n('incomingError')
: text;
if (!contents) {
return null;
}
return (
<div
dir="auto"
className={classNames(
'module-message__text',
`module-message__text--${direction}`,
status === 'error' && direction === 'incoming'
? 'module-message__text--error'
: null
)}
>
<MessageBody
2020-09-16 22:42:48 +00:00
bodyRanges={bodyRanges}
direction={direction}
i18n={i18n}
2020-09-16 22:42:48 +00:00
openConversation={openConversation}
text={contents || ''}
textPending={textPending}
/>
</div>
);
}
2020-09-14 19:51:27 +00:00
public renderError(isCorrectSide: boolean): JSX.Element | null {
const { status, direction } = this.props;
2020-08-07 01:22:52 +00:00
if (!isCorrectSide || (status !== 'error' && status !== 'partial-sent')) {
return null;
}
return (
<div className="module-message__error-container">
<div
className={classNames(
'module-message__error',
`module-message__error--${direction}`
)}
/>
</div>
);
}
2020-09-14 19:51:27 +00:00
public renderMenu(
isCorrectSide: boolean,
triggerId: string
): JSX.Element | null {
const {
attachments,
canReply,
direction,
disableMenu,
i18n,
id,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToView,
2020-09-14 19:51:27 +00:00
reactToMessage,
renderEmojiPicker,
replyToMessage,
2020-09-14 19:51:27 +00:00
selectedReaction,
} = this.props;
if (!isCorrectSide || disableMenu) {
return null;
}
2020-01-23 23:57:37 +00:00
const { reactionPickerRoot, isWide } = this.state;
2020-01-17 22:23:19 +00:00
const multipleAttachments = attachments && attachments.length > 1;
const firstAttachment = attachments && attachments[0];
2018-10-04 01:12:42 +00:00
const downloadButton =
!isSticker &&
!multipleAttachments &&
2019-06-26 19:33:13 +00:00
!isTapToView &&
firstAttachment &&
!firstAttachment.pending ? (
2020-09-14 19:51:27 +00:00
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
2019-11-07 21:36:16 +00:00
onClick={this.openGenericAttachment}
role="button"
2020-09-14 19:51:27 +00:00
aria-label={i18n('downloadAttachment')}
className={classNames(
'module-message__buttons__download',
`module-message__buttons__download--${direction}`
)}
/>
) : null;
2020-01-23 23:57:37 +00:00
const reactButton = (
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the reaction button if it is
// visible in the page (it is hidden when the page is narrow)
const maybePopperRef = isWide ? popperRef : undefined;
return (
2020-09-14 19:51:27 +00:00
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
2020-01-23 23:57:37 +00:00
<div
ref={maybePopperRef}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
this.toggleReactionPicker();
}}
role="button"
className="module-message__buttons__react"
2020-09-14 19:51:27 +00:00
aria-label={i18n('reactToMessage')}
2020-01-23 23:57:37 +00:00
/>
);
}}
</Reference>
);
const replyButton = (
2020-09-14 19:51:27 +00:00
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
2019-11-07 21:36:16 +00:00
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
replyToMessage(id);
}}
2019-11-07 21:36:16 +00:00
// This a menu meant for mouse use only
role="button"
2020-09-14 19:51:27 +00:00
aria-label={i18n('replyToMessage')}
className={classNames(
'module-message__buttons__reply',
`module-message__buttons__download--${direction}`
)}
/>
);
2020-09-14 19:51:27 +00:00
// This a menu meant for mouse use only
/* eslint-disable jsx-a11y/interactive-supports-focus */
/* eslint-disable jsx-a11y/click-events-have-key-events */
const menuButton = (
2020-01-23 23:57:37 +00:00
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the collapsed menu button if
// the reaction button is not visible in the page (it is hidden when
// the page is narrow)
const maybePopperRef = !isWide ? popperRef : undefined;
return (
<ContextMenuTrigger
id={triggerId}
2020-09-14 19:51:27 +00:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2020-01-23 23:57:37 +00:00
ref={this.captureMenuTrigger as any}
>
<div
ref={maybePopperRef}
role="button"
onClick={this.showMenu}
2020-09-14 19:51:27 +00:00
aria-label={i18n('messageContextMenuButton')}
2020-01-23 23:57:37 +00:00
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
/>
</ContextMenuTrigger>
);
}}
</Reference>
);
2020-09-14 19:51:27 +00:00
/* eslint-enable jsx-a11y/interactive-supports-focus */
/* eslint-enable jsx-a11y/click-events-have-key-events */
return (
2020-01-23 23:57:37 +00:00
<Manager>
<div
className={classNames(
'module-message__buttons',
`module-message__buttons--${direction}`
2020-01-23 23:57:37 +00:00
)}
>
{canReply ? reactButton : null}
2020-05-27 21:37:06 +00:00
{canReply ? downloadButton : null}
{canReply ? replyButton : null}
2020-01-23 23:57:37 +00:00
{menuButton}
</div>
{reactionPickerRoot &&
createPortal(
2020-09-14 19:51:27 +00:00
// eslint-disable-next-line consistent-return
2020-01-23 23:57:37 +00:00
<Popper placement="top">
{({ ref, style }) => (
<ReactionPicker
i18n={i18n}
2020-01-23 23:57:37 +00:00
ref={ref}
style={style}
2020-09-14 19:51:27 +00:00
selected={selectedReaction}
2020-01-23 23:57:37 +00:00
onClose={this.toggleReactionPicker}
onPick={emoji => {
this.toggleReactionPicker(true);
2020-09-14 19:51:27 +00:00
reactToMessage(id, {
2020-01-23 23:57:37 +00:00
emoji,
2020-09-14 19:51:27 +00:00
remove: emoji === selectedReaction,
2020-01-23 23:57:37 +00:00
});
}}
renderEmojiPicker={renderEmojiPicker}
2020-01-23 23:57:37 +00:00
/>
)}
</Popper>,
reactionPickerRoot
)}
</Manager>
);
}
2020-09-14 19:51:27 +00:00
public renderContextMenu(triggerId: string): JSX.Element {
const {
attachments,
canReply,
2019-06-26 19:33:13 +00:00
deleteMessage,
deleteMessageForEveryone,
direction,
i18n,
id,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToView,
replyToMessage,
retrySend,
2019-06-26 19:33:13 +00:00
showMessageDetail,
status,
} = this.props;
const { canDeleteForEveryone } = this.state;
const showRetry = status === 'error' && direction === 'outgoing';
const multipleAttachments = attachments && attachments.length > 1;
const menu = (
<ContextMenu id={triggerId}>
2019-06-26 19:33:13 +00:00
{!isSticker &&
!multipleAttachments &&
!isTapToView &&
attachments &&
attachments[0] ? (
<MenuItem
attributes={{
className: 'module-message__context__download',
}}
2019-11-07 21:36:16 +00:00
onClick={this.openGenericAttachment}
>
{i18n('downloadAttachment')}
</MenuItem>
) : null}
{canReply ? (
<>
<MenuItem
attributes={{
className: 'module-message__context__react',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
2020-01-23 23:57:37 +00:00
this.toggleReactionPicker();
}}
>
{i18n('reactToMessage')}
</MenuItem>
<MenuItem
attributes={{
className: 'module-message__context__reply',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
2019-11-07 21:36:16 +00:00
replyToMessage(id);
}}
>
{i18n('replyToMessage')}
</MenuItem>
</>
) : null}
<MenuItem
attributes={{
className: 'module-message__context__more-info',
}}
2019-11-07 21:36:16 +00:00
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showMessageDetail(id);
}}
>
{i18n('moreInfo')}
</MenuItem>
{showRetry ? (
<MenuItem
attributes={{
className: 'module-message__context__retry-send',
}}
2019-11-07 21:36:16 +00:00
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
retrySend(id);
}}
>
{i18n('retrySend')}
</MenuItem>
) : null}
<MenuItem
attributes={{
className: 'module-message__context__delete-message',
}}
2019-11-07 21:36:16 +00:00
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
deleteMessage(id);
}}
>
{i18n('deleteMessage')}
</MenuItem>
{canDeleteForEveryone ? (
<MenuItem
attributes={{
className: 'module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
deleteMessageForEveryone(id);
}}
>
{i18n('deleteMessageForEveryone')}
</MenuItem>
) : null}
</ContextMenu>
);
return ReactDOM.createPortal(menu, document.body);
}
2019-01-14 21:49:58 +00:00
public getWidth(): number | undefined {
const { attachments, isSticker, previews } = this.props;
2019-01-16 03:03:56 +00:00
if (attachments && attachments.length) {
if (isSticker) {
2019-11-07 21:36:16 +00:00
// Padding is 8px, on both sides, plus two for 1px border
return STICKER_SIZE + 8 * 2 + 2;
}
2019-01-16 03:03:56 +00:00
const dimensions = getGridDimensions(attachments);
if (dimensions) {
2019-11-07 21:36:16 +00:00
// Add two for 1px border
return dimensions.width + 2;
2019-01-16 03:03:56 +00:00
}
}
if (previews && previews.length) {
const first = previews[0];
if (!first || !first.image) {
2020-09-14 19:51:27 +00:00
return undefined;
2019-01-16 03:03:56 +00:00
}
const { width } = first.image;
if (
!first.isStickerPack &&
2019-01-16 03:03:56 +00:00
isImageAttachment(first.image) &&
width &&
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH
) {
const dimensions = getImageDimensions(first.image);
if (dimensions) {
2019-11-07 21:36:16 +00:00
// Add two for 1px border
return dimensions.width + 2;
2019-01-16 03:03:56 +00:00
}
}
}
2020-09-14 19:51:27 +00:00
return undefined;
2019-01-16 03:03:56 +00:00
}
2020-09-14 19:51:27 +00:00
// Messy return here.
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
2019-01-16 03:03:56 +00:00
public isShowingImage() {
2019-06-26 19:33:13 +00:00
const { isTapToView, attachments, previews } = this.props;
2019-01-16 03:03:56 +00:00
const { imageBroken } = this.state;
2019-06-26 19:33:13 +00:00
if (imageBroken || isTapToView) {
2019-01-16 03:03:56 +00:00
return false;
}
if (attachments && attachments.length) {
const displayImage = canDisplayImage(attachments);
return (
displayImage &&
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
);
}
if (previews && previews.length) {
const first = previews[0];
const { image } = first;
if (!image) {
return false;
}
return isImageAttachment(image);
}
return false;
}
2020-09-14 19:51:27 +00:00
public isAttachmentPending(): boolean {
2019-06-26 19:33:13 +00:00
const { attachments } = this.props;
if (!attachments || attachments.length < 1) {
return false;
}
const first = attachments[0];
return Boolean(first.pending);
}
2020-09-14 19:51:27 +00:00
public renderTapToViewIcon(): JSX.Element {
2019-06-26 19:33:13 +00:00
const { direction, isTapToViewExpired } = this.props;
const isDownloadPending = this.isAttachmentPending();
return !isTapToViewExpired && isDownloadPending ? (
<div className="module-message__tap-to-view__spinner-container">
<Spinner svgSize="small" size="20px" direction={direction} />
</div>
) : (
<div
className={classNames(
'module-message__tap-to-view__icon',
`module-message__tap-to-view__icon--${direction}`,
isTapToViewExpired
? 'module-message__tap-to-view__icon--expired'
: null
)}
/>
);
}
2020-09-14 19:51:27 +00:00
public renderTapToViewText(): string | undefined {
2019-06-26 19:33:13 +00:00
const {
2019-10-03 19:03:46 +00:00
attachments,
2019-06-26 19:33:13 +00:00
direction,
i18n,
isTapToViewExpired,
isTapToViewError,
} = this.props;
const incomingString = isTapToViewExpired
? i18n('Message--tap-to-view-expired')
2019-10-03 19:03:46 +00:00
: i18n(
`Message--tap-to-view--incoming${
isVideo(attachments) ? '-video' : ''
}`
);
2019-06-26 19:33:13 +00:00
const outgoingString = i18n('Message--tap-to-view--outgoing');
const isDownloadPending = this.isAttachmentPending();
if (isDownloadPending) {
return;
}
2020-09-14 19:51:27 +00:00
// eslint-disable-next-line consistent-return, no-nested-ternary
2019-06-26 19:33:13 +00:00
return isTapToViewError
? i18n('incomingError')
: direction === 'outgoing'
2020-01-08 17:44:54 +00:00
? outgoingString
: incomingString;
2019-06-26 19:33:13 +00:00
}
2020-09-14 19:51:27 +00:00
public renderTapToView(): JSX.Element {
2019-06-26 19:33:13 +00:00
const {
collapseMetadata,
conversationType,
direction,
isTapToViewExpired,
isTapToViewError,
} = this.props;
const withContentBelow = !collapseMetadata;
const withContentAbove =
!collapseMetadata &&
conversationType === 'group' &&
direction === 'incoming';
return (
<div
className={classNames(
'module-message__tap-to-view',
withContentBelow
? 'module-message__tap-to-view--with-content-below'
: null,
withContentAbove
? 'module-message__tap-to-view--with-content-above'
: null
)}
>
{isTapToViewError ? null : this.renderTapToViewIcon()}
<div
className={classNames(
'module-message__tap-to-view__text',
`module-message__tap-to-view__text--${direction}`,
isTapToViewExpired
? `module-message__tap-to-view__text--${direction}-expired`
: null,
isTapToViewError
? `module-message__tap-to-view__text--${direction}-error`
: null
)}
>
{this.renderTapToViewText()}
</div>
</div>
);
}
2020-09-14 19:51:27 +00:00
public toggleReactionViewer = (onlyRemove = false): void => {
2020-01-17 22:23:19 +00:00
this.setState(({ reactionViewerRoot }) => {
if (reactionViewerRoot) {
document.body.removeChild(reactionViewerRoot);
document.body.removeEventListener(
'click',
2020-01-23 23:57:37 +00:00
this.handleClickOutsideReactionViewer,
2020-01-17 22:23:19 +00:00
true
);
return { reactionViewerRoot: null };
2020-01-17 22:23:19 +00:00
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
2020-01-23 23:57:37 +00:00
document.body.addEventListener(
'click',
this.handleClickOutsideReactionViewer,
true
);
2020-01-17 22:23:19 +00:00
return {
reactionViewerRoot: root,
};
}
return { reactionViewerRoot: null };
2020-01-17 22:23:19 +00:00
});
};
2020-09-14 19:51:27 +00:00
public toggleReactionPicker = (onlyRemove = false): void => {
2020-01-23 23:57:37 +00:00
this.setState(({ reactionPickerRoot }) => {
if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutsideReactionPicker,
true
);
return { reactionPickerRoot: null };
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
document.body.addEventListener(
'click',
this.handleClickOutsideReactionPicker,
true
);
return {
reactionPickerRoot: root,
};
}
return { reactionPickerRoot: null };
});
};
2020-09-14 19:51:27 +00:00
public handleClickOutsideReactionViewer = (e: MouseEvent): void => {
2020-01-17 22:23:19 +00:00
const { reactionViewerRoot } = this.state;
const { current: reactionsContainer } = this.reactionsContainerRef;
if (reactionViewerRoot && reactionsContainer) {
if (
!reactionViewerRoot.contains(e.target as HTMLElement) &&
!reactionsContainer.contains(e.target as HTMLElement)
) {
this.toggleReactionViewer(true);
}
}
};
2020-09-14 19:51:27 +00:00
public handleClickOutsideReactionPicker = (e: MouseEvent): void => {
2020-01-23 23:57:37 +00:00
const { reactionPickerRoot } = this.state;
if (reactionPickerRoot) {
if (!reactionPickerRoot.contains(e.target as HTMLElement)) {
this.toggleReactionPicker(true);
}
}
};
2020-09-14 19:51:27 +00:00
public renderReactions(outgoing: boolean): JSX.Element | null {
2020-01-17 22:23:19 +00:00
const { reactions, i18n } = this.props;
if (!reactions || (reactions && reactions.length === 0)) {
return null;
}
// Group by emoji and order each group by timestamp descending
const grouped = Object.values(groupBy(reactions, 'emoji')).map(res =>
orderBy(res, ['timestamp'], ['desc'])
);
// Order groups by length and subsequently by most recent reaction
const ordered = orderBy(
grouped,
['length', ([{ timestamp }]) => timestamp],
['desc', 'desc']
);
// Take the first three groups for rendering
const toRender = take(ordered, 3).map(res => ({
2020-01-17 22:23:19 +00:00
emoji: res[0].emoji,
count: res.length,
2020-01-17 22:23:19 +00:00
isMe: res.some(re => Boolean(re.from.isMe)),
}));
const someNotRendered = ordered.length > 3;
// We only drop two here because the third emoji would be replaced by the
// more button
const maybeNotRendered = drop(ordered, 2);
const maybeNotRenderedTotal = maybeNotRendered.reduce(
(sum, res) => sum + res.length,
0
);
const notRenderedIsMe =
someNotRendered &&
maybeNotRendered.some(res => res.some(re => Boolean(re.from.isMe)));
const { reactionViewerRoot, containerWidth } = this.state;
// Calculate the width of the reactions container
const reactionsWidth = toRender.reduce((sum, res, i, arr) => {
if (someNotRendered && i === arr.length - 1) {
return sum + 28;
}
if (res.count > 1) {
return sum + 40;
}
2020-01-17 22:23:19 +00:00
return sum + 28;
}, 0);
2020-01-17 22:23:19 +00:00
const reactionsXAxisOffset = Math.max(
containerWidth - reactionsWidth - 6,
6
);
2020-01-17 22:23:19 +00:00
const popperPlacement = outgoing ? 'bottom-end' : 'bottom-start';
return (
<Manager>
<Reference>
{({ ref: popperRef }) => (
<div
2020-03-23 21:09:12 +00:00
ref={this.reactionsContainerRefMerger(
this.reactionsContainerRef,
popperRef
)}
className={classNames(
'module-message__reactions',
outgoing
? 'module-message__reactions--outgoing'
: 'module-message__reactions--incoming'
)}
style={{
[outgoing ? 'right' : 'left']: `${reactionsXAxisOffset}px`,
2020-01-17 22:23:19 +00:00
}}
>
{toRender.map((re, i) => {
const isLast = i === toRender.length - 1;
const isMore = isLast && someNotRendered;
const isMoreWithMe = isMore && notRenderedIsMe;
return (
<button
2020-09-14 19:51:27 +00:00
type="button"
// eslint-disable-next-line react/no-array-index-key
key={`${re.emoji}-${i}`}
className={classNames(
'module-message__reactions__reaction',
re.count > 1
? 'module-message__reactions__reaction--with-count'
: null,
outgoing
? 'module-message__reactions__reaction--outgoing'
: 'module-message__reactions__reaction--incoming',
isMoreWithMe || (re.isMe && !isMoreWithMe)
? 'module-message__reactions__reaction--is-me'
: null
)}
onClick={e => {
e.stopPropagation();
e.preventDefault();
this.toggleReactionViewer(false);
}}
onKeyDown={e => {
// Prevent enter key from opening stickers/attachments
if (e.key === 'Enter') {
2020-01-17 22:23:19 +00:00
e.stopPropagation();
}
}}
>
{isMore ? (
<span
className={classNames(
'module-message__reactions__reaction__count',
'module-message__reactions__reaction__count--no-emoji',
isMoreWithMe
? 'module-message__reactions__reaction__count--is-me'
: null
)}
>
+{maybeNotRenderedTotal}
</span>
) : (
2020-09-14 19:51:27 +00:00
<>
<Emoji size={16} emoji={re.emoji} />
{re.count > 1 ? (
<span
className={classNames(
'module-message__reactions__reaction__count',
re.isMe
? 'module-message__reactions__reaction__count--is-me'
: null
)}
>
{re.count}
</span>
) : null}
2020-09-14 19:51:27 +00:00
</>
)}
</button>
);
})}
</div>
2020-01-17 22:23:19 +00:00
)}
</Reference>
{reactionViewerRoot &&
createPortal(
<Popper placement={popperPlacement}>
{({ ref, style }) => (
<ReactionViewer
ref={ref}
style={{
...style,
zIndex: 2,
}}
reactions={reactions}
i18n={i18n}
onClose={this.toggleReactionViewer}
/>
)}
</Popper>,
reactionViewerRoot
)}
</Manager>
);
}
2020-09-14 19:51:27 +00:00
public renderContents(): JSX.Element | null {
2020-04-29 21:24:12 +00:00
const { isTapToView, deletedForEveryone } = this.props;
if (deletedForEveryone) {
return this.renderText();
}
2019-06-26 19:33:13 +00:00
if (isTapToView) {
return (
<>
{this.renderTapToView()}
{this.renderMetadata()}
</>
);
}
return (
<>
{this.renderQuote()}
{this.renderAttachment()}
{this.renderPreview()}
{this.renderEmbeddedContact()}
{this.renderText()}
{this.renderMetadata()}
{this.renderSendMessageButton()}
</>
);
}
2019-11-07 21:36:16 +00:00
public handleOpen = (
event: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent
2020-09-14 19:51:27 +00:00
): void => {
const {
attachments,
contact,
2019-06-26 19:33:13 +00:00
displayTapToViewMessage,
direction,
id,
2019-11-07 21:36:16 +00:00
isTapToView,
isTapToViewExpired,
openConversation,
showContactDetail,
2019-11-07 21:36:16 +00:00
showVisualAttachment,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
2019-11-07 21:36:16 +00:00
} = this.props;
const { imageBroken } = this.state;
const isAttachmentPending = this.isAttachmentPending();
if (isTapToView) {
if (isAttachmentPending) {
return;
}
if (isTapToViewExpired) {
const action =
direction === 'outgoing'
? showExpiredOutgoingTapToViewToast
: showExpiredIncomingTapToViewToast;
action();
} else {
2019-11-07 21:36:16 +00:00
event.preventDefault();
event.stopPropagation();
displayTapToViewMessage(id);
}
return;
}
if (
!imageBroken &&
attachments &&
attachments.length > 0 &&
!isAttachmentPending &&
canDisplayImage(attachments) &&
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
) {
event.preventDefault();
event.stopPropagation();
const attachment = attachments[0];
showVisualAttachment({ attachment, messageId: id });
return;
}
if (
attachments &&
attachments.length === 1 &&
!isAttachmentPending &&
!isAudio(attachments)
) {
event.preventDefault();
event.stopPropagation();
this.openGenericAttachment();
return;
}
if (
!isAttachmentPending &&
isAudio(attachments) &&
this.audioRef &&
this.audioRef.current
) {
event.preventDefault();
event.stopPropagation();
if (this.audioRef.current.paused) {
this.audioRef.current.play();
} else {
this.audioRef.current.pause();
}
}
if (contact && contact.signalAccount) {
openConversation(contact.signalAccount);
event.preventDefault();
event.stopPropagation();
}
if (contact) {
showContactDetail({ contact, signalAccount: contact.signalAccount });
event.preventDefault();
event.stopPropagation();
}
2019-11-07 21:36:16 +00:00
};
2020-09-14 19:51:27 +00:00
public openGenericAttachment = (event?: React.MouseEvent): void => {
2019-11-07 21:36:16 +00:00
const { attachments, downloadAttachment, timestamp } = this.props;
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!attachments || attachments.length !== 1) {
return;
}
const attachment = attachments[0];
const { fileName } = attachment;
const isDangerous = isFileDangerous(fileName || '');
downloadAttachment({
isDangerous,
attachment,
timestamp,
});
};
2020-09-14 19:51:27 +00:00
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
// Do not allow reactions to error messages
const { canReply } = this.props;
2020-01-23 23:57:37 +00:00
if (
(event.key === 'E' || event.key === 'e') &&
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
canReply
2020-01-23 23:57:37 +00:00
) {
this.toggleReactionPicker();
}
2019-11-07 21:36:16 +00:00
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
this.handleOpen(event);
};
2020-09-14 19:51:27 +00:00
public handleClick = (event: React.MouseEvent): void => {
2019-11-07 21:36:16 +00:00
// We don't want clicks on body text to result in the 'default action' for the message
const { text } = this.props;
if (text && text.length > 0) {
return;
}
2020-05-27 21:37:06 +00:00
// If there an incomplete attachment, do not execute the default action
const { attachments } = this.props;
if (attachments && attachments.length > 0) {
const [firstAttachment] = attachments;
if (!firstAttachment.url) {
return;
}
}
2019-11-07 21:36:16 +00:00
this.handleOpen(event);
};
2020-09-14 19:51:27 +00:00
public renderContainer(): JSX.Element {
2019-11-07 21:36:16 +00:00
const {
authorColor,
2020-04-29 21:24:12 +00:00
deletedForEveryone,
2019-11-07 21:36:16 +00:00
direction,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToView,
isTapToViewExpired,
isTapToViewError,
reactions,
} = this.props;
const { isSelected } = this.state;
2019-06-26 19:33:13 +00:00
const isAttachmentPending = this.isAttachmentPending();
2019-11-07 21:36:16 +00:00
const width = this.getWidth();
const isShowingImage = this.isShowingImage();
const containerClassnames = classNames(
'module-message__container',
isSelected && !isSticker ? 'module-message__container--selected' : null,
isSticker ? 'module-message__container--with-sticker' : null,
!isSticker ? `module-message__container--${direction}` : null,
isTapToView ? 'module-message__container--with-tap-to-view' : null,
isTapToView && isTapToViewExpired
? 'module-message__container--with-tap-to-view-expired'
: null,
!isSticker && direction === 'incoming'
? `module-message__container--incoming-${authorColor}`
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? 'module-message__container--with-tap-to-view-pending'
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? `module-message__container--${direction}-${authorColor}-tap-to-view-pending`
: null,
isTapToViewError
? 'module-message__container--with-tap-to-view-error'
: null,
reactions && reactions.length > 0
? 'module-message__container--with-reactions'
2020-04-29 21:24:12 +00:00
: null,
deletedForEveryone
? 'module-message__container--deleted-for-everyone'
2019-11-07 21:36:16 +00:00
: null
);
const containerStyles = {
width: isShowingImage ? width : undefined,
};
return (
<Measure
2020-09-14 19:51:27 +00:00
bounds
onResize={({ bounds = { width: 0 } }) => {
this.setState({ containerWidth: bounds.width });
}}
>
{({ measureRef }) => (
<div
ref={measureRef}
className={containerClassnames}
style={containerStyles}
>
{this.renderAuthor()}
{this.renderContents()}
{this.renderAvatar()}
</div>
)}
</Measure>
2019-11-07 21:36:16 +00:00
);
}
2020-09-14 19:51:27 +00:00
public render(): JSX.Element | null {
2019-11-07 21:36:16 +00:00
const {
authorPhoneNumber,
attachments,
conversationType,
direction,
id,
isSticker,
timestamp,
} = this.props;
const { expired, expiring, imageBroken, isSelected } = this.state;
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
const triggerId = String(id || `${authorPhoneNumber}-${timestamp}`);
if (expired) {
return null;
}
if (isSticker && (imageBroken || !attachments || !attachments.length)) {
return null;
}
return (
<div
className={classNames(
'module-message',
`module-message--${direction}`,
isSelected ? 'module-message--selected' : null,
expiring ? 'module-message--expired' : null,
conversationType === 'group' ? 'module-message--group' : null
)}
2019-11-07 21:36:16 +00:00
tabIndex={0}
// We pretend to be a button because we sometimes contain buttons and a button
// cannot be within another button
role="button"
onKeyDown={this.handleKeyDown}
onClick={this.handleClick}
onFocus={this.handleFocus}
2019-11-07 21:36:16 +00:00
ref={this.focusRef}
>
{this.renderError(direction === 'incoming')}
{this.renderMenu(direction === 'outgoing', triggerId)}
2019-11-07 21:36:16 +00:00
{this.renderContainer()}
{this.renderError(direction === 'outgoing')}
{this.renderMenu(direction === 'incoming', triggerId)}
{this.renderContextMenu(triggerId)}
2020-01-17 22:23:19 +00:00
{this.renderReactions(direction === 'outgoing')}
</div>
);
}
}