import type { FC } from '../../../lib/teact/teact'; import React, { beginHeavyAnimation, memo, useCallback, useEffect, useMemo, useRef, useState, useUnmountCleanup, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiAvailableEffect, ApiAvailableReaction, ApiChat, ApiChatMember, ApiMessage, ApiMessageOutgoingStatus, ApiPeer, ApiReaction, ApiReactionKey, ApiSavedReactionTag, ApiThreadInfo, ApiTopic, ApiTypeStory, ApiUser, } from '../../../api/types'; import type { ActiveEmojiInteraction, ChatTranslatedMessages, MessageListType } from '../../../global/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { FocusDirection, IAlbum, ISettings, ScrollTargetPosition, ThreadId, } from '../../../types'; import type { Signal } from '../../../util/signals'; import type { OnIntersectPinnedMessage } from '../hooks/usePinnedMessage'; import { MAIN_THREAD_ID } from '../../../api/types'; import { AudioOrigin } from '../../../types'; import { EMOJI_STATUS_LOOP_LIMIT } from '../../../config'; import { areReactionsEmpty, getIsDownloading, getMessageContent, getMessageCustomShape, getMessageDownloadableMedia, getMessageHtmlId, getMessageSingleCustomEmoji, getMessageSingleRegularEmoji, getSenderTitle, hasMessageText, hasMessageTtl, isAnonymousForwardsChat, isAnonymousOwnMessage, isChatChannel, isChatGroup, isChatPublic, isGeoLiveExpired, isMessageLocal, isMessageTranslatable, isOwnMessage, isReplyToMessage, isSystemBot, isUserId, } from '../../../global/helpers'; import { getMessageReplyInfo, getStoryReplyInfo } from '../../../global/helpers/replies'; import { selectActiveDownloads, selectAnimatedEmoji, selectCanAutoLoadMedia, selectCanAutoPlayMedia, selectCanReplyToMessage, selectChat, selectChatFullInfo, selectChatMessage, selectChatTranslations, selectCurrentMiddleSearch, selectDefaultReaction, selectForwardedSender, selectIsChatProtected, selectIsChatWithSelf, selectIsCurrentUserPremium, selectIsDocumentGroupSelected, selectIsInSelectMode, selectIsMessageFocused, selectIsMessageProtected, selectIsMessageSelected, selectMessageIdsByGroupId, selectOutgoingStatus, selectPeer, selectPeerStory, selectPerformanceSettingsValue, selectRequestedChatTranslationLanguage, selectRequestedMessageTranslationLanguage, selectSender, selectSenderFromHeader, selectShouldDetectChatLanguage, selectShouldLoopStickers, selectTabState, selectTheme, selectThreadInfo, selectTopicFromMessage, selectUploadProgress, selectUser, } from '../../../global/selectors'; import { isAnimatingScroll } from '../../../util/animateScroll'; import buildClassName from '../../../util/buildClassName'; import { isElementInViewport } from '../../../util/isElementInViewport'; import { getMessageKey } from '../../../util/keys/messageKey'; import stopEvent from '../../../util/stopEvent'; import { IS_ANDROID, IS_ELECTRON, IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment'; import { calculateDimensionsForMessageMedia, getStickerDimensions, REM, ROUND_VIDEO_DIMENSIONS_PX, } from '../../common/helpers/mediaDimensions'; import { getPeerColorClass } from '../../common/helpers/peerColor'; import renderText from '../../common/helpers/renderText'; import { getCustomEmojiSize } from '../composer/helpers/customEmoji'; import { buildContentClassName } from './helpers/buildContentClassName'; import { calculateAlbumLayout } from './helpers/calculateAlbumLayout'; import getSingularPaidMedia from './helpers/getSingularPaidMedia'; import { calculateMediaDimensions, getMinMediaWidth, MIN_MEDIA_WIDTH_WITH_TEXT } from './helpers/mediaDimensions'; import useAppLayout from '../../../hooks/useAppLayout'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useEnsureStory from '../../../hooks/useEnsureStory'; import useFlag from '../../../hooks/useFlag'; import { useOnIntersect } from '../../../hooks/useIntersectionObserver'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated'; import useResizeObserver from '../../../hooks/useResizeObserver'; import useShowTransition from '../../../hooks/useShowTransition'; import useTextLanguage from '../../../hooks/useTextLanguage'; import useThrottledCallback from '../../../hooks/useThrottledCallback'; import useDetectChatLanguage from './hooks/useDetectChatLanguage'; import useFocusMessage from './hooks/useFocusMessage'; import useInnerHandlers from './hooks/useInnerHandlers'; import useMessageTranslation from './hooks/useMessageTranslation'; import useOuterHandlers from './hooks/useOuterHandlers'; import Audio from '../../common/Audio'; import Avatar from '../../common/Avatar'; import CustomEmoji from '../../common/CustomEmoji'; import Document from '../../common/Document'; import DotAnimation from '../../common/DotAnimation'; import EmbeddedMessage from '../../common/embedded/EmbeddedMessage'; import EmbeddedStory from '../../common/embedded/EmbeddedStory'; import FakeIcon from '../../common/FakeIcon'; import Icon from '../../common/icons/Icon'; import StarIcon from '../../common/icons/StarIcon'; import MessageText from '../../common/MessageText'; import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji'; import TopicChip from '../../common/TopicChip'; import Button from '../../ui/Button'; import Album from './Album'; import AnimatedCustomEmoji from './AnimatedCustomEmoji'; import AnimatedEmoji from './AnimatedEmoji'; import CommentButton from './CommentButton'; import Contact from './Contact'; import ContextMenuContainer from './ContextMenuContainer.async'; import FactCheck from './FactCheck'; import Game from './Game'; import Giveaway from './Giveaway'; import InlineButtons from './InlineButtons'; import Invoice from './Invoice'; import InvoiceMediaPreview from './InvoiceMediaPreview'; import Location from './Location'; import MessageAppendix from './MessageAppendix'; import MessageEffect from './MessageEffect'; import MessageMeta from './MessageMeta'; import MessagePhoneCall from './MessagePhoneCall'; import PaidMediaOverlay from './PaidMediaOverlay'; import Photo from './Photo'; import Poll from './Poll'; import Reactions from './reactions/Reactions'; import RoundVideo from './RoundVideo'; import Sticker from './Sticker'; import Story from './Story'; import StoryMention from './StoryMention'; import Video from './Video'; import WebPage from './WebPage'; import './Message.scss'; type MessagePositionProperties = { isFirstInGroup: boolean; isLastInGroup: boolean; isFirstInDocumentGroup: boolean; isLastInDocumentGroup: boolean; isLastInList: boolean; }; type OwnProps = { message: ApiMessage; observeIntersectionForBottom: ObserveFn; observeIntersectionForLoading: ObserveFn; observeIntersectionForPlaying: ObserveFn; album?: IAlbum; noAvatars?: boolean; withAvatar?: boolean; withSenderName?: boolean; threadId: ThreadId; messageListType: MessageListType; noComments: boolean; noReplies: boolean; appearanceOrder: number; isJustAdded: boolean; memoFirstUnreadIdRef: { current: number | undefined }; getIsMessageListReady: Signal; onIntersectPinnedMessage: OnIntersectPinnedMessage; } & MessagePositionProperties; type StateProps = { theme: ISettings['theme']; forceSenderName?: boolean; sender?: ApiPeer; canShowSender: boolean; originSender?: ApiPeer; botSender?: ApiUser; isThreadTop?: boolean; shouldHideReply?: boolean; replyMessage?: ApiMessage; replyMessageSender?: ApiPeer; replyMessageForwardSender?: ApiPeer; replyMessageChat?: ApiChat; isReplyPrivate?: boolean; replyStory?: ApiTypeStory; storySender?: ApiPeer; outgoingStatus?: ApiMessageOutgoingStatus; uploadProgress?: number; isInDocumentGroup: boolean; isProtected?: boolean; isChatProtected?: boolean; isFocused?: boolean; focusDirection?: FocusDirection; focusedQuote?: string; noFocusHighlight?: boolean; scrollTargetPosition?: ScrollTargetPosition; isResizingContainer?: boolean; isForwarding?: boolean; isChatWithSelf?: boolean; isRepliesChat?: boolean; isAnonymousForwards?: boolean; isChannel?: boolean; isGroup?: boolean; canReply?: boolean; highlight?: string; animatedEmoji?: string; animatedCustomEmoji?: string; hasActiveReactions?: boolean; isInSelectMode?: boolean; isSelected?: boolean; isGroupSelected?: boolean; isDownloading?: boolean; threadId?: ThreadId; isPinnedList?: boolean; isPinned?: boolean; canAutoLoadMedia?: boolean; canAutoPlayMedia?: boolean; hasLinkedChat?: boolean; shouldLoopStickers?: boolean; autoLoadFileMaxSizeMb: number; repliesThreadInfo?: ApiThreadInfo; reactionMessage?: ApiMessage; availableReactions?: ApiAvailableReaction[]; defaultReaction?: ApiReaction; activeEmojiInteractions?: ActiveEmojiInteraction[]; hasUnreadReaction?: boolean; isTranscribing?: boolean; transcribedText?: string; isTranscriptionError?: boolean; isPremium: boolean; senderAdminMember?: ApiChatMember; messageTopic?: ApiTopic; hasTopicChip?: boolean; chatTranslations?: ChatTranslatedMessages; areTranslationsEnabled?: boolean; shouldDetectChatLanguage?: boolean; requestedTranslationLanguage?: string; requestedChatTranslationLanguage?: string; withAnimatedEffects?: boolean; webPageStory?: ApiTypeStory; isConnected: boolean; isLoadingComments?: boolean; shouldWarnAboutSvg?: boolean; senderBoosts?: number; tags?: Record; canTranscribeVoice?: boolean; viaBusinessBot?: ApiUser; effect?: ApiAvailableEffect; availableStars?: number; }; type MetaPosition = 'in-text' | 'standalone' | 'none'; type ReactionsPosition = 'inside' | 'outside' | 'none'; type QuickReactionPosition = 'in-content' | 'in-meta'; const NBSP = '\u00A0'; const APPEARANCE_DELAY = 10; const NO_MEDIA_CORNERS_THRESHOLD = 18; const QUICK_REACTION_SIZE = 1.75 * REM; const EXTRA_SPACE_FOR_REACTIONS = 2.25 * REM; const BOTTOM_FOCUS_SCROLL_THRESHOLD = 5; const THROTTLE_MS = 300; const RESIZE_ANIMATION_DURATION = 400; const Message: FC = ({ message, observeIntersectionForBottom, observeIntersectionForLoading, observeIntersectionForPlaying, album, noAvatars, withAvatar, withSenderName, noComments, noReplies, appearanceOrder, isJustAdded, isFirstInGroup, isPremium, isLastInGroup, isFirstInDocumentGroup, isLastInDocumentGroup, isTranscribing, transcribedText, isLastInList, theme, forceSenderName, sender, canShowSender, originSender, botSender, isThreadTop, shouldHideReply, replyMessage, replyMessageSender, replyMessageForwardSender, replyMessageChat, replyStory, isReplyPrivate, storySender, outgoingStatus, uploadProgress, isInDocumentGroup, isLoadingComments, isProtected, isChatProtected, isFocused, focusDirection, focusedQuote, noFocusHighlight, scrollTargetPosition, isResizingContainer, isForwarding, isChatWithSelf, isRepliesChat, isAnonymousForwards, isChannel, isGroup, canReply, highlight, animatedEmoji, animatedCustomEmoji, hasActiveReactions, hasLinkedChat, isInSelectMode, isSelected, isGroupSelected, threadId, reactionMessage, availableReactions, defaultReaction, activeEmojiInteractions, messageListType, isPinnedList, isPinned, isDownloading, canAutoLoadMedia, canAutoPlayMedia, shouldLoopStickers, autoLoadFileMaxSizeMb, repliesThreadInfo, hasUnreadReaction, memoFirstUnreadIdRef, senderAdminMember, messageTopic, hasTopicChip, chatTranslations, areTranslationsEnabled, shouldDetectChatLanguage, requestedTranslationLanguage, requestedChatTranslationLanguage, withAnimatedEffects, webPageStory, isConnected, getIsMessageListReady, shouldWarnAboutSvg, senderBoosts, tags, canTranscribeVoice, viaBusinessBot, effect, availableStars, onIntersectPinnedMessage, }) => { const { toggleMessageSelection, clickBotInlineButton, disableContextMenuHint, animateUnreadReaction, focusLastMessage, markMentionsRead, } = getActions(); // eslint-disable-next-line no-null/no-null const ref = useRef(null); // eslint-disable-next-line no-null/no-null const bottomMarkerRef = useRef(null); // eslint-disable-next-line no-null/no-null const quickReactionRef = useRef(null); const messageHeightRef = useRef(0); const lang = useOldLang(); const [isTranscriptionHidden, setTranscriptionHidden] = useState(false); const [shouldPlayEffect, requestEffect, hideEffect] = useFlag(); const { isMobile, isTouchScreen } = useAppLayout(); useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); const { isContextMenuOpen, contextMenuAnchor, contextMenuTarget, handleBeforeContextMenu, handleContextMenu: onContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers( ref, isTouchScreen && isInSelectMode, !IS_ELECTRON, IS_ANDROID, getIsMessageListReady, ); useEffect(() => { if (isContextMenuOpen) { disableContextMenuHint(); } }, [isContextMenuOpen, disableContextMenuHint]); const noAppearanceAnimation = appearanceOrder <= 0; const [isShown, markShown] = useFlag(noAppearanceAnimation); useEffect(() => { if (noAppearanceAnimation) { return; } setTimeout(markShown, appearanceOrder * APPEARANCE_DELAY); }, [appearanceOrder, markShown, noAppearanceAnimation]); useShowTransition({ ref, isOpen: isShown || isJustAdded, noMountTransition: noAppearanceAnimation && !isJustAdded, className: false, }); const { id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck, } = message; useUnmountCleanup(() => { if (message.isPinned) { const id = album ? album.mainMessage.id : messageId; onIntersectPinnedMessage({ viewportPinnedIdsToRemove: [id] }); } }); const isLocal = isMessageLocal(message); const isOwn = isOwnMessage(message); const isScheduled = messageListType === 'scheduled' || message.isScheduled; const hasMessageReply = isReplyToMessage(message) && !shouldHideReply; const { paidMedia } = getMessageContent(message); const { photo: paidMediaPhoto, video: paidMediaVideo } = getSingularPaidMedia(paidMedia); const { photo = paidMediaPhoto, video = paidMediaVideo, audio, voice, document, sticker, contact, poll, webPage, invoice, location, action, game, storyData, giveaway, giveawayResults, } = getMessageContent(message); const messageReplyInfo = getMessageReplyInfo(message); const storyReplyInfo = getStoryReplyInfo(message); const hasStoryReply = Boolean(storyReplyInfo); const hasThread = Boolean(repliesThreadInfo) && messageListType === 'thread'; const isCustomShape = getMessageCustomShape(message); const hasAnimatedEmoji = isCustomShape && (animatedEmoji || animatedCustomEmoji); const hasReactions = reactionMessage?.reactions && !areReactionsEmpty(reactionMessage.reactions); const asForwarded = ( forwardInfo && (!isChatWithSelf || isScheduled) && !isRepliesChat && !isAnonymousForwards && !forwardInfo.isLinkedChannelPost && !isCustomShape ) || Boolean(storyData && !storyData.isMention); const canShowSenderBoosts = Boolean(senderBoosts) && !asForwarded && isFirstInGroup; const isStoryMention = storyData?.isMention; const isRoundVideo = video?.mediaType === 'video' && video.isRound; const isAlbum = Boolean(album) && ( (album.isPaidMedia && paidMedia!.extendedMedia.length > 1) || album.messages.length > 1 ) && !album.messages.some((msg) => Object.keys(msg.content).length === 0); const isInDocumentGroupNotFirst = isInDocumentGroup && !isFirstInDocumentGroup; const isInDocumentGroupNotLast = isInDocumentGroup && !isLastInDocumentGroup; const isContextMenuShown = contextMenuAnchor !== undefined; const canShowActionButton = ( !(isContextMenuShown || isInSelectMode || isForwarding) && !isInDocumentGroupNotLast && !isStoryMention ); const canForward = isChannel && !isScheduled && message.isForwardingAllowed && !isChatProtected; const canFocus = Boolean(isPinnedList || (forwardInfo && (forwardInfo.isChannelPost || isChatWithSelf || isRepliesChat || isAnonymousForwards) && forwardInfo.fromMessageId )); const noUserColors = isOwn && !isCustomShape; const hasFactCheck = Boolean(factCheck?.text); const hasSubheader = hasTopicChip || hasMessageReply || hasStoryReply; const selectMessage = useLastCallback((e?: React.MouseEvent, groupedId?: string) => { toggleMessageSelection({ messageId, groupedId, ...(e?.shiftKey && { withShift: true }), ...(isAlbum && { childMessageIds: album!.messages.map(({ id }) => id) }), }); }); const messageSender = canShowSender ? sender : undefined; const withVoiceTranscription = Boolean(!isTranscriptionHidden && (isTranscriptionError || transcribedText)); const shouldPreferOriginSender = forwardInfo && (isChatWithSelf || isRepliesChat || isAnonymousForwards || !messageSender); const avatarPeer = shouldPreferOriginSender ? originSender : messageSender; const messageColorPeer = originSender || sender; const senderPeer = (forwardInfo || storyData) ? originSender : messageSender; const hasTtl = hasMessageTtl(message); const { handleMouseDown, handleClick, handleContextMenu, handleDoubleClick, handleContentDoubleClick, handleMouseMove, handleSendQuickReaction, handleMouseLeave, isSwiped, isQuickReactionVisible, handleDocumentGroupMouseEnter, } = useOuterHandlers( selectMessage, ref, messageId, Boolean(isInSelectMode), Boolean(canReply), Boolean(isProtected), onContextMenu, handleBeforeContextMenu, chatId, isContextMenuShown, quickReactionRef, isInDocumentGroupNotLast, getIsMessageListReady, ); const { handleAvatarClick, handleSenderClick, handleViaBotClick, handleReplyClick, handleMediaClick, handleAudioPlay, handleAlbumMediaClick, handlePhotoMediaClick, handleVideoMediaClick, handleMetaClick, handleTranslationClick, handleOpenThread, handleReadMedia, handleCancelUpload, handleVoteSend, handleGroupForward, handleForward, handleFocus, handleFocusForwarded, handleDocumentGroupSelectAll, handleTopicChipClick, handleStoryClick, } = useInnerHandlers({ lang, selectMessage, message, chatId, threadId, isInDocumentGroup, asForwarded, isScheduled, album, avatarPeer, senderPeer, botSender, messageTopic, isTranslatingChat: Boolean(requestedChatTranslationLanguage), story: replyStory && 'content' in replyStory ? replyStory : undefined, isReplyPrivate, isRepliesChat, isSavedMessages: isChatWithSelf, }); const handleEffectClick = useLastCallback((e: React.MouseEvent) => { e.stopPropagation(); requestEffect(); }); useEffect(() => { if (!isLastInList) { return; } if (withVoiceTranscription && transcribedText) { focusLastMessage(); } }, [focusLastMessage, isLastInList, transcribedText, withVoiceTranscription]); const textMessage = album?.hasMultipleCaptions ? undefined : (album?.captionMessage || message); const hasTextContent = textMessage && hasMessageText(textMessage); const hasText = hasTextContent || hasFactCheck; const containerClassName = buildClassName( 'Message message-list-item', isFirstInGroup && 'first-in-group', isProtected && !hasTextContent ? 'is-protected' : 'allow-selection', isLastInGroup && 'last-in-group', isFirstInDocumentGroup && 'first-in-document-group', isLastInDocumentGroup && 'last-in-document-group', isLastInList && 'last-in-list', isOwn && 'own', Boolean(message.viewsCount) && 'has-views', message.isEdited && 'was-edited', hasMessageReply && 'has-reply', isContextMenuOpen && 'has-menu-open', isFocused && !noFocusHighlight && 'focused', isForwarding && 'is-forwarding', message.isDeleting && 'is-deleting', isInDocumentGroup && 'is-in-document-group', isAlbum && 'is-album', message.hasUnreadMention && 'has-unread-mention', isSelected && 'is-selected', isInSelectMode && 'is-in-selection-mode', isThreadTop && !withAvatar && 'is-thread-top', Boolean(message.inlineButtons) && 'has-inline-buttons', isSwiped && 'is-swiped', isJustAdded && 'is-just-added', (hasActiveReactions || shouldPlayEffect) && 'has-active-effect', isStoryMention && 'is-story-mention', ); const text = textMessage && getMessageContent(textMessage).text; const isInvertedMedia = Boolean(message.isInvertedMedia); const { replyToMsgId, replyToPeerId, isQuote } = messageReplyInfo || {}; const { peerId: storyReplyPeerId, storyId: storyReplyId } = storyReplyInfo || {}; useEffect(() => { if ((sticker?.hasEffect || effect) && (( memoFirstUnreadIdRef.current && messageId >= memoFirstUnreadIdRef.current ) || isLocal)) { requestEffect(); } }, [effect, isLocal, memoFirstUnreadIdRef, messageId, sticker?.hasEffect]); const detectedLanguage = useTextLanguage( text?.text, !(areTranslationsEnabled || shouldDetectChatLanguage), getIsMessageListReady, ); useDetectChatLanguage(message, detectedLanguage, !shouldDetectChatLanguage, getIsMessageListReady); const shouldTranslate = isMessageTranslatable(message, !requestedChatTranslationLanguage); const { isPending: isTranslationPending, translatedText } = useMessageTranslation( chatTranslations, chatId, shouldTranslate ? messageId : undefined, requestedTranslationLanguage, ); // Used to display previous result while new one is loading const previousTranslatedText = usePreviousDeprecated(translatedText, Boolean(shouldTranslate)); const currentTranslatedText = translatedText || previousTranslatedText; const { phoneCall } = action || {}; const isMediaWithCommentButton = (repliesThreadInfo || (hasLinkedChat && isChannel && isLocal)) && !isInDocumentGroupNotLast && messageListType === 'thread' && !noComments; const withCommentButton = repliesThreadInfo?.isCommentsInfo && !isInDocumentGroupNotLast && messageListType === 'thread' && !noComments; const withQuickReactionButton = !isTouchScreen && !phoneCall && !isInSelectMode && defaultReaction && !isInDocumentGroupNotLast && !isStoryMention && !hasTtl; const hasOutsideReactions = hasReactions && (isCustomShape || ((photo || video || storyData || (location?.mediaType === 'geo')) && !hasText)); const contentClassName = buildContentClassName(message, album, { hasSubheader, isCustomShape, isLastInGroup, asForwarded, hasThread: hasThread && !noComments, forceSenderName, hasCommentCounter: hasThread && repliesThreadInfo.messagesCount > 0, hasActionButton: canForward || canFocus, hasReactions, isGeoLiveActive: location?.mediaType === 'geoLive' && !isGeoLiveExpired(message), withVoiceTranscription, peerColorClass: getPeerColorClass(messageColorPeer, noUserColors), hasOutsideReactions, }); const withAppendix = contentClassName.includes('has-appendix'); const emojiSize = getCustomEmojiSize(message.emojiOnlyCount); let metaPosition!: MetaPosition; if (phoneCall) { metaPosition = 'none'; } else if (isInDocumentGroupNotLast) { metaPosition = 'none'; } else if (hasText && !webPage && !emojiSize && !isInvertedMedia) { metaPosition = 'in-text'; } else if (isInvertedMedia && !emojiSize && (hasFactCheck || webPage)) { metaPosition = 'in-text'; } else { metaPosition = 'standalone'; } let reactionsPosition!: ReactionsPosition; if (hasReactions) { if (hasOutsideReactions) { reactionsPosition = 'outside'; } else if (asForwarded) { metaPosition = 'standalone'; reactionsPosition = 'inside'; } else { reactionsPosition = 'inside'; } } else { reactionsPosition = 'none'; } const quickReactionPosition: QuickReactionPosition = isCustomShape ? 'in-meta' : 'in-content'; useEnsureMessage( replyToPeerId || chatId, replyToMsgId, replyMessage, message.id, shouldHideReply || isQuote || isReplyPrivate, ); useEnsureStory( storyReplyPeerId || chatId, storyReplyId, replyStory, ); useFocusMessage( ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded, Boolean(focusedQuote), scrollTargetPosition, ); const viaBusinessBotTitle = viaBusinessBot ? getSenderTitle(lang, viaBusinessBot) : undefined; const canShowPostAuthor = !message.senderId; const signature = viaBusinessBotTitle || (canShowPostAuthor && message.postAuthorTitle) || ((asForwarded || isChatWithSelf) && forwardInfo?.postAuthorTitle) || undefined; const shouldFocusOnResize = isLastInList; const handleResize = useLastCallback((entry: ResizeObserverEntry) => { const lastHeight = messageHeightRef.current; const newHeight = entry.contentRect.height; messageHeightRef.current = newHeight; if (isAnimatingScroll() || !lastHeight || newHeight <= lastHeight) return; const container = entry.target.closest('.MessageList'); if (!container) return; beginHeavyAnimation(RESIZE_ANIMATION_DURATION); const resizeDiff = newHeight - lastHeight; const { offsetHeight, scrollHeight, scrollTop } = container; const currentScrollBottom = Math.round(scrollHeight - scrollTop - offsetHeight); const previousScrollBottom = currentScrollBottom - resizeDiff; if (previousScrollBottom <= BOTTOM_FOCUS_SCROLL_THRESHOLD) { focusLastMessage(); } }); const throttledResize = useThrottledCallback(handleResize, [handleResize], THROTTLE_MS, false); useResizeObserver(ref, throttledResize, !shouldFocusOnResize); useEffect(() => { const bottomMarker = bottomMarkerRef.current; if (!bottomMarker || !isElementInViewport(bottomMarker)) return; if (hasUnreadReaction) { animateUnreadReaction({ messageIds: [messageId] }); } if (message.hasUnreadMention) { markMentionsRead({ messageIds: [messageId] }); } }, [hasUnreadReaction, messageId, animateUnreadReaction, message.hasUnreadMention]); const albumLayout = useMemo(() => { return isAlbum ? calculateAlbumLayout(isOwn, Boolean(asForwarded), Boolean(noAvatars), album!, isMobile) : undefined; }, [isAlbum, isOwn, asForwarded, noAvatars, album, isMobile]); const extraPadding = asForwarded ? 28 : 0; const sizeCalculations = useMemo(() => { let calculatedWidth; let contentWidth: number | undefined; let noMediaCorners = false; let style = ''; let reactionsMaxWidth; if (!isAlbum && (photo || video || invoice?.extendedMedia)) { let width: number | undefined; if (photo) { width = calculateMediaDimensions({ media: photo, isOwn, asForwarded, noAvatars, isMobile, }).width; } else if (video) { if (isRoundVideo) { width = ROUND_VIDEO_DIMENSIONS_PX; } else { width = calculateMediaDimensions({ media: video, isOwn, asForwarded, noAvatars, isMobile, }).width; } } else if (invoice?.extendedMedia && ( invoice.extendedMedia.width && invoice.extendedMedia.height )) { const { width: previewWidth, height: previewHeight } = invoice.extendedMedia; width = calculateDimensionsForMessageMedia({ width: previewWidth, height: previewHeight, fromOwnMessage: isOwn, asForwarded, noAvatars, isMobile, }).width; } if (width) { if (width < MIN_MEDIA_WIDTH_WITH_TEXT) { contentWidth = width; } calculatedWidth = Math.max(getMinMediaWidth(text?.text, isMediaWithCommentButton), width); if (!asForwarded && invoice?.extendedMedia && calculatedWidth - width > NO_MEDIA_CORNERS_THRESHOLD) { noMediaCorners = true; } } } else if (albumLayout) { calculatedWidth = Math.max( getMinMediaWidth(text?.text, isMediaWithCommentButton), albumLayout.containerStyle.width, ); if (calculatedWidth - albumLayout.containerStyle.width > NO_MEDIA_CORNERS_THRESHOLD) { noMediaCorners = true; } } if (calculatedWidth) { style = `width: ${calculatedWidth}px`; reactionsMaxWidth = calculatedWidth + EXTRA_SPACE_FOR_REACTIONS; } else if (sticker && !hasSubheader) { const { width } = getStickerDimensions(sticker, isMobile); style = `width: ${width + extraPadding}px`; reactionsMaxWidth = width + EXTRA_SPACE_FOR_REACTIONS; } return { contentWidth, noMediaCorners, style, reactionsMaxWidth, }; }, [ albumLayout, asForwarded, extraPadding, hasSubheader, invoice?.extendedMedia, isAlbum, isMediaWithCommentButton, isMobile, isOwn, noAvatars, photo, sticker, text?.text, video, isRoundVideo, ]); const { contentWidth, noMediaCorners, style, reactionsMaxWidth, } = sizeCalculations; function renderAvatar() { const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined; return ( ); } function renderMessageText(isForAnimation?: boolean) { if (!textMessage) return undefined; return ( ); } const renderQuickReactionButton = useCallback(() => { if (!defaultReaction) return undefined; return (
); }, [ hasActiveReactions, availableReactions, defaultReaction, handleSendQuickReaction, isQuickReactionVisible, observeIntersectionForPlaying, ]); function renderReactionsAndMeta() { const meta = ( ); if (reactionsPosition !== 'inside') { return meta; } return ( ); } function renderContent() { const className = buildClassName( 'content-inner', asForwarded && 'forwarded-message', hasSubheader && 'with-subheader', noMediaCorners && 'no-media-corners', ); const hasCustomAppendix = isLastInGroup && (!hasText || (isInvertedMedia && !hasFactCheck && !hasReactions)) && !withCommentButton; const textContentClass = buildClassName( 'text-content', 'clearfix', metaPosition === 'in-text' && 'with-meta', outgoingStatus && 'with-outgoing-icon', ); const shouldReadMedia = !hasTtl || !isOwn || isChatWithSelf; return (
{!asForwarded && renderSenderName()} {hasSubheader && (
{hasTopicChip && ( )} {hasMessageReply && ( )} {hasStoryReply && ( )}
)} {sticker && ( )} {hasAnimatedEmoji && animatedCustomEmoji && ( )} {hasAnimatedEmoji && animatedEmoji && ( )} {withAnimatedEffects && effect && !isLocal && ( )} {phoneCall && ( )} {!isAlbum && isRoundVideo && ( )} {(audio || voice) && (
); } function renderInvertedMediaContent(hasCustomAppendix: boolean) { const textContentClass = buildClassName( 'text-content', 'clearfix', ); const footerClass = buildClassName( 'text-content', 'clearfix', metaPosition === 'in-text' && 'with-meta', outgoingStatus && 'with-outgoing-icon', ); const hasMediaAfterText = isAlbum || (!isAlbum && photo) || (!isAlbum && video && !isRoundVideo); const hasContentAfterText = hasMediaAfterText || (!hasAnimatedEmoji && hasFactCheck); const isMetaInText = metaPosition === 'in-text'; return ( <> {renderWebPage()} {hasText && !hasAnimatedEmoji && (
{renderMessageText()} {isTranslationPending && (
{renderMessageText(true)}
)} {!hasContentAfterText && isMetaInText && renderReactionsAndMeta()}
)} {hasContentAfterText && ( <> {renderInvertibleMediaContent(hasCustomAppendix)} {!hasAnimatedEmoji && (
{hasFactCheck && ( )} {isMetaInText && renderReactionsAndMeta()}
)} )} ); } function renderWebPage() { return webPage && ( ); } function renderInvertibleMediaContent(hasCustomAppendix: boolean) { const content = ( <> {isAlbum && ( )} {!isAlbum && photo && ( )} {!isAlbum && video && !isRoundVideo && (