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>, (
'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' |
'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,
isFromScheduled: mtpMessage.fromScheduled,
isSilent: mtpMessage.silent,
isPinned: mtpMessage.pinned,
reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions),
emojiOnlyCount,
...(replyToMsgId && { replyToMessageId: replyToMsgId }),

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import type {
ApiUser, ApiMessage, ApiChat, ApiSticker, ApiTopic,
} from '../../api/types';
import type { FocusDirection } from '../../types';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import {
selectUser,
@ -45,6 +46,7 @@ type OwnProps = {
isLastInList?: boolean;
isInsideTopic?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined };
onPinnedIntersectionChange?: PinnedIntersectionChangedCallback;
};
type StateProps = {
@ -84,6 +86,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
observeIntersectionForReading,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onPinnedIntersectionChange,
}) => {
const { openPremiumModal, requestConfetti } = getActions();
@ -96,6 +99,14 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
useEnsureMessage(message.chatId, message.replyToMessageId, targetMessage);
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 [isShown, markShown] = useFlag(noAppearanceAnimation);
const isGift = Boolean(message.content.action?.text.startsWith('ActionGift'));
@ -209,6 +220,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
id={getMessageHtmlId(message.id)}
className={className}
data-message-id={message.id}
data-is-pinned={message.isPinned || undefined}
onMouseDown={handleMouseDown}
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 useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useAsyncRendering from '../right/hooks/useAsyncRendering';
import RippleEffect from '../ui/RippleEffect';
import ConfirmDialog from '../ui/ConfirmDialog';
@ -24,7 +25,13 @@ import Button from '../ui/Button';
import PinnedMessageNavigation from './PinnedMessageNavigation';
import MessageSummary from '../common/MessageSummary';
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 = {
message: ApiMessage;
index: number;
@ -34,17 +41,22 @@ type OwnProps = {
onUnpinMessage?: (id: number) => void;
onClick?: () => void;
onAllPinnedClick?: () => void;
isLoading?: boolean;
isFullWidth?: boolean;
};
const HeaderPinnedMessage: FC<OwnProps> = ({
message, count, index, customTitle, className, onUnpinMessage, onClick, onAllPinnedClick,
isLoading, isFullWidth,
}) => {
const { clickBotInlineButton } = getActions();
const lang = useLang();
const mediaThumbnail = useThumbnail(message);
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram'));
const isSpoiler = getMessageIsSpoiler(message);
const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY);
const shouldShowLoader = canRenderLoader && isLoading;
const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag();
@ -66,18 +78,44 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
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 (
<div className={buildClassName('HeaderPinnedMessage-wrapper', className)}>
{count > 1 && (
<div className={buildClassName(
'HeaderPinnedMessageWrapper', styles.root, isFullWidth && 'full-width', className,
)}
>
{(count > 1 || shouldShowLoader) && (
<Button
round
size="smaller"
color="translucent"
className="pin-list-button"
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>
)}
{onUnpinMessage && (
@ -86,7 +124,6 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
size="smaller"
color="translucent"
ariaLabel={lang('UnpinMessageAlertTitle')}
className="unpin-button"
onClick={openUnpinDialog}
>
<i className="icon-close" />
@ -100,7 +137,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
confirmHandler={handleUnpinMessage}
/>
<div
className={buildClassName('HeaderPinnedMessage', noHoverColor && 'no-hover')}
className={buildClassName(styles.pinnedMessage, noHoverColor && styles.noHover)}
onClick={onClick}
dir={lang.isRtl ? 'rtl' : undefined}
>
@ -108,20 +145,32 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
count={count}
index={index}
/>
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isSpoiler)}
<div className="message-text">
<div className="title" dir="auto">
{customTitle ? renderText(customTitle) : `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`}
<Transition activeKey={message.id} name="slide-vertical" className={styles.pictogramTransition}>
{renderPictogram(
mediaThumbnail,
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>
<p dir="auto">
<MessageSummary lang={lang} message={message} noEmoji={Boolean(mediaThumbnail)} />
</p>
<RippleEffect />
<Transition activeKey={message.id} name="slide-vertical-fade" className={styles.messageTextTransition}>
<p dir="auto" className={styles.summary}>
<MessageSummary lang={lang} message={message} noEmoji={Boolean(mediaThumbnail)} />
</p>
</Transition>
</div>
<RippleEffect />
{inlineButton && (
<Button
size="tiny"
className="inline-button"
className={styles.inlineButton}
onClick={handleInlineButtonClick}
shouldStopPropagation
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);

View File

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

View File

@ -4,6 +4,7 @@ import React, { memo } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { MessageListType } from '../../global/types';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import { SCHEDULED_WHEN_ONLINE } from '../../config';
import { MAIN_THREAD_ID } from '../../api/types';
@ -50,6 +51,7 @@ interface OwnProps {
noAppearanceAnimation: boolean;
onFabToggle: AnyToVoidFunction;
onNotchToggle: AnyToVoidFunction;
onPinnedIntersectionChange: PinnedIntersectionChangedCallback;
}
const UNREAD_DIVIDER_CLASS = 'unread-divider';
@ -80,6 +82,7 @@ const MessageListContent: FC<OwnProps> = ({
noAppearanceAnimation,
onFabToggle,
onNotchToggle,
onPinnedIntersectionChange,
}) => {
const { openHistoryCalendar } = getActions();
@ -87,7 +90,7 @@ const MessageListContent: FC<OwnProps> = ({
observeIntersectionForReading,
observeIntersectionForLoading,
observeIntersectionForPlaying,
} = useMessageObservers(type, containerRef, memoFirstUnreadIdRef);
} = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onPinnedIntersectionChange);
const {
backwardsTriggerRef,
@ -154,6 +157,7 @@ const MessageListContent: FC<OwnProps> = ({
memoFirstUnreadIdRef={memoFirstUnreadIdRef}
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isLastInList={isLastInList}
onPinnedIntersectionChange={onPinnedIntersectionChange}
/>,
]);
}
@ -222,6 +226,7 @@ const MessageListContent: FC<OwnProps> = ({
isLastInDocumentGroup={position.isLastInDocumentGroup}
isLastInList={position.isLastInList}
memoFirstUnreadIdRef={memoFirstUnreadIdRef}
onPinnedIntersectionChange={onPinnedIntersectionChange}
/>,
message.id === threadTopMessageId && (
<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 useSyncEffect from '../../hooks/useSyncEffect';
import useAppLayout from '../../hooks/useAppLayout';
import usePinnedMessage from './hooks/usePinnedMessage';
import Transition from '../ui/Transition';
import MiddleHeader from './MiddleHeader';
@ -130,6 +131,7 @@ type StateProps = {
shouldJoinToSend?: boolean;
shouldSendJoinRequest?: boolean;
lastSyncTime?: number;
pinnedIds?: number[];
};
function isImage(item: DataTransferItem) {
@ -177,6 +179,7 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
shouldSendJoinRequest,
shouldLoadFullChat,
lastSyncTime,
pinnedIds,
}) => {
const {
openChat,
@ -350,6 +353,14 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
const customBackgroundValue = useCustomBackground(theme, customBackground);
const {
onIntersectionChanged,
onFocusPinnedMessage,
getCurrentPinnedIndexes,
getLoadingPinnedId,
getForceNextPinnedInHeader,
} = usePinnedMessage(chatId, threadId, pinnedIds);
const className = buildClassName(
renderingHasTools && 'has-header-tools',
MASK_IMAGE_DISABLED ? 'mask-image-disabled' : 'mask-image-enabled',
@ -444,6 +455,9 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
messageListType={renderingMessageListType}
isReady={isReady}
isMobile={isMobile}
getCurrentPinnedIndexes={getCurrentPinnedIndexes}
getLoadingPinnedId={getLoadingPinnedId}
onFocusPinnedMessage={onFocusPinnedMessage}
/>
<Transition
name={shouldSkipHistoryAnimations ? 'none' : animationLevel === ANIMATION_LEVEL_MAX ? 'slide' : 'fade'}
@ -464,6 +478,8 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
isReady={isReady}
withBottomShift={withMessageListBottomShift}
withDefaultBg={Boolean(!customBackground && !backgroundColor)}
onPinnedIntersectionChange={onIntersectionChanged}
getForceNextPinnedInHeader={getForceNextPinnedInHeader}
/>
<div className={footerClassName}>
{renderingCanPost && (
@ -698,6 +714,7 @@ export default memo(withGlobal<OwnProps>(
shouldJoinToSend,
shouldSendJoinRequest,
shouldLoadFullChat,
pinnedIds,
};
},
)(MiddleColumn));

View File

@ -1,47 +1,9 @@
@import "../../styles/mixins";
@mixin mobile-header-styles() {
.HeaderPinnedMessage-wrapper,
.AudioPlayer {
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 2.875rem;
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
@include header-mobile;
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;
margin-top: 0;
padding: 0.25rem 0.5rem;
@ -59,25 +21,6 @@
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 {
@ -162,10 +105,6 @@
overflow: hidden;
}
body.animation-level-0 & .HeaderPinnedMessage-wrapper {
transition: none !important;
}
.header-tools {
display: flex;
align-items: center;
@ -208,7 +147,6 @@
}
@media (min-width: 1276px) and (max-width: 1439px) {
&:not(.tools-stacked) .HeaderPinnedMessage-wrapper,
&:not(.tools-stacked) .AudioPlayer {
opacity: 1;
@ -228,7 +166,6 @@
}
}
&.tools-stacked.animated .HeaderPinnedMessage-wrapper,
&.tools-stacked.animated .AudioPlayer {
animation: fade-in var(--layer-transition) forwards;
@ -383,213 +320,6 @@
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 {
flex-shrink: 0;
margin-left: auto;
@ -597,6 +327,14 @@
align-items: center;
justify-content: flex-end;
.Button {
margin-left: 0.25rem;
&.tiny {
margin-right: 0.625rem;
}
}
.toggle-right-pane-button {
&.active {
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) {
@include mobile-header-styles();
}

View File

@ -1,11 +1,11 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useRef, useState,
memo, useCallback, useEffect, useRef,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import cycleRestrict from '../../util/cycleRestrict';
import type { GlobalState, MessageListType } from '../../global/types';
import type { Signal } from '../../util/signals';
import type {
ApiChat, ApiMessage, ApiTypingStatus, ApiUser,
} from '../../api/types';
@ -51,6 +51,7 @@ import useLang from '../../hooks/useLang';
import useConnectionStatus from '../../hooks/useConnectionStatus';
import usePrevious from '../../hooks/usePrevious';
import useAppLayout from '../../hooks/useAppLayout';
import useDerivedState from '../../hooks/useDerivedState';
import PrivateChatInfo from '../common/PrivateChatInfo';
import GroupChatInfo from '../common/GroupChatInfo';
@ -75,6 +76,9 @@ type OwnProps = {
messageListType: MessageListType;
isReady?: boolean;
isMobile?: boolean;
getCurrentPinnedIndexes: Signal<Record<string, number>>;
getLoadingPinnedId: Signal<number | undefined>;
onFocusPinnedMessage: (messageId: number) => boolean;
};
type StateProps = {
@ -93,7 +97,6 @@ type StateProps = {
isChatWithSelf?: boolean;
lastSyncTime?: number;
hasButtonInHeader?: boolean;
hasReachedFocusedMessage?: boolean;
shouldSkipHistoryAnimations?: boolean;
currentTransitionKey: number;
connectionState?: GlobalState['connectionState'];
@ -124,8 +127,10 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
shouldSkipHistoryAnimations,
currentTransitionKey,
connectionState,
hasReachedFocusedMessage,
isSyncing,
getCurrentPinnedIndexes,
getLoadingPinnedId,
onFocusPinnedMessage,
}) => {
const {
openChatWithInfo,
@ -133,7 +138,6 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
focusMessage,
openChat,
openPreviousChat,
setReachedFocusedMessage,
loadPinnedMessages,
toggleLeftColumn,
exitMessageSelectMode,
@ -141,11 +145,12 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
const lang = useLang();
const isBackButtonActive = useRef(true);
const [isWaitingForPinnedMessageFocus, setWaitingForPinnedMessageFocus] = useState(false);
const { isTablet } = useAppLayout();
const [pinnedMessageIndex, setPinnedMessageIndex] = useState(0);
const pinnedMessageId = Array.isArray(pinnedMessageIds) ? pinnedMessageIds[pinnedMessageIndex] : pinnedMessageIds;
const currentPinnedIndexes = useDerivedState(getCurrentPinnedIndexes);
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 pinnedMessagesCount = Array.isArray(pinnedMessageIds)
? pinnedMessageIds.length : (pinnedMessageIds ? 1 : undefined);
@ -160,25 +165,6 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
}
}, [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);
const { width: windowWidth } = useWindowSize();
@ -199,14 +185,14 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
}, [pinMessage]);
const handlePinnedMessageClick = useCallback((): void => {
if (pinnedMessage) {
if (!pinnedMessage) return;
if (onFocusPinnedMessage(pinnedMessage.id)) {
focusMessage({
chatId: pinnedMessage.chatId, threadId, messageId: pinnedMessage.id, noForumTopicPanel: true,
});
setWaitingForPinnedMessageFocus(true);
}
}, [pinnedMessage, focusMessage, threadId]);
}, [pinnedMessage, threadId, onFocusPinnedMessage]);
const handleAllPinnedClick = useCallback(() => {
openChat({ id: chatId, threadId, type: 'pinned' });
@ -440,12 +426,14 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
key={chatId}
message={renderingPinnedMessage}
count={renderingPinnedMessagesCount || 0}
index={pinnedMessageIndex}
index={currentPinnedIndex}
customTitle={renderingPinnedMessageTitle}
className={buildClassName(pinnedMessageClassNames, isPinnedMessagesFullWidth && 'full-width')}
className={pinnedMessageClassNames}
onUnpinMessage={renderingCanUnpin ? handleUnpinMessage : undefined}
onClick={handlePinnedMessageClick}
onAllPinnedClick={handleAllPinnedClick}
isLoading={waitingForPinnedId !== undefined}
isFullWidth={isPinnedMessagesFullWidth}
/>
)}
@ -514,7 +502,6 @@ export default memo(withGlobal<OwnProps>(
);
const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest);
const typingStatus = selectThreadParam(global, chatId, threadId, 'typingStatus');
const focusedMessage = selectTabState(global).focusedMessage;
const state: StateProps = {
typingStatus,
@ -531,7 +518,6 @@ export default memo(withGlobal<OwnProps>(
connectionState: global.connectionState,
isSyncing: global.isSyncing,
hasButtonInHeader: canStartBot || canRestartBot || canSubscribe || shouldSendJoinRequest,
hasReachedFocusedMessage: !focusedMessage || focusedMessage.hasReachedMessage,
};
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 styles from './PinnedMessageNavigation.module.scss';
type OwnProps = {
count: number;
index: number;
@ -66,9 +68,9 @@ const PinnedMessageNavigation: FC<OwnProps> = ({
if (count === 1) {
return (
<div className="pinned-message-border">
<div className={styles.pinnedMessageBorder}>
<div
className="pinned-message-border-wrapper-1"
className={styles.pinnedMessageBorderWrapper1}
ref={containerRef}
/>
</div>
@ -80,9 +82,13 @@ const PinnedMessageNavigation: FC<OwnProps> = ({
} = markupParams;
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
className="pinned-message-border-wrapper"
className={styles.pinnedMessageBorderWrapper}
ref={containerRef}
style={
`clip-path: url("#${clipPathId}"); width: 2px;
@ -91,8 +97,9 @@ const PinnedMessageNavigation: FC<OwnProps> = ({
>
<span />
<div
className="pinned-message-border-mark"
style={`height: ${markHeight}px; transform: translateY(${markTranslateY}px);`}
className={styles.pinnedMessageBorderMark}
style={`--height: ${markHeight}px; --translate-y: ${markTranslateY}px; `
+ `--translate-track: ${trackTranslateY}px;`}
/>
</div>
</div>

View File

@ -2,6 +2,7 @@ import type { RefObject } from 'react';
import { getActions } from '../../../global';
import type { MessageListType } from '../../../global/types';
import type { PinnedIntersectionChangedCallback } from './usePinnedMessage';
import { IS_ANDROID } from '../../../util/windowEnvironment';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
@ -15,8 +16,11 @@ export default function useMessageObservers(
type: MessageListType,
containerRef: RefObject<HTMLDivElement>,
memoFirstUnreadIdRef: { current: number | undefined },
onPinnedIntersectionChange: PinnedIntersectionChangedCallback,
) {
const { markMessageListRead, markMentionsRead, animateUnreadReaction } = getActions();
const {
markMessageListRead, markMentionsRead, animateUnreadReaction,
} = getActions();
const { isMobile } = useAppLayout();
const INTERSECTION_MARGIN_FOR_LOADING = isMobile ? 300 : 500;
@ -34,17 +38,28 @@ export default function useMessageObservers(
let maxId = 0;
const mentionIds: number[] = [];
const reactionIds: number[] = [];
const viewportPinnedIdsToAdd: number[] = [];
const viewportPinnedIdsToRemove: number[] = [];
let isReversed = false;
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 (dataset.isPinned) {
if (rootBounds && boundingClientRect.bottom < rootBounds.top) {
isReversed = true;
}
viewportPinnedIdsToRemove.push(messageId);
}
return;
}
const { dataset } = target as HTMLDivElement;
const messageId = Number(dataset.lastMessageId || dataset.messageId);
if (messageId > maxId) {
maxId = messageId;
}
@ -56,6 +71,10 @@ export default function useMessageObservers(
if (dataset.hasUnreadReaction) {
reactionIds.push(messageId);
}
if (dataset.isPinned) {
viewportPinnedIdsToAdd.push(messageId);
}
});
if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) {
@ -69,6 +88,10 @@ export default function useMessageObservers(
if (reactionIds.length) {
animateUnreadReaction({ messageIds: reactionIds });
}
if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) {
onPinnedIntersectionChange({ viewportPinnedIdsToAdd, viewportPinnedIdsToRemove, isReversed });
}
});
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,
} from '../../../types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { PinnedIntersectionChangedCallback } from '../hooks/usePinnedMessage';
import { AudioOrigin } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
@ -185,6 +186,7 @@ type OwnProps =
noReplies: boolean;
appearanceOrder: number;
memoFirstUnreadIdRef: { current: number | undefined };
onPinnedIntersectionChange: PinnedIntersectionChangedCallback;
}
& MessagePositionProperties;
@ -226,6 +228,7 @@ type StateProps = {
isDownloading: boolean;
threadId?: number;
isPinnedList?: boolean;
isPinned?: boolean;
canAutoLoadMedia?: boolean;
canAutoPlayMedia?: boolean;
hasLinkedChat?: boolean;
@ -335,6 +338,7 @@ const Message: FC<OwnProps & StateProps> = ({
activeEmojiInteractions,
messageListType,
isPinnedList,
isPinned,
isDownloading,
canAutoLoadMedia,
canAutoPlayMedia,
@ -350,6 +354,7 @@ const Message: FC<OwnProps & StateProps> = ({
chatTranslations,
areTranslationsEnabled,
requestedTranslationLanguage,
onPinnedIntersectionChange,
}) => {
const {
toggleMessageSelection,
@ -397,12 +402,22 @@ const Message: FC<OwnProps & StateProps> = ({
setTimeout(markShown, appearanceOrder * APPEARANCE_DELAY);
}, [appearanceOrder, markShown, noAppearanceAnimation]);
const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false);
const {
id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError,
} = 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 isOwn = isOwnMessage(message);
const isScheduled = messageListType === 'scheduled' || message.isScheduled;
@ -772,6 +787,7 @@ const Message: FC<OwnProps & StateProps> = ({
const meta = (
<MessageMeta
message={message}
isPinned={isPinned}
noReplies={noReplies}
repliesThreadInfo={repliesThreadInfo}
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-has-unread-mention={message.hasUnreadMention || undefined}
data-has-unread-reaction={hasUnreadReaction || undefined}
data-is-pinned={isPinned || undefined}
/>
{!isInDocumentGroup && (
<div className="message-select-control">
@ -1407,6 +1424,7 @@ export default memo(withGlobal<OwnProps>(
threadId,
isDownloading,
isPinnedList: messageListType === 'pinned',
isPinned: message.isPinned || Boolean(message.forwardInfo?.isLinkedChannelPost),
canAutoLoadMedia: selectCanAutoLoadMedia(global, message),
canAutoPlayMedia: selectCanAutoPlayMedia(global, message),
autoLoadFileMaxSizeMb: global.settings.byKey.autoLoadFileMaxSizeMb,

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,8 @@ export type TransitionProps = {
activeKey: number;
name: (
'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;
renderCount?: number;

View File

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

View File

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

View File

@ -22,7 +22,7 @@ import {
replaceThreadParam,
replaceTabThreadParam,
updateFocusDirection,
updateFocusedMessage, updateFocusedMessageReached,
updateFocusedMessage,
} from '../../reducers';
import {
selectCurrentChat,
@ -381,12 +381,6 @@ addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType
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 => {
const {
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);
if (viewportIds && viewportIds.includes(messageId)) {
setGlobal(global);
setGlobal(global, { forceOnHeavyAnimation: true });
actions.openChat({
id: chatId,
threadId,
@ -450,14 +444,12 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
global = replaceTabThreadParam(global, chatId, threadId, 'viewportIds', undefined, tabId);
}
global = replaceTabThreadParam(global, chatId, threadId, 'outlyingIds', undefined, tabId);
if (viewportIds && !shouldSwitchChat) {
const direction = messageId > viewportIds[0] ? FocusDirection.Down : FocusDirection.Up;
global = updateFocusDirection(global, direction, tabId);
}
setGlobal(global);
setGlobal(global, { forceOnHeavyAnimation: true });
actions.openChat({
id: chatId,
@ -468,6 +460,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
});
actions.loadViewportMessages({
tabId,
shouldForceRender: true,
});
return undefined;
});

View File

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

View File

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

View File

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

View File

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

View File

@ -66,12 +66,16 @@ const containers = new Map<string, {
const runCallbacksThrottled = throttleWithTickEnd(runCallbacks);
let forceOnHeavyAnimation = true;
function runImmediateCallbacks() {
immediateCallbacks.forEach((cb) => cb(currentGlobal));
}
function runCallbacks(forceOnHeavyAnimation = false) {
if (!forceOnHeavyAnimation && isHeavyAnimating()) {
function runCallbacks() {
if (forceOnHeavyAnimation) {
forceOnHeavyAnimation = false;
} else if (isHeavyAnimating()) {
fastRafWithFallback(runCallbacksThrottled);
return;
}
@ -94,9 +98,14 @@ export function setGlobal(newGlobal?: GlobalState, options?: ActionOptions) {
if (!options?.noUpdate) runImmediateCallbacks();
if (options?.forceSyncOnIOs) {
runCallbacks(true);
forceOnHeavyAnimation = true;
runCallbacks();
} 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 {
content: "\e991";
}
.icon-pinned-message:before {
content: "\e9bf";
}
.icon-archive-filled:before {
content: "\e9ba";
}

View File

@ -148,6 +148,43 @@ function isObject(value: any): value is object {
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 {
let cursor = array.length;