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 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;
});

View File

@ -97,7 +97,12 @@ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
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`
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,
});
});
}

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
let currentDocumentGroupId: string | undefined;
return senderGroup.map((
const senderGroupElements = senderGroup.map((
messageOrAlbum,
messageIndex,
) => {
@ -260,7 +259,44 @@ const MessageListContent: FC<OwnProps> = ({
),
]);
}).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 (
<div

View File

@ -174,7 +174,13 @@ const EmojiPicker: FC<OwnProps & StateProps> = ({
setActiveCategoryIndex(index);
const categoryEl = containerRef.current!.closest<HTMLElement>('.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) => {

View File

@ -618,7 +618,6 @@ const Message: FC<OwnProps & StateProps> = ({
);
const {
handleAvatarClick,
handleSenderClick,
handleViaBotClick,
handleReplyClick,
@ -974,19 +973,6 @@ const Message: FC<OwnProps & StateProps> = ({
contentWidth, noMediaCorners, style, reactionsMaxWidth,
} = 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) {
if (!textMessage) return undefined;
return (
@ -1629,7 +1615,6 @@ const Message: FC<OwnProps & StateProps> = ({
)}
</div>
)}
{withAvatar && renderAvatar()}
<div
className={buildClassName('message-content-wrapper',
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 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<HTMLSpanElement>('.is-quote');

View File

@ -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,

View File

@ -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;

View File

@ -15,19 +15,27 @@ import { selectCanAnimateInterface } from '../global/selectors';
import { animateSingle, cancelSingleAnimation } from './animation';
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 currentArgs: Parameters<typeof createMutateFunction> | 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;

View File

@ -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,
});
}
}