From d00230ad332c2c01055ee3e470bf035742cf9381 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sun, 29 Dec 2024 11:59:16 +0100 Subject: [PATCH] Message: Support sticky avatar (#5360) --- .../common/hooks/useStickerPickerObservers.ts | 14 +- .../middle/FloatingActionButtons.tsx | 7 +- src/components/middle/MessageList.tsx | 15 +- src/components/middle/MessageListContent.tsx | 50 ++++++- .../middle/composer/EmojiPicker.tsx | 8 +- src/components/middle/message/Message.tsx | 15 -- .../message/SenderGroupContainer.module.scss | 32 +++++ .../middle/message/SenderGroupContainer.tsx | 133 ++++++++++++++++++ .../middle/message/hooks/useFocusMessage.ts | 24 ++-- .../middle/message/hooks/useInnerHandlers.ts | 10 -- src/components/right/hooks/useProfileState.ts | 19 ++- src/util/animateScroll.ts | 69 ++++++--- src/util/setTooltipItemVisible.ts | 7 +- 13 files changed, 314 insertions(+), 89 deletions(-) create mode 100644 src/components/middle/message/SenderGroupContainer.module.scss create mode 100644 src/components/middle/message/SenderGroupContainer.tsx diff --git a/src/components/common/hooks/useStickerPickerObservers.ts b/src/components/common/hooks/useStickerPickerObservers.ts index 279f1503e..7b281ff9c 100644 --- a/src/components/common/hooks/useStickerPickerObservers.ts +++ b/src/components/common/hooks/useStickerPickerObservers.ts @@ -92,13 +92,13 @@ export function useStickerPickerObservers( const stickerSetEl = document.getElementById(`${idPrefix}-${index}`)!; const isClose = Math.abs(currentIndex - index) === 1; - animateScroll( - containerRef.current!, - stickerSetEl, - 'start', - FOCUS_MARGIN, - isClose ? SCROLL_MAX_DISTANCE_WHEN_CLOSE : SCROLL_MAX_DISTANCE_WHEN_FAR, - ); + animateScroll({ + container: containerRef.current!, + element: stickerSetEl, + position: 'start', + margin: FOCUS_MARGIN, + maxDistance: isClose ? SCROLL_MAX_DISTANCE_WHEN_CLOSE : SCROLL_MAX_DISTANCE_WHEN_FAR, + }); return index; }); diff --git a/src/components/middle/FloatingActionButtons.tsx b/src/components/middle/FloatingActionButtons.tsx index def72edec..d27eb1e52 100644 --- a/src/components/middle/FloatingActionButtons.tsx +++ b/src/components/middle/FloatingActionButtons.tsx @@ -97,7 +97,12 @@ const FloatingActionButtons: FC = ({ return; } - animateScroll(messagesContainer, lastMessageElement, 'end', FOCUS_MARGIN); + animateScroll({ + container: messagesContainer, + element: lastMessageElement, + position: 'end', + margin: FOCUS_MARGIN, + }); } }); diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index d0e7825b1..bb35cdb21 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -555,16 +555,13 @@ const MessageList: FC = ({ // Break out of `forceLayout` requestMeasure(() => { const shouldScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement; - - animateScroll( + animateScroll({ container, - shouldScrollToBottom ? lastItemElement! : firstUnreadElement!, - shouldScrollToBottom ? 'end' : 'start', - BOTTOM_FOCUS_MARGIN, - undefined, - undefined, - noMessageSendingAnimation ? 0 : undefined, - ); + element: shouldScrollToBottom ? lastItemElement! : firstUnreadElement!, + position: shouldScrollToBottom ? 'end' : 'start', + margin: BOTTOM_FOCUS_MARGIN, + forceDuration: noMessageSendingAnimation ? 0 : undefined, + }); }); } diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 184ec3b3a..ddf941a77 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -31,6 +31,7 @@ import useScrollHooks from './hooks/useScrollHooks'; import ActionMessage from './ActionMessage'; import Message from './message/Message'; +import SenderGroupContainer from './message/SenderGroupContainer'; import SponsoredMessage from './message/SponsoredMessage'; import MessageListBotInfo from './MessageListBotInfo'; @@ -142,12 +143,10 @@ const MessageListContent: FC = ({ messageIds && prevMessageIds && messageIds[messageIds.length - 2] === prevMessageIds[prevMessageIds.length - 1], ); - const dateGroups = messageGroups.map(( - dateGroup: MessageDateGroup, - dateGroupIndex: number, - dateGroupsArray: MessageDateGroup[], - ) => { - const senderGroups = dateGroup.senderGroups.map(( + function calculateSenderGroups( + dateGroup: MessageDateGroup, dateGroupIndex: number, dateGroupsArray: MessageDateGroup[], + ) { + return dateGroup.senderGroups.map(( senderGroup, senderGroupIndex, senderGroupsArray, @@ -186,7 +185,7 @@ const MessageListContent: FC = ({ let currentDocumentGroupId: string | undefined; - return senderGroup.map(( + const senderGroupElements = senderGroup.map(( messageOrAlbum, messageIndex, ) => { @@ -260,7 +259,44 @@ const MessageListContent: FC = ({ ), ]); }).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 ( + + {senderGroupElements} + + ); }); + } + + const dateGroups = messageGroups.map(( + dateGroup: MessageDateGroup, + dateGroupIndex: number, + dateGroupsArray: MessageDateGroup[], + ) => { + const senderGroups = calculateSenderGroups(dateGroup, dateGroupIndex, dateGroupsArray); return (
= ({ setActiveCategoryIndex(index); const categoryEl = containerRef.current!.closest('.SymbolMenu-main')! .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) => { diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 4e9aa6029..305499339 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -618,7 +618,6 @@ const Message: FC = ({ ); const { - handleAvatarClick, handleSenderClick, handleViaBotClick, handleReplyClick, @@ -974,19 +973,6 @@ const Message: FC = ({ contentWidth, noMediaCorners, style, reactionsMaxWidth, } = sizeCalculations; - function renderAvatar() { - const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined; - - return ( - - ); - } - function renderMessageText(isForAnimation?: boolean) { if (!textMessage) return undefined; return ( @@ -1629,7 +1615,6 @@ const Message: FC = ({ )}
)} - {withAvatar && renderAvatar()}
= ({ + 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 ( + + ); + } + + const className = buildClassName( + 'sender-group-container', + styles.root, + ); + + return ( +
+ {withAvatar && ( +
+ {renderAvatar()} +
+ )} + {children} +
+ ); +}; + +export default memo(withGlobal( + (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)); diff --git a/src/components/middle/message/hooks/useFocusMessage.ts b/src/components/middle/message/hooks/useFocusMessage.ts index b7df10207..4e29e1dc5 100644 --- a/src/components/middle/message/hooks/useFocusMessage.ts +++ b/src/components/middle/message/hooks/useFocusMessage.ts @@ -48,17 +48,19 @@ export default function useFocusMessage({ const scrollPosition = scrollTargetPosition || isToBottom ? 'end' : 'centerOrTop'; const exec = () => { - const result = animateScroll( - messagesContainer, - elementRef.current!, - scrollPosition, - FOCUS_MARGIN, - focusDirection !== undefined ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined, - focusDirection, - undefined, - isResizingContainer, - true, - ); + const maxDistance = focusDirection !== undefined + ? (isToBottom ? BOTTOM_FOCUS_OFFSET : RELOCATED_FOCUS_OFFSET) : undefined; + + const result = animateScroll({ + container: messagesContainer, + element: elementRef.current!, + position: scrollPosition, + margin: FOCUS_MARGIN, + maxDistance, + forceDirection: focusDirection, + forceNormalContainerHeight: isResizingContainer, + shouldReturnMutationFn: true, + }); if (isQuote) { const firstQuote = elementRef.current!.querySelector('.is-quote'); diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index ee2cee23a..dbb083bbf 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -23,7 +23,6 @@ export default function useInnerHandlers({ asForwarded, isScheduled, album, - avatarPeer, senderPeer, botSender, messageTopic, @@ -66,14 +65,6 @@ export default function useInnerHandlers({ replyToMsgId, replyToPeerId, replyToTopId, isQuote, quoteText, } = getMessageReplyInfo(message) || {}; - const handleAvatarClick = useLastCallback(() => { - if (!avatarPeer) { - return; - } - - openChat({ id: avatarPeer.id }); - }); - const handleSenderClick = useLastCallback(() => { if (!senderPeer) { showNotification({ message: lang('HidAccount') }); @@ -255,7 +246,6 @@ export default function useInnerHandlers({ }); return { - handleAvatarClick, handleSenderClick, handleViaBotClick, handleReplyClick, diff --git a/src/components/right/hooks/useProfileState.ts b/src/components/right/hooks/useProfileState.ts index 00192a0ff..79612ccff 100644 --- a/src/components/right/hooks/useProfileState.ts +++ b/src/components/right/hooks/useProfileState.ts @@ -33,7 +33,12 @@ export default function useProfileState( if (container.scrollTop < tabsEl.offsetTop) { onProfileStateChange(getStateFromTabType(tabType)); isScrollingProgrammatically = true; - animateScroll(container, tabsEl, 'start', undefined, undefined, undefined, TRANSITION_DURATION); + animateScroll({ + container, + element: tabsEl, + position: 'start', + forceDuration: TRANSITION_DURATION, + }); setTimeout(() => { isScrollingProgrammatically = false; }, PROGRAMMATIC_SCROLL_TIMEOUT_MS); @@ -59,13 +64,13 @@ export default function useProfileState( } isScrollingProgrammatically = true; - animateScroll( + + animateScroll({ container, - container.firstElementChild as HTMLElement, - 'start', - undefined, - container.offsetHeight * 2, - ); + element: container.firstElementChild as HTMLElement, + position: 'start', + maxDistance: container.offsetHeight * 2, + }); setTimeout(() => { isScrollingProgrammatically = false; diff --git a/src/util/animateScroll.ts b/src/util/animateScroll.ts index 2f2f0c684..f9234c199 100644 --- a/src/util/animateScroll.ts +++ b/src/util/animateScroll.ts @@ -15,19 +15,27 @@ import { selectCanAnimateInterface } from '../global/selectors'; import { animateSingle, cancelSingleAnimation } from './animation'; import { IS_ANDROID } from './windowEnvironment'; -type Params = Parameters; +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 currentArgs: Parameters | undefined; +let currentArgs: AnimateScrollArgs | undefined; let onHeavyAnimationEnd: NoneToVoidFunction | undefined; -export default function animateScroll(...args: Params | [...Params, boolean]) { - currentArgs = args.slice(0, 8) as Params; +export default function animateScroll(args: AnimateScrollArgs) { + currentArgs = args; + const mutate = createMutateFunction(args); - const mutate = createMutateFunction(...currentArgs); - - const shouldReturnMutationFn = args[8]; - if (shouldReturnMutationFn) { + if (args.shouldReturnMutationFn) { return mutate; } @@ -43,20 +51,39 @@ export function restartCurrentScrollAnimation() { cancelSingleAnimation(); requestMeasure(() => { - requestMutation(createMutateFunction(...currentArgs!)); + requestMutation(createMutateFunction(currentArgs!)); }); } -function createMutateFunction( - container: HTMLElement, - element: HTMLElement, - position: ScrollTargetPosition, - margin = 0, - maxDistance = SCROLL_MAX_DISTANCE, - forceDirection?: FocusDirection, - forceDuration?: number, - forceNormalContainerHeight?: boolean, -) { +function getOffsetToContainer(element: HTMLElement, container: HTMLElement) { + let offsetTop = 0; + let offsetLeft = 0; + + let current: HTMLElement | null = element; + + while (current && current !== container && !current.contains(container)) { + offsetTop += current.offsetTop; + 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 ( forceDirection === FocusDirection.Static || !selectCanAnimateInterface(getGlobal()) @@ -64,8 +91,10 @@ function createMutateFunction( forceDuration = 0; } - const { offsetTop: elementTop, offsetHeight: elementHeight } = element; + const { offsetHeight: elementHeight } = element; const { scrollTop: currentScrollTop, offsetHeight: containerHeight, scrollHeight } = container; + const elementTop = getOffsetToContainer(element, container).top; + const targetContainerHeight = forceNormalContainerHeight && container.dataset.normalHeight ? Number(container.dataset.normalHeight) : containerHeight; diff --git a/src/util/setTooltipItemVisible.ts b/src/util/setTooltipItemVisible.ts index fc52c235d..7dda580ab 100644 --- a/src/util/setTooltipItemVisible.ts +++ b/src/util/setTooltipItemVisible.ts @@ -25,6 +25,11 @@ export default function setTooltipItemVisible(selector: string, index: number, c if (!visibleIndexes.includes(index) || (index === first && !isFullyVisible(container, allElements[first]))) { 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, + }); } }