355 lines
12 KiB
TypeScript
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));
|