diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index bdb1d0b11..8b890ea48 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1386,6 +1386,10 @@ $message-padding-horizontal: 12px; } } +.module-message--typing-bubble { + height: 44px; +} + .module-message__typing-avatar-container { align-items: center; display: flex; @@ -1729,7 +1733,7 @@ $message-padding-horizontal: 12px; } } -.module-message__typing-container { +.module-message__typing-animation-container { height: 16px; display: flex; diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index e09e28a00..8e2db3fe4 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -381,7 +381,9 @@ ConversationsMessageStatuses.story = { export const ConversationTypingStatus = (): JSX.Element => renderConversation({ - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { + [generateUuid()]: date('timestamp', new Date()), + }, }); ConversationTypingStatus.story = { diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index b26033978..0ff9c1f8e 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -370,7 +370,7 @@ export function ConversationList({ 'shouldShowDraft', 'title', 'type', - 'typingContactIds', + 'typingContactIdTimestamps', 'unblurredAvatarPath', 'unreadCount', 'unreadMentionsCount', diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 9c3fa61b3..2567a189d 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -19,7 +19,6 @@ import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext import { ConversationHero } from './ConversationHero'; import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from '../../state/smart/ContactSpoofingReviewDialog'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; -import { getRandomColor } from '../../test-both/helpers/getRandomColor'; import { TypingBubble } from './TypingBubble'; import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ReadStatus } from '../../messages/MessageReadStatus'; @@ -441,20 +440,13 @@ const renderHeroRow = () => { }; const renderTypingBubble = () => ( getDefaultConversation()} + getPreferredBadge={() => undefined} showContactModal={action('showContactModal')} i18n={i18n} theme={ThemeType.light} diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 8783fe5a9..1a83055e2 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -795,7 +795,6 @@ export class Timeline extends React.Component< invitedContactsForNewlyCreatedGroup, isConversationSelected, isGroupV1AndDisabled, - isSomeoneTyping, items, messageLoadingState, oldestUnseenIndex, @@ -1144,7 +1143,7 @@ export class Timeline extends React.Component< {messageNodes} - {isSomeoneTyping && haveNewest && renderTypingBubble(id)} + {haveNewest && renderTypingBubble(id)}
= times(10, index => { +const CONTACTS = times(10, index => { const letter = (index + 10).toString(36).toUpperCase(); - return { + return getDefaultConversation({ id: `contact-${index}`, acceptedMessageRequest: false, avatarPath: '', - badge: undefined, + badges: [], color: AvatarColors[index], name: `${letter} ${letter}`, phoneNumber: '(202) 555-0001', @@ -36,21 +35,52 @@ const contacts: Array = times(10, index => { isMe: false, sharedGroupNames: [], 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 = ( overrideProps: Partial = {} -): TypingBubblePropsType => ({ - typingContacts: overrideProps.typingContacts || contacts.slice(0, 1), - i18n, - conversationId: '123', - conversationType: - overrideProps.conversationType || - select('conversationType', { group: 'group', direct: 'direct' }, 'direct'), - showContactModal: action('showContactModal'), - theme: ThemeType.light, -}); +): TypingBubblePropsType => { + return { + typingContactIdTimestamps: + overrideProps.typingContactIdTimestamps ?? + getTypingContactIdTimestamps(1), + lastItemAuthorId: '123', + lastItemTimestamp: undefined, + i18n, + 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 { const props = createProps(); @@ -58,9 +88,24 @@ export function Direct(): JSX.Element { return ; } +export function DirectStoppedTyping(): JSX.Element { + const props = createProps(); + const [afterTimeoutProps, setAfterTimeoutProps] = useState({}); + useEffect(() => { + setTimeout( + () => + setAfterTimeoutProps({ + typingContactIdTimestamps: {}, + }), + 500 + ); + }, []); + + return ; +} + export function Group(): JSX.Element { const props = createProps({ conversationType: 'group' }); - return ; } @@ -68,21 +113,31 @@ Group.story = { name: 'Group (1 person typing)', }; -export function GroupMultiTyping2(): JSX.Element { +export function GroupStoppedTyping(): JSX.Element { const props = createProps({ conversationType: 'group', - typingContacts: contacts.slice(0, 2), + typingContactIdTimestamps: getTypingContactIdTimestamps(1), }); + const [afterTimeoutProps, setAfterTimeoutProps] = useState({}); + useEffect(() => { + setTimeout( + () => setAfterTimeoutProps({ typingContactIdTimestamps: {} }), + 500 + ); + }, []); - return ; + return ; } +GroupStoppedTyping.story = { + name: 'Group (1 person stopped typing)', +}; + export function GroupWithBadge(): JSX.Element { const props = createProps({ conversationType: 'group', - typingContacts: contacts - .slice(0, 1) - .map(contact => ({ ...contact, badge: getFakeBadge() })), + typingContactIdTimestamps: getTypingContactIdTimestamps(1), + getConversation: getConversationWithBadges, }); return ; @@ -92,40 +147,79 @@ GroupWithBadge.story = { name: 'Group (with badge)', }; -GroupMultiTyping2.story = { - name: 'Group (2 persons typing)', -}; - -export function GroupMultiTyping3(): JSX.Element { +export function GroupMultiTyping1To2(): JSX.Element { const props = createProps({ conversationType: 'group', - typingContacts: contacts.slice(0, 3), + typingContactIdTimestamps: getTypingContactIdTimestamps(1), }); + const [afterTimeoutProps, setAfterTimeoutProps] = useState({}); + useEffect(() => { + setTimeout( + () => + setAfterTimeoutProps({ + typingContactIdTimestamps: getTypingContactIdTimestamps(2), + }), + 500 + ); + }, []); - return ; + return ; } -GroupMultiTyping3.story = { - name: 'Group (3 persons typing)', +GroupMultiTyping1To2.story = { + name: 'Group (1 to 2 persons)', }; -export function GroupMultiTyping4(): JSX.Element { +export function GroupMultiTyping2Then1PersonStops(): JSX.Element { const props = createProps({ conversationType: 'group', - typingContacts: contacts.slice(0, 4), + typingContactIdTimestamps: getTypingContactIdTimestamps(2), }); + const [afterTimeoutProps, setAfterTimeoutProps] = useState({}); + useEffect(() => { + setTimeout( + () => + setAfterTimeoutProps({ + typingContactIdTimestamps: getTypingContactIdTimestamps(1), + }), + 500 + ); + }, []); - return ; + return ; } -GroupMultiTyping4.story = { - name: 'Group (4 persons typing)', +GroupMultiTyping2Then1PersonStops.story = { + 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 ; +} + +GroupMultiTyping3To4.story = { + name: 'Group (3 to 4)', }; export function GroupMultiTyping10(): JSX.Element { const props = createProps({ conversationType: 'group', - typingContacts: contacts.slice(0, 10), + typingContactIdTimestamps: getTypingContactIdTimestamps(10), }); return ; @@ -138,19 +232,8 @@ GroupMultiTyping10.story = { export function GroupMultiTypingWithBadges(): JSX.Element { const props = createProps({ conversationType: 'group', - typingContacts: [ - { - ...contacts[0], - badge: getFakeBadge(), - }, - { - ...contacts[1], - }, - { - ...contacts[2], - badge: getFakeBadge(), - }, - ], + typingContactIdTimestamps: getTypingContactIdTimestamps(3), + getConversation: getConversationWithBadges, }); return ; diff --git a/ts/components/conversation/TypingBubble.tsx b/ts/components/conversation/TypingBubble.tsx index 14c2e9fba..7ad9a60e0 100644 --- a/ts/components/conversation/TypingBubble.tsx +++ b/ts/components/conversation/TypingBubble.tsx @@ -2,124 +2,384 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; +import { animated, useSpring } from '@react-spring/web'; import { TypingAnimation } from './TypingAnimation'; import { Avatar } from '../Avatar'; import type { LocalizerType, ThemeType } from '../../types/Util'; 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; -export type PropsType = { +type TypingContactType = Pick< + ConversationType, + | 'acceptedMessageRequest' + | 'avatarPath' + | 'badges' + | 'color' + | 'id' + | 'isMe' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' +>; + +export type TypingBubblePropsType = { conversationId: string; conversationType: 'group' | 'direct'; + typingContactIdTimestamps: Record; + lastItemAuthorId: string | undefined; + lastItemTimestamp: number | undefined; + getConversation: (id: string) => ConversationType; + getPreferredBadge: PreferredBadgeSelectorType; showContactModal: (contactId: string, conversationId?: string) => void; i18n: LocalizerType; theme: ThemeType; - typingContacts: Array< - Pick< - ConversationType, - | 'acceptedMessageRequest' - | 'avatarPath' - | 'color' - | 'id' - | 'isMe' - | 'phoneNumber' - | 'profileName' - | 'sharedGroupNames' - | 'title' - > & { - badge: undefined | BadgeType; +}; + +const SPRING_CONFIG = { + mass: 1, + tension: 986, + friction: 64, + precision: 0, + velocity: 0, +}; + +const AVATAR_ANIMATION_PROPS: Record<'visible' | 'hidden', object> = { + visible: { + opacity: 1, + 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 ( + + { + event.stopPropagation(); + event.preventDefault(); + showContactModal(contact.id, conversationId); + }} + phoneNumber={contact.phoneNumber} + profileName={contact.profileName} + theme={theme} + title={contact.title} + sharedGroupNames={contact.sharedGroupNames} + size={28} + /> + + ); +} + +function TypingBubbleGroupAvatars({ + conversationId, + typingContactIds, + getConversation, + getPreferredBadge, + showContactModal, + i18n, + theme, +}: Pick< + TypingBubblePropsType, + | 'conversationId' + | 'getConversation' + | 'getPreferredBadge' + | 'showContactModal' + | 'i18n' + | 'theme' +> & { + typingContactIds: ReadonlyArray; +}): ReactElement { + const [allContactsById, setAllContactsById] = useState< + Map + >(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 = useMemo(() => { + const set = new Set(); + 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 ( +
+ {typingContactsOverflowCount > 0 && ( +
+
+
+ +
+
+
+ )} + {[...allContactsById.keys()] + .slice(-1 * MAX_AVATARS_COUNT) + .map(contactId => ( + + ))} +
+ ); +} + +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({ conversationId, conversationType, + typingContactIdTimestamps, + lastItemAuthorId, + lastItemTimestamp, + getConversation, + getPreferredBadge, showContactModal, i18n, theme, - typingContacts, -}: PropsType): ReactElement { +}: TypingBubblePropsType): ReactElement | null { + 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 typingContactsOverflowCount = Math.max( - typingContacts.length - MAX_AVATARS_COUNT, - 0 - ); - return ( -
- {isGroup && ( -
- {typingContactsOverflowCount > 0 && ( -
-
-
- -
-
+
+ {isGroup && ( + + )} +
+ +
+
- )} - {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 19e72ec0b..4bfbf2194 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -60,7 +60,7 @@ export type PropsData = Pick< | 'shouldShowDraft' | 'title' | 'type' - | 'typingContactIds' + | 'typingContactIdTimestamps' | 'unblurredAvatarPath' | 'unreadCount' | 'unreadMentionsCount' @@ -104,14 +104,15 @@ export const ConversationListItem: FunctionComponent = React.memo( theme, title, type, - typingContactIds, + typingContactIdTimestamps, unblurredAvatarPath, unreadCount, unreadMentionsCount, serviceId, }) { const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt); - const isSomeoneTyping = (typingContactIds?.length ?? 0) > 0; + const isSomeoneTyping = + Object.keys(typingContactIdTimestamps ?? {}).length > 0; const headerName = ( <> {isMe ? ( diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index b1876ef20..282f4ad97 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -217,7 +217,11 @@ export class ConversationModel extends window.Backbone contactTypingTimers?: Record< string, - { senderId: string; timer: NodeJS.Timer } + { + senderId: string; + timer: NodeJS.Timer; + timestamp: number; + } >; contactCollection?: Backbone.Collection; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 2735c9e96..777615e78 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; - typingContactIds?: Array; + typingContactIdTimestamps?: Record; recentMediaItems?: ReadonlyArray; profileSharing?: boolean; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index b38e720c1..a1bc3e7ab 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -241,14 +241,16 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { 'unreadCount', 'unreadMentionsCount', 'isGroupV1AndDisabled', - 'typingContactIds', + 'typingContactIdTimestamps', ]), isConversationSelected: state.conversations.selectedConversationId === id, isIncomingMessageRequest: Boolean( conversation.messageRequestsEnabled && !conversation.acceptedMessageRequest ), - isSomeoneTyping: Boolean(conversation.typingContactIds?.[0]), + isSomeoneTyping: Boolean( + Object.keys(conversation.typingContactIdTimestamps ?? {}).length > 0 + ), ...conversationMessages, invitedContactsForNewlyCreatedGroup: diff --git a/ts/state/smart/TypingBubble.tsx b/ts/state/smart/TypingBubble.tsx index 16248c69c..0743fa5fb 100644 --- a/ts/state/smart/TypingBubble.tsx +++ b/ts/state/smart/TypingBubble.tsx @@ -1,23 +1,28 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { last } from 'lodash'; import React from 'react'; 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 { useProxySelector } from '../../hooks/useProxySelector'; 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 { isInternalUser } from '../selectors/items'; type ExternalProps = { conversationId: string; }; -export function SmartTypingBubble(props: ExternalProps): JSX.Element { - const { conversationId } = props; +export function SmartTypingBubble({ + conversationId, +}: ExternalProps): JSX.Element { const i18n = useSelector(getIntl); const theme = useSelector(getTheme); 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!`); } - strictAssert( - conversation.typingContactIds?.[0], - 'Missing typing contact IDs' + const typingContactIdTimestamps = + conversation.typingContactIdTimestamps ?? {}; + 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 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), - }; - }); + const getPreferredBadge = useSelector(getPreferredBadgeSelector); return ( ); } diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 3d9a286a8..1ab71eaab 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, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1166,7 +1166,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'B', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1187,7 +1187,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'C', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1208,7 +1208,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'A', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1229,7 +1229,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'First!', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1271,7 +1271,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'Pin Two', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1293,7 +1293,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'Pin Three', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1315,7 +1315,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'Pin One', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1354,7 +1354,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'Pin Two', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1375,7 +1375,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'Pin Three', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1396,7 +1396,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'Pin One', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1418,7 +1418,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'Pin One', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), @@ -1439,7 +1439,7 @@ describe('both/state/selectors/conversations-extra', () => { title: 'Pin One', unreadCount: 1, isSelected: false, - typingContactIds: [generateUuid()], + typingContactIdTimestamps: { [generateUuid()]: Date.now() }, acceptedMessageRequest: true, }), diff --git a/ts/util/getConversation.ts b/ts/util/getConversation.ts index a6b9a45ff..4b3dd4b03 100644 --- a/ts/util/getConversation.ts +++ b/ts/util/getConversation.ts @@ -81,7 +81,9 @@ export function getConversation(model: ConversationModel): ConversationType { Object.values(model.contactTypingTimers || {}), '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 ourPni = window.textsecure.storage.user.getPni(); @@ -222,7 +224,7 @@ export function getConversation(model: ConversationModel): ConversationType { timestamp: dropNull(timestamp), title: getTitle(attributes), titleNoDefault: getTitleNoDefault(attributes), - typingContactIds, + typingContactIdTimestamps, searchableTitle: isMe(attributes) ? window.i18n('icu:noteToSelf') : getTitle(attributes),