// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable react/jsx-pascal-case */ import type { DetailedHTMLProps, HTMLAttributes, ReactNode, RefObject, } from 'react'; import React, { forwardRef, useRef } from 'react'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import getDirection from 'direction'; import { drop, take, unescape } from 'lodash'; import { Manager, Popper, Reference } from 'react-popper'; import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow'; import type { ReadonlyDeep } from 'type-fest'; import type { ConversationType, ConversationTypeType, InteractionModeType, PushPanelForConversationActionType, SaveAttachmentActionCreatorType, SaveAttachmentsActionCreatorType, ShowConversationType, } from '../../state/ducks/conversations'; import type { ViewStoryActionCreatorType } from '../../state/ducks/stories'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { Avatar, AvatarSize } from '../Avatar'; import { AvatarSpacer } from '../AvatarSpacer'; import { MessageBodyReadMore } from './MessageBodyReadMore'; import { MessageMetadata } from './MessageMetadata'; import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer'; import { ImageGrid } from './ImageGrid'; import { GIF } from './GIF'; import { CurveType, Image } from './Image'; import { ContactName } from './ContactName'; import type { QuotedAttachmentForUIType } from './Quote'; import { Quote } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; import type { OwnProps as ReactionViewerProps, Reaction, } from './ReactionViewer'; import { ReactionViewer } from './ReactionViewer'; import { LinkPreviewDate } from './LinkPreviewDate'; import type { LinkPreviewForUIType } from '../../types/message/LinkPreviews'; import { toLogFormat } from '../../types/errors'; import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage'; import type { WidthBreakpoint } from '../_util'; import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal'; import { createLogger } from '../../logging/log'; import { StoryViewModeType } from '../../types/Stories'; import type { AttachmentForUIType, AttachmentType, } from '../../types/Attachment'; import { canDisplayImage, getExtensionForDisplay, getGridDimensions, getImageDimensions, hasImage, hasVideoScreenshot, isAudio, isDownloaded, isDownloading, isGIF, isImage, isImageAttachment, isPlayed, isVideo, } from '../../types/Attachment'; import type { EmbeddedContactForUIType } from '../../types/EmbeddedContact'; import { getIncrement } from '../../util/timer'; import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary'; import { missingCaseError } from '../../util/missingCaseError'; import type { HydratedBodyRangesType } from '../../types/BodyRange'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; import type { ContactNameColorType, ConversationColorType, CustomColorType, } from '../../types/Colors'; import { createRefMerger } from '../../util/refMerger'; import { getCustomColorStyle } from '../../util/getCustomColorStyle'; import type { ServiceIdString } from '../../types/ServiceId'; import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations'; import { BadgeImageTheme } from '../../badges/BadgeImageTheme'; import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath'; import { handleOutsideClick } from '../../util/handleOutsideClick'; import { isPaymentNotificationEvent } from '../../types/Payment'; import type { AnyPaymentEvent } from '../../types/Payment'; import { getPaymentEventDescription } from '../../messages/helpers'; import { PanelType } from '../../types/Panels'; import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser'; import { RenderLocation } from './MessageTextRenderer'; import { UserText } from '../UserText'; import { getColorForCallLink } from '../../util/getColorForCallLink'; import { getKeyFromCallLink } from '../../util/callLinks'; import { InAnotherCallTooltip } from './InAnotherCallTooltip'; import { formatFileSize } from '../../util/formatFileSize'; import { AttachmentNotAvailableModalType } from '../AttachmentNotAvailableModal'; import { assertDev, strictAssert } from '../../util/assert'; import { AttachmentStatusIcon } from './AttachmentStatusIcon'; import { isFileDangerous } from '../../util/isFileDangerous'; import { TapToViewNotAvailableType } from '../TapToViewNotAvailableModal'; import type { DataPropsType as TapToViewNotAvailablePropsType } from '../TapToViewNotAvailableModal'; import { FunStaticEmoji } from '../fun/FunEmoji'; import { type EmojifyData, getEmojifyData, getEmojiParentByKey, getEmojiParentKeyByVariantKey, getEmojiVariantByKey, getEmojiVariantKeyByValue, isEmojiVariantValue, } from '../fun/data/emojis'; import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions'; const log = createLogger('Message'); const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; const GUESS_METADATA_WIDTH_SMS_SIZE = 18; const GUESS_METADATA_WIDTH_EDITED_SIZE = 40; const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record = { 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; const GIF_SIZE = 300; // Note: this needs to match the animation time const TARGETED_TIMEOUT = 1200; const SENT_STATUSES = new Set([ 'delivered', 'read', 'sent', 'viewed', ]); const GIFT_BADGE_UPDATE_INTERVAL = 30 * SECOND; enum MetadataPlacement { NotRendered, RenderedElsewhere, InlineWithText, Bottom, } export enum TextDirection { LeftToRight = 'LeftToRight', RightToLeft = 'RightToLeft', Default = 'Default', None = 'None', } const TextDirectionToDirAttribute = { [TextDirection.LeftToRight]: 'ltr', [TextDirection.RightToLeft]: 'rtl', [TextDirection.Default]: 'auto', [TextDirection.None]: 'auto', }; export const MessageStatuses = [ 'delivered', 'error', 'paused', 'partial-sent', 'read', 'sending', 'sent', 'viewed', ] as const; 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; theme: ThemeType | undefined; attachment: AttachmentForUIType; collapseMetadata: boolean; withContentAbove: boolean; withContentBelow: boolean; direction: DirectionType; expirationLength?: number; expirationTimestamp?: number; id: string; conversationId: string; played: boolean; pushPanelForConversation: PushPanelForConversationActionType; status?: MessageStatusType; textPending?: boolean; timestamp: number; kickOffAttachmentDownload(): void; cancelAttachmentDownload(): void; onCorrupted(): void; }; export enum GiftBadgeStates { Unopened = 'Unopened', Opened = 'Opened', Redeemed = 'Redeemed', Failed = 'Failed', } export type GiftBadgeType = | { state: | GiftBadgeStates.Unopened | GiftBadgeStates.Opened | GiftBadgeStates.Redeemed; expiration: number; id: string | undefined; level: number; } | { state: GiftBadgeStates.Failed; }; function ReactionEmoji(props: { emojiVariantValue: string }) { strictAssert( isEmojiVariantValue(props.emojiVariantValue), 'Expected a valid emoji variant value' ); const emojiVariantKey = getEmojiVariantKeyByValue(props.emojiVariantValue); const emojiVariant = getEmojiVariantByKey(emojiVariantKey); const emojiParentKey = getEmojiParentKeyByVariantKey(emojiVariantKey); const emojiParent = getEmojiParentByKey(emojiParentKey); return ( ); } export type PropsData = { id: string; renderingContext: string; contactNameColor?: ContactNameColorType; conversationColor: ConversationColorType; conversationTitle: string; customColor?: CustomColorType; conversationId: string; displayLimit?: number; activeCallConversationId?: string; text?: string; textDirection: TextDirection; textAttachment?: AttachmentForUIType; isEditedMessage?: boolean; isSticker?: boolean; isTargeted?: boolean; isTargetedCounter?: number; isSelected: boolean; isSelectMode: boolean; isSMS: boolean; isSpoilerExpanded?: Record; direction: DirectionType; timestamp: number; receivedAtMS?: number; status?: MessageStatusType; contact?: ReadonlyDeep; author: Pick< ConversationType, | 'avatarPlaceholderGradient' | 'acceptedMessageRequest' | 'avatarUrl' | 'badges' | 'color' | 'firstName' | 'hasAvatar' | 'id' | 'isMe' | 'phoneNumber' | 'profileName' | 'sharedGroupNames' | 'title' >; conversationType: ConversationTypeType; attachments?: ReadonlyArray; giftBadge?: GiftBadgeType; payment?: AnyPaymentEvent; quote?: { conversationColor: ConversationColorType; conversationTitle: string; customColor?: CustomColorType; text: string; rawAttachment?: QuotedAttachmentForUIType; payment?: AnyPaymentEvent; isFromMe: boolean; sentAt: number; authorId: string; authorPhoneNumber?: string; authorProfileName?: string; authorTitle: string; authorName?: string; bodyRanges?: HydratedBodyRangesType; referencedMessageNotFound: boolean; isViewOnce: boolean; isGiftBadge: boolean; }; storyReplyContext?: { authorTitle: string; conversationColor: ConversationColorType; customColor?: CustomColorType; emoji?: string; isFromMe: boolean; rawAttachment?: QuotedAttachmentForUIType; storyId?: string; text: string; }; previews: ReadonlyArray; isTapToView?: boolean; isTapToViewExpired?: boolean; isTapToViewError?: boolean; readStatus?: ReadStatus; expirationLength?: number; expirationTimestamp?: number; reactions?: ReactionViewerProps['reactions']; deletedForEveryone?: boolean; attachmentDroppedDueToSize?: boolean; canDeleteForEveryone: boolean; isBlocked: boolean; isMessageRequestAccepted: boolean; bodyRanges?: HydratedBodyRangesType; renderMenu?: () => JSX.Element | undefined; item?: never; // test-only, to force GIF's reduced motion experience _forceTapToPlay?: boolean; }; export type PropsHousekeeping = { containerElementRef: RefObject; containerWidthBreakpoint: WidthBreakpoint; disableScroll?: boolean; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; interactionMode: InteractionModeType; platform: string; renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element; shouldCollapseAbove: boolean; shouldCollapseBelow: boolean; shouldHideMetadata: boolean; onContextMenu?: (event: React.MouseEvent) => void; theme: ThemeType; }; export type PropsActions = { clearTargetedMessage: () => unknown; doubleCheckMissingQuoteReference: (messageId: string) => unknown; messageExpanded: (id: string, displayLimit: number) => unknown; checkForAccount: (phoneNumber: string) => unknown; startConversation: (e164: string, serviceId: ServiceIdString) => void; showConversation: ShowConversationType; openGiftBadge: (messageId: string) => void; pushPanelForConversation: PushPanelForConversationActionType; retryMessageSend: (messageId: string) => unknown; showContactModal: (contactId: string, conversationId?: string) => void; showSpoiler: (messageId: string, data: Record) => void; cancelAttachmentDownload: (options: { messageId: string }) => void; kickOffAttachmentDownload: (options: { messageId: string }) => void; markAttachmentAsCorrupted: (options: { attachment: AttachmentType; messageId: string; }) => void; saveAttachment: SaveAttachmentActionCreatorType; saveAttachments: SaveAttachmentsActionCreatorType; showLightbox: (options: { attachment: AttachmentType; messageId: string; }) => void; showLightboxForViewOnceMedia: (messageId: string) => unknown; scrollToQuotedMessage: (options: { authorId: string; conversationId: string; sentAt: number; }) => void; targetMessage?: (messageId: string, conversationId: string) => unknown; showEditHistoryModal?: (id: string) => unknown; showAttachmentDownloadStillInProgressToast: (count: number) => unknown; showAttachmentNotAvailableModal: ( modalType: AttachmentNotAvailableModalType ) => void; showExpiredIncomingTapToViewToast: () => unknown; showExpiredOutgoingTapToViewToast: () => unknown; showMediaNoLongerAvailableToast: () => unknown; showTapToViewNotAvailableModal: ( props: TapToViewNotAvailablePropsType ) => void; viewStory: ViewStoryActionCreatorType; onToggleSelect: (selected: boolean, shift: boolean) => void; onReplyToMessage: () => void; }; export type Props = PropsData & PropsHousekeeping & PropsActions; type State = { metadataWidth: number; expiring: boolean; expired: boolean; imageBroken: boolean; isTargeted?: boolean; prevTargetedCounter?: number; reactionViewerRoot: HTMLDivElement | null; reactionViewerOutsideClickDestructor?: () => void; giftBadgeCounter: number | null; showOutgoingGiftBadgeModal: boolean; hasDeleteForEveryoneTimerExpired: boolean; }; // Function component for reactions that can use hooks type MessageReactionsProps = { reactions: Array; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; theme: ThemeType; outgoing: boolean; toggleReactionViewer: (onlyRemove?: boolean) => void; reactionViewerRoot: HTMLDivElement | null; popperPreventOverflowModifier: () => Partial; }; const MessageReactions = forwardRef(function MessageReactions( { reactions, getPreferredBadge, i18n, theme, outgoing, toggleReactionViewer, reactionViewerRoot, popperPreventOverflowModifier, }: MessageReactionsProps, parentRef ): JSX.Element { const ordered = useGroupedAndOrderedReactions(reactions); const reactionsContainerRefMerger = useRef(createRefMerger()); // Take the first three groups for rendering const toRender = take(ordered, 3).map(res => { const isMe = res.some(re => Boolean(re.from.isMe)); const count = res.length; const { emoji } = res[0]; let label: string; if (isMe) { label = i18n('icu:Message__reaction-emoji-label--you', { emoji }); } else if (count === 1) { label = i18n('icu:Message__reaction-emoji-label--single', { title: res[0].from.title, emoji, }); } else { label = i18n('icu:Message__reaction-emoji-label--many', { count, emoji, }); } return { count, emoji, isMe, label, }; }); const someNotRendered = ordered.length > 3; // We only drop two here because the third emoji would be replaced by the // more button const maybeNotRendered = drop(ordered, 2); const maybeNotRenderedTotal = maybeNotRendered.reduce( (sum, res) => sum + res.length, 0 ); const notRenderedIsMe = someNotRendered && maybeNotRendered.some(res => res.some(re => Boolean(re.from.isMe))); const popperPlacement = outgoing ? 'bottom-end' : 'bottom-start'; return ( {({ ref: popperRef }) => (
{ ev.stopPropagation(); }} > {toRender.map((re, i) => { const isLast = i === toRender.length - 1; const isMore = isLast && someNotRendered; const isMoreWithMe = isMore && notRenderedIsMe; return ( ); })}
)}
{reactionViewerRoot && createPortal( {({ ref, style }) => ( )} , reactionViewerRoot )}
); }); export class Message extends React.PureComponent { public focusRef: React.RefObject = React.createRef(); public audioButtonRef: React.RefObject = React.createRef(); public reactionsContainerRef: React.RefObject = React.createRef(); #hasSelectedTextRef: React.MutableRefObject = { current: false, }; #metadataRef: React.RefObject = React.createRef(); public expirationCheckInterval: NodeJS.Timeout | undefined; public giftBadgeInterval: NodeJS.Timeout | undefined; public expiredTimeout: NodeJS.Timeout | undefined; public targetedTimeout: 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, isTargeted: props.isTargeted, prevTargetedCounter: props.isTargetedCounter, reactionViewerRoot: null, giftBadgeCounter: null, showOutgoingGiftBadgeModal: false, hasDeleteForEveryoneTimerExpired: this.#getTimeRemainingForDeleteForEveryone() <= 0, }; } public static getDerivedStateFromProps(props: Props, state: State): State { if (!props.isTargeted) { return { ...state, isTargeted: false, prevTargetedCounter: 0, }; } if ( props.isTargeted && props.isTargetedCounter !== state.prevTargetedCounter ) { return { ...state, isTargeted: props.isTargeted, prevTargetedCounter: props.isTargetedCounter, }; } return state; } #hasReactions(): boolean { const { reactions } = this.props; return Boolean(reactions && reactions.length); } public handleFocus = (): void => { const { interactionMode, isTargeted } = this.props; if (interactionMode === 'keyboard' && !isTargeted) { this.setTargeted(); } }; public handleImageError = (): void => { const { id } = this.props; log.info(`${id}: Image failed to load; failing over to placeholder`); this.setState({ imageBroken: true, }); }; public setTargeted = (): void => { const { id, conversationId, targetMessage } = this.props; if (targetMessage) { targetMessage(id, conversationId); } }; public setFocus = (): void => { const container = this.focusRef.current; if (container && !container.contains(document.activeElement)) { container.focus(); } }; public override componentDidMount(): void { const { conversationId } = this.props; window.ConversationController?.onConvoMessageMount(conversationId); this.startTargetedTimer(); this.#startDeleteForEveryoneTimerIfApplicable(); this.startGiftBadgeInterval(); const { isTargeted } = this.props; if (isTargeted) { 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.serviceId) { checkForAccount(contact.firstNumber); } document.addEventListener('selectionchange', this.#handleSelectionChange); } public override componentWillUnmount(): void { clearTimeoutIfNecessary(this.targetedTimeout); clearTimeoutIfNecessary(this.expirationCheckInterval); clearTimeoutIfNecessary(this.expiredTimeout); clearTimeoutIfNecessary(this.deleteForEveryoneTimeout); clearTimeoutIfNecessary(this.giftBadgeInterval); this.toggleReactionViewer(true); document.removeEventListener( 'selectionchange', this.#handleSelectionChange ); } public override componentDidUpdate(prevProps: Readonly): void { const { isTargeted, status, timestamp } = this.props; this.startTargetedTimer(); this.#startDeleteForEveryoneTimerIfApplicable(); if (!prevProps.isTargeted && isTargeted) { this.setFocus(); } this.checkExpired(); if ( prevProps.status === 'sending' && (status === 'sent' || status === 'delivered' || status === 'read' || status === 'viewed') ) { const delta = Date.now() - timestamp; window.SignalCI?.handleEvent('message:send-complete', { timestamp, delta, }); log.info( `tsx: Rendered 'send complete' for message ${timestamp}; took ${delta}ms` ); } } #getMetadataPlacement( { attachmentDroppedDueToSize, attachments, deletedForEveryone, direction, expirationLength, expirationTimestamp, giftBadge, i18n, isTapToView, isTapToViewError, isTapToViewExpired, readStatus, shouldHideMetadata, status, text, }: Readonly = this.props ): MetadataPlacement { const { imageBroken } = this.state; if ( !expirationLength && !expirationTimestamp && (!status || SENT_STATUSES.has(status)) && shouldHideMetadata ) { return MetadataPlacement.NotRendered; } if (giftBadge) { const description = direction === 'incoming' ? i18n('icu:message--donation--unopened--incoming') : i18n('icu:message--donation--unopened--outgoing'); const isDescriptionRTL = getDirection(description) === 'rtl'; if (giftBadge.state === GiftBadgeStates.Unopened && !isDescriptionRTL) { return MetadataPlacement.InlineWithText; } return MetadataPlacement.Bottom; } if (isTapToView) { if ( readStatus !== ReadStatus.Viewed && direction !== 'outgoing' && (isTapToViewExpired || isTapToViewError) ) { return MetadataPlacement.Bottom; } return MetadataPlacement.RenderedElsewhere; } if (!text && !deletedForEveryone && !attachmentDroppedDueToSize) { const firstAttachment = attachments && attachments[0]; const isAttachmentNotAvailable = firstAttachment?.isPermanentlyUndownloadable; if (this.isGenericAttachment(attachments, imageBroken)) { return MetadataPlacement.RenderedElsewhere; } if (isAudio(attachments) && !isAttachmentNotAvailable) { return MetadataPlacement.RenderedElsewhere; } return MetadataPlacement.Bottom; } if (!text && attachmentDroppedDueToSize) { return MetadataPlacement.InlineWithText; } if (this.#canRenderStickerLikeEmoji()) { return MetadataPlacement.Bottom; } if (this.#shouldShowJoinButton()) { 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. */ #guessMetadataWidth(): number { const { direction, expirationLength, isSMS, status, isEditedMessage } = this.props; let result = GUESS_METADATA_WIDTH_TIMESTAMP_SIZE; if (isEditedMessage) { result += GUESS_METADATA_WIDTH_EDITED_SIZE; } const hasExpireTimer = Boolean(expirationLength); if (hasExpireTimer) { result += GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE; } if (isSMS) { result += GUESS_METADATA_WIDTH_SMS_SIZE; } if (direction === 'outgoing' && status) { result += GUESS_METADATA_WIDTH_OUTGOING_SIZE[status]; } return result; } public startTargetedTimer(): void { const { clearTargetedMessage, interactionMode } = this.props; const { isTargeted } = this.state; if (interactionMode === 'keyboard' || !isTargeted) { return; } if (!this.targetedTimeout) { this.targetedTimeout = setTimeout(() => { this.targetedTimeout = undefined; this.setState({ isTargeted: false }); clearTargetedMessage(); }, TARGETED_TIMEOUT); } } 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, })); } #getTimeRemainingForDeleteForEveryone(): number { const { timestamp } = this.props; return Math.max(timestamp - Date.now() + DAY, 0); } #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()); } public checkExpired(): void { const now = Date.now(); const { expirationTimestamp, expirationLength } = this.props; if (!expirationTimestamp || !expirationLength) { return; } if (this.expiredTimeout) { return; } if (now >= expirationTimestamp) { this.setState({ expiring: true, }); const setExpired = () => { this.setState({ expired: true, }); }; this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY); } } #areLinksEnabled(): boolean { const { isMessageRequestAccepted, isBlocked } = this.props; return isMessageRequestAccepted && !isBlocked; } #shouldRenderAuthor(): boolean { const { author, conversationType, direction, shouldCollapseAbove } = this.props; return Boolean( direction === 'incoming' && conversationType === 'group' && author.title && !shouldCollapseAbove ); } #cachedEmojifyData: EmojifyData | null = null; #canRenderStickerLikeEmoji(): boolean { const { attachments, bodyRanges, previews, quote, storyReplyContext, text, } = this.props; if ( text == null || quote != null || storyReplyContext != null || (attachments != null && attachments.length > 0) || (bodyRanges != null && bodyRanges.length > 0) || (previews != null && previews.length > 0) ) { return false; } if ( this.#cachedEmojifyData == null || this.#cachedEmojifyData.text !== text ) { this.#cachedEmojifyData = getEmojifyData(text); } const emojifyData = this.#cachedEmojifyData; if ( !emojifyData.isEmojiOnlyText || emojifyData.emojiCount === 0 || emojifyData.emojiCount >= 6 ) { return false; } return true; } #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), })); }; #handleSelectionChange = () => { const selection = document.getSelection(); if (selection != null && !selection.isCollapsed) { this.#hasSelectedTextRef.current = true; } }; #renderMetadata(): ReactNode { let isInline: boolean; const metadataPlacement = this.#getMetadataPlacement(); switch (metadataPlacement) { case MetadataPlacement.NotRendered: case MetadataPlacement.RenderedElsewhere: return null; case MetadataPlacement.InlineWithText: isInline = true; break; case MetadataPlacement.Bottom: isInline = false; break; default: log.error(toLogFormat(missingCaseError(metadataPlacement))); isInline = false; break; } const { attachmentDroppedDueToSize, deletedForEveryone, direction, expirationLength, expirationTimestamp, i18n, id, isEditedMessage, isSMS, isSticker, retryMessageSend, pushPanelForConversation, showEditHistoryModal, status, text, textAttachment, timestamp, } = this.props; const isStickerLike = isSticker || this.#canRenderStickerLikeEmoji(); return ( ); } #renderAuthor(): ReactNode { const { author, contactNameColor, i18n, isSticker } = this.props; if (!this.#shouldRenderAuthor()) { return null; } const stickerSuffix = isSticker ? '_with_sticker' : ''; const moduleName = `module-message__author${stickerSuffix}`; return (
); } public renderAttachment(): JSX.Element | null { const { _forceTapToPlay, attachmentDroppedDueToSize, attachments, cancelAttachmentDownload, conversationId, direction, expirationLength, expirationTimestamp, i18n, id, isSticker, kickOffAttachmentDownload, markAttachmentAsCorrupted, pushPanelForConversation, quote, readStatus, renderAudioAttachment, renderingContext, retryMessageSend, shouldHideMetadata, shouldCollapseAbove, shouldCollapseBelow, showEditHistoryModal, showLightbox, showMediaNoLongerAvailableToast, status, text, textAttachment, theme, timestamp, } = this.props; 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 || attachmentDroppedDueToSize); const withContentAbove = Boolean(quote) || this.#shouldRenderAuthor(); const displayImage = canDisplayImage(attachments) && !attachmentDroppedDueToSize; // attachmentDroppedDueToSize is handled in renderAttachmentTooBig const isAttachmentNotAvailable = firstAttachment.isPermanentlyUndownloadable && !attachmentDroppedDueToSize; if ( displayImage && !imageBroken && !(isSticker && isAttachmentNotAvailable) ) { const prefix = isSticker ? 'sticker' : 'attachment'; 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 ); if (isGIF(attachments)) { return (
{ showLightbox({ attachment: firstAttachment, messageId: id, }); }} startDownload={() => { kickOffAttachmentDownload({ messageId: id, }); }} cancelDownload={() => { cancelAttachmentDownload({ messageId: id, }); }} showMediaNoLongerAvailableToast={showMediaNoLongerAvailableToast} />
); } if (isSticker || isImage(attachments) || isVideo(attachments)) { 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 (
{ showLightbox({ attachment, messageId: id }); }} startDownload={() => { kickOffAttachmentDownload({ messageId: id }); }} cancelDownload={() => { cancelAttachmentDownload({ messageId: id }); }} showMediaNoLongerAvailableToast={showMediaNoLongerAvailableToast} />
); } } const isAttachmentAudio = isAudio(attachments); if (isAttachmentNotAvailable && (isAttachmentAudio || isSticker)) { return this.renderSimpleAttachmentNotAvailable(); } if (isAttachmentAudio) { const played = isPlayed(direction, status, readStatus); return renderAudioAttachment({ i18n, buttonRef: this.audioButtonRef, renderingContext, theme, attachment: firstAttachment, collapseMetadata, withContentAbove, withContentBelow, direction, expirationLength, expirationTimestamp, id, conversationId, played, pushPanelForConversation, status, textPending: textAttachment?.pending, timestamp, cancelAttachmentDownload() { cancelAttachmentDownload({ messageId: id }); }, kickOffAttachmentDownload() { kickOffAttachmentDownload({ messageId: id }); }, onCorrupted() { markAttachmentAsCorrupted({ attachment: firstAttachment, messageId: id, }); }, }); } const { fileName, size, contentType } = firstAttachment; const isIncoming = direction === 'incoming'; const renderAttachmentDownloaded = () => { const extension = getExtensionForDisplay({ contentType, fileName }); const isDangerous = isFileDangerous(fileName || ''); const moreChar = extension && extension.length > 3; const extensionForDisplay = extension && extension.length > 4 ? `${extension.slice(0, 3)}…` : extension; return ( <>
{extension ? (
{extensionForDisplay}
) : null}
{isDangerous ? (
) : null} ); }; const willShowMetadata = expirationLength || expirationTimestamp || !shouldHideMetadata; // Note: this has to be interactive for the case where text comes along with the // attachment. But we don't want the user to tab here unless that text exists. const tabIndex = text ? 0 : -1; return ( ); } public renderSimpleAttachmentNotAvailable(): JSX.Element | null { const { attachmentDroppedDueToSize, attachments, author, i18n, isSticker, isTapToView, isTapToViewError, isTapToViewExpired, readStatus, showAttachmentNotAvailableModal, showTapToViewNotAvailableModal, text, quote, } = this.props; const isAttachmentAudio = isAudio(attachments); const withContentBelow = Boolean(text || attachmentDroppedDueToSize); const withContentAbove = Boolean(quote) || this.#shouldRenderAuthor(); const isViewed = readStatus === ReadStatus.Viewed; let attachmentType: string; let info: string; let attachmentModalType: AttachmentNotAvailableModalType | undefined; let tapToViewModalType: TapToViewNotAvailableType | undefined; if (isAttachmentAudio) { attachmentType = 'audio'; info = i18n('icu:attachmentNotAvailable__voice'); attachmentModalType = AttachmentNotAvailableModalType.VoiceMessage; } else if (isSticker) { attachmentType = 'sticker'; info = i18n('icu:attachmentNotAvailable__sticker'); attachmentModalType = AttachmentNotAvailableModalType.Sticker; } else if (isTapToView && !isViewed && isTapToViewExpired) { attachmentType = 'tap-to-view'; info = i18n('icu:attachmentNotAvailable__tapToView'); tapToViewModalType = TapToViewNotAvailableType.Expired; } else if (isTapToView && !isViewed && isTapToViewError) { attachmentType = 'tap-to-view'; info = i18n('icu:attachmentNotAvailable__tapToViewCannotDownload'); tapToViewModalType = TapToViewNotAvailableType.Error; } else { assertDev( false, 'renderAttachment(): Invalid case for permanently undownloadable attachment' ); return null; } const containerClassName = classNames( 'module-message__undownloadable-attachment', withContentAbove ? 'module-message__undownloadable-attachment--with-content-above' : null, withContentBelow ? 'module-message__undownloadable-attachment--with-content-below' : null, text ? null : 'module-message__undownloadable-attachment--no-text' ); const iconClassName = classNames( 'module-message__undownloadable-attachment__icon', `module-message__undownloadable-attachment__icon--${attachmentType}` ); return (
{info}
); } public renderUndownloadableTextAttachment(): JSX.Element | null { const { i18n, textAttachment, showAttachmentNotAvailableModal } = this.props; if (!textAttachment || !textAttachment.isPermanentlyUndownloadable) { return null; } return ( ); } public renderPreview(): JSX.Element | null { const { attachments, conversationType, direction, i18n, id, kickOffAttachmentDownload, cancelAttachmentDownload, showMediaNoLongerAvailableToast, previews, quote, shouldCollapseAbove, theme, } = this.props; // Attachments take precedence over Link Previews if (attachments && attachments.length) { return null; } if (!previews || previews.length < 1) { return null; } const first = previews[0]; if (!first) { return null; } const withContentAbove = Boolean(quote) || (!shouldCollapseAbove && conversationType === 'group' && direction === 'incoming'); const previewHasImage = isImageAttachment(first.image); const isFullSizeImage = shouldUseFullSizeLinkPreviewImage(first); const linkPreviewDate = first.date || null; const title = first.title || (first.isCallLink ? i18n('icu:calling__call-link-default-title') : undefined); const description = first.description || (first.isCallLink ? i18n('icu:message--call-link-description') : undefined); const isClickable = this.#areLinksEnabled(); const className = classNames( 'module-message__link-preview', `module-message__link-preview--${direction}`, { 'module-message__link-preview--with-content-above': withContentAbove, 'module-message__link-preview--nonclickable': !isClickable, } ); const contents = ( <> {first.image && previewHasImage && isFullSizeImage ? ( { openLinkInWebBrowser(first.url); }} startDownload={() => { kickOffAttachmentDownload({ messageId: id }); }} cancelDownload={() => { cancelAttachmentDownload({ messageId: id }); }} showMediaNoLongerAvailableToast={showMediaNoLongerAvailableToast} /> ) : null}
{first.image && first.domain && previewHasImage && !isFullSizeImage ? (
{i18n('icu:previewThumbnail', { openLinkInWebBrowser(first.url); }} startDownload={() => { kickOffAttachmentDownload({ messageId: id }); }} cancelDownload={() => { cancelAttachmentDownload({ messageId: id }); }} />
) : null} {first.isCallLink && (
)}
{title}
{description && (
{unescape(description)}
)}
{first.domain}
); return isClickable ? (
{ if (event.key === 'Enter' || event.key === 'Space') { event.stopPropagation(); event.preventDefault(); openLinkInWebBrowser(first.url); } }} onClick={(event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); openLinkInWebBrowser(first.url); }} > {contents}
) : (
{contents}
); } public renderAttachmentTooBig(): JSX.Element | null { const { attachments, attachmentDroppedDueToSize, direction, i18n, quote, shouldCollapseAbove, shouldCollapseBelow, text, } = this.props; const { metadataWidth } = this.state; if (!attachmentDroppedDueToSize) { return null; } const labelText = attachments?.length ? i18n('icu:message--attachmentTooBig--multiple') : i18n('icu:message--attachmentTooBig--one'); const isContentAbove = quote || attachments?.length; const isContentBelow = Boolean(text); const willCollapseAbove = shouldCollapseAbove && !isContentAbove; const willCollapseBelow = shouldCollapseBelow && !isContentBelow; const maybeSpacer = text ? undefined : this.#getMetadataPlacement() === MetadataPlacement.InlineWithText && ( ); return (
{labelText} {maybeSpacer}
); } 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 || giftBadge.state === GiftBadgeStates.Failed ) { const description = direction === 'incoming' ? i18n('icu:message--donation--unopened--incoming') : i18n('icu:message--donation--unopened--outgoing'); const { metadataWidth } = this.state; return (
{description} {this.#getMetadataPlacement() === MetadataPlacement.InlineWithText && ( )}
{this.#renderMetadata()}
); } if ( giftBadge.state === GiftBadgeStates.Redeemed || giftBadge.state === GiftBadgeStates.Opened ) { const badgeId = giftBadge.id || `BOOST-${giftBadge.level}`; 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('icu:message--donation--remaining--days', { days: remainingDays, }); } else if (remainingHours > 1) { remaining = i18n('icu:message--donation--remaining--hours', { hours: remainingHours, }); } else if (remainingMinutes > 0) { remaining = i18n('icu:message--donation--remaining--minutes', { minutes: remainingMinutes, }); } else { remaining = i18n('icu:message--donation--expired'); } const wasSent = direction === 'outgoing'; const buttonContents = wasSent ? ( i18n('icu:message--donation--view') ) : ( <> {' '} {i18n('icu:message--donation--redeemed')} ); const badgeElement = badge ? ( {badge.name} ) : (
); return (
{badgeElement}
{i18n('icu:message--donation')}
{remaining}
{this.#renderMetadata()} {showOutgoingGiftBadgeModal ? ( this.setState({ showOutgoingGiftBadgeModal: false }) } /> ) : null}
); } throw missingCaseError(giftBadge.state); } public renderPayment(): JSX.Element | null { const { payment, direction, author, conversationTitle, conversationColor, i18n, } = this.props; if (payment == null || !isPaymentNotificationEvent(payment)) { return null; } return (

{getPaymentEventDescription( payment, author.title, conversationTitle, author.isMe, i18n )}

{i18n('icu:payment-event-notification-check-primary-device')}

{payment.note != null && (

)}
); } public renderQuote(): JSX.Element | null { const { conversationColor, conversationId, conversationTitle, customColor, direction, disableScroll, doubleCheckMissingQuoteReference, i18n, id, quote, scrollToQuotedMessage, } = this.props; if (!quote) { return null; } const { isGiftBadge, isViewOnce, referencedMessageNotFound } = quote; const clickHandler = disableScroll ? undefined : () => { scrollToQuotedMessage({ authorId: quote.authorId, conversationId, sentAt: quote.sentAt, }); }; const isIncoming = direction === 'incoming'; return ( doubleCheckMissingQuoteReference(id) } /> ); } public renderStoryReplyContext(): JSX.Element | null { const { conversationTitle, conversationColor, customColor, direction, i18n, storyReplyContext, viewStory, } = this.props; if (!storyReplyContext) { return null; } const isIncoming = direction === 'incoming'; return ( <> {storyReplyContext.emoji && (
{isIncoming ? i18n('icu:Quote__story-reaction--you') : i18n('icu:Quote__story-reaction', { name: storyReplyContext.authorTitle, })}
)} { if (!storyReplyContext.storyId) { return; } viewStory({ storyId: storyReplyContext.storyId, storyViewMode: StoryViewModeType.Single, }); }} rawAttachment={storyReplyContext.rawAttachment} reactionEmoji={storyReplyContext.emoji} referencedMessageNotFound={!storyReplyContext.storyId} text={storyReplyContext.text} /> ); } public renderEmbeddedContact(): JSX.Element | null { const { cancelAttachmentDownload, contact, conversationType, direction, i18n, id, kickOffAttachmentDownload, pushPanelForConversation, 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.serviceId) || withCaption; const attachment = contact.avatar?.avatar; const avatarNeedsAction = attachment && !isDownloaded(attachment) && !attachment.isPermanentlyUndownloadable; const tabIndex = otherContent || avatarNeedsAction ? 0 : -1; return ( { if (avatarNeedsAction) { if (isDownloading(attachment)) { cancelAttachmentDownload({ messageId: id }); } else { kickOffAttachmentDownload({ messageId: id }); } return; } pushPanelForConversation({ type: PanelType.ContactDetails, args: { messageId: id, }, }); }} withContentAbove={withContentAbove} withContentBelow={withContentBelow} tabIndex={tabIndex} /> ); } 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, serviceId } = contact; if (!firstNumber || !serviceId) { return null; } return ( ); } #renderAvatar(): ReactNode { const { author, conversationId, conversationType, direction, getPreferredBadge, i18n, shouldCollapseBelow, showContactModal, theme, } = this.props; if (conversationType !== 'group' || direction !== 'incoming') { return null; } return (
{shouldCollapseBelow ? ( ) : ( { 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} /> )}
); } #getContents(): string | undefined { const { deletedForEveryone, direction, i18n, status, text } = this.props; if (deletedForEveryone) { return i18n('icu:message--deletedForEveryone'); } if (direction === 'incoming' && status === 'error') { return i18n('icu:incomingError'); } return text; } public renderText(): JSX.Element | null { const { bodyRanges, deletedForEveryone, direction, displayLimit, i18n, id, isSpoilerExpanded, kickOffAttachmentDownload, messageExpanded, payment, showConversation, showSpoiler, status, textAttachment, } = this.props; const { metadataWidth } = this.state; const contents = this.#getContents(); if (!contents) { return null; } // Payment notifications are rendered in renderPayment, but they may have additional // text in message.body for backwards-compatibility that we don't want to render if (payment && isPaymentNotificationEvent(payment)) { return null; } return (
{ // Prevent metadata from being selected on triple clicks. const clickCount = e.detail; const range = window.getSelection()?.getRangeAt(0); if ( clickCount === 3 && this.#metadataRef.current && range?.intersectsNode(this.#metadataRef.current) ) { range.setEndBefore(this.#metadataRef.current); } }} onDoubleClick={(event: React.MouseEvent) => { // Prevent double-click interefering with interactions _inside_ // the bubble. event.stopPropagation(); }} > { if (!textAttachment) { return; } if (isDownloaded(textAttachment)) { return; } kickOffAttachmentDownload({ messageId: id, }); }} messageExpanded={messageExpanded} showConversation={showConversation} renderLocation={RenderLocation.Timeline} onExpandSpoiler={data => showSpoiler(id, data)} text={contents || ''} textAttachment={textAttachment} /> {this.#getMetadataPlacement() === MetadataPlacement.InlineWithText && ( )}
); } #shouldShowJoinButton(): boolean { const { previews } = this.props; if (previews?.length !== 1) { return false; } const onlyPreview = previews[0]; return Boolean(onlyPreview.isCallLink); } #renderAction(): JSX.Element | null { const { direction, activeCallConversationId, i18n, previews } = this.props; if (this.#shouldShowJoinButton()) { const firstPreview = previews[0]; const inAnotherCall = Boolean( activeCallConversationId && (!firstPreview.callLinkRoomId || activeCallConversationId !== firstPreview.callLinkRoomId) ); const joinButton = ( ); return inAnotherCall ? ( {joinButton} ) : ( joinButton ); } return null; } #renderError(): ReactNode { const { status, direction } = this.props; if ( status !== 'paused' && status !== 'error' && status !== 'partial-sent' ) { return null; } return (
); } public getWidth(): number | undefined { const { attachments, giftBadge, isSticker, isTapToView, previews } = this.props; if (isTapToView) { return undefined; } if (giftBadge) { return 240; } if (attachments && attachments.length) { if (isGIF(attachments)) { // Message container border return GIF_SIZE + 2; } if (isSticker) { // Padding is 8px, on both sides return STICKER_SIZE + 8 * 2; } const dimensions = getGridDimensions(attachments); if (dimensions) { return dimensions.width; } } const firstLinkPreview = (previews || [])[0]; if ( firstLinkPreview && firstLinkPreview.image && shouldUseFullSizeLinkPreviewImage(firstLinkPreview) ) { const dimensions = getImageDimensions(firstLinkPreview.image); if (dimensions) { return dimensions.width; } } if (firstLinkPreview && firstLinkPreview.isCallLink) { return 300; } return undefined; } public isShowingImage(): boolean { const { isTapToView, attachments, previews } = this.props; const { imageBroken } = this.state; if (imageBroken || isTapToView) { return false; } if (attachments && attachments.length) { const displayImage = canDisplayImage(attachments); return displayImage && (isImage(attachments) || isVideo(attachments)); } if (previews && previews.length) { const first = previews[0]; const { image } = first; return isImageAttachment(image); } return false; } public isAttachmentPending(): boolean { const { attachments } = this.props; if (!attachments || attachments.length < 1) { return false; } const first = attachments[0]; return Boolean(first.pending); } public renderTapToViewIcon(): JSX.Element { const { direction, isTapToViewError, isTapToViewExpired, readStatus } = this.props; const isIncoming = direction === 'incoming'; let state = 'ready'; let isDisabled = false; if (!isIncoming) { state = 'outgoing'; isDisabled = true; } else if (readStatus === ReadStatus.Viewed) { state = 'viewed'; isDisabled = true; } else if (isTapToViewError || isTapToViewExpired) { throw new Error( 'renderTapToViewIcon: This state is handled in renderSimpleAttachmentNotAvailable' ); } return (
); } public renderTapToViewText(): { title: string; detail: string | undefined } { const { attachments, direction, i18n, isTapToViewExpired, isTapToViewError, readStatus, } = this.props; if (direction === 'outgoing') { return { title: i18n('icu:Message--tap-to-view--media'), detail: undefined, }; } if (readStatus === ReadStatus.Viewed) { return { title: i18n('icu:Message--tap-to-view--viewed'), detail: undefined, }; } if (isTapToViewExpired || isTapToViewError) { throw new Error( 'renderTapToViewText: This state is handled in renderSimpleAttachmentNotAvailable' ); } let detail = i18n('icu:Message--tap-to-view--helper-text'); const firstAttachment = attachments?.[0]; if (firstAttachment && !firstAttachment.path) { detail = formatFileSize(firstAttachment.size); } if (isVideo(attachments) || isGIF(attachments)) { return { title: i18n('icu:Message--tap-to-view--video'), detail, }; } return { title: i18n('icu:Message--tap-to-view--photo'), detail, }; } public renderTapToView(): JSX.Element | null { const { attachments, attachmentDroppedDueToSize, conversationType, direction, expirationLength, expirationTimestamp, i18n, id, isTapToViewError, isTapToViewExpired, pushPanelForConversation, readStatus, retryMessageSend, showEditHistoryModal, status, timestamp, } = this.props; const firstAttachment = attachments?.[0]; const isIncoming = direction === 'incoming'; const isViewed = readStatus === ReadStatus.Viewed; const isExpired = Boolean( !isViewed && (isTapToViewExpired || firstAttachment?.isPermanentlyUndownloadable) ); const isError = isTapToViewError || attachmentDroppedDueToSize; const collapseMetadata = this.#getMetadataPlacement() === MetadataPlacement.NotRendered; const withContentAbove = !collapseMetadata && conversationType === 'group' && direction === 'incoming'; if (isIncoming && !isViewed && (isError || isExpired)) { return this.renderSimpleAttachmentNotAvailable(); } const text = this.renderTapToViewText(); let content: JSX.Element; if (text.title && text.detail) { content = (
{text.title}
{text.detail}
{collapseMetadata ? undefined : (
)}
); } else { content = ( <>
{text.title}
{collapseMetadata ? undefined : (
)} ); } return (
this.renderTapToViewIcon()} /> {content}
); } #popperPreventOverflowModifier = (): Partial => { const { containerElementRef } = this.props; return { name: 'preventOverflow', options: { altAxis: true, boundary: containerElementRef.current || undefined, padding: { bottom: 16, left: 8, right: 8, top: 16, }, }, }; }; public toggleReactionViewer = (onlyRemove = false): void => { this.setState(oldState => { const { reactionViewerRoot } = oldState; if (reactionViewerRoot) { document.body.removeChild(reactionViewerRoot); oldState.reactionViewerOutsideClickDestructor?.(); return { reactionViewerRoot: null, reactionViewerOutsideClickDestructor: undefined, }; } if (!onlyRemove) { const root = document.createElement('div'); document.body.appendChild(root); const reactionViewerOutsideClickDestructor = handleOutsideClick( () => { this.toggleReactionViewer(true); return true; }, { containerElements: [root, this.reactionsContainerRef], name: 'Message.reactionViewer', } ); return { reactionViewerRoot: root, reactionViewerOutsideClickDestructor, }; } return null; }); }; public renderReactions(outgoing: boolean): JSX.Element | null { const { getPreferredBadge, reactions = [], i18n, theme } = this.props; if (!this.#hasReactions()) { return null; } const { reactionViewerRoot } = this.state; return ( { this.toggleReactionViewer(); }} reactionViewerRoot={reactionViewerRoot} popperPreventOverflowModifier={this.#popperPreventOverflowModifier} ref={this.reactionsContainerRef} /> ); } public renderContents(): JSX.Element | null { const { deletedForEveryone, giftBadge, isTapToView } = this.props; if (deletedForEveryone) { return ( <> {this.renderText()} {this.#renderMetadata()} ); } if (giftBadge) { return this.renderGiftBadge(); } if (isTapToView) { return ( <> {this.renderTapToView()} {this.#renderMetadata()} ); } return ( <> {this.renderQuote()} {this.renderStoryReplyContext()} {this.renderAttachment()} {this.renderPreview()} {this.renderAttachmentTooBig()} {this.renderPayment()} {this.renderEmbeddedContact()} {this.renderText()} {this.renderUndownloadableTextAttachment()} {this.#renderAction()} {this.#renderMetadata()} {this.renderSendMessageButton()} ); } public handleOpen = (event: React.KeyboardEvent | React.MouseEvent): void => { const { attachments, cancelAttachmentDownload, contact, direction, giftBadge, id, isSticker, isTapToView, isTapToViewError, isTapToViewExpired, kickOffAttachmentDownload, openGiftBadge, pushPanelForConversation, readStatus, showAttachmentNotAvailableModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, showLightbox, showLightboxForViewOnceMedia, showMediaNoLongerAvailableToast, startConversation, } = this.props; const { imageBroken } = this.state; const isAttachmentPending = this.isAttachmentPending(); if (giftBadge && giftBadge.state === GiftBadgeStates.Unopened) { openGiftBadge(id); return; } if (isTapToView) { event.preventDefault(); event.stopPropagation(); if (direction === 'outgoing') { showExpiredOutgoingTapToViewToast(); return; } if (readStatus === ReadStatus.Viewed) { showExpiredIncomingTapToViewToast(); return; } if (isTapToViewError || isTapToViewExpired) { // The only interactive element is the Learn More button return; } if (attachments && !isDownloaded(attachments[0])) { if (isDownloading(attachments[0])) { cancelAttachmentDownload({ messageId: id }); } else { kickOffAttachmentDownload({ messageId: id }); } return; } showLightboxForViewOnceMedia(id); return; } if (attachments?.[0]?.isPermanentlyUndownloadable) { event.preventDefault(); event.stopPropagation(); // This needs to be the first check because canDisplayImage is true for stickers if (isSticker) { showAttachmentNotAvailableModal( AttachmentNotAvailableModalType.Sticker ); } else if (canDisplayImage(attachments)) { showMediaNoLongerAvailableToast(); } else if (isAudio(attachments)) { showAttachmentNotAvailableModal( AttachmentNotAvailableModalType.VoiceMessage ); } else { showAttachmentNotAvailableModal(AttachmentNotAvailableModalType.File); } return; } if (contact && contact.firstNumber && contact.serviceId) { startConversation(contact.firstNumber, contact.serviceId); event.preventDefault(); event.stopPropagation(); return; } if (contact) { pushPanelForConversation({ type: PanelType.ContactDetails, args: { messageId: id, }, }); event.preventDefault(); event.stopPropagation(); return; } if (this.isGenericAttachment(attachments, imageBroken)) { this.openGenericAttachment(); return; } if ( isAudio(attachments) && this.audioButtonRef && this.audioButtonRef.current ) { event.preventDefault(); event.stopPropagation(); this.audioButtonRef.current.click(); return; } if ( !imageBroken && attachments && attachments.length > 0 && !isAttachmentPending && !isDownloaded(attachments[0]) ) { event.preventDefault(); event.stopPropagation(); kickOffAttachmentDownload({ messageId: id }); return; } if ( !imageBroken && attachments && attachments.length > 0 && !isAttachmentPending && canDisplayImage(attachments) && ((isImage(attachments) && hasImage(attachments)) || (isVideo(attachments) && hasVideoScreenshot(attachments))) ) { event.preventDefault(); event.stopPropagation(); const attachment = attachments[0]; showLightbox({ attachment, messageId: id }); } }; public openGenericAttachment = (event?: React.MouseEvent): void => { const { id, attachments, saveAttachment, timestamp, kickOffAttachmentDownload, attachmentDroppedDueToSize, showAttachmentNotAvailableModal, cancelAttachmentDownload, } = this.props; if (event) { event.preventDefault(); event.stopPropagation(); } const firstAttachment = attachments?.[0]; if (!firstAttachment) { return; } const isAttachmentNotAvailable = firstAttachment.isPermanentlyUndownloadable && !attachmentDroppedDueToSize; if (isAttachmentNotAvailable) { showAttachmentNotAvailableModal(AttachmentNotAvailableModalType.File); } else if (firstAttachment.pending) { cancelAttachmentDownload({ messageId: id, }); } else if (!firstAttachment.path) { kickOffAttachmentDownload({ messageId: id, }); } else { saveAttachment(firstAttachment, timestamp); } }; public handleClick = (event: React.MouseEvent): void => { // We don't want clicks on body text to result in the 'default action' for the message const { text } = this.props; if (text && text.length > 0) { return; } this.handleOpen(event); }; public handleKeyDown = (event: React.KeyboardEvent): void => { if (event.key !== 'Enter' && event.key !== ' ') { return; } this.handleOpen(event); }; private isGenericAttachment( attachments: ReadonlyArray | undefined, imageBroken: boolean ) { return ( attachments?.length && (!isImage(attachments) || !canDisplayImage(attachments) || imageBroken) && (!isVideo(attachments) || !canDisplayImage(attachments) || imageBroken) && !isAudio(attachments) ); } public renderContainer(): JSX.Element { const { attachments, attachmentDroppedDueToSize, contact, conversationColor, customColor, deletedForEveryone, direction, id, isSticker, isTapToView, onContextMenu, text, textDirection, } = this.props; const { isTargeted, imageBroken } = this.state; const width = this.getWidth(); const isEmojiOnly = this.#canRenderStickerLikeEmoji(); const isStickerLike = isEmojiOnly || (isSticker && attachments && attachments[0] && !attachments[0].isPermanentlyUndownloadable); // If it's a mostly-normal gray incoming text box, we don't want to darken it as much const lighterSelect = isTargeted && direction === 'incoming' && !isStickerLike && (text || (!isVideo(attachments) && !isImage(attachments))); const isClickable = isTapToView || (this.isGenericAttachment(attachments, imageBroken) && !text) || contact; const containerClassnames = classNames( 'module-message__container', isGIF(attachments) && !isTapToView ? 'module-message__container--gif' : null, isTargeted ? 'module-message__container--targeted' : null, lighterSelect ? 'module-message__container--targeted-lighter' : null, !isStickerLike ? `module-message__container--${direction}` : null, isEmojiOnly ? 'module-message__container--emoji' : null, !isStickerLike && direction === 'outgoing' ? `module-message__container--outgoing-${conversationColor}` : null, isClickable ? 'module-message__container--is-clickable' : null, this.#hasReactions() ? 'module-message__container--with-reactions' : null, deletedForEveryone ? 'module-message__container--deleted-for-everyone' : null ); const containerStyles = { width, }; if ( !isStickerLike && !deletedForEveryone && !(attachmentDroppedDueToSize && !text) && direction === 'outgoing' ) { Object.assign(containerStyles, getCustomColorStyle(customColor)); } return (
{/* the keyboard handler is a level higher in hierarchy due to selection */} {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
{ // Prevent double click from triggering the replyToMessage action ev.stopPropagation(); }} tabIndex={-1} > {this.#renderAuthor()}
{this.renderContents()}
{this.renderReactions(direction === 'outgoing')}
); } renderAltAccessibilityTree(): JSX.Element { const { id, i18n, author } = this.props; return ( {author.isMe ? i18n('icu:messageAccessibilityLabel--outgoing') : i18n('icu:messageAccessibilityLabel--incoming', { author: author.title, })} {this.renderText()} ); } public override render(): JSX.Element | null { const { id, attachments, direction, i18n, isSticker, isSelected, isSelectMode, platform, renderMenu, shouldCollapseAbove, shouldCollapseBelow, timestamp, onToggleSelect, onReplyToMessage, } = this.props; const isMacOS = platform === 'darwin'; const { expired, expiring, isTargeted, imageBroken } = this.state; if (expired) { return null; } if (isSticker && (imageBroken || !attachments || !attachments.length)) { return null; } let wrapperProps: DetailedHTMLProps< HTMLAttributes, HTMLDivElement >; if (isSelectMode) { wrapperProps = { role: 'checkbox', 'aria-checked': isSelected, 'aria-labelledby': `message-accessibility-label:${id}`, 'aria-describedby': `message-accessibility-description:${id}`, tabIndex: 0, onClick: event => { event.preventDefault(); onToggleSelect(!isSelected, event.shiftKey); }, onKeyDown: event => { if (event.code === 'Space') { event.preventDefault(); onToggleSelect(!isSelected, event.shiftKey); } }, }; } else { wrapperProps = { onMouseDown: () => { this.#hasSelectedTextRef.current = false; }, // We use `onClickCapture` here and prevent default/stop propagation to // prevent other click handlers from firing. onClickCapture: event => { if (isMacOS ? event.metaKey : event.ctrlKey) { if (this.#hasSelectedTextRef.current) { return; } const target = event.target as HTMLElement; const link = target.closest('a[href], [role=link]'); if (event.currentTarget.contains(link)) { return; } event.preventDefault(); event.stopPropagation(); onToggleSelect(true, false); } }, onDoubleClick: event => { event.stopPropagation(); event.preventDefault(); if (!isSelectMode) { onReplyToMessage(); } }, onKeyDown: event => this.handleKeyDown(event), }; } return (
{isSelectMode && ( <> {this.renderAltAccessibilityTree()} )}
{this.#renderError()} {this.#renderAvatar()} {this.renderContainer()} {renderMenu?.()}
); } }