TelegramPWA/src/components/middle/panes/HeaderPinnedMessage.tsx
2026-04-14 14:47:32 +02:00

355 lines
12 KiB
TypeScript

import type React from '../../../lib/teact/teact';
import { memo, useEffect } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiChat, ApiMessage, ApiPeer } from '../../../api/types';
import type { MessageListType, ThreadId } from '../../../types';
import type { Signal } from '../../../util/signals';
import { MAIN_THREAD_ID } from '../../../api/types';
import {
getIsSavedDialog,
getMessageIsSpoiler,
getMessageSingleInlineButton,
} from '../../../global/helpers';
import { getPeerTitle } from '../../../global/helpers/peers';
import {
selectAllowedMessageActionsSlow,
selectChat,
selectChatMessage,
selectChatMessages,
selectForwardedSender,
selectPinnedIds,
} from '../../../global/selectors';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import cycleRestrict from '../../../util/cycleRestrict';
import { REM } from '../../common/helpers/mediaDimensions';
import renderText from '../../common/helpers/renderText';
import renderKeyboardButtonText from '../composer/helpers/renderKeyboardButtonText';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useDerivedState from '../../../hooks/useDerivedState';
import useEnsureMessage from '../../../hooks/useEnsureMessage';
import { useFastClick } from '../../../hooks/useFastClick';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useShowTransition from '../../../hooks/useShowTransition';
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane';
import AnimatedCounter from '../../common/AnimatedCounter';
import CompactMediaPreview, { canRenderCompactMediaPreview } from '../../common/CompactMediaPreview';
import Icon from '../../common/icons/Icon';
import MessageSummary from '../../common/MessageSummary';
import Button from '../../ui/Button';
import ConfirmDialog from '../../ui/ConfirmDialog';
import RippleEffect from '../../ui/RippleEffect';
import Spinner from '../../ui/Spinner';
import Transition from '../../ui/Transition';
import PinnedMessageNavigation from '../PinnedMessageNavigation';
import styles from './HeaderPinnedMessage.module.scss';
const MAX_LENGTH = 256;
const SHOW_LOADER_DELAY = 450;
const EMOJI_SIZE = 1.125 * REM;
type OwnProps = {
chatId: string;
threadId: ThreadId;
messageListType: MessageListType;
className?: string;
isFullWidth?: boolean;
shouldHide?: boolean;
getLoadingPinnedId: Signal<number | undefined>;
getCurrentPinnedIndex: Signal<number>;
onFocusPinnedMessage: (messageId: number) => void;
onPaneStateChange?: (state: PaneState) => void;
};
type StateProps = {
chat?: ApiChat;
pinnedMessageIds?: number[] | number;
messagesById?: Record<number, ApiMessage>;
canUnpin?: boolean;
topMessageSender?: ApiPeer;
isSynced?: boolean;
};
const HeaderPinnedMessage = ({
chatId,
threadId,
canUnpin,
getLoadingPinnedId,
pinnedMessageIds,
messagesById,
isFullWidth,
topMessageSender,
getCurrentPinnedIndex,
className,
chat,
isSynced,
shouldHide,
onPaneStateChange,
onFocusPinnedMessage,
}: OwnProps & StateProps) => {
const {
clickBotInlineButton, focusMessage, openThread, pinMessage, loadPinnedMessages,
} = getActions();
const lang = useLang();
const currentPinnedIndex = useDerivedState(getCurrentPinnedIndex);
const pinnedMessageId = Array.isArray(pinnedMessageIds) ? pinnedMessageIds[currentPinnedIndex] : pinnedMessageIds;
const pinnedMessage = messagesById && pinnedMessageId ? messagesById[pinnedMessageId] : undefined;
const pinnedMessagesCount = Array.isArray(pinnedMessageIds)
? pinnedMessageIds.length : (pinnedMessageIds ? 1 : 0);
const pinnedMessageNumber = Math.max(pinnedMessagesCount - currentPinnedIndex, 1);
const topMessageTitle = topMessageSender ? getPeerTitle(lang, topMessageSender) : undefined;
const isLoading = Boolean(useDerivedState(getLoadingPinnedId));
const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY);
const shouldShowLoader = canRenderLoader && isLoading;
const renderingPinnedMessage = useCurrentOrPrev(pinnedMessage, true);
const hasPictogram = Boolean(
renderingPinnedMessage && canRenderCompactMediaPreview(renderingPinnedMessage.content),
);
const isSpoiler = renderingPinnedMessage && getMessageIsSpoiler(renderingPinnedMessage);
useEffect(() => {
if (isSynced && (threadId === MAIN_THREAD_ID || chat?.isForum)) {
loadPinnedMessages({ chatId, threadId });
}
}, [chatId, threadId, isSynced, chat?.isForum]);
useEnsureMessage(chatId, pinnedMessageId, pinnedMessage);
const isOpen = Boolean(pinnedMessage) && !shouldHide;
const {
ref: transitionRef,
} = useShowTransition({
isOpen,
noOpenTransition: true,
shouldForceOpen: isFullWidth, // Use pane animation instead
});
const { ref, shouldRender } = useHeaderPane({
isOpen,
isDisabled: !isFullWidth,
ref: transitionRef,
onStateChange: onPaneStateChange,
});
const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag();
const handleUnpinMessage = useLastCallback(() => {
closeUnpinDialog();
pinMessage({ chatId, messageId: pinnedMessage!.id, isUnpin: true });
});
const inlineButton = pinnedMessage && getMessageSingleInlineButton(pinnedMessage);
const handleInlineButtonClick = useLastCallback(() => {
if (inlineButton) {
clickBotInlineButton({ chatId: pinnedMessage.chatId, messageId: pinnedMessage.id, button: inlineButton });
}
});
const handleAllPinnedClick = useLastCallback(() => {
openThread({ chatId, threadId, type: 'pinned' });
});
const handleMessageClick = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent>): void => {
const nextMessageId = e.shiftKey && Array.isArray(pinnedMessageIds)
? pinnedMessageIds[cycleRestrict(pinnedMessageIds.length, pinnedMessageIds.indexOf(pinnedMessageId!) - 2)]
: pinnedMessageId!;
if (!getLoadingPinnedId()) {
focusMessage({
chatId, threadId, messageId: nextMessageId, noForumTopicPanel: true,
});
onFocusPinnedMessage(nextMessageId);
}
});
const [noHoverColor, markNoHoverColor, unmarkNoHoverColor] = useFlag();
const { handleClick, handleMouseDown } = useFastClick(handleMessageClick);
if (!shouldRender || !renderingPinnedMessage) return undefined;
return (
<div
ref={ref}
className={buildClassName(
'HeaderPinnedMessageWrapper', styles.root, isFullWidth ? styles.fullWidth : styles.mini, className,
)}
>
{(pinnedMessagesCount > 1 || shouldShowLoader) && (
<Button
round
size="smaller"
color="translucent"
ariaLabel={lang('EventLogFilterPinnedMessages')}
onClick={!shouldShowLoader ? handleAllPinnedClick : undefined}
>
{isLoading && (
<Spinner
color="blue"
className={buildClassName(
styles.loading, styles.pinListIcon, !shouldShowLoader && styles.pinListIconHidden,
)}
/>
)}
<Icon
name="pin-list"
className={buildClassName(
styles.pinListIcon, shouldShowLoader && styles.pinListIconHidden,
)}
/>
</Button>
)}
{canUnpin && (
<Button
round
size="smaller"
color="translucent"
ariaLabel={lang('UnpinMessageAlertTitle')}
onClick={openUnpinDialog}
iconName="close"
/>
)}
<ConfirmDialog
isOpen={isUnpinDialogOpen}
onClose={closeUnpinDialog}
text={lang('PinnedConfirmUnpin')}
confirmLabel={lang('DialogUnpin')}
confirmHandler={handleUnpinMessage}
/>
<div
className={buildClassName(styles.pinnedMessage, noHoverColor && styles.noHover)}
onClick={handleClick}
onMouseDown={handleMouseDown}
dir={lang.isRtl ? 'rtl' : undefined}
>
<PinnedMessageNavigation
count={pinnedMessagesCount}
index={currentPinnedIndex}
/>
<Transition activeKey={renderingPinnedMessage.id} name="slideVertical" className={styles.pictogramTransition}>
<CompactMediaPreview
media={renderingPinnedMessage.content}
className={styles.pinnedThumb}
isPictogram
isSpoiler={isSpoiler}
/>
</Transition>
<div
className={buildClassName(styles.messageText, hasPictogram && styles.withMedia)}
dir={lang.isRtl ? 'rtl' : undefined}
>
<div className={styles.title} dir={lang.isRtl ? 'rtl' : undefined}>
{!topMessageTitle && (
<AnimatedCounter
text={pinnedMessagesCount === 1
? lang('PinnedMessageTitleSingle')
: lang('PinnedMessageTitle', { index: pinnedMessageNumber }, { pluralValue: pinnedMessagesCount })}
/>
)}
{topMessageTitle && renderText(topMessageTitle)}
</div>
<Transition
activeKey={renderingPinnedMessage.id}
name="slideVerticalFade"
className={styles.messageTextTransition}
>
<p dir="auto" className={styles.summary}>
<MessageSummary
message={renderingPinnedMessage}
truncateLength={MAX_LENGTH}
noEmoji={hasPictogram}
emojiSize={EMOJI_SIZE}
/>
</p>
</Transition>
</div>
<RippleEffect />
{inlineButton && (
<Button
size="tiny"
className={styles.inlineButton}
onClick={handleInlineButtonClick}
shouldStopPropagation
onMouseEnter={!IS_TOUCH_ENV ? markNoHoverColor : undefined}
onMouseLeave={!IS_TOUCH_ENV ? unmarkNoHoverColor : undefined}
>
{renderKeyboardButtonText(lang, inlineButton)}
</Button>
)}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, {
chatId, threadId, messageListType,
}): Complete<StateProps> => {
const chat = selectChat(global, chatId);
const isSynced = global.isSynced;
const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId);
const messagesById = selectChatMessages(global, chatId);
const state = {
chat,
isSynced,
};
if (messageListType !== 'thread' || !messagesById) {
return state as Complete<StateProps>;
}
if (threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum) {
const pinnedMessageId = Number(threadId);
const message = pinnedMessageId ? selectChatMessage(global, chatId, pinnedMessageId) : undefined;
const topMessageSender = message ? selectForwardedSender(global, message) : undefined;
return {
...state,
pinnedMessageIds: pinnedMessageId,
messagesById,
canUnpin: false,
topMessageSender,
};
}
const pinnedMessageIds = !isSavedDialog ? selectPinnedIds(global, chatId, threadId) : undefined;
if (pinnedMessageIds?.length) {
const firstPinnedMessage = messagesById[pinnedMessageIds[0]];
const {
canUnpin = false,
} = (
firstPinnedMessage
&& pinnedMessageIds.length === 1
&& selectAllowedMessageActionsSlow(global, firstPinnedMessage, threadId)
) || {};
return {
...state,
pinnedMessageIds,
messagesById,
canUnpin,
} as Complete<StateProps>;
}
return state as Complete<StateProps>;
},
)(HeaderPinnedMessage));