import React, { FC, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, } from '../../../lib/teact/teact'; import { withGlobal } from '../../../lib/teact/teactn'; import { GlobalActions, MessageListType } from '../../../global/types'; import { ApiMessage, ApiMessageOutgoingStatus, ApiUser, ApiChat, ApiSticker, } from '../../../api/types'; import { AudioOrigin, FocusDirection, IAlbum, ISettings, } from '../../../types'; import { IS_ANDROID, IS_TOUCH_ENV } from '../../../util/environment'; import { pick } from '../../../util/iteratees'; import { selectChat, selectChatMessage, selectUploadProgress, selectIsChatWithSelf, selectOutgoingStatus, selectUser, selectIsMessageFocused, selectCurrentTextSearch, selectAnimatedEmoji, selectIsInSelectMode, selectIsMessageSelected, selectIsDocumentGroupSelected, selectSender, selectForwardedSender, selectThreadTopMessageId, selectShouldAutoLoadMedia, selectShouldAutoPlayMedia, selectShouldLoopStickers, selectTheme, selectAllowedMessageActions, } from '../../../modules/selectors'; import { getMessageContent, isOwnMessage, isReplyMessage, isAnonymousOwnMessage, isMessageLocal, isChatPrivate, isChatWithRepliesBot, getMessageCustomShape, isChatChannel, getMessageSingleEmoji, getSenderTitle, getUserColorKey, } from '../../../modules/helpers'; import buildClassName from '../../../util/buildClassName'; import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import { renderMessageText } from '../../common/helpers/renderMessageText'; import { ROUND_VIDEO_DIMENSIONS } from '../../common/helpers/mediaDimensions'; import { buildContentClassName, isEmojiOnlyMessage } from './helpers/buildContentClassName'; import { getMinMediaWidth, calculateMediaDimensions } from './helpers/mediaDimensions'; import { calculateAlbumLayout } from './helpers/calculateAlbumLayout'; import renderText from '../../common/helpers/renderText'; import calculateAuthorWidth from './helpers/calculateAuthorWidth'; import { ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver'; import useLang from '../../../hooks/useLang'; import useShowTransition from '../../../hooks/useShowTransition'; import useFlag from '../../../hooks/useFlag'; import useFocusMessage from './hooks/useFocusMessage'; import useOuterHandlers from './hooks/useOuterHandlers'; import useInnerHandlers from './hooks/useInnerHandlers'; import Button from '../../ui/Button'; import Avatar from '../../common/Avatar'; import EmbeddedMessage from '../../common/EmbeddedMessage'; import Document from '../../common/Document'; import Audio from '../../common/Audio'; import MessageMeta from './MessageMeta'; import ContextMenuContainer from './ContextMenuContainer.async'; import Sticker from './Sticker'; import AnimatedEmoji from '../../common/AnimatedEmoji'; import Photo from './Photo'; import Video from './Video'; import Contact from './Contact'; import Poll from './Poll'; import WebPage from './WebPage'; import Invoice from './Invoice'; import Album from './Album'; import RoundVideo from './RoundVideo'; import InlineButtons from './InlineButtons'; import CommentButton from './CommentButton'; import './Message.scss'; type MessagePositionProperties = { isFirstInGroup: boolean; isLastInGroup: boolean; isFirstInDocumentGroup: boolean; isLastInDocumentGroup: boolean; isLastInList: boolean; }; type OwnProps = { message: ApiMessage; observeIntersectionForBottom: ObserveFn; observeIntersectionForMedia: ObserveFn; observeIntersectionForAnimatedStickers: ObserveFn; album?: IAlbum; noAvatars?: boolean; withAvatar?: boolean; withSenderName?: boolean; threadId: number; messageListType: MessageListType; noComments: boolean; appearanceOrder: number; } & MessagePositionProperties; type StateProps = { theme: ISettings['theme']; forceSenderName?: boolean; chatUsername?: string; sender?: ApiUser | ApiChat; originSender?: ApiUser | ApiChat; botSender?: ApiUser; isThreadTop?: boolean; shouldHideReply?: boolean; replyMessage?: ApiMessage; replyMessageSender?: ApiUser | ApiChat; outgoingStatus?: ApiMessageOutgoingStatus; uploadProgress?: number; isFocused?: boolean; focusDirection?: FocusDirection; noFocusHighlight?: boolean; isResizingContainer?: boolean; isForwarding?: boolean; isChatWithSelf?: boolean; isRepliesChat?: boolean; isChannel?: boolean; canReply?: boolean; lastSyncTime?: number; highlight?: string; isSingleEmoji?: boolean; animatedEmoji?: ApiSticker; isInSelectMode?: boolean; isSelected?: boolean; isGroupSelected?: boolean; threadId?: number; isPinnedList?: boolean; shouldAutoLoadMedia?: boolean; shouldAutoPlayMedia?: boolean; shouldLoopStickers?: boolean; }; type DispatchProps = Pick; const NBSP = '\u00A0'; const GROUP_MESSAGE_HOVER_ATTRIBUTE = 'data-is-document-group-hover'; // eslint-disable-next-line max-len const APPENDIX_OWN = ''; // eslint-disable-next-line max-len const APPENDIX_NOT_OWN = ''; const APPEARANCE_DELAY = 10; const NO_MEDIA_CORNERS_THRESHOLD = 18; const Message: FC = ({ message, chatUsername, observeIntersectionForBottom, observeIntersectionForMedia, observeIntersectionForAnimatedStickers, album, noAvatars, withAvatar, withSenderName, noComments, appearanceOrder, isFirstInGroup, isLastInGroup, isFirstInDocumentGroup, isLastInDocumentGroup, isLastInList, theme, forceSenderName, sender, originSender, botSender, isThreadTop, shouldHideReply, replyMessage, replyMessageSender, outgoingStatus, uploadProgress, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isForwarding, isChatWithSelf, isRepliesChat, isChannel, canReply, lastSyncTime, highlight, animatedEmoji, isInSelectMode, isSelected, isGroupSelected, threadId, messageListType, isPinnedList, shouldAutoLoadMedia, shouldAutoPlayMedia, shouldLoopStickers, toggleMessageSelection, clickInlineButton, disableContextMenuHint, }) => { // 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 appendixRef = useRef(null); const lang = useLang(); useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); const { isContextMenuOpen, contextMenuPosition, handleBeforeContextMenu, handleContextMenu: onContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(ref, IS_TOUCH_ENV && isInSelectMode, true, IS_ANDROID); 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]); const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false); const { id: messageId, chatId, threadInfo } = message; const isLocal = isMessageLocal(message); const isOwn = isOwnMessage(message); const isScheduled = messageListType === 'scheduled' || message.isScheduled; const hasReply = isReplyMessage(message) && !shouldHideReply; const hasThread = Boolean(threadInfo) && messageListType === 'thread'; const { forwardInfo, viaBotId } = message; const asForwarded = forwardInfo && !isChatWithSelf && !isRepliesChat && !forwardInfo.isLinkedChannelPost; const isInDocumentGroup = Boolean(message.groupedId) && !message.isInAlbum; const isAlbum = Boolean(album) && album!.messages.length > 1; const { text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, } = getMessageContent(message); const customShape = getMessageCustomShape(message); const textParts = renderMessageText(message, highlight, isEmojiOnlyMessage(customShape)); const isContextMenuShown = contextMenuPosition !== undefined; const signature = ( (isChannel && message.adminTitle) || (forwardInfo && !asForwarded && forwardInfo.adminTitle) || undefined ); const metaSafeAuthorWidth = useMemo(() => { return signature ? calculateAuthorWidth(signature) : undefined; }, [signature]); const canShowActionButton = ( !(isContextMenuShown || isInSelectMode || isForwarding) && (!isInDocumentGroup || isLastInDocumentGroup) ); const canForward = isChannel && !isScheduled; const canFocus = Boolean(isPinnedList || (forwardInfo && (forwardInfo.isChannelPost || (isChatWithSelf && !isOwn) || isRepliesChat) && forwardInfo.fromMessageId )); const avatarPeer = forwardInfo && (isChatWithSelf || isRepliesChat || !sender) ? originSender : sender; const senderPeer = forwardInfo ? originSender : sender; const selectMessage = useCallback((e?: React.MouseEvent, groupedId?: string) => { if (isLocal) { return; } toggleMessageSelection({ messageId, groupedId, ...(e?.shiftKey && { withShift: true }), ...(isAlbum && { childMessageIds: album!.messages.map(({ id }) => id) }), }); }, [isLocal, toggleMessageSelection, messageId, isAlbum, album]); const { handleMouseDown, handleClick, handleContextMenu, handleDoubleClick, handleContentDoubleClick, isSwiped, } = useOuterHandlers( selectMessage, ref, messageId, isLocal, isAlbum, Boolean(isInSelectMode), Boolean(canReply), onContextMenu, handleBeforeContextMenu, ); const { handleAvatarClick, handleSenderClick, handleViaBotClick, handleReplyClick, handleMediaClick, handleAudioPlay, handleAlbumMediaClick, handleMetaClick, handleReadMedia, handleCancelUpload, handleVoteSend, handleGroupForward, handleForward, handleFocus, handleFocusForwarded, handleDocumentGroupSelectAll, } = useInnerHandlers( lang, selectMessage, message, chatId, threadId, isInDocumentGroup, Boolean(isScheduled), isRepliesChat, album, avatarPeer, senderPeer, botSender, ); const containerClassName = buildClassName( 'Message message-list-item', isFirstInGroup && 'first-in-group', isLastInGroup && 'last-in-group', isFirstInDocumentGroup && 'first-in-document-group', isLastInDocumentGroup && 'last-in-document-group', isLastInList && 'last-in-list', isOwn && 'own', Boolean(message.views) && 'has-views', message.isEdited && 'was-edited', hasReply && 'has-reply', isContextMenuShown && '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 && 'is-thread-top', Boolean(message.inlineButtons) && 'has-inline-buttons', isSwiped && 'is-swiped', transitionClassNames, ); const contentClassName = buildContentClassName(message, { hasReply, customShape, isLastInGroup, asForwarded, hasThread, forceSenderName, hasComments: message.threadInfo && message.threadInfo.messagesCount > 0, hasActionButton: canForward || canFocus, }); const withCommentButton = message.threadInfo && (!isInDocumentGroup || isLastInDocumentGroup) && messageListType === 'thread' && !noComments; const withAppendix = contentClassName.includes('has-appendix'); useEnsureMessage( isRepliesChat && message.replyToChatId ? message.replyToChatId : chatId, hasReply ? message.replyToMessageId : undefined, replyMessage, message.id, ); useFocusMessage(ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer); useLayoutEffect(() => { if (!appendixRef.current) { return; } appendixRef.current.innerHTML = isOwn ? APPENDIX_OWN : APPENDIX_NOT_OWN; }, [isOwn, withAppendix]); let style = ''; let calculatedWidth; let noMediaCorners = false; const albumLayout = useMemo(() => { return isAlbum ? calculateAlbumLayout(isOwn, Boolean(asForwarded), Boolean(noAvatars), album!) : undefined; }, [isAlbum, isOwn, asForwarded, noAvatars, album]); const extraPadding = asForwarded ? 28 : 0; if (!isAlbum && (photo || video)) { let width: number | undefined; if (photo) { width = calculateMediaDimensions(message, noAvatars).width; } else if (video) { if (video.isRound) { width = ROUND_VIDEO_DIMENSIONS; } else { width = calculateMediaDimensions(message, noAvatars).width; } } if (width) { calculatedWidth = Math.max(getMinMediaWidth(Boolean(text), withCommentButton), width); if (calculatedWidth - width > NO_MEDIA_CORNERS_THRESHOLD) { noMediaCorners = true; } } } else if (albumLayout) { calculatedWidth = Math.max(getMinMediaWidth(Boolean(text), withCommentButton), albumLayout.containerStyle.width); if (calculatedWidth - albumLayout.containerStyle.width > NO_MEDIA_CORNERS_THRESHOLD) { noMediaCorners = true; } } if (calculatedWidth) { style = `width: ${calculatedWidth + extraPadding}px`; } function renderAvatar() { const isAvatarPeerUser = avatarPeer && isChatPrivate(avatarPeer.id); const avatarUser = (avatarPeer && isAvatarPeerUser) ? avatarPeer as ApiUser : undefined; const avatarChat = (avatarPeer && !isAvatarPeerUser) ? avatarPeer as ApiChat : undefined; const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined; return ( ); } function renderContent() { const className = buildClassName( 'content-inner', asForwarded && !customShape && 'forwarded-message', hasReply && 'reply-message', noMediaCorners && 'no-media-corners', ); const hasCustomAppendix = isLastInGroup && !textParts && !asForwarded && !hasThread; const shouldInlineMeta = !webPage && !animatedEmoji && textParts; const textContentClass = buildClassName( 'text-content', shouldInlineMeta && 'with-meta', outgoingStatus && 'with-outgoing-icon', ); return (
{renderSenderName()} {hasReply && ( )} {sticker && ( )} {animatedEmoji && ( )} {isAlbum && ( )} {!isAlbum && photo && ( )} {!isAlbum && video && video.isRound && ( )} {!isAlbum && video && !video.isRound && (
); } function renderSenderName() { const shouldRender = !(customShape && !viaBotId) && ( (withSenderName && !photo && !video) || asForwarded || viaBotId || forceSenderName ) && (!isInDocumentGroup || isFirstInDocumentGroup) && !(hasReply && customShape); if (!shouldRender) { return undefined; } let senderTitle; let senderColor; if (senderPeer && !(customShape && viaBotId)) { senderTitle = getSenderTitle(lang, senderPeer); if (!asForwarded) { senderColor = `color-${getUserColorKey(senderPeer)}`; } } else if (forwardInfo?.hiddenUserName) { senderTitle = forwardInfo.hiddenUserName; } return (
{senderTitle ? ( {renderText(senderTitle)} ) : !botSender ? ( NBSP ) : undefined} {botSender && ( <> {lang('ViaBot')} {renderText(`@${botSender.username}`)} )} {forwardInfo?.isLinkedChannelPost ? ( {lang('DiscussChannel')} ) : message.adminTitle && !isChannel ? ( {message.adminTitle} ) : undefined}
); } return (
{!isLocal && !isInDocumentGroup && (
{isSelected && }
)} {!isLocal && isLastInDocumentGroup && (
{isGroupSelected && ( )}
)} {withAvatar && renderAvatar()}
{asForwarded && !customShape && (!isInDocumentGroup || isFirstInDocumentGroup) && (
{lang('ForwardedMessage')}
)} {renderContent()} {(!isInDocumentGroup || isLastInDocumentGroup) && !(!webPage && !animatedEmoji && textParts) && ( )} {canShowActionButton && canForward ? ( ) : canShowActionButton && canFocus ? ( ) : undefined} {withCommentButton && } {withAppendix &&
}
{message.inlineButtons && ( )}
{contextMenuPosition && ( )}
); }; function handleDocumentGroupMouseEnter(e: React.MouseEvent) { const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget); if (lastGroupElement) { lastGroupElement.setAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE, ''); } } function handleDocumentGroupMouseLeave(e: React.MouseEvent) { const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget); if (lastGroupElement) { lastGroupElement.removeAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE); } } function getLastElementInDocumentGroup(element: Element) { let current: Element | null = element; do { current = current.nextElementSibling; } while (current && !current.classList.contains('last-in-document-group')); return current; } export default memo(withGlobal( (global, ownProps): StateProps => { const { focusedMessage, forwardMessages, lastSyncTime } = global; const { message, album, withSenderName, withAvatar, threadId, messageListType, } = ownProps; const { id, chatId, viaBotId, replyToChatId, replyToMessageId, isOutgoing, } = message; const chat = selectChat(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isRepliesChat = isChatWithRepliesBot(chatId); const isChannel = chat && isChatChannel(chat); const chatUsername = chat?.username; const forceSenderName = !isChatWithSelf && isAnonymousOwnMessage(message); const canShowSender = withSenderName || withAvatar || forceSenderName; const sender = canShowSender ? selectSender(global, message) : undefined; const originSender = selectForwardedSender(global, message); const botSender = viaBotId ? selectUser(global, viaBotId) : undefined; const threadTopMessageId = threadId ? selectThreadTopMessageId(global, chatId, threadId) : undefined; const isThreadTop = message.id === threadTopMessageId; const shouldHideReply = replyToMessageId === threadTopMessageId; const replyMessage = replyToMessageId && !shouldHideReply ? selectChatMessage(global, isRepliesChat && replyToChatId ? replyToChatId : chatId, replyToMessageId) : undefined; const replyMessageSender = replyMessage && selectSender(global, replyMessage); const uploadProgress = selectUploadProgress(global, message); const isFocused = messageListType === 'thread' && ( album ? album.messages.some((m) => selectIsMessageFocused(global, m)) : selectIsMessageFocused(global, message) ); const { direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer, } = (isFocused && focusedMessage) || {}; const isForwarding = forwardMessages.messageIds && forwardMessages.messageIds.includes(id); const { query: highlight } = selectCurrentTextSearch(global) || {}; const singleEmoji = getMessageSingleEmoji(message); let isSelected: boolean; if (album?.messages) { isSelected = album.messages.every(({ id: messageId }) => selectIsMessageSelected(global, messageId)); } else { isSelected = selectIsMessageSelected(global, id); } const { canReply } = (messageListType === 'thread' && selectAllowedMessageActions(global, message, threadId)) || {}; return { theme: selectTheme(global), chatUsername, forceSenderName, sender, originSender, botSender, shouldHideReply, isThreadTop, replyMessage, replyMessageSender, isFocused, isForwarding, isChatWithSelf, isRepliesChat, isChannel, canReply, lastSyncTime, highlight, isSingleEmoji: Boolean(singleEmoji), animatedEmoji: singleEmoji ? selectAnimatedEmoji(global, singleEmoji) : undefined, isInSelectMode: selectIsInSelectMode(global), isSelected, isGroupSelected: ( !!message.groupedId && !message.isInAlbum && selectIsDocumentGroupSelected(global, chatId, message.groupedId) ), threadId, isPinnedList: messageListType === 'pinned', shouldAutoLoadMedia: chat ? selectShouldAutoLoadMedia(global, message, chat, sender) : undefined, shouldAutoPlayMedia: selectShouldAutoPlayMedia(global, message), shouldLoopStickers: selectShouldLoopStickers(global), ...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }), ...(typeof uploadProgress === 'number' && { uploadProgress }), ...(isFocused && { focusDirection, noFocusHighlight, isResizingContainer }), }; }, (setGlobal, actions): DispatchProps => pick(actions, [ 'toggleMessageSelection', 'clickInlineButton', 'disableContextMenuHint', ]), )(Message));