Message: Support sticky avatar (#5360)

This commit is contained in:
Alexander Zinchuk 2024-12-29 11:59:16 +01:00
parent 8a8aea1b4d
commit d00230ad33
13 changed files with 314 additions and 89 deletions

View File

@ -92,13 +92,13 @@ export function useStickerPickerObservers(
const stickerSetEl = document.getElementById(`${idPrefix}-${index}`)!; const stickerSetEl = document.getElementById(`${idPrefix}-${index}`)!;
const isClose = Math.abs(currentIndex - index) === 1; const isClose = Math.abs(currentIndex - index) === 1;
animateScroll( animateScroll({
containerRef.current!, container: containerRef.current!,
stickerSetEl, element: stickerSetEl,
'start', position: 'start',
FOCUS_MARGIN, margin: FOCUS_MARGIN,
isClose ? SCROLL_MAX_DISTANCE_WHEN_CLOSE : SCROLL_MAX_DISTANCE_WHEN_FAR, maxDistance: isClose ? SCROLL_MAX_DISTANCE_WHEN_CLOSE : SCROLL_MAX_DISTANCE_WHEN_FAR,
); });
return index; return index;
}); });

View File

@ -97,7 +97,12 @@ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
return; return;
} }
animateScroll(messagesContainer, lastMessageElement, 'end', FOCUS_MARGIN); animateScroll({
container: messagesContainer,
element: lastMessageElement,
position: 'end',
margin: FOCUS_MARGIN,
});
} }
}); });

View File

@ -555,16 +555,13 @@ const MessageList: FC<OwnProps & StateProps> = ({
// Break out of `forceLayout` // Break out of `forceLayout`
requestMeasure(() => { requestMeasure(() => {
const shouldScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement; const shouldScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement;
animateScroll({
animateScroll(
container, container,
shouldScrollToBottom ? lastItemElement! : firstUnreadElement!, element: shouldScrollToBottom ? lastItemElement! : firstUnreadElement!,
shouldScrollToBottom ? 'end' : 'start', position: shouldScrollToBottom ? 'end' : 'start',
BOTTOM_FOCUS_MARGIN, margin: BOTTOM_FOCUS_MARGIN,
undefined, forceDuration: noMessageSendingAnimation ? 0 : undefined,
undefined, });
noMessageSendingAnimation ? 0 : undefined,
);
}); });
} }

View File

