Group Typing Bubble Animations

This commit is contained in:
ayumi-signal 2023-09-27 17:23:52 -04:00 committed by GitHub
parent 88df942029
commit 283ef57779
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 565 additions and 209 deletions

View file

@ -1386,6 +1386,10 @@ $message-padding-horizontal: 12px;
} }
} }
.module-message--typing-bubble {
height: 44px;
}
.module-message__typing-avatar-container { .module-message__typing-avatar-container {
align-items: center; align-items: center;
display: flex; display: flex;
@ -1729,7 +1733,7 @@ $message-padding-horizontal: 12px;
} }
} }
.module-message__typing-container { .module-message__typing-animation-container {
height: 16px; height: 16px;
display: flex; display: flex;

View file

@ -381,7 +381,9 @@ ConversationsMessageStatuses.story = {
export const ConversationTypingStatus = (): JSX.Element => export const ConversationTypingStatus = (): JSX.Element =>
renderConversation({ renderConversation({
typingContactIds: [generateUuid()], typingContactIdTimestamps: {
[generateUuid()]: date('timestamp', new Date()),
},
}); });
ConversationTypingStatus.story = { ConversationTypingStatus.story = {

View file

@ -370,7 +370,7 @@ export function ConversationList({
'shouldShowDraft', 'shouldShowDraft',
'title', 'title',
'type', 'type',
'typingContactIds', 'typingContactIdTimestamps',
'unblurredAvatarPath', 'unblurredAvatarPath',
'unreadCount', 'unreadCount',
'unreadMentionsCount', 'unreadMentionsCount',

View file

@ -19,7 +19,6 @@ import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext
import { ConversationHero } from './ConversationHero'; import { ConversationHero } from './ConversationHero';
import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from '../../state/smart/ContactSpoofingReviewDialog'; import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from '../../state/smart/ContactSpoofingReviewDialog';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { TypingBubble } from './TypingBubble'; import { TypingBubble } from './TypingBubble';
import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ContactSpoofingType } from '../../util/contactSpoofing';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
@ -441,20 +440,13 @@ const renderHeroRow = () => {
}; };
const renderTypingBubble = () => ( const renderTypingBubble = () => (
<TypingBubble <TypingBubble
typingContacts={[ typingContactIdTimestamps={{ [getDefaultConversation().id]: Date.now() }}
{ lastItemAuthorId="123"
acceptedMessageRequest: true, lastItemTimestamp={undefined}
badge: undefined,
color: getRandomColor(),
phoneNumber: '+18005552222',
id: getDefaultConversation().id,
isMe: false,
sharedGroupNames: [],
title: 'title',
},
]}
conversationId="123" conversationId="123"
conversationType="direct" conversationType="direct"
getConversation={() => getDefaultConversation()}
getPreferredBadge={() => undefined}
showContactModal={action('showContactModal')} showContactModal={action('showContactModal')}
i18n={i18n} i18n={i18n}
theme={ThemeType.light} theme={ThemeType.light}

View file

@ -795,7 +795,6 @@ export class Timeline extends React.Component<
invitedContactsForNewlyCreatedGroup, invitedContactsForNewlyCreatedGroup,
isConversationSelected, isConversationSelected,
isGroupV1AndDisabled, isGroupV1AndDisabled,
isSomeoneTyping,
items, items,
messageLoadingState, messageLoadingState,
oldestUnseenIndex, oldestUnseenIndex,
@ -1144,7 +1143,7 @@ export class Timeline extends React.Component<
{messageNodes} {messageNodes}
{isSomeoneTyping && haveNewest && renderTypingBubble(id)} {haveNewest && renderTypingBubble(id)}
<div <div
className="module-timeline__messages__at-bottom-detector" className="module-timeline__messages__at-bottom-detector"

View file

@ -1,14 +1,15 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import React, { useEffect, useState } from 'react';
import { times } from 'lodash'; import { times } from 'lodash';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { select } from '@storybook/addon-knobs'; import { date, select } from '@storybook/addon-knobs';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import type { PropsType as TypingBubblePropsType } from './TypingBubble'; import type { TypingBubblePropsType } from './TypingBubble';
import { TypingBubble } from './TypingBubble'; import { TypingBubble } from './TypingBubble';
import { AvatarColors } from '../../types/Colors'; import { AvatarColors } from '../../types/Colors';
import { getFakeBadge } from '../../test-both/helpers/getFakeBadge'; import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
@ -20,15 +21,13 @@ export default {
title: 'Components/Conversation/TypingBubble', title: 'Components/Conversation/TypingBubble',
}; };
type TypingContactType = TypingBubblePropsType['typingContacts'][number]; const CONTACTS = times(10, index => {
const contacts: Array<TypingContactType> = times(10, index => {
const letter = (index + 10).toString(36).toUpperCase(); const letter = (index + 10).toString(36).toUpperCase();
return { return getDefaultConversation({
id: `contact-${index}`, id: `contact-${index}`,
acceptedMessageRequest: false, acceptedMessageRequest: false,
avatarPath: '', avatarPath: '',
badge: undefined, badges: [],
color: AvatarColors[index], color: AvatarColors[index],
name: `${letter} ${letter}`, name: `${letter} ${letter}`,
phoneNumber: '(202) 555-0001', phoneNumber: '(202) 555-0001',
@ -36,21 +35,52 @@ const contacts: Array<TypingContactType> = times(10, index => {
isMe: false, isMe: false,
sharedGroupNames: [], sharedGroupNames: [],
title: `${letter} ${letter}`, title: `${letter} ${letter}`,
}; });
}); });
const CONTACT_IDS = CONTACTS.map(contact => contact.id);
const CONTACTS_BY_ID = new Map(CONTACTS.map(contact => [contact.id, contact]));
const getConversation = (id: string) =>
CONTACTS_BY_ID.get(id) || getDefaultConversation();
const CONTACTS_WITH_BADGES = CONTACTS.map(contact => {
return { ...contact, badges: [getFakeBadge()] };
});
const CONTACTS_WITH_BADGES_BY_ID = new Map(
CONTACTS_WITH_BADGES.map(contact => [contact.id, contact])
);
const getConversationWithBadges = (id: string) =>
CONTACTS_WITH_BADGES_BY_ID.get(id) || getDefaultConversation();
const getTypingContactIdTimestamps = (count: number) =>
Object.fromEntries(
CONTACT_IDS.slice(0, count).map(id => [id, date('timestamp', new Date())])
);
const createProps = ( const createProps = (
overrideProps: Partial<TypingBubblePropsType> = {} overrideProps: Partial<TypingBubblePropsType> = {}
): TypingBubblePropsType => ({ ): TypingBubblePropsType => {
typingContacts: overrideProps.typingContacts || contacts.slice(0, 1), return {
i18n, typingContactIdTimestamps:
conversationId: '123', overrideProps.typingContactIdTimestamps ??
conversationType: getTypingContactIdTimestamps(1),
overrideProps.conversationType || lastItemAuthorId: '123',
select('conversationType', { group: 'group', direct: 'direct' }, 'direct'), lastItemTimestamp: undefined,
showContactModal: action('showContactModal'), i18n,
theme: ThemeType.light, conversationId: '123',
}); conversationType:
overrideProps.conversationType ||
select(
'conversationType',
{ group: 'group', direct: 'direct' },
'direct'
),
getConversation: overrideProps.getConversation || getConversation,
getPreferredBadge: badges =>
badges.length > 0 ? getFakeBadge() : undefined,
showContactModal: action('showContactModal'),
theme: ThemeType.light,
};
};
export function Direct(): JSX.Element { export function Direct(): JSX.Element {
const props = createProps(); const props = createProps();
@ -58,9 +88,24 @@ export function Direct(): JSX.Element {
return <TypingBubble {...props} />; return <TypingBubble {...props} />;
} }
export function DirectStoppedTyping(): JSX.Element {
const props = createProps();
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
useEffect(() => {
setTimeout(
() =>
setAfterTimeoutProps({
typingContactIdTimestamps: {},
}),
500
);
}, []);
return <TypingBubble {...props} {...afterTimeoutProps} />;
}
export function Group(): JSX.Element { export function Group(): JSX.Element {
const props = createProps({ conversationType: 'group' }); const props = createProps({ conversationType: 'group' });
return <TypingBubble {...props} />; return <TypingBubble {...props} />;
} }
@ -68,21 +113,31 @@ Group.story = {
name: 'Group (1 person typing)', name: 'Group (1 person typing)',
}; };
export function GroupMultiTyping2(): JSX.Element { export function GroupStoppedTyping(): JSX.Element {
const props = createProps({ const props = createProps({
conversationType: 'group', conversationType: 'group',
typingContacts: contacts.slice(0, 2), typingContactIdTimestamps: getTypingContactIdTimestamps(1),
}); });
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
useEffect(() => {
setTimeout(
() => setAfterTimeoutProps({ typingContactIdTimestamps: {} }),
500
);
}, []);
return <TypingBubble {...props} />; return <TypingBubble {...props} {...afterTimeoutProps} />;
} }
GroupStoppedTyping.story = {
name: 'Group (1 person stopped typing)',
};
export function GroupWithBadge(): JSX.Element { export function GroupWithBadge(): JSX.Element {
const props = createProps({ const props = createProps({
conversationType: 'group', conversationType: 'group',
typingContacts: contacts typingContactIdTimestamps: getTypingContactIdTimestamps(1),
.slice(0, 1) getConversation: getConversationWithBadges,
.map(contact => ({ ...contact, badge: getFakeBadge() })),
}); });
return <TypingBubble {...props} />; return <TypingBubble {...props} />;
@ -92,40 +147,79 @@ GroupWithBadge.story = {
name: 'Group (with badge)', name: 'Group (with badge)',
}; };
GroupMultiTyping2.story = { export function GroupMultiTyping1To2(): JSX.Element {
name: 'Group (2 persons typing)',
};
export function GroupMultiTyping3(): JSX.Element {
const props = createProps({ const props = createProps({
conversationType: 'group', conversationType: 'group',
typingContacts: contacts.slice(0, 3), typingContactIdTimestamps: getTypingContactIdTimestamps(1),
}); });
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
useEffect(() => {
setTimeout(
() =>
setAfterTimeoutProps({
typingContactIdTimestamps: getTypingContactIdTimestamps(2),
}),
500
);
}, []);
return <TypingBubble {...props} />; return <TypingBubble {...props} {...afterTimeoutProps} />;
} }
GroupMultiTyping3.story = { GroupMultiTyping1To2.story = {
name: 'Group (3 persons typing)', name: 'Group (1 to 2 persons)',
}; };
export function GroupMultiTyping4(): JSX.Element { export function GroupMultiTyping2Then1PersonStops(): JSX.Element {
const props = createProps({ const props = createProps({
conversationType: 'group', conversationType: 'group',
typingContacts: contacts.slice(0, 4), typingContactIdTimestamps: getTypingContactIdTimestamps(2),
}); });
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
useEffect(() => {
setTimeout(
() =>
setAfterTimeoutProps({
typingContactIdTimestamps: getTypingContactIdTimestamps(1),
}),
500
);
}, []);
return <TypingBubble {...props} />; return <TypingBubble {...props} {...afterTimeoutProps} />;
} }
GroupMultiTyping4.story = { GroupMultiTyping2Then1PersonStops.story = {
name: 'Group (4 persons typing)', name: 'Group (2 persons typing then 1 person stops)',
};
export function GroupMultiTyping3To4(): JSX.Element {
const props = createProps({
conversationType: 'group',
typingContactIdTimestamps: getTypingContactIdTimestamps(3),
});
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
useEffect(() => {
setTimeout(
() =>
setAfterTimeoutProps({
typingContactIdTimestamps: getTypingContactIdTimestamps(4),
}),
500
);
}, []);
return <TypingBubble {...props} {...afterTimeoutProps} />;
}
GroupMultiTyping3To4.story = {
name: 'Group (3 to 4)',
}; };
export function GroupMultiTyping10(): JSX.Element { export function GroupMultiTyping10(): JSX.Element {
const props = createProps({ const props = createProps({
conversationType: 'group', conversationType: 'group',
typingContacts: contacts.slice(0, 10), typingContactIdTimestamps: getTypingContactIdTimestamps(10),
}); });
return <TypingBubble {...props} />; return <TypingBubble {...props} />;
@ -138,19 +232,8 @@ GroupMultiTyping10.story = {
export function GroupMultiTypingWithBadges(): JSX.Element { export function GroupMultiTypingWithBadges(): JSX.Element {
const props = createProps({ const props = createProps({
conversationType: 'group', conversationType: 'group',
typingContacts: [ typingContactIdTimestamps: getTypingContactIdTimestamps(3),
{ getConversation: getConversationWithBadges,
...contacts[0],
badge: getFakeBadge(),
},
{
...contacts[1],
},
{
...contacts[2],
badge: getFakeBadge(),
},
],
}); });
return <TypingBubble {...props} />; return <TypingBubble {...props} />;

View file

@ -2,124 +2,384 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import React from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { animated, useSpring } from '@react-spring/web';
import { TypingAnimation } from './TypingAnimation'; import { TypingAnimation } from './TypingAnimation';
import { Avatar } from '../Avatar'; import { Avatar } from '../Avatar';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations'; 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; 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; conversationId: string;
conversationType: 'group' | 'direct'; 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; showContactModal: (contactId: string, conversationId?: string) => void;
i18n: LocalizerType; i18n: LocalizerType;
theme: ThemeType; theme: ThemeType;
typingContacts: Array< };
Pick<
ConversationType, const SPRING_CONFIG = {
| 'acceptedMessageRequest' mass: 1,
| 'avatarPath' tension: 986,
| 'color' friction: 64,
| 'id' precision: 0,
| 'isMe' velocity: 0,
| 'phoneNumber' };
| 'profileName'
| 'sharedGroupNames' const AVATAR_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
| 'title' visible: {
> & { opacity: 1,
badge: undefined | BadgeType; 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({ export function TypingBubble({
conversationId, conversationId,
conversationType, conversationType,
typingContactIdTimestamps,
lastItemAuthorId,
lastItemTimestamp,
getConversation,
getPreferredBadge,
showContactModal, showContactModal,
i18n, i18n,
theme, theme,
typingContacts, }: TypingBubblePropsType): ReactElement | null {
}: PropsType): ReactElement { 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 isGroup = conversationType === 'group';
const typingContactsOverflowCount = Math.max(
typingContacts.length - MAX_AVATARS_COUNT,
0
);
return ( return (
<div <animated.div
className={classNames( className="module-timeline__typing-bubble-container"
'module-message', style={outerDivStyle}
'module-message--incoming',
isGroup ? 'module-message--group' : null
)}
> >
{isGroup && ( <div
<div className="module-message__typing-avatar-container"> className={classNames(
{typingContactsOverflowCount > 0 && ( 'module-message',
<div 'module-message--incoming',
className="module-message__typing-avatar module-message__typing-avatar--overflow-count 'module-message--typing-bubble',
" isGroup ? 'module-message--group' : null
> )}
<div >
aria-label={i18n('icu:TypingBubble__avatar--overflow-count', { {isGroup && (
count: typingContactsOverflowCount, <TypingBubbleGroupAvatars
})} conversationId={conversationId}
className="module-Avatar" typingContactIds={typingContactIds}
> getConversation={getConversation}
<div className="module-Avatar__contents"> getPreferredBadge={getPreferredBadge}
<div aria-hidden="true" className="module-Avatar__label"> showContactModal={showContactModal}
+{typingContactsOverflowCount} i18n={i18n}
</div> theme={theme}
</div> />
</div> )}
<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> </div>
)} </animated.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>
</div> </div>
</div> </div>
</div> </animated.div>
); );
} }

View file

@ -60,7 +60,7 @@ export type PropsData = Pick<
| 'shouldShowDraft' | 'shouldShowDraft'
| 'title' | 'title'
| 'type' | 'type'
| 'typingContactIds' | 'typingContactIdTimestamps'
| 'unblurredAvatarPath' | 'unblurredAvatarPath'
| 'unreadCount' | 'unreadCount'
| 'unreadMentionsCount' | 'unreadMentionsCount'
@ -104,14 +104,15 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
theme, theme,
title, title,
type, type,
typingContactIds, typingContactIdTimestamps,
unblurredAvatarPath, unblurredAvatarPath,
unreadCount, unreadCount,
unreadMentionsCount, unreadMentionsCount,
serviceId, serviceId,
}) { }) {
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt); const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
const isSomeoneTyping = (typingContactIds?.length ?? 0) > 0; const isSomeoneTyping =
Object.keys(typingContactIdTimestamps ?? {}).length > 0;
const headerName = ( const headerName = (
<> <>
{isMe ? ( {isMe ? (

View file

@ -217,7 +217,11 @@ export class ConversationModel extends window.Backbone
contactTypingTimers?: Record< contactTypingTimers?: Record<
string, string,
{ senderId: string; timer: NodeJS.Timer } {
senderId: string;
timer: NodeJS.Timer;
timestamp: number;
}
>; >;
contactCollection?: Backbone.Collection<ConversationModel>; contactCollection?: Backbone.Collection<ConversationModel>;

View file

@ -298,7 +298,7 @@ export type ConversationType = ReadonlyDeep<
unreadMentionsCount?: number; unreadMentionsCount?: number;
isSelected?: boolean; isSelected?: boolean;
isFetchingUUID?: boolean; isFetchingUUID?: boolean;
typingContactIds?: Array<string>; typingContactIdTimestamps?: Record<string, number>;
recentMediaItems?: ReadonlyArray<MediaItemType>; recentMediaItems?: ReadonlyArray<MediaItemType>;
profileSharing?: boolean; profileSharing?: boolean;

View file

@ -241,14 +241,16 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
'unreadCount', 'unreadCount',
'unreadMentionsCount', 'unreadMentionsCount',
'isGroupV1AndDisabled', 'isGroupV1AndDisabled',
'typingContactIds', 'typingContactIdTimestamps',
]), ]),
isConversationSelected: state.conversations.selectedConversationId === id, isConversationSelected: state.conversations.selectedConversationId === id,
isIncomingMessageRequest: Boolean( isIncomingMessageRequest: Boolean(
conversation.messageRequestsEnabled && conversation.messageRequestsEnabled &&
!conversation.acceptedMessageRequest !conversation.acceptedMessageRequest
), ),
isSomeoneTyping: Boolean(conversation.typingContactIds?.[0]), isSomeoneTyping: Boolean(
Object.keys(conversation.typingContactIdTimestamps ?? {}).length > 0
),
...conversationMessages, ...conversationMessages,
invitedContactsForNewlyCreatedGroup: invitedContactsForNewlyCreatedGroup:

View file

@ -1,23 +1,28 @@
// Copyright 2019 Signal Messenger, LLC // Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { last } from 'lodash';
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { TypingBubble } from '../../components/conversation/TypingBubble';
import { strictAssert } from '../../util/assert';
import { TypingBubble } from '../../components/conversation/TypingBubble';
import { useGlobalModalActions } from '../ducks/globalModals'; import { useGlobalModalActions } from '../ducks/globalModals';
import { useProxySelector } from '../../hooks/useProxySelector';
import { getIntl, getTheme } from '../selectors/user'; import { getIntl, getTheme } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations'; import { getTimelineItem } from '../selectors/timeline';
import {
getConversationSelector,
getConversationMessagesSelector,
} from '../selectors/conversations';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { isInternalUser } from '../selectors/items';
type ExternalProps = { type ExternalProps = {
conversationId: string; conversationId: string;
}; };
export function SmartTypingBubble(props: ExternalProps): JSX.Element { export function SmartTypingBubble({
const { conversationId } = props; conversationId,
}: ExternalProps): JSX.Element {
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const getConversation = useSelector(getConversationSelector); const getConversation = useSelector(getConversationSelector);
@ -26,37 +31,39 @@ export function SmartTypingBubble(props: ExternalProps): JSX.Element {
throw new Error(`Did not find conversation ${conversationId} in state!`); throw new Error(`Did not find conversation ${conversationId} in state!`);
} }
strictAssert( const typingContactIdTimestamps =
conversation.typingContactIds?.[0], conversation.typingContactIdTimestamps ?? {};
'Missing typing contact IDs' const conversationMessages = useSelector(getConversationMessagesSelector)(
conversationId
); );
const lastMessageId = last(conversationMessages.items);
const lastItem = useProxySelector(getTimelineItem, lastMessageId);
let lastItemAuthorId: string | undefined;
let lastItemTimestamp: number | undefined;
if (lastItem?.data) {
if ('author' in lastItem.data) {
lastItemAuthorId = lastItem.data.author?.id;
}
if ('timestamp' in lastItem.data) {
lastItemTimestamp = lastItem.data.timestamp;
}
}
const { showContactModal } = useGlobalModalActions(); const { showContactModal } = useGlobalModalActions();
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const preferredBadgeSelector = useSelector(getPreferredBadgeSelector);
const internalUser = useSelector(isInternalUser);
const typingContactIdsVisible = internalUser
? conversation.typingContactIds
: conversation.typingContactIds.slice(0, 1);
const typingContacts = typingContactIdsVisible
.map(contactId => getConversation(contactId))
.map(typingConversation => {
return {
...typingConversation,
badge: preferredBadgeSelector(typingConversation.badges),
};
});
return ( return (
<TypingBubble <TypingBubble
showContactModal={showContactModal}
conversationId={conversationId} conversationId={conversationId}
conversationType={conversation.type} conversationType={conversation.type}
typingContactIdTimestamps={typingContactIdTimestamps}
lastItemAuthorId={lastItemAuthorId}
lastItemTimestamp={lastItemTimestamp}
i18n={i18n} i18n={i18n}
theme={theme} theme={theme}
typingContacts={typingContacts} getConversation={getConversation}
getPreferredBadge={getPreferredBadge}
showContactModal={showContactModal}
/> />
); );
} }

View file

@ -1145,7 +1145,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'No timestamp', title: 'No timestamp',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1166,7 +1166,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'B', title: 'B',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1187,7 +1187,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'C', title: 'C',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1208,7 +1208,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'A', title: 'A',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1229,7 +1229,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'First!', title: 'First!',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1271,7 +1271,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'Pin Two', title: 'Pin Two',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1293,7 +1293,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'Pin Three', title: 'Pin Three',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1315,7 +1315,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'Pin One', title: 'Pin One',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1354,7 +1354,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'Pin Two', title: 'Pin Two',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1375,7 +1375,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'Pin Three', title: 'Pin Three',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1396,7 +1396,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'Pin One', title: 'Pin One',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1418,7 +1418,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'Pin One', title: 'Pin One',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
@ -1439,7 +1439,7 @@ describe('both/state/selectors/conversations-extra', () => {
title: 'Pin One', title: 'Pin One',
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContactIds: [generateUuid()], typingContactIdTimestamps: { [generateUuid()]: Date.now() },
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),

View file

@ -81,7 +81,9 @@ export function getConversation(model: ConversationModel): ConversationType {
Object.values(model.contactTypingTimers || {}), Object.values(model.contactTypingTimers || {}),
'timestamp' 'timestamp'
); );
const typingContactIds = typingValues.map(({ senderId }) => senderId); const typingContactIdTimestamps = Object.fromEntries(
typingValues.map(({ senderId, timestamp }) => [senderId, timestamp])
);
const ourAci = window.textsecure.storage.user.getAci(); const ourAci = window.textsecure.storage.user.getAci();
const ourPni = window.textsecure.storage.user.getPni(); const ourPni = window.textsecure.storage.user.getPni();
@ -222,7 +224,7 @@ export function getConversation(model: ConversationModel): ConversationType {
timestamp: dropNull(timestamp), timestamp: dropNull(timestamp),
title: getTitle(attributes), title: getTitle(attributes),
titleNoDefault: getTitleNoDefault(attributes), titleNoDefault: getTitleNoDefault(attributes),
typingContactIds, typingContactIdTimestamps,
searchableTitle: isMe(attributes) searchableTitle: isMe(attributes)
? window.i18n('icu:noteToSelf') ? window.i18n('icu:noteToSelf')
: getTitle(attributes), : getTitle(attributes),