Multiple person typing indicators in groups
This commit is contained in:
parent
17ea2b58de
commit
e4238de4db
15 changed files with 342 additions and 115 deletions
|
@ -6751,6 +6751,10 @@
|
||||||
"messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
"messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||||
"description": "Call History > Short description of call > When call was declined"
|
"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": {
|
"icu:WhatsNew__modal-title": {
|
||||||
"messageformat": "What's New",
|
"messageformat": "What's New",
|
||||||
"description": "Title for the whats new modal"
|
"description": "Title for the whats new modal"
|
||||||
|
|
|
@ -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 {
|
.module-message__unopened-gift-badge {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
height: 132px;
|
height: 132px;
|
||||||
|
|
|
@ -24,6 +24,7 @@ export type ConfigKeyType =
|
||||||
| 'desktop.contactManagement'
|
| 'desktop.contactManagement'
|
||||||
| 'desktop.groupCallOutboundRing2.beta'
|
| 'desktop.groupCallOutboundRing2.beta'
|
||||||
| 'desktop.groupCallOutboundRing2'
|
| 'desktop.groupCallOutboundRing2'
|
||||||
|
| 'desktop.groupMultiTypingIndicators'
|
||||||
| 'desktop.internalUser'
|
| 'desktop.internalUser'
|
||||||
| 'desktop.mandatoryProfileSharing'
|
| 'desktop.mandatoryProfileSharing'
|
||||||
| 'desktop.mediaQuality.levels'
|
| 'desktop.mediaQuality.levels'
|
||||||
|
|
|
@ -381,7 +381,7 @@ ConversationsMessageStatuses.story = {
|
||||||
|
|
||||||
export const ConversationTypingStatus = (): JSX.Element =>
|
export const ConversationTypingStatus = (): JSX.Element =>
|
||||||
renderConversation({
|
renderConversation({
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
});
|
});
|
||||||
|
|
||||||
ConversationTypingStatus.story = {
|
ConversationTypingStatus.story = {
|
||||||
|
|
|
@ -370,7 +370,7 @@ export function ConversationList({
|
||||||
'shouldShowDraft',
|
'shouldShowDraft',
|
||||||
'title',
|
'title',
|
||||||
'type',
|
'type',
|
||||||
'typingContactId',
|
'typingContactIds',
|
||||||
'unblurredAvatarPath',
|
'unblurredAvatarPath',
|
||||||
'unreadCount',
|
'unreadCount',
|
||||||
'unreadMentionsCount',
|
'unreadMentionsCount',
|
||||||
|
|
|
@ -441,16 +441,23 @@ const renderHeroRow = () => {
|
||||||
};
|
};
|
||||||
const renderTypingBubble = () => (
|
const renderTypingBubble = () => (
|
||||||
<TypingBubble
|
<TypingBubble
|
||||||
acceptedMessageRequest
|
typingContacts={[
|
||||||
badge={undefined}
|
{
|
||||||
color={getRandomColor()}
|
acceptedMessageRequest: true,
|
||||||
|
badge: undefined,
|
||||||
|
color: getRandomColor(),
|
||||||
|
phoneNumber: '+18005552222',
|
||||||
|
id: getDefaultConversation().id,
|
||||||
|
isMe: false,
|
||||||
|
sharedGroupNames: [],
|
||||||
|
title: 'title',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
conversationId="123"
|
||||||
conversationType="direct"
|
conversationType="direct"
|
||||||
phoneNumber="+18005552222"
|
showContactModal={action('showContactModal')}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isMe={false}
|
|
||||||
title="title"
|
|
||||||
theme={ThemeType.light}
|
theme={ThemeType.light}
|
||||||
sharedGroupNames={[]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const renderMiniPlayer = () => (
|
const renderMiniPlayer = () => (
|
||||||
|
|
|
@ -2,11 +2,13 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
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 { setupI18n } from '../../util/setupI18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import type { Props } from './TypingBubble';
|
import type { PropsType as 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';
|
||||||
|
@ -18,25 +20,35 @@ export default {
|
||||||
title: 'Components/Conversation/TypingBubble',
|
title: 'Components/Conversation/TypingBubble',
|
||||||
};
|
};
|
||||||
|
|
||||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
type TypingContactType = TypingBubblePropsType['typingContacts'][number];
|
||||||
acceptedMessageRequest: true,
|
|
||||||
badge: overrideProps.badge,
|
const contacts: Array<TypingContactType> = times(10, index => {
|
||||||
isMe: false,
|
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,
|
i18n,
|
||||||
color: select(
|
conversationId: '123',
|
||||||
'color',
|
conversationType:
|
||||||
AvatarColors.reduce((m, c) => ({ ...m, [c]: c }), {}),
|
overrideProps.conversationType ||
|
||||||
overrideProps.color || AvatarColors[0]
|
select('conversationType', { group: 'group', direct: 'direct' }, 'direct'),
|
||||||
),
|
showContactModal: action('showContactModal'),
|
||||||
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
|
|
||||||
title: '',
|
|
||||||
profileName: text('profileName', overrideProps.profileName || ''),
|
|
||||||
conversationType: select(
|
|
||||||
'conversationType',
|
|
||||||
{ group: 'group', direct: 'direct' },
|
|
||||||
overrideProps.conversationType || 'direct'
|
|
||||||
),
|
|
||||||
sharedGroupNames: [],
|
|
||||||
theme: ThemeType.light,
|
theme: ThemeType.light,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -52,10 +64,25 @@ export function Group(): JSX.Element {
|
||||||
return <TypingBubble {...props} />;
|
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 {
|
export function GroupWithBadge(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
badge: getFakeBadge(),
|
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
|
typingContacts: contacts
|
||||||
|
.slice(0, 1)
|
||||||
|
.map(contact => ({ ...contact, badge: getFakeBadge() })),
|
||||||
});
|
});
|
||||||
|
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} />;
|
||||||
|
@ -64,3 +91,71 @@ export function GroupWithBadge(): JSX.Element {
|
||||||
GroupWithBadge.story = {
|
GroupWithBadge.story = {
|
||||||
name: 'Group (with badge)',
|
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 { ConversationType } from '../../state/ducks/conversations';
|
||||||
import type { BadgeType } from '../../badges/types';
|
import type { BadgeType } from '../../badges/types';
|
||||||
|
|
||||||
export type Props = Pick<
|
const MAX_AVATARS_COUNT = 3;
|
||||||
ConversationType,
|
|
||||||
| 'acceptedMessageRequest'
|
export type PropsType = {
|
||||||
| 'avatarPath'
|
conversationId: string;
|
||||||
| 'color'
|
|
||||||
| 'isMe'
|
|
||||||
| 'phoneNumber'
|
|
||||||
| 'profileName'
|
|
||||||
| 'sharedGroupNames'
|
|
||||||
| 'title'
|
|
||||||
> & {
|
|
||||||
badge: undefined | BadgeType;
|
|
||||||
conversationType: 'group' | 'direct';
|
conversationType: 'group' | 'direct';
|
||||||
|
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
|
typingContacts: Array<
|
||||||
|
Pick<
|
||||||
|
ConversationType,
|
||||||
|
| 'acceptedMessageRequest'
|
||||||
|
| 'avatarPath'
|
||||||
|
| 'color'
|
||||||
|
| 'id'
|
||||||
|
| 'isMe'
|
||||||
|
| 'phoneNumber'
|
||||||
|
| 'profileName'
|
||||||
|
| 'sharedGroupNames'
|
||||||
|
| 'title'
|
||||||
|
> & {
|
||||||
|
badge: undefined | BadgeType;
|
||||||
|
}
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TypingBubble({
|
export function TypingBubble({
|
||||||
acceptedMessageRequest,
|
conversationId,
|
||||||
avatarPath,
|
|
||||||
badge,
|
|
||||||
color,
|
|
||||||
conversationType,
|
conversationType,
|
||||||
|
showContactModal,
|
||||||
i18n,
|
i18n,
|
||||||
isMe,
|
|
||||||
phoneNumber,
|
|
||||||
profileName,
|
|
||||||
sharedGroupNames,
|
|
||||||
theme,
|
theme,
|
||||||
title,
|
typingContacts,
|
||||||
}: Props): ReactElement {
|
}: PropsType): ReactElement {
|
||||||
const isGroup = conversationType === 'group';
|
const isGroup = conversationType === 'group';
|
||||||
|
|
||||||
|
const typingContactsOverflowCount = Math.max(
|
||||||
|
typingContacts.length - MAX_AVATARS_COUNT,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -54,22 +62,50 @@ export function TypingBubble({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isGroup && (
|
{isGroup && (
|
||||||
<div className="module-message__author-avatar-container">
|
<div className="module-message__typing-avatar-container">
|
||||||
<Avatar
|
{typingContactsOverflowCount > 0 && (
|
||||||
acceptedMessageRequest={acceptedMessageRequest}
|
<div
|
||||||
avatarPath={avatarPath}
|
className="module-message__typing-avatar module-message__typing-avatar--overflow-count
|
||||||
badge={badge}
|
"
|
||||||
color={color}
|
>
|
||||||
conversationType="direct"
|
<div
|
||||||
i18n={i18n}
|
aria-label={i18n('icu:TypingBubble__avatar--overflow-count', {
|
||||||
isMe={isMe}
|
count: typingContactsOverflowCount,
|
||||||
phoneNumber={phoneNumber}
|
})}
|
||||||
profileName={profileName}
|
className="module-Avatar"
|
||||||
theme={theme}
|
>
|
||||||
title={title}
|
<div className="module-Avatar__contents">
|
||||||
sharedGroupNames={sharedGroupNames}
|
<div aria-hidden="true" className="module-Avatar__label">
|
||||||
size={28}
|
+{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>
|
||||||
)}
|
)}
|
||||||
<div className="module-message__container-outer">
|
<div className="module-message__container-outer">
|
||||||
|
|
|
@ -60,7 +60,7 @@ export type PropsData = Pick<
|
||||||
| 'shouldShowDraft'
|
| 'shouldShowDraft'
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'type'
|
| 'type'
|
||||||
| 'typingContactId'
|
| 'typingContactIds'
|
||||||
| 'unblurredAvatarPath'
|
| 'unblurredAvatarPath'
|
||||||
| 'unreadCount'
|
| 'unreadCount'
|
||||||
| 'unreadMentionsCount'
|
| 'unreadMentionsCount'
|
||||||
|
@ -104,13 +104,14 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
theme,
|
theme,
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
typingContactId,
|
typingContactIds,
|
||||||
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 headerName = (
|
const headerName = (
|
||||||
<>
|
<>
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
|
@ -139,7 +140,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
{i18n('icu:ConversationListItem--message-request')}
|
{i18n('icu:ConversationListItem--message-request')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (typingContactId) {
|
} else if (isSomeoneTyping) {
|
||||||
messageText = <TypingAnimation i18n={i18n} />;
|
messageText = <TypingAnimation i18n={i18n} />;
|
||||||
} else if (shouldShowDraft && draftPreview) {
|
} else if (shouldShowDraft && draftPreview) {
|
||||||
messageText = (
|
messageText = (
|
||||||
|
|
|
@ -5111,8 +5111,8 @@ export class ConversationModel extends window.Backbone
|
||||||
this.clearContactTypingTimer.bind(this, typingToken),
|
this.clearContactTypingTimer.bind(this, typingToken),
|
||||||
15 * 1000
|
15 * 1000
|
||||||
);
|
);
|
||||||
|
// User was not previously typing before. State change!
|
||||||
if (!record) {
|
if (!record) {
|
||||||
// User was not previously typing before. State change!
|
|
||||||
this.trigger('change', this, { force: true });
|
this.trigger('change', this, { force: true });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -298,7 +298,7 @@ export type ConversationType = ReadonlyDeep<
|
||||||
unreadMentionsCount?: number;
|
unreadMentionsCount?: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isFetchingUUID?: boolean;
|
isFetchingUUID?: boolean;
|
||||||
typingContactId?: string;
|
typingContactIds?: Array<string>;
|
||||||
recentMediaItems?: ReadonlyArray<MediaItemType>;
|
recentMediaItems?: ReadonlyArray<MediaItemType>;
|
||||||
profileSharing?: boolean;
|
profileSharing?: boolean;
|
||||||
|
|
||||||
|
|
|
@ -98,8 +98,8 @@ function renderHeroRow(id: string): JSX.Element {
|
||||||
function renderMiniPlayer(options: { shouldFlow: boolean }): JSX.Element {
|
function renderMiniPlayer(options: { shouldFlow: boolean }): JSX.Element {
|
||||||
return <SmartMiniPlayer {...options} />;
|
return <SmartMiniPlayer {...options} />;
|
||||||
}
|
}
|
||||||
function renderTypingBubble(id: string): JSX.Element {
|
function renderTypingBubble(conversationId: string): JSX.Element {
|
||||||
return <SmartTypingBubble id={id} />;
|
return <SmartTypingBubble conversationId={conversationId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getWarning = (
|
const getWarning = (
|
||||||
|
@ -241,13 +241,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
'unreadCount',
|
'unreadCount',
|
||||||
'unreadMentionsCount',
|
'unreadMentionsCount',
|
||||||
'isGroupV1AndDisabled',
|
'isGroupV1AndDisabled',
|
||||||
|
'typingContactIds',
|
||||||
]),
|
]),
|
||||||
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.typingContactId),
|
isSomeoneTyping: Boolean(conversation.typingContactIds?.[0]),
|
||||||
...conversationMessages,
|
...conversationMessages,
|
||||||
|
|
||||||
invitedContactsForNewlyCreatedGroup:
|
invitedContactsForNewlyCreatedGroup:
|
||||||
|
|
|
@ -1,41 +1,62 @@
|
||||||
// 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 { connect } from 'react-redux';
|
import React from 'react';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { useSelector } from 'react-redux';
|
||||||
import { TypingBubble } from '../../components/conversation/TypingBubble';
|
import { TypingBubble } from '../../components/conversation/TypingBubble';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import type { StateType } from '../reducer';
|
|
||||||
|
|
||||||
|
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
import { getIntl, getTheme } from '../selectors/user';
|
import { getIntl, getTheme } from '../selectors/user';
|
||||||
import { getConversationSelector } from '../selectors/conversations';
|
import { getConversationSelector } from '../selectors/conversations';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
|
import { isInternalUser } from '../selectors/items';
|
||||||
|
|
||||||
type ExternalProps = {
|
type ExternalProps = {
|
||||||
id: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
export function SmartTypingBubble(props: ExternalProps): JSX.Element {
|
||||||
const { id } = props;
|
const { conversationId } = props;
|
||||||
|
const i18n = useSelector(getIntl);
|
||||||
const conversationSelector = getConversationSelector(state);
|
const theme = useSelector(getTheme);
|
||||||
const conversation = conversationSelector(id);
|
const getConversation = useSelector(getConversationSelector);
|
||||||
|
const conversation = getConversation(conversationId);
|
||||||
if (!conversation) {
|
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');
|
strictAssert(
|
||||||
const typingContact = conversationSelector(conversation.typingContactId);
|
conversation.typingContactIds?.[0],
|
||||||
|
'Missing typing contact IDs'
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
const { showContactModal } = useGlobalModalActions();
|
||||||
...typingContact,
|
|
||||||
badge: getPreferredBadgeSelector(state)(typingContact.badges),
|
|
||||||
conversationType: conversation.type,
|
|
||||||
i18n: getIntl(state),
|
|
||||||
theme: getTheme(state),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1145,7 +1145,7 @@ describe('both/state/selectors/conversations-extra', () => {
|
||||||
title: 'No timestamp',
|
title: 'No timestamp',
|
||||||
unreadCount: 1,
|
unreadCount: 1,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
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,
|
||||||
typingContactId: generateUuid(),
|
typingContactIds: [generateUuid()],
|
||||||
|
|
||||||
acceptedMessageRequest: true,
|
acceptedMessageRequest: true,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import memoizee from 'memoizee';
|
import memoizee from 'memoizee';
|
||||||
import { head, sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type { ConversationModel } from '../models/conversations';
|
import type { ConversationModel } from '../models/conversations';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { ConversationAttributesType } from '../model-types';
|
import type { ConversationAttributesType } from '../model-types';
|
||||||
|
@ -77,8 +77,11 @@ function sortConversationTitles(
|
||||||
// `ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE`, remove it from that list.
|
// `ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE`, remove it from that list.
|
||||||
export function getConversation(model: ConversationModel): ConversationType {
|
export function getConversation(model: ConversationModel): ConversationType {
|
||||||
const { attributes } = model;
|
const { attributes } = model;
|
||||||
const typingValues = Object.values(model.contactTypingTimers || {});
|
const typingValues = sortBy(
|
||||||
const typingMostRecent = head(sortBy(typingValues, 'timestamp'));
|
Object.values(model.contactTypingTimers || {}),
|
||||||
|
'timestamp'
|
||||||
|
);
|
||||||
|
const typingContactIds = typingValues.map(({ senderId }) => senderId);
|
||||||
|
|
||||||
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();
|
||||||
|
@ -219,7 +222,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),
|
||||||
typingContactId: typingMostRecent?.senderId,
|
typingContactIds,
|
||||||
searchableTitle: isMe(attributes)
|
searchableTitle: isMe(attributes)
|
||||||
? window.i18n('icu:noteToSelf')
|
? window.i18n('icu:noteToSelf')
|
||||||
: getTitle(attributes),
|
: getTitle(attributes),
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue