Group Typing Bubble Animations
This commit is contained in:
parent
88df942029
commit
283ef57779
14 changed files with 565 additions and 209 deletions
|
@ -1386,6 +1386,10 @@ $message-padding-horizontal: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message--typing-bubble {
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__typing-avatar-container {
|
.module-message__typing-avatar-container {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -1729,7 +1733,7 @@ $message-padding-horizontal: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__typing-container {
|
.module-message__typing-animation-container {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -381,7 +381,9 @@ ConversationsMessageStatuses.story = {
|
||||||
|
|
||||||
export const ConversationTypingStatus = (): JSX.Element =>
|
export const ConversationTypingStatus = (): JSX.Element =>
|
||||||
renderConversation({
|
renderConversation({
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: {
|
||||||
|
[generateUuid()]: date('timestamp', new Date()),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ConversationTypingStatus.story = {
|
ConversationTypingStatus.story = {
|
||||||
|
|
|
@ -370,7 +370,7 @@ export function ConversationList({
|
||||||
'shouldShowDraft',
|
'shouldShowDraft',
|
||||||
'title',
|
'title',
|
||||||
'type',
|
'type',
|
||||||
'typingContactIds',
|
'typingContactIdTimestamps',
|
||||||
'unblurredAvatarPath',
|
'unblurredAvatarPath',
|
||||||
'unreadCount',
|
'unreadCount',
|
||||||
'unreadMentionsCount',
|
'unreadMentionsCount',
|
||||||
|
|
|
@ -19,7 +19,6 @@ import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext
|
||||||
import { ConversationHero } from './ConversationHero';
|
import { ConversationHero } from './ConversationHero';
|
||||||
import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from '../../state/smart/ContactSpoofingReviewDialog';
|
import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from '../../state/smart/ContactSpoofingReviewDialog';
|
||||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||||
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
|
|
||||||
import { TypingBubble } from './TypingBubble';
|
import { TypingBubble } from './TypingBubble';
|
||||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
@ -441,20 +440,13 @@ const renderHeroRow = () => {
|
||||||
};
|
};
|
||||||
const renderTypingBubble = () => (
|
const renderTypingBubble = () => (
|
||||||
<TypingBubble
|
<TypingBubble
|
||||||
typingContacts={[
|
typingContactIdTimestamps={{ [getDefaultConversation().id]: Date.now() }}
|
||||||
{
|
lastItemAuthorId="123"
|
||||||
acceptedMessageRequest: true,
|
lastItemTimestamp={undefined}
|
||||||
badge: undefined,
|
|
||||||
color: getRandomColor(),
|
|
||||||
phoneNumber: '+18005552222',
|
|
||||||
id: getDefaultConversation().id,
|
|
||||||
isMe: false,
|
|
||||||
sharedGroupNames: [],
|
|
||||||
title: 'title',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
conversationId="123"
|
conversationId="123"
|
||||||
conversationType="direct"
|
conversationType="direct"
|
||||||
|
getConversation={() => getDefaultConversation()}
|
||||||
|
getPreferredBadge={() => undefined}
|
||||||
showContactModal={action('showContactModal')}
|
showContactModal={action('showContactModal')}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
theme={ThemeType.light}
|
theme={ThemeType.light}
|
||||||
|
|
|
@ -795,7 +795,6 @@ export class Timeline extends React.Component<
|
||||||
invitedContactsForNewlyCreatedGroup,
|
invitedContactsForNewlyCreatedGroup,
|
||||||
isConversationSelected,
|
isConversationSelected,
|
||||||
isGroupV1AndDisabled,
|
isGroupV1AndDisabled,
|
||||||
isSomeoneTyping,
|
|
||||||
items,
|
items,
|
||||||
messageLoadingState,
|
messageLoadingState,
|
||||||
oldestUnseenIndex,
|
oldestUnseenIndex,
|
||||||
|
@ -1144,7 +1143,7 @@ export class Timeline extends React.Component<
|
||||||
|
|
||||||
{messageNodes}
|
{messageNodes}
|
||||||
|
|
||||||
{isSomeoneTyping && haveNewest && renderTypingBubble(id)}
|
{haveNewest && renderTypingBubble(id)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="module-timeline__messages__at-bottom-detector"
|
className="module-timeline__messages__at-bottom-detector"
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { times } from 'lodash';
|
import { times } from 'lodash';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import { select } from '@storybook/addon-knobs';
|
import { date, select } from '@storybook/addon-knobs';
|
||||||
|
|
||||||
|
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||||
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 { PropsType as TypingBubblePropsType } from './TypingBubble';
|
import type { 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';
|
||||||
|
@ -20,15 +21,13 @@ export default {
|
||||||
title: 'Components/Conversation/TypingBubble',
|
title: 'Components/Conversation/TypingBubble',
|
||||||
};
|
};
|
||||||
|
|
||||||
type TypingContactType = TypingBubblePropsType['typingContacts'][number];
|
const CONTACTS = times(10, index => {
|
||||||
|
|
||||||
const contacts: Array<TypingContactType> = times(10, index => {
|
|
||||||
const letter = (index + 10).toString(36).toUpperCase();
|
const letter = (index + 10).toString(36).toUpperCase();
|
||||||
return {
|
return getDefaultConversation({
|
||||||
id: `contact-${index}`,
|
id: `contact-${index}`,
|
||||||
acceptedMessageRequest: false,
|
acceptedMessageRequest: false,
|
||||||
avatarPath: '',
|
avatarPath: '',
|
||||||
badge: undefined,
|
badges: [],
|
||||||
color: AvatarColors[index],
|
color: AvatarColors[index],
|
||||||
name: `${letter} ${letter}`,
|
name: `${letter} ${letter}`,
|
||||||
phoneNumber: '(202) 555-0001',
|
phoneNumber: '(202) 555-0001',
|
||||||
|
@ -36,21 +35,52 @@ const contacts: Array<TypingContactType> = times(10, index => {
|
||||||
isMe: false,
|
isMe: false,
|
||||||
sharedGroupNames: [],
|
sharedGroupNames: [],
|
||||||
title: `${letter} ${letter}`,
|
title: `${letter} ${letter}`,
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
|
const CONTACT_IDS = CONTACTS.map(contact => contact.id);
|
||||||
|
const CONTACTS_BY_ID = new Map(CONTACTS.map(contact => [contact.id, contact]));
|
||||||
|
const getConversation = (id: string) =>
|
||||||
|
CONTACTS_BY_ID.get(id) || getDefaultConversation();
|
||||||
|
|
||||||
|
const CONTACTS_WITH_BADGES = CONTACTS.map(contact => {
|
||||||
|
return { ...contact, badges: [getFakeBadge()] };
|
||||||
|
});
|
||||||
|
const CONTACTS_WITH_BADGES_BY_ID = new Map(
|
||||||
|
CONTACTS_WITH_BADGES.map(contact => [contact.id, contact])
|
||||||
|
);
|
||||||
|
const getConversationWithBadges = (id: string) =>
|
||||||
|
CONTACTS_WITH_BADGES_BY_ID.get(id) || getDefaultConversation();
|
||||||
|
|
||||||
|
const getTypingContactIdTimestamps = (count: number) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
CONTACT_IDS.slice(0, count).map(id => [id, date('timestamp', new Date())])
|
||||||
|
);
|
||||||
|
|
||||||
const createProps = (
|
const createProps = (
|
||||||
overrideProps: Partial<TypingBubblePropsType> = {}
|
overrideProps: Partial<TypingBubblePropsType> = {}
|
||||||
): TypingBubblePropsType => ({
|
): TypingBubblePropsType => {
|
||||||
typingContacts: overrideProps.typingContacts || contacts.slice(0, 1),
|
return {
|
||||||
i18n,
|
typingContactIdTimestamps:
|
||||||
conversationId: '123',
|
overrideProps.typingContactIdTimestamps ??
|
||||||
conversationType:
|
getTypingContactIdTimestamps(1),
|
||||||
overrideProps.conversationType ||
|
lastItemAuthorId: '123',
|
||||||
select('conversationType', { group: 'group', direct: 'direct' }, 'direct'),
|
lastItemTimestamp: undefined,
|
||||||
showContactModal: action('showContactModal'),
|
i18n,
|
||||||
theme: ThemeType.light,
|
conversationId: '123',
|
||||||
});
|
conversationType:
|
||||||
|
overrideProps.conversationType ||
|
||||||
|
select(
|
||||||
|
'conversationType',
|
||||||
|
{ group: 'group', direct: 'direct' },
|
||||||
|
'direct'
|
||||||
|
),
|
||||||
|
getConversation: overrideProps.getConversation || getConversation,
|
||||||
|
getPreferredBadge: badges =>
|
||||||
|
badges.length > 0 ? getFakeBadge() : undefined,
|
||||||
|
showContactModal: action('showContactModal'),
|
||||||
|
theme: ThemeType.light,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function Direct(): JSX.Element {
|
export function Direct(): JSX.Element {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
|
@ -58,9 +88,24 @@ export function Direct(): JSX.Element {
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DirectStoppedTyping(): JSX.Element {
|
||||||
|
const props = createProps();
|
||||||
|
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
setAfterTimeoutProps({
|
||||||
|
typingContactIdTimestamps: {},
|
||||||
|
}),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <TypingBubble {...props} {...afterTimeoutProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
export function Group(): JSX.Element {
|
export function Group(): JSX.Element {
|
||||||
const props = createProps({ conversationType: 'group' });
|
const props = createProps({ conversationType: 'group' });
|
||||||
|
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,21 +113,31 @@ Group.story = {
|
||||||
name: 'Group (1 person typing)',
|
name: 'Group (1 person typing)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GroupMultiTyping2(): JSX.Element {
|
export function GroupStoppedTyping(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
typingContacts: contacts.slice(0, 2),
|
typingContactIdTimestamps: getTypingContactIdTimestamps(1),
|
||||||
});
|
});
|
||||||
|
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(
|
||||||
|
() => setAfterTimeoutProps({ typingContactIdTimestamps: {} }),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} {...afterTimeoutProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GroupStoppedTyping.story = {
|
||||||
|
name: 'Group (1 person stopped typing)',
|
||||||
|
};
|
||||||
|
|
||||||
export function GroupWithBadge(): JSX.Element {
|
export function GroupWithBadge(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
typingContacts: contacts
|
typingContactIdTimestamps: getTypingContactIdTimestamps(1),
|
||||||
.slice(0, 1)
|
getConversation: getConversationWithBadges,
|
||||||
.map(contact => ({ ...contact, badge: getFakeBadge() })),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} />;
|
||||||
|
@ -92,40 +147,79 @@ GroupWithBadge.story = {
|
||||||
name: 'Group (with badge)',
|
name: 'Group (with badge)',
|
||||||
};
|
};
|
||||||
|
|
||||||
GroupMultiTyping2.story = {
|
export function GroupMultiTyping1To2(): JSX.Element {
|
||||||
name: 'Group (2 persons typing)',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function GroupMultiTyping3(): JSX.Element {
|
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
typingContacts: contacts.slice(0, 3),
|
typingContactIdTimestamps: getTypingContactIdTimestamps(1),
|
||||||
});
|
});
|
||||||
|
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
setAfterTimeoutProps({
|
||||||
|
typingContactIdTimestamps: getTypingContactIdTimestamps(2),
|
||||||
|
}),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} {...afterTimeoutProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
GroupMultiTyping3.story = {
|
GroupMultiTyping1To2.story = {
|
||||||
name: 'Group (3 persons typing)',
|
name: 'Group (1 to 2 persons)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GroupMultiTyping4(): JSX.Element {
|
export function GroupMultiTyping2Then1PersonStops(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
typingContacts: contacts.slice(0, 4),
|
typingContactIdTimestamps: getTypingContactIdTimestamps(2),
|
||||||
});
|
});
|
||||||
|
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
setAfterTimeoutProps({
|
||||||
|
typingContactIdTimestamps: getTypingContactIdTimestamps(1),
|
||||||
|
}),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} {...afterTimeoutProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
GroupMultiTyping4.story = {
|
GroupMultiTyping2Then1PersonStops.story = {
|
||||||
name: 'Group (4 persons typing)',
|
name: 'Group (2 persons typing then 1 person stops)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GroupMultiTyping3To4(): JSX.Element {
|
||||||
|
const props = createProps({
|
||||||
|
conversationType: 'group',
|
||||||
|
typingContactIdTimestamps: getTypingContactIdTimestamps(3),
|
||||||
|
});
|
||||||
|
const [afterTimeoutProps, setAfterTimeoutProps] = useState({});
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
setAfterTimeoutProps({
|
||||||
|
typingContactIdTimestamps: getTypingContactIdTimestamps(4),
|
||||||
|
}),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <TypingBubble {...props} {...afterTimeoutProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupMultiTyping3To4.story = {
|
||||||
|
name: 'Group (3 to 4)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GroupMultiTyping10(): JSX.Element {
|
export function GroupMultiTyping10(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
typingContacts: contacts.slice(0, 10),
|
typingContactIdTimestamps: getTypingContactIdTimestamps(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} />;
|
||||||
|
@ -138,19 +232,8 @@ GroupMultiTyping10.story = {
|
||||||
export function GroupMultiTypingWithBadges(): JSX.Element {
|
export function GroupMultiTypingWithBadges(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
typingContacts: [
|
typingContactIdTimestamps: getTypingContactIdTimestamps(3),
|
||||||
{
|
getConversation: getConversationWithBadges,
|
||||||
...contacts[0],
|
|
||||||
badge: getFakeBadge(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...contacts[1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...contacts[2],
|
|
||||||
badge: getFakeBadge(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return <TypingBubble {...props} />;
|
return <TypingBubble {...props} />;
|
||||||
|
|
|
@ -2,124 +2,384 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import React from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
import { TypingAnimation } from './TypingAnimation';
|
import { TypingAnimation } from './TypingAnimation';
|
||||||
import { Avatar } from '../Avatar';
|
import { Avatar } from '../Avatar';
|
||||||
|
|
||||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
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 { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
||||||
|
|
||||||
const MAX_AVATARS_COUNT = 3;
|
const MAX_AVATARS_COUNT = 3;
|
||||||
|
|
||||||
export type PropsType = {
|
type TypingContactType = Pick<
|
||||||
|
ConversationType,
|
||||||
|
| 'acceptedMessageRequest'
|
||||||
|
| 'avatarPath'
|
||||||
|
| 'badges'
|
||||||
|
| 'color'
|
||||||
|
| 'id'
|
||||||
|
| 'isMe'
|
||||||
|
| 'phoneNumber'
|
||||||
|
| 'profileName'
|
||||||
|
| 'sharedGroupNames'
|
||||||
|
| 'title'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TypingBubblePropsType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
conversationType: 'group' | 'direct';
|
conversationType: 'group' | 'direct';
|
||||||
|
typingContactIdTimestamps: Record<string, number>;
|
||||||
|
lastItemAuthorId: string | undefined;
|
||||||
|
lastItemTimestamp: number | undefined;
|
||||||
|
getConversation: (id: string) => ConversationType;
|
||||||
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
typingContacts: Array<
|
};
|
||||||
Pick<
|
|
||||||
ConversationType,
|
const SPRING_CONFIG = {
|
||||||
| 'acceptedMessageRequest'
|
mass: 1,
|
||||||
| 'avatarPath'
|
tension: 986,
|
||||||
| 'color'
|
friction: 64,
|
||||||
| 'id'
|
precision: 0,
|
||||||
| 'isMe'
|
velocity: 0,
|
||||||
| 'phoneNumber'
|
};
|
||||||
| 'profileName'
|
|
||||||
| 'sharedGroupNames'
|
const AVATAR_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
|
||||||
| 'title'
|
visible: {
|
||||||
> & {
|
opacity: 1,
|
||||||
badge: undefined | BadgeType;
|
scale: 1,
|
||||||
|
width: '28px',
|
||||||
|
x: '0px',
|
||||||
|
y: '0px',
|
||||||
|
},
|
||||||
|
hidden: {
|
||||||
|
opacity: 0.5,
|
||||||
|
scale: 0.5,
|
||||||
|
width: '4px', // Match value of module-message__typing-avatar margin-inline-start
|
||||||
|
x: '14px',
|
||||||
|
y: '30px',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function TypingBubbleAvatar({
|
||||||
|
conversationId,
|
||||||
|
contact,
|
||||||
|
visible,
|
||||||
|
getPreferredBadge,
|
||||||
|
onContactExit,
|
||||||
|
showContactModal,
|
||||||
|
i18n,
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
contact: TypingContactType | undefined;
|
||||||
|
visible: boolean;
|
||||||
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
|
onContactExit: (id: string | undefined) => void;
|
||||||
|
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
theme: ThemeType;
|
||||||
|
}): ReactElement | null {
|
||||||
|
const [springProps, springApi] = useSpring(
|
||||||
|
{
|
||||||
|
config: SPRING_CONFIG,
|
||||||
|
from: AVATAR_ANIMATION_PROPS[visible ? 'hidden' : 'visible'],
|
||||||
|
to: AVATAR_ANIMATION_PROPS[visible ? 'visible' : 'hidden'],
|
||||||
|
onRest: () => {
|
||||||
|
if (!visible) {
|
||||||
|
onContactExit(contact?.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[visible]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
springApi.stop();
|
||||||
|
springApi.start(AVATAR_ANIMATION_PROPS[visible ? 'visible' : 'hidden']);
|
||||||
|
}, [visible, springApi]);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<animated.div className="module-message__typing-avatar" style={springProps}>
|
||||||
|
<Avatar
|
||||||
|
acceptedMessageRequest={contact.acceptedMessageRequest}
|
||||||
|
avatarPath={contact.avatarPath}
|
||||||
|
badge={getPreferredBadge(contact.badges)}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</animated.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypingBubbleGroupAvatars({
|
||||||
|
conversationId,
|
||||||
|
typingContactIds,
|
||||||
|
getConversation,
|
||||||
|
getPreferredBadge,
|
||||||
|
showContactModal,
|
||||||
|
i18n,
|
||||||
|
theme,
|
||||||
|
}: Pick<
|
||||||
|
TypingBubblePropsType,
|
||||||
|
| 'conversationId'
|
||||||
|
| 'getConversation'
|
||||||
|
| 'getPreferredBadge'
|
||||||
|
| 'showContactModal'
|
||||||
|
| 'i18n'
|
||||||
|
| 'theme'
|
||||||
|
> & {
|
||||||
|
typingContactIds: ReadonlyArray<string>;
|
||||||
|
}): ReactElement {
|
||||||
|
const [allContactsById, setAllContactsById] = useState<
|
||||||
|
Map<string, TypingContactType>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
|
const onContactExit = useCallback((id: string | undefined) => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
>;
|
|
||||||
|
setAllContactsById(prevMap => {
|
||||||
|
const map = new Map([...prevMap]);
|
||||||
|
map.delete(id);
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleContactIds: Set<string> = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const id of typingContactIds) {
|
||||||
|
set.add(id);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [typingContactIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAllContactsById(prevMap => {
|
||||||
|
const map = new Map([...prevMap]);
|
||||||
|
for (const id of typingContactIds) {
|
||||||
|
map.set(id, getConversation(id));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
}, [typingContactIds, getConversation]);
|
||||||
|
|
||||||
|
const typingContactsOverflowCount = Math.max(
|
||||||
|
typingContactIds.length - MAX_AVATARS_COUNT,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Avatars are rendered Right-to-Left so the leftmost avatars can render on top.
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{[...allContactsById.keys()]
|
||||||
|
.slice(-1 * MAX_AVATARS_COUNT)
|
||||||
|
.map(contactId => (
|
||||||
|
<TypingBubbleAvatar
|
||||||
|
key={contactId}
|
||||||
|
conversationId={conversationId}
|
||||||
|
contact={allContactsById.get(contactId)}
|
||||||
|
getPreferredBadge={getPreferredBadge}
|
||||||
|
showContactModal={showContactModal}
|
||||||
|
onContactExit={onContactExit}
|
||||||
|
i18n={i18n}
|
||||||
|
theme={theme}
|
||||||
|
visible={visibleContactIds.has(contactId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTER_DIV_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
|
||||||
|
visible: { height: '44px' },
|
||||||
|
hidden: { height: '0px' },
|
||||||
|
};
|
||||||
|
const BUBBLE_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = {
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
y: '0px',
|
||||||
|
},
|
||||||
|
hidden: {
|
||||||
|
opacity: 0.5,
|
||||||
|
scale: 0.5,
|
||||||
|
y: '30px',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TypingBubble({
|
export function TypingBubble({
|
||||||
conversationId,
|
conversationId,
|
||||||
conversationType,
|
conversationType,
|
||||||
|
typingContactIdTimestamps,
|
||||||
|
lastItemAuthorId,
|
||||||
|
lastItemTimestamp,
|
||||||
|
getConversation,
|
||||||
|
getPreferredBadge,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
i18n,
|
i18n,
|
||||||
theme,
|
theme,
|
||||||
typingContacts,
|
}: TypingBubblePropsType): ReactElement | null {
|
||||||
}: PropsType): ReactElement {
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const typingContactIds = useMemo(
|
||||||
|
() => Object.keys(typingContactIdTimestamps),
|
||||||
|
[typingContactIdTimestamps]
|
||||||
|
);
|
||||||
|
const isSomeoneTyping = useMemo(
|
||||||
|
() => typingContactIds.length > 0,
|
||||||
|
[typingContactIds]
|
||||||
|
);
|
||||||
|
const [outerDivStyle, outerDivSpringApi] = useSpring(
|
||||||
|
{
|
||||||
|
from: OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'hidden' : 'visible'],
|
||||||
|
to: OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],
|
||||||
|
config: SPRING_CONFIG,
|
||||||
|
},
|
||||||
|
[isSomeoneTyping]
|
||||||
|
);
|
||||||
|
const [typingAnimationStyle, typingAnimationSpringApi] = useSpring(
|
||||||
|
{
|
||||||
|
from: BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'hidden' : 'visible'],
|
||||||
|
to: BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],
|
||||||
|
config: SPRING_CONFIG,
|
||||||
|
onRest: () => {
|
||||||
|
if (!isSomeoneTyping) {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[isSomeoneTyping]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// When typing stops, stay visible to allow time to animate out the bubble.
|
||||||
|
if (isSomeoneTyping) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
typingAnimationSpringApi.stop();
|
||||||
|
typingAnimationSpringApi.start(
|
||||||
|
BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden']
|
||||||
|
);
|
||||||
|
outerDivSpringApi.stop();
|
||||||
|
outerDivSpringApi.start(
|
||||||
|
OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden']
|
||||||
|
);
|
||||||
|
}, [isSomeoneTyping, typingAnimationSpringApi, outerDivSpringApi]);
|
||||||
|
|
||||||
|
// When only one person is typing and they just sent a new message, then instantly
|
||||||
|
// hide the bubble without animation to seamlessly transition to their new message.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
typingContactIds.length !== 1 ||
|
||||||
|
!lastItemAuthorId ||
|
||||||
|
!lastItemTimestamp
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTypingContactId = typingContactIds[0];
|
||||||
|
const lastTypingTimestamp = typingContactIdTimestamps[lastTypingContactId];
|
||||||
|
if (
|
||||||
|
lastItemAuthorId === lastTypingContactId &&
|
||||||
|
lastItemTimestamp > lastTypingTimestamp
|
||||||
|
) {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
lastItemAuthorId,
|
||||||
|
lastItemTimestamp,
|
||||||
|
typingContactIds,
|
||||||
|
typingContactIdTimestamps,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const isGroup = conversationType === 'group';
|
const isGroup = conversationType === 'group';
|
||||||
|
|
||||||
const typingContactsOverflowCount = Math.max(
|
|
||||||
typingContacts.length - MAX_AVATARS_COUNT,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<animated.div
|
||||||
className={classNames(
|
className="module-timeline__typing-bubble-container"
|
||||||
'module-message',
|
style={outerDivStyle}
|
||||||
'module-message--incoming',
|
|
||||||
isGroup ? 'module-message--group' : null
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isGroup && (
|
<div
|
||||||
<div className="module-message__typing-avatar-container">
|
className={classNames(
|
||||||
{typingContactsOverflowCount > 0 && (
|
'module-message',
|
||||||
<div
|
'module-message--incoming',
|
||||||
className="module-message__typing-avatar module-message__typing-avatar--overflow-count
|
'module-message--typing-bubble',
|
||||||
"
|
isGroup ? 'module-message--group' : null
|
||||||
>
|
)}
|
||||||
<div
|
>
|
||||||
aria-label={i18n('icu:TypingBubble__avatar--overflow-count', {
|
{isGroup && (
|
||||||
count: typingContactsOverflowCount,
|
<TypingBubbleGroupAvatars
|
||||||
})}
|
conversationId={conversationId}
|
||||||
className="module-Avatar"
|
typingContactIds={typingContactIds}
|
||||||
>
|
getConversation={getConversation}
|
||||||
<div className="module-Avatar__contents">
|
getPreferredBadge={getPreferredBadge}
|
||||||
<div aria-hidden="true" className="module-Avatar__label">
|
showContactModal={showContactModal}
|
||||||
+{typingContactsOverflowCount}
|
i18n={i18n}
|
||||||
</div>
|
theme={theme}
|
||||||
</div>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
<div className="module-message__container-outer">
|
||||||
|
<animated.div
|
||||||
|
className={classNames(
|
||||||
|
'module-message__container',
|
||||||
|
'module-message__container--incoming'
|
||||||
|
)}
|
||||||
|
style={typingAnimationStyle}
|
||||||
|
>
|
||||||
|
<div className="module-message__typing-animation-container">
|
||||||
|
<TypingAnimation color="light" i18n={i18n} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
</animated.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">
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'module-message__container',
|
|
||||||
'module-message__container--incoming'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="module-message__typing-container">
|
|
||||||
<TypingAnimation color="light" i18n={i18n} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</animated.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,7 +60,7 @@ export type PropsData = Pick<
|
||||||
| 'shouldShowDraft'
|
| 'shouldShowDraft'
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'type'
|
| 'type'
|
||||||
| 'typingContactIds'
|
| 'typingContactIdTimestamps'
|
||||||
| 'unblurredAvatarPath'
|
| 'unblurredAvatarPath'
|
||||||
| 'unreadCount'
|
| 'unreadCount'
|
||||||
| 'unreadMentionsCount'
|
| 'unreadMentionsCount'
|
||||||
|
@ -104,14 +104,15 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
theme,
|
theme,
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
typingContactIds,
|
typingContactIdTimestamps,
|
||||||
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 isSomeoneTyping =
|
||||||
|
Object.keys(typingContactIdTimestamps ?? {}).length > 0;
|
||||||
const headerName = (
|
const headerName = (
|
||||||
<>
|
<>
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
|
|
|
@ -217,7 +217,11 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
contactTypingTimers?: Record<
|
contactTypingTimers?: Record<
|
||||||
string,
|
string,
|
||||||
{ senderId: string; timer: NodeJS.Timer }
|
{
|
||||||
|
senderId: string;
|
||||||
|
timer: NodeJS.Timer;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
contactCollection?: Backbone.Collection<ConversationModel>;
|
contactCollection?: Backbone.Collection<ConversationModel>;
|
||||||
|
|
|
@ -298,7 +298,7 @@ export type ConversationType = ReadonlyDeep<
|
||||||
unreadMentionsCount?: number;
|
unreadMentionsCount?: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isFetchingUUID?: boolean;
|
isFetchingUUID?: boolean;
|
||||||
typingContactIds?: Array<string>;
|
typingContactIdTimestamps?: Record<string, number>;
|
||||||
recentMediaItems?: ReadonlyArray<MediaItemType>;
|
recentMediaItems?: ReadonlyArray<MediaItemType>;
|
||||||
profileSharing?: boolean;
|
profileSharing?: boolean;
|
||||||
|
|
||||||
|
|
|
@ -241,14 +241,16 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
'unreadCount',
|
'unreadCount',
|
||||||
'unreadMentionsCount',
|
'unreadMentionsCount',
|
||||||
'isGroupV1AndDisabled',
|
'isGroupV1AndDisabled',
|
||||||
'typingContactIds',
|
'typingContactIdTimestamps',
|
||||||
]),
|
]),
|
||||||
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.typingContactIds?.[0]),
|
isSomeoneTyping: Boolean(
|
||||||
|
Object.keys(conversation.typingContactIdTimestamps ?? {}).length > 0
|
||||||
|
),
|
||||||
...conversationMessages,
|
...conversationMessages,
|
||||||
|
|
||||||
invitedContactsForNewlyCreatedGroup:
|
invitedContactsForNewlyCreatedGroup:
|
||||||
|
|
|
@ -1,23 +1,28 @@
|
||||||
// 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 { last } from 'lodash';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { TypingBubble } from '../../components/conversation/TypingBubble';
|
|
||||||
import { strictAssert } from '../../util/assert';
|
|
||||||
|
|
||||||
|
import { TypingBubble } from '../../components/conversation/TypingBubble';
|
||||||
import { useGlobalModalActions } from '../ducks/globalModals';
|
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
|
import { useProxySelector } from '../../hooks/useProxySelector';
|
||||||
import { getIntl, getTheme } from '../selectors/user';
|
import { getIntl, getTheme } from '../selectors/user';
|
||||||
import { getConversationSelector } from '../selectors/conversations';
|
import { getTimelineItem } from '../selectors/timeline';
|
||||||
|
import {
|
||||||
|
getConversationSelector,
|
||||||
|
getConversationMessagesSelector,
|
||||||
|
} from '../selectors/conversations';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
import { isInternalUser } from '../selectors/items';
|
|
||||||
|
|
||||||
type ExternalProps = {
|
type ExternalProps = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SmartTypingBubble(props: ExternalProps): JSX.Element {
|
export function SmartTypingBubble({
|
||||||
const { conversationId } = props;
|
conversationId,
|
||||||
|
}: ExternalProps): JSX.Element {
|
||||||
const i18n = useSelector(getIntl);
|
const i18n = useSelector(getIntl);
|
||||||
const theme = useSelector(getTheme);
|
const theme = useSelector(getTheme);
|
||||||
const getConversation = useSelector(getConversationSelector);
|
const getConversation = useSelector(getConversationSelector);
|
||||||
|
@ -26,37 +31,39 @@ export function SmartTypingBubble(props: ExternalProps): JSX.Element {
|
||||||
throw new Error(`Did not find conversation ${conversationId} in state!`);
|
throw new Error(`Did not find conversation ${conversationId} in state!`);
|
||||||
}
|
}
|
||||||
|
|
||||||
strictAssert(
|
const typingContactIdTimestamps =
|
||||||
conversation.typingContactIds?.[0],
|
conversation.typingContactIdTimestamps ?? {};
|
||||||
'Missing typing contact IDs'
|
const conversationMessages = useSelector(getConversationMessagesSelector)(
|
||||||
|
conversationId
|
||||||
);
|
);
|
||||||
|
const lastMessageId = last(conversationMessages.items);
|
||||||
|
const lastItem = useProxySelector(getTimelineItem, lastMessageId);
|
||||||
|
let lastItemAuthorId: string | undefined;
|
||||||
|
let lastItemTimestamp: number | undefined;
|
||||||
|
if (lastItem?.data) {
|
||||||
|
if ('author' in lastItem.data) {
|
||||||
|
lastItemAuthorId = lastItem.data.author?.id;
|
||||||
|
}
|
||||||
|
if ('timestamp' in lastItem.data) {
|
||||||
|
lastItemTimestamp = lastItem.data.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { showContactModal } = useGlobalModalActions();
|
const { showContactModal } = useGlobalModalActions();
|
||||||
|
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
||||||
const preferredBadgeSelector = useSelector(getPreferredBadgeSelector);
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<TypingBubble
|
<TypingBubble
|
||||||
showContactModal={showContactModal}
|
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
conversationType={conversation.type}
|
conversationType={conversation.type}
|
||||||
|
typingContactIdTimestamps={typingContactIdTimestamps}
|
||||||
|
lastItemAuthorId={lastItemAuthorId}
|
||||||
|
lastItemTimestamp={lastItemTimestamp}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
typingContacts={typingContacts}
|
getConversation={getConversation}
|
||||||
|
getPreferredBadge={getPreferredBadge}
|
||||||
|
showContactModal={showContactModal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1145,7 +1145,7 @@ describe('both/state/selectors/conversations-extra', () => {
|
||||||
title: 'No timestamp',
|
title: 'No timestamp',
|
||||||
unreadCount: 1,
|
unreadCount: 1,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
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,
|
||||||
typingContactIds: [generateUuid()],
|
typingContactIdTimestamps: { [generateUuid()]: Date.now() },
|
||||||
|
|
||||||
acceptedMessageRequest: true,
|
acceptedMessageRequest: true,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -81,7 +81,9 @@ export function getConversation(model: ConversationModel): ConversationType {
|
||||||
Object.values(model.contactTypingTimers || {}),
|
Object.values(model.contactTypingTimers || {}),
|
||||||
'timestamp'
|
'timestamp'
|
||||||
);
|
);
|
||||||
const typingContactIds = typingValues.map(({ senderId }) => senderId);
|
const typingContactIdTimestamps = Object.fromEntries(
|
||||||
|
typingValues.map(({ senderId, timestamp }) => [senderId, timestamp])
|
||||||
|
);
|
||||||
|
|
||||||
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();
|
||||||
|
@ -222,7 +224,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),
|
||||||
typingContactIds,
|
typingContactIdTimestamps,
|
||||||
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