signal-desktop/ts/components/conversation/TypingBubble.tsx
2023-10-06 14:30:36 -07:00

411 lines
11 KiB
TypeScript

// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } 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 { PreferredBadgeSelectorType } from '../../state/selectors/badges';
const MAX_AVATARS_COUNT = 3;
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;
};
const SPRING_CONFIG = {
mass: 1,
tension: 439,
friction: 42,
precision: 0,
velocity: 0,
};
const AVATAR_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
visible: {
opacity: 1,
width: '28px',
x: '0px',
top: '0px',
},
hidden: {
opacity: 0.5,
width: '4px', // Match value of module-message__typing-avatar margin-inline-start
x: '12px',
top: '34px',
},
};
function TypingBubbleAvatar({
conversationId,
contact,
visible,
shouldAnimate,
getPreferredBadge,
onContactExit,
showContactModal,
i18n,
theme,
}: {
conversationId: string;
contact: TypingContactType | undefined;
visible: boolean;
shouldAnimate: 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: shouldAnimate
? 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,
shouldAnimate,
getConversation,
getPreferredBadge,
showContactModal,
i18n,
theme,
}: Pick<
TypingBubblePropsType,
| 'conversationId'
| 'getConversation'
| 'getPreferredBadge'
| 'showContactModal'
| 'i18n'
| 'theme'
> & {
typingContactIds: ReadonlyArray<string>;
shouldAnimate: boolean;
}): 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__author-avatar-container module-message__author-avatar-container--typing">
<div className="module-message__typing-avatar-spacer" />
{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)}
shouldAnimate={shouldAnimate}
/>
))}
</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,
top: '0px',
},
hidden: {
opacity: 0.5,
top: '30px',
},
};
export function TypingBubble({
conversationId,
conversationType,
typingContactIdTimestamps,
lastItemAuthorId,
lastItemTimestamp,
getConversation,
getPreferredBadge,
showContactModal,
i18n,
theme,
}: TypingBubblePropsType): ReactElement | null {
const [isVisible, setIsVisible] = useState(false);
const typingContactIds = useMemo(
() => Object.keys(typingContactIdTimestamps),
[typingContactIdTimestamps]
);
const [shouldAnimate, setShouldAnimate] = useState(false);
const prevTypingContactIds = React.useRef<
ReadonlyArray<string> | undefined
>();
const isSomeoneTyping = useMemo(
() => typingContactIds.length > 0,
[typingContactIds]
);
const [outerDivStyle, outerDivSpringApi] = useSpring(
{
to: OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],
config: SPRING_CONFIG,
},
[isSomeoneTyping]
);
const [typingAnimationStyle, typingAnimationSpringApi] = useSpring(
{
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,
]);
// Only animate when the user observes a change in typing contacts, not when first
// switching to a conversation.
useEffect(() => {
if (shouldAnimate) {
return;
}
if (!prevTypingContactIds.current) {
prevTypingContactIds.current = typingContactIds;
return;
}
if (prevTypingContactIds.current !== typingContactIds) {
setShouldAnimate(true);
}
}, [shouldAnimate, typingContactIds]);
if (!isVisible) {
return null;
}
const isGroup = conversationType === 'group';
return (
<animated.div
className="module-timeline__typing-bubble-container"
style={outerDivStyle}
>
<animated.div
className={classNames(
'module-message',
'module-message--incoming',
'module-message--typing-bubble',
isGroup ? 'module-message--group' : null
)}
style={outerDivStyle}
>
{isGroup && (
<TypingBubbleGroupAvatars
conversationId={conversationId}
typingContactIds={typingContactIds}
shouldAnimate={shouldAnimate}
getConversation={getConversation}
getPreferredBadge={getPreferredBadge}
showContactModal={showContactModal}
i18n={i18n}
theme={theme}
/>
)}
<div className="module-message__container-outer module-message__container-outer--typing-bubble">
<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>
</animated.div>
</div>
</animated.div>
</animated.div>
);
}