@ -31,6 +31,7 @@ import useScrollHooks from './hooks/useScrollHooks';
import ActionMessage from './ActionMessage'; import ActionMessage from './ActionMessage';
import Message from './message/Message'; import Message from './message/Message';
import SenderGroupContainer from './message/SenderGroupContainer';
import SponsoredMessage from './message/SponsoredMessage'; import SponsoredMessage from './message/SponsoredMessage';
import MessageListBotInfo from './MessageListBotInfo'; import MessageListBotInfo from './MessageListBotInfo';
@ -142,12 +143,10 @@ const MessageListContent: FC<OwnProps> = ({
messageIds && prevMessageIds && messageIds[messageIds.length - 2] === prevMessageIds[prevMessageIds.length - 1], messageIds && prevMessageIds && messageIds[messageIds.length - 2] === prevMessageIds[prevMessageIds.length - 1],
); );
const dateGroups = messageGroups.map(( function calculateSenderGroups(
dateGroup: MessageDateGroup, dateGroup: MessageDateGroup, dateGroupIndex: number, dateGroupsArray: MessageDateGroup[],
dateGroupIndex: number, ) {
dateGroupsArray: MessageDateGroup[], return dateGroup.senderGroups.map((
) => {
const senderGroups = dateGroup.senderGroups.map((
senderGroup, senderGroup,
senderGroupIndex, senderGroupIndex,
senderGroupsArray, senderGroupsArray,
@ -186,7 +185,7 @@ const MessageListContent: FC<OwnProps> = ({
let currentDocumentGroupId: string | undefined; let currentDocumentGroupId: string | undefined;
return senderGroup.map(( const senderGroupElements = senderGroup.map((
messageOrAlbum, messageOrAlbum,
messageIndex, messageIndex,
) => { ) => {
@ -260,7 +259,44 @@ const MessageListContent: FC<OwnProps> = ({
), ),
]); ]);
}).flat(); }).flat();
if (!withUsers) return senderGroupElements;
const lastMessageOrAlbum = senderGroup[senderGroup.length - 1];
const lastMessage = isAlbum(lastMessageOrAlbum) ? lastMessageOrAlbum.mainMessage : lastMessageOrAlbum;
const lastMessageId = getMessageOriginalId(lastMessage);
const isTopicTopMessage = lastMessage.id === threadId;
const isOwn = isOwnMessage(lastMessage);
const firstMessageOrAlbum = senderGroup[0];
const firstMessage = isAlbum(firstMessageOrAlbum) ? firstMessageOrAlbum.mainMessage : firstMessageOrAlbum;
const firstMessageId = getMessageOriginalId(firstMessage);
const key = `${firstMessageId}-${lastMessageId}`;
const id = (firstMessageId === lastMessageId) ? `message-group-${firstMessageId}`
: `message-group-${firstMessageId}-${lastMessageId}`;
const withAvatar = withUsers && !isOwn && (!isTopicTopMessage || !isComments);
return (
<SenderGroupContainer
key={key}
id={id}
message={lastMessage}
withAvatar={withAvatar}
>
{senderGroupElements}
</SenderGroupContainer>
);
}); });
}
const dateGroups = messageGroups.map((
dateGroup: MessageDateGroup,
dateGroupIndex: number,
dateGroupsArray: MessageDateGroup[],
) => {
const senderGroups = calculateSenderGroups(dateGroup, dateGroupIndex, dateGroupsArray);
return ( return (
<div <div

View File

@ -174,7 +174,13 @@ const EmojiPicker: FC<OwnProps & StateProps> = ({
setActiveCategoryIndex(index); setActiveCategoryIndex(index);
const categoryEl = containerRef.current!.closest<HTMLElement>('.SymbolMenu-main')! const categoryEl = containerRef.current!.closest<HTMLElement>('.SymbolMenu-main')!
.querySelector(`#emoji-category-${index}`)! as HTMLElement; .querySelector(`#emoji-category-${index}`)! as HTMLElement;
animateScroll(containerRef.current!, categoryEl, 'start', FOCUS_MARGIN, SMOOTH_SCROLL_DISTANCE); animateScroll({
container: containerRef.current!,
element: categoryEl,
position: 'start',
margin: FOCUS_MARGIN,
maxDistance: SMOOTH_SCROLL_DISTANCE,
});
}); });
const handleEmojiSelect = useLastCallback((emoji: string, name: string) => { const handleEmojiSelect = useLastCallback((emoji: string, name: string) => {

View File

@ -618,7 +618,6 @@ const Message: FC<OwnProps & StateProps> = ({
); );
const { const {
handleAvatarClick,
handleSenderClick, handleSenderClick,
handleViaBotClick, handleViaBotClick,
handleReplyClick, handleReplyClick,
@ -974,19 +973,6 @@ const Message: FC<OwnProps & StateProps> = ({
contentWidth, noMediaCorners, style, reactionsMaxWidth, contentWidth, noMediaCorners, style, reactionsMaxWidth,
} = sizeCalculations; } = sizeCalculations;
function renderAvatar() {
const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined;
return (
<Avatar
size="small"
peer={avatarPeer}
text={hiddenName}
onClick={avatarPeer ? handleAvatarClick : undefined}
/>
);
}
function renderMessageText(isForAnimation?: boolean) { function renderMessageText(isForAnimation?: boolean) {
if (!textMessage) return undefined; if (!textMessage) return undefined;
return ( return (
@ -1629,7 +1615,6 @@ const Message: FC<OwnProps & StateProps> = ({
)} )}
</div> </div>
)} )}
{withAvatar && renderAvatar()}
<div <div
className={buildClassName('message-content-wrapper', className={buildClassName('message-content-wrapper',
contentClassName.includes('text') && 'can-select-text', contentClassName.includes('text') && 'can-select-text',

View File

@ -0,0 +1,32 @@
.root {
position: relative;
}
.avatarContainer {
position: absolute;
flex-direction: column-reverse;
display: flex;
top: 0;
bottom: 0;
z-index: 2;
transform: translateX(0);
transition: transform var(--select-transition);
:global(.select-mode-active) & {
transform: translateX(2.5rem);
}
}
.senderAvatar {
position: sticky !important;
bottom: 0.25rem;
:global(.select-mode-active) & {
bottom: 5rem;
@media (max-width: 600px) {
bottom: 3.6875rem;
}
}
}

View File

@ -0,0 +1,133 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type {
ApiMessage,
ApiPeer,
} from '../../../api/types';
import {
isAnonymousForwardsChat,
isAnonymousOwnMessage,
isSystemBot,
} from '../../../global/helpers';
import {
selectForwardedSender,
selectIsChatWithSelf,
selectSender,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import useLastCallback from '../../../hooks/useLastCallback';
import Avatar from '../../common/Avatar';
import styles from './SenderGroupContainer.module.scss';
type OwnProps =
{
message: ApiMessage;
withAvatar?: boolean;
children: React.ReactNode;
id: string;
};
type StateProps = {
sender?: ApiPeer;
canShowSender: boolean;
originSender?: ApiPeer;
isChatWithSelf?: boolean;
isRepliesChat?: boolean;
isAnonymousForwards?: boolean;
};
const SenderGroupContainer: FC<OwnProps & StateProps> = ({
message,
withAvatar,
children,
id,
sender,
canShowSender,
originSender,
isChatWithSelf,
isRepliesChat,
isAnonymousForwards,
}) => {
const { openChat } = getActions();
const { forwardInfo } = message;
const messageSender = canShowSender ? sender : undefined;
const shouldPreferOriginSender = forwardInfo
&& (isChatWithSelf || isRepliesChat || isAnonymousForwards || !messageSender);
const avatarPeer = shouldPreferOriginSender ? originSender : messageSender;
const handleAvatarClick = useLastCallback(() => {
if (!avatarPeer) {
return;
}
openChat({ id: avatarPeer.id });
});
function renderAvatar() {
const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined;
return (
<Avatar
size="small"
className={styles.senderAvatar}
peer={avatarPeer}
text={hiddenName}
onClick={avatarPeer ? handleAvatarClick : undefined}
/>
);
}
const className = buildClassName(
'sender-group-container',
styles.root,
);
return (
<div id={id} className={className}>
{withAvatar && (
<div className={styles.avatarContainer}>
{renderAvatar()}
</div>
)}
{children}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, ownProps): StateProps => {
const {
message, withAvatar,
} = ownProps;
const { chatId } = message;
const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const isSystemBotChat = isSystemBot(chatId);
const isAnonymousForwards = isAnonymousForwardsChat(chatId);
const forceSenderName = !isChatWithSelf && isAnonymousOwnMessage(message);
const canShowSender = withAvatar || forceSenderName;
const sender = selectSender(global, message);
const originSender = selectForwardedSender(global, message);
return {
sender,
canShowSender,
originSender,
isChatWithSelf,
isRepliesChat: isSystemBotChat,
isAnonymousForwards,
};
},
)(SenderGroupContainer));

View File

@ -48,17 +48,19 @@ export default function useFocusMessage({
const scrollPosition = scrollTargetPosition || isToBottom ? 'end' : 'centerOrTop'; const scrollPosition = scrollTargetPosition || isToBottom ? 'end' : 'centerOrTop';
const exec = () => { const exec = () => {
const result = animateScroll( const maxDistance = focusDirection !== undefined
messagesContainer, ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined;
elementRef.current!,
scrollPosition, const result = animateScroll({
FOCUS_MARGIN, container: messagesContainer,
focusDirection !== undefined ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined, element: elementRef.current!,
focusDirection, position: scrollPosition,
undefined, margin: FOCUS_MARGIN,
isResizingContainer, maxDistance,
true, forceDirection: focusDirection,
); forceNormalContainerHeight: isResizingContainer,
shouldReturnMutationFn: true,
});
if (isQuote) { if (isQuote) {
const firstQuote = elementRef.current!.querySelector<HTMLSpanElement>('.is-quote'); const firstQuote = elementRef.current!.querySelector<HTMLSpanElement>('.is-quote');

View File

@ -23,7 +23,6 @@ export default function useInnerHandlers({
asForwarded, asForwarded,
isScheduled, isScheduled,
album, album,
avatarPeer,
senderPeer, senderPeer,
botSender, botSender,
messageTopic, messageTopic,
@ -66,14 +65,6 @@ export default function useInnerHandlers({
replyToMsgId, replyToPeerId, replyToTopId, isQuote, quoteText, replyToMsgId, replyToPeerId, replyToTopId, isQuote, quoteText,
} = getMessageReplyInfo(message) || {}; } = getMessageReplyInfo(message) || {};
const handleAvatarClick = useLastCallback(() => {
if (!avatarPeer) {
return;
}
openChat({ id: avatarPeer.id });
});
const handleSenderClick = useLastCallback(() => { const handleSenderClick = useLastCallback(() => {
if (!senderPeer) { if (!senderPeer) {
showNotification({ message: lang('HidAccount') }); showNotification({ message: lang('HidAccount') });
@ -255,7 +246,6 @@ export default function useInnerHandlers({
}); });
return { return {
handleAvatarClick,
handleSenderClick, handleSenderClick,
handleViaBotClick, handleViaBotClick,
handleReplyClick, handleReplyClick,

View File

@ -33,7 +33,12 @@ export default function useProfileState(
if (container.scrollTop < tabsEl.offsetTop) { if (container.scrollTop < tabsEl.offsetTop) {
onProfileStateChange(getStateFromTabType(tabType)); onProfileStateChange(getStateFromTabType(tabType));
isScrollingProgrammatically = true; isScrollingProgrammatically = true;
animateScroll(container, tabsEl, 'start', undefined, undefined, undefined, TRANSITION_DURATION); animateScroll({
container,
element: tabsEl,
position: 'start',
forceDuration: TRANSITION_DURATION,
});
setTimeout(() => { setTimeout(() => {
isScrollingProgrammatically = false; isScrollingProgrammatically = false;
}, PROGRAMMATIC_SCROLL_TIMEOUT_MS); }, PROGRAMMATIC_SCROLL_TIMEOUT_MS);
@ -59,13 +64,13 @@ export default function useProfileState(
} }
isScrollingProgrammatically = true; isScrollingProgrammatically = true;
animateScroll(
animateScroll({
container, container,
container.firstElementChild as HTMLElement, element: container.firstElementChild as HTMLElement,
'start', position: 'start',
undefined, maxDistance: container.offsetHeight * 2,
container.offsetHeight * 2, });
);
setTimeout(() => { setTimeout(() => {
isScrollingProgrammatically = false; isScrollingProgrammatically = false;

View File

@ -15,19 +15,27 @@ import { selectCanAnimateInterface } from '../global/selectors';
import { animateSingle, cancelSingleAnimation } from './animation'; import { animateSingle, cancelSingleAnimation } from './animation';
import { IS_ANDROID } from './windowEnvironment'; import { IS_ANDROID } from './windowEnvironment';
type Params = Parameters<typeof createMutateFunction>; export type AnimateScrollArgs = {
container: HTMLElement;
element: HTMLElement;
position: ScrollTargetPosition;
margin?: number;
maxDistance?: number;
forceDirection?: FocusDirection;
forceDuration?: number;
forceNormalContainerHeight?: boolean;
shouldReturnMutationFn?: boolean;
};
let isAnimating = false; let isAnimating = false;
let currentArgs: Parameters<typeof createMutateFunction> | undefined; let currentArgs: AnimateScrollArgs | undefined;
let onHeavyAnimationEnd: NoneToVoidFunction | undefined; let onHeavyAnimationEnd: NoneToVoidFunction | undefined;
export default function animateScroll(...args: Params | [...Params, boolean]) { export default function animateScroll(args: AnimateScrollArgs) {
currentArgs = args.slice(0, 8) as Params; currentArgs = args;
const mutate = createMutateFunction(args);
const mutate = createMutateFunction(...currentArgs); if (args.shouldReturnMutationFn) {
const shouldReturnMutationFn = args[8];
if (shouldReturnMutationFn) {
return mutate; return mutate;
} }
@ -43,20 +51,39 @@ export function restartCurrentScrollAnimation() {
cancelSingleAnimation(); cancelSingleAnimation();
requestMeasure(() => { requestMeasure(() => {
requestMutation(createMutateFunction(...currentArgs!)); requestMutation(createMutateFunction(currentArgs!));
}); });
} }
function createMutateFunction( function getOffsetToContainer(element: HTMLElement, container: HTMLElement) {
container: HTMLElement, let offsetTop = 0;
element: HTMLElement, let offsetLeft = 0;
position: ScrollTargetPosition,
margin = 0, let current: HTMLElement | null = element;
maxDistance = SCROLL_MAX_DISTANCE,
forceDirection?: FocusDirection, while (current && current !== container && !current.contains(container)) {
forceDuration?: number, offsetTop += current.offsetTop;
forceNormalContainerHeight?: boolean, offsetLeft += current.offsetLeft;
) {
current = current.offsetParent as HTMLElement;
}
return { top: offsetTop, left: offsetLeft };
}
function createMutateFunction(args: AnimateScrollArgs) {
const {
container,
element,
position,
margin = 0,
maxDistance = SCROLL_MAX_DISTANCE,
forceDirection,
forceNormalContainerHeight,
} = args;
let forceDuration = args.forceDuration;
if ( if (
forceDirection === FocusDirection.Static forceDirection === FocusDirection.Static
|| !selectCanAnimateInterface(getGlobal()) || !selectCanAnimateInterface(getGlobal())
@ -64,8 +91,10 @@ function createMutateFunction(
forceDuration = 0; forceDuration = 0;
} }
const { offsetTop: elementTop, offsetHeight: elementHeight } = element; const { offsetHeight: elementHeight } = element;
const { scrollTop: currentScrollTop, offsetHeight: containerHeight, scrollHeight } = container; const { scrollTop: currentScrollTop, offsetHeight: containerHeight, scrollHeight } = container;
const elementTop = getOffsetToContainer(element, container).top;
const targetContainerHeight = forceNormalContainerHeight && container.dataset.normalHeight const targetContainerHeight = forceNormalContainerHeight && container.dataset.normalHeight
? Number(container.dataset.normalHeight) ? Number(container.dataset.normalHeight)
: containerHeight; : containerHeight;

View File

@ -25,6 +25,11 @@ export default function setTooltipItemVisible(selector: string, index: number, c
if (!visibleIndexes.includes(index) if (!visibleIndexes.includes(index)
|| (index === first && !isFullyVisible(container, allElements[first]))) { || (index === first && !isFullyVisible(container, allElements[first]))) {
const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end'; const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end';
animateScroll(container, allElements[index], position, SCROLL_MARGIN); animateScroll({
container,
element: allElements[index],
position,
margin: SCROLL_MARGIN,
});
} }
} }