Group Typing Bubble Animations
This commit is contained in:
parent
88df942029
commit
283ef57779
14 changed files with 565 additions and 209 deletions
|
@ -2,124 +2,384 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
import { TypingAnimation } from './TypingAnimation';
|
||||
import { Avatar } from '../Avatar';
|
||||
|
||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import type { BadgeType } from '../../badges/types';
|
||||
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
||||
|
||||
const MAX_AVATARS_COUNT = 3;
|
||||
|
||||
export type PropsType = {
|
||||
type TypingContactType = Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'badges'
|
||||
| 'color'
|
||||
| 'id'
|
||||
| 'isMe'
|
||||
| 'phoneNumber'
|
||||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
>;
|
||||
|
||||
export type TypingBubblePropsType = {
|
||||
conversationId: string;
|
||||
conversationType: 'group' | 'direct';
|
||||
typingContactIdTimestamps: Record<string, number>;
|
||||
lastItemAuthorId: string | undefined;
|
||||
lastItemTimestamp: number | undefined;
|
||||
getConversation: (id: string) => ConversationType;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
typingContacts: Array<
|
||||
Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
| 'id'
|
||||
| 'isMe'
|
||||
| 'phoneNumber'
|
||||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
> & {
|
||||
badge: undefined | BadgeType;
|
||||
};
|
||||
|
||||
const SPRING_CONFIG = {
|
||||
mass: 1,
|
||||
tension: 986,
|
||||
friction: 64,
|
||||
precision: 0,
|
||||
velocity: 0,
|
||||
};
|
||||
|
||||
const AVATAR_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
width: '28px',
|
||||
x: '0px',
|
||||
y: '0px',
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0.5,
|
||||
scale: 0.5,
|
||||
width: '4px', // Match value of module-message__typing-avatar margin-inline-start
|
||||
x: '14px',
|
||||
y: '30px',
|
||||
},
|
||||
};
|
||||
|
||||
function TypingBubbleAvatar({
|
||||
conversationId,
|
||||
contact,
|
||||
visible,
|
||||
getPreferredBadge,
|
||||
onContactExit,
|
||||
showContactModal,
|
||||
i18n,
|
||||
theme,
|
||||
}: {
|
||||
conversationId: string;
|
||||
contact: TypingContactType | undefined;
|
||||
visible: boolean;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
onContactExit: (id: string | undefined) => void;
|
||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
}): ReactElement | null {
|
||||
const [springProps, springApi] = useSpring(
|
||||
{
|
||||
config: SPRING_CONFIG,
|
||||
from: AVATAR_ANIMATION_PROPS[visible ? 'hidden' : 'visible'],
|
||||
to: AVATAR_ANIMATION_PROPS[visible ? 'visible' : 'hidden'],
|
||||
onRest: () => {
|
||||
if (!visible) {
|
||||
onContactExit(contact?.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
[visible]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
springApi.stop();
|
||||
springApi.start(AVATAR_ANIMATION_PROPS[visible ? 'visible' : 'hidden']);
|
||||
}, [visible, springApi]);
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<animated.div className="module-message__typing-avatar" style={springProps}>
|
||||
<Avatar
|
||||
acceptedMessageRequest={contact.acceptedMessageRequest}
|
||||
avatarPath={contact.avatarPath}
|
||||
badge={getPreferredBadge(contact.badges)}
|
||||
color={contact.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={contact.isMe}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
showContactModal(contact.id, conversationId);
|
||||
}}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
profileName={contact.profileName}
|
||||
theme={theme}
|
||||
title={contact.title}
|
||||
sharedGroupNames={contact.sharedGroupNames}
|
||||
size={28}
|
||||
/>
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypingBubbleGroupAvatars({
|
||||
conversationId,
|
||||
typingContactIds,
|
||||
getConversation,
|
||||
getPreferredBadge,
|
||||
showContactModal,
|
||||
i18n,
|
||||
theme,
|
||||
}: Pick<
|
||||
TypingBubblePropsType,
|
||||
| 'conversationId'
|
||||
| 'getConversation'
|
||||
| 'getPreferredBadge'
|
||||
| 'showContactModal'
|
||||
| 'i18n'
|
||||
| 'theme'
|
||||
> & {
|
||||
typingContactIds: ReadonlyArray<string>;
|
||||
}): ReactElement {
|
||||
const [allContactsById, setAllContactsById] = useState<
|
||||
Map<string, TypingContactType>
|
||||
>(new Map());
|
||||
|
||||
const onContactExit = useCallback((id: string | undefined) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
>;
|
||||
|
||||
setAllContactsById(prevMap => {
|
||||
const map = new Map([...prevMap]);
|
||||
map.delete(id);
|
||||
return map;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const visibleContactIds: Set<string> = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const id of typingContactIds) {
|
||||
set.add(id);
|
||||
}
|
||||
return set;
|
||||
}, [typingContactIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllContactsById(prevMap => {
|
||||
const map = new Map([...prevMap]);
|
||||
for (const id of typingContactIds) {
|
||||
map.set(id, getConversation(id));
|
||||
}
|
||||
return map;
|
||||
});
|
||||
}, [typingContactIds, getConversation]);
|
||||
|
||||
const typingContactsOverflowCount = Math.max(
|
||||
typingContactIds.length - MAX_AVATARS_COUNT,
|
||||
0
|
||||
);
|
||||
|
||||
// Avatars are rendered Right-to-Left so the leftmost avatars can render on top.
|
||||
return (
|
||||
<div className="module-message__typing-avatar-container">
|
||||
{typingContactsOverflowCount > 0 && (
|
||||
<div
|
||||
className="module-message__typing-avatar module-message__typing-avatar--overflow-count
|
||||
"
|
||||
>
|
||||
<div
|
||||
aria-label={i18n('icu:TypingBubble__avatar--overflow-count', {
|
||||
count: typingContactsOverflowCount,
|
||||
})}
|
||||
className="module-Avatar"
|
||||
>
|
||||
<div className="module-Avatar__contents">
|
||||
<div aria-hidden="true" className="module-Avatar__label">
|
||||
+{typingContactsOverflowCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{[...allContactsById.keys()]
|
||||
.slice(-1 * MAX_AVATARS_COUNT)
|
||||
.map(contactId => (
|
||||
<TypingBubbleAvatar
|
||||
key={contactId}
|
||||
conversationId={conversationId}
|
||||
contact={allContactsById.get(contactId)}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
showContactModal={showContactModal}
|
||||
onContactExit={onContactExit}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
visible={visibleContactIds.has(contactId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OUTER_DIV_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
|
||||
visible: { height: '44px' },
|
||||
hidden: { height: '0px' },
|
||||
};
|
||||
const BUBBLE_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: '0px',
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0.5,
|
||||
scale: 0.5,
|
||||
y: '30px',
|
||||
},
|
||||
};
|
||||
|
||||
export function TypingBubble({
|
||||
conversationId,
|
||||
conversationType,
|
||||
typingContactIdTimestamps,
|
||||
lastItemAuthorId,
|
||||
lastItemTimestamp,
|
||||
getConversation,
|
||||
getPreferredBadge,
|
||||
showContactModal,
|
||||
i18n,
|
||||
theme,
|
||||
typingContacts,
|
||||
}: PropsType): ReactElement {
|
||||
}: TypingBubblePropsType): ReactElement | null {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const typingContactIds = useMemo(
|
||||
() => Object.keys(typingContactIdTimestamps),
|
||||
[typingContactIdTimestamps]
|
||||
);
|
||||
const isSomeoneTyping = useMemo(
|
||||
() => typingContactIds.length > 0,
|
||||
[typingContactIds]
|
||||
);
|
||||
const [outerDivStyle, outerDivSpringApi] = useSpring(
|
||||
{
|
||||
from: OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'hidden' : 'visible'],
|
||||
to: OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],
|
||||
config: SPRING_CONFIG,
|
||||
},
|
||||
[isSomeoneTyping]
|
||||
);
|
||||
const [typingAnimationStyle, typingAnimationSpringApi] = useSpring(
|
||||
{
|
||||
from: BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'hidden' : 'visible'],
|
||||
to: BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],
|
||||
config: SPRING_CONFIG,
|
||||
onRest: () => {
|
||||
if (!isSomeoneTyping) {
|
||||
setIsVisible(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
[isSomeoneTyping]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// When typing stops, stay visible to allow time to animate out the bubble.
|
||||
if (isSomeoneTyping) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
typingAnimationSpringApi.stop();
|
||||
typingAnimationSpringApi.start(
|
||||
BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden']
|
||||
);
|
||||
outerDivSpringApi.stop();
|
||||
outerDivSpringApi.start(
|
||||
OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden']
|
||||
);
|
||||
}, [isSomeoneTyping, typingAnimationSpringApi, outerDivSpringApi]);
|
||||
|
||||
// When only one person is typing and they just sent a new message, then instantly
|
||||
// hide the bubble without animation to seamlessly transition to their new message.
|
||||
useEffect(() => {
|
||||
if (
|
||||
typingContactIds.length !== 1 ||
|
||||
!lastItemAuthorId ||
|
||||
!lastItemTimestamp
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastTypingContactId = typingContactIds[0];
|
||||
const lastTypingTimestamp = typingContactIdTimestamps[lastTypingContactId];
|
||||
if (
|
||||
lastItemAuthorId === lastTypingContactId &&
|
||||
lastItemTimestamp > lastTypingTimestamp
|
||||
) {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [
|
||||
lastItemAuthorId,
|
||||
lastItemTimestamp,
|
||||
typingContactIds,
|
||||
typingContactIdTimestamps,
|
||||
]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isGroup = conversationType === 'group';
|
||||
|
||||
const typingContactsOverflowCount = Math.max(
|
||||
typingContacts.length - MAX_AVATARS_COUNT,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message',
|
||||
'module-message--incoming',
|
||||
isGroup ? 'module-message--group' : null
|
||||
)}
|
||||
<animated.div
|
||||
className="module-timeline__typing-bubble-container"
|
||||
style={outerDivStyle}
|
||||
>
|
||||
{isGroup && (
|
||||
<div className="module-message__typing-avatar-container">
|
||||
{typingContactsOverflowCount > 0 && (
|
||||
<div
|
||||
className="module-message__typing-avatar module-message__typing-avatar--overflow-count
|
||||
"
|
||||
>
|
||||
<div
|
||||
aria-label={i18n('icu:TypingBubble__avatar--overflow-count', {
|
||||
count: typingContactsOverflowCount,
|
||||
})}
|
||||
className="module-Avatar"
|
||||
>
|
||||
<div className="module-Avatar__contents">
|
||||
<div aria-hidden="true" className="module-Avatar__label">
|
||||
+{typingContactsOverflowCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message',
|
||||
'module-message--incoming',
|
||||
'module-message--typing-bubble',
|
||||
isGroup ? 'module-message--group' : null
|
||||
)}
|
||||
>
|
||||
{isGroup && (
|
||||
<TypingBubbleGroupAvatars
|
||||
conversationId={conversationId}
|
||||
typingContactIds={typingContactIds}
|
||||
getConversation={getConversation}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
showContactModal={showContactModal}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
<div className="module-message__container-outer">
|
||||
<animated.div
|
||||
className={classNames(
|
||||
'module-message__container',
|
||||
'module-message__container--incoming'
|
||||
)}
|
||||
style={typingAnimationStyle}
|
||||
>
|
||||
<div className="module-message__typing-animation-container">
|
||||
<TypingAnimation color="light" i18n={i18n} />
|
||||
</div>
|
||||
)}
|
||||
{typingContacts.slice(-1 * MAX_AVATARS_COUNT).map(contact => (
|
||||
<div key={contact.id} className="module-message__typing-avatar">
|
||||
<Avatar
|
||||
acceptedMessageRequest={contact.acceptedMessageRequest}
|
||||
avatarPath={contact.avatarPath}
|
||||
badge={contact.badge}
|
||||
color={contact.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={contact.isMe}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
showContactModal(contact.id, conversationId);
|
||||
}}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
profileName={contact.profileName}
|
||||
theme={theme}
|
||||
title={contact.title}
|
||||
sharedGroupNames={contact.sharedGroupNames}
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="module-message__container-outer">
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__container',
|
||||
'module-message__container--incoming'
|
||||
)}
|
||||
>
|
||||
<div className="module-message__typing-container">
|
||||
<TypingAnimation color="light" i18n={i18n} />
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue