import { memo, useEffect, useMemo, useRef, useUnmountCleanup, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiMessageAction } from '../../../api/types/messageActions'; import type { FocusDirection, ScrollTargetPosition, ThreadId, } from '../../../types'; import type { Signal } from '../../../util/signals'; import { type ApiKeyboardButton, type ApiMessage, type ApiPeer, type KeyboardButtonGiftOffer, type KeyboardButtonNoForwardsRequest, MAIN_THREAD_ID, } from '../../../api/types'; import { MediaViewerOrigin } from '../../../types'; import { MESSAGE_APPEARANCE_DELAY } from '../../../config'; import { getMessageHtmlId } from '../../../global/helpers'; import { getPeerTitle } from '../../../global/helpers/peers'; import { getMessageReplyInfo } from '../../../global/helpers/replies'; import { selectActionMessageBg, selectChat, selectChatMessage, selectIsCurrentUserFrozen, selectIsCurrentUserPremium, selectIsInSelectMode, selectIsMessageFocused, selectSender, selectTabState, } from '../../../global/selectors'; import { selectThreadReadState } from '../../../global/selectors/threads'; import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_ANDROID, IS_FLUID_BACKGROUND_SUPPORTED } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import { isLocalMessageId } from '../../../util/keys/messageKey'; import { getServerTime } from '../../../util/serverTime'; import { isElementInViewport } from '../../../util/visibility/isElementInViewport'; import { preventMessageInputBlur } from '../helpers/preventMessageInputBlur'; import useAppLayout from '../../../hooks/useAppLayout'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useFlag from '../../../hooks/useFlag'; import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useShowTransition from '../../../hooks/useShowTransition'; import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter'; import useFocusMessageListElement from './hooks/useFocusMessageListElement'; import ConfirmDialog from '../../ui/ConfirmDialog'; import ActionMessageText from './ActionMessageText'; import ChannelPhoto from './actions/ChannelPhoto'; import Gift from './actions/Gift'; import GiveawayPrize from './actions/GiveawayPrize'; import NoForwardsRequest from './actions/NoForwardsRequest'; import StarGift from './actions/StarGift'; import StarGiftPurchaseOffer from './actions/StarGiftPurchaseOffer'; import StarGiftUnique from './actions/StarGiftUnique'; import SuggestedPhoto from './actions/SuggestedPhoto'; import SuggestedPostApproval from './actions/SuggestedPostApproval'; import SuggestedPostBalanceTooLow from './actions/SuggestedPostBalanceTooLow'; import SuggestedPostRejected from './actions/SuggestedPostRejected'; import ContextMenuContainer from './ContextMenuContainer'; import InlineButtons from './InlineButtons'; import Reactions from './reactions/Reactions'; import SimilarChannels from './SimilarChannels'; import styles from './ActionMessage.module.scss'; type OwnProps = { message: ApiMessage; threadId: ThreadId; appearanceOrder: number; isJustAdded?: boolean; isLastInList?: boolean; memoFirstUnreadIdRef?: { current: number | undefined }; getIsMessageListReady?: Signal; observeIntersectionForBottom?: ObserveFn; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; onMessageUnmount?: (messageId: number) => void; }; type StateProps = { sender?: ApiPeer; currentUserId?: string; isInsideTopic?: boolean; isFocused?: boolean; focusDirection?: FocusDirection; noFocusHighlight?: boolean; replyMessage?: ApiMessage; actionMessageBg?: string; isCurrentUserPremium?: boolean; isInSelectMode?: boolean; hasUnreadReaction?: boolean; isResizingContainer?: boolean; scrollTargetPosition?: ScrollTargetPosition; isAccountFrozen?: boolean; noForwardsRequestExpirePeriod: number; }; const SINGLE_LINE_ACTIONS = new Set([ 'pinMessage', 'chatEditPhoto', 'chatDeletePhoto', 'todoCompletions', 'todoAppendTasks', 'unsupported', ]); const HIDDEN_TEXT_ACTIONS = new Set(['giftCode', 'prizeStars', 'suggestProfilePhoto', 'suggestedPostApproval', 'starGiftPurchaseOffer', 'noForwardsRequest']); const ActionMessage = ({ message, threadId, sender, currentUserId, appearanceOrder, isJustAdded, isLastInList, memoFirstUnreadIdRef, getIsMessageListReady, isInsideTopic, isFocused, focusDirection, noFocusHighlight, replyMessage, actionMessageBg, isCurrentUserPremium, isInSelectMode, hasUnreadReaction, isResizingContainer, scrollTargetPosition, isAccountFrozen, noForwardsRequestExpirePeriod, observeIntersectionForBottom, observeIntersectionForLoading, observeIntersectionForPlaying, onMessageUnmount, }: OwnProps & StateProps) => { const { requestConfetti, openMediaViewer, getReceipt, checkGiftCode, openPrizeStarsTransactionFromGiveaway, openPremiumModal, openStarsTransactionFromGift, openGiftInfoModalFromMessage, toggleChannelRecommendations, animateUnreadReaction, markMentionsRead, focusMessage, openGiftOfferAcceptModal, declineStarGiftOffer, showNotification, toggleNoForwards, } = getActions(); const ref = useRef(); const { id, chatId } = message; const action = message.content.action!; const isLocal = isLocalMessageId(id); const isTextHidden = HIDDEN_TEXT_ACTIONS.has(action.type); const isSingleLine = SINGLE_LINE_ACTIONS.has(action.type); const isFluidMultiline = IS_FLUID_BACKGROUND_SUPPORTED && !isSingleLine; const isClickableText = action.type === 'suggestedPostSuccess'; const isNarrowMessage = action.type === 'starGiftPurchaseOfferDeclined'; const messageReplyInfo = getMessageReplyInfo(message); const { replyToMsgId, replyToPeerId } = messageReplyInfo || {}; const withServiceReactions = Boolean(message.areReactionsPossible && message?.reactions?.results?.length); const hasGiftOfferExpired = action.type === 'starGiftPurchaseOffer' && action.expiresAt <= getServerTime(); const shouldRenderGiftOfferButtons = action.type === 'starGiftPurchaseOffer' && !message.isOutgoing && !action.isAccepted && !action.isDeclined && !hasGiftOfferExpired; const hasNoForwardsRequestExpired = action.type === 'noForwardsRequest' && (message.date + noForwardsRequestExpirePeriod) <= getServerTime(); const shouldRenderNoForwardsButtons = action.type === 'noForwardsRequest' && !message.isOutgoing && !action.isExpired && !hasNoForwardsRequestExpired; const shouldRenderInlineButtons = shouldRenderGiftOfferButtons || shouldRenderNoForwardsButtons; const shouldSkipRender = isInsideTopic && action.type === 'topicCreate'; const lang = useLang(); const { isTouchScreen } = useAppLayout(); const giftOfferInlineButtons: KeyboardButtonGiftOffer[][] = useMemo(() => [ [ { type: 'giftOffer', buttonType: 'reject', text: lang('GiftOfferReject'), }, { type: 'giftOffer', buttonType: 'accept', text: lang('GiftOfferAccept'), }, ], ], [lang]); const noForwardsInlineButtons: KeyboardButtonNoForwardsRequest[][] = useMemo(() => [ [ { type: 'noForwardsRequest', buttonType: 'reject', text: lang('NoForwardsRequestReject'), }, { type: 'noForwardsRequest', buttonType: 'accept', text: lang('NoForwardsRequestAccept'), }, ], ], [lang]); const [isRejectOfferDialogOpen, openRejectOfferDialog, closeRejectOfferDialog] = useFlag(false); const handleInlineButtonClick = useLastCallback((button: ApiKeyboardButton) => { if (button.type === 'giftOffer') { if (button.buttonType === 'accept') { if (action.type === 'starGiftPurchaseOffer') { openGiftOfferAcceptModal({ peerId: chatId, messageId: id, gift: action.gift, price: action.price, }); } } else if (button.buttonType === 'reject') { openRejectOfferDialog(); } } else if (button.type === 'noForwardsRequest') { if (action.type === 'noForwardsRequest') { const isAccept = button.buttonType === 'accept'; toggleNoForwards({ userId: chatId, isEnabled: isAccept ? action.newValue : action.prevValue, requestMsgId: id, }); } } }); const handleRejectOfferConfirm = useLastCallback(() => { closeRejectOfferDialog(); declineStarGiftOffer({ messageId: id }); }); const handleRejectOfferClose = useLastCallback(() => { closeRejectOfferDialog(); }); useOnIntersect(ref, !shouldSkipRender ? observeIntersectionForBottom : undefined); useEnsureMessage( replyToPeerId || chatId, replyToMsgId, replyMessage, id, ); useFocusMessageListElement({ elementRef: ref, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded, scrollTargetPosition, }); const { isContextMenuOpen, contextMenuAnchor, handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers( ref, (isTouchScreen && isInSelectMode) || isAccountFrozen, !IS_TAURI, IS_ANDROID, getIsMessageListReady, ); const isContextMenuShown = contextMenuAnchor !== undefined; const handleMouseDown = (e: React.MouseEvent) => { preventMessageInputBlur(e); handleBeforeContextMenu(e); }; const noAppearanceAnimation = appearanceOrder <= 0; const [isShown, markShown] = useFlag(noAppearanceAnimation); useEffect(() => { if (noAppearanceAnimation) { return; } // Keep as is for now to avoid breaking appearance order setTimeout(markShown, appearanceOrder * MESSAGE_APPEARANCE_DELAY); }, [appearanceOrder, noAppearanceAnimation]); const { ref: refWithTransition } = useShowTransition({ isOpen: isShown, noOpenTransition: noAppearanceAnimation, noCloseTransition: true, className: false, ref, }); useUnmountCleanup(() => { onMessageUnmount?.(id); }); useEffect(() => { const bottomMarker = ref.current; if (!bottomMarker || !isElementInViewport(bottomMarker)) return; if (hasUnreadReaction) { animateUnreadReaction({ chatId, messageIds: [id] }); } if (message.hasUnreadMention) { markMentionsRead({ chatId, messageIds: [id] }); } }, [hasUnreadReaction, chatId, id, animateUnreadReaction, message.hasUnreadMention]); useEffect(() => { if (action.type !== 'giftPremium') return; if ((memoFirstUnreadIdRef?.current && id >= memoFirstUnreadIdRef.current) || isLocal) { requestConfetti({}); } }, [action.type, id, isLocal, memoFirstUnreadIdRef]); const fluidBackgroundStyle = useFluidBackgroundFilter(isFluidMultiline ? actionMessageBg : undefined); const handleKeyDown = useLastCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }); const handleClick = useLastCallback(() => { switch (action.type) { case 'paymentSent': case 'paymentRefunded': { getReceipt({ chatId: message.chatId, messageId: message.id, }); break; } case 'chatEditPhoto': { openMediaViewer({ chatId: message.chatId, messageId: message.id, threadId, origin: MediaViewerOrigin.ChannelAvatar, }); break; } case 'giftCode': { checkGiftCode({ slug: action.slug, message: { chatId: message.chatId, messageId: message.id } }); break; } case 'prizeStars': { openPrizeStarsTransactionFromGiveaway({ chatId: message.chatId, messageId: message.id, }); break; } case 'giftPremium': { openPremiumModal({ isGift: true, fromUserId: sender?.id, toUserId: sender && sender.id === currentUserId ? chatId : currentUserId, daysAmount: action.days, }); break; } case 'giftTon': case 'giftStars': { openStarsTransactionFromGift({ chatId: message.chatId, messageId: message.id, }); break; } case 'starGift': { openGiftInfoModalFromMessage({ chatId: message.chatId, messageId: message.id, }); break; } case 'starGiftUnique': { if (action.gift.isBurned) { showNotification({ message: lang('ActionStarGiftUniqueBurnedError') }); break; } openGiftInfoModalFromMessage({ chatId: message.chatId, messageId: message.id, }); break; } case 'starGiftPurchaseOffer': { if (shouldRenderGiftOfferButtons) { openGiftOfferAcceptModal({ peerId: chatId, messageId: id, gift: action.gift, price: action.price, }); } break; } case 'channelJoined': { toggleChannelRecommendations({ chatId }); break; } case 'suggestedPostApproval': { const replyInfo = getMessageReplyInfo(message); if (replyInfo?.type === 'message' && replyInfo.replyToMsgId) { focusMessage({ chatId: message.chatId, threadId, messageId: replyInfo.replyToMsgId, }); } break; } case 'suggestedPostSuccess': { const replyInfo = getMessageReplyInfo(message); if (replyInfo?.type === 'message' && replyInfo.replyToMsgId) { focusMessage({ chatId: message.chatId, threadId, messageId: replyInfo.replyToMsgId, }); } break; } } }); const fullContent = useMemo(() => { switch (action.type) { case 'chatEditPhoto': { if (!action.photo) return undefined; return ( ); } case 'suggestProfilePhoto': return ( ); case 'prizeStars': case 'giftCode': return ( ); case 'giftPremium': case 'giftTon': case 'giftStars': return ( ); case 'starGift': return ( ); case 'starGiftUnique': return ( ); case 'starGiftPurchaseOffer': return ( ); case 'channelJoined': return ( ); case 'noForwardsRequest': return ( ); case 'suggestedPostApproval': if (action.isBalanceTooLow) { return ( ); } return action.isRejected ? ( ) : ( ); default: return undefined; } }, [ action, message, observeIntersectionForLoading, sender, observeIntersectionForPlaying, shouldRenderGiftOfferButtons, ]); if ((isInsideTopic && action.type === 'topicCreate') || action.type === 'phoneCall') { return undefined; } return (
{!isTextHidden && ( <> {isFluidMultiline && (
)}
)} {(fullContent || shouldRenderInlineButtons) && (
{fullContent} {shouldRenderGiftOfferButtons && ( )} {shouldRenderNoForwardsButtons && ( )}
)} {contextMenuAnchor && ( )} {withServiceReactions && ( )}
); }; export default memo(withGlobal( (global, { message, threadId }): Complete => { const tabState = selectTabState(global); const chat = selectChat(global, message.chatId); const sender = selectSender(global, message); const isInsideTopic = chat?.isForum && threadId !== MAIN_THREAD_ID; const { replyToMsgId, replyToPeerId } = getMessageReplyInfo(message) || {}; const replyMessage = replyToMsgId ? selectChatMessage(global, replyToPeerId || message.chatId, replyToMsgId) : undefined; const isFocused = threadId ? selectIsMessageFocused(global, message, threadId) : false; const { direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer, scrollTargetPosition, } = (isFocused && tabState.focusedMessage) || {}; const isCurrentUserPremium = selectIsCurrentUserPremium(global); const readState = selectThreadReadState(global, message.chatId, threadId); const hasUnreadReaction = readState?.unreadReactions?.includes(message.id); const isAccountFrozen = selectIsCurrentUserFrozen(global); return { sender, currentUserId: global.currentUserId, isCurrentUserPremium, isFocused, focusDirection, noFocusHighlight, isInsideTopic, replyMessage, isInSelectMode: selectIsInSelectMode(global), actionMessageBg: selectActionMessageBg(global), hasUnreadReaction, isResizingContainer, scrollTargetPosition, isAccountFrozen, noForwardsRequestExpirePeriod: global.appConfig.noForwardsRequestExpirePeriod, }; }, )(ActionMessage));