498 lines
15 KiB
TypeScript

import React, {
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 ApiMessage, type ApiPeer, MAIN_THREAD_ID } from '../../../api/types';
import { MediaViewerOrigin } from '../../../types';
import { MESSAGE_APPEARANCE_DELAY } from '../../../config';
import { getMessageHtmlId } from '../../../global/helpers';
import { getMessageReplyInfo } from '../../../global/helpers/replies';
import {
selectChat,
selectChatMessage,
selectIsCurrentUserPremium,
selectIsInSelectMode,
selectIsMessageFocused,
selectSender,
selectTabState,
selectTheme,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { isLocalMessageId } from '../../../util/keys/messageKey';
import { isElementInViewport } from '../../../util/visibility/isElementInViewport';
import { IS_ANDROID, IS_ELECTRON, IS_FLUID_BACKGROUND_SUPPORTED } from '../../../util/windowEnvironment';
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 useLastCallback from '../../../hooks/useLastCallback';
import useMessageResizeObserver from '../../../hooks/useResizeMessageObserver';
import useShowTransition from '../../../hooks/useShowTransition';
import { type OnIntersectPinnedMessage } from '../hooks/usePinnedMessage';
import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter';
import useFocusMessage from './hooks/useFocusMessage';
import ActionMessageText from './ActionMessageText';
import ChannelPhoto from './actions/ChannelPhoto';
import Gift from './actions/Gift';
import PremiumGiftCode from './actions/GiveawayPrize';
import StarGift from './actions/StarGift';
import StarGiftUnique from './actions/StarGiftUnique';
import SuggestedPhoto from './actions/SuggestedPhoto';
import ContextMenuContainer from './ContextMenuContainer';
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<boolean>;
onIntersectPinnedMessage?: OnIntersectPinnedMessage;
observeIntersectionForBottom?: ObserveFn;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
};
type StateProps = {
sender?: ApiPeer;
currentUserId?: string;
isInsideTopic?: boolean;
isFocused?: boolean;
focusDirection?: FocusDirection;
noFocusHighlight?: boolean;
replyMessage?: ApiMessage;
patternColor?: string;
isCurrentUserPremium?: boolean;
isInSelectMode?: boolean;
hasUnreadReaction?: boolean;
isResizingContainer?: boolean;
scrollTargetPosition?: ScrollTargetPosition;
};
const SINGLE_LINE_ACTIONS: Set<ApiMessageAction['type']> = new Set([
'pinMessage',
'chatEditPhoto',
'chatDeletePhoto',
'unsupported',
]);
const HIDDEN_TEXT_ACTIONS: Set<ApiMessageAction['type']> = new Set(['giftCode', 'prizeStars', 'suggestProfilePhoto']);
const ActionMessage = ({
message,
threadId,
sender,
currentUserId,
appearanceOrder,
isJustAdded,
isLastInList,
memoFirstUnreadIdRef,
getIsMessageListReady,
isInsideTopic,
isFocused,
focusDirection,
noFocusHighlight,
replyMessage,
patternColor,
isCurrentUserPremium,
isInSelectMode,
hasUnreadReaction,
isResizingContainer,
scrollTargetPosition,
onIntersectPinnedMessage,
observeIntersectionForBottom,
observeIntersectionForLoading,
observeIntersectionForPlaying,
}: OwnProps & StateProps) => {
const {
requestConfetti,
openMediaViewer,
getReceipt,
checkGiftCode,
openPrizeStarsTransactionFromGiveaway,
openPremiumModal,
openStarsTransactionFromGift,
openGiftInfoModalFromMessage,
toggleChannelRecommendations,
animateUnreadReaction,
markMentionsRead,
} = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
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 messageReplyInfo = getMessageReplyInfo(message);
const { replyToMsgId, replyToPeerId } = messageReplyInfo || {};
const withServiceReactions = Boolean(message.areReactionsPossible && message?.reactions?.results?.length);
const shouldSkipRender = isInsideTopic && action.type === 'topicCreate';
const { isTouchScreen } = useAppLayout();
useOnIntersect(ref, !shouldSkipRender ? observeIntersectionForBottom : undefined);
useMessageResizeObserver(ref, !shouldSkipRender && isLastInList && action.type !== 'channelJoined');
useEnsureMessage(
replyToPeerId || chatId,
replyToMsgId,
replyMessage,
id,
);
useFocusMessage({
elementRef: ref,
chatId,
isFocused,
focusDirection,
noFocusHighlight,
isResizingContainer,
isJustAdded,
scrollTargetPosition,
});
useUnmountCleanup(() => {
if (message.isPinned) {
onIntersectPinnedMessage?.({ viewportPinnedIdsToRemove: [message.id] });
}
});
const {
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(
ref,
isTouchScreen && isInSelectMode,
!IS_ELECTRON,
IS_ANDROID,
getIsMessageListReady,
);
const isContextMenuShown = contextMenuAnchor !== undefined;
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
preventMessageInputBlur(e);
handleBeforeContextMenu(e);
};
const noAppearanceAnimation = appearanceOrder <= 0;
const [isShown, markShown] = useFlag(noAppearanceAnimation);
useEffect(() => {
if (noAppearanceAnimation) {
return;
}
setTimeout(markShown, appearanceOrder * MESSAGE_APPEARANCE_DELAY);
}, [appearanceOrder, markShown, noAppearanceAnimation]);
const { ref: refWithTransition } = useShowTransition({
isOpen: isShown,
noOpenTransition: noAppearanceAnimation,
noCloseTransition: true,
className: false,
ref,
});
useEffect(() => {
const bottomMarker = ref.current;
if (!bottomMarker || !isElementInViewport(bottomMarker)) return;
if (hasUnreadReaction) {
animateUnreadReaction({ 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 ? patternColor : undefined);
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,
monthsAmount: action.months,
});
break;
}
case 'giftStars': {
openStarsTransactionFromGift({
chatId: message.chatId,
messageId: message.id,
});
break;
}
case 'starGift':
case 'starGiftUnique': {
openGiftInfoModalFromMessage({
chatId: message.chatId,
messageId: message.id,
});
break;
}
case 'channelJoined': {
toggleChannelRecommendations({ chatId });
break;
}
}
});
const fullContent = useMemo(() => {
switch (action.type) {
case 'chatEditPhoto': {
if (!action.photo) return undefined;
return (
<ChannelPhoto
action={action}
observeIntersection={observeIntersectionForLoading}
onClick={handleClick}
/>
);
}
case 'suggestProfilePhoto':
return (
<SuggestedPhoto
message={message}
action={action}
observeIntersection={observeIntersectionForLoading}
/>
);
case 'prizeStars':
case 'giftCode':
return (
<PremiumGiftCode
action={action}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={handleClick}
/>
);
case 'giftPremium':
case 'giftStars':
return (
<Gift
action={action}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={handleClick}
/>
);
case 'starGift':
return (
<StarGift
action={action}
message={message}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={handleClick}
/>
);
case 'starGiftUnique':
return (
<StarGiftUnique
action={action}
message={message}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={handleClick}
/>
);
case 'channelJoined':
return (
<SimilarChannels
chatId={message.chatId}
/>
);
default:
return undefined;
}
}, [action, observeIntersectionForLoading, message, observeIntersectionForPlaying]);
if ((isInsideTopic && action.type === 'topicCreate') || action.type === 'phoneCall') {
return undefined;
}
return (
<div
ref={refWithTransition}
id={getMessageHtmlId(id)}
className={buildClassName(
'ActionMessage',
'message-list-item',
styles.root,
isSingleLine && styles.singleLine,
isFluidMultiline && styles.fluidMultiline,
fullContent && styles.hasFullContent,
isFocused && !noFocusHighlight && 'focused',
isContextMenuShown && 'has-menu-open',
isLastInList && 'last-in-list',
)}
data-message-id={message.id}
data-is-pinned={message.isPinned || undefined}
data-has-unread-mention={message.hasUnreadMention || undefined}
data-has-unread-reaction={hasUnreadReaction || undefined}
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
>
{!isTextHidden && (
<>
{isFluidMultiline && (
<div className={styles.inlineWrapper}>
<span className={styles.fluidBackground} style={fluidBackgroundStyle}>
<ActionMessageText message={message} isInsideTopic={isInsideTopic} />
</span>
</div>
)}
<div className={styles.inlineWrapper}>
<span className={styles.textContent} onClick={handleClick}>
<ActionMessageText message={message} isInsideTopic={isInsideTopic} />
</span>
</div>
</>
)}
{fullContent}
{contextMenuAnchor && (
<ContextMenuContainer
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
message={message}
messageListType="thread"
className={styles.contextContainer}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
/>
)}
{withServiceReactions && (
<Reactions
isOutside
message={message!}
threadId={threadId}
observeIntersection={observeIntersectionForPlaying}
isCurrentUserPremium={isCurrentUserPremium}
/>
)}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { message, threadId }): StateProps => {
const { settings: { themes } } = global;
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 hasUnreadReaction = chat?.unreadReactions?.includes(message.id);
return {
sender,
currentUserId: global.currentUserId,
isCurrentUserPremium,
isFocused,
focusDirection,
noFocusHighlight,
isInsideTopic,
replyMessage,
isInSelectMode: selectIsInSelectMode(global),
patternColor: themes[selectTheme(global)]?.patternColor,
hasUnreadReaction,
isResizingContainer,
scrollTargetPosition,
};
},
)(ActionMessage));