Header Pinned Message: Refactor and fix getting stuck on the same message

This commit is contained in:
Alexander Zinchuk 2024-09-24 14:48:35 +02:00
parent cb6c1faa86
commit bceec156b5
9 changed files with 152 additions and 197 deletions

View File

@ -1,6 +1,6 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { import React, {
memo, useCallback, useEffect, useMemo, useRef, memo, useCallback, useEffect, useMemo, useRef, useUnmountCleanup,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
@ -10,7 +10,7 @@ import type {
import type { MessageListType } from '../../global/types'; import type { MessageListType } from '../../global/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { FocusDirection, ThreadId } from '../../types'; import type { FocusDirection, ThreadId } from '../../types';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; import type { OnIntersectPinnedMessage } from './hooks/usePinnedMessage';
import { getChatTitle, getMessageHtmlId, isJoinedChannelMessage } from '../../global/helpers'; import { getChatTitle, getMessageHtmlId, isJoinedChannelMessage } from '../../global/helpers';
import { getMessageReplyInfo } from '../../global/helpers/replies'; import { getMessageReplyInfo } from '../../global/helpers/replies';
@ -57,7 +57,7 @@ type OwnProps = {
isLastInList?: boolean; isLastInList?: boolean;
isInsideTopic?: boolean; isInsideTopic?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined }; memoFirstUnreadIdRef?: { current: number | undefined };
onPinnedIntersectionChange?: PinnedIntersectionChangedCallback; onIntersectPinnedMessage?: OnIntersectPinnedMessage;
}; };
type StateProps = { type StateProps = {
@ -102,7 +102,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
observeIntersectionForReading, observeIntersectionForReading,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
onPinnedIntersectionChange, onIntersectPinnedMessage,
}) => { }) => {
const { const {
openPremiumModal, requestConfetti, checkGiftCode, getReceipt, openStarsTransactionFromGift, openPremiumModal, requestConfetti, checkGiftCode, getReceipt, openStarsTransactionFromGift,
@ -121,13 +121,11 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
); );
useFocusMessage(ref, message.chatId, isFocused, focusDirection, noFocusHighlight, isJustAdded); useFocusMessage(ref, message.chatId, isFocused, focusDirection, noFocusHighlight, isJustAdded);
useEffect(() => { useUnmountCleanup(() => {
if (!message.isPinned) return undefined; if (message.isPinned) {
onIntersectPinnedMessage?.({ viewportPinnedIdsToRemove: [message.id] });
return () => { }
onPinnedIntersectionChange?.({ viewportPinnedIdsToRemove: [message.id], isUnmount: true }); });
};
}, [onPinnedIntersectionChange, message.isPinned, message.id]);
const noAppearanceAnimation = appearanceOrder <= 0; const noAppearanceAnimation = appearanceOrder <= 0;
const [isShown, markShown] = useFlag(noAppearanceAnimation); const [isShown, markShown] = useFlag(noAppearanceAnimation);
@ -287,10 +285,14 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
{oldLang(isUnclaimed ? 'BoostingUnclaimedPrize' : 'BoostingCongratulations')} {oldLang(isUnclaimed ? 'BoostingUnclaimedPrize' : 'BoostingCongratulations')}
</strong> </strong>
<span className="action-message-subtitle"> <span className="action-message-subtitle">
{targetChat && renderText(oldLang(isFromGiveaway ? 'BoostingReceivedGiftFrom' : isUnclaimed {targetChat && renderText(
? 'BoostingReceivedPrizeFrom' : 'BoostingYouHaveUnclaimedPrize', oldLang(
getChatTitle(oldLang, targetChat)), isFromGiveaway ? 'BoostingReceivedGiftFrom' : isUnclaimed
['simple_markdown'])} ? 'BoostingReceivedPrizeFrom' : 'BoostingYouHaveUnclaimedPrize',
getChatTitle(oldLang, targetChat),
),
['simple_markdown'],
)}
</span> </span>
<span className="action-message-subtitle"> <span className="action-message-subtitle">
{renderText(oldLang( {renderText(oldLang(

View File

@ -3,17 +3,16 @@ import React, { memo } from '../../lib/teact/teact';
import { getActions } from '../../global'; import { getActions } from '../../global';
import type { ApiMessage } from '../../api/types'; import type { ApiMessage } from '../../api/types';
import type { Signal } from '../../util/signals';
import { import { getMessageIsSpoiler, getMessageMediaHash, getMessageSingleInlineButton } from '../../global/helpers';
getMessageIsSpoiler,
getMessageMediaHash, getMessageSingleInlineButton,
} from '../../global/helpers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { getPictogramDimensions, REM } from '../common/helpers/mediaDimensions'; import { getPictogramDimensions, REM } from '../common/helpers/mediaDimensions';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
import renderKeyboardButtonText from './composer/helpers/renderKeyboardButtonText'; import renderKeyboardButtonText from './composer/helpers/renderKeyboardButtonText';
import useDerivedState from '../../hooks/useDerivedState';
import { useFastClick } from '../../hooks/useFastClick'; import { useFastClick } from '../../hooks/useFastClick';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
@ -46,13 +45,13 @@ type OwnProps = {
onUnpinMessage?: (id: number) => void; onUnpinMessage?: (id: number) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void; onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
onAllPinnedClick?: () => void; onAllPinnedClick?: () => void;
isLoading?: boolean; getLoadingPinnedId: Signal<number | undefined>;
isFullWidth?: boolean; isFullWidth?: boolean;
}; };
const HeaderPinnedMessage: FC<OwnProps> = ({ const HeaderPinnedMessage: FC<OwnProps> = ({
message, count, index, customTitle, className, onUnpinMessage, onClick, onAllPinnedClick, message, count, index, customTitle, className, onUnpinMessage, onClick, onAllPinnedClick,
isLoading, isFullWidth, getLoadingPinnedId, isFullWidth,
}) => { }) => {
const { clickBotInlineButton } = getActions(); const { clickBotInlineButton } = getActions();
const lang = useOldLang(); const lang = useOldLang();
@ -60,6 +59,8 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
const mediaThumbnail = useThumbnail(message); const mediaThumbnail = useThumbnail(message);
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram')); const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram'));
const isSpoiler = getMessageIsSpoiler(message); const isSpoiler = getMessageIsSpoiler(message);
const isLoading = Boolean(useDerivedState(getLoadingPinnedId));
const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY); const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY);
const shouldShowLoader = canRenderLoader && isLoading; const shouldShowLoader = canRenderLoader && isLoading;

View File

@ -1,21 +1,15 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { import React, {
beginHeavyAnimation, beginHeavyAnimation, memo, useEffect, useMemo, useRef,
memo,
useEffect,
useMemo,
useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { addExtraClass, removeExtraClass } from '../../lib/teact/teact-dom'; import { addExtraClass, removeExtraClass } from '../../lib/teact/teact-dom';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
import type { import type {
ApiChatFullInfo, ApiChatFullInfo, ApiMessage, ApiRestrictionReason, ApiTopic,
ApiMessage, ApiRestrictionReason, ApiTopic,
} from '../../api/types'; } from '../../api/types';
import type { MessageListType } from '../../global/types'; import type { MessageListType } from '../../global/types';
import type { Signal } from '../../util/signals'; import type { OnIntersectPinnedMessage } from './hooks/usePinnedMessage';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import { MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
import { LoadMoreDirection, type ThreadId } from '../../types'; import { LoadMoreDirection, type ThreadId } from '../../types';
@ -98,8 +92,7 @@ type OwnProps = {
hasTools?: boolean; hasTools?: boolean;
withBottomShift?: boolean; withBottomShift?: boolean;
withDefaultBg: boolean; withDefaultBg: boolean;
onPinnedIntersectionChange: PinnedIntersectionChangedCallback; onIntersectPinnedMessage: OnIntersectPinnedMessage;
getForceNextPinnedInHeader: Signal<boolean | undefined>;
isContactRequirePremium?: boolean; isContactRequirePremium?: boolean;
}; };
@ -185,11 +178,10 @@ const MessageList: FC<OwnProps & StateProps> = ({
noMessageSendingAnimation, noMessageSendingAnimation,
isServiceNotificationsChat, isServiceNotificationsChat,
currentUserId, currentUserId,
getForceNextPinnedInHeader,
isContactRequirePremium, isContactRequirePremium,
areAdsEnabled, areAdsEnabled,
channelJoinInfo, channelJoinInfo,
onPinnedIntersectionChange, onIntersectPinnedMessage,
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
}) => { }) => {
@ -418,9 +410,10 @@ const MessageList: FC<OwnProps & StateProps> = ({
runDebouncedForScroll(() => { runDebouncedForScroll(() => {
const global = getGlobal(); const global = getGlobal();
const forceNextPinnedInHeader = getForceNextPinnedInHeader() && !selectTabState(global).focusedMessage?.chatId;
if (forceNextPinnedInHeader) { const isFocusing = Boolean(selectTabState(global).focusedMessage?.chatId);
onPinnedIntersectionChange({ hasScrolled: true }); if (!isFocusing) {
onIntersectPinnedMessage({ shouldCancelWaiting: true });
} }
if (!container.parentElement) { if (!container.parentElement) {
@ -731,7 +724,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current} noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current}
onScrollDownToggle={onScrollDownToggle} onScrollDownToggle={onScrollDownToggle}
onNotchToggle={onNotchToggle} onNotchToggle={onNotchToggle}
onPinnedIntersectionChange={onPinnedIntersectionChange} onIntersectPinnedMessage={onIntersectPinnedMessage}
/> />
) : ( ) : (
<Loading color="white" backgroundColor="dark" /> <Loading color="white" backgroundColor="dark" />

View File

@ -7,7 +7,7 @@ import type { MessageListType } from '../../global/types';
import type { ThreadId } from '../../types'; import type { ThreadId } from '../../types';
import type { Signal } from '../../util/signals'; import type { Signal } from '../../util/signals';
import type { MessageDateGroup } from './helpers/groupMessages'; import type { MessageDateGroup } from './helpers/groupMessages';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; import type { OnIntersectPinnedMessage } from './hooks/usePinnedMessage';
import { MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
import { SCHEDULED_WHEN_ONLINE } from '../../config'; import { SCHEDULED_WHEN_ONLINE } from '../../config';
@ -62,7 +62,7 @@ interface OwnProps {
isSavedDialog?: boolean; isSavedDialog?: boolean;
onScrollDownToggle: BooleanToVoidFunction; onScrollDownToggle: BooleanToVoidFunction;
onNotchToggle: AnyToVoidFunction; onNotchToggle: AnyToVoidFunction;
onPinnedIntersectionChange: PinnedIntersectionChangedCallback; onIntersectPinnedMessage: OnIntersectPinnedMessage;
} }
const UNREAD_DIVIDER_CLASS = 'unread-divider'; const UNREAD_DIVIDER_CLASS = 'unread-divider';
@ -94,7 +94,7 @@ const MessageListContent: FC<OwnProps> = ({
isSavedDialog, isSavedDialog,
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
onPinnedIntersectionChange, onIntersectPinnedMessage,
}) => { }) => {
const { openHistoryCalendar } = getActions(); const { openHistoryCalendar } = getActions();
@ -107,7 +107,7 @@ const MessageListContent: FC<OwnProps> = ({
observeIntersectionForReading, observeIntersectionForReading,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
} = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onPinnedIntersectionChange, chatId); } = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onIntersectPinnedMessage, chatId);
const { const {
withHistoryTriggers, withHistoryTriggers,
@ -180,7 +180,7 @@ const MessageListContent: FC<OwnProps> = ({
appearanceOrder={messageCountToAnimate - ++appearanceIndex} appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isJustAdded={isLastInList && isNewMessage} isJustAdded={isLastInList && isNewMessage}
isLastInList={isLastInList} isLastInList={isLastInList}
onPinnedIntersectionChange={onPinnedIntersectionChange} onIntersectPinnedMessage={onIntersectPinnedMessage}
/>, />,
]); ]);
} }
@ -249,7 +249,7 @@ const MessageListContent: FC<OwnProps> = ({
isLastInDocumentGroup={position.isLastInDocumentGroup} isLastInDocumentGroup={position.isLastInDocumentGroup}
isLastInList={position.isLastInList} isLastInList={position.isLastInList}
memoFirstUnreadIdRef={memoFirstUnreadIdRef} memoFirstUnreadIdRef={memoFirstUnreadIdRef}
onPinnedIntersectionChange={onPinnedIntersectionChange} onIntersectPinnedMessage={onIntersectPinnedMessage}
getIsMessageListReady={getIsReady} getIsMessageListReady={getIsReady}
/>, />,
message.id === threadId && ( message.id === threadId && (

View File

@ -154,7 +154,6 @@ type StateProps = {
shouldJoinToSend?: boolean; shouldJoinToSend?: boolean;
shouldSendJoinRequest?: boolean; shouldSendJoinRequest?: boolean;
pinnedIds?: number[]; pinnedIds?: number[];
topMessageId?: number;
canUnpin?: boolean; canUnpin?: boolean;
canUnblock?: boolean; canUnblock?: boolean;
isSavedDialog?: boolean; isSavedDialog?: boolean;
@ -216,7 +215,6 @@ function MiddleColumn({
shouldSendJoinRequest, shouldSendJoinRequest,
shouldLoadFullChat, shouldLoadFullChat,
pinnedIds, pinnedIds,
topMessageId,
canUnpin, canUnpin,
canUnblock, canUnblock,
isSavedDialog, isSavedDialog,
@ -252,12 +250,11 @@ function MiddleColumn({
const [isUnpinModalOpen, setIsUnpinModalOpen] = useState(false); const [isUnpinModalOpen, setIsUnpinModalOpen] = useState(false);
const { const {
onIntersectionChanged, handleIntersectPinnedMessage,
onFocusPinnedMessage, handleFocusPinnedMessage,
getCurrentPinnedIndexes, getCurrentPinnedIndex,
getLoadingPinnedId, getLoadingPinnedId,
getForceNextPinnedInHeader, } = usePinnedMessage(chatId, threadId, pinnedIds);
} = usePinnedMessage(chatId, threadId, pinnedIds, topMessageId);
const closeAnimationDuration = isMobile ? LAYER_ANIMATION_DURATION_MS : undefined; const closeAnimationDuration = isMobile ? LAYER_ANIMATION_DURATION_MS : undefined;
const hasTools = hasPinned && ( const hasTools = hasPinned && (
@ -287,8 +284,8 @@ function MiddleColumn({
const renderingIsChannel = usePrevDuringAnimation(isChannel, closeAnimationDuration); const renderingIsChannel = usePrevDuringAnimation(isChannel, closeAnimationDuration);
const renderingShouldJoinToSend = usePrevDuringAnimation(shouldJoinToSend, closeAnimationDuration); const renderingShouldJoinToSend = usePrevDuringAnimation(shouldJoinToSend, closeAnimationDuration);
const renderingShouldSendJoinRequest = usePrevDuringAnimation(shouldSendJoinRequest, closeAnimationDuration); const renderingShouldSendJoinRequest = usePrevDuringAnimation(shouldSendJoinRequest, closeAnimationDuration);
const renderingOnPinnedIntersectionChange = usePrevDuringAnimation( const renderingHandleIntersectPinnedMessage = usePrevDuringAnimation(
chatId ? onIntersectionChanged : undefined, chatId ? handleIntersectPinnedMessage : undefined,
closeAnimationDuration, closeAnimationDuration,
); );
@ -538,9 +535,9 @@ function MiddleColumn({
isComments={isComments} isComments={isComments}
isReady={isReady} isReady={isReady}
isMobile={isMobile} isMobile={isMobile}
getCurrentPinnedIndexes={getCurrentPinnedIndexes} getCurrentPinnedIndex={getCurrentPinnedIndex}
getLoadingPinnedId={getLoadingPinnedId} getLoadingPinnedId={getLoadingPinnedId}
onFocusPinnedMessage={onFocusPinnedMessage} onFocusPinnedMessage={handleFocusPinnedMessage}
/> />
<Transition <Transition
name={shouldSkipHistoryAnimations ? 'none' : withInterfaceAnimations ? 'slide' : 'fade'} name={shouldSkipHistoryAnimations ? 'none' : withInterfaceAnimations ? 'slide' : 'fade'}
@ -563,8 +560,7 @@ function MiddleColumn({
isContactRequirePremium={isContactRequirePremium} isContactRequirePremium={isContactRequirePremium}
withBottomShift={withMessageListBottomShift} withBottomShift={withMessageListBottomShift}
withDefaultBg={Boolean(!customBackground && !backgroundColor)} withDefaultBg={Boolean(!customBackground && !backgroundColor)}
onPinnedIntersectionChange={renderingOnPinnedIntersectionChange!} onIntersectPinnedMessage={renderingHandleIntersectPinnedMessage!}
getForceNextPinnedInHeader={getForceNextPinnedInHeader}
/> />
<div className={footerClassName}> <div className={footerClassName}>
{renderingCanPost && ( {renderingCanPost && (
@ -812,7 +808,6 @@ export default memo(withGlobal<OwnProps>(
const canShowOpenChatButton = isSavedDialog && threadId !== ANONYMOUS_USER_ID; const canShowOpenChatButton = isSavedDialog && threadId !== ANONYMOUS_USER_ID;
const isCommentThread = threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum; const isCommentThread = threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum;
const topMessageId = isCommentThread ? Number(threadId) : undefined;
const canUnpin = chat && ( const canUnpin = chat && (
isPrivate || ( isPrivate || (
@ -856,7 +851,6 @@ export default memo(withGlobal<OwnProps>(
shouldSendJoinRequest, shouldSendJoinRequest,
shouldLoadFullChat, shouldLoadFullChat,
pinnedIds, pinnedIds,
topMessageId,
canUnpin, canUnpin,
canUnblock, canUnblock,
isSavedDialog, isSavedDialog,

View File

@ -91,9 +91,9 @@ type OwnProps = {
isComments?: boolean; isComments?: boolean;
isReady?: boolean; isReady?: boolean;
isMobile?: boolean; isMobile?: boolean;
getCurrentPinnedIndexes: Signal<Record<string, number>>; getCurrentPinnedIndex: Signal<number>;
getLoadingPinnedId: Signal<number | undefined>; getLoadingPinnedId: Signal<number | undefined>;
onFocusPinnedMessage: (messageId: number) => boolean; onFocusPinnedMessage: (messageId: number) => void;
}; };
type StateProps = { type StateProps = {
@ -147,7 +147,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
isSyncing, isSyncing,
isSynced, isSynced,
isFetchingDifference, isFetchingDifference,
getCurrentPinnedIndexes, getCurrentPinnedIndex,
getLoadingPinnedId, getLoadingPinnedId,
emojiStatusSticker, emojiStatusSticker,
isSavedDialog, isSavedDialog,
@ -173,9 +173,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
const isBackButtonActive = useRef(true); const isBackButtonActive = useRef(true);
const { isTablet } = useAppLayout(); const { isTablet } = useAppLayout();
const currentPinnedIndexes = useDerivedState(getCurrentPinnedIndexes); const currentPinnedIndex = useDerivedState(getCurrentPinnedIndex);
const currentPinnedIndex = currentPinnedIndexes[`${chatId}_${threadId}`] || 0;
const waitingForPinnedId = useDerivedState(getLoadingPinnedId);
const pinnedMessageId = Array.isArray(pinnedMessageIds) ? pinnedMessageIds[currentPinnedIndex] : pinnedMessageIds; const pinnedMessageId = Array.isArray(pinnedMessageIds) ? pinnedMessageIds[currentPinnedIndex] : pinnedMessageIds;
const pinnedMessage = messagesById && pinnedMessageId ? messagesById[pinnedMessageId] : undefined; const pinnedMessage = messagesById && pinnedMessageId ? messagesById[pinnedMessageId] : undefined;
const pinnedMessagesCount = Array.isArray(pinnedMessageIds) const pinnedMessagesCount = Array.isArray(pinnedMessageIds)
@ -233,10 +231,11 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
? pinnedMessageIds[cycleRestrict(pinnedMessageIds.length, pinnedMessageIds.indexOf(pinnedMessageId!) - 2)] ? pinnedMessageIds[cycleRestrict(pinnedMessageIds.length, pinnedMessageIds.indexOf(pinnedMessageId!) - 2)]
: pinnedMessageId!; : pinnedMessageId!;
if (onFocusPinnedMessage(messageId)) { if (!getLoadingPinnedId()) {
focusMessage({ focusMessage({
chatId, threadId, messageId, noForumTopicPanel: true, chatId, threadId, messageId, noForumTopicPanel: true,
}); });
onFocusPinnedMessage(messageId);
} }
}); });
@ -509,7 +508,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
onUnpinMessage={renderingCanUnpin ? handleUnpinMessage : undefined} onUnpinMessage={renderingCanUnpin ? handleUnpinMessage : undefined}
onClick={handlePinnedMessageClick} onClick={handlePinnedMessageClick}
onAllPinnedClick={handleAllPinnedClick} onAllPinnedClick={handleAllPinnedClick}
isLoading={waitingForPinnedId !== undefined} getLoadingPinnedId={getLoadingPinnedId}
isFullWidth={isPinnedMessagesFullWidth} isFullWidth={isPinnedMessagesFullWidth}
/> />
)} )}

View File

@ -2,7 +2,7 @@ import type { RefObject } from 'react';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { MessageListType } from '../../../global/types'; import type { MessageListType } from '../../../global/types';
import type { PinnedIntersectionChangedCallback } from './usePinnedMessage'; import type { OnIntersectPinnedMessage } from './usePinnedMessage';
import { IS_ANDROID } from '../../../util/windowEnvironment'; import { IS_ANDROID } from '../../../util/windowEnvironment';
@ -17,7 +17,7 @@ export default function useMessageObservers(
type: MessageListType, type: MessageListType,
containerRef: RefObject<HTMLDivElement>, containerRef: RefObject<HTMLDivElement>,
memoFirstUnreadIdRef: { current: number | undefined }, memoFirstUnreadIdRef: { current: number | undefined },
onPinnedIntersectionChange: PinnedIntersectionChangedCallback, onIntersectPinnedMessage: OnIntersectPinnedMessage,
chatId: string, chatId: string,
) { ) {
const { const {
@ -44,12 +44,9 @@ export default function useMessageObservers(
const viewportPinnedIdsToAdd: number[] = []; const viewportPinnedIdsToAdd: number[] = [];
const viewportPinnedIdsToRemove: number[] = []; const viewportPinnedIdsToRemove: number[] = [];
const scheduledToUpdateViews: number[] = []; const scheduledToUpdateViews: number[] = [];
let isReversed = false;
entries.forEach((entry) => { entries.forEach((entry) => {
const { const { isIntersecting, target } = entry;
isIntersecting, target, boundingClientRect, rootBounds,
} = entry;
const { dataset } = target as HTMLDivElement; const { dataset } = target as HTMLDivElement;
const messageId = Number(dataset.lastMessageId || dataset.messageId); const messageId = Number(dataset.lastMessageId || dataset.messageId);
@ -58,9 +55,6 @@ export default function useMessageObservers(
if (!isIntersecting) { if (!isIntersecting) {
if (dataset.isPinned) { if (dataset.isPinned) {
if (rootBounds && boundingClientRect.bottom < rootBounds.top) {
isReversed = true;
}
viewportPinnedIdsToRemove.push(albumMainId || messageId); viewportPinnedIdsToRemove.push(albumMainId || messageId);
} }
return; return;
@ -100,7 +94,7 @@ export default function useMessageObservers(
} }
if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) { if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) {
onPinnedIntersectionChange({ viewportPinnedIdsToAdd, viewportPinnedIdsToRemove, isReversed }); onIntersectPinnedMessage({ viewportPinnedIdsToAdd, viewportPinnedIdsToRemove });
} }
if (scheduledToUpdateViews.length) { if (scheduledToUpdateViews.length) {

View File

@ -1,171 +1,139 @@
import { useEffect, useRef, useSignal } from '../../../lib/teact/teact'; import { useEffect, useSignal } from '../../../lib/teact/teact';
import { getGlobal } from '../../../global'; import { getGlobal } from '../../../global';
import type { ThreadId } from '../../../types'; import type { ThreadId } from '../../../types';
import { import { selectFocusedMessageId, selectListedIds, selectOutlyingListByMessageId } from '../../../global/selectors';
selectFocusedMessageId,
selectListedIds,
selectOutlyingListByMessageId,
} from '../../../global/selectors';
import cycleRestrict from '../../../util/cycleRestrict'; import cycleRestrict from '../../../util/cycleRestrict';
import { unique } from '../../../util/iteratees'; import { unique } from '../../../util/iteratees';
import { clamp } from '../../../util/math';
import useDerivedSignal from '../../../hooks/useDerivedSignal';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
type PinnedIntersectionChangedParams = { export type OnIntersectPinnedMessage = (params: {
viewportPinnedIdsToAdd?: number[]; viewportPinnedIdsToAdd?: number[];
viewportPinnedIdsToRemove?: number[]; viewportPinnedIdsToRemove?: number[];
isReversed?: boolean; shouldCancelWaiting?: boolean;
hasScrolled?: boolean; }) => void;
isUnmount?: boolean;
};
export type PinnedIntersectionChangedCallback = (params: PinnedIntersectionChangedParams) => void; let viewportPinnedIds: number[] | undefined;
let lastFocusedId: number | undefined;
export default function usePinnedMessage( export default function usePinnedMessage(
chatId?: string, threadId?: ThreadId, pinnedIds?: number[], topMessageId?: number, chatId?: string, threadId?: ThreadId, pinnedIds?: number[],
) { ) {
const [getCurrentPinnedIndexes, setCurrentPinnedIndexes] = useSignal<Record<string, number>>({}); const [getPinnedIndexByKey, setPinnedIndexByKey] = useSignal<Record<string, number>>({});
const [getForceNextPinnedInHeader, setForceNextPinnedInHeader] = useSignal<boolean | undefined>();
const viewportPinnedIdsRef = useRef<number[] | undefined>();
const [getLoadingPinnedId, setLoadingPinnedId] = useSignal<number | undefined>(); const [getLoadingPinnedId, setLoadingPinnedId] = useSignal<number | undefined>();
const key = chatId ? `${chatId}_${threadId}` : undefined; const key = chatId ? `${chatId}_${threadId}` : undefined;
const getCurrentPinnedIndex = useDerivedSignal(
() => (getPinnedIndexByKey()[key!] ?? 0),
[getPinnedIndexByKey, key],
);
// Reset when switching chat // Reset when switching chat
useEffect(() => { useEffect(() => {
setForceNextPinnedInHeader(undefined); viewportPinnedIds = undefined;
viewportPinnedIdsRef.current = undefined;
setLoadingPinnedId(undefined); setLoadingPinnedId(undefined);
}, [ }, [
chatId, setCurrentPinnedIndexes, setForceNextPinnedInHeader, setLoadingPinnedId, threadId, chatId, setPinnedIndexByKey, setLoadingPinnedId, threadId,
]); ]);
useEffect(() => { useEffect(() => {
if (!key) return; if (!key) return;
const currentPinnedIndex = getCurrentPinnedIndexes()[key]; const currentPinnedIndex = getPinnedIndexByKey()[key];
const pinnedLength = pinnedIds?.length || 0; const pinnedLength = pinnedIds?.length || 0;
if (currentPinnedIndex >= pinnedLength) { if (currentPinnedIndex >= pinnedLength) {
setCurrentPinnedIndexes({ setPinnedIndexByKey({
...getCurrentPinnedIndexes(), ...getPinnedIndexByKey(),
[key]: Math.max(0, pinnedLength - 1), [key]: Math.max(0, pinnedLength - 1),
}); });
} }
}, [getCurrentPinnedIndexes, key, pinnedIds?.length, setCurrentPinnedIndexes]); }, [getPinnedIndexByKey, key, pinnedIds?.length, setPinnedIndexByKey]);
const onIntersectionChanged = useLastCallback(({ const handleIntersectPinnedMessage: OnIntersectPinnedMessage = useLastCallback(({
viewportPinnedIdsToAdd = [], viewportPinnedIdsToRemove = [], isReversed, hasScrolled, isUnmount, viewportPinnedIdsToAdd = [],
}: PinnedIntersectionChangedParams) => { viewportPinnedIdsToRemove = [],
if (!chatId || !threadId || !key) return; shouldCancelWaiting,
}) => {
if (!chatId || !threadId || !key || !pinnedIds?.length) return;
const global = getGlobal(); if (shouldCancelWaiting) {
lastFocusedId = undefined;
setLoadingPinnedId(undefined);
return;
}
const pinnedMessagesCount = pinnedIds?.length || 0; const loadingPinnedId = getLoadingPinnedId();
if (loadingPinnedId) {
if (!pinnedMessagesCount || !pinnedIds) return; const newPinnedIndex = pinnedIds.indexOf(loadingPinnedId);
setPinnedIndexByKey({
const waitingForPinnedId = getLoadingPinnedId(); ...getPinnedIndexByKey(),
if (waitingForPinnedId && !hasScrolled) {
const newPinnedIndex = pinnedIds.indexOf(waitingForPinnedId);
setCurrentPinnedIndexes({
...getCurrentPinnedIndexes(),
[key]: newPinnedIndex, [key]: newPinnedIndex,
}); });
setLoadingPinnedId(undefined); setLoadingPinnedId(undefined);
} }
if (hasScrolled) { viewportPinnedIds = unique(
setForceNextPinnedInHeader(undefined); (viewportPinnedIds?.filter((id) => !viewportPinnedIdsToRemove.includes(id)) ?? [])
setLoadingPinnedId(undefined);
}
const forceNextPinnedInHeader = getForceNextPinnedInHeader();
const currentViewportPinnedIds = viewportPinnedIdsRef.current;
// Unmounting the Message component will fire this action, and if we've already marked the pin as
// outside the viewport, we don't need to do anything
if (isUnmount
&& viewportPinnedIdsToAdd.length === 0 && viewportPinnedIdsToRemove.length === 1
&& !currentViewportPinnedIds?.includes(viewportPinnedIdsToRemove[0])) {
return;
}
const newPinnedViewportIds = unique(
(currentViewportPinnedIds?.filter((id) => !viewportPinnedIdsToRemove.includes(id)) || [])
.concat(viewportPinnedIdsToAdd), .concat(viewportPinnedIdsToAdd),
); );
viewportPinnedIdsRef.current = newPinnedViewportIds; // Sometimes this callback is called after focus has been reset in global, so we leverage `lastFocusedId`
const focusedMessageId = selectFocusedMessageId(getGlobal(), chatId) || lastFocusedId;
const focusedMessageId = selectFocusedMessageId(global, chatId); if (lastFocusedId && viewportPinnedIds.includes(lastFocusedId)) {
// Focused to some non-pinned message lastFocusedId = undefined;
if (!newPinnedViewportIds.length && isUnmount && focusedMessageId && !pinnedIds.includes(focusedMessageId)) {
const firstPinnedIdAfterFocused = pinnedIds.find((id) => id < focusedMessageId);
if (firstPinnedIdAfterFocused) {
const newIndex = pinnedIds.indexOf(firstPinnedIdAfterFocused);
setCurrentPinnedIndexes({
...getCurrentPinnedIndexes(),
[key]: newIndex,
});
}
} }
if (forceNextPinnedInHeader || isUnmount) { if (focusedMessageId) {
const pinnedIndexAboveFocused = pinnedIds.findIndex((id) => id < focusedMessageId);
const newIndex = pinnedIndexAboveFocused !== -1 ? pinnedIndexAboveFocused : 0;
setPinnedIndexByKey({
...getPinnedIndexByKey(),
[key]: newIndex,
});
} else if (viewportPinnedIds.length) {
const maxViewportPinnedId = Math.max(...viewportPinnedIds);
const newIndex = pinnedIds.indexOf(maxViewportPinnedId);
setPinnedIndexByKey({
...getPinnedIndexByKey(),
[key]: newIndex,
});
}
});
const handleFocusPinnedMessage = useLastCallback((messageId: number) => {
// Focusing on a post in comments
if (!chatId || !threadId || !pinnedIds?.length) {
return; return;
} }
const maxId = Math.max(...newPinnedViewportIds); lastFocusedId = messageId;
const maxIdIndex = pinnedIds.findIndex((id) => id === maxId);
const delta = isReversed ? 0 : 1;
const newIndex = newPinnedViewportIds.length ? maxIdIndex : (
currentViewportPinnedIds?.length
? clamp(pinnedIds.indexOf(currentViewportPinnedIds[0]) + delta, 0, pinnedIds.length - 1)
: 0
);
setCurrentPinnedIndexes({
...getCurrentPinnedIndexes(),
[key]: newIndex,
});
});
const onFocusPinnedMessage = useLastCallback((messageId: number): boolean => {
if (!chatId || !threadId || !key || getLoadingPinnedId()) return false;
const global = getGlobal(); const global = getGlobal();
if (!pinnedIds?.length) {
// Focusing on a post in comments
return topMessageId === messageId;
}
const index = pinnedIds.indexOf(messageId);
const newPinnedIndex = cycleRestrict(pinnedIds.length, index + 1);
setForceNextPinnedInHeader(true);
const listedIds = selectListedIds(global, chatId, threadId); const listedIds = selectListedIds(global, chatId, threadId);
const isMessageLoaded = listedIds?.includes(messageId) const isMessageLoaded = listedIds?.includes(messageId)
|| selectOutlyingListByMessageId(global, chatId, threadId, messageId); || selectOutlyingListByMessageId(global, chatId, threadId, messageId);
const currentIndex = pinnedIds.indexOf(messageId);
const newIndex = cycleRestrict(pinnedIds.length, currentIndex + 1);
if (isMessageLoaded) { if (isMessageLoaded) {
setCurrentPinnedIndexes({ setPinnedIndexByKey({
...getCurrentPinnedIndexes(), ...getPinnedIndexByKey(),
[key]: newPinnedIndex, [key!]: newIndex,
}); });
return true;
} else { } else {
setLoadingPinnedId(pinnedIds[newPinnedIndex]); setLoadingPinnedId(pinnedIds[newIndex]);
return true;
} }
}); });
return { return {
onIntersectionChanged, handleIntersectPinnedMessage,
onFocusPinnedMessage, handleFocusPinnedMessage,
getCurrentPinnedIndexes, getCurrentPinnedIndex,
getLoadingPinnedId, getLoadingPinnedId,
getForceNextPinnedInHeader,
}; };
} }

View File

@ -1,7 +1,13 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
beginHeavyAnimation, beginHeavyAnimation,
memo, useCallback, useEffect, useMemo, useRef, useState, memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useUnmountCleanup,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
@ -27,7 +33,7 @@ import type {
FocusDirection, IAlbum, ISettings, ScrollTargetPosition, ThreadId, FocusDirection, IAlbum, ISettings, ScrollTargetPosition, ThreadId,
} from '../../../types'; } from '../../../types';
import type { Signal } from '../../../util/signals'; import type { Signal } from '../../../util/signals';
import type { PinnedIntersectionChangedCallback } from '../hooks/usePinnedMessage'; import type { OnIntersectPinnedMessage } from '../hooks/usePinnedMessage';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
import { AudioOrigin } from '../../../types'; import { AudioOrigin } from '../../../types';
@ -206,7 +212,7 @@ type OwnProps =
isJustAdded: boolean; isJustAdded: boolean;
memoFirstUnreadIdRef: { current: number | undefined }; memoFirstUnreadIdRef: { current: number | undefined };
getIsMessageListReady: Signal<boolean>; getIsMessageListReady: Signal<boolean>;
onPinnedIntersectionChange: PinnedIntersectionChangedCallback; onIntersectPinnedMessage: OnIntersectPinnedMessage;
} }
& MessagePositionProperties; & MessagePositionProperties;
@ -408,7 +414,7 @@ const Message: FC<OwnProps & StateProps> = ({
canTranscribeVoice, canTranscribeVoice,
viaBusinessBot, viaBusinessBot,
effect, effect,
onPinnedIntersectionChange, onIntersectPinnedMessage,
}) => { }) => {
const { const {
toggleMessageSelection, toggleMessageSelection,
@ -479,14 +485,12 @@ const Message: FC<OwnProps & StateProps> = ({
id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck, id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck,
} = message; } = message;
useEffect(() => { useUnmountCleanup(() => {
if (!isPinned) return undefined; if (message.isPinned) {
const id = album ? album.mainMessage.id : messageId; const id = album ? album.mainMessage.id : messageId;
onIntersectPinnedMessage({ viewportPinnedIdsToRemove: [id] });
return () => { }
onPinnedIntersectionChange({ viewportPinnedIdsToRemove: [id], isUnmount: true }); });
};
}, [album, isPinned, messageId, onPinnedIntersectionChange]);
const isLocal = isMessageLocal(message); const isLocal = isMessageLocal(message);
const isOwn = isOwnMessage(message); const isOwn = isOwnMessage(message);
@ -1050,7 +1054,7 @@ const Message: FC<OwnProps & StateProps> = ({
noMediaCorners && 'no-media-corners', noMediaCorners && 'no-media-corners',
); );
const hasCustomAppendix = isLastInGroup const hasCustomAppendix = isLastInGroup
&& (!hasText || (isInvertedMedia && !hasFactCheck && !hasReactions)) && !asForwarded && !withCommentButton; && (!hasText || (isInvertedMedia && !hasFactCheck && !hasReactions)) && !asForwarded && !withCommentButton;
const textContentClass = buildClassName( const textContentClass = buildClassName(
'text-content', 'text-content',
'clearfix', 'clearfix',