+ {typingContactsOverflowCount > 0 && (
+
+
+
+
+ +{typingContactsOverflowCount}
+
+
+
+
+ )}
+ {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),