Middle Column: Better pinned message animation (#2716)
Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
This commit is contained in:
parent
2d49580287
commit
d965b6c479
@ -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 }),
|
||||
|
||||
@ -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.
@ -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}>
|
||||
|
||||
@ -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) || {};
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
269
src/components/middle/HeaderPinnedMessage.module.scss
Normal file
269
src/components/middle/HeaderPinnedMessage.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
35
src/components/middle/PinnedMessageNavigation.module.scss
Normal file
35
src/components/middle/PinnedMessageNavigation.module.scss
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
170
src/components/middle/hooks/usePinnedMessage.ts
Normal file
170
src/components/middle/hooks/usePinnedMessage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,9 @@
|
||||
.icon-volume-3:before {
|
||||
content: "\e991";
|
||||
}
|
||||
.icon-pinned-message:before {
|
||||
content: "\e9bf";
|
||||
}
|
||||
.icon-archive-filled:before {
|
||||
content: "\e9ba";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user