Multiple person typing indicators in groups
This commit is contained in:
parent
17ea2b58de
commit
e4238de4db
15 changed files with 342 additions and 115 deletions
|
@ -381,7 +381,7 @@ ConversationsMessageStatuses.story = {
|
|||
|
||||
export const ConversationTypingStatus = (): JSX.Element =>
|
||||
renderConversation({
|
||||
typingContactId: generateUuid(),
|
||||
typingContactIds: [generateUuid()],
|
||||
});
|
||||
|
||||
ConversationTypingStatus.story = {
|
||||
|
|
|
@ -370,7 +370,7 @@ export function ConversationList({
|
|||
'shouldShowDraft',
|
||||
'title',
|
||||
'type',
|
||||
'typingContactId',
|
||||
'typingContactIds',
|
||||
'unblurredAvatarPath',
|
||||
'unreadCount',
|
||||
'unreadMentionsCount',
|
||||
|
|
|
@ -441,16 +441,23 @@ const renderHeroRow = () => {
|
|||
};
|
||||
const renderTypingBubble = () => (
|
||||
<TypingBubble
|
||||
acceptedMessageRequest
|
||||
badge={undefined}
|
||||
color={getRandomColor()}
|
||||
typingContacts={[
|
||||
{
|
||||
acceptedMessageRequest: true,
|
||||
badge: undefined,
|
||||
color: getRandomColor(),
|
||||
phoneNumber: '+18005552222',
|
||||
id: getDefaultConversation().id,
|
||||
isMe: false,
|
||||
sharedGroupNames: [],
|
||||
title: 'title',
|
||||
},
|
||||
]}
|
||||
conversationId="123"
|
||||
conversationType="direct"
|
||||
phoneNumber="+18005552222"
|
||||
showContactModal={action('showContactModal')}
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title="title"
|
||||
theme={ThemeType.light}
|
||||
sharedGroupNames={[]}
|
||||
/>
|
||||
);
|
||||
const renderMiniPlayer = () => (
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { select, text } from '@storybook/addon-knobs';
|
||||
import { times } from 'lodash';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { select } from '@storybook/addon-knobs';
|
||||
|
||||
import { setupI18n } from '../../util/setupI18n';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import type { Props } from './TypingBubble';
|
||||
import type { PropsType as TypingBubblePropsType } from './TypingBubble';
|
||||
import { TypingBubble } from './TypingBubble';
|
||||
import { AvatarColors } from '../../types/Colors';
|
||||
import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
|
||||
|
@ -18,25 +20,35 @@ export default {
|
|||
title: 'Components/Conversation/TypingBubble',
|
||||
};
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
acceptedMessageRequest: true,
|
||||
badge: overrideProps.badge,
|
||||
isMe: false,
|
||||
type TypingContactType = TypingBubblePropsType['typingContacts'][number];
|
||||
|
||||
const contacts: Array<TypingContactType> = times(10, index => {
|
||||
const letter = (index + 10).toString(36).toUpperCase();
|
||||
return {
|
||||
id: `contact-${index}`,
|
||||
acceptedMessageRequest: false,
|
||||
avatarPath: '',
|
||||
badge: undefined,
|
||||
color: AvatarColors[index],
|
||||
name: `${letter} ${letter}`,
|
||||
phoneNumber: '(202) 555-0001',
|
||||
profileName: `${letter} ${letter}`,
|
||||
isMe: false,
|
||||
sharedGroupNames: [],
|
||||
title: `${letter} ${letter}`,
|
||||
};
|
||||
});
|
||||
|
||||
const createProps = (
|
||||
overrideProps: Partial<TypingBubblePropsType> = {}
|
||||
): TypingBubblePropsType => ({
|
||||
typingContacts: overrideProps.typingContacts || contacts.slice(0, 1),
|
||||
i18n,
|
||||
color: select(
|
||||
'color',
|
||||
AvatarColors.reduce((m, c) => ({ ...m, [c]: c }), {}),
|
||||
overrideProps.color || AvatarColors[0]
|
||||
),
|
||||
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
|
||||
title: '',
|
||||
profileName: text('profileName', overrideProps.profileName || ''),
|
||||
conversationType: select(
|
||||
'conversationType',
|
||||
{ group: 'group', direct: 'direct' },
|
||||
overrideProps.conversationType || 'direct'
|
||||
),
|
||||
sharedGroupNames: [],
|
||||
conversationId: '123',
|
||||
conversationType:
|
||||
overrideProps.conversationType ||
|
||||
select('conversationType', { group: 'group', direct: 'direct' }, 'direct'),
|
||||
showContactModal: action('showContactModal'),
|
||||
theme: ThemeType.light,
|
||||
});
|
||||
|
||||
|
@ -52,10 +64,25 @@ export function Group(): JSX.Element {
|
|||
return <TypingBubble {...props} />;
|
||||
}
|
||||
|
||||
Group.story = {
|
||||
name: 'Group (1 person typing)',
|
||||
};
|
||||
|
||||
export function GroupMultiTyping2(): JSX.Element {
|
||||
const props = createProps({
|
||||
conversationType: 'group',
|
||||
typingContacts: contacts.slice(0, 2),
|
||||
});
|
||||
|
||||
return <TypingBubble {...props} />;
|
||||
}
|
||||
|
||||
export function GroupWithBadge(): JSX.Element {
|
||||
const props = createProps({
|
||||
badge: getFakeBadge(),
|
||||
conversationType: 'group',
|
||||
typingContacts: contacts
|
||||
.slice(0, 1)
|
||||
.map(contact => ({ ...contact, badge: getFakeBadge() })),
|
||||
});
|
||||
|
||||
return <TypingBubble {...props} />;
|
||||
|
@ -64,3 +91,71 @@ export function GroupWithBadge(): JSX.Element {
|
|||
GroupWithBadge.story = {
|
||||
name: 'Group (with badge)',
|
||||
};
|
||||
|
||||
GroupMultiTyping2.story = {
|
||||
name: 'Group (2 persons typing)',
|
||||
};
|
||||
|
||||
export function GroupMultiTyping3(): JSX.Element {
|
||||
const props = createProps({
|
||||
conversationType: 'group',
|
||||
typingContacts: contacts.slice(0, 3),
|
||||
});
|
||||
|
||||
return <TypingBubble {...props} />;
|
||||
}
|
||||
|
||||
GroupMultiTyping3.story = {
|
||||
name: 'Group (3 persons typing)',
|
||||
};
|
||||
|
||||
export function GroupMultiTyping4(): JSX.Element {
|
||||
const props = createProps({
|
||||
conversationType: 'group',
|
||||
typingContacts: contacts.slice(0, 4),
|
||||
});
|
||||
|
||||
return <TypingBubble {...props} />;
|
||||
}
|
||||
|
||||
GroupMultiTyping4.story = {
|
||||
name: 'Group (4 persons typing)',
|
||||
};
|
||||
|
||||
export function GroupMultiTyping10(): JSX.Element {
|
||||
const props = createProps({
|
||||
conversationType: 'group',
|
||||
typingContacts: contacts.slice(0, 10),
|
||||
});
|
||||
|
||||
return <TypingBubble {...props} />;
|
||||
}
|
||||
|
||||
GroupMultiTyping10.story = {
|
||||
name: 'Group (10 persons typing)',
|
||||
};
|
||||
|
||||
export function GroupMultiTypingWithBadges(): JSX.Element {
|
||||
const props = createProps({
|
||||
conversationType: 'group',
|
||||
typingContacts: [
|
||||
{
|
||||
...contacts[0],
|
||||
badge: getFakeBadge(),
|
||||
},
|
||||
{
|
||||
...contacts[1],
|
||||
},
|
||||
{
|
||||
...contacts[2],
|
||||
badge: getFakeBadge(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return <TypingBubble {...props} />;
|
||||
}
|
||||
|
||||
GroupMultiTypingWithBadges.story = {
|
||||
name: 'Group (3 persons typing, 2 persons have badge)',
|
||||
};
|
||||
|
|
|
@ -12,39 +12,47 @@ import type { LocalizerType, ThemeType } from '../../types/Util';
|
|||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import type { BadgeType } from '../../badges/types';
|
||||
|
||||
export type Props = Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
| 'isMe'
|
||||
| 'phoneNumber'
|
||||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
> & {
|
||||
badge: undefined | BadgeType;
|
||||
const MAX_AVATARS_COUNT = 3;
|
||||
|
||||
export type PropsType = {
|
||||
conversationId: string;
|
||||
conversationType: 'group' | 'direct';
|
||||
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;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export function TypingBubble({
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
badge,
|
||||
color,
|
||||
conversationId,
|
||||
conversationType,
|
||||
showContactModal,
|
||||
i18n,
|
||||
isMe,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
sharedGroupNames,
|
||||
theme,
|
||||
title,
|
||||
}: Props): ReactElement {
|
||||
typingContacts,
|
||||
}: PropsType): ReactElement {
|
||||
const isGroup = conversationType === 'group';
|
||||
|
||||
const typingContactsOverflowCount = Math.max(
|
||||
typingContacts.length - MAX_AVATARS_COUNT,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
|
@ -54,22 +62,50 @@ export function TypingBubble({
|
|||
)}
|
||||
>
|
||||
{isGroup && (
|
||||
<div className="module-message__author-avatar-container">
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={badge}
|
||||
color={color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={isMe}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
theme={theme}
|
||||
title={title}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={28}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
{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">
|
||||
|
|
|
@ -60,7 +60,7 @@ export type PropsData = Pick<
|
|||
| 'shouldShowDraft'
|
||||
| 'title'
|
||||
| 'type'
|
||||
| 'typingContactId'
|
||||
| 'typingContactIds'
|
||||
| 'unblurredAvatarPath'
|
||||
| 'unreadCount'
|
||||
| 'unreadMentionsCount'
|
||||
|
@ -104,13 +104,14 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
|||
theme,
|
||||
title,
|
||||
type,
|
||||
typingContactId,
|
||||
typingContactIds,
|
||||
unblurredAvatarPath,
|
||||
unreadCount,
|
||||
unreadMentionsCount,
|
||||
serviceId,
|
||||
}) {
|
||||
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
|
||||
const isSomeoneTyping = (typingContactIds?.length ?? 0) > 0;
|
||||
const headerName = (
|
||||
<>
|
||||
{isMe ? (
|
||||
|
@ -139,7 +140,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
|||
{i18n('icu:ConversationListItem--message-request')}
|
||||
</span>
|
||||
);
|
||||
} else if (typingContactId) {
|
||||
} else if (isSomeoneTyping) {
|
||||
messageText = <TypingAnimation i18n={i18n} />;
|
||||
} else if (shouldShowDraft && draftPreview) {
|
||||
messageText = (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue