Multiple person typing indicators in groups

This commit is contained in:
ayumi-signal 2023-09-18 14:17:26 -07:00 committed by GitHub
parent 17ea2b58de
commit e4238de4db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 342 additions and 115 deletions

View file

@ -6751,6 +6751,10 @@
"messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call",
"description": "Call History > Short description of call > When call was declined"
},
"icu:TypingBubble__avatar--overflow-count": {
"messageformat": "{count, plural, one {# other is} other {# others are}} typing.",
"description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden."
},
"icu:WhatsNew__modal-title": {
"messageformat": "What's New",
"description": "Title for the whats new modal"

View file

@ -1386,6 +1386,64 @@ $message-padding-horizontal: 12px;
}
}
.module-message__typing-avatar-container {
align-items: center;
display: flex;
flex-direction: row-reverse;
justify-content: center;
margin-inline-end: 8px;
&--with-reactions {
padding-bottom: 15px;
}
}
.module-message__typing-avatar {
display: flex;
justify-content: center;
z-index: $z-index-base;
&:not(:last-child) {
margin-inline-start: -4px;
}
&--overflow-count {
.module-Avatar__contents {
@include light-theme() {
background: $color-gray-05;
color: $color-gray-60;
}
@include dark-theme() {
background: $color-gray-75;
color: $color-gray-25;
}
}
.module-Avatar__label {
@include font-caption-bold;
}
}
.module-Avatar {
min-width: 28px;
width: 28px;
height: 28px;
}
.module-Avatar__contents {
outline: 3px solid;
@include light-theme() {
outline-color: $color-white;
}
@include dark-theme() {
outline-color: $color-gray-95;
}
}
}
.module-message__unopened-gift-badge {
width: 240px;
height: 132px;

View file

@ -24,6 +24,7 @@ export type ConfigKeyType =
| 'desktop.contactManagement'
| 'desktop.groupCallOutboundRing2.beta'
| 'desktop.groupCallOutboundRing2'
| 'desktop.groupMultiTypingIndicators'
| 'desktop.internalUser'
| 'desktop.mandatoryProfileSharing'
| 'desktop.mediaQuality.levels'

View file

@ -381,7 +381,7 @@ ConversationsMessageStatuses.story = {
export const ConversationTypingStatus = (): JSX.Element =>
renderConversation({
typingContactId: generateUuid(),
typingContactIds: [generateUuid()],
});
ConversationTypingStatus.story = {

View file

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

View file

@ -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 = () => (

View file

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

View file

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

View file

@ -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 = (

View file

@ -5111,8 +5111,8 @@ export class ConversationModel extends window.Backbone
this.clearContactTypingTimer.bind(this, typingToken),
15 * 1000
);
// User was not previously typing before. State change!
if (!record) {
// User was not previously typing before. State change!
this.trigger('change', this, { force: true });
}
} else {

View file

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

View file

@ -98,8 +98,8 @@ function renderHeroRow(id: string): JSX.Element {
function renderMiniPlayer(options: { shouldFlow: boolean }): JSX.Element {
return <SmartMiniPlayer {...options} />;
}
function renderTypingBubble(id: string): JSX.Element {
return <SmartTypingBubble id={id} />;
function renderTypingBubble(conversationId: string): JSX.Element {
return <SmartTypingBubble conversationId={conversationId} />;
}
const getWarning = (
@ -241,13 +241,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
'unreadCount',
'unreadMentionsCount',
'isGroupV1AndDisabled',
'typingContactIds',
]),
isConversationSelected: state.conversations.selectedConversationId === id,
isIncomingMessageRequest: Boolean(
conversation.messageRequestsEnabled &&
!conversation.acceptedMessageRequest
),
isSomeoneTyping: Boolean(conversation.typingContactId),
isSomeoneTyping: Boolean(conversation.typingContactIds?.[0]),
...conversationMessages,
invitedContactsForNewlyCreatedGroup:

View file

@ -1,41 +1,62 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import React from 'react';
import { useSelector } from 'react-redux';
import { TypingBubble } from '../../components/conversation/TypingBubble';
import { strictAssert } from '../../util/assert';
import type { StateType } from '../reducer';
import { useGlobalModalActions } from '../ducks/globalModals';
import { getIntl, getTheme } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { isInternalUser } from '../selectors/items';
type ExternalProps = {
id: string;
conversationId: string;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const conversationSelector = getConversationSelector(state);
const conversation = conversationSelector(id);
export function SmartTypingBubble(props: ExternalProps): JSX.Element {
const { conversationId } = props;
const i18n = useSelector(getIntl);
const theme = useSelector(getTheme);
const getConversation = useSelector(getConversationSelector);
const conversation = getConversation(conversationId);
if (!conversation) {
throw new Error(`Did not find conversation ${id} in state!`);
throw new Error(`Did not find conversation ${conversationId} in state!`);
}
strictAssert(conversation.typingContactId, 'Missing typing contact ID');
const typingContact = conversationSelector(conversation.typingContactId);
strictAssert(
conversation.typingContactIds?.[0],
'Missing typing contact IDs'
);
return {
...typingContact,
badge: getPreferredBadgeSelector(state)(typingContact.badges),
conversationType: conversation.type,
i18n: getIntl(state),
theme: getTheme(state),
};
};
const { showContactModal } = useGlobalModalActions();
const smart = connect(mapStateToProps, mapDispatchToProps);
const preferredBadgeSelector = useSelector(getPreferredBadgeSelector);
export const SmartTypingBubble = smart(TypingBubble);
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 (
<TypingBubble
showContactModal={showContactModal}
conversationId={conversationId}
conversationType={conversation.type}
i18n={i18n}
theme={theme}
typingContacts={typingContacts}
/>
);
}

View file

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

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import memoizee from 'memoizee';
import { head, sortBy } from 'lodash';
import { sortBy } from 'lodash';
import type { ConversationModel } from '../models/conversations';
import type { ConversationType } from '../state/ducks/conversations';
import type { ConversationAttributesType } from '../model-types';
@ -77,8 +77,11 @@ function sortConversationTitles(
// `ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE`, remove it from that list.
export function getConversation(model: ConversationModel): ConversationType {
const { attributes } = model;
const typingValues = Object.values(model.contactTypingTimers || {});
const typingMostRecent = head(sortBy(typingValues, 'timestamp'));
const typingValues = sortBy(
Object.values(model.contactTypingTimers || {}),
'timestamp'
);
const typingContactIds = typingValues.map(({ senderId }) => senderId);
const ourAci = window.textsecure.storage.user.getAci();
const ourPni = window.textsecure.storage.user.getPni();
@ -219,7 +222,7 @@ export function getConversation(model: ConversationModel): ConversationType {
timestamp: dropNull(timestamp),
title: getTitle(attributes),
titleNoDefault: getTitleNoDefault(attributes),
typingContactId: typingMostRecent?.senderId,
typingContactIds,
searchableTitle: isMe(attributes)
? window.i18n('icu:noteToSelf')
: getTitle(attributes),