Middle Column: Better pinned message animation (#2716)

Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
This commit is contained in:
Alexander Zinchuk 2023-03-30 18:26:14 -05:00
parent 2d49580287
commit d965b6c479
37 changed files with 1304 additions and 709 deletions

View File

@ -162,7 +162,7 @@ type UniversalMessage = (
& Pick<Partial<GramJs.Message & GramJs.MessageService>, ( & Pick<Partial<GramJs.Message & GramJs.MessageService>, (
'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' | 'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' |
'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' | 'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' |
'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' | 'forwards' | 'silent' 'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' | 'forwards' | 'silent' | 'pinned'
)> )>
); );
@ -213,6 +213,7 @@ export function buildApiMessageWithChatId(
forwards: mtpMessage.forwards, forwards: mtpMessage.forwards,
isFromScheduled: mtpMessage.fromScheduled, isFromScheduled: mtpMessage.fromScheduled,
isSilent: mtpMessage.silent, isSilent: mtpMessage.silent,
isPinned: mtpMessage.pinned,
reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions),
emojiOnlyCount, emojiOnlyCount,
...(replyToMsgId && { replyToMessageId: replyToMsgId }), ...(replyToMsgId && { replyToMessageId: replyToMsgId }),

View File

@ -423,6 +423,7 @@ export interface ApiMessage {
isHideKeyboardSelective?: boolean; isHideKeyboardSelective?: boolean;
isFromScheduled?: boolean; isFromScheduled?: boolean;
isSilent?: boolean; isSilent?: boolean;
isPinned?: boolean;
seenByUserIds?: string[]; seenByUserIds?: string[];
isProtected?: boolean; isProtected?: boolean;
isForwardingAllowed?: boolean; isForwardingAllowed?: boolean;

Binary file not shown.

Binary file not shown.

View File

@ -1,12 +1,12 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { useMemo, useRef } from '../../lib/teact/teact'; import React, {
useEffect, useMemo, useRef,
} from '../../lib/teact/teact';
import { getGlobal } from '../../global'; import { getGlobal } from '../../global';
import { ANIMATION_LEVEL_MAX } from '../../config'; import { ANIMATION_LEVEL_MAX } from '../../config';
import usePrevious from '../../hooks/usePrevious';
import useForceUpdate from '../../hooks/useForceUpdate';
import useTimeout from '../../hooks/useTimeout';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag';
import styles from './AnimatedCounter.module.scss'; import styles from './AnimatedCounter.module.scss';
@ -14,22 +14,25 @@ type OwnProps = {
text: string; text: string;
}; };
const ANIMATION_TIME = 200;
const AnimatedCounter: FC<OwnProps> = ({ const AnimatedCounter: FC<OwnProps> = ({
text, text,
}) => { }) => {
const lang = useLang(); const lang = useLang();
const prevText = usePrevious(text); const prevTextRef = useRef<string>();
const forceUpdate = useForceUpdate(); const [isAnimating, markAnimating, unmarkAnimating] = useFlag(false);
const isAnimatingRef = useRef(false);
const shouldAnimate = getGlobal().settings.byKey.animationLevel === ANIMATION_LEVEL_MAX; const shouldAnimate = getGlobal().settings.byKey.animationLevel === ANIMATION_LEVEL_MAX;
const textElement = useMemo(() => { const textElement = useMemo(() => {
if (!shouldAnimate) return text; if (!shouldAnimate) {
return text;
}
if (!isAnimating) {
return prevTextRef.current || text;
}
const prevText = prevTextRef.current;
const elements = []; const elements = [];
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
@ -37,8 +40,8 @@ const AnimatedCounter: FC<OwnProps> = ({
elements.push( elements.push(
<div className={styles.characterContainer}> <div className={styles.characterContainer}>
<div className={styles.character}>{text[i]}</div> <div className={styles.character}>{text[i]}</div>
<div className={styles.characterOld}>{prevText[i]}</div> <div className={styles.characterOld} onAnimationEnd={unmarkAnimating}>{prevText[i]}</div>
<div className={styles.characterNew}>{text[i]}</div> <div className={styles.characterNew} onAnimationEnd={unmarkAnimating}>{text[i]}</div>
</div>, </div>,
); );
} else { } else {
@ -46,15 +49,14 @@ const AnimatedCounter: FC<OwnProps> = ({
} }
} }
isAnimatingRef.current = true; prevTextRef.current = text;
return elements; return elements;
}, [prevText, shouldAnimate, text]); }, [shouldAnimate, isAnimating, text]);
useTimeout(() => { useEffect(() => {
isAnimatingRef.current = false; markAnimating();
forceUpdate(); }, [text]);
}, isAnimatingRef.current ? ANIMATION_TIME : undefined);
return ( return (
<span className={styles.root} dir={lang.isRtl ? 'rtl' : undefined}> <span className={styles.root} dir={lang.isRtl ? 'rtl' : undefined}>

View File

@ -17,9 +17,8 @@ import {
selectCurrentMediaSearch, selectTabState, selectCurrentMediaSearch, selectTabState,
selectIsChatWithSelf, selectIsChatWithSelf,
selectListedIds, selectListedIds,
selectOutlyingIds,
selectScheduledMessage, selectScheduledMessage,
selectUser, selectUser, selectOutlyingListByMessageId,
} from '../../global/selectors'; } from '../../global/selectors';
import { stopCurrentAudio } from '../../util/audioPlayer'; import { stopCurrentAudio } from '../../util/audioPlayer';
import captureEscKeyListener from '../../util/captureEscKeyListener'; import captureEscKeyListener from '../../util/captureEscKeyListener';
@ -462,9 +461,9 @@ export default memo(withGlobal(
let collectionIds: number[] | undefined; let collectionIds: number[] | undefined;
if (origin === MediaViewerOrigin.Inline if (origin === MediaViewerOrigin.Inline
|| origin === MediaViewerOrigin.Album || origin === MediaViewerOrigin.Album) {
|| origin === MediaViewerOrigin.SuggestedAvatar) { collectionIds = selectOutlyingListByMessageId(global, chatId, threadId, message.id)
collectionIds = selectOutlyingIds(global, chatId, threadId) || selectListedIds(global, chatId, threadId); || selectListedIds(global, chatId, threadId);
} else if (origin === MediaViewerOrigin.SharedMedia) { } else if (origin === MediaViewerOrigin.SharedMedia) {
const currentSearch = selectCurrentMediaSearch(global); const currentSearch = selectCurrentMediaSearch(global);
const { foundIds } = (currentSearch && currentSearch.resultsByType && currentSearch.resultsByType.media) || {}; const { foundIds } = (currentSearch && currentSearch.resultsByType && currentSearch.resultsByType.media) || {};

View File

@ -8,6 +8,7 @@ import type {
ApiUser, ApiMessage, ApiChat, ApiSticker, ApiTopic, ApiUser, ApiMessage, ApiChat, ApiSticker, ApiTopic,
} from '../../api/types'; } from '../../api/types';
import type { FocusDirection } from '../../types'; import type { FocusDirection } from '../../types';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import { import {
selectUser, selectUser,
@ -45,6 +46,7 @@ type OwnProps = {
isLastInList?: boolean; isLastInList?: boolean;
isInsideTopic?: boolean; isInsideTopic?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined }; memoFirstUnreadIdRef?: { current: number | undefined };
onPinnedIntersectionChange?: PinnedIntersectionChangedCallback;
}; };
type StateProps = { type StateProps = {
@ -84,6 +86,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
observeIntersectionForReading, observeIntersectionForReading,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
onPinnedIntersectionChange,
}) => { }) => {
const { openPremiumModal, requestConfetti } = getActions(); const { openPremiumModal, requestConfetti } = getActions();
@ -96,6 +99,14 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
useEnsureMessage(message.chatId, message.replyToMessageId, targetMessage); useEnsureMessage(message.chatId, message.replyToMessageId, targetMessage);
useFocusMessage(ref, message.chatId, isFocused, focusDirection, noFocusHighlight); useFocusMessage(ref, message.chatId, isFocused, focusDirection, noFocusHighlight);
useEffect(() => {
if (!message.isPinned) return undefined;
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);
const isGift = Boolean(message.content.action?.text.startsWith('ActionGift')); const isGift = Boolean(message.content.action?.text.startsWith('ActionGift'));
@ -209,6 +220,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
id={getMessageHtmlId(message.id)} id={getMessageHtmlId(message.id)}
className={className} className={className}
data-message-id={message.id} data-message-id={message.id}
data-is-pinned={message.isPinned || undefined}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >

View File

@ -0,0 +1,269 @@
@import "../../styles/mixins";
.root {
display: flex;
align-items: center;
margin-left: auto;
cursor: default;
flex-direction: row-reverse;
background: var(--color-background);
:global {
.Button {
margin-left: 0.25rem;
&.tiny {
margin-right: 0.625rem;
}
}
}
:global(body.animation-level-1) & {
:global(.ripple-container) {
display: none;
}
}
:global(body.animation-level-0) & {
transition: none !important;
}
@media (min-width: 1276px) {
transform: translate3d(0, 0, 0);
transition: opacity 0.15s ease, transform var(--layer-transition);
:global(#Main.right-column-open) & {
transform: translate3d(calc(var(--right-column-width) * -1), 0, 0);
}
}
> :global(.Button) {
flex-shrink: 0;
}
}
.root:global(.full-width) {
position: absolute;
left: 0;
right: 0;
top: 100%;
background: var(--color-background);
padding: 0.25rem 0.8125rem 0.25rem 1rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
transform: translate3d(0, 0, 0);
transition: opacity 0.15s ease, transform var(--layer-transition);
&::before {
content: "";
display: block;
position: absolute;
top: -0.1875rem;
left: 0;
right: 0;
height: 0.125rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
}
.pinnedMessage {
margin-top: 0;
margin-bottom: 0;
flex: 1;
}
.messageText {
max-width: none;
}
@media (min-width: 1276px) {
transform: translate3d(0, 0, 0);
transition: opacity 0.15s ease, transform var(--layer-transition);
:global(#Main.right-column-open) & {
padding-left: calc(var(--right-column-width) + 1rem);
}
}
}
.loading {
--spinner-size: 1.5rem;
}
.pinListIcon {
position: absolute;
transition: 0.25s ease-in-out opacity, 0.25s ease-in-out transform;
}
.pinListIconHidden {
opacity: 0;
transform: scale(0.6);
}
.pinnedMessage {
display: flex;
flex-shrink: 1;
margin-top: -0.25rem;
margin-bottom: -0.25rem;
padding: 0.25rem;
padding-left: 0.375rem;
border-radius: var(--border-radius-messages-small);
position: relative;
overflow: hidden;
cursor: pointer;
align-items: center;
&:hover:not(.no-hover) {
background-color: var(--color-interactive-element-hover);
}
}
.messageTextTransition {
height: 1.125rem;
width: 100%;
overflow: hidden;
}
.messageText {
overflow: hidden;
margin-inline-start: 0.375rem;
margin-top: 0.125rem;
max-width: 15rem;
min-width: 15rem;
flex-grow: 1;
transition: 0.25s ease-in-out transform;
&.withMedia {
transform: translateX(2.625rem);
margin-right: 2.625rem;
max-width: calc(15rem - 2.625rem);
min-width: calc(15rem - 2.625rem);
}
:global(.emoji-small) {
width: 1rem;
height: 1rem;
}
@media (min-width: 1440px) and (max-width: 1500px) {
max-width: 14rem;
}
}
.title {
font-weight: 500;
font-size: 0.875rem;
line-height: 1rem;
height: 1rem;
color: var(--color-primary);
margin-bottom: 0.125rem;
white-space: pre;
text-align: initial;
:global(body.is-ios) & {
font-size: 0.9375rem;
}
}
.summary {
font-size: 0.875rem;
line-height: 1.125rem;
height: 1.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
:global(body.is-ios) & {
font-size: 0.9375rem;
}
}
.inlineButton {
display: block;
width: auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 1.5rem;
padding: 0 0.75rem;
font-weight: 500;
text-transform: none;
height: 2rem;
max-width: 10rem;
flex-shrink: 1;
}
.pictogramTransition {
position: absolute;
width: 2.25rem !important;
height: 2.25rem;
margin-inline-start: 0.5rem;
margin-top: 0.125rem;
overflow: hidden;
}
.pinnedThumb {
width: 100%;
height: 100%;
flex-shrink: 0;
border-radius: 0.25rem;
overflow: hidden;
& + .messageText {
max-width: 12rem;
}
}
.pinnedThumbImage {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 600px) {
.pinnedMessage {
flex-grow: 1;
padding-top: 0;
padding-bottom: 0;
max-width: unset;
margin-top: -0.1875rem;
&::before {
top: 0.125rem;
bottom: 0.125rem;
}
.messageText {
max-width: none;
}
}
.root:global(.full-width) {
display: none;
}
.root {
@include header-mobile();
}
}
@media (min-width: 1276px) and (max-width: 1439px) {
:global(:not(.tools-stacked)) .root {
opacity: 1;
:global(#Main.right-column-open) & {
opacity: 0;
}
}
}
:global(.tools-stacked.animated) .root {
animation: fade-in var(--layer-transition) forwards;
:global(body.animation-level-0) & {
animation: none;
}
}

View File

@ -17,6 +17,7 @@ import useMedia from '../../hooks/useMedia';
import useThumbnail from '../../hooks/useThumbnail'; import useThumbnail from '../../hooks/useThumbnail';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useAsyncRendering from '../right/hooks/useAsyncRendering';
import RippleEffect from '../ui/RippleEffect'; import RippleEffect from '../ui/RippleEffect';
import ConfirmDialog from '../ui/ConfirmDialog'; import ConfirmDialog from '../ui/ConfirmDialog';
@ -24,7 +25,13 @@ import Button from '../ui/Button';
import PinnedMessageNavigation from './PinnedMessageNavigation'; import PinnedMessageNavigation from './PinnedMessageNavigation';
import MessageSummary from '../common/MessageSummary'; import MessageSummary from '../common/MessageSummary';
import MediaSpoiler from '../common/MediaSpoiler'; import MediaSpoiler from '../common/MediaSpoiler';
import AnimatedCounter from '../common/AnimatedCounter';
import Transition from '../ui/Transition';
import Spinner from '../ui/Spinner';
import styles from './HeaderPinnedMessage.module.scss';
const SHOW_LOADER_DELAY = 450;
type OwnProps = { type OwnProps = {
message: ApiMessage; message: ApiMessage;
index: number; index: number;
@ -34,17 +41,22 @@ type OwnProps = {
onUnpinMessage?: (id: number) => void; onUnpinMessage?: (id: number) => void;
onClick?: () => void; onClick?: () => void;
onAllPinnedClick?: () => void; onAllPinnedClick?: () => void;
isLoading?: 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,
}) => { }) => {
const { clickBotInlineButton } = getActions(); const { clickBotInlineButton } = getActions();
const lang = useLang(); const lang = useLang();
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 canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY);
const shouldShowLoader = canRenderLoader && isLoading;
const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag(); const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag();
@ -66,18 +78,44 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
const [noHoverColor, markNoHoverColor, unmarkNoHoverColor] = useFlag(); const [noHoverColor, markNoHoverColor, unmarkNoHoverColor] = useFlag();
function renderPictogram(thumbDataUri?: string, blobUrl?: string, spoiler?: boolean) {
const { width, height } = getPictogramDimensions();
const srcUrl = blobUrl || thumbDataUri;
return (
<div className={styles.pinnedThumb}>
{thumbDataUri && !spoiler
&& <img className={styles.pinnedThumbImage} src={srcUrl} width={width} height={height} alt="" />}
{thumbDataUri
&& <MediaSpoiler thumbDataUri={srcUrl} isVisible={Boolean(spoiler)} width={width} height={height} />}
</div>
);
}
return ( return (
<div className={buildClassName('HeaderPinnedMessage-wrapper', className)}> <div className={buildClassName(
{count > 1 && ( 'HeaderPinnedMessageWrapper', styles.root, isFullWidth && 'full-width', className,
)}
>
{(count > 1 || shouldShowLoader) && (
<Button <Button
round round
size="smaller" size="smaller"
color="translucent" color="translucent"
className="pin-list-button"
ariaLabel={lang('EventLogFilterPinnedMessages')} ariaLabel={lang('EventLogFilterPinnedMessages')}
onClick={onAllPinnedClick} onClick={!shouldShowLoader ? onAllPinnedClick : undefined}
> >
<i className="icon-pin-list" /> <Spinner
color="blue"
className={buildClassName(
styles.loading, styles.pinListIcon, !shouldShowLoader && styles.pinListIconHidden,
)}
/>
<i
className={buildClassName(
'icon-pin-list', styles.pinListIcon, shouldShowLoader && styles.pinListIconHidden,
)}
/>
</Button> </Button>
)} )}
{onUnpinMessage && ( {onUnpinMessage && (
@ -86,7 +124,6 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
size="smaller" size="smaller"
color="translucent" color="translucent"
ariaLabel={lang('UnpinMessageAlertTitle')} ariaLabel={lang('UnpinMessageAlertTitle')}
className="unpin-button"
onClick={openUnpinDialog} onClick={openUnpinDialog}
> >
<i className="icon-close" /> <i className="icon-close" />
@ -100,7 +137,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
confirmHandler={handleUnpinMessage} confirmHandler={handleUnpinMessage}
/> />
<div <div
className={buildClassName('HeaderPinnedMessage', noHoverColor && 'no-hover')} className={buildClassName(styles.pinnedMessage, noHoverColor && styles.noHover)}
onClick={onClick} onClick={onClick}
dir={lang.isRtl ? 'rtl' : undefined} dir={lang.isRtl ? 'rtl' : undefined}
> >
@ -108,20 +145,32 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
count={count} count={count}
index={index} index={index}
/> />
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isSpoiler)} <Transition activeKey={message.id} name="slide-vertical" className={styles.pictogramTransition}>
<div className="message-text"> {renderPictogram(
<div className="title" dir="auto"> mediaThumbnail,
{customTitle ? renderText(customTitle) : `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`} mediaBlobUrl,
isSpoiler,
)}
</Transition>
<div className={buildClassName(styles.messageText, mediaThumbnail && styles.withMedia)}>
<div className={styles.title} dir="auto">
{!customTitle && (
<AnimatedCounter text={`${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`} />
)}
{customTitle && renderText(customTitle)}
</div> </div>
<p dir="auto"> <Transition activeKey={message.id} name="slide-vertical-fade" className={styles.messageTextTransition}>
<MessageSummary lang={lang} message={message} noEmoji={Boolean(mediaThumbnail)} /> <p dir="auto" className={styles.summary}>
</p> <MessageSummary lang={lang} message={message} noEmoji={Boolean(mediaThumbnail)} />
<RippleEffect /> </p>
</Transition>
</div> </div>
<RippleEffect />
{inlineButton && ( {inlineButton && (
<Button <Button
size="tiny" size="tiny"
className="inline-button" className={styles.inlineButton}
onClick={handleInlineButtonClick} onClick={handleInlineButtonClick}
shouldStopPropagation shouldStopPropagation
onMouseEnter={!IS_TOUCH_ENV ? markNoHoverColor : undefined} onMouseEnter={!IS_TOUCH_ENV ? markNoHoverColor : undefined}
@ -135,16 +184,4 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
); );
}; };
function renderPictogram(thumbDataUri: string, blobUrl?: string, isSpoiler?: boolean) {
const { width, height } = getPictogramDimensions();
const srcUrl = blobUrl || thumbDataUri;
return (
<div className="pinned-thumb">
{!isSpoiler && <img className="pinned-thumb-image" src={srcUrl} width={width} height={height} alt="" />}
<MediaSpoiler thumbDataUri={srcUrl} isVisible={Boolean(isSpoiler)} width={width} height={height} />
</div>
);
}
export default memo(HeaderPinnedMessage); export default memo(HeaderPinnedMessage);

View File

@ -10,6 +10,8 @@ import type {
import { MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
import type { MessageListType } from '../../global/types'; import type { MessageListType } from '../../global/types';
import type { AnimationLevel } from '../../types'; import type { AnimationLevel } from '../../types';
import type { Signal } from '../../util/signals';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import { LoadMoreDirection } from '../../types'; import { LoadMoreDirection } from '../../types';
import { ANIMATION_END_DELAY, LOCAL_MESSAGE_MIN_ID, MESSAGE_LIST_SLICE } from '../../config'; import { ANIMATION_END_DELAY, LOCAL_MESSAGE_MIN_ID, MESSAGE_LIST_SLICE } from '../../config';
@ -28,7 +30,10 @@ import {
selectFirstMessageId, selectFirstMessageId,
selectChatScheduledMessages, selectChatScheduledMessages,
selectCurrentMessageIds, selectCurrentMessageIds,
selectIsCurrentUserPremium, selectLastScrollOffset, selectThreadInfo, selectIsCurrentUserPremium,
selectLastScrollOffset,
selectThreadInfo,
selectTabState,
} from '../../global/selectors'; } from '../../global/selectors';
import { import {
isChatChannel, isChatChannel,
@ -82,6 +87,8 @@ type OwnProps = {
hasTools?: boolean; hasTools?: boolean;
withBottomShift?: boolean; withBottomShift?: boolean;
withDefaultBg: boolean; withDefaultBg: boolean;
onPinnedIntersectionChange: PinnedIntersectionChangedCallback;
getForceNextPinnedInHeader: Signal<boolean | undefined>;
}; };
type StateProps = { type StateProps = {
@ -162,6 +169,8 @@ const MessageList: FC<OwnProps & StateProps> = ({
withBottomShift, withBottomShift,
withDefaultBg, withDefaultBg,
topic, topic,
onPinnedIntersectionChange,
getForceNextPinnedInHeader,
}) => { }) => {
const { const {
loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds,
@ -309,6 +318,12 @@ const MessageList: FC<OwnProps & StateProps> = ({
} }
runDebouncedForScroll(() => { runDebouncedForScroll(() => {
const global = getGlobal();
const forceNextPinnedInHeader = getForceNextPinnedInHeader() && !selectTabState(global).focusedMessage?.chatId;
if (forceNextPinnedInHeader) {
onPinnedIntersectionChange({ hasScrolled: true });
}
isScrollingRef.current = false; isScrollingRef.current = false;
fastRaf(() => { fastRaf(() => {
@ -323,7 +338,9 @@ const MessageList: FC<OwnProps & StateProps> = ({
} }
}); });
}); });
}, [updateStickyDates, hasTools, type, setScrollOffset, chatId, threadId]); }, [
updateStickyDates, hasTools, getForceNextPinnedInHeader, onPinnedIntersectionChange, type, chatId, threadId,
]);
// Container resize observer (caused by Composer reply/webpage panels) // Container resize observer (caused by Composer reply/webpage panels)
const handleResize = useCallback((entry: ResizeObserverEntry) => { const handleResize = useCallback((entry: ResizeObserverEntry) => {
@ -643,6 +660,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current} noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current}
onFabToggle={onFabToggle} onFabToggle={onFabToggle}
onNotchToggle={onNotchToggle} onNotchToggle={onNotchToggle}
onPinnedIntersectionChange={onPinnedIntersectionChange}
/> />
) : ( ) : (
<Loading color="white" backgroundColor="dark" /> <Loading color="white" backgroundColor="dark" />

View File

@ -4,6 +4,7 @@ import React, { memo } from '../../lib/teact/teact';
import { getActions } from '../../global'; import { getActions } from '../../global';
import type { MessageListType } from '../../global/types'; import type { MessageListType } from '../../global/types';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import { SCHEDULED_WHEN_ONLINE } from '../../config'; import { SCHEDULED_WHEN_ONLINE } from '../../config';
import { MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
@ -50,6 +51,7 @@ interface OwnProps {
noAppearanceAnimation: boolean; noAppearanceAnimation: boolean;
onFabToggle: AnyToVoidFunction; onFabToggle: AnyToVoidFunction;
onNotchToggle: AnyToVoidFunction; onNotchToggle: AnyToVoidFunction;
onPinnedIntersectionChange: PinnedIntersectionChangedCallback;
} }
const UNREAD_DIVIDER_CLASS = 'unread-divider'; const UNREAD_DIVIDER_CLASS = 'unread-divider';
@ -80,6 +82,7 @@ const MessageListContent: FC<OwnProps> = ({
noAppearanceAnimation, noAppearanceAnimation,
onFabToggle, onFabToggle,
onNotchToggle, onNotchToggle,
onPinnedIntersectionChange,
}) => { }) => {
const { openHistoryCalendar } = getActions(); const { openHistoryCalendar } = getActions();
@ -87,7 +90,7 @@ const MessageListContent: FC<OwnProps> = ({
observeIntersectionForReading, observeIntersectionForReading,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
} = useMessageObservers(type, containerRef, memoFirstUnreadIdRef); } = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onPinnedIntersectionChange);
const { const {
backwardsTriggerRef, backwardsTriggerRef,
@ -154,6 +157,7 @@ const MessageListContent: FC<OwnProps> = ({
memoFirstUnreadIdRef={memoFirstUnreadIdRef} memoFirstUnreadIdRef={memoFirstUnreadIdRef}
appearanceOrder={messageCountToAnimate - ++appearanceIndex} appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isLastInList={isLastInList} isLastInList={isLastInList}
onPinnedIntersectionChange={onPinnedIntersectionChange}
/>, />,
]); ]);
} }
@ -222,6 +226,7 @@ const MessageListContent: FC<OwnProps> = ({
isLastInDocumentGroup={position.isLastInDocumentGroup} isLastInDocumentGroup={position.isLastInDocumentGroup}
isLastInList={position.isLastInList} isLastInList={position.isLastInList}
memoFirstUnreadIdRef={memoFirstUnreadIdRef} memoFirstUnreadIdRef={memoFirstUnreadIdRef}
onPinnedIntersectionChange={onPinnedIntersectionChange}
/>, />,
message.id === threadTopMessageId && ( message.id === threadTopMessageId && (
<div className="local-action-message" key="discussion-started"> <div className="local-action-message" key="discussion-started">

View File

@ -66,6 +66,7 @@ import usePrevious from '../../hooks/usePrevious';
import useForceUpdate from '../../hooks/useForceUpdate'; import useForceUpdate from '../../hooks/useForceUpdate';
import useSyncEffect from '../../hooks/useSyncEffect'; import useSyncEffect from '../../hooks/useSyncEffect';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import usePinnedMessage from './hooks/usePinnedMessage';
import Transition from '../ui/Transition'; import Transition from '../ui/Transition';
import MiddleHeader from './MiddleHeader'; import MiddleHeader from './MiddleHeader';
@ -130,6 +131,7 @@ type StateProps = {
shouldJoinToSend?: boolean; shouldJoinToSend?: boolean;
shouldSendJoinRequest?: boolean; shouldSendJoinRequest?: boolean;
lastSyncTime?: number; lastSyncTime?: number;
pinnedIds?: number[];
}; };
function isImage(item: DataTransferItem) { function isImage(item: DataTransferItem) {
@ -177,6 +179,7 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
shouldSendJoinRequest, shouldSendJoinRequest,
shouldLoadFullChat, shouldLoadFullChat,
lastSyncTime, lastSyncTime,
pinnedIds,
}) => { }) => {
const { const {
openChat, openChat,
@ -350,6 +353,14 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
const customBackgroundValue = useCustomBackground(theme, customBackground); const customBackgroundValue = useCustomBackground(theme, customBackground);
const {
onIntersectionChanged,
onFocusPinnedMessage,
getCurrentPinnedIndexes,
getLoadingPinnedId,
getForceNextPinnedInHeader,
} = usePinnedMessage(chatId, threadId, pinnedIds);
const className = buildClassName( const className = buildClassName(
renderingHasTools && 'has-header-tools', renderingHasTools && 'has-header-tools',
MASK_IMAGE_DISABLED ? 'mask-image-disabled' : 'mask-image-enabled', MASK_IMAGE_DISABLED ? 'mask-image-disabled' : 'mask-image-enabled',
@ -444,6 +455,9 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
messageListType={renderingMessageListType} messageListType={renderingMessageListType}
isReady={isReady} isReady={isReady}
isMobile={isMobile} isMobile={isMobile}
getCurrentPinnedIndexes={getCurrentPinnedIndexes}
getLoadingPinnedId={getLoadingPinnedId}
onFocusPinnedMessage={onFocusPinnedMessage}
/> />
<Transition <Transition
name={shouldSkipHistoryAnimations ? 'none' : animationLevel === ANIMATION_LEVEL_MAX ? 'slide' : 'fade'} name={shouldSkipHistoryAnimations ? 'none' : animationLevel === ANIMATION_LEVEL_MAX ? 'slide' : 'fade'}
@ -464,6 +478,8 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
isReady={isReady} isReady={isReady}
withBottomShift={withMessageListBottomShift} withBottomShift={withMessageListBottomShift}
withDefaultBg={Boolean(!customBackground && !backgroundColor)} withDefaultBg={Boolean(!customBackground && !backgroundColor)}
onPinnedIntersectionChange={onIntersectionChanged}
getForceNextPinnedInHeader={getForceNextPinnedInHeader}
/> />
<div className={footerClassName}> <div className={footerClassName}>
{renderingCanPost && ( {renderingCanPost && (
@ -698,6 +714,7 @@ export default memo(withGlobal<OwnProps>(
shouldJoinToSend, shouldJoinToSend,
shouldSendJoinRequest, shouldSendJoinRequest,
shouldLoadFullChat, shouldLoadFullChat,
pinnedIds,
}; };
}, },
)(MiddleColumn)); )(MiddleColumn));

View File

@ -1,47 +1,9 @@
@import "../../styles/mixins"; @import "../../styles/mixins";
@mixin mobile-header-styles() { @mixin mobile-header-styles() {
.HeaderPinnedMessage-wrapper,
.AudioPlayer { .AudioPlayer {
position: absolute; @include header-mobile;
top: 100%;
left: 0;
right: 0;
height: 2.875rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
display: flex;
flex-direction: row-reverse;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.5rem, env(safe-area-inset-right));
background: var(--color-background);
// Target: Old Firefox (Waterfox Classic)
@supports not (padding-left: max(0.75rem, env(safe-area-inset-left))) {
padding-left: 0.75rem;
padding-right: 0.5rem;
}
&::before {
content: "";
display: block;
position: absolute;
top: -0.1875rem;
left: 0;
right: 0;
height: 0.125rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
}
.HeaderPinnedMessage {
max-width: unset;
margin-top: -0.1875rem;
}
}
.AudioPlayer {
flex-direction: row; flex-direction: row;
margin-top: 0; margin-top: 0;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
@ -59,25 +21,6 @@
margin-left: auto; margin-left: auto;
} }
} }
.HeaderPinnedMessage {
flex-grow: 1;
padding-top: 0;
padding-bottom: 0;
&::before {
top: 0.125rem;
bottom: 0.125rem;
}
.message-text {
max-width: none;
}
}
.HeaderPinnedMessage-wrapper.full-width {
display: none;
}
} }
.MiddleHeader { .MiddleHeader {
@ -162,10 +105,6 @@
overflow: hidden; overflow: hidden;
} }
body.animation-level-0 & .HeaderPinnedMessage-wrapper {
transition: none !important;
}
.header-tools { .header-tools {
display: flex; display: flex;
align-items: center; align-items: center;
@ -208,7 +147,6 @@
} }
@media (min-width: 1276px) and (max-width: 1439px) { @media (min-width: 1276px) and (max-width: 1439px) {
&:not(.tools-stacked) .HeaderPinnedMessage-wrapper,
&:not(.tools-stacked) .AudioPlayer { &:not(.tools-stacked) .AudioPlayer {
opacity: 1; opacity: 1;
@ -228,7 +166,6 @@
} }
} }
&.tools-stacked.animated .HeaderPinnedMessage-wrapper,
&.tools-stacked.animated .AudioPlayer { &.tools-stacked.animated .AudioPlayer {
animation: fade-in var(--layer-transition) forwards; animation: fade-in var(--layer-transition) forwards;
@ -383,213 +320,6 @@
height: 2.5rem; height: 2.5rem;
} }
.HeaderPinnedMessage-wrapper {
display: flex;
align-items: center;
margin-left: auto;
cursor: default;
flex-direction: row-reverse;
background: var(--color-background);
body.animation-level-1 & {
.ripple-container {
display: none;
}
}
@media (min-width: 1276px) {
transform: translate3d(0, 0, 0);
transition: opacity 0.15s ease, transform var(--layer-transition);
#Main.right-column-open & {
transform: translate3d(calc(var(--right-column-width) * -1), 0, 0);
}
}
> .Button {
flex-shrink: 0;
}
&.full-width {
position: absolute;
left: 0;
right: 0;
top: 100%;
background: var(--color-background);
padding: 0.25rem 0.8125rem 0.25rem 1rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
transform: translate3d(0, 0, 0);
transition: opacity 0.15s ease, transform var(--layer-transition);
&::before {
content: "";
display: block;
position: absolute;
top: -0.1875rem;
left: 0;
right: 0;
height: 0.125rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
}
.HeaderPinnedMessage {
margin-top: 0;
margin-bottom: 0;
flex: 1;
.message-text {
max-width: none;
}
}
@media (min-width: 1276px) {
transform: translate3d(0, 0, 0);
transition: opacity 0.15s ease, transform var(--layer-transition);
#Main.right-column-open & {
padding-left: calc(var(--right-column-width) + 1rem);
}
}
}
}
.HeaderPinnedMessage {
display: flex;
flex-shrink: 1;
margin-top: -0.25rem;
margin-bottom: -0.25rem;
padding: 0.25rem;
padding-left: 0.375rem;
border-radius: var(--border-radius-messages-small);
position: relative;
overflow: hidden;
cursor: pointer;
align-items: center;
&:hover:not(.no-hover) {
background-color: var(--color-interactive-element-hover);
}
.pinned-message-border {
position: relative;
height: 2.25rem;
margin: 0.125rem 0;
width: 0.125rem;
min-width: 0.125rem;
overflow: hidden;
.pinned-message-border-wrapper-1 {
height: 2.25rem;
width: 0.125rem;
border-radius: 0.0625rem;
background: var(--color-primary);
}
.pinned-message-border-wrapper {
background-color: var(--color-primary-opacity);
position: relative;
will-change: transform;
transition: transform 0.25s ease-in-out;
}
.pinned-message-border-mark {
position: absolute;
left: 0;
top: 0;
width: 0.125rem;
background: var(--color-primary);
border-radius: 0.0625rem;
will-change: transform;
transition: transform 0.25s ease-in-out;
}
}
.message-text {
overflow: hidden;
margin-inline-start: 0.375rem;
margin-top: 0.125rem;
max-width: 15rem;
min-width: 8rem;
flex-grow: 1;
@media (min-width: 1440px) and (max-width: 1500px) {
max-width: 14rem;
}
.title {
font-weight: 500;
font-size: 0.875rem;
line-height: 1rem;
height: 1rem;
color: var(--color-primary);
margin-bottom: 0.125rem;
white-space: pre;
text-align: initial;
body.is-ios & {
font-size: 0.9375rem;
}
}
p {
font-size: 0.875rem;
line-height: 1.125rem;
height: 1.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
body.is-ios & {
font-size: 0.9375rem;
}
}
}
.inline-button {
display: block;
width: auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 1.5rem;
padding: 0 0.75rem;
font-weight: 500;
text-transform: none;
height: 2rem;
max-width: 10rem;
flex-shrink: 1;
}
.emoji-small {
width: 1rem;
height: 1rem;
}
.pinned-thumb {
position: relative;
width: 2.25rem;
height: 2.25rem;
margin-inline-start: 0.375rem;
margin-top: 0.125rem;
flex-shrink: 0;
border-radius: 0.25rem;
overflow: hidden;
& + .message-text {
max-width: 12rem;
}
}
.pinned-thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.HeaderActions { .HeaderActions {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; margin-left: auto;
@ -597,6 +327,14 @@
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
.Button {
margin-left: 0.25rem;
&.tiny {
margin-right: 0.625rem;
}
}
.toggle-right-pane-button { .toggle-right-pane-button {
&.active { &.active {
color: var(--color-primary); color: var(--color-primary);
@ -626,17 +364,6 @@
} }
} }
.HeaderPinnedMessage-wrapper,
.HeaderActions {
.Button {
margin-left: 0.25rem;
&.tiny {
margin-right: 0.625rem;
}
}
}
@media (max-width: 600px) { @media (max-width: 600px) {
@include mobile-header-styles(); @include mobile-header-styles();
} }

View File

@ -1,11 +1,11 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { import React, {
memo, useCallback, useEffect, useRef, useState, memo, useCallback, useEffect, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import cycleRestrict from '../../util/cycleRestrict';
import type { GlobalState, MessageListType } from '../../global/types'; import type { GlobalState, MessageListType } from '../../global/types';
import type { Signal } from '../../util/signals';
import type { import type {
ApiChat, ApiMessage, ApiTypingStatus, ApiUser, ApiChat, ApiMessage, ApiTypingStatus, ApiUser,
} from '../../api/types'; } from '../../api/types';
@ -51,6 +51,7 @@ import useLang from '../../hooks/useLang';
import useConnectionStatus from '../../hooks/useConnectionStatus'; import useConnectionStatus from '../../hooks/useConnectionStatus';
import usePrevious from '../../hooks/usePrevious'; import usePrevious from '../../hooks/usePrevious';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useDerivedState from '../../hooks/useDerivedState';
import PrivateChatInfo from '../common/PrivateChatInfo'; import PrivateChatInfo from '../common/PrivateChatInfo';
import GroupChatInfo from '../common/GroupChatInfo'; import GroupChatInfo from '../common/GroupChatInfo';
@ -75,6 +76,9 @@ type OwnProps = {
messageListType: MessageListType; messageListType: MessageListType;
isReady?: boolean; isReady?: boolean;
isMobile?: boolean; isMobile?: boolean;
getCurrentPinnedIndexes: Signal<Record<string, number>>;
getLoadingPinnedId: Signal<number | undefined>;
onFocusPinnedMessage: (messageId: number) => boolean;
}; };
type StateProps = { type StateProps = {
@ -93,7 +97,6 @@ type StateProps = {
isChatWithSelf?: boolean; isChatWithSelf?: boolean;
lastSyncTime?: number; lastSyncTime?: number;
hasButtonInHeader?: boolean; hasButtonInHeader?: boolean;
hasReachedFocusedMessage?: boolean;
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
currentTransitionKey: number; currentTransitionKey: number;
connectionState?: GlobalState['connectionState']; connectionState?: GlobalState['connectionState'];
@ -124,8 +127,10 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
currentTransitionKey, currentTransitionKey,
connectionState, connectionState,
hasReachedFocusedMessage,
isSyncing, isSyncing,
getCurrentPinnedIndexes,
getLoadingPinnedId,
onFocusPinnedMessage,
}) => { }) => {
const { const {
openChatWithInfo, openChatWithInfo,
@ -133,7 +138,6 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
focusMessage, focusMessage,
openChat, openChat,
openPreviousChat, openPreviousChat,
setReachedFocusedMessage,
loadPinnedMessages, loadPinnedMessages,
toggleLeftColumn, toggleLeftColumn,
exitMessageSelectMode, exitMessageSelectMode,
@ -141,11 +145,12 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
const lang = useLang(); const lang = useLang();
const isBackButtonActive = useRef(true); const isBackButtonActive = useRef(true);
const [isWaitingForPinnedMessageFocus, setWaitingForPinnedMessageFocus] = useState(false);
const { isTablet } = useAppLayout(); const { isTablet } = useAppLayout();
const [pinnedMessageIndex, setPinnedMessageIndex] = useState(0); const currentPinnedIndexes = useDerivedState(getCurrentPinnedIndexes);
const pinnedMessageId = Array.isArray(pinnedMessageIds) ? pinnedMessageIds[pinnedMessageIndex] : pinnedMessageIds; const currentPinnedIndex = currentPinnedIndexes[`${chatId}_${threadId}`] || 0;
const waitingForPinnedId = useDerivedState(getLoadingPinnedId);
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)
? pinnedMessageIds.length : (pinnedMessageIds ? 1 : undefined); ? pinnedMessageIds.length : (pinnedMessageIds ? 1 : undefined);
@ -160,25 +165,6 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
} }
}, [chatId, loadPinnedMessages, lastSyncTime, threadId, isReady, isForum]); }, [chatId, loadPinnedMessages, lastSyncTime, threadId, isReady, isForum]);
// Reset pinned index when switching chats and pinning/unpinning
useEffect(() => {
setPinnedMessageIndex(0);
setWaitingForPinnedMessageFocus(false);
}, [pinnedMessageIds]);
useEffect(() => {
if (hasReachedFocusedMessage && isWaitingForPinnedMessageFocus) {
setReachedFocusedMessage({ hasReached: false });
setWaitingForPinnedMessageFocus(false);
const newIndex = cycleRestrict(pinnedMessagesCount || 1, pinnedMessageIndex + 1);
setPinnedMessageIndex(newIndex);
}
}, [
hasReachedFocusedMessage, isWaitingForPinnedMessageFocus, pinnedMessageIndex, pinnedMessagesCount,
setReachedFocusedMessage,
]);
useEnsureMessage(chatId, pinnedMessageId, pinnedMessage); useEnsureMessage(chatId, pinnedMessageId, pinnedMessage);
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
@ -199,14 +185,14 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
}, [pinMessage]); }, [pinMessage]);
const handlePinnedMessageClick = useCallback((): void => { const handlePinnedMessageClick = useCallback((): void => {
if (pinnedMessage) { if (!pinnedMessage) return;
if (onFocusPinnedMessage(pinnedMessage.id)) {
focusMessage({ focusMessage({
chatId: pinnedMessage.chatId, threadId, messageId: pinnedMessage.id, noForumTopicPanel: true, chatId: pinnedMessage.chatId, threadId, messageId: pinnedMessage.id, noForumTopicPanel: true,
}); });
setWaitingForPinnedMessageFocus(true);
} }
}, [pinnedMessage, focusMessage, threadId]); }, [pinnedMessage, threadId, onFocusPinnedMessage]);
const handleAllPinnedClick = useCallback(() => { const handleAllPinnedClick = useCallback(() => {
openChat({ id: chatId, threadId, type: 'pinned' }); openChat({ id: chatId, threadId, type: 'pinned' });
@ -440,12 +426,14 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
key={chatId} key={chatId}
message={renderingPinnedMessage} message={renderingPinnedMessage}
count={renderingPinnedMessagesCount || 0} count={renderingPinnedMessagesCount || 0}
index={pinnedMessageIndex} index={currentPinnedIndex}
customTitle={renderingPinnedMessageTitle} customTitle={renderingPinnedMessageTitle}
className={buildClassName(pinnedMessageClassNames, isPinnedMessagesFullWidth && 'full-width')} className={pinnedMessageClassNames}
onUnpinMessage={renderingCanUnpin ? handleUnpinMessage : undefined} onUnpinMessage={renderingCanUnpin ? handleUnpinMessage : undefined}
onClick={handlePinnedMessageClick} onClick={handlePinnedMessageClick}
onAllPinnedClick={handleAllPinnedClick} onAllPinnedClick={handleAllPinnedClick}
isLoading={waitingForPinnedId !== undefined}
isFullWidth={isPinnedMessagesFullWidth}
/> />
)} )}
@ -514,7 +502,6 @@ export default memo(withGlobal<OwnProps>(
); );
const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest); const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest);
const typingStatus = selectThreadParam(global, chatId, threadId, 'typingStatus'); const typingStatus = selectThreadParam(global, chatId, threadId, 'typingStatus');
const focusedMessage = selectTabState(global).focusedMessage;
const state: StateProps = { const state: StateProps = {
typingStatus, typingStatus,
@ -531,7 +518,6 @@ export default memo(withGlobal<OwnProps>(
connectionState: global.connectionState, connectionState: global.connectionState,
isSyncing: global.isSyncing, isSyncing: global.isSyncing,
hasButtonInHeader: canStartBot || canRestartBot || canSubscribe || shouldSendJoinRequest, hasButtonInHeader: canStartBot || canRestartBot || canSubscribe || shouldSendJoinRequest,
hasReachedFocusedMessage: !focusedMessage || focusedMessage.hasReachedMessage,
}; };
const messagesById = selectChatMessages(global, chatId); const messagesById = selectChatMessages(global, chatId);

View File

@ -0,0 +1,35 @@
.pinned-message-border {
position: relative;
height: 2.25rem;
margin: 0.125rem 0;
width: 0.125rem;
min-width: 0.125rem;
overflow: hidden;
}
.pinned-message-border-wrapper-1 {
height: 2.25rem;
width: 0.125rem;
border-radius: 0.0625rem;
background: var(--color-primary);
}
.pinned-message-border-wrapper {
background-color: var(--color-primary-opacity);
position: relative;
will-change: transform;
transition: transform 0.25s ease-in-out;
}
.pinned-message-border-mark {
position: absolute;
left: 0;
top: 0;
width: 0.125rem;
background: var(--color-primary);
border-radius: 0.0625rem;
will-change: transform;
transition: transform 0.25s ease-in-out;
transform: translateY(var(--translate-y));
height: var(--height);
}

View File

@ -8,6 +8,8 @@ import React, {
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import styles from './PinnedMessageNavigation.module.scss';
type OwnProps = { type OwnProps = {
count: number; count: number;
index: number; index: number;
@ -66,9 +68,9 @@ const PinnedMessageNavigation: FC<OwnProps> = ({
if (count === 1) { if (count === 1) {
return ( return (
<div className="pinned-message-border"> <div className={styles.pinnedMessageBorder}>
<div <div
className="pinned-message-border-wrapper-1" className={styles.pinnedMessageBorderWrapper1}
ref={containerRef} ref={containerRef}
/> />
</div> </div>
@ -80,9 +82,13 @@ const PinnedMessageNavigation: FC<OwnProps> = ({
} = markupParams; } = markupParams;
return ( return (
<div className={buildClassName('pinned-message-border', count > BORDER_MASK_LEVEL && 'pinned-message-border-mask')}> <div className={buildClassName(
styles.pinnedMessageBorder,
count > BORDER_MASK_LEVEL && styles.pinnedMessageBorderMask,
)}
>
<div <div
className="pinned-message-border-wrapper" className={styles.pinnedMessageBorderWrapper}
ref={containerRef} ref={containerRef}
style={ style={
`clip-path: url("#${clipPathId}"); width: 2px; `clip-path: url("#${clipPathId}"); width: 2px;
@ -91,8 +97,9 @@ const PinnedMessageNavigation: FC<OwnProps> = ({
> >
<span /> <span />
<div <div
className="pinned-message-border-mark" className={styles.pinnedMessageBorderMark}
style={`height: ${markHeight}px; transform: translateY(${markTranslateY}px);`} style={`--height: ${markHeight}px; --translate-y: ${markTranslateY}px; `
+ `--translate-track: ${trackTranslateY}px;`}
/> />
</div> </div>
</div> </div>

View File

@ -2,6 +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 { IS_ANDROID } from '../../../util/windowEnvironment'; import { IS_ANDROID } from '../../../util/windowEnvironment';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
@ -15,8 +16,11 @@ export default function useMessageObservers(
type: MessageListType, type: MessageListType,
containerRef: RefObject<HTMLDivElement>, containerRef: RefObject<HTMLDivElement>,
memoFirstUnreadIdRef: { current: number | undefined }, memoFirstUnreadIdRef: { current: number | undefined },
onPinnedIntersectionChange: PinnedIntersectionChangedCallback,
) { ) {
const { markMessageListRead, markMentionsRead, animateUnreadReaction } = getActions(); const {
markMessageListRead, markMentionsRead, animateUnreadReaction,
} = getActions();
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const INTERSECTION_MARGIN_FOR_LOADING = isMobile ? 300 : 500; const INTERSECTION_MARGIN_FOR_LOADING = isMobile ? 300 : 500;
@ -34,17 +38,28 @@ export default function useMessageObservers(
let maxId = 0; let maxId = 0;
const mentionIds: number[] = []; const mentionIds: number[] = [];
const reactionIds: number[] = []; const reactionIds: number[] = [];
const viewportPinnedIdsToAdd: number[] = [];
const viewportPinnedIdsToRemove: number[] = [];
let isReversed = false;
entries.forEach((entry) => { entries.forEach((entry) => {
const { isIntersecting, target } = entry; const {
isIntersecting, target, boundingClientRect, rootBounds,
} = entry;
const { dataset } = target as HTMLDivElement;
const messageId = Number(dataset.lastMessageId || dataset.messageId);
if (!isIntersecting) { if (!isIntersecting) {
if (dataset.isPinned) {
if (rootBounds && boundingClientRect.bottom < rootBounds.top) {
isReversed = true;
}
viewportPinnedIdsToRemove.push(messageId);
}
return; return;
} }
const { dataset } = target as HTMLDivElement;
const messageId = Number(dataset.lastMessageId || dataset.messageId);
if (messageId > maxId) { if (messageId > maxId) {
maxId = messageId; maxId = messageId;
} }
@ -56,6 +71,10 @@ export default function useMessageObservers(
if (dataset.hasUnreadReaction) { if (dataset.hasUnreadReaction) {
reactionIds.push(messageId); reactionIds.push(messageId);
} }
if (dataset.isPinned) {
viewportPinnedIdsToAdd.push(messageId);
}
}); });
if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) { if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) {
@ -69,6 +88,10 @@ export default function useMessageObservers(
if (reactionIds.length) { if (reactionIds.length) {
animateUnreadReaction({ messageIds: reactionIds }); animateUnreadReaction({ messageIds: reactionIds });
} }
if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) {
onPinnedIntersectionChange({ viewportPinnedIdsToAdd, viewportPinnedIdsToRemove, isReversed });
}
}); });
useBackgroundMode(freezeForReading, unfreezeForReading); useBackgroundMode(freezeForReading, unfreezeForReading);

View File

@ -0,0 +1,170 @@
import { getGlobal } from '../../../global';
import { useCallback, useEffect, useRef } from '../../../lib/teact/teact';
import {
selectFocusedMessageId,
selectListedIds,
selectOutlyingListByMessageId,
} from '../../../global/selectors';
import { unique } from '../../../util/iteratees';
import { clamp } from '../../../util/math';
import cycleRestrict from '../../../util/cycleRestrict';
import useSignal from '../../../hooks/useSignal';
type PinnedIntersectionChangedParams = {
viewportPinnedIdsToAdd?: number[];
viewportPinnedIdsToRemove?: number[];
isReversed?: boolean;
hasScrolled?: boolean;
isUnmount?: boolean;
};
export type PinnedIntersectionChangedCallback = (params: PinnedIntersectionChangedParams) => void;
export default function usePinnedMessage(chatId?: string, threadId?: number, pinnedIds?: number[]) {
const [getCurrentPinnedIndexes, setCurrentPinnedIndexes] = useSignal<Record<string, number>>({});
const [getForceNextPinnedInHeader, setForceNextPinnedInHeader] = useSignal<boolean | undefined>();
const viewportPinnedIdsRef = useRef<number[] | undefined>();
const [getLoadingPinnedId, setLoadingPinnedId] = useSignal<number | undefined>();
const key = chatId ? `${chatId}_${threadId}` : undefined;
// Reset when switching chat
useEffect(() => {
setForceNextPinnedInHeader(undefined);
viewportPinnedIdsRef.current = undefined;
setLoadingPinnedId(undefined);
}, [
chatId, setCurrentPinnedIndexes, setForceNextPinnedInHeader, setLoadingPinnedId, threadId,
]);
useEffect(() => {
if (!key) return;
const currentPinnedIndex = getCurrentPinnedIndexes()[key];
const pinnedLength = pinnedIds?.length || 0;
if (currentPinnedIndex >= pinnedLength) {
setCurrentPinnedIndexes({
...getCurrentPinnedIndexes(),
[key]: Math.max(0, pinnedLength - 1),
});
}
}, [getCurrentPinnedIndexes, key, pinnedIds?.length, setCurrentPinnedIndexes]);
const onIntersectionChanged = useCallback(({
viewportPinnedIdsToAdd = [], viewportPinnedIdsToRemove = [], isReversed, hasScrolled, isUnmount,
}: PinnedIntersectionChangedParams) => {
if (!chatId || !threadId || !key) return;
const global = getGlobal();
const pinnedMessagesCount = pinnedIds?.length || 0;
if (!pinnedMessagesCount || !pinnedIds) return;
const waitingForPinnedId = getLoadingPinnedId();
if (waitingForPinnedId && !hasScrolled) {
const newPinnedIndex = pinnedIds.indexOf(waitingForPinnedId);
setCurrentPinnedIndexes({
...getCurrentPinnedIndexes(),
[key]: newPinnedIndex,
});
setLoadingPinnedId(undefined);
}
if (hasScrolled) {
setForceNextPinnedInHeader(undefined);
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),
);
viewportPinnedIdsRef.current = newPinnedViewportIds;
const focusedMessageId = selectFocusedMessageId(global, chatId);
// Focused to some non-pinned message
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) {
return;
}
const maxId = Math.max(...newPinnedViewportIds);
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,
});
}, [
chatId, threadId, key, pinnedIds, getLoadingPinnedId, getForceNextPinnedInHeader, setCurrentPinnedIndexes,
getCurrentPinnedIndexes, setLoadingPinnedId, setForceNextPinnedInHeader,
]);
const onFocusPinnedMessage = useCallback((messageId: number): boolean => {
if (!chatId || !threadId || !key || getLoadingPinnedId()) return false;
const global = getGlobal();
if (!pinnedIds?.length) return false;
const index = pinnedIds.indexOf(messageId);
const newPinnedIndex = cycleRestrict(pinnedIds.length, index + 1);
setForceNextPinnedInHeader(true);
const listedIds = selectListedIds(global, chatId, threadId);
const isMessageLoaded = listedIds?.includes(messageId)
|| selectOutlyingListByMessageId(global, chatId, threadId, messageId);
if (isMessageLoaded) {
setCurrentPinnedIndexes({
...getCurrentPinnedIndexes(),
[key]: newPinnedIndex,
});
return true;
} else {
setLoadingPinnedId(pinnedIds[newPinnedIndex]);
return true;
}
}, [
chatId, getCurrentPinnedIndexes, getLoadingPinnedId, key, pinnedIds, setCurrentPinnedIndexes,
setForceNextPinnedInHeader, setLoadingPinnedId, threadId,
]);
return {
onIntersectionChanged,
onFocusPinnedMessage,
getCurrentPinnedIndexes,
getLoadingPinnedId,
getForceNextPinnedInHeader,
};
}

View File

@ -29,6 +29,7 @@ import type {
AnimationLevel, FocusDirection, IAlbum, ISettings, AnimationLevel, FocusDirection, IAlbum, ISettings,
} from '../../../types'; } from '../../../types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { PinnedIntersectionChangedCallback } from '../hooks/usePinnedMessage';
import { AudioOrigin } from '../../../types'; import { AudioOrigin } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
@ -185,6 +186,7 @@ type OwnProps =
noReplies: boolean; noReplies: boolean;
appearanceOrder: number; appearanceOrder: number;
memoFirstUnreadIdRef: { current: number | undefined }; memoFirstUnreadIdRef: { current: number | undefined };
onPinnedIntersectionChange: PinnedIntersectionChangedCallback;
} }
& MessagePositionProperties; & MessagePositionProperties;
@ -226,6 +228,7 @@ type StateProps = {
isDownloading: boolean; isDownloading: boolean;
threadId?: number; threadId?: number;
isPinnedList?: boolean; isPinnedList?: boolean;
isPinned?: boolean;
canAutoLoadMedia?: boolean; canAutoLoadMedia?: boolean;
canAutoPlayMedia?: boolean; canAutoPlayMedia?: boolean;
hasLinkedChat?: boolean; hasLinkedChat?: boolean;
@ -335,6 +338,7 @@ const Message: FC<OwnProps & StateProps> = ({
activeEmojiInteractions, activeEmojiInteractions,
messageListType, messageListType,
isPinnedList, isPinnedList,
isPinned,
isDownloading, isDownloading,
canAutoLoadMedia, canAutoLoadMedia,
canAutoPlayMedia, canAutoPlayMedia,
@ -350,6 +354,7 @@ const Message: FC<OwnProps & StateProps> = ({
chatTranslations, chatTranslations,
areTranslationsEnabled, areTranslationsEnabled,
requestedTranslationLanguage, requestedTranslationLanguage,
onPinnedIntersectionChange,
}) => { }) => {
const { const {
toggleMessageSelection, toggleMessageSelection,
@ -397,12 +402,22 @@ const Message: FC<OwnProps & StateProps> = ({
setTimeout(markShown, appearanceOrder * APPEARANCE_DELAY); setTimeout(markShown, appearanceOrder * APPEARANCE_DELAY);
}, [appearanceOrder, markShown, noAppearanceAnimation]); }, [appearanceOrder, markShown, noAppearanceAnimation]);
const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false); const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false);
const { const {
id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError,
} = message; } = message;
useEffect(() => {
if (!isPinned) return undefined;
const id = album ? album.messages[album.messages.length - 1].id : messageId;
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);
const isScheduled = messageListType === 'scheduled' || message.isScheduled; const isScheduled = messageListType === 'scheduled' || message.isScheduled;
@ -772,6 +787,7 @@ const Message: FC<OwnProps & StateProps> = ({
const meta = ( const meta = (
<MessageMeta <MessageMeta
message={message} message={message}
isPinned={isPinned}
noReplies={noReplies} noReplies={noReplies}
repliesThreadInfo={repliesThreadInfo} repliesThreadInfo={repliesThreadInfo}
outgoingStatus={outgoingStatus} outgoingStatus={outgoingStatus}
@ -1172,6 +1188,7 @@ const Message: FC<OwnProps & StateProps> = ({
data-last-message-id={album ? album.messages[album.messages.length - 1].id : undefined} data-last-message-id={album ? album.messages[album.messages.length - 1].id : undefined}
data-has-unread-mention={message.hasUnreadMention || undefined} data-has-unread-mention={message.hasUnreadMention || undefined}
data-has-unread-reaction={hasUnreadReaction || undefined} data-has-unread-reaction={hasUnreadReaction || undefined}
data-is-pinned={isPinned || undefined}
/> />
{!isInDocumentGroup && ( {!isInDocumentGroup && (
<div className="message-select-control"> <div className="message-select-control">
@ -1407,6 +1424,7 @@ export default memo(withGlobal<OwnProps>(
threadId, threadId,
isDownloading, isDownloading,
isPinnedList: messageListType === 'pinned', isPinnedList: messageListType === 'pinned',
isPinned: message.isPinned || Boolean(message.forwardInfo?.isLinkedChannelPost),
canAutoLoadMedia: selectCanAutoLoadMedia(global, message), canAutoLoadMedia: selectCanAutoLoadMedia(global, message),
canAutoPlayMedia: selectCanAutoPlayMedia(global, message), canAutoPlayMedia: selectCanAutoPlayMedia(global, message),
autoLoadFileMaxSizeMb: global.settings.byKey.autoLoadFileMaxSizeMb, autoLoadFileMaxSizeMb: global.settings.byKey.autoLoadFileMaxSizeMb,

View File

@ -245,7 +245,7 @@ const MessageContextMenu: FC<OwnProps> = ({
const getLayout = useCallback(() => { const getLayout = useCallback(() => {
const extraHeightAudioPlayer = (isMobile const extraHeightAudioPlayer = (isMobile
&& (document.querySelector<HTMLElement>('.AudioPlayer-content'))?.offsetHeight) || 0; && (document.querySelector<HTMLElement>('.AudioPlayer-content'))?.offsetHeight) || 0;
const pinnedElement = document.querySelector<HTMLElement>('.HeaderPinnedMessage-wrapper'); const pinnedElement = document.querySelector<HTMLElement>('.HeaderPinnedMessageWrapper');
const extraHeightPinned = (((isMobile && !extraHeightAudioPlayer) const extraHeightPinned = (((isMobile && !extraHeightAudioPlayer)
|| (!isMobile && pinnedElement?.classList.contains('full-width'))) || (!isMobile && pinnedElement?.classList.contains('full-width')))
&& pinnedElement?.offsetHeight) || 0; && pinnedElement?.offsetHeight) || 0;

View File

@ -18,7 +18,8 @@
.message-signature, .message-signature,
.message-views, .message-views,
.message-replies, .message-replies,
.message-translated { .message-translated,
.message-pinned {
font-size: 0.75rem; font-size: 0.75rem;
white-space: nowrap; white-space: nowrap;
} }
@ -41,6 +42,10 @@
margin-inline-end: 0.25rem; margin-inline-end: 0.25rem;
} }
.message-pinned {
margin-inline-end: 0.1875rem;
}
.message-imported, .message-imported,
.message-signature { .message-signature {
overflow: hidden; overflow: hidden;

View File

@ -28,6 +28,7 @@ type OwnProps = {
noReplies?: boolean; noReplies?: boolean;
repliesThreadInfo?: ApiThreadInfo; repliesThreadInfo?: ApiThreadInfo;
isTranslated?: boolean; isTranslated?: boolean;
isPinned?: boolean;
onClick: (e: React.MouseEvent<HTMLDivElement>) => void; onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onTranslationClick: (e: React.MouseEvent<HTMLDivElement>) => void; onTranslationClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onOpenThread: NoneToVoidFunction; onOpenThread: NoneToVoidFunction;
@ -41,6 +42,7 @@ const MessageMeta: FC<OwnProps> = ({
repliesThreadInfo, repliesThreadInfo,
noReplies, noReplies,
isTranslated, isTranslated,
isPinned,
onClick, onClick,
onTranslationClick, onTranslationClick,
onOpenThread, onOpenThread,
@ -113,6 +115,9 @@ const MessageMeta: FC<OwnProps> = ({
<i className="icon-reply-filled" /> <i className="icon-reply-filled" />
</span> </span>
)} )}
{isPinned && (
<i className="icon-pinned-message message-pinned" />
)}
{signature && ( {signature && (
<span className="message-signature">{renderText(signature)}</span> <span className="message-signature">{renderText(signature)}</span>
)} )}

View File

@ -1,5 +1,3 @@
import { getActions } from '../../../../global';
import type { FocusDirection } from '../../../../types'; import type { FocusDirection } from '../../../../types';
import { useLayoutEffect } from '../../../../lib/teact/teact'; import { useLayoutEffect } from '../../../../lib/teact/teact';
@ -17,16 +15,10 @@ export default function useFocusMessage(
noFocusHighlight?: boolean, noFocusHighlight?: boolean,
isResizingContainer?: boolean, isResizingContainer?: boolean,
) { ) {
const { setReachedFocusedMessage } = getActions();
useLayoutEffect(() => { useLayoutEffect(() => {
if (isFocused && elementRef.current) { if (isFocused && elementRef.current) {
const messagesContainer = elementRef.current.closest<HTMLDivElement>('.MessageList')!; const messagesContainer = elementRef.current.closest<HTMLDivElement>('.MessageList')!;
setReachedFocusedMessage({
hasReached: true,
});
fastSmoothScroll( fastSmoothScroll(
messagesContainer, messagesContainer,
elementRef.current, elementRef.current,
@ -40,6 +32,6 @@ export default function useFocusMessage(
); );
} }
}, [ }, [
elementRef, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, setReachedFocusedMessage, elementRef, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer,
]); ]);
} }

View File

@ -109,6 +109,78 @@
} }
} }
/*
* slide-vertical
*/
&.slide-vertical {
> .to {
transform: translateY(100%);
}
&.animating {
> .from {
animation: slide-vertical-out var(--slide-transition);
}
> .to {
animation: slide-vertical-in var(--slide-transition);
}
}
}
&.slide-vertical.backwards {
> .to {
transform: translateY(-100%);
}
&.animating {
> .from {
animation: slide-vertical-in-backwards var(--slide-transition);
}
> .to {
animation: slide-vertical-out-backwards var(--slide-transition);
}
}
}
/*
* slide-vertical-fade
*/
&.slide-vertical-fade {
> .to {
transform: translateY(100%);
}
&.animating {
> .from {
animation: slide-vertical-fade-out var(--slide-transition);
}
> .to {
animation: slide-vertical-fade-in var(--slide-transition);
}
}
}
&.slide-vertical-fade.backwards {
> .to {
transform: translateY(-100%);
}
&.animating {
> .from {
animation: slide-vertical-fade-in-backwards var(--slide-transition);
}
> .to {
animation: slide-vertical-fade-out-backwards var(--slide-transition);
}
}
}
/* /*
* mv-slide * mv-slide
*/ */
@ -483,6 +555,94 @@
} }
} }
/*
* slide-vertical
*/
@keyframes slide-vertical-in {
0% {
transform: translateY(100%);
}
100% {
transform: translateY(0);
}
}
@keyframes slide-vertical-out {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-100%);
}
}
@keyframes slide-vertical-in-backwards {
0% {
transform: translateY(0);
}
100% {
transform: translateY(100%);
}
}
@keyframes slide-vertical-out-backwards {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(0);
}
}
/*
* slide-vertical-fade
*/
@keyframes slide-vertical-fade-in {
0% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-vertical-fade-out {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-100%);
opacity: 0;
}
}
@keyframes slide-vertical-fade-in-backwards {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(100%);
opacity: 0;
}
}
@keyframes slide-vertical-fade-out-backwards {
0% {
transform: translateY(-100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
/* /*
* mv-slide * mv-slide
*/ */

View File

@ -20,7 +20,8 @@ export type TransitionProps = {
activeKey: number; activeKey: number;
name: ( name: (
'none' | 'slide' | 'slide-rtl' | 'mv-slide' | 'slide-fade' | 'zoom-fade' | 'slide-layers' 'none' | 'slide' | 'slide-rtl' | 'mv-slide' | 'slide-fade' | 'zoom-fade' | 'slide-layers'
| 'fade' | 'push-slide' | 'reveal' | 'slide-optimized' | 'slide-optimized-rtl' | 'fade' | 'push-slide' | 'reveal' | 'slide-optimized' | 'slide-optimized-rtl' | 'slide-vertical'
| 'slide-vertical-fade'
); );
direction?: 'auto' | 'inverse' | 1 | -1; direction?: 'auto' | 'inverse' | 1 | -1;
renderCount?: number; renderCount?: number;

View File

@ -15,10 +15,7 @@ import type {
ApiUser, ApiUser,
ApiVideo, ApiVideo,
} from '../../../api/types'; } from '../../../api/types';
import { import { MAIN_THREAD_ID, MESSAGE_DELETED } from '../../../api/types';
MAIN_THREAD_ID,
MESSAGE_DELETED,
} from '../../../api/types';
import { LoadMoreDirection } from '../../../types'; import { LoadMoreDirection } from '../../../types';
import { import {
@ -39,60 +36,62 @@ import {
areSortedArraysIntersecting, buildCollectionByKey, omit, split, unique, areSortedArraysIntersecting, buildCollectionByKey, omit, split, unique,
} from '../../../util/iteratees'; } from '../../../util/iteratees';
import { import {
addUsers,
addChatMessagesById, addChatMessagesById,
addChats,
addUsers,
removeOutlyingList,
removeRequestedMessageTranslation,
replaceScheduledMessages,
replaceThreadParam, replaceThreadParam,
safeReplaceViewportIds, safeReplaceViewportIds,
updateChatMessage,
addChats,
updateListedIds,
updateOutlyingIds,
replaceScheduledMessages,
updateThreadInfos,
updateChat, updateChat,
updateThreadUnreadFromForwardedMessage, updateChatMessage,
updateSponsoredMessage, updateListedIds,
updateTopic,
updateThreadInfo,
replaceTabThreadParam,
updateRequestedMessageTranslation,
removeRequestedMessageTranslation,
updateMessageTranslation, updateMessageTranslation,
updateOutlyingLists,
updateRequestedMessageTranslation,
updateSponsoredMessage,
updateThreadInfo,
updateThreadInfos,
updateThreadUnreadFromForwardedMessage,
updateTopic,
} from '../../reducers'; } from '../../reducers';
import { import {
selectChat, selectChat,
selectChatMessage, selectChatMessage,
selectCurrentMessageList,
selectFocusedMessageId,
selectCurrentChat, selectCurrentChat,
selectCurrentMessageList,
selectDraft,
selectEditingId,
selectEditingMessage,
selectEditingScheduledId,
selectFirstUnreadId,
selectFocusedMessageId,
selectForwardsCanBeSentToChat,
selectForwardsContainVoiceMessages,
selectIsCurrentUserPremium,
selectLanguageCode,
selectListedIds, selectListedIds,
selectOutlyingIds, selectNoWebPage,
selectViewportIds, selectOutlyingListByMessageId,
selectRealLastReadId, selectRealLastReadId,
selectReplyingToId, selectReplyingToId,
selectEditingId,
selectDraft,
selectThreadTopMessageId,
selectEditingScheduledId,
selectEditingMessage,
selectScheduledMessage, selectScheduledMessage,
selectNoWebPage,
selectFirstUnreadId,
selectUser,
selectSendAs, selectSendAs,
selectSponsoredMessage, selectSponsoredMessage,
selectIsCurrentUserPremium,
selectForwardsContainVoiceMessages,
selectTabState, selectTabState,
selectThreadIdFromMessage, selectThreadIdFromMessage,
selectLanguageCode, selectThreadTopMessageId,
selectForwardsCanBeSentToChat, selectUser,
selectViewportIds,
} from '../../selectors'; } from '../../selectors';
import { debounce, onTickEnd, rafPromise } from '../../../util/schedulers';
import { import {
debounce, onTickEnd, rafPromise, getMessageOriginalId,
} from '../../../util/schedulers'; getUserFullName,
import { isDeletedUser,
getMessageOriginalId, getUserFullName, isDeletedUser, isServiceNotificationMessage, isUserBot, isServiceNotificationMessage,
isUserBot,
} from '../../helpers'; } from '../../helpers';
import { translate } from '../../../util/langProvider'; import { translate } from '../../../util/langProvider';
import { ensureProtocol } from '../../../util/ensureProtocol'; import { ensureProtocol } from '../../../util/ensureProtocol';
@ -109,6 +108,7 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur
const { const {
direction = LoadMoreDirection.Around, direction = LoadMoreDirection.Around,
isBudgetPreload = false, isBudgetPreload = false,
shouldForceRender = false,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload || {}; } = payload || {};
@ -117,7 +117,7 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur
if (!chatId || !threadId) { if (!chatId || !threadId) {
const currentMessageList = selectCurrentMessageList(global, tabId); const currentMessageList = selectCurrentMessageList(global, tabId);
if (!currentMessageList) { if (!currentMessageList) {
return undefined; return;
} }
chatId = currentMessageList.chatId; chatId = currentMessageList.chatId;
@ -127,17 +127,18 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
// TODO Revise if `chat.isRestricted` check is needed // TODO Revise if `chat.isRestricted` check is needed
if (!chat || chat.isRestricted) { if (!chat || chat.isRestricted) {
return undefined; return;
} }
const viewportIds = selectViewportIds(global, chatId, threadId, tabId); const viewportIds = selectViewportIds(global, chatId, threadId, tabId);
const listedIds = selectListedIds(global, chatId, threadId); const listedIds = selectListedIds(global, chatId, threadId);
const outlyingIds = selectOutlyingIds(global, chatId, threadId, tabId);
if (!viewportIds || !viewportIds.length || direction === LoadMoreDirection.Around) { if (!viewportIds || !viewportIds.length || direction === LoadMoreDirection.Around) {
const offsetId = selectFocusedMessageId(global, chatId, tabId) || selectRealLastReadId(global, chatId, threadId); const offsetId = selectFocusedMessageId(global, chatId, tabId) || selectRealLastReadId(global, chatId, threadId);
const isOutlying = Boolean(offsetId && listedIds && !listedIds.includes(offsetId)); const isOutlying = Boolean(offsetId && listedIds && !listedIds.includes(offsetId));
const historyIds = (isOutlying ? outlyingIds : listedIds) || []; const historyIds = (isOutlying
? selectOutlyingListByMessageId(global, chatId, threadId, offsetId!)
: listedIds) || [];
const { const {
newViewportIds, areSomeLocal, areAllLocal, newViewportIds, areSomeLocal, areAllLocal,
} = getViewportSlice(historyIds, offsetId, LoadMoreDirection.Around); } = getViewportSlice(historyIds, offsetId, LoadMoreDirection.Around);
@ -155,8 +156,9 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur
} }
} else { } else {
const offsetId = direction === LoadMoreDirection.Backwards ? viewportIds[0] : viewportIds[viewportIds.length - 1]; const offsetId = direction === LoadMoreDirection.Backwards ? viewportIds[0] : viewportIds[viewportIds.length - 1];
const isOutlying = Boolean(outlyingIds); const isOutlying = Boolean(listedIds && !listedIds.includes(offsetId));
const historyIds = (isOutlying ? outlyingIds : listedIds)!; const historyIds = (isOutlying
? selectOutlyingListByMessageId(global, chatId, threadId, offsetId) : listedIds)!;
const { const {
newViewportIds, areSomeLocal, areAllLocal, newViewportIds, areSomeLocal, areAllLocal,
} = getViewportSlice(historyIds, offsetId, direction); } = getViewportSlice(historyIds, offsetId, direction);
@ -172,11 +174,11 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur
}); });
if (isBudgetPreload) { if (isBudgetPreload) {
return undefined; return;
} }
} }
return global; setGlobal(global, { forceOnHeavyAnimation: shouldForceRender });
}); });
async function loadWithBudget<T extends GlobalState>( async function loadWithBudget<T extends GlobalState>(
@ -580,7 +582,7 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn
} }
const viewportIds = selectViewportIds(global, chatId, threadId, tabId); const viewportIds = selectViewportIds(global, chatId, threadId, tabId);
const minId = selectFirstUnreadId(global, chatId, threadId, tabId); const minId = selectFirstUnreadId(global, chatId, threadId);
if (!viewportIds || !minId || !chat.unreadCount) { if (!viewportIds || !minId || !chat.unreadCount) {
return global; return global;
} }
@ -975,7 +977,7 @@ async function loadViewportMessages<T extends GlobalState>(
global = addChatMessagesById(global, chatId, byId); global = addChatMessagesById(global, chatId, byId);
global = isOutlying global = isOutlying
? updateOutlyingIds(global, chatId, threadId, ids, tabId) ? updateOutlyingLists(global, chatId, threadId, ids)
: updateListedIds(global, chatId, threadId, ids); : updateListedIds(global, chatId, threadId, ids);
global = addUsers(global, buildCollectionByKey(users, 'id')); global = addUsers(global, buildCollectionByKey(users, 'id'));
@ -983,19 +985,19 @@ async function loadViewportMessages<T extends GlobalState>(
global = updateThreadInfos(global, chatId, repliesThreadInfos); global = updateThreadInfos(global, chatId, repliesThreadInfos);
let listedIds = selectListedIds(global, chatId, threadId); let listedIds = selectListedIds(global, chatId, threadId);
const outlyingIds = selectOutlyingIds(global, chatId, threadId, tabId); const outlyingList = offsetId ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId) : undefined;
if (isOutlying && listedIds && outlyingIds) { if (isOutlying && listedIds && outlyingList) {
if (!outlyingIds.length || areSortedArraysIntersecting(listedIds, outlyingIds)) { if (!outlyingList.length || areSortedArraysIntersecting(listedIds, outlyingList)) {
global = updateListedIds(global, chatId, threadId, outlyingIds); global = updateListedIds(global, chatId, threadId, outlyingList);
listedIds = selectListedIds(global, chatId, threadId); listedIds = selectListedIds(global, chatId, threadId);
global = replaceTabThreadParam(global, chatId, threadId, 'outlyingIds', undefined, tabId); global = removeOutlyingList(global, chatId, threadId, outlyingList);
isOutlying = false; isOutlying = false;
} }
} }
if (!isBudgetPreload) { if (!isBudgetPreload) {
const historyIds = isOutlying ? outlyingIds! : listedIds!; const historyIds = isOutlying ? outlyingList! : listedIds!;
const { newViewportIds } = getViewportSlice(historyIds, offsetId, direction); const { newViewportIds } = getViewportSlice(historyIds, offsetId, direction);
global = safeReplaceViewportIds(global, chatId, threadId, newViewportIds!, tabId); global = safeReplaceViewportIds(global, chatId, threadId, newViewportIds!, tabId);
} }
@ -1056,9 +1058,11 @@ function getViewportSlice(
const { length } = sourceIds; const { length } = sourceIds;
const index = offsetId ? findClosestIndex(sourceIds, offsetId) : -1; const index = offsetId ? findClosestIndex(sourceIds, offsetId) : -1;
const isBackwards = direction === LoadMoreDirection.Backwards; const isBackwards = direction === LoadMoreDirection.Backwards;
const isAround = direction === LoadMoreDirection.Around;
const indexForDirection = isBackwards ? index : (index + 1) || length; const indexForDirection = isBackwards ? index : (index + 1) || length;
const from = indexForDirection - MESSAGE_LIST_SLICE; const sliceSize = isAround ? Math.round(MESSAGE_LIST_SLICE / 2) : MESSAGE_LIST_SLICE;
const to = indexForDirection + MESSAGE_LIST_SLICE - 1; const from = indexForDirection - sliceSize;
const to = indexForDirection + sliceSize - 1;
const newViewportIds = sourceIds.slice(Math.max(0, from), to + 1); const newViewportIds = sourceIds.slice(Math.max(0, from), to + 1);
let areSomeLocal; let areSomeLocal;

View File

@ -324,6 +324,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
}; };
Object.values(messages).forEach((message) => { Object.values(messages).forEach((message) => {
const threadId = selectThreadIdFromMessage(global, message); const threadId = selectThreadIdFromMessage(global, message);
global = updateChatMessage(global, chatId, message.id, {
isPinned,
});
if (threadId === MAIN_THREAD_ID) return; if (threadId === MAIN_THREAD_ID) return;
const currentUpdatedInThread = updatePerThread[threadId] || []; const currentUpdatedInThread = updatePerThread[threadId] || [];
currentUpdatedInThread.push(message.id); currentUpdatedInThread.push(message.id);
@ -818,7 +821,7 @@ function updateListedAndViewportIds<T extends GlobalState>(
Object.values(global.byTabId).forEach(({ id: tabId }) => { Object.values(global.byTabId).forEach(({ id: tabId }) => {
if (selectIsViewportNewest(global, chatId, MAIN_THREAD_ID, tabId)) { if (selectIsViewportNewest(global, chatId, MAIN_THREAD_ID, tabId)) {
// Always keep the first unread message in the viewport list // Always keep the first unread message in the viewport list
const firstUnreadId = selectFirstUnreadId(global, chatId, MAIN_THREAD_ID, tabId); const firstUnreadId = selectFirstUnreadId(global, chatId, MAIN_THREAD_ID);
const candidateGlobal = addViewportId(global, chatId, MAIN_THREAD_ID, id, tabId); const candidateGlobal = addViewportId(global, chatId, MAIN_THREAD_ID, id, tabId);
const newViewportIds = selectViewportIds(candidateGlobal, chatId, MAIN_THREAD_ID, tabId); const newViewportIds = selectViewportIds(candidateGlobal, chatId, MAIN_THREAD_ID, tabId);

View File

@ -22,7 +22,7 @@ import {
replaceThreadParam, replaceThreadParam,
replaceTabThreadParam, replaceTabThreadParam,
updateFocusDirection, updateFocusDirection,
updateFocusedMessage, updateFocusedMessageReached, updateFocusedMessage,
} from '../../reducers'; } from '../../reducers';
import { import {
selectCurrentChat, selectCurrentChat,
@ -381,12 +381,6 @@ addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType
return undefined; return undefined;
}); });
addActionHandler('setReachedFocusedMessage', (global, actions, payload): ActionReturnType => {
const { hasReached = false, tabId = getCurrentTabId() } = payload;
return updateFocusedMessageReached(global, hasReached, tabId);
});
addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => { addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => {
const { const {
chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId, chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId,
@ -435,7 +429,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
const viewportIds = selectViewportIds(global, chatId, threadId, tabId); const viewportIds = selectViewportIds(global, chatId, threadId, tabId);
if (viewportIds && viewportIds.includes(messageId)) { if (viewportIds && viewportIds.includes(messageId)) {
setGlobal(global); setGlobal(global, { forceOnHeavyAnimation: true });
actions.openChat({ actions.openChat({
id: chatId, id: chatId,
threadId, threadId,
@ -450,14 +444,12 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
global = replaceTabThreadParam(global, chatId, threadId, 'viewportIds', undefined, tabId); global = replaceTabThreadParam(global, chatId, threadId, 'viewportIds', undefined, tabId);
} }
global = replaceTabThreadParam(global, chatId, threadId, 'outlyingIds', undefined, tabId);
if (viewportIds && !shouldSwitchChat) { if (viewportIds && !shouldSwitchChat) {
const direction = messageId > viewportIds[0] ? FocusDirection.Down : FocusDirection.Up; const direction = messageId > viewportIds[0] ? FocusDirection.Down : FocusDirection.Up;
global = updateFocusDirection(global, direction, tabId); global = updateFocusDirection(global, direction, tabId);
} }
setGlobal(global); setGlobal(global, { forceOnHeavyAnimation: true });
actions.openChat({ actions.openChat({
id: chatId, id: chatId,
@ -468,6 +460,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
}); });
actions.loadViewportMessages({ actions.loadViewportMessages({
tabId, tabId,
shouldForceRender: true,
}); });
return undefined; return undefined;
}); });

View File

@ -490,6 +490,7 @@ function reduceMessages<T extends GlobalState>(global: T): GlobalState['messages
acc[Number(key)] = { acc[Number(key)] = {
...t, ...t,
listedIds: t.lastViewportIds, listedIds: t.lastViewportIds,
pinnedIds: undefined,
}; };
return acc; return acc;
}, {} as GlobalState['messages']['byChatId'][string]['threadsById']); }, {} as GlobalState['messages']['byChatId'][string]['threadsById']);

View File

@ -15,7 +15,6 @@ import {
selectListedIds, selectListedIds,
selectChatMessages, selectChatMessages,
selectViewportIds, selectViewportIds,
selectOutlyingIds,
selectPinnedIds, selectPinnedIds,
selectThreadInfo, selectThreadInfo,
selectMessageIdsByGroupId, selectMessageIdsByGroupId,
@ -25,10 +24,10 @@ import {
selectChatMessage, selectChatMessage,
selectCurrentMessageList, selectCurrentMessageList,
selectChat, selectChat,
selectTabState, selectTabState, selectOutlyingLists,
} from '../selectors'; } from '../selectors';
import { import {
areSortedArraysEqual, omit, pickTruthy, unique, areSortedArraysEqual, mergeIdRanges, omit, orderHistoryIds, pickTruthy, unique,
} from '../../util/iteratees'; } from '../../util/iteratees';
import { updateTabState } from './tabs'; import { updateTabState } from './tabs';
import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getCurrentTabId } from '../../util/establishMultitabRole';
@ -252,6 +251,7 @@ export function deleteChatMessages<T extends GlobalState>(
let listedIds = selectListedIds(global, chatId, threadId); let listedIds = selectListedIds(global, chatId, threadId);
let pinnedIds = selectPinnedIds(global, chatId, threadId); let pinnedIds = selectPinnedIds(global, chatId, threadId);
let outlyingLists = selectOutlyingLists(global, chatId, threadId);
let mainPinnedIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID); let mainPinnedIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID);
let newMessageCount = threadInfo?.messagesCount; let newMessageCount = threadInfo?.messagesCount;
@ -261,6 +261,11 @@ export function deleteChatMessages<T extends GlobalState>(
if (newMessageCount !== undefined && !isLocalMessageId(messageId)) newMessageCount -= 1; if (newMessageCount !== undefined && !isLocalMessageId(messageId)) newMessageCount -= 1;
} }
outlyingLists = outlyingLists?.map((list) => {
if (!list.includes(messageId)) return list;
return list.filter((id) => id !== messageId);
});
if (pinnedIds?.includes(messageId)) { if (pinnedIds?.includes(messageId)) {
pinnedIds = pinnedIds.filter((id) => id !== messageId); pinnedIds = pinnedIds.filter((id) => id !== messageId);
} }
@ -271,24 +276,19 @@ export function deleteChatMessages<T extends GlobalState>(
}); });
Object.values(global.byTabId).forEach(({ id: tabId }) => { Object.values(global.byTabId).forEach(({ id: tabId }) => {
let outlyingIds = selectOutlyingIds(global, chatId, threadId, tabId);
let viewportIds = selectViewportIds(global, chatId, threadId, tabId); let viewportIds = selectViewportIds(global, chatId, threadId, tabId);
messageIds.forEach((messageId) => { messageIds.forEach((messageId) => {
if (outlyingIds?.includes(messageId)) {
outlyingIds = outlyingIds.filter((id) => id !== messageId);
}
if (viewportIds?.includes(messageId)) { if (viewportIds?.includes(messageId)) {
viewportIds = viewportIds.filter((id) => id !== messageId); viewportIds = viewportIds.filter((id) => id !== messageId);
} }
}); });
global = replaceTabThreadParam(global, chatId, threadId, 'outlyingIds', outlyingIds, tabId);
global = replaceTabThreadParam(global, chatId, threadId, 'viewportIds', viewportIds, tabId); global = replaceTabThreadParam(global, chatId, threadId, 'viewportIds', viewportIds, tabId);
}); });
global = replaceThreadParam(global, chatId, threadId, 'listedIds', listedIds); global = replaceThreadParam(global, chatId, threadId, 'listedIds', listedIds);
global = replaceThreadParam(global, chatId, threadId, 'outlyingLists', outlyingLists);
global = replaceThreadParam(global, chatId, threadId, 'pinnedIds', pinnedIds); global = replaceThreadParam(global, chatId, threadId, 'pinnedIds', pinnedIds);
global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'pinnedIds', mainPinnedIds); global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'pinnedIds', mainPinnedIds);
@ -379,30 +379,35 @@ export function updateListedIds<T extends GlobalState>(
])); ]));
} }
export function updateOutlyingIds<T extends GlobalState>( export function removeOutlyingList<T extends GlobalState>(
global: T,
chatId: string,
threadId: number,
list: number[],
): T {
const outlyingLists = selectOutlyingLists(global, chatId, threadId);
if (!outlyingLists) {
return global;
}
const newOutlyingLists = outlyingLists.filter((l) => l !== list);
return replaceThreadParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists);
}
export function updateOutlyingLists<T extends GlobalState>(
global: T, global: T,
chatId: string, chatId: string,
threadId: number, threadId: number,
idsUpdate: number[], idsUpdate: number[],
...[tabId = getCurrentTabId()]: TabArgs<T>
): T { ): T {
const outlyingIds = selectOutlyingIds(global, chatId, threadId, tabId); if (!idsUpdate.length) return global;
const newIds = outlyingIds?.length
? idsUpdate.filter((id) => !outlyingIds.includes(id))
: idsUpdate;
if (outlyingIds && !newIds.length) { const outlyingLists = selectOutlyingLists(global, chatId, threadId);
return global;
}
return replaceTabThreadParam(global, chatId, threadId, 'outlyingIds', orderHistoryIds([ const newOutlyingLists = mergeIdRanges(outlyingLists || [], idsUpdate);
...(outlyingIds || []),
...newIds,
]), tabId);
}
function orderHistoryIds(listedIds: number[]) { return replaceThreadParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists);
return listedIds.sort((a, b) => a - b);
} }
export function addViewportId<T extends GlobalState>( export function addViewportId<T extends GlobalState>(
@ -518,22 +523,6 @@ export function updateFocusedMessage<T extends GlobalState>(
}, tabId); }, tabId);
} }
export function updateFocusedMessageReached<T extends GlobalState>(
global: T, hasReachedMessage: boolean,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const focusedMessage = selectTabState(global, tabId).focusedMessage;
if (!focusedMessage) return global;
return updateTabState(global, {
focusedMessage: {
...focusedMessage,
hasReachedMessage,
},
}, tabId);
}
export function updateSponsoredMessage<T extends GlobalState>( export function updateSponsoredMessage<T extends GlobalState>(
global: T, chatId: string, message: ApiSponsoredMessage, global: T, chatId: string, message: ApiSponsoredMessage,
): T { ): T {

View File

@ -126,10 +126,23 @@ export function selectListedIds<T extends GlobalState>(global: T, chatId: string
return selectThreadParam(global, chatId, threadId, 'listedIds'); return selectThreadParam(global, chatId, threadId, 'listedIds');
} }
export function selectOutlyingIds<T extends GlobalState>( export function selectOutlyingListByMessageId<T extends GlobalState>(
global: T, chatId: string, threadId: number, ...[tabId = getCurrentTabId()]: TabArgs<T> global: T, chatId: string, threadId: number, messageId: number,
) { ) {
return selectTabThreadParam(global, chatId, threadId, 'outlyingIds', tabId); const outlyingLists = selectOutlyingLists(global, chatId, threadId);
if (!outlyingLists) {
return undefined;
}
return outlyingLists.find((list) => {
return list[0] <= messageId && list[list.length - 1] >= messageId;
});
}
export function selectOutlyingLists<T extends GlobalState>(
global: T, chatId: string, threadId: number,
) {
return selectThreadParam(global, chatId, threadId, 'outlyingLists');
} }
export function selectCurrentMessageIds<T extends GlobalState>( export function selectCurrentMessageIds<T extends GlobalState>(
@ -764,7 +777,6 @@ export function selectRealLastReadId<T extends GlobalState>(global: T, chatId: s
export function selectFirstUnreadId<T extends GlobalState>( export function selectFirstUnreadId<T extends GlobalState>(
global: T, chatId: string, threadId: number, global: T, chatId: string, threadId: number,
...[tabId = getCurrentTabId()]: TabArgs<T>
) { ) {
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
@ -780,10 +792,10 @@ export function selectFirstUnreadId<T extends GlobalState>(
} }
} }
const outlyingIds = selectOutlyingIds(global, chatId, threadId, tabId); const outlyingLists = selectOutlyingLists(global, chatId, threadId);
const listedIds = selectListedIds(global, chatId, threadId); const listedIds = selectListedIds(global, chatId, threadId);
const byId = selectChatMessages(global, chatId); const byId = selectChatMessages(global, chatId);
if (!byId || !(outlyingIds || listedIds)) { if (!byId || !(outlyingLists?.length || listedIds)) {
return undefined; return undefined;
} }
@ -811,8 +823,8 @@ export function selectFirstUnreadId<T extends GlobalState>(
}); });
} }
if (outlyingIds) { if (outlyingLists?.length) {
const found = findAfterLastReadId(outlyingIds); const found = outlyingLists.map((list) => findAfterLastReadId(list)).filter(Boolean)[0];
if (found) { if (found) {
return found; return found;
} }

View File

@ -121,7 +121,6 @@ export interface ActiveReaction {
export interface TabThread { export interface TabThread {
scrollOffset?: number; scrollOffset?: number;
replyStack?: number[]; replyStack?: number[];
outlyingIds?: number[];
viewportIds?: number[]; viewportIds?: number[];
} }
@ -129,6 +128,7 @@ export interface Thread {
lastScrollOffset?: number; lastScrollOffset?: number;
lastViewportIds?: number[]; lastViewportIds?: number[];
listedIds?: number[]; listedIds?: number[];
outlyingLists?: number[][];
pinnedIds?: number[]; pinnedIds?: number[];
scheduledIds?: number[]; scheduledIds?: number[];
editingId?: number; editingId?: number;
@ -228,7 +228,6 @@ export type TabState = {
direction?: FocusDirection; direction?: FocusDirection;
noHighlight?: boolean; noHighlight?: boolean;
isResizingContainer?: boolean; isResizingContainer?: boolean;
hasReachedMessage?: boolean;
}; };
selectedMessages?: { selectedMessages?: {
@ -1127,6 +1126,7 @@ export interface ActionPayloads {
isBudgetPreload?: boolean; isBudgetPreload?: boolean;
chatId?: string; chatId?: string;
threadId?: number; threadId?: number;
shouldForceRender?: boolean;
} & WithTabId; } & WithTabId;
sendMessage: { sendMessage: {
text?: string; text?: string;
@ -1497,9 +1497,6 @@ export interface ActionPayloads {
showDialog: { showDialog: {
data: TabState['dialogs'][number]; data: TabState['dialogs'][number];
} & WithTabId; } & WithTabId;
setReachedFocusedMessage: {
hasReached?: boolean;
} & WithTabId;
focusMessage: { focusMessage: {
chatId: string; chatId: string;
threadId?: number; threadId?: number;

View File

@ -66,12 +66,16 @@ const containers = new Map<string, {
const runCallbacksThrottled = throttleWithTickEnd(runCallbacks); const runCallbacksThrottled = throttleWithTickEnd(runCallbacks);
let forceOnHeavyAnimation = true;
function runImmediateCallbacks() { function runImmediateCallbacks() {
immediateCallbacks.forEach((cb) => cb(currentGlobal)); immediateCallbacks.forEach((cb) => cb(currentGlobal));
} }
function runCallbacks(forceOnHeavyAnimation = false) { function runCallbacks() {
if (!forceOnHeavyAnimation && isHeavyAnimating()) { if (forceOnHeavyAnimation) {
forceOnHeavyAnimation = false;
} else if (isHeavyAnimating()) {
fastRafWithFallback(runCallbacksThrottled); fastRafWithFallback(runCallbacksThrottled);
return; return;
} }
@ -94,9 +98,14 @@ export function setGlobal(newGlobal?: GlobalState, options?: ActionOptions) {
if (!options?.noUpdate) runImmediateCallbacks(); if (!options?.noUpdate) runImmediateCallbacks();
if (options?.forceSyncOnIOs) { if (options?.forceSyncOnIOs) {
runCallbacks(true); forceOnHeavyAnimation = true;
runCallbacks();
} else { } else {
runCallbacksThrottled(options?.forceOnHeavyAnimation); if (options?.forceOnHeavyAnimation) {
forceOnHeavyAnimation = true;
}
runCallbacksThrottled();
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -53,3 +53,37 @@
} }
} }
} }
@mixin header-mobile {
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 2.875rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
display: flex;
flex-direction: row-reverse;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.5rem, env(safe-area-inset-right));
background: var(--color-background);
// Target: Old Firefox (Waterfox Classic)
@supports not (padding-left: max(0.75rem, env(safe-area-inset-left))) {
padding-left: 0.75rem;
padding-right: 0.5rem;
}
&::before {
content: "";
display: block;
position: absolute;
top: -0.1875rem;
left: 0;
right: 0;
height: 0.125rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
}
}

View File

@ -51,6 +51,9 @@
.icon-volume-3:before { .icon-volume-3:before {
content: "\e991"; content: "\e991";
} }
.icon-pinned-message:before {
content: "\e9bf";
}
.icon-archive-filled:before { .icon-archive-filled:before {
content: "\e9ba"; content: "\e9ba";
} }

View File

@ -148,6 +148,43 @@ function isObject(value: any): value is object {
return typeof value === 'object' && value !== null; return typeof value === 'object' && value !== null;
} }
export function orderHistoryIds(listedIds: number[]) {
return listedIds.sort((a, b) => a - b);
}
export function mergeIdRanges(ranges: number[][], idsUpdate: number[]): number[][] {
let hasIntersection = false;
let newOutlyingLists = ranges.length ? ranges.map((list) => {
if (areSortedArraysIntersecting(list, idsUpdate) && !hasIntersection) {
hasIntersection = true;
return orderHistoryIds(unique(list.concat(idsUpdate)));
}
return list;
}) : [idsUpdate];
if (!hasIntersection) {
newOutlyingLists = newOutlyingLists.concat([idsUpdate]);
}
newOutlyingLists.sort((a, b) => a[0] - b[0]);
let length = newOutlyingLists.length;
for (let i = 0; i < length; i++) {
const array = newOutlyingLists[i];
const prevArray = newOutlyingLists[i - 1];
if (prevArray && (prevArray.includes(array[0]) || prevArray.includes(array[0] - 1))) {
newOutlyingLists[i - 1] = orderHistoryIds(unique(array.concat(prevArray)));
newOutlyingLists.splice(i, 1);
length--;
i--;
}
}
return newOutlyingLists;
}
export function findLast<T>(array: Array<T>, predicate: (value: T, index: number, obj: T[]) => boolean): T | undefined { export function findLast<T>(array: Array<T>, predicate: (value: T, index: number, obj: T[]) => boolean): T | undefined {
let cursor = array.length; let cursor = array.length;