
Remove Android length warning Handle incoming long message attachments Show long download pending status in message bubble Fix the width of the smallest spinner Remove Android length warning from HTML templates
989 lines
26 KiB
TypeScript
989 lines
26 KiB
TypeScript
import React from 'react';
|
|
import classNames from 'classnames';
|
|
|
|
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 {
|
|
canDisplayImage,
|
|
getExtensionForDisplay,
|
|
getGridDimensions,
|
|
getImageDimensions,
|
|
hasImage,
|
|
hasVideoScreenshot,
|
|
isAudio,
|
|
isImage,
|
|
isImageAttachment,
|
|
isVideo,
|
|
} from '../../../ts/types/Attachment';
|
|
import { AttachmentType } from '../../types/Attachment';
|
|
import { Contact } from '../../types/Contact';
|
|
|
|
import { getIncrement } from '../../util/timer';
|
|
import { isFileDangerous } from '../../util/isFileDangerous';
|
|
import { ColorType, LocalizerType } from '../../types/Util';
|
|
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
|
|
|
interface Trigger {
|
|
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
}
|
|
|
|
// Same as MIN_WIDTH in ImageGrid.tsx
|
|
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
|
|
|
|
interface LinkPreviewType {
|
|
title: string;
|
|
domain: string;
|
|
url: string;
|
|
image?: AttachmentType;
|
|
}
|
|
|
|
export interface Props {
|
|
disableMenu?: boolean;
|
|
text?: string;
|
|
textPending?: boolean;
|
|
id?: string;
|
|
collapseMetadata?: boolean;
|
|
direction: 'incoming' | 'outgoing';
|
|
timestamp: number;
|
|
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
|
|
// What if changed this over to a single contact like quote, and put the events on it?
|
|
contact?: Contact & {
|
|
hasSignalAccount: boolean;
|
|
onSendMessage?: () => void;
|
|
onClick?: () => void;
|
|
};
|
|
i18n: LocalizerType;
|
|
authorName?: string;
|
|
authorProfileName?: string;
|
|
/** Note: this should be formatted for display */
|
|
authorPhoneNumber: string;
|
|
authorColor?: ColorType;
|
|
conversationType: 'group' | 'direct';
|
|
attachments?: Array<AttachmentType>;
|
|
quote?: {
|
|
text: string;
|
|
attachment?: QuotedAttachmentType;
|
|
isFromMe: boolean;
|
|
authorPhoneNumber: string;
|
|
authorProfileName?: string;
|
|
authorName?: string;
|
|
authorColor?: ColorType;
|
|
onClick?: () => void;
|
|
referencedMessageNotFound: boolean;
|
|
};
|
|
previews: Array<LinkPreviewType>;
|
|
authorAvatarPath?: string;
|
|
isExpired: boolean;
|
|
expirationLength?: number;
|
|
expirationTimestamp?: number;
|
|
onClickAttachment?: (attachment: AttachmentType) => void;
|
|
onClickLinkPreview?: (url: string) => void;
|
|
onReply?: () => void;
|
|
onRetrySend?: () => void;
|
|
onDownload?: (isDangerous: boolean) => void;
|
|
onDelete?: () => void;
|
|
onShowDetail: () => void;
|
|
}
|
|
|
|
interface State {
|
|
expiring: boolean;
|
|
expired: boolean;
|
|
imageBroken: boolean;
|
|
}
|
|
|
|
const EXPIRATION_CHECK_MINIMUM = 2000;
|
|
const EXPIRED_DELAY = 600;
|
|
|
|
export class Message extends React.PureComponent<Props, State> {
|
|
public captureMenuTriggerBound: (trigger: any) => void;
|
|
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
public handleImageErrorBound: () => void;
|
|
|
|
public menuTriggerRef: Trigger | undefined;
|
|
public expirationCheckInterval: any;
|
|
public expiredTimeout: any;
|
|
|
|
public constructor(props: Props) {
|
|
super(props);
|
|
|
|
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
|
|
this.showMenuBound = this.showMenu.bind(this);
|
|
this.handleImageErrorBound = this.handleImageError.bind(this);
|
|
|
|
this.state = {
|
|
expiring: false,
|
|
expired: false,
|
|
imageBroken: false,
|
|
};
|
|
}
|
|
|
|
public componentDidMount() {
|
|
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() {
|
|
if (this.expirationCheckInterval) {
|
|
clearInterval(this.expirationCheckInterval);
|
|
}
|
|
if (this.expiredTimeout) {
|
|
clearTimeout(this.expiredTimeout);
|
|
}
|
|
}
|
|
|
|
public componentDidUpdate() {
|
|
this.checkExpired();
|
|
}
|
|
|
|
public checkExpired() {
|
|
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);
|
|
}
|
|
}
|
|
|
|
public handleImageError() {
|
|
// tslint:disable-next-line no-console
|
|
console.log('Message: Image failed to load; failing over to placeholder');
|
|
this.setState({
|
|
imageBroken: true,
|
|
});
|
|
}
|
|
|
|
public renderMetadata() {
|
|
const {
|
|
collapseMetadata,
|
|
direction,
|
|
expirationLength,
|
|
expirationTimestamp,
|
|
i18n,
|
|
status,
|
|
text,
|
|
textPending,
|
|
timestamp,
|
|
} = this.props;
|
|
|
|
if (collapseMetadata) {
|
|
return null;
|
|
}
|
|
|
|
const isShowingImage = this.isShowingImage();
|
|
const withImageNoCaption = Boolean(!text && isShowingImage);
|
|
const showError = status === 'error' && direction === 'outgoing';
|
|
|
|
return (
|
|
<div
|
|
className={classNames(
|
|
'module-message__metadata',
|
|
withImageNoCaption
|
|
? 'module-message__metadata--with-image-no-caption'
|
|
: null
|
|
)}
|
|
>
|
|
{showError ? (
|
|
<span
|
|
className={classNames(
|
|
'module-message__metadata__date',
|
|
`module-message__metadata__date--${direction}`,
|
|
withImageNoCaption
|
|
? 'module-message__metadata__date--with-image-no-caption'
|
|
: null
|
|
)}
|
|
>
|
|
{i18n('sendFailed')}
|
|
</span>
|
|
) : (
|
|
<Timestamp
|
|
i18n={i18n}
|
|
timestamp={timestamp}
|
|
extended={true}
|
|
direction={direction}
|
|
withImageNoCaption={withImageNoCaption}
|
|
module="module-message__metadata__date"
|
|
/>
|
|
)}
|
|
{expirationLength && expirationTimestamp ? (
|
|
<ExpireTimer
|
|
direction={direction}
|
|
expirationLength={expirationLength}
|
|
expirationTimestamp={expirationTimestamp}
|
|
withImageNoCaption={withImageNoCaption}
|
|
/>
|
|
) : null}
|
|
<span className="module-message__metadata__spacer" />
|
|
{textPending ? (
|
|
<div className="module-message__metadata__spinner-container">
|
|
<Spinner size="mini" direction={direction} />
|
|
</div>
|
|
) : null}
|
|
{!textPending && direction === 'outgoing' && status !== 'error' ? (
|
|
<div
|
|
className={classNames(
|
|
'module-message__metadata__status-icon',
|
|
`module-message__metadata__status-icon--${status}`,
|
|
withImageNoCaption
|
|
? 'module-message__metadata__status-icon--with-image-no-caption'
|
|
: null
|
|
)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
public renderAuthor() {
|
|
const {
|
|
authorName,
|
|
authorPhoneNumber,
|
|
authorProfileName,
|
|
conversationType,
|
|
direction,
|
|
i18n,
|
|
} = this.props;
|
|
|
|
const title = authorName ? authorName : authorPhoneNumber;
|
|
|
|
if (direction !== 'incoming' || conversationType !== 'group' || !title) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="module-message__author">
|
|
<ContactName
|
|
phoneNumber={authorPhoneNumber}
|
|
name={authorName}
|
|
profileName={authorProfileName}
|
|
module="module-message__author"
|
|
i18n={i18n}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
|
|
public renderAttachment() {
|
|
const {
|
|
attachments,
|
|
text,
|
|
collapseMetadata,
|
|
conversationType,
|
|
direction,
|
|
i18n,
|
|
quote,
|
|
onClickAttachment,
|
|
} = 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) && hasImage(attachments)) ||
|
|
(isVideo(attachments) && hasVideoScreenshot(attachments)))
|
|
) {
|
|
return (
|
|
<div
|
|
className={classNames(
|
|
'module-message__attachment-container',
|
|
withContentAbove
|
|
? 'module-message__attachment-container--with-content-above'
|
|
: null,
|
|
withContentBelow
|
|
? 'module-message__attachment-container--with-content-below'
|
|
: null
|
|
)}
|
|
>
|
|
<ImageGrid
|
|
attachments={attachments}
|
|
withContentAbove={withContentAbove}
|
|
withContentBelow={withContentBelow}
|
|
bottomOverlay={!collapseMetadata}
|
|
i18n={i18n}
|
|
onError={this.handleImageErrorBound}
|
|
onClickAttachment={onClickAttachment}
|
|
/>
|
|
</div>
|
|
);
|
|
} else if (!firstAttachment.pending && isAudio(attachments)) {
|
|
return (
|
|
<audio
|
|
controls={true}
|
|
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>
|
|
);
|
|
} else {
|
|
const { pending, fileName, fileSize, contentType } = firstAttachment;
|
|
const extension = getExtensionForDisplay({ contentType, fileName });
|
|
const isDangerous = isFileDangerous(fileName || '');
|
|
|
|
return (
|
|
<div
|
|
className={classNames(
|
|
'module-message__generic-attachment',
|
|
withContentBelow
|
|
? 'module-message__generic-attachment--with-content-below'
|
|
: null,
|
|
withContentAbove
|
|
? 'module-message__generic-attachment--with-content-above'
|
|
: null
|
|
)}
|
|
>
|
|
{pending ? (
|
|
<div className="module-message__generic-attachment__spinner-container">
|
|
<Spinner size="small" 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>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// tslint:disable-next-line cyclomatic-complexity
|
|
public renderPreview() {
|
|
const {
|
|
attachments,
|
|
conversationType,
|
|
direction,
|
|
i18n,
|
|
onClickLinkPreview,
|
|
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 = width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
className={classNames(
|
|
'module-message__link-preview',
|
|
withContentAbove
|
|
? 'module-message__link-preview--with-content-above'
|
|
: null
|
|
)}
|
|
onClick={() => {
|
|
if (onClickLinkPreview) {
|
|
onClickLinkPreview(first.url);
|
|
}
|
|
}}
|
|
>
|
|
{first.image && previewHasImage && isFullSizeImage ? (
|
|
<ImageGrid
|
|
attachments={[first.image]}
|
|
withContentAbove={withContentAbove}
|
|
withContentBelow={true}
|
|
onError={this.handleImageErrorBound}
|
|
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}
|
|
softCorners={true}
|
|
alt={i18n('previewThumbnail', [first.domain])}
|
|
height={72}
|
|
width={72}
|
|
url={first.image.url}
|
|
attachment={first.image}
|
|
onError={this.handleImageErrorBound}
|
|
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>
|
|
<div className="module-message__link-preview__location">
|
|
{first.domain}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
public renderQuote() {
|
|
const {
|
|
conversationType,
|
|
authorColor,
|
|
direction,
|
|
i18n,
|
|
quote,
|
|
} = this.props;
|
|
|
|
if (!quote) {
|
|
return null;
|
|
}
|
|
|
|
const withContentAbove =
|
|
conversationType === 'group' && direction === 'incoming';
|
|
const quoteColor =
|
|
direction === 'incoming' ? authorColor : quote.authorColor;
|
|
|
|
return (
|
|
<Quote
|
|
i18n={i18n}
|
|
onClick={quote.onClick}
|
|
text={quote.text}
|
|
attachment={quote.attachment}
|
|
isIncoming={direction === 'incoming'}
|
|
authorPhoneNumber={quote.authorPhoneNumber}
|
|
authorProfileName={quote.authorProfileName}
|
|
authorName={quote.authorName}
|
|
authorColor={quoteColor}
|
|
referencedMessageNotFound={quote.referencedMessageNotFound}
|
|
isFromMe={quote.isFromMe}
|
|
withContentAbove={withContentAbove}
|
|
/>
|
|
);
|
|
}
|
|
|
|
public renderEmbeddedContact() {
|
|
const {
|
|
collapseMetadata,
|
|
contact,
|
|
conversationType,
|
|
direction,
|
|
i18n,
|
|
text,
|
|
} = this.props;
|
|
if (!contact) {
|
|
return null;
|
|
}
|
|
|
|
const withCaption = Boolean(text);
|
|
const withContentAbove =
|
|
conversationType === 'group' && direction === 'incoming';
|
|
const withContentBelow = withCaption || !collapseMetadata;
|
|
|
|
return (
|
|
<EmbeddedContact
|
|
contact={contact}
|
|
hasSignalAccount={contact.hasSignalAccount}
|
|
isIncoming={direction === 'incoming'}
|
|
i18n={i18n}
|
|
onClick={contact.onClick}
|
|
withContentAbove={withContentAbove}
|
|
withContentBelow={withContentBelow}
|
|
/>
|
|
);
|
|
}
|
|
|
|
public renderSendMessageButton() {
|
|
const { contact, i18n } = this.props;
|
|
if (!contact || !contact.hasSignalAccount) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
onClick={contact.onSendMessage}
|
|
className="module-message__send-message-button"
|
|
>
|
|
{i18n('sendMessageToContact')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
public renderAvatar() {
|
|
const {
|
|
authorAvatarPath,
|
|
authorName,
|
|
authorPhoneNumber,
|
|
authorProfileName,
|
|
collapseMetadata,
|
|
authorColor,
|
|
conversationType,
|
|
direction,
|
|
i18n,
|
|
} = this.props;
|
|
|
|
if (
|
|
collapseMetadata ||
|
|
conversationType !== 'group' ||
|
|
direction === 'outgoing'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
return (
|
|
<div className="module-message__author-avatar">
|
|
<Avatar
|
|
avatarPath={authorAvatarPath}
|
|
color={authorColor}
|
|
conversationType="direct"
|
|
i18n={i18n}
|
|
name={authorName}
|
|
phoneNumber={authorPhoneNumber}
|
|
profileName={authorProfileName}
|
|
size={36}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
public renderText() {
|
|
const { text, textPending, i18n, direction, status } = this.props;
|
|
|
|
const contents =
|
|
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
|
|
text={contents || ''}
|
|
i18n={i18n}
|
|
textPending={textPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
public renderError(isCorrectSide: boolean) {
|
|
const { status, direction } = this.props;
|
|
|
|
if (!isCorrectSide || status !== 'error') {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="module-message__error-container">
|
|
<div
|
|
className={classNames(
|
|
'module-message__error',
|
|
`module-message__error--${direction}`
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
public captureMenuTrigger(triggerRef: Trigger) {
|
|
this.menuTriggerRef = triggerRef;
|
|
}
|
|
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
|
|
if (this.menuTriggerRef) {
|
|
this.menuTriggerRef.handleContextClick(event);
|
|
}
|
|
}
|
|
|
|
public renderMenu(isCorrectSide: boolean, triggerId: string) {
|
|
const {
|
|
attachments,
|
|
direction,
|
|
disableMenu,
|
|
onDownload,
|
|
onReply,
|
|
} = this.props;
|
|
|
|
if (!isCorrectSide || disableMenu) {
|
|
return null;
|
|
}
|
|
|
|
const fileName =
|
|
attachments && attachments[0] ? attachments[0].fileName : null;
|
|
const isDangerous = isFileDangerous(fileName || '');
|
|
const multipleAttachments = attachments && attachments.length > 1;
|
|
const firstAttachment = attachments && attachments[0];
|
|
|
|
const downloadButton =
|
|
!multipleAttachments && firstAttachment && !firstAttachment.pending ? (
|
|
<div
|
|
onClick={() => {
|
|
if (onDownload) {
|
|
onDownload(isDangerous);
|
|
}
|
|
}}
|
|
role="button"
|
|
className={classNames(
|
|
'module-message__buttons__download',
|
|
`module-message__buttons__download--${direction}`
|
|
)}
|
|
/>
|
|
) : null;
|
|
|
|
const replyButton = (
|
|
<div
|
|
onClick={onReply}
|
|
role="button"
|
|
className={classNames(
|
|
'module-message__buttons__reply',
|
|
`module-message__buttons__download--${direction}`
|
|
)}
|
|
/>
|
|
);
|
|
|
|
const menuButton = (
|
|
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
|
|
<div
|
|
role="button"
|
|
onClick={this.showMenuBound}
|
|
className={classNames(
|
|
'module-message__buttons__menu',
|
|
`module-message__buttons__download--${direction}`
|
|
)}
|
|
/>
|
|
</ContextMenuTrigger>
|
|
);
|
|
|
|
const first = direction === 'incoming' ? downloadButton : menuButton;
|
|
const last = direction === 'incoming' ? menuButton : downloadButton;
|
|
|
|
return (
|
|
<div
|
|
className={classNames(
|
|
'module-message__buttons',
|
|
`module-message__buttons--${direction}`
|
|
)}
|
|
>
|
|
{first}
|
|
{replyButton}
|
|
{last}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
public renderContextMenu(triggerId: string) {
|
|
const {
|
|
attachments,
|
|
direction,
|
|
status,
|
|
onDelete,
|
|
onDownload,
|
|
onReply,
|
|
onRetrySend,
|
|
onShowDetail,
|
|
i18n,
|
|
} = this.props;
|
|
|
|
const showRetry = status === 'error' && direction === 'outgoing';
|
|
const fileName =
|
|
attachments && attachments[0] ? attachments[0].fileName : null;
|
|
const isDangerous = isFileDangerous(fileName || '');
|
|
const multipleAttachments = attachments && attachments.length > 1;
|
|
|
|
return (
|
|
<ContextMenu id={triggerId}>
|
|
{!multipleAttachments && attachments && attachments[0] ? (
|
|
<MenuItem
|
|
attributes={{
|
|
className: 'module-message__context__download',
|
|
}}
|
|
onClick={() => {
|
|
if (onDownload) {
|
|
onDownload(isDangerous);
|
|
}
|
|
}}
|
|
>
|
|
{i18n('downloadAttachment')}
|
|
</MenuItem>
|
|
) : null}
|
|
<MenuItem
|
|
attributes={{
|
|
className: 'module-message__context__reply',
|
|
}}
|
|
onClick={onReply}
|
|
>
|
|
{i18n('replyToMessage')}
|
|
</MenuItem>
|
|
<MenuItem
|
|
attributes={{
|
|
className: 'module-message__context__more-info',
|
|
}}
|
|
onClick={onShowDetail}
|
|
>
|
|
{i18n('moreInfo')}
|
|
</MenuItem>
|
|
{showRetry ? (
|
|
<MenuItem
|
|
attributes={{
|
|
className: 'module-message__context__retry-send',
|
|
}}
|
|
onClick={onRetrySend}
|
|
>
|
|
{i18n('retrySend')}
|
|
</MenuItem>
|
|
) : null}
|
|
<MenuItem
|
|
attributes={{
|
|
className: 'module-message__context__delete-message',
|
|
}}
|
|
onClick={onDelete}
|
|
>
|
|
{i18n('deleteMessage')}
|
|
</MenuItem>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
public getWidth(): number | undefined {
|
|
const { attachments, previews } = this.props;
|
|
|
|
if (attachments && attachments.length) {
|
|
const dimensions = getGridDimensions(attachments);
|
|
if (dimensions) {
|
|
return dimensions.width;
|
|
}
|
|
}
|
|
|
|
if (previews && previews.length) {
|
|
const first = previews[0];
|
|
|
|
if (!first || !first.image) {
|
|
return;
|
|
}
|
|
const { width } = first.image;
|
|
|
|
if (
|
|
isImageAttachment(first.image) &&
|
|
width &&
|
|
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH
|
|
) {
|
|
const dimensions = getImageDimensions(first.image);
|
|
if (dimensions) {
|
|
return dimensions.width;
|
|
}
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
public isShowingImage() {
|
|
const { attachments, previews } = this.props;
|
|
const { imageBroken } = this.state;
|
|
|
|
if (imageBroken) {
|
|
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;
|
|
}
|
|
|
|
public render() {
|
|
const {
|
|
authorPhoneNumber,
|
|
authorColor,
|
|
direction,
|
|
id,
|
|
timestamp,
|
|
} = this.props;
|
|
const { expired, expiring } = 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;
|
|
}
|
|
|
|
const width = this.getWidth();
|
|
const isShowingImage = this.isShowingImage();
|
|
|
|
return (
|
|
<div
|
|
className={classNames(
|
|
'module-message',
|
|
`module-message--${direction}`,
|
|
expiring ? 'module-message--expired' : null
|
|
)}
|
|
>
|
|
{this.renderError(direction === 'incoming')}
|
|
{this.renderMenu(direction === 'outgoing', triggerId)}
|
|
<div
|
|
className={classNames(
|
|
'module-message__container',
|
|
`module-message__container--${direction}`,
|
|
direction === 'incoming'
|
|
? `module-message__container--incoming-${authorColor}`
|
|
: null
|
|
)}
|
|
style={{
|
|
width: isShowingImage ? width : undefined,
|
|
}}
|
|
>
|
|
{this.renderAuthor()}
|
|
{this.renderQuote()}
|
|
{this.renderAttachment()}
|
|
{this.renderPreview()}
|
|
{this.renderEmbeddedContact()}
|
|
{this.renderText()}
|
|
{this.renderMetadata()}
|
|
{this.renderSendMessageButton()}
|
|
{this.renderAvatar()}
|
|
</div>
|
|
{this.renderError(direction === 'outgoing')}
|
|
{this.renderMenu(direction === 'incoming', triggerId)}
|
|
{this.renderContextMenu(triggerId)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|