signal-desktop/ts/components/conversation/Message.tsx
Fedor Indutny 12d7f24d0f New UI for audio playback and global audio player
Introduce new UI and behavior for playing audio attachments in
conversations. Previously, playback stopped unexpectedly during window
resizes and scrolling through the messages due to the row height
recomputation in `react-virtualized`.

With this commit we introduce `<GlobalAudioContext/>` instance that
wraps whole conversation and provides an `<audio/>` element that
doesn't get re-rendered (or destroyed) whenever `react-virtualized`
recomputes messages. The audio players (with a freshly designed UI) now
share this global `<audio/>` instance and manage access to it using
`audioPlayer.owner` state from the redux.

New UI computes on the fly, caches, and displays waveforms for each
audio attachment. Storybook had to be slightly modified to accomodate
testing of Android bubbles by introducing the new knob for
`authorColor`.
2021-03-19 16:57:35 -04:00

2247 lines
61 KiB
TypeScript

// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import ReactDOM, { createPortal } from 'react-dom';
import classNames from 'classnames';
import { drop, groupBy, orderBy, take } from 'lodash';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { Manager, Popper, Reference } from 'react-popper';
import { Avatar } from '../Avatar';
import { Spinner } from '../Spinner';
import { MessageBody } from './MessageBody';
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';
import {
OwnProps as ReactionViewerProps,
ReactionViewer,
} from './ReactionViewer';
import { Props as ReactionPickerProps } from './ReactionPicker';
import { Emoji } from '../emoji/Emoji';
import { LinkPreviewDate } from './LinkPreviewDate';
import { LinkPreviewType } from '../../types/message/LinkPreviews';
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
import {
AttachmentType,
canDisplayImage,
getExtensionForDisplay,
getGridDimensions,
getImageDimensions,
hasImage,
hasNotDownloaded,
hasVideoScreenshot,
isAudio,
isImage,
isImageAttachment,
isVideo,
} from '../../types/Attachment';
import { ContactType } from '../../types/Contact';
import { getIncrement } from '../../util/timer';
import { isFileDangerous } from '../../util/isFileDangerous';
import { BodyRangesType, LocalizerType, ThemeType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import { createRefMerger } from '../_util';
import { emojiToData } from '../emoji/lib';
import { SmartReactionPicker } from '../../state/smart/ReactionPicker';
type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
};
const STICKER_SIZE = 200;
const SELECTED_TIMEOUT = 1000;
const THREE_HOURS = 3 * 60 * 60 * 1000;
export const MessageStatuses = [
'delivered',
'error',
'partial-sent',
'read',
'sending',
'sent',
] as const;
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];
export type AudioAttachmentProps = {
id: string;
i18n: LocalizerType;
buttonRef: React.RefObject<HTMLButtonElement>;
direction: DirectionType;
theme: ThemeType | undefined;
url: string;
withContentAbove: boolean;
withContentBelow: boolean;
};
export type PropsData = {
id: string;
conversationId: string;
text?: string;
textPending?: boolean;
isSticker?: boolean;
isSelected?: boolean;
isSelectedCounter?: number;
interactionMode: InteractionModeType;
direction: DirectionType;
timestamp: number;
status?: MessageStatusType;
contact?: ContactType;
authorId: string;
authorTitle: string;
authorName?: string;
authorProfileName?: string;
authorPhoneNumber?: string;
authorColor?: ColorType;
conversationType: ConversationTypesType;
attachments?: Array<AttachmentType>;
quote?: {
text: string;
attachment?: QuotedAttachmentType;
isFromMe: boolean;
sentAt: number;
authorId: string;
authorPhoneNumber?: string;
authorProfileName?: string;
authorTitle: string;
authorName?: string;
authorColor?: ColorType;
bodyRanges?: BodyRangesType;
referencedMessageNotFound: boolean;
};
previews: Array<LinkPreviewType>;
authorAvatarPath?: string;
isExpired?: boolean;
isTapToView?: boolean;
isTapToViewExpired?: boolean;
isTapToViewError?: boolean;
expirationLength?: number;
expirationTimestamp?: number;
reactions?: ReactionViewerProps['reactions'];
selectedReaction?: string;
deletedForEveryone?: boolean;
canReply: boolean;
canDownload: boolean;
canDeleteForEveryone: boolean;
isBlocked: boolean;
isMessageRequestAccepted: boolean;
bodyRanges?: BodyRangesType;
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
};
export type PropsHousekeeping = {
i18n: LocalizerType;
theme?: ThemeType;
disableMenu?: boolean;
disableScroll?: boolean;
collapseMetadata?: boolean;
};
export type PropsActions = {
clearSelectedMessage: () => unknown;
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;
showContactDetail: (options: {
contact: ContactType;
signalAccount?: string;
}) => void;
showContactModal: (contactId: string) => void;
kickOffAttachmentDownload: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
showVisualAttachment: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
downloadAttachment: (options: {
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
}) => void;
displayTapToViewMessage: (messageId: string) => unknown;
openLink: (url: string) => void;
scrollToQuotedMessage: (options: {
authorId: string;
sentAt: number;
}) => void;
selectMessage?: (messageId: string, conversationId: string) => unknown;
showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown;
};
export type Props = PropsData &
PropsHousekeeping &
PropsActions &
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
type State = {
expiring: boolean;
expired: boolean;
imageBroken: boolean;
isSelected?: boolean;
prevSelectedCounter?: number;
reactionViewerRoot: HTMLDivElement | null;
reactionPickerRoot: HTMLDivElement | null;
isWide: boolean;
canDeleteForEveryone: boolean;
};
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined;
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();
public reactionsContainerRef: React.RefObject<
HTMLDivElement
> = React.createRef();
public reactionsContainerRefMerger = createRefMerger();
public wideMl: MediaQueryList;
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);
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,
reactionViewerRoot: null,
reactionPickerRoot: null,
isWide: this.wideMl.matches,
canDeleteForEveryone: props.canDeleteForEveryone,
};
}
public static getDerivedStateFromProps(props: Props, state: State): State {
const newState = {
...state,
canDeleteForEveryone:
props.canDeleteForEveryone && state.canDeleteForEveryone,
};
if (!props.isSelected) {
return {
...newState,
isSelected: false,
prevSelectedCounter: 0,
};
}
if (
props.isSelected &&
props.isSelectedCounter !== state.prevSelectedCounter
) {
return {
...newState,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
};
}
return newState;
}
private hasReactions(): boolean {
const { reactions } = this.props;
return Boolean(reactions && reactions.length);
}
public handleWideMlChange = (event: MediaQueryListEvent): void => {
this.setState({ isWide: event.matches });
};
public captureMenuTrigger = (triggerRef: Trigger): void => {
this.menuTriggerRef = triggerRef;
};
public showMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
if (this.menuTriggerRef) {
this.menuTriggerRef.handleContextClick(event);
}
};
public handleImageError = (): void => {
const { id } = this.props;
window.log.info(
`Message ${id}: Image failed to load; failing over to placeholder`
);
this.setState({
imageBroken: true,
});
};
public handleFocus = (): void => {
const { interactionMode } = this.props;
if (interactionMode === 'keyboard') {
this.setSelected();
}
};
public setSelected = (): void => {
const { id, conversationId, selectMessage } = this.props;
if (selectMessage) {
selectMessage(id, conversationId);
}
};
public setFocus = (): void => {
const container = this.focusRef.current;
if (container && !container.contains(document.activeElement)) {
container.focus();
}
};
public componentDidMount(): void {
this.startSelectedTimer();
this.startDeleteForEveryoneTimer();
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);
}
public componentWillUnmount(): void {
if (this.selectedTimeout) {
clearTimeout(this.selectedTimeout);
}
if (this.expirationCheckInterval) {
clearInterval(this.expirationCheckInterval);
}
if (this.expiredTimeout) {
clearTimeout(this.expiredTimeout);
}
if (this.deleteForEveryoneTimeout) {
clearTimeout(this.deleteForEveryoneTimeout);
}
this.toggleReactionViewer(true);
this.toggleReactionPicker(true);
this.wideMl.removeEventListener('change', this.handleWideMlChange);
}
public componentDidUpdate(prevProps: Props): void {
const { canDeleteForEveryone, isSelected } = this.props;
this.startSelectedTimer();
if (!prevProps.isSelected && isSelected) {
this.setFocus();
}
this.checkExpired();
if (canDeleteForEveryone !== prevProps.canDeleteForEveryone) {
this.startDeleteForEveryoneTimer();
}
}
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 });
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);
}
}
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);
}
}
private areLinksEnabled(): boolean {
const { isMessageRequestAccepted, isBlocked } = this.props;
return isMessageRequestAccepted && !isBlocked;
}
public renderTimestamp(): JSX.Element {
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
type="button"
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}
extended
direction={metadataDirection}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
withTapToViewExpired={isTapToViewExpired}
module="module-message__metadata__date"
/>
);
}
public renderMetadata(): JSX.Element | null {
const {
collapseMetadata,
direction,
expirationLength,
expirationTimestamp,
isSticker,
isTapToViewExpired,
status,
text,
textPending,
} = this.props;
if (collapseMetadata) {
return null;
}
const isShowingImage = this.isShowingImage();
const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage);
const metadataDirection = isSticker ? undefined : direction;
return (
<div
className={classNames(
'module-message__metadata',
`module-message__metadata--${direction}`,
this.hasReactions()
? 'module-message__metadata--with-reactions'
: null,
withImageNoCaption
? 'module-message__metadata--with-image-no-caption'
: null
)}
>
{this.renderTimestamp()}
{expirationLength && expirationTimestamp ? (
<ExpireTimer
direction={metadataDirection}
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}
{!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,
isTapToViewExpired
? 'module-message__metadata__status-icon--with-tap-to-view-expired'
: null
)}
/>
) : null}
</div>
);
}
public renderAuthor(): JSX.Element | null {
const {
authorTitle,
authorName,
authorPhoneNumber,
authorProfileName,
collapseMetadata,
conversationType,
direction,
i18n,
isSticker,
isTapToView,
isTapToViewExpired,
} = this.props;
if (collapseMetadata) {
return null;
}
if (
direction !== 'incoming' ||
conversationType !== 'group' ||
!authorTitle
) {
return null;
}
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
title={authorTitle}
phoneNumber={authorPhoneNumber}
name={authorName}
profileName={authorProfileName}
module={moduleName}
i18n={i18n}
/>
</div>
);
}
public renderAttachment(): JSX.Element | null {
const {
attachments,
collapseMetadata,
conversationType,
direction,
i18n,
id,
kickOffAttachmentDownload,
quote,
showVisualAttachment,
isSticker,
text,
theme,
renderAudioAttachment,
} = this.props;
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) || isVideo(attachments))
) {
const prefix = isSticker ? 'sticker' : 'attachment';
const bottomOverlay = !isSticker && !collapseMetadata;
// 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}
theme={theme}
onError={this.handleImageError}
tabIndex={tabIndex}
onClick={attachment => {
if (hasNotDownloaded(attachment)) {
kickOffAttachmentDownload({ attachment, messageId: id });
} else {
showVisualAttachment({ attachment, messageId: id });
}
}}
/>
</div>
);
}
if (!firstAttachment.pending && isAudio(attachments)) {
return renderAudioAttachment({
i18n,
buttonRef: this.audioButtonRef,
id,
direction,
theme,
url: firstAttachment.url,
withContentAbove,
withContentBelow,
});
}
const { pending, fileName, fileSize, contentType } = firstAttachment;
const extension = getExtensionForDisplay({ contentType, fileName });
const isDangerous = isFileDangerous(fileName || '');
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();
if (hasNotDownloaded(firstAttachment)) {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
return;
}
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}
</div>
) : null}
</div>
{isDangerous ? (
<div className="module-message__generic-attachment__icon-dangerous-container">
<div className="module-message__generic-attachment__icon-dangerous" />
</div>
) : null}
</div>
)}
<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>
);
}
public renderPreview(): JSX.Element | null {
const {
attachments,
conversationType,
direction,
i18n,
openLink,
previews,
quote,
theme,
} = 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 = isImageAttachment(first.image);
const isFullSizeImage = shouldUseFullSizeLinkPreviewImage(first);
const linkPreviewDate = first.date || null;
const isClickable = this.areLinksEnabled();
const className = classNames(
'module-message__link-preview',
`module-message__link-preview--${direction}`,
{
'module-message__link-preview--with-content-above': withContentAbove,
'module-message__link-preview--nonclickable': !isClickable,
}
);
const contents = (
<>
{first.image && previewHasImage && isFullSizeImage ? (
<ImageGrid
attachments={[first.image]}
withContentAbove={withContentAbove}
withContentBelow
onError={this.handleImageError}
i18n={i18n}
theme={theme}
/>
) : null}
<div className="module-message__link-preview__content">
{first.image && previewHasImage && !isFullSizeImage ? (
<div className="module-message__link-preview__icon_container">
<Image
smallCurveTopLeft={!withContentAbove}
noBorder
noBackground
softCorners
alt={i18n('previewThumbnail', [first.domain])}
height={72}
width={72}
url={first.image.url}
attachment={first.image}
onError={this.handleImageError}
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>
<LinkPreviewDate
date={linkPreviewDate}
className="module-message__link-preview__date"
/>
</div>
</div>
</div>
</>
);
return isClickable ? (
<button
type="button"
className={className}
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);
}}
>
{contents}
</button>
) : (
<div className={className}>{contents}</div>
);
}
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({
authorId: 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}
authorTitle={quote.authorTitle}
bodyRanges={quote.bodyRanges}
referencedMessageNotFound={referencedMessageNotFound}
isFromMe={quote.isFromMe}
withContentAbove={withContentAbove}
/>
);
}
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}
/>
);
}
public renderSendMessageButton(): JSX.Element | null {
const { contact, openConversation, i18n } = this.props;
if (!contact || !contact.signalAccount) {
return null;
}
return (
<button
type="button"
onClick={() => {
if (contact.signalAccount) {
openConversation(contact.signalAccount);
}
}}
className="module-message__send-message-button"
>
{i18n('sendMessageToContact')}
</button>
);
}
public renderAvatar(): JSX.Element | undefined {
const {
authorAvatarPath,
authorId,
authorName,
authorPhoneNumber,
authorProfileName,
authorTitle,
collapseMetadata,
authorColor,
conversationType,
direction,
i18n,
showContactModal,
} = this.props;
if (
collapseMetadata ||
conversationType !== 'group' ||
direction === 'outgoing'
) {
return undefined;
}
return (
<div
className={classNames('module-message__author-avatar-container', {
'module-message__author-avatar-container--with-reactions': this.hasReactions(),
})}
>
<button
type="button"
className="module-message__author-avatar"
onClick={() => showContactModal(authorId)}
tabIndex={0}
>
<Avatar
avatarPath={authorAvatarPath}
color={authorColor}
conversationType="direct"
i18n={i18n}
name={authorName}
phoneNumber={authorPhoneNumber}
profileName={authorProfileName}
title={authorTitle}
size={28}
/>
</button>
</div>
);
}
public renderText(): JSX.Element | null {
const {
bodyRanges,
deletedForEveryone,
direction,
i18n,
openConversation,
status,
text,
textPending,
} = this.props;
// eslint-disable-next-line no-nested-ternary
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
bodyRanges={bodyRanges}
disableLinks={!this.areLinksEnabled()}
direction={direction}
i18n={i18n}
openConversation={openConversation}
text={contents || ''}
textPending={textPending}
/>
</div>
);
}
public renderError(isCorrectSide: boolean): JSX.Element | null {
const { status, direction } = this.props;
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>
);
}
public renderMenu(
isCorrectSide: boolean,
triggerId: string
): JSX.Element | null {
const {
attachments,
canDownload,
canReply,
direction,
disableMenu,
i18n,
id,
isSticker,
isTapToView,
reactToMessage,
renderEmojiPicker,
replyToMessage,
selectedReaction,
} = this.props;
if (!isCorrectSide || disableMenu) {
return null;
}
const { reactionPickerRoot, isWide } = this.state;
const multipleAttachments = attachments && attachments.length > 1;
const firstAttachment = attachments && attachments[0];
const downloadButton =
!isSticker &&
!multipleAttachments &&
!isTapToView &&
firstAttachment &&
!firstAttachment.pending ? (
// 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
onClick={this.openGenericAttachment}
role="button"
aria-label={i18n('downloadAttachment')}
className={classNames(
'module-message__buttons__download',
`module-message__buttons__download--${direction}`
)}
/>
) : null;
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 (
// 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
ref={maybePopperRef}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
this.toggleReactionPicker();
}}
role="button"
className="module-message__buttons__react"
aria-label={i18n('reactToMessage')}
/>
);
}}
</Reference>
);
const replyButton = (
// 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
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
replyToMessage(id);
}}
// This a menu meant for mouse use only
role="button"
aria-label={i18n('replyToMessage')}
className={classNames(
'module-message__buttons__reply',
`module-message__buttons__download--${direction}`
)}
/>
);
// 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 = (
<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}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={this.captureMenuTrigger as any}
>
<div
ref={maybePopperRef}
role="button"
onClick={this.showMenu}
aria-label={i18n('messageContextMenuButton')}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
/>
</ContextMenuTrigger>
);
}}
</Reference>
);
/* eslint-enable jsx-a11y/interactive-supports-focus */
/* eslint-enable jsx-a11y/click-events-have-key-events */
return (
<Manager>
<div
className={classNames(
'module-message__buttons',
`module-message__buttons--${direction}`
)}
>
{canReply ? reactButton : null}
{canDownload ? downloadButton : null}
{canReply ? replyButton : null}
{menuButton}
</div>
{reactionPickerRoot &&
createPortal(
// eslint-disable-next-line consistent-return
<Popper placement="top">
{({ ref, style }) => (
<SmartReactionPicker
ref={ref}
style={style}
selected={selectedReaction}
onClose={this.toggleReactionPicker}
onPick={emoji => {
this.toggleReactionPicker(true);
reactToMessage(id, {
emoji,
remove: emoji === selectedReaction,
});
}}
renderEmojiPicker={renderEmojiPicker}
/>
)}
</Popper>,
reactionPickerRoot
)}
</Manager>
);
}
public renderContextMenu(triggerId: string): JSX.Element {
const {
attachments,
canDownload,
canReply,
deleteMessage,
deleteMessageForEveryone,
direction,
i18n,
id,
isSticker,
isTapToView,
replyToMessage,
retrySend,
showMessageDetail,
status,
} = this.props;
const { canDeleteForEveryone } = this.state;
const showRetry =
(status === 'error' || status === 'partial-sent') &&
direction === 'outgoing';
const multipleAttachments = attachments && attachments.length > 1;
const menu = (
<ContextMenu id={triggerId}>
{canDownload &&
!isSticker &&
!multipleAttachments &&
!isTapToView &&
attachments &&
attachments[0] ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__download',
}}
onClick={this.openGenericAttachment}
>
{i18n('downloadAttachment')}
</MenuItem>
) : null}
{canReply ? (
<>
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__reply',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
replyToMessage(id);
}}
>
{i18n('replyToMessage')}
</MenuItem>
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__react',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
this.toggleReactionPicker();
}}
>
{i18n('reactToMessage')}
</MenuItem>
</>
) : null}
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__more-info',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showMessageDetail(id);
}}
>
{i18n('moreInfo')}
</MenuItem>
{showRetry ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__retry-send',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
retrySend(id);
}}
>
{i18n('retrySend')}
</MenuItem>
) : null}
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
deleteMessage(id);
}}
>
{i18n('deleteMessage')}
</MenuItem>
{canDeleteForEveryone ? (
<MenuItem
attributes={{
className:
'module-message__context--icon 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);
}
public getWidth(): number | undefined {
const { attachments, isSticker, previews } = this.props;
if (attachments && attachments.length) {
if (isSticker) {
// Padding is 8px, on both sides, plus two for 1px border
return STICKER_SIZE + 8 * 2 + 2;
}
const dimensions = getGridDimensions(attachments);
if (dimensions) {
// Add two for 1px border
return dimensions.width + 2;
}
}
const firstLinkPreview = (previews || [])[0];
if (
firstLinkPreview &&
firstLinkPreview.image &&
shouldUseFullSizeLinkPreviewImage(firstLinkPreview)
) {
const dimensions = getImageDimensions(firstLinkPreview.image);
if (dimensions) {
// Add two for 1px border
return dimensions.width + 2;
}
}
return undefined;
}
// Messy return here.
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public isShowingImage() {
const { isTapToView, attachments, previews } = this.props;
const { imageBroken } = this.state;
if (imageBroken || isTapToView) {
return false;
}
if (attachments && attachments.length) {
const displayImage = canDisplayImage(attachments);
return displayImage && (isImage(attachments) || isVideo(attachments));
}
if (previews && previews.length) {
const first = previews[0];
const { image } = first;
return isImageAttachment(image);
}
return false;
}
public isAttachmentPending(): boolean {
const { attachments } = this.props;
if (!attachments || attachments.length < 1) {
return false;
}
const first = attachments[0];
return Boolean(first.pending);
}
public renderTapToViewIcon(): JSX.Element {
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
)}
/>
);
}
public renderTapToViewText(): string | undefined {
const {
attachments,
direction,
i18n,
isTapToViewExpired,
isTapToViewError,
} = this.props;
const incomingString = isTapToViewExpired
? i18n('Message--tap-to-view-expired')
: i18n(
`Message--tap-to-view--incoming${
isVideo(attachments) ? '-video' : ''
}`
);
const outgoingString = i18n('Message--tap-to-view--outgoing');
const isDownloadPending = this.isAttachmentPending();
if (isDownloadPending) {
return;
}
// eslint-disable-next-line consistent-return, no-nested-ternary
return isTapToViewError
? i18n('incomingError')
: direction === 'outgoing'
? outgoingString
: incomingString;
}
public renderTapToView(): JSX.Element {
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>
);
}
public toggleReactionViewer = (onlyRemove = false): void => {
this.setState(({ reactionViewerRoot }) => {
if (reactionViewerRoot) {
document.body.removeChild(reactionViewerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutsideReactionViewer,
true
);
return { reactionViewerRoot: null };
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
document.body.addEventListener(
'click',
this.handleClickOutsideReactionViewer,
true
);
return {
reactionViewerRoot: root,
};
}
return { reactionViewerRoot: null };
});
};
public toggleReactionPicker = (onlyRemove = false): void => {
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 };
});
};
public handleClickOutsideReactionViewer = (e: MouseEvent): void => {
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);
}
}
};
public handleClickOutsideReactionPicker = (e: MouseEvent): void => {
const { reactionPickerRoot } = this.state;
if (reactionPickerRoot) {
if (!reactionPickerRoot.contains(e.target as HTMLElement)) {
this.toggleReactionPicker(true);
}
}
};
public renderReactions(outgoing: boolean): JSX.Element | null {
const { reactions = [], i18n } = this.props;
if (!this.hasReactions()) {
return null;
}
const reactionsWithEmojiData = reactions.map(reaction => ({
...reaction,
...emojiToData(reaction.emoji),
}));
// Group by emoji and order each group by timestamp descending
const groupedAndSortedReactions = Object.values(
groupBy(reactionsWithEmojiData, 'short_name')
).map(groupedReactions =>
orderBy(
groupedReactions,
[reaction => reaction.from.isMe, 'timestamp'],
['desc', 'desc']
)
);
// Order groups by length and subsequently by most recent reaction
const ordered = orderBy(
groupedAndSortedReactions,
['length', ([{ timestamp }]) => timestamp],
['desc', 'desc']
);
// Take the first three groups for rendering
const toRender = take(ordered, 3).map(res => ({
emoji: res[0].emoji,
count: res.length,
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 } = this.state;
const popperPlacement = outgoing ? 'bottom-end' : 'bottom-start';
return (
<Manager>
<Reference>
{({ ref: popperRef }) => (
<div
ref={this.reactionsContainerRefMerger(
this.reactionsContainerRef,
popperRef
)}
className={classNames(
'module-message__reactions',
outgoing
? 'module-message__reactions--outgoing'
: 'module-message__reactions--incoming'
)}
>
{toRender.map((re, i) => {
const isLast = i === toRender.length - 1;
const isMore = isLast && someNotRendered;
const isMoreWithMe = isMore && notRenderedIsMe;
return (
<button
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') {
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>
) : (
<>
<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}
</>
)}
</button>
);
})}
</div>
)}
</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>
);
}
public renderContents(): JSX.Element | null {
const { isTapToView, deletedForEveryone } = this.props;
if (deletedForEveryone) {
return this.renderText();
}
if (isTapToView) {
return (
<>
{this.renderTapToView()}
{this.renderMetadata()}
</>
);
}
return (
<>
{this.renderQuote()}
{this.renderAttachment()}
{this.renderPreview()}
{this.renderEmbeddedContact()}
{this.renderText()}
{this.renderMetadata()}
{this.renderSendMessageButton()}
</>
);
}
public handleOpen = (
event: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent
): void => {
const {
attachments,
contact,
displayTapToViewMessage,
direction,
id,
isTapToView,
isTapToViewExpired,
kickOffAttachmentDownload,
openConversation,
showContactDetail,
showVisualAttachment,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
} = 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 {
event.preventDefault();
event.stopPropagation();
displayTapToViewMessage(id);
}
return;
}
if (
!imageBroken &&
attachments &&
attachments.length > 0 &&
!isAttachmentPending &&
(isImage(attachments) || isVideo(attachments)) &&
hasNotDownloaded(attachments[0])
) {
event.preventDefault();
event.stopPropagation();
const attachment = attachments[0];
kickOffAttachmentDownload({ attachment, messageId: 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.audioButtonRef &&
this.audioButtonRef.current
) {
event.preventDefault();
event.stopPropagation();
this.audioButtonRef.current.click();
}
if (contact && contact.signalAccount) {
openConversation(contact.signalAccount);
event.preventDefault();
event.stopPropagation();
}
if (contact) {
showContactDetail({ contact, signalAccount: contact.signalAccount });
event.preventDefault();
event.stopPropagation();
}
};
public openGenericAttachment = (event?: React.MouseEvent): void => {
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,
});
};
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
// Do not allow reactions to error messages
const { canReply } = this.props;
if (
(event.key === 'E' || event.key === 'e') &&
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
canReply
) {
this.toggleReactionPicker();
}
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
this.handleOpen(event);
};
public handleClick = (event: React.MouseEvent): void => {
// 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;
}
// 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;
}
}
this.handleOpen(event);
};
public renderContainer(): JSX.Element {
const {
authorColor,
deletedForEveryone,
direction,
isSticker,
isTapToView,
isTapToViewExpired,
isTapToViewError,
} = this.props;
const { isSelected } = this.state;
const isAttachmentPending = this.isAttachmentPending();
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,
this.hasReactions() ? 'module-message__container--with-reactions' : null,
deletedForEveryone
? 'module-message__container--deleted-for-everyone'
: null
);
const containerStyles = {
width: isShowingImage ? width : undefined,
};
return (
<div className="module-message__container-outer">
<div className={containerClassnames} style={containerStyles}>
{this.renderAuthor()}
{this.renderContents()}
</div>
{this.renderReactions(direction === 'outgoing')}
</div>
);
}
public render(): JSX.Element | null {
const {
authorPhoneNumber,
attachments,
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
)}
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}
ref={this.focusRef}
>
{this.renderError(direction === 'incoming')}
{this.renderMenu(direction === 'outgoing', triggerId)}
{this.renderAvatar()}
{this.renderContainer()}
{this.renderError(direction === 'outgoing')}
{this.renderMenu(direction === 'incoming', triggerId)}
{this.renderContextMenu(triggerId)}
</div>
);
}
}