From e4238de4dbe40121421c8d509b3b459d65a1c758 Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Mon, 18 Sep 2023 14:17:26 -0700 Subject: [PATCH] Multiple person typing indicators in groups --- _locales/en/messages.json | 4 + stylesheets/_modules.scss | 58 ++++++++ ts/RemoteConfig.ts | 1 + ts/components/ConversationList.stories.tsx | 2 +- ts/components/ConversationList.tsx | 2 +- .../conversation/Timeline.stories.tsx | 21 ++- .../conversation/TypingBubble.stories.tsx | 137 +++++++++++++++--- ts/components/conversation/TypingBubble.tsx | 112 +++++++++----- .../conversationList/ConversationListItem.tsx | 7 +- ts/models/conversations.ts | 2 +- ts/state/ducks/conversations.ts | 2 +- ts/state/smart/Timeline.tsx | 7 +- ts/state/smart/TypingBubble.tsx | 65 ++++++--- .../state/selectors/conversations_test.ts | 26 ++-- ts/util/getConversation.ts | 11 +- 15 files changed, 342 insertions(+), 115 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 482d2f73aaaa..57c9c0c9f4c0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 82a2f4328758..66b221b7701d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index cc2e0ab351ee..6ab0d1c285b5 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -24,6 +24,7 @@ export type ConfigKeyType = | 'desktop.contactManagement' | 'desktop.groupCallOutboundRing2.beta' | 'desktop.groupCallOutboundRing2' + | 'desktop.groupMultiTypingIndicators' | 'desktop.internalUser' | 'desktop.mandatoryProfileSharing' | 'desktop.mediaQuality.levels' diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index db84f0000344..e09e28a007aa 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -381,7 +381,7 @@ ConversationsMessageStatuses.story = { export const ConversationTypingStatus = (): JSX.Element => renderConversation({ - typingContactId: generateUuid(), + typingContactIds: [generateUuid()], }); ConversationTypingStatus.story = { diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 7c9e658b12ce..b260339781e8 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -370,7 +370,7 @@ export function ConversationList({ 'shouldShowDraft', 'title', 'type', - 'typingContactId', + 'typingContactIds', 'unblurredAvatarPath', 'unreadCount', 'unreadMentionsCount', diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index efc0e19407c9..9c3fa61b301e 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -441,16 +441,23 @@ const renderHeroRow = () => { }; const renderTypingBubble = () => ( ); const renderMiniPlayer = () => ( diff --git a/ts/components/conversation/TypingBubble.stories.tsx b/ts/components/conversation/TypingBubble.stories.tsx index 566965c6ee71..2240d318b56a 100644 --- a/ts/components/conversation/TypingBubble.stories.tsx +++ b/ts/components/conversation/TypingBubble.stories.tsx @@ -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 => ({ - acceptedMessageRequest: true, - badge: overrideProps.badge, - isMe: false, +type TypingContactType = TypingBubblePropsType['typingContacts'][number]; + +const contacts: Array = 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 => ({ + 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 ; } +Group.story = { + name: 'Group (1 person typing)', +}; + +export function GroupMultiTyping2(): JSX.Element { + const props = createProps({ + conversationType: 'group', + typingContacts: contacts.slice(0, 2), + }); + + return ; +} + export function GroupWithBadge(): JSX.Element { const props = createProps({ - badge: getFakeBadge(), conversationType: 'group', + typingContacts: contacts + .slice(0, 1) + .map(contact => ({ ...contact, badge: getFakeBadge() })), }); return ; @@ -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 ; +} + +GroupMultiTyping3.story = { + name: 'Group (3 persons typing)', +}; + +export function GroupMultiTyping4(): JSX.Element { + const props = createProps({ + conversationType: 'group', + typingContacts: contacts.slice(0, 4), + }); + + return ; +} + +GroupMultiTyping4.story = { + name: 'Group (4 persons typing)', +}; + +export function GroupMultiTyping10(): JSX.Element { + const props = createProps({ + conversationType: 'group', + typingContacts: contacts.slice(0, 10), + }); + + return ; +} + +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 ; +} + +GroupMultiTypingWithBadges.story = { + name: 'Group (3 persons typing, 2 persons have badge)', +}; diff --git a/ts/components/conversation/TypingBubble.tsx b/ts/components/conversation/TypingBubble.tsx index d594deba9370..14c2e9fba19e 100644 --- a/ts/components/conversation/TypingBubble.tsx +++ b/ts/components/conversation/TypingBubble.tsx @@ -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 (
{isGroup && ( -
- +
+ {typingContactsOverflowCount > 0 && ( +
+
+
+ +
+
+
+ )} + {typingContacts.slice(-1 * MAX_AVATARS_COUNT).map(contact => ( +
+ { + event.stopPropagation(); + event.preventDefault(); + showContactModal(contact.id, conversationId); + }} + phoneNumber={contact.phoneNumber} + profileName={contact.profileName} + theme={theme} + title={contact.title} + sharedGroupNames={contact.sharedGroupNames} + size={28} + /> +
+ ))}
)}
diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index cb0974876a5d..19e72ec0b9f6 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -60,7 +60,7 @@ export type PropsData = Pick< | 'shouldShowDraft' | 'title' | 'type' - | 'typingContactId' + | 'typingContactIds' | 'unblurredAvatarPath' | 'unreadCount' | 'unreadMentionsCount' @@ -104,13 +104,14 @@ export const ConversationListItem: FunctionComponent = 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 = React.memo( {i18n('icu:ConversationListItem--message-request')} ); - } else if (typingContactId) { + } else if (isSomeoneTyping) { messageText = ; } else if (shouldShowDraft && draftPreview) { messageText = ( diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 443015dbeb21..3c7e4023e205 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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 { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 0ca91a37c8d2..484e4e53dd6a 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -298,7 +298,7 @@ export type ConversationType = ReadonlyDeep< unreadMentionsCount?: number; isSelected?: boolean; isFetchingUUID?: boolean; - typingContactId?: string; + typingContactIds?: Array; recentMediaItems?: ReadonlyArray; profileSharing?: boolean; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 86f287d71fc2..b38e720c1d50 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -98,8 +98,8 @@ function renderHeroRow(id: string): JSX.Element { function renderMiniPlayer(options: { shouldFlow: boolean }): JSX.Element { return ; } -function renderTypingBubble(id: string): JSX.Element { - return ; +function renderTypingBubble(conversationId: string): JSX.Element { + return ; } 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: diff --git a/ts/state/smart/TypingBubble.tsx b/ts/state/smart/TypingBubble.tsx index 9820b382a453..16248c69c3df 100644 --- a/ts/state/smart/TypingBubble.tsx +++ b/ts/state/smart/TypingBubble.tsx @@ -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 ( + + ); +} diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 4ef9506e02e5..3d9a286a86aa 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -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, }), diff --git a/ts/util/getConversation.ts b/ts/util/getConversation.ts index cdeb49b87c72..a6b9a45ffd0c 100644 --- a/ts/util/getConversation.ts +++ b/ts/util/getConversation.ts @@ -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),