import { MouseEvent as ReactMouseEvent } from 'react'; 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, MAIN_THREAD_ID, } from '../../../api/types'; import { FocusDirection, IAlbum, MediaViewerOrigin } from '../../../types'; import { pick } from '../../../util/iteratees'; import { selectChat, selectChatMessage, selectUploadProgress, selectIsChatWithSelf, selectOutgoingStatus, selectUser, selectIsMessageFocused, selectCurrentTextSearch, selectAnimatedEmoji, selectIsInSelectMode, selectIsMessageSelected, selectIsDocumentGroupSelected, selectSender, selectForwardedSender, selectThreadTopMessageId, selectShouldAutoLoadMedia, selectShouldAutoPlayMedia, selectShouldLoopStickers, } from '../../../modules/selectors'; import { getMessageContent, isOwnMessage, isReplyMessage, isAnonymousOwnMessage, isChatPrivate, 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 useFocusMessage from './hooks/useFocusMessage'; import useWindowSize from '../../../hooks/useWindowSize'; import useLang from '../../../hooks/useLang'; import useShowTransition from '../../../hooks/useShowTransition'; import useFlag from '../../../hooks/useFlag'; 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; withAvatar?: boolean; withSenderName?: boolean; threadId: number; messageListType: MessageListType; noComments: boolean; appearanceOrder: number; } & MessagePositionProperties; type StateProps = { forceSenderName?: boolean; 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; isForwarding?: boolean; isChatWithSelf?: boolean; isChannel?: 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, observeIntersectionForBottom, observeIntersectionForMedia, observeIntersectionForAnimatedStickers, album, withAvatar, withSenderName, noComments, appearanceOrder, isFirstInGroup, isLastInGroup, isFirstInDocumentGroup, isLastInDocumentGroup, isLastInList, forceSenderName, sender, originSender, botSender, isThreadTop, shouldHideReply, replyMessage, replyMessageSender, outgoingStatus, uploadProgress, isFocused, focusDirection, noFocusHighlight, isForwarding, isChatWithSelf, isChannel, lastSyncTime, highlight, animatedEmoji, isInSelectMode, isSelected, isGroupSelected, threadId, messageListType, isPinnedList, shouldAutoLoadMedia, shouldAutoPlayMedia, shouldLoopStickers, focusMessage, openMediaViewer, openAudioPlayer, openUserInfo, openChat, cancelSendingMessage, markMessagesRead, sendPollVote, toggleMessageSelection, setReplyingToId, openForwardMenu, clickInlineButton, }) => { // 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); useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); const { width: windowWidth } = useWindowSize(); const { isContextMenuOpen, contextMenuPosition, handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(ref, false, true); 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 { chatId, id: messageId, threadInfo } = 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 && !forwardInfo.isLinkedChannelPost; const isInDocumentGroup = !!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 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', transitionClassNames, ); const contentClassName = buildContentClassName(message, { hasReply, customShape, isLastInGroup, asForwarded, hasThread, forceSenderName, hasComments: message.threadInfo && message.threadInfo.messagesCount > 0, }); const avatarPeer = forwardInfo && (isChatWithSelf || !sender) ? originSender : sender; const senderPeer = forwardInfo ? originSender : sender; const signature = ( (isChannel && message.adminTitle) || (forwardInfo && !asForwarded && forwardInfo.adminTitle) || undefined ); const withCommentButton = message.threadInfo && (!isInDocumentGroup || isLastInDocumentGroup) && messageListType === 'thread' && !noComments; const withAppendix = contentClassName.includes('has-appendix'); useEnsureMessage(chatId, hasReply ? message.replyToMessageId : undefined, replyMessage, message.id); useFocusMessage(ref, chatId, isFocused, focusDirection, noFocusHighlight); useLayoutEffect(() => { if (!appendixRef.current) { return; } appendixRef.current.innerHTML = isOwn ? APPENDIX_OWN : APPENDIX_NOT_OWN; }, [isOwn, withAppendix]); const handleGroupDocumentMessagesSelect = useCallback((e: ReactMouseEvent) => { e.stopPropagation(); toggleMessageSelection({ messageId, groupedId: message.groupedId, }); }, [messageId, message.groupedId, toggleMessageSelection]); const handleMessageSelect = useCallback((e?: ReactMouseEvent) => { const params = isAlbum && album && album.messages ? { messageId, childMessageIds: album.messages.map(({ id }) => id), withShift: e && e.shiftKey, } : { messageId, withShift: e && e.shiftKey }; toggleMessageSelection(params); }, [toggleMessageSelection, messageId, isAlbum, album]); const handleContainerDoubleClick = useCallback(() => { setReplyingToId({ messageId }); }, [setReplyingToId, messageId]); const handleContentDoubleClick = useCallback((e: ReactMouseEvent) => { e.stopPropagation(); }, []); const handleAvatarClick = useCallback(() => { if (!avatarPeer) { return; } if (isChatPrivate(avatarPeer.id)) { openUserInfo({ id: avatarPeer.id }); } else { openChat({ id: avatarPeer.id }); } }, [avatarPeer, openUserInfo, openChat]); const handleSenderClick = useCallback(() => { if (!senderPeer) { return; } if (isChatPrivate(senderPeer.id)) { openUserInfo({ id: senderPeer.id }); } else { openChat({ id: senderPeer.id }); } }, [senderPeer, openUserInfo, openChat]); const handleViaBotClick = useCallback(() => { if (!botSender) { return; } openUserInfo({ id: botSender.id }); }, [botSender, openUserInfo]); const handleReplyClick = useCallback((): void => { focusMessage({ chatId, threadId, messageId: message.replyToMessageId }); }, [focusMessage, chatId, threadId, message.replyToMessageId]); const handleMediaClick = useCallback((): void => { openMediaViewer({ chatId, threadId, messageId, origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, }); }, [chatId, threadId, messageId, openMediaViewer, isScheduled]); const handleAudioPlay = useCallback((): void => { openAudioPlayer({ chatId, messageId }); }, [chatId, messageId, openAudioPlayer]); const handleAlbumMediaClick = useCallback((albumMessageId: number): void => { openMediaViewer({ chatId, threadId, messageId: albumMessageId, origin: isScheduled ? MediaViewerOrigin.ScheduledAlbum : MediaViewerOrigin.Album, }); }, [chatId, threadId, openMediaViewer, isScheduled]); const handleReadMedia = useCallback((): void => { markMessagesRead({ messageIds: [messageId] }); }, [messageId, markMessagesRead]); const handleCancelUpload = useCallback(() => { cancelSendingMessage({ chatId, messageId }); }, [cancelSendingMessage, chatId, messageId]); const handleVoteSend = useCallback((options: string[]) => { sendPollVote({ chatId, messageId, options }); }, [chatId, messageId, sendPollVote]); const handleGroupForward = useCallback(() => { openForwardMenu({ fromChatId: chatId, groupedId: message.groupedId }); }, [openForwardMenu, chatId, message.groupedId]); const handleForward = useCallback(() => { if (album && album.messages) { const messageIds = album.messages.map(({ id }) => id); openForwardMenu({ fromChatId: chatId, messageIds }); } else { openForwardMenu({ fromChatId: chatId, messageIds: [messageId] }); } }, [album, openForwardMenu, chatId, messageId]); const handleFocus = useCallback(() => { focusMessage({ chatId, threadId: MAIN_THREAD_ID, messageId, }); }, [focusMessage, chatId, messageId]); const handleFocusForwarded = useCallback(() => { if (isInDocumentGroup) { focusMessage({ chatId: forwardInfo!.fromChatId, groupedId: message.groupedId, groupedChatId: chatId, }); return; } focusMessage({ chatId: forwardInfo!.fromChatId, messageId: forwardInfo!.fromMessageId, }); }, [focusMessage, forwardInfo, message, chatId, isInDocumentGroup]); const lang = useLang(); let style = ''; let calculatedWidth; let noMediaCorners = false; const albumLayout = useMemo(() => { return isAlbum ? calculateAlbumLayout(isOwn, Boolean(asForwarded), album!, windowWidth) : undefined; }, [isAlbum, windowWidth, isOwn, asForwarded, album]); const extraPadding = asForwarded ? 28 : 0; if (!isAlbum && (photo || video)) { let width: number | undefined; if (photo) { width = calculateMediaDimensions(message).width; } else if (video) { if (video.isRound) { width = ROUND_VIDEO_DIMENSIONS; } else { width = calculateMediaDimensions(message).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; return (
{renderSenderName()} {hasReply && ( )} {sticker && ( )} {animatedEmoji && ( )} {isAlbum && ( )} {!isAlbum && photo && ( )} {!isAlbum && video && video.isRound && ( )} {!isAlbum && video && !video.isRound && (
); } function renderSenderName() { const shouldRender = !customShape && ( (withSenderName && !photo && !video) || asForwarded || viaBotId || forceSenderName ) && (!isInDocumentGroup || isFirstInDocumentGroup); if (!shouldRender) { return undefined; } let senderTitle; let senderColor; if (senderPeer) { senderTitle = getSenderTitle(lang, senderPeer); if (!asForwarded) { senderColor = `color-${getUserColorKey(senderPeer)}`; } } else if (forwardInfo && forwardInfo.hiddenUserName) { senderTitle = forwardInfo.hiddenUserName; } return (
{senderTitle ? ( {renderText(senderTitle)} ) : !botSender ? ( NBSP ) : undefined} {botSender && ( <> {lang('ViaBot')} {renderText(`@${botSender.username}`)} )} {forwardInfo && forwardInfo.isLinkedChannelPost ? ( {lang('DiscussChannel')} ) : message.adminTitle && !isChannel ? ( {message.adminTitle} ) : undefined}
); } const metaSafeAuthorWidth = useMemo(() => { return signature ? calculateAuthorWidth(signature) : undefined; }, [signature]); const canShowActionButton = ( !(isContextMenuShown || isInSelectMode || isForwarding) && (!isInDocumentGroup || isLastInDocumentGroup) ); const canForward = canShowActionButton && isChannel && !isScheduled; const canFocus = canShowActionButton && ( (forwardInfo && (forwardInfo.isChannelPost || (isChatWithSelf && !isOwn)) && forwardInfo.fromMessageId) || isPinnedList ); return (
{!isInDocumentGroup && (
{isSelected && }
)} {isLastInDocumentGroup && (
{isGroupSelected && ( )}
)} {withAvatar && renderAvatar()}
{withAppendix && (
)} {asForwarded && !customShape && (!isInDocumentGroup || isFirstInDocumentGroup) && (
{lang('ForwardedMessage')}
)} {renderContent()} {(!isInDocumentGroup || isLastInDocumentGroup) && ( )} {canForward ? ( ) : canFocus ? ( ) : undefined} {withCommentButton && }
{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, replyToMessageId, isOutgoing, } = message; const chat = selectChat(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isChannel = chat && isChatChannel(chat); 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, 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 } = (isFocused && focusedMessage) || {}; const isForwarding = forwardMessages.messageIds && forwardMessages.messageIds.includes(id); const { query: highlight } = selectCurrentTextSearch(global) || {}; const singleEmoji = getMessageSingleEmoji(message); let isSelected: boolean; if (album && album.messages) { isSelected = album.messages.every(({ id: messageId }) => selectIsMessageSelected(global, messageId)); } else { isSelected = selectIsMessageSelected(global, id); } return { forceSenderName, sender, originSender, botSender, shouldHideReply, isThreadTop, replyMessage, replyMessageSender, ...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }), ...(typeof uploadProgress === 'number' && { uploadProgress }), isFocused, ...(isFocused && { focusDirection, noFocusHighlight }), isForwarding, isChatWithSelf, isChannel, 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), }; }, (setGlobal, actions): DispatchProps => pick(actions, [ 'focusMessage', 'openMediaViewer', 'openAudioPlayer', 'cancelSendingMessage', 'openUserInfo', 'openChat', 'markMessagesRead', 'sendPollVote', 'toggleMessageSelection', 'setReplyingToId', 'openForwardMenu', 'clickInlineButton', ]), )(Message));