Signal-Desktop/ts/components/conversation/Message.tsx

3031 lines
85 KiB
TypeScript
Raw Normal View History

// Copyright 2018-2022 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode, RefObject } from 'react';
import React from 'react';
2020-01-17 22:23:19 +00:00
import ReactDOM, { createPortal } from 'react-dom';
import classNames from 'classnames';
2022-05-11 20:59:58 +00:00
import getDirection from 'direction';
import { drop, groupBy, orderBy, take, unescape } 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 type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
import type {
ConversationType,
ConversationTypeType,
InteractionModeType,
} from '../../state/ducks/conversations';
2022-07-06 19:06:20 +00:00
import type { ViewStoryActionCreatorType } from '../../state/ducks/stories';
import type { TimelineItemType } from './TimelineItem';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { Avatar, AvatarSize } from '../Avatar';
import { AvatarSpacer } from '../AvatarSpacer';
import { Spinner } from '../Spinner';
import {
doesMessageBodyOverflow,
MessageBodyReadMore,
} from './MessageBodyReadMore';
import { MessageMetadata } from './MessageMetadata';
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
2019-01-14 21:49:58 +00:00
import { ImageGrid } from './ImageGrid';
2021-04-27 22:11:59 +00:00
import { GIF } from './GIF';
import { CurveType, Image } from './Image';
2019-01-14 21:49:58 +00:00
import { ContactName } from './ContactName';
import type { QuotedAttachmentType } from './Quote';
import { Quote } from './Quote';
2019-01-14 21:49:58 +00:00
import { EmbeddedContact } from './EmbeddedContact';
import type { OwnProps as ReactionViewerProps } from './ReactionViewer';
import { ReactionViewer } from './ReactionViewer';
import type { Props as ReactionPickerProps } 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';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
import { WidthBreakpoint } from '../_util';
2022-05-11 20:59:58 +00:00
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
import * as log from '../../logging/log';
2022-07-06 19:06:20 +00:00
import { StoryViewModeType } from '../../types/Stories';
2019-01-14 21:49:58 +00:00
import type { AttachmentType } from '../../types/Attachment';
import {
2019-01-14 21:49:58 +00:00
canDisplayImage,
getExtensionForDisplay,
getGridDimensions,
2019-01-16 03:03:56 +00:00
getImageDimensions,
hasImage,
2022-03-29 01:10:08 +00:00
isDownloaded,
hasVideoScreenshot,
2019-01-14 21:49:58 +00:00
isAudio,
isImage,
2019-01-16 03:03:56 +00:00
isImageAttachment,
isVideo,
2021-04-27 22:11:59 +00:00
isGIF,
2020-09-14 19:51:27 +00:00
} from '../../types/Attachment';
import type { EmbeddedContactType } from '../../types/EmbeddedContact';
2019-01-14 21:49:58 +00:00
import { getIncrement } from '../../util/timer';
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
2018-10-04 01:12:42 +00:00
import { isFileDangerous } from '../../util/isFileDangerous';
import { missingCaseError } from '../../util/missingCaseError';
import type {
BodyRangesType,
LocalizerType,
ThemeType,
} from '../../types/Util';
2022-05-11 20:59:58 +00:00
2021-11-17 21:11:46 +00:00
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
import type {
2021-05-28 16:15:17 +00:00
ContactNameColorType,
ConversationColorType,
CustomColorType,
} from '../../types/Colors';
import { createRefMerger } from '../../util/refMerger';
2021-10-06 17:37:53 +00:00
import { emojiToData, getEmojiCount } from '../emoji/lib';
import { isEmojiOnlyText } from '../../util/isEmojiOnlyText';
import type { SmartReactionPicker } from '../../state/smart/ReactionPicker';
2021-05-28 16:15:17 +00:00
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
import { offsetDistanceModifier } from '../../util/popperUtil';
2021-09-29 21:20:52 +00:00
import * as KeyboardLayout from '../../services/keyboardLayout';
import { StopPropagation } from '../StopPropagation';
import type { UUIDStringType } from '../../types/UUID';
2022-05-11 20:59:58 +00:00
import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
import { handleOutsideClick } from '../../util/handleOutsideClick';
type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
};
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
delivered: 24,
error: 24,
paused: 18,
'partial-sent': 24,
read: 24,
sending: 18,
sent: 24,
viewed: 24,
};
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT;
const STICKER_SIZE = 200;
2021-04-27 22:11:59 +00:00
const GIF_SIZE = 300;
// Note: this needs to match the animation time
const SELECTED_TIMEOUT = 1200;
const THREE_HOURS = 3 * 60 * 60 * 1000;
const SENT_STATUSES = new Set<MessageStatusType>([
'delivered',
'read',
'sent',
'viewed',
]);
2022-05-11 20:59:58 +00:00
const GIFT_BADGE_UPDATE_INTERVAL = 30 * SECOND;
enum MetadataPlacement {
NotRendered,
RenderedByMessageAudioComponent,
InlineWithText,
Bottom,
}
2019-01-16 03:03:56 +00:00
export enum TextDirection {
LeftToRight = 'LeftToRight',
RightToLeft = 'RightToLeft',
Default = 'Default',
None = 'None',
}
2020-08-27 18:10:35 +00:00
export const MessageStatuses = [
2020-08-27 16:57:12 +00:00
'delivered',
'error',
'paused',
2020-08-27 16:57:12 +00:00
'partial-sent',
'read',
'sending',
'sent',
'viewed',
2020-08-27 16:57:12 +00:00
] as const;
2020-08-27 18:10:35 +00:00
export type MessageStatusType = typeof MessageStatuses[number];
export const Directions = ['incoming', 'outgoing'] as const;
export type DirectionType = typeof Directions[number];
export type AudioAttachmentProps = {
renderingContext: string;
i18n: LocalizerType;
buttonRef: React.RefObject<HTMLButtonElement>;
theme: ThemeType | undefined;
attachment: AttachmentType;
collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
direction: DirectionType;
expirationLength?: number;
expirationTimestamp?: number;
id: string;
conversationId: string;
played: boolean;
showMessageDetail: (id: string) => void;
status?: MessageStatusType;
textPending?: boolean;
timestamp: number;
kickOffAttachmentDownload(): void;
onCorrupted(): void;
onFirstPlayed(): void;
};
2022-05-11 20:59:58 +00:00
export enum GiftBadgeStates {
Unopened = 'Unopened',
2022-05-16 19:54:38 +00:00
Opened = 'Opened',
2022-05-11 20:59:58 +00:00
Redeemed = 'Redeemed',
}
export type GiftBadgeType = {
expiration: number;
2022-05-16 19:54:38 +00:00
id: string | undefined;
level: number;
state: GiftBadgeStates;
2022-05-11 20:59:58 +00:00
};
export type PropsData = {
id: string;
renderingContext: string;
2021-05-28 16:15:17 +00:00
contactNameColor?: ContactNameColorType;
conversationColor: ConversationColorType;
2022-05-11 20:59:58 +00:00
conversationTitle: string;
2021-05-28 16:15:17 +00:00
customColor?: CustomColorType;
2019-11-07 21:36:16 +00:00
conversationId: string;
displayLimit?: number;
text?: string;
textDirection: TextDirection;
textAttachment?: AttachmentType;
isSticker?: boolean;
isSelected?: boolean;
isSelectedCounter?: number;
2020-08-27 18:10:35 +00:00
direction: DirectionType;
timestamp: number;
2020-08-27 18:10:35 +00:00
status?: MessageStatusType;
contact?: EmbeddedContactType;
2021-04-27 19:55:21 +00:00
author: Pick<
ConversationType,
| 'acceptedMessageRequest'
2021-04-27 19:55:21 +00:00
| 'avatarPath'
| 'badges'
2021-04-27 19:55:21 +00:00
| 'color'
| 'id'
| 'isMe'
2021-04-27 19:55:21 +00:00
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
2021-04-27 19:55:21 +00:00
| 'title'
| 'unblurredAvatarPath'
2021-04-27 19:55:21 +00:00
>;
2021-04-27 22:11:59 +00:00
reducedMotion?: boolean;
conversationType: ConversationTypeType;
attachments?: Array<AttachmentType>;
2022-05-11 20:59:58 +00:00
giftBadge?: GiftBadgeType;
quote?: {
2021-05-28 16:15:17 +00:00
conversationColor: ConversationColorType;
customColor?: CustomColorType;
text: string;
2021-04-02 21:35:28 +00:00
rawAttachment?: 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;
2020-09-16 22:42:48 +00:00
bodyRanges?: BodyRangesType;
referencedMessageNotFound: boolean;
isViewOnce: boolean;
2022-05-11 20:59:58 +00:00
isGiftBadge: boolean;
};
2022-03-16 17:30:14 +00:00
storyReplyContext?: {
authorTitle: string;
conversationColor: ConversationColorType;
customColor?: CustomColorType;
emoji?: string;
2022-03-16 17:30:14 +00:00
isFromMe: boolean;
rawAttachment?: QuotedAttachmentType;
2022-07-06 19:06:20 +00:00
storyId?: string;
text: string;
2022-03-16 17:30:14 +00:00
};
2019-01-16 03:03:56 +00:00
previews: Array<LinkPreviewType>;
2019-06-26 19:33:13 +00:00
isTapToView?: boolean;
isTapToViewExpired?: boolean;
isTapToViewError?: boolean;
readStatus?: ReadStatus;
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;
canRetry: boolean;
canRetryDeleteForEveryone: boolean;
canReact: boolean;
canReply: boolean;
canDownload: boolean;
canDeleteForEveryone: boolean;
isBlocked: boolean;
isMessageRequestAccepted: boolean;
2020-09-16 22:42:48 +00:00
bodyRanges?: BodyRangesType;
};
export type PropsHousekeeping = {
containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
disableMenu?: boolean;
disableScroll?: boolean;
2021-11-17 21:11:46 +00:00
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
interactionMode: InteractionModeType;
item?: TimelineItemType;
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean;
shouldHideMetadata: boolean;
theme: ThemeType;
};
export type PropsActions = {
clearSelectedMessage: () => unknown;
doubleCheckMissingQuoteReference: (messageId: string) => unknown;
messageExpanded: (id: string, displayLimit: number) => unknown;
checkForAccount: (phoneNumber: string) => unknown;
2020-01-23 23:57:37 +00:00
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
) => void;
replyToMessage: (id: string) => void;
retryDeleteForEveryone: (id: string) => void;
retrySend: (id: string) => void;
2021-04-27 22:35:35 +00:00
showForwardMessageModal: (id: string) => void;
deleteMessage: (id: string) => void;
deleteMessageForEveryone: (id: string) => void;
showMessageDetail: (id: string) => void;
startConversation: (e164: string, uuid: UUIDStringType) => void;
openConversation: (conversationId: string, messageId?: string) => void;
2022-05-11 20:59:58 +00:00
openGiftBadge: (messageId: string) => void;
2020-01-08 17:44:54 +00:00
showContactDetail: (options: {
contact: EmbeddedContactType;
signalAccount?: {
phoneNumber: string;
uuid: UUIDStringType;
};
2020-01-08 17:44:54 +00:00
}) => void;
showContactModal: (contactId: string, conversationId?: string) => void;
2020-01-08 17:44:54 +00:00
2021-01-29 22:58:28 +00:00
kickOffAttachmentDownload: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
markAttachmentAsCorrupted: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
markViewed(messageId: string): void;
2020-01-08 17:44:54 +00:00
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;
scrollToQuotedMessage: (options: {
authorId: string;
sentAt: number;
}) => void;
selectMessage?: (messageId: string, conversationId: string) => unknown;
showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown;
2022-07-06 19:06:20 +00:00
viewStory: ViewStoryActionCreatorType;
};
export type Props = PropsData &
PropsHousekeeping &
PropsActions &
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
type State = {
metadataWidth: number;
expiring: boolean;
expired: boolean;
imageBroken: boolean;
isSelected?: boolean;
prevSelectedCounter?: number;
2020-01-17 22:23:19 +00:00
reactionViewerRoot: HTMLDivElement | null;
reactionViewerOutsideClickDestructor?: () => void;
2020-01-23 23:57:37 +00:00
reactionPickerRoot: HTMLDivElement | null;
reactionPickerOutsideClickDestructor?: () => void;
2020-01-23 23:57:37 +00:00
2022-05-11 20:59:58 +00:00
giftBadgeCounter: number | null;
showOutgoingGiftBadgeModal: boolean;
hasDeleteForEveryoneTimerExpired: boolean;
};
2021-08-11 16:23:21 +00:00
export class Message extends React.PureComponent<Props, State> {
2019-01-14 21:49:58 +00:00
public menuTriggerRef: Trigger | undefined;
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
public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();
2021-11-11 22:43:05 +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-09-14 19:51:27 +00:00
public expirationCheckInterval: NodeJS.Timeout | undefined;
2022-05-11 20:59:58 +00:00
public giftBadgeInterval: NodeJS.Timeout | undefined;
2020-09-14 19:51:27 +00:00
public expiredTimeout: NodeJS.Timeout | undefined;
public selectedTimeout: NodeJS.Timeout | undefined;
public deleteForEveryoneTimeout: NodeJS.Timeout | undefined;
public constructor(props: Props) {
super(props);
this.state = {
metadataWidth: this.guessMetadataWidth(),
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,
2022-05-11 20:59:58 +00:00
giftBadgeCounter: null,
showOutgoingGiftBadgeModal: false,
hasDeleteForEveryoneTimerExpired:
this.getTimeRemainingForDeleteForEveryone() <= 0,
};
}
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;
}
2021-01-27 21:15:43 +00:00
private hasReactions(): boolean {
const { reactions } = this.props;
return Boolean(reactions && reactions.length);
}
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);
}
};
public showContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
2021-06-09 22:30:05 +00:00
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
return;
}
if (event.target instanceof HTMLAnchorElement) {
return;
}
2021-06-09 22:30:05 +00:00
this.showMenu(event);
};
2020-09-14 19:51:27 +00:00
public handleImageError = (): void => {
2019-11-07 21:36:16 +00:00
const { id } = this.props;
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, isSelected } = this.props;
if (interactionMode === 'keyboard' && !isSelected) {
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
}
};
public override componentDidMount(): void {
2022-01-20 00:40:29 +00:00
const { conversationId } = this.props;
window.ConversationController?.onConvoMessageMount(conversationId);
2022-01-20 00:40:29 +00:00
this.startSelectedTimer();
this.startDeleteForEveryoneTimerIfApplicable();
2022-05-11 20:59:58 +00:00
this.startGiftBadgeInterval();
2019-11-07 21:36:16 +00:00
const { isSelected } = this.props;
if (isSelected) {
this.setFocus();
}
const { expirationLength } = this.props;
if (expirationLength) {
const increment = getIncrement(expirationLength);
const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
this.checkExpired();
this.expirationCheckInterval = setInterval(() => {
this.checkExpired();
}, checkFrequency);
}
const { contact, checkForAccount } = this.props;
if (contact && contact.firstNumber && !contact.uuid) {
checkForAccount(contact.firstNumber);
}
}
public override componentWillUnmount(): void {
clearTimeoutIfNecessary(this.selectedTimeout);
clearTimeoutIfNecessary(this.expirationCheckInterval);
clearTimeoutIfNecessary(this.expiredTimeout);
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
2022-05-11 20:59:58 +00:00
clearTimeoutIfNecessary(this.giftBadgeInterval);
2020-01-17 22:23:19 +00:00
this.toggleReactionViewer(true);
2020-01-23 23:57:37 +00:00
this.toggleReactionPicker(true);
}
public override componentDidUpdate(prevProps: Readonly<Props>): void {
const { isSelected, status, timestamp } = this.props;
2020-09-14 19:51:27 +00:00
this.startSelectedTimer();
this.startDeleteForEveryoneTimerIfApplicable();
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();
2021-07-30 18:37:03 +00:00
if (
prevProps.status === 'sending' &&
(status === 'sent' ||
status === 'delivered' ||
status === 'read' ||
status === 'viewed')
) {
const delta = Date.now() - timestamp;
2021-08-11 19:29:07 +00:00
window.CI?.handleEvent('message:send-complete', {
timestamp,
delta,
});
log.info(
2021-07-30 18:37:03 +00:00
`Message.tsx: Rendered 'send complete' for message ${timestamp}; took ${delta}ms`
);
}
}
private getMetadataPlacement(
{
attachments,
deletedForEveryone,
direction,
expirationLength,
expirationTimestamp,
2022-05-11 20:59:58 +00:00
giftBadge,
i18n,
shouldHideMetadata,
status,
text,
textDirection,
}: Readonly<Props> = this.props
): MetadataPlacement {
const isRTL = textDirection === TextDirection.RightToLeft;
if (
!expirationLength &&
!expirationTimestamp &&
(!status || SENT_STATUSES.has(status)) &&
shouldHideMetadata
) {
return MetadataPlacement.NotRendered;
}
2022-05-11 20:59:58 +00:00
if (giftBadge) {
const description = i18n(`message--giftBadge--unopened--${direction}`);
2022-05-11 20:59:58 +00:00
const isDescriptionRTL = getDirection(description) === 'rtl';
if (giftBadge.state === GiftBadgeStates.Unopened && !isDescriptionRTL) {
return MetadataPlacement.InlineWithText;
}
return MetadataPlacement.Bottom;
}
if (!text && !deletedForEveryone) {
return isAudio(attachments)
? MetadataPlacement.RenderedByMessageAudioComponent
: MetadataPlacement.Bottom;
}
if (this.canRenderStickerLikeEmoji()) {
return MetadataPlacement.Bottom;
}
if (isRTL) {
return MetadataPlacement.Bottom;
}
return MetadataPlacement.InlineWithText;
}
/**
* A lot of the time, we add an invisible inline spacer for messages. This spacer is the
* same size as the message metadata. Unfortunately, we don't know how wide it is until
* we render it.
*
* This will probably guess wrong, but it's valuable to get close to the real value
* because it can reduce layout jumpiness.
*/
private guessMetadataWidth(): number {
const { direction, expirationLength, status } = this.props;
let result = GUESS_METADATA_WIDTH_TIMESTAMP_SIZE;
const hasExpireTimer = Boolean(expirationLength);
if (hasExpireTimer) {
result += GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE;
}
if (direction === 'outgoing' && status) {
result += GUESS_METADATA_WIDTH_OUTGOING_SIZE[status];
}
return result;
}
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);
}
}
2022-05-11 20:59:58 +00:00
public startGiftBadgeInterval(): void {
const { giftBadge } = this.props;
if (!giftBadge) {
return;
}
this.giftBadgeInterval = setInterval(() => {
this.updateGiftBadgeCounter();
}, GIFT_BADGE_UPDATE_INTERVAL);
}
public updateGiftBadgeCounter(): void {
this.setState((state: State) => ({
giftBadgeCounter: (state.giftBadgeCounter || 0) + 1,
}));
}
private getTimeRemainingForDeleteForEveryone(): number {
const { timestamp } = this.props;
return Math.max(timestamp - Date.now() + THREE_HOURS, 0);
}
private canDeleteForEveryone(): boolean {
const { canDeleteForEveryone } = this.props;
const { hasDeleteForEveryoneTimerExpired } = this.state;
return canDeleteForEveryone && !hasDeleteForEveryoneTimerExpired;
}
private startDeleteForEveryoneTimerIfApplicable(): void {
const { canDeleteForEveryone } = this.props;
const { hasDeleteForEveryoneTimerExpired } = this.state;
if (
!canDeleteForEveryone ||
hasDeleteForEveryoneTimerExpired ||
this.deleteForEveryoneTimeout
) {
return;
}
this.deleteForEveryoneTimeout = setTimeout(() => {
this.setState({ hasDeleteForEveryoneTimerExpired: true });
delete this.deleteForEveryoneTimeout;
}, this.getTimeRemainingForDeleteForEveryone());
}
2020-09-14 19:51:27 +00:00
public checkExpired(): void {
const now = Date.now();
2021-06-16 22:20:17 +00:00
const { expirationTimestamp, expirationLength } = this.props;
if (!expirationTimestamp || !expirationLength) {
return;
}
if (this.expiredTimeout) {
return;
}
2021-06-16 22:20:17 +00:00
if (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;
}
private shouldRenderAuthor(): boolean {
const { author, conversationType, direction, shouldCollapseAbove } =
this.props;
return Boolean(
direction === 'incoming' &&
conversationType === 'group' &&
author.title &&
!shouldCollapseAbove
);
}
private canRenderStickerLikeEmoji(): boolean {
const { text, quote, storyReplyContext, attachments, previews } =
this.props;
return Boolean(
text &&
isEmojiOnlyText(text) &&
getEmojiCount(text) < 6 &&
!quote &&
!storyReplyContext &&
(!attachments || !attachments.length) &&
(!previews || !previews.length)
);
}
private updateMetadataWidth = (newMetadataWidth: number): void => {
this.setState(({ metadataWidth }) => ({
// We don't want text to jump around if the metadata shrinks, but we want to make
// sure we have enough room.
metadataWidth: Math.max(metadataWidth, newMetadataWidth),
}));
};
private renderMetadata(): ReactNode {
let isInline: boolean;
const metadataPlacement = this.getMetadataPlacement();
switch (metadataPlacement) {
case MetadataPlacement.NotRendered:
case MetadataPlacement.RenderedByMessageAudioComponent:
return null;
case MetadataPlacement.InlineWithText:
isInline = true;
break;
case MetadataPlacement.Bottom:
isInline = false;
break;
default:
log.error(missingCaseError(metadataPlacement));
isInline = false;
break;
}
const {
deletedForEveryone,
direction,
expirationLength,
expirationTimestamp,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToViewExpired,
status,
i18n,
text,
textAttachment,
timestamp,
id,
showMessageDetail,
} = this.props;
const isStickerLike = isSticker || this.canRenderStickerLikeEmoji();
2021-10-06 17:37:53 +00:00
return (
<MessageMetadata
deletedForEveryone={deletedForEveryone}
direction={direction}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
hasText={Boolean(text)}
i18n={i18n}
id={id}
isInline={isInline}
isShowingImage={this.isShowingImage()}
2021-10-06 17:37:53 +00:00
isSticker={isStickerLike}
isTapToViewExpired={isTapToViewExpired}
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
showMessageDetail={showMessageDetail}
status={status}
textPending={textAttachment?.pending}
timestamp={timestamp}
/>
);
}
private renderAuthor(): ReactNode {
const {
2021-04-27 19:55:21 +00:00
author,
2021-05-28 16:15:17 +00:00
contactNameColor,
2022-08-25 16:10:56 +00:00
i18n,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToView,
isTapToViewExpired,
} = this.props;
if (!this.shouldRenderAuthor()) {
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
2021-05-28 16:15:17 +00:00
contactNameColor={contactNameColor}
2022-08-25 16:10:56 +00:00
title={author.isMe ? i18n('you') : author.title}
module={moduleName}
/>
</div>
);
}
2020-09-14 19:51:27 +00:00
public renderAttachment(): JSX.Element | null {
const {
attachments,
direction,
expirationLength,
expirationTimestamp,
i18n,
id,
conversationId,
isSticker,
2021-01-29 22:58:28 +00:00
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
markViewed,
quote,
readStatus,
reducedMotion,
renderAudioAttachment,
renderingContext,
showMessageDetail,
showVisualAttachment,
shouldCollapseAbove,
shouldCollapseBelow,
status,
text,
textAttachment,
theme,
timestamp,
} = this.props;
2019-11-07 21:36:16 +00:00
const { imageBroken } = this.state;
const collapseMetadata =
this.getMetadataPlacement() === MetadataPlacement.NotRendered;
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) || this.shouldRenderAuthor();
const displayImage = canDisplayImage(attachments);
2021-04-27 22:11:59 +00:00
if (displayImage && !imageBroken) {
const prefix = isSticker ? 'sticker' : 'attachment';
2021-04-27 22:11:59 +00:00
const containerClassName = 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
);
2021-04-27 22:11:59 +00:00
if (isGIF(attachments)) {
return (
<div className={containerClassName}>
<GIF
attachment={firstAttachment}
size={GIF_SIZE}
theme={theme}
i18n={i18n}
tabIndex={0}
reducedMotion={reducedMotion}
onError={this.handleImageError}
2021-07-14 23:39:52 +00:00
showVisualAttachment={() => {
showVisualAttachment({
attachment: firstAttachment,
messageId: id,
});
}}
2021-04-27 22:11:59 +00:00
kickOffAttachmentDownload={() => {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
}}
/>
</div>
);
}
if (
isImage(attachments) ||
(isVideo(attachments) &&
(!isDownloaded(attachments[0]) ||
!attachments?.[0].pending ||
hasVideoScreenshot(attachments)))
) {
2021-04-27 22:11:59 +00:00
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={containerClassName}>
<ImageGrid
attachments={attachments}
direction={direction}
withContentAbove={isSticker || withContentAbove}
withContentBelow={isSticker || withContentBelow}
2021-04-27 22:11:59 +00:00
isSticker={isSticker}
stickerSize={STICKER_SIZE}
bottomOverlay={bottomOverlay}
i18n={i18n}
onError={this.handleImageError}
theme={theme}
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
2021-04-27 22:11:59 +00:00
tabIndex={tabIndex}
onClick={attachment => {
2022-03-29 01:10:08 +00:00
if (!isDownloaded(attachment)) {
2021-04-27 22:11:59 +00:00
kickOffAttachmentDownload({ attachment, messageId: id });
} else {
showVisualAttachment({ attachment, messageId: id });
}
}}
/>
</div>
);
}
2020-09-14 19:51:27 +00:00
}
if (isAudio(attachments)) {
let played: boolean;
switch (direction) {
case 'outgoing':
played = status === 'viewed';
break;
case 'incoming':
played = readStatus === ReadStatus.Viewed;
break;
default:
log.error(missingCaseError(direction));
played = false;
break;
}
return renderAudioAttachment({
i18n,
buttonRef: this.audioButtonRef,
renderingContext,
theme,
attachment: firstAttachment,
collapseMetadata,
withContentAbove,
withContentBelow,
direction,
expirationLength,
expirationTimestamp,
id,
conversationId,
played,
showMessageDetail,
status,
textPending: textAttachment?.pending,
timestamp,
kickOffAttachmentDownload() {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
},
onCorrupted() {
markAttachmentAsCorrupted({
attachment: firstAttachment,
messageId: id,
});
},
onFirstPlayed() {
markViewed(id);
},
});
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 => {
event.stopPropagation();
event.preventDefault();
if (!isDownloaded(firstAttachment)) {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
} else {
this.openGenericAttachment();
}
}}
2020-09-14 19:51:27 +00:00
>
{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,
2022-05-11 20:59:58 +00:00
id,
kickOffAttachmentDownload,
openLink,
2019-01-16 03:03:56 +00:00
previews,
quote,
shouldCollapseAbove,
theme,
2019-01-16 03:03:56 +00:00
} = 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) ||
(!shouldCollapseAbove &&
conversationType === 'group' &&
direction === 'incoming');
2019-01-16 03:03:56 +00:00
const previewHasImage = isImageAttachment(first.image);
const isFullSizeImage = shouldUseFullSizeLinkPreviewImage(first);
2019-01-16 03:03:56 +00:00
2020-09-28 23:46:31 +00:00
const linkPreviewDate = first.date || null;
const isClickable = this.areLinksEnabled();
2019-11-07 21:36:16 +00:00
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 onPreviewImageClick = () => {
2022-03-29 01:10:08 +00:00
if (first.image && !isDownloaded(first.image)) {
kickOffAttachmentDownload({
attachment: first.image,
messageId: id,
});
2021-06-04 00:42:01 +00:00
return;
}
2021-06-04 00:42:01 +00:00
openLink(first.url);
};
const contents = (
<>
2019-01-16 03:03:56 +00:00
{first.image && previewHasImage && isFullSizeImage ? (
<ImageGrid
attachments={[first.image]}
withContentAbove={withContentAbove}
direction={direction}
shouldCollapseAbove={shouldCollapseAbove}
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}
theme={theme}
onClick={onPreviewImageClick}
2019-01-16 03:03:56 +00:00
/>
) : null}
<div className="module-message__link-preview__content">
2022-06-17 00:48:57 +00:00
{first.image &&
first.domain &&
previewHasImage &&
!isFullSizeImage ? (
2019-01-16 03:03:56 +00:00
<div className="module-message__link-preview__icon_container">
<Image
2020-09-14 19:51:27 +00:00
noBorder
noBackground
curveBottomLeft={
withContentAbove ? CurveType.Tiny : CurveType.Small
}
curveBottomRight={CurveType.Tiny}
curveTopRight={CurveType.Tiny}
curveTopLeft={CurveType.Tiny}
2019-01-16 03:03:56 +00:00
alt={i18n('previewThumbnail', [first.domain])}
height={72}
width={72}
url={first.image.url}
attachment={first.image}
blurHash={first.image.blurHash}
2019-11-07 21:36:16 +00:00
onError={this.handleImageError}
2019-01-16 03:03:56 +00:00
i18n={i18n}
onClick={onPreviewImageClick}
2019-01-16 03:03:56 +00:00
/>
</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">
{unescape(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>
</>
);
return isClickable ? (
<div
role="link"
tabIndex={0}
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}
</div>
) : (
<div className={className}>{contents}</div>
2019-01-16 03:03:56 +00:00
);
}
2022-05-11 20:59:58 +00:00
public renderGiftBadge(): JSX.Element | null {
const { conversationTitle, direction, getPreferredBadge, giftBadge, i18n } =
this.props;
const { showOutgoingGiftBadgeModal } = this.state;
if (!giftBadge) {
return null;
}
if (giftBadge.state === GiftBadgeStates.Unopened) {
const description = i18n(`message--giftBadge--unopened--${direction}`);
2022-05-11 20:59:58 +00:00
const isRTL = getDirection(description) === 'rtl';
const { metadataWidth } = this.state;
return (
<div className="module-message__unopened-gift-badge__container">
<div
className={classNames(
'module-message__unopened-gift-badge',
`module-message__unopened-gift-badge--${direction}`
)}
aria-label={i18n('message--giftBadge--unopened--label')}
>
<div
className="module-message__unopened-gift-badge__ribbon-horizontal"
aria-hidden
/>
<div
className="module-message__unopened-gift-badge__ribbon-vertical"
aria-hidden
/>
<img
className="module-message__unopened-gift-badge__bow"
src="images/gift-bow.svg"
alt=""
aria-hidden
/>
</div>
<div
className={classNames(
'module-message__unopened-gift-badge__text',
`module-message__unopened-gift-badge__text--${direction}`
)}
>
<div
className={classNames(
'module-message__text',
`module-message__text--${direction}`
)}
dir={isRTL ? 'rtl' : undefined}
>
{description}
{this.getMetadataPlacement() ===
MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} />
)}
</div>
{this.renderMetadata()}
</div>
</div>
);
}
2022-05-16 19:54:38 +00:00
if (
giftBadge.state === GiftBadgeStates.Redeemed ||
giftBadge.state === GiftBadgeStates.Opened
) {
const badgeId = giftBadge.id || `BOOST-${giftBadge.level}`;
2022-05-11 20:59:58 +00:00
const badgeSize = 64;
const badge = getPreferredBadge([{ id: badgeId }]);
const badgeImagePath = getBadgeImageFileLocalPath(
badge,
badgeSize,
BadgeImageTheme.Transparent
);
let remaining: string;
const duration = giftBadge.expiration - Date.now();
const remainingDays = Math.floor(duration / DAY);
const remainingHours = Math.floor(duration / HOUR);
const remainingMinutes = Math.floor(duration / MINUTE);
if (remainingDays > 1) {
remaining = i18n('message--giftBadge--remaining--days', {
days: remainingDays,
});
} else if (remainingHours > 1) {
remaining = i18n('message--giftBadge--remaining--hours', {
hours: remainingHours,
});
} else if (remainingMinutes > 1) {
remaining = i18n('message--giftBadge--remaining--minutes', {
minutes: remainingMinutes,
});
} else if (remainingMinutes === 1) {
remaining = i18n('message--giftBadge--remaining--one-minute');
} else {
remaining = i18n('message--giftBadge--expired');
}
const wasSent = direction === 'outgoing';
const buttonContents = wasSent ? (
i18n('message--giftBadge--view')
) : (
<>
<span
className={classNames(
'module-message__redeemed-gift-badge__icon-check',
`module-message__redeemed-gift-badge__icon-check--${direction}`
)}
/>{' '}
{i18n('message--giftBadge--redeemed')}
</>
);
const badgeElement = badge ? (
<img
className="module-message__redeemed-gift-badge__badge"
src={badgeImagePath}
alt={badge.name}
/>
) : (
<div
className={classNames(
'module-message__redeemed-gift-badge__badge',
`module-message__redeemed-gift-badge__badge--missing-${direction}`
)}
aria-label={i18n('giftBadge--missing')}
/>
);
return (
<div className="module-message__redeemed-gift-badge__container">
<div className="module-message__redeemed-gift-badge">
{badgeElement}
<div className="module-message__redeemed-gift-badge__text">
<div className="module-message__redeemed-gift-badge__title">
{i18n('message--giftBadge')}
</div>
<div
className={classNames(
'module-message__redeemed-gift-badge__remaining',
`module-message__redeemed-gift-badge__remaining--${direction}`
)}
>
{remaining}
</div>
</div>
</div>
<button
className={classNames(
'module-message__redeemed-gift-badge__button',
`module-message__redeemed-gift-badge__button--${direction}`
)}
disabled={!wasSent}
onClick={
wasSent
? () => this.setState({ showOutgoingGiftBadgeModal: true })
: undefined
}
type="button"
>
<div className="module-message__redeemed-gift-badge__button__text">
{buttonContents}
</div>
</button>
{this.renderMetadata()}
{showOutgoingGiftBadgeModal ? (
<OutgoingGiftBadgeModal
i18n={i18n}
recipientTitle={conversationTitle}
badgeId={badgeId}
getPreferredBadge={getPreferredBadge}
hideOutgoingGiftBadgeModal={() =>
this.setState({ showOutgoingGiftBadgeModal: false })
}
/>
) : null}
</div>
);
}
throw missingCaseError(giftBadge.state);
}
2020-09-14 19:51:27 +00:00
public renderQuote(): JSX.Element | null {
const {
2021-05-28 16:15:17 +00:00
conversationColor,
customColor,
direction,
disableScroll,
doubleCheckMissingQuoteReference,
i18n,
id,
quote,
scrollToQuotedMessage,
} = this.props;
if (!quote) {
return null;
}
2022-05-11 20:59:58 +00:00
const { isGiftBadge, isViewOnce, referencedMessageNotFound } = quote;
const clickHandler = disableScroll
? undefined
: () => {
scrollToQuotedMessage({
authorId: quote.authorId,
sentAt: quote.sentAt,
});
};
const isIncoming = direction === 'incoming';
return (
<Quote
i18n={i18n}
onClick={clickHandler}
text={quote.text}
2021-04-02 21:35:28 +00:00
rawAttachment={quote.rawAttachment}
isIncoming={isIncoming}
2020-07-24 01:35:32 +00:00
authorTitle={quote.authorTitle}
2020-09-16 22:42:48 +00:00
bodyRanges={quote.bodyRanges}
2021-05-28 16:15:17 +00:00
conversationColor={conversationColor}
customColor={customColor}
isViewOnce={isViewOnce}
2022-05-11 20:59:58 +00:00
isGiftBadge={isGiftBadge}
referencedMessageNotFound={referencedMessageNotFound}
isFromMe={quote.isFromMe}
doubleCheckMissingQuoteReference={() =>
doubleCheckMissingQuoteReference(id)
}
/>
);
}
2022-03-16 17:30:14 +00:00
public renderStoryReplyContext(): JSX.Element | null {
const {
conversationColor,
customColor,
direction,
i18n,
storyReplyContext,
2022-07-06 19:06:20 +00:00
viewStory,
2022-03-16 17:30:14 +00:00
} = this.props;
if (!storyReplyContext) {
return null;
}
const isIncoming = direction === 'incoming';
return (
<>
{storyReplyContext.emoji && (
<div className="module-message__quote-story-reaction-header">
{i18n('Quote__story-reaction', [storyReplyContext.authorTitle])}
</div>
)}
<Quote
authorTitle={storyReplyContext.authorTitle}
conversationColor={conversationColor}
customColor={customColor}
i18n={i18n}
isFromMe={storyReplyContext.isFromMe}
2022-05-11 20:59:58 +00:00
isGiftBadge={false}
isIncoming={isIncoming}
isStoryReply
isViewOnce={false}
moduleClassName="StoryReplyQuote"
onClick={() => {
2022-08-22 17:44:23 +00:00
if (!storyReplyContext.storyId) {
return;
}
2022-07-25 18:55:44 +00:00
viewStory({
storyId: storyReplyContext.storyId,
storyViewMode: StoryViewModeType.Single,
});
}}
rawAttachment={storyReplyContext.rawAttachment}
reactionEmoji={storyReplyContext.emoji}
2022-07-06 19:06:20 +00:00
referencedMessageNotFound={!storyReplyContext.storyId}
text={storyReplyContext.text}
/>
</>
2022-03-16 17:30:14 +00:00
);
}
2020-09-14 19:51:27 +00:00
public renderEmbeddedContact(): JSX.Element | null {
const {
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 ||
this.getMetadataPlacement() !== MetadataPlacement.NotRendered;
const otherContent =
(contact && contact.firstNumber && contact.uuid) || withCaption;
const tabIndex = otherContent ? 0 : -1;
return (
<EmbeddedContact
contact={contact}
isIncoming={direction === 'incoming'}
i18n={i18n}
onClick={() => {
const signalAccount =
contact.firstNumber && contact.uuid
? {
phoneNumber: contact.firstNumber,
uuid: contact.uuid,
}
: undefined;
showContactDetail({
contact,
signalAccount,
});
}}
withContentAbove={withContentAbove}
withContentBelow={withContentBelow}
tabIndex={tabIndex}
/>
);
}
2020-09-14 19:51:27 +00:00
public renderSendMessageButton(): JSX.Element | null {
const { contact, direction, shouldCollapseBelow, startConversation, i18n } =
this.props;
const noBottomLeftCurve = direction === 'incoming' && shouldCollapseBelow;
const noBottomRightCurve = direction === 'outgoing' && shouldCollapseBelow;
if (!contact) {
return null;
}
const { firstNumber, uuid } = contact;
if (!firstNumber || !uuid) {
return null;
}
return (
2019-11-07 21:36:16 +00:00
<button
2020-09-14 19:51:27 +00:00
type="button"
2022-06-28 00:37:05 +00:00
onClick={e => {
e.preventDefault();
e.stopPropagation();
startConversation(firstNumber, uuid);
}}
className={classNames(
'module-message__send-message-button',
noBottomLeftCurve &&
'module-message__send-message-button--no-bottom-left-curve',
noBottomRightCurve &&
'module-message__send-message-button--no-bottom-right-curve'
)}
>
{i18n('sendMessageToContact')}
2019-11-07 21:36:16 +00:00
</button>
);
}
private renderAvatar(): ReactNode {
const {
author,
conversationId,
conversationType,
direction,
getPreferredBadge,
i18n,
shouldCollapseBelow,
showContactModal,
theme,
} = this.props;
if (conversationType !== 'group' || direction !== 'incoming') {
return null;
}
return (
2021-01-27 21:15:43 +00:00
<div
className={classNames('module-message__author-avatar-container', {
2021-11-11 22:43:05 +00:00
'module-message__author-avatar-container--with-reactions':
this.hasReactions(),
2021-01-27 21:15:43 +00:00
})}
>
{shouldCollapseBelow ? (
<AvatarSpacer size={GROUP_AVATAR_SIZE} />
) : (
<Avatar
acceptedMessageRequest={author.acceptedMessageRequest}
avatarPath={author.avatarPath}
badge={getPreferredBadge(author.badges)}
color={author.color}
conversationType="direct"
i18n={i18n}
isMe={author.isMe}
name={author.name}
onClick={event => {
event.stopPropagation();
event.preventDefault();
showContactModal(author.id, conversationId);
}}
phoneNumber={author.phoneNumber}
profileName={author.profileName}
sharedGroupNames={author.sharedGroupNames}
size={GROUP_AVATAR_SIZE}
theme={theme}
title={author.title}
unblurredAvatarPath={author.unblurredAvatarPath}
/>
)}
2021-01-27 21:15:43 +00:00
</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,
displayLimit,
2020-04-29 21:24:12 +00:00
i18n,
id,
messageExpanded,
2020-09-16 22:42:48 +00:00
openConversation,
kickOffAttachmentDownload,
2020-04-29 21:24:12 +00:00
status,
text,
textDirection,
textAttachment,
2020-04-29 21:24:12 +00:00
} = this.props;
const { metadataWidth } = this.state;
const isRTL = textDirection === TextDirection.RightToLeft;
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
className={classNames(
'module-message__text',
`module-message__text--${direction}`,
status === 'error' && direction === 'incoming'
? 'module-message__text--error'
: null,
deletedForEveryone
? 'module-message__text--delete-for-everyone'
: null
)}
dir={isRTL ? 'rtl' : undefined}
>
2021-10-20 20:46:42 +00:00
<MessageBodyReadMore
2020-09-16 22:42:48 +00:00
bodyRanges={bodyRanges}
disableLinks={!this.areLinksEnabled()}
2020-09-16 22:42:48 +00:00
direction={direction}
displayLimit={displayLimit}
i18n={i18n}
id={id}
messageExpanded={messageExpanded}
2020-09-16 22:42:48 +00:00
openConversation={openConversation}
kickOffBodyDownload={() => {
if (!textAttachment) {
return;
}
kickOffAttachmentDownload({
attachment: textAttachment,
messageId: id,
});
}}
2020-09-16 22:42:48 +00:00
text={contents || ''}
textAttachment={textAttachment}
/>
{!isRTL &&
this.getMetadataPlacement() === MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} />
)}
</div>
);
}
private renderError(): ReactNode {
const { status, direction } = this.props;
if (
status !== 'paused' &&
status !== 'error' &&
status !== 'partial-sent'
) {
return null;
}
return (
<div className="module-message__error-container">
<div
className={classNames(
'module-message__error',
`module-message__error--${direction}`,
`module-message__error--${status}`
)}
/>
</div>
);
}
private renderMenu(triggerId: string): ReactNode {
const {
attachments,
canDownload,
canReact,
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,
renderReactionPicker,
replyToMessage,
2020-09-14 19:51:27 +00:00
selectedReaction,
} = this.props;
if (disableMenu) {
return null;
}
2021-10-19 16:24:36 +00:00
const { reactionPickerRoot } = 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
2021-10-19 16:24:36 +00:00
// visible (it is hidden when the timeline is narrow)
const maybePopperRef = this.isWindowWidthNotNarrow()
2021-10-19 16:24:36 +00:00
? popperRef
: undefined;
2020-01-23 23:57:37 +00:00
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 }) => {
2021-10-19 16:24:36 +00:00
// Only attach the popper reference to the collapsed menu button if the reaction
// button is not visible (it is hidden when the timeline is narrow)
const maybePopperRef = !this.isWindowWidthNotNarrow()
2021-10-19 16:24:36 +00:00
? popperRef
: undefined;
2020-01-23 23:57:37 +00:00
return (
<StopPropagation className="module-message__buttons__menu--container">
<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>
</StopPropagation>
2020-01-23 23:57:37 +00:00
);
}}
</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
)}
>
{this.isWindowWidthNotNarrow() && (
<>
{canReact ? reactButton : null}
{canDownload ? downloadButton : null}
{canReply ? replyButton : null}
</>
)}
2020-01-23 23:57:37 +00:00
{menuButton}
</div>
{reactionPickerRoot &&
createPortal(
<Popper
placement="top"
modifiers={[
offsetDistanceModifier(4),
this.popperPreventOverflowModifier(),
]}
>
{({ ref, style }) =>
renderReactionPicker({
ref,
style,
selected: selectedReaction,
onClose: this.toggleReactionPicker,
onPick: emoji => {
this.toggleReactionPicker(true);
reactToMessage(id, {
emoji,
remove: emoji === selectedReaction,
});
},
renderEmojiPicker,
})
}
</Popper>,
2020-01-23 23:57:37 +00:00
reactionPickerRoot
)}
</Manager>
);
}
2020-09-14 19:51:27 +00:00
public renderContextMenu(triggerId: string): JSX.Element {
const {
attachments,
canDownload,
contact,
canReact,
canReply,
canRetry,
canRetryDeleteForEveryone,
2019-06-26 19:33:13 +00:00
deleteMessage,
deleteMessageForEveryone,
2021-04-27 22:35:35 +00:00
deletedForEveryone,
2022-05-11 20:59:58 +00:00
giftBadge,
i18n,
id,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToView,
replyToMessage,
retrySend,
retryDeleteForEveryone,
2021-04-27 22:35:35 +00:00
showForwardMessageModal,
2019-06-26 19:33:13 +00:00
showMessageDetail,
text,
} = this.props;
2022-05-11 20:59:58 +00:00
const canForward =
!isTapToView && !deletedForEveryone && !giftBadge && !contact;
const multipleAttachments = attachments && attachments.length > 1;
const shouldShowAdditional =
doesMessageBodyOverflow(text || '') || !this.isWindowWidthNotNarrow();
const menu = (
<ContextMenu id={triggerId}>
{canDownload &&
shouldShowAdditional &&
!isSticker &&
2019-06-26 19:33:13 +00:00
!multipleAttachments &&
!isTapToView &&
attachments &&
attachments[0] ? (
<MenuItem
attributes={{
2020-10-21 18:26:35 +00:00
className:
'module-message__context--icon module-message__context__download',
}}
2019-11-07 21:36:16 +00:00
onClick={this.openGenericAttachment}
>
{i18n('downloadAttachment')}
</MenuItem>
) : null}
{shouldShowAdditional ? (
<>
{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>
)}
{canReact && (
<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={{
2020-10-21 18:26:35 +00:00
className:
'module-message__context--icon 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>
{canRetry ? (
<MenuItem
attributes={{
2020-10-21 18:26:35 +00:00
className:
'module-message__context--icon 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}
{canRetryDeleteForEveryone ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
retryDeleteForEveryone(id);
}}
>
{i18n('retryDeleteForEveryone')}
</MenuItem>
) : null}
2021-04-27 22:35:35 +00:00
{canForward ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__forward-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showForwardMessageModal(id);
}}
>
{i18n('forwardMessage')}
</MenuItem>
) : null}
<MenuItem
attributes={{
2020-10-21 18:26:35 +00:00
className:
'module-message__context--icon 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>
{this.canDeleteForEveryone() ? (
<MenuItem
attributes={{
2020-10-21 18:26:35 +00:00
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);
}
private isWindowWidthNotNarrow(): boolean {
const { containerWidthBreakpoint } = this.props;
return containerWidthBreakpoint !== WidthBreakpoint.Narrow;
}
2019-01-14 21:49:58 +00:00
public getWidth(): number | undefined {
2022-05-11 20:59:58 +00:00
const { attachments, giftBadge, isSticker, previews } = this.props;
if (giftBadge) {
return 240;
}
2019-01-16 03:03:56 +00:00
if (attachments && attachments.length) {
2021-04-27 22:11:59 +00:00
if (isGIF(attachments)) {
2021-06-24 21:00:11 +00:00
// Message container border
return GIF_SIZE + 2;
2021-04-27 22:11:59 +00:00
}
if (isSticker) {
// Padding is 8px, on both sides
return STICKER_SIZE + 8 * 2;
}
2019-01-16 03:03:56 +00:00
const dimensions = getGridDimensions(attachments);
if (dimensions) {
return dimensions.width;
2019-01-16 03:03:56 +00:00
}
}
const firstLinkPreview = (previews || [])[0];
if (
firstLinkPreview &&
firstLinkPreview.image &&
shouldUseFullSizeLinkPreviewImage(firstLinkPreview)
) {
const dimensions = getImageDimensions(firstLinkPreview.image);
if (dimensions) {
return dimensions.width;
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
}
public isShowingImage(): boolean {
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) || isVideo(attachments));
2019-01-16 03:03:56 +00:00
}
if (previews && previews.length) {
const first = previews[0];
const { image } = first;
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;
}
// eslint-disable-next-line 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 {
conversationType,
direction,
isTapToViewExpired,
isTapToViewError,
} = this.props;
const collapseMetadata =
this.getMetadataPlacement() === MetadataPlacement.NotRendered;
2019-06-26 19:33:13 +00:00
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>
);
}
private popperPreventOverflowModifier(): Partial<PreventOverflowModifier> {
const { containerElementRef } = this.props;
return {
name: 'preventOverflow',
options: {
altAxis: true,
boundary: containerElementRef.current || undefined,
padding: {
bottom: 16,
left: 8,
right: 8,
top: 16,
},
},
};
}
2020-09-14 19:51:27 +00:00
public toggleReactionViewer = (onlyRemove = false): void => {
this.setState(oldState => {
const { reactionViewerRoot } = oldState;
2020-01-17 22:23:19 +00:00
if (reactionViewerRoot) {
document.body.removeChild(reactionViewerRoot);
oldState.reactionViewerOutsideClickDestructor?.();
return {
reactionViewerRoot: null,
reactionViewerOutsideClickDestructor: undefined,
};
2020-01-17 22:23:19 +00:00
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
const reactionViewerOutsideClickDestructor = handleOutsideClick(
() => {
this.toggleReactionViewer(true);
return true;
},
{ containerElements: [root, this.reactionsContainerRef] }
2020-01-23 23:57:37 +00:00
);
2020-01-17 22:23:19 +00:00
return {
reactionViewerRoot: root,
reactionViewerOutsideClickDestructor,
2020-01-17 22:23:19 +00:00
};
}
return null;
2020-01-17 22:23:19 +00:00
});
};
2020-09-14 19:51:27 +00:00
public toggleReactionPicker = (onlyRemove = false): void => {
this.setState(oldState => {
const { reactionPickerRoot } = oldState;
2020-01-23 23:57:37 +00:00
if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot);
oldState.reactionPickerOutsideClickDestructor?.();
return {
reactionPickerRoot: null,
reactionPickerOutsideClickDestructor: undefined,
};
2020-01-23 23:57:37 +00:00
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
const reactionPickerOutsideClickDestructor = handleOutsideClick(
() => {
this.toggleReactionPicker(true);
return true;
},
{ containerElements: [root] }
2020-01-23 23:57:37 +00:00
);
return {
reactionPickerRoot: root,
reactionPickerOutsideClickDestructor,
2020-01-23 23:57:37 +00:00
};
}
return null;
2020-01-23 23:57:37 +00:00
});
};
2020-09-14 19:51:27 +00:00
public renderReactions(outgoing: boolean): JSX.Element | null {
2021-11-17 21:11:46 +00:00
const { getPreferredBadge, reactions = [], i18n, theme } = this.props;
2020-01-17 22:23:19 +00:00
2021-01-27 21:15:43 +00:00
if (!this.hasReactions()) {
2020-01-17 22:23:19 +00:00
return null;
}
2020-10-02 20:05:09 +00:00
const reactionsWithEmojiData = reactions.map(reaction => ({
...reaction,
...emojiToData(reaction.emoji),
}));
2020-01-17 22:23:19 +00:00
// Group by emoji and order each group by timestamp descending
2020-10-02 20:05:09 +00:00
const groupedAndSortedReactions = Object.values(
groupBy(reactionsWithEmojiData, 'short_name')
).map(groupedReactions =>
orderBy(
groupedReactions,
[reaction => reaction.from.isMe, 'timestamp'],
['desc', 'desc']
)
2020-01-17 22:23:19 +00:00
);
// Order groups by length and subsequently by most recent reaction
const ordered = orderBy(
2020-10-02 20:05:09 +00:00
groupedAndSortedReactions,
2020-01-17 22:23:19 +00:00
['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)));
2021-01-27 21:15:43 +00:00
const { reactionViewerRoot } = this.state;
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'
)}
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}
strategy="fixed"
modifiers={[this.popperPreventOverflowModifier()]}
>
{({ ref, style }) => (
<ReactionViewer
ref={ref}
style={{
...style,
zIndex: 2,
}}
getPreferredBadge={getPreferredBadge}
reactions={reactions}
i18n={i18n}
onClose={this.toggleReactionViewer}
theme={theme}
/>
)}
</Popper>,
2020-01-17 22:23:19 +00:00
reactionViewerRoot
)}
</Manager>
);
}
2020-09-14 19:51:27 +00:00
public renderContents(): JSX.Element | null {
2022-05-11 20:59:58 +00:00
const { giftBadge, isTapToView, deletedForEveryone } = this.props;
2020-04-29 21:24:12 +00:00
if (deletedForEveryone) {
return (
<>
{this.renderText()}
{this.renderMetadata()}
</>
);
2020-04-29 21:24:12 +00:00
}
2019-06-26 19:33:13 +00:00
2022-05-11 20:59:58 +00:00
if (giftBadge) {
return this.renderGiftBadge();
}
2019-06-26 19:33:13 +00:00
if (isTapToView) {
return (
<>
{this.renderTapToView()}
{this.renderMetadata()}
</>
);
}
return (
<>
{this.renderQuote()}
2022-03-16 17:30:14 +00:00
{this.renderStoryReplyContext()}
2019-06-26 19:33:13 +00:00
{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,
2022-05-11 20:59:58 +00:00
giftBadge,
id,
2019-11-07 21:36:16 +00:00
isTapToView,
isTapToViewExpired,
2021-01-29 22:58:28 +00:00
kickOffAttachmentDownload,
2022-06-28 00:37:05 +00:00
startConversation,
2022-05-11 20:59:58 +00:00
openGiftBadge,
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();
2022-05-11 20:59:58 +00:00
if (giftBadge && giftBadge.state === GiftBadgeStates.Unopened) {
openGiftBadge(id);
return;
}
2019-11-07 21:36:16 +00:00
if (isTapToView) {
if (isAttachmentPending) {
log.info(
'<Message> handleOpen: tap-to-view attachment is pending; not showing the lightbox'
);
return;
}
2022-03-29 01:10:08 +00:00
if (attachments && !isDownloaded(attachments[0])) {
event.preventDefault();
event.stopPropagation();
kickOffAttachmentDownload({
attachment: attachments[0],
messageId: id,
});
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;
}
2021-01-29 22:58:28 +00:00
if (
!imageBroken &&
attachments &&
attachments.length > 0 &&
!isAttachmentPending &&
2022-03-29 01:10:08 +00:00
!isDownloaded(attachments[0])
2021-01-29 22:58:28 +00:00
) {
event.preventDefault();
event.stopPropagation();
const attachment = attachments[0];
kickOffAttachmentDownload({ attachment, messageId: id });
return;
}
2019-11-07 21:36:16 +00:00
if (
!imageBroken &&
attachments &&
attachments.length > 0 &&
!isAttachmentPending &&
canDisplayImage(attachments) &&
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
2019-11-07 21:36:16 +00:00
) {
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
2019-11-07 21:36:16 +00:00
) {
event.preventDefault();
event.stopPropagation();
this.audioButtonRef.current.click();
2022-06-28 00:37:05 +00:00
return;
2019-11-07 21:36:16 +00:00
}
if (contact && contact.firstNumber && contact.uuid) {
2022-06-28 00:37:05 +00:00
startConversation(contact.firstNumber, contact.uuid);
event.preventDefault();
event.stopPropagation();
2022-06-28 00:37:05 +00:00
return;
}
if (contact) {
const signalAccount =
contact.firstNumber && contact.uuid
? {
phoneNumber: contact.firstNumber,
uuid: contact.uuid,
}
: undefined;
showContactDetail({ 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 => {
const {
id,
attachments,
downloadAttachment,
timestamp,
kickOffAttachmentDownload,
} = this.props;
2019-11-07 21:36:16 +00:00
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!attachments || attachments.length !== 1) {
return;
}
const attachment = attachments[0];
2022-03-29 01:10:08 +00:00
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
return;
}
2019-11-07 21:36:16 +00:00
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 { canReact } = this.props;
2021-09-29 21:20:52 +00:00
const key = KeyboardLayout.lookup(event.nativeEvent);
2020-01-23 23:57:37 +00:00
if (
2021-09-29 21:20:52 +00:00
(key === 'E' || key === 'e') &&
2020-01-23 23:57:37 +00:00
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
canReact
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;
}
this.handleOpen(event);
};
2020-09-14 19:51:27 +00:00
public renderContainer(): JSX.Element {
2019-11-07 21:36:16 +00:00
const {
2021-04-27 22:11:59 +00:00
attachments,
2021-05-28 16:15:17 +00:00
conversationColor,
customColor,
2020-04-29 21:24:12 +00:00
deletedForEveryone,
2019-11-07 21:36:16 +00:00
direction,
2022-05-11 20:59:58 +00:00
giftBadge,
isSticker,
2019-06-26 19:33:13 +00:00
isTapToView,
isTapToViewExpired,
isTapToViewError,
text,
} = 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();
2022-05-11 20:59:58 +00:00
const shouldUseWidth = Boolean(giftBadge || this.isShowingImage());
2019-11-07 21:36:16 +00:00
const isEmojiOnly = this.canRenderStickerLikeEmoji();
2021-10-06 17:37:53 +00:00
const isStickerLike = isSticker || isEmojiOnly;
// If it's a mostly-normal gray incoming text box, we don't want to darken it as much
const lighterSelect =
isSelected &&
direction === 'incoming' &&
!isStickerLike &&
(text || (!isVideo(attachments) && !isImage(attachments)));
2019-11-07 21:36:16 +00:00
const containerClassnames = classNames(
'module-message__container',
2021-04-27 22:11:59 +00:00
isGIF(attachments) ? 'module-message__container--gif' : null,
isSelected ? 'module-message__container--selected' : null,
lighterSelect ? 'module-message__container--selected-lighter' : null,
2021-10-06 17:37:53 +00:00
!isStickerLike ? `module-message__container--${direction}` : null,
isEmojiOnly ? 'module-message__container--emoji' : null,
2019-11-07 21:36:16 +00:00
isTapToView ? 'module-message__container--with-tap-to-view' : null,
isTapToView && isTapToViewExpired
? 'module-message__container--with-tap-to-view-expired'
: null,
2021-10-06 17:37:53 +00:00
!isStickerLike && direction === 'outgoing'
2021-05-28 16:15:17 +00:00
? `module-message__container--outgoing-${conversationColor}`
2019-11-07 21:36:16 +00:00
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? 'module-message__container--with-tap-to-view-pending'
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
2021-05-28 16:15:17 +00:00
? `module-message__container--${direction}-${conversationColor}-tap-to-view-pending`
2019-11-07 21:36:16 +00:00
: null,
isTapToViewError
? 'module-message__container--with-tap-to-view-error'
: null,
2021-01-27 21:15:43 +00:00
this.hasReactions() ? 'module-message__container--with-reactions' : null,
2020-04-29 21:24:12 +00:00
deletedForEveryone
? 'module-message__container--deleted-for-everyone'
2019-11-07 21:36:16 +00:00
: null
);
const containerStyles = {
2022-05-11 20:59:58 +00:00
width: shouldUseWidth ? width : undefined,
2019-11-07 21:36:16 +00:00
};
if (!isStickerLike && !deletedForEveryone && direction === 'outgoing') {
2021-05-28 16:15:17 +00:00
Object.assign(containerStyles, getCustomColorStyle(customColor));
}
2019-11-07 21:36:16 +00:00
return (
2021-01-27 21:15:43 +00:00
<div className="module-message__container-outer">
2021-06-09 22:30:05 +00:00
<div
className={containerClassnames}
style={containerStyles}
onContextMenu={this.showContextMenu}
role="row"
onKeyDown={this.handleKeyDown}
onClick={this.handleClick}
tabIndex={-1}
2021-06-09 22:30:05 +00:00
>
2021-01-27 21:15:43 +00:00
{this.renderAuthor()}
{this.renderContents()}
</div>
{this.renderReactions(direction === 'outgoing')}
</div>
2019-11-07 21:36:16 +00:00
);
}
public override render(): JSX.Element | null {
const {
author,
attachments,
direction,
id,
isSticker,
shouldCollapseAbove,
shouldCollapseBelow,
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.
2021-04-27 19:55:21 +00:00
const triggerId = String(id || `${author.id}-${timestamp}`);
if (expired) {
return null;
}
if (isSticker && (imageBroken || !attachments || !attachments.length)) {
return null;
}
return (
<div
className={classNames(
'module-message',
`module-message--${direction}`,
shouldCollapseAbove && 'module-message--collapsed-above',
shouldCollapseBelow && 'module-message--collapsed-below',
isSelected ? 'module-message--selected' : null,
expiring ? 'module-message--expired' : null
)}
2019-11-07 21:36:16 +00:00
tabIndex={0}
// We need to have a role because screenreaders need to be able to focus here to
// read the message, but we can't be a button; that would break inner buttons.
role="row"
2019-11-07 21:36:16 +00:00
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
2019-11-07 21:36:16 +00:00
ref={this.focusRef}
>
{this.renderError()}
2021-01-27 21:15:43 +00:00
{this.renderAvatar()}
2019-11-07 21:36:16 +00:00
{this.renderContainer()}
{this.renderMenu(triggerId)}
{this.renderContextMenu(triggerId)}
</div>
);
}
}