Optimize rendering

This commit is contained in:
Fedor Indutny 2021-08-11 09:23:21 -07:00 committed by GitHub
parent 81f06e2404
commit 12c78c742f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 702 additions and 444 deletions

View file

@ -6624,6 +6624,7 @@ button.module-image__border-overlay:focus {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding-left: 16px; padding-left: 16px;
height: 100%;
@include dark-theme { @include dark-theme {
color: $color-gray-05; color: $color-gray-05;

View file

@ -3,7 +3,7 @@
import { isNumber, noop } from 'lodash'; import { isNumber, noop } from 'lodash';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { render } from 'react-dom'; import { render, unstable_batchedUpdates as batchedUpdates } from 'react-dom';
import MessageReceiver from './textsecure/MessageReceiver'; import MessageReceiver from './textsecure/MessageReceiver';
import { SessionResetsType, ProcessedDataMessage } from './textsecure/Types.d'; import { SessionResetsType, ProcessedDataMessage } from './textsecure/Types.d';
@ -1241,8 +1241,10 @@ export async function startApp(): Promise<void> {
`${batch.length} into ${deduped.size}` `${batch.length} into ${deduped.size}`
); );
deduped.forEach(conversation => { batchedUpdates(() => {
conversationChanged(conversation.id, conversation.format()); deduped.forEach(conversation => {
conversationChanged(conversation.id, conversation.format());
});
}); });
}, },

View file

@ -36,9 +36,6 @@ export const AnnouncementsOnlyGroupBanner = ({
onClick={() => { onClick={() => {
openConversation(admin.id); openConversation(admin.id);
}} }}
// Required by the component but unecessary for us
style={{}}
// We don't want these values to show
draftPreview="" draftPreview=""
lastMessage={undefined} lastMessage={undefined}
lastUpdated={undefined} lastUpdated={undefined}

View file

@ -54,7 +54,7 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
onSelectConversation: action('onSelectConversation'), onSelectConversation: action('onSelectConversation'),
onClickArchiveButton: action('onClickArchiveButton'), onClickArchiveButton: action('onClickArchiveButton'),
onClickContactCheckbox: action('onClickContactCheckbox'), onClickContactCheckbox: action('onClickContactCheckbox'),
renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( renderMessageSearchResult: (id: string) => (
<MessageSearchResult <MessageSearchResult
body="Lorem ipsum wow" body="Lorem ipsum wow"
bodyRanges={[]} bodyRanges={[]}
@ -65,7 +65,6 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
openConversationInternal={action('openConversationInternal')} openConversationInternal={action('openConversationInternal')}
sentAt={1587358800000} sentAt={1587358800000}
snippet="Lorem <<left>>ipsum<<right>> wow" snippet="Lorem <<left>>ipsum<<right>> wow"
style={style}
to={defaultConversations[1]} to={defaultConversations[1]}
/> />
), ),
@ -288,6 +287,7 @@ story.add('Contact checkboxes: disabled', () => (
story.add('Conversation: Typing Status', () => story.add('Conversation: Typing Status', () =>
renderConversation({ renderConversation({
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone Here', name: 'Someone Here',
}, },
}) })

View file

@ -1,9 +1,10 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useEffect, useCallback, CSSProperties } from 'react'; import React, { useRef, useEffect, useCallback, ReactNode } from 'react';
import { List, ListRowRenderer } from 'react-virtualized'; import { List, ListRowRenderer } from 'react-virtualized';
import classNames from 'classnames'; import classNames from 'classnames';
import { pick } from 'lodash';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { assert } from '../util/assert'; import { assert } from '../util/assert';
@ -128,7 +129,7 @@ export type PropsType = {
disabledReason: undefined | ContactCheckboxDisabledReason disabledReason: undefined | ContactCheckboxDisabledReason
) => void; ) => void;
onSelectConversation: (conversationId: string, messageId?: string) => void; onSelectConversation: (conversationId: string, messageId?: string) => void;
renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element; renderMessageSearchResult: (id: string) => JSX.Element;
showChooseGroupMembers: () => void; showChooseGroupMembers: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void; startNewConversationFromPhoneNumber: (e164: string) => void;
}; };
@ -187,13 +188,12 @@ export const ConversationList: React.FC<PropsType> = ({
return <div key={key} style={style} />; return <div key={key} style={style} />;
} }
let result: ReactNode;
switch (row.type) { switch (row.type) {
case RowType.ArchiveButton: case RowType.ArchiveButton:
return ( result = (
<button <button
key={key}
className="module-conversation-list__item--archive-button" className="module-conversation-list__item--archive-button"
style={style}
onClick={onClickArchiveButton} onClick={onClickArchiveButton}
type="button" type="button"
> >
@ -203,90 +203,108 @@ export const ConversationList: React.FC<PropsType> = ({
</span> </span>
</button> </button>
); );
break;
case RowType.Blank: case RowType.Blank:
return <div key={key} style={style} />; result = <></>;
break;
case RowType.Contact: { case RowType.Contact: {
const { isClickable = true } = row; const { isClickable = true } = row;
return ( result = (
<ContactListItem <ContactListItem
{...row.contact} {...row.contact}
key={key}
style={style}
onClick={isClickable ? onSelectConversation : undefined} onClick={isClickable ? onSelectConversation : undefined}
i18n={i18n} i18n={i18n}
/> />
); );
break;
} }
case RowType.ContactCheckbox: case RowType.ContactCheckbox:
return ( result = (
<ContactCheckboxComponent <ContactCheckboxComponent
{...row.contact} {...row.contact}
isChecked={row.isChecked} isChecked={row.isChecked}
disabledReason={row.disabledReason} disabledReason={row.disabledReason}
key={key}
style={style}
onClick={onClickContactCheckbox} onClick={onClickContactCheckbox}
i18n={i18n} i18n={i18n}
/> />
); );
case RowType.Conversation: break;
return ( case RowType.Conversation: {
const itemProps = pick(row.conversation, [
'acceptedMessageRequest',
'avatarPath',
'color',
'draftPreview',
'id',
'isMe',
'isSelected',
'lastMessage',
'lastUpdated',
'markedUnread',
'muteExpiresAt',
'name',
'phoneNumber',
'profileName',
'sharedGroupNames',
'shouldShowDraft',
'title',
'type',
'typingContact',
'unblurredAvatarPath',
'unreadCount',
]);
result = (
<ConversationListItem <ConversationListItem
{...row.conversation} {...itemProps}
key={key} key={key}
style={style}
onClick={onSelectConversation} onClick={onSelectConversation}
i18n={i18n} i18n={i18n}
/> />
); );
break;
}
case RowType.CreateNewGroup: case RowType.CreateNewGroup:
return ( result = (
<CreateNewGroupButton <CreateNewGroupButton
i18n={i18n} i18n={i18n}
key={key}
onClick={showChooseGroupMembers} onClick={showChooseGroupMembers}
style={style}
/> />
); );
break;
case RowType.Header: case RowType.Header:
return ( result = (
<div <div className="module-conversation-list__item--header">
className="module-conversation-list__item--header"
key={key}
style={style}
>
{i18n(row.i18nKey)} {i18n(row.i18nKey)}
</div> </div>
); );
break;
case RowType.MessageSearchResult: case RowType.MessageSearchResult:
return ( result = <>{renderMessageSearchResult(row.messageId)}</>;
<React.Fragment key={key}> break;
{renderMessageSearchResult(row.messageId, style)}
</React.Fragment>
);
case RowType.SearchResultsLoadingFakeHeader: case RowType.SearchResultsLoadingFakeHeader:
return ( result = <SearchResultsLoadingFakeHeaderComponent />;
<SearchResultsLoadingFakeHeaderComponent key={key} style={style} /> break;
);
case RowType.SearchResultsLoadingFakeRow: case RowType.SearchResultsLoadingFakeRow:
return ( result = <SearchResultsLoadingFakeRowComponent />;
<SearchResultsLoadingFakeRowComponent key={key} style={style} /> break;
);
case RowType.StartNewConversation: case RowType.StartNewConversation:
return ( result = (
<StartNewConversationComponent <StartNewConversationComponent
i18n={i18n} i18n={i18n}
key={key}
phoneNumber={row.phoneNumber} phoneNumber={row.phoneNumber}
onClick={() => { onClick={startNewConversationFromPhoneNumber}
startNewConversationFromPhoneNumber(row.phoneNumber);
}}
style={style}
/> />
); );
break;
default: default:
throw missingCaseError(row); throw missingCaseError(row);
} }
return (
<span style={style} key={key}>
{result}
</span>
);
}, },
[ [
getRow, getRow,

View file

@ -98,7 +98,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
setChallengeStatus: action('setChallengeStatus'), setChallengeStatus: action('setChallengeStatus'),
renderExpiredBuildDialog: () => <div />, renderExpiredBuildDialog: () => <div />,
renderMainHeader: () => <div />, renderMainHeader: () => <div />,
renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( renderMessageSearchResult: (id: string) => (
<MessageSearchResult <MessageSearchResult
body="Lorem ipsum wow" body="Lorem ipsum wow"
bodyRanges={[]} bodyRanges={[]}
@ -109,7 +109,6 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
openConversationInternal={action('openConversationInternal')} openConversationInternal={action('openConversationInternal')}
sentAt={1587358800000} sentAt={1587358800000}
snippet="Lorem <<left>>ipsum<<right>> wow" snippet="Lorem <<left>>ipsum<<right>> wow"
style={style}
to={defaultConversations[1]} to={defaultConversations[1]}
/> />
), ),

View file

@ -1,7 +1,7 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useMemo, CSSProperties } from 'react'; import React, { useEffect, useCallback, useMemo } from 'react';
import Measure, { MeasuredComponentProps } from 'react-measure'; import Measure, { MeasuredComponentProps } from 'react-measure';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -119,7 +119,7 @@ export type PropsType = {
// Render Props // Render Props
renderExpiredBuildDialog: () => JSX.Element; renderExpiredBuildDialog: () => JSX.Element;
renderMainHeader: () => JSX.Element; renderMainHeader: () => JSX.Element;
renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element; renderMessageSearchResult: (id: string) => JSX.Element;
renderNetworkStatus: () => JSX.Element; renderNetworkStatus: () => JSX.Element;
renderRelinkDialog: () => JSX.Element; renderRelinkDialog: () => JSX.Element;
renderUpdateDialog: () => JSX.Element; renderUpdateDialog: () => JSX.Element;
@ -376,6 +376,17 @@ export const LeftPane: React.FC<PropsType> = ({
const getRow = useMemo(() => helper.getRow.bind(helper), [helper]); const getRow = useMemo(() => helper.getRow.bind(helper), [helper]);
const onSelectConversation = useCallback(
(conversationId: string, messageId?: string) => {
openConversationInternal({
conversationId,
messageId,
switchToAssociatedView: true,
});
},
[openConversationInternal]
);
const previousSelectedConversationId = usePrevious( const previousSelectedConversationId = usePrevious(
selectedConversationId, selectedConversationId,
selectedConversationId selectedConversationId
@ -458,16 +469,7 @@ export const LeftPane: React.FC<PropsType> = ({
throw missingCaseError(disabledReason); throw missingCaseError(disabledReason);
} }
}} }}
onSelectConversation={( onSelectConversation={onSelectConversation}
conversationId: string,
messageId?: string
) => {
openConversationInternal({
conversationId,
messageId,
switchToAssociatedView: true,
});
}}
renderMessageSearchResult={renderMessageSearchResult} renderMessageSearchResult={renderMessageSearchResult}
rowCount={helper.getRowCount()} rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior} scrollBehavior={scrollBehavior}

View file

@ -271,7 +271,7 @@ type State = {
const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600; const EXPIRED_DELAY = 600;
export class Message extends React.Component<Props, State> { export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined; public menuTriggerRef: Trigger | undefined;
public focusRef: React.RefObject<HTMLDivElement> = React.createRef(); public focusRef: React.RefObject<HTMLDivElement> = React.createRef();

View file

@ -375,7 +375,6 @@ const renderItem = (id: string) => (
i18n={i18n} i18n={i18n}
interactionMode="keyboard" interactionMode="keyboard"
conversationId="" conversationId=""
conversationAccepted
renderContact={() => '*ContactName*'} renderContact={() => '*ContactName*'}
renderUniversalTimerNotification={() => ( renderUniversalTimerNotification={() => (
<div>*UniversalTimerNotification*</div> <div>*UniversalTimerNotification*</div>

View file

@ -1,7 +1,7 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { debounce, get, isNumber } from 'lodash'; import { debounce, get, isNumber, pick } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { CSSProperties, ReactChild, ReactNode } from 'react'; import React, { CSSProperties, ReactChild, ReactNode } from 'react';
import { import {
@ -15,12 +15,14 @@ import Measure from 'react-measure';
import { ScrollDownButton } from './ScrollDownButton'; import { ScrollDownButton } from './ScrollDownButton';
import { LocalizerType } from '../../types/Util'; import { AssertProps, LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations'; import { ConversationType } from '../../state/ducks/conversations';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { PropsActions as MessageActionsType } from './Message'; import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage';
import { PropsActionsType as ChatSessionRefreshedNotificationActionsType } from './ChatSessionRefreshedNotification';
import { ErrorBoundary } from './ErrorBoundary'; import { ErrorBoundary } from './ErrorBoundary';
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification'; import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
import { Intl } from '../Intl'; import { Intl } from '../Intl';
@ -104,7 +106,7 @@ type PropsHousekeepingType = {
id: string, id: string,
conversationId: string, conversationId: string,
onHeightChange: (messageId: string) => unknown, onHeightChange: (messageId: string) => unknown,
actions: Record<string, unknown> actions: PropsActionsType
) => JSX.Element; ) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element; renderLastSeenIndicator: (id: string) => JSX.Element;
renderHeroRow: ( renderHeroRow: (
@ -117,7 +119,7 @@ type PropsHousekeepingType = {
renderTypingBubble: (id: string) => JSX.Element; renderTypingBubble: (id: string) => JSX.Element;
}; };
type PropsActionsType = { export type PropsActionsType = {
acknowledgeGroupMemberNameCollisions: ( acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle> groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
) => void; ) => void;
@ -152,7 +154,9 @@ type PropsActionsType = {
unblurAvatar: () => void; unblurAvatar: () => void;
updateSharedGroups: () => unknown; updateSharedGroups: () => unknown;
} & MessageActionsType & } & MessageActionsType &
SafetyNumberActionsType; SafetyNumberActionsType &
UnsupportedMessageActionsType &
ChatSessionRefreshedNotificationActionsType;
export type PropsType = PropsDataType & export type PropsType = PropsDataType &
PropsHousekeepingType & PropsHousekeepingType &
@ -721,6 +725,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
); );
} }
const messageId = items[itemIndex]; const messageId = items[itemIndex];
rowContents = ( rowContents = (
<div <div
id={messageId} id={messageId}
@ -730,7 +735,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
role="row" role="row"
> >
<ErrorBoundary i18n={i18n} showDebugLog={() => window.showDebugLog()}> <ErrorBoundary i18n={i18n} showDebugLog={() => window.showDebugLog()}>
{renderItem(messageId, id, this.resizeMessage, this.props)} {renderItem(messageId, id, this.resizeMessage, this.getActions())}
</ErrorBoundary> </ErrorBoundary>
</div> </div>
); );
@ -1474,4 +1479,65 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
throw missingCaseError(warning); throw missingCaseError(warning);
} }
} }
private getActions(): PropsActionsType {
const unsafe = pick(this.props, [
'acknowledgeGroupMemberNameCollisions',
'clearChangedMessages',
'clearInvitedConversationsForNewlyCreatedGroup',
'closeContactSpoofingReview',
'setLoadCountdownStart',
'setIsNearBottom',
'reviewGroupMemberNameCollision',
'reviewMessageRequestNameCollision',
'learnMoreAboutDeliveryIssue',
'loadAndScroll',
'loadOlderMessages',
'loadNewerMessages',
'loadNewestMessages',
'markMessageRead',
'onBlock',
'onBlockAndReportSpam',
'onDelete',
'onUnblock',
'removeMember',
'selectMessage',
'clearSelectedMessage',
'unblurAvatar',
'updateSharedGroups',
'doubleCheckMissingQuoteReference',
'onHeightChange',
'checkForAccount',
'reactToMessage',
'replyToMessage',
'retrySend',
'showForwardMessageModal',
'deleteMessage',
'deleteMessageForEveryone',
'showMessageDetail',
'openConversation',
'showContactDetail',
'showContactModal',
'kickOffAttachmentDownload',
'markAttachmentAsCorrupted',
'showVisualAttachment',
'downloadAttachment',
'displayTapToViewMessage',
'openLink',
'scrollToQuotedMessage',
'showExpiredIncomingTapToViewToast',
'showExpiredOutgoingTapToViewToast',
'showIdentity',
'downloadNewVersion',
'contactSupport',
]);
const safe: AssertProps<PropsActionsType, typeof unsafe> = unsafe;
return safe;
}
} }

View file

@ -42,7 +42,6 @@ const renderUniversalTimerNotification = () => (
const getDefaultProps = () => ({ const getDefaultProps = () => ({
conversationId: 'conversation-id', conversationId: 'conversation-id',
conversationAccepted: true,
id: 'asdf', id: 'asdf',
isSelected: false, isSelected: false,
interactionMode: 'keyboard' as const, interactionMode: 'keyboard' as const,

View file

@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
import { omit } from 'lodash';
import { LocalizerType, ThemeType } from '../../types/Util'; import { LocalizerType, ThemeType } from '../../types/Util';
import { InteractionModeType } from '../../state/ducks/conversations'; import { InteractionModeType } from '../../state/ducks/conversations';
@ -152,7 +154,6 @@ export type TimelineItemType =
type PropsLocalType = { type PropsLocalType = {
conversationId: string; conversationId: string;
conversationAccepted: boolean;
item?: TimelineItemType; item?: TimelineItemType;
id: string; id: string;
isSelected: boolean; isSelected: boolean;
@ -201,7 +202,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
if (item.type === 'message') { if (item.type === 'message') {
return ( return (
<Message <Message
{...this.props} {...omit(this.props, ['item'])}
{...item.data} {...item.data}
i18n={i18n} i18n={i18n}
theme={theme} theme={theme}

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactNode, CSSProperties, FunctionComponent } from 'react'; import React, { ReactNode, FunctionComponent } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { isBoolean, isNumber } from 'lodash'; import { isBoolean, isNumber } from 'lodash';
@ -37,7 +37,6 @@ type PropsType = {
messageStatusIcon?: ReactNode; messageStatusIcon?: ReactNode;
messageText?: ReactNode; messageText?: ReactNode;
onClick?: () => void; onClick?: () => void;
style: CSSProperties;
unreadCount?: number; unreadCount?: number;
} & Pick< } & Pick<
ConversationType, ConversationType,
@ -77,7 +76,6 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
phoneNumber, phoneNumber,
profileName, profileName,
sharedGroupNames, sharedGroupNames,
style,
title, title,
unblurredAvatarPath, unblurredAvatarPath,
unreadCount, unreadCount,
@ -198,7 +196,6 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
{ [`${BASE_CLASS_NAME}--is-checkbox--disabled`]: disabled } { [`${BASE_CLASS_NAME}--is-checkbox--disabled`]: disabled }
)} )}
data-id={id ? cleanId(id) : undefined} data-id={id ? cleanId(id) : undefined}
style={style}
// `onClick` is will double-fire if we're enabled. We want it to fire when we're // `onClick` is will double-fire if we're enabled. We want it to fire when we're
// disabled so we can show any "can't add contact" modals, etc. This won't // disabled so we can show any "can't add contact" modals, etc. This won't
// work for keyboard users, though, because labels are not tabbable. // work for keyboard users, though, because labels are not tabbable.
@ -219,7 +216,6 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
data-id={id ? cleanId(id) : undefined} data-id={id ? cleanId(id) : undefined}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
style={style}
type="button" type="button"
> >
{contents} {contents}
@ -228,11 +224,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
} }
return ( return (
<div <div className={commonClassNames} data-id={id ? cleanId(id) : undefined}>
className={commonClassNames}
data-id={id ? cleanId(id) : undefined}
style={style}
>
{contents} {contents}
</div> </div>
); );

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent, ReactNode } from 'react'; import React, { FunctionComponent, ReactNode } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem'; import { BaseConversationListItem } from './BaseConversationListItem';
import { ConversationType } from '../../state/ducks/conversations'; import { ConversationType } from '../../state/ducks/conversations';
@ -38,7 +38,6 @@ export type PropsDataType = {
type PropsHousekeepingType = { type PropsHousekeepingType = {
i18n: LocalizerType; i18n: LocalizerType;
style: CSSProperties;
onClick: ( onClick: (
id: string, id: string,
disabledReason: undefined | ContactCheckboxDisabledReason disabledReason: undefined | ContactCheckboxDisabledReason
@ -63,7 +62,6 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
phoneNumber, phoneNumber,
profileName, profileName,
sharedGroupNames, sharedGroupNames,
style,
title, title,
type, type,
unblurredAvatarPath, unblurredAvatarPath,
@ -114,7 +112,6 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
profileName={profileName} profileName={profileName}
sharedGroupNames={sharedGroupNames} sharedGroupNames={sharedGroupNames}
style={style}
title={title} title={title}
unblurredAvatarPath={unblurredAvatarPath} unblurredAvatarPath={unblurredAvatarPath}
/> />

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react'; import React, { FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem'; import { BaseConversationListItem } from './BaseConversationListItem';
import { ConversationType } from '../../state/ducks/conversations'; import { ConversationType } from '../../state/ducks/conversations';
@ -28,7 +28,6 @@ export type PropsDataType = Pick<
type PropsHousekeepingType = { type PropsHousekeepingType = {
i18n: LocalizerType; i18n: LocalizerType;
style: CSSProperties;
onClick?: (id: string) => void; onClick?: (id: string) => void;
}; };
@ -48,7 +47,6 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
phoneNumber, phoneNumber,
profileName, profileName,
sharedGroupNames, sharedGroupNames,
style,
title, title,
type, type,
unblurredAvatarPath, unblurredAvatarPath,
@ -85,7 +83,6 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
profileName={profileName} profileName={profileName}
sharedGroupNames={sharedGroupNames} sharedGroupNames={sharedGroupNames}
style={style}
title={title} title={title}
unblurredAvatarPath={unblurredAvatarPath} unblurredAvatarPath={unblurredAvatarPath}
/> />

View file

@ -1,12 +1,7 @@
// Copyright 2018-2021 Signal Messenger, LLC // Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { import React, { useCallback, FunctionComponent, ReactNode } from 'react';
useCallback,
CSSProperties,
FunctionComponent,
ReactNode,
} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
@ -62,7 +57,6 @@ export type PropsData = Pick<
type PropsHousekeeping = { type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
style: CSSProperties;
onClick: (id: string) => void; onClick: (id: string) => void;
}; };
@ -88,7 +82,6 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
profileName, profileName,
sharedGroupNames, sharedGroupNames,
shouldShowDraft, shouldShowDraft,
style,
title, title,
type, type,
typingContact, typingContact,
@ -197,7 +190,6 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
profileName={profileName} profileName={profileName}
sharedGroupNames={sharedGroupNames} sharedGroupNames={sharedGroupNames}
style={style}
title={title} title={title}
unreadCount={unreadCount} unreadCount={unreadCount}
unblurredAvatarPath={unblurredAvatarPath} unblurredAvatarPath={unblurredAvatarPath}

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react'; import React, { FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem'; import { BaseConversationListItem } from './BaseConversationListItem';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
@ -9,11 +9,10 @@ import { LocalizerType } from '../../types/Util';
type PropsType = { type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
onClick: () => void; onClick: () => void;
style: CSSProperties;
}; };
export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo( export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo(
({ i18n, onClick, style }) => { ({ i18n, onClick }) => {
const title = i18n('createNewGroupButton'); const title = i18n('createNewGroupButton');
return ( return (
@ -26,7 +25,6 @@ export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo(
isSelected={false} isSelected={false}
onClick={onClick} onClick={onClick}
sharedGroupNames={[]} sharedGroupNames={[]}
style={style}
title={title} title={title}
/> />
); );

View file

@ -55,7 +55,6 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
'isSearchingInConversation', 'isSearchingInConversation',
overrideProps.isSearchingInConversation || false overrideProps.isSearchingInConversation || false
), ),
style: {},
}); });
story.add('Default', () => { story.add('Default', () => {

View file

@ -1,12 +1,7 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { import React, { useCallback, FunctionComponent, ReactNode } from 'react';
useCallback,
CSSProperties,
FunctionComponent,
ReactNode,
} from 'react';
import { escapeRegExp } from 'lodash'; import { escapeRegExp } from 'lodash';
import { MessageBodyHighlight } from './MessageBodyHighlight'; import { MessageBodyHighlight } from './MessageBodyHighlight';
@ -59,7 +54,6 @@ type PropsHousekeepingType = {
conversationId: string; conversationId: string;
messageId?: string; messageId?: string;
}) => void; }) => void;
style: CSSProperties;
}; };
export type PropsType = PropsDataType & PropsHousekeepingType; export type PropsType = PropsDataType & PropsHousekeepingType;
@ -159,7 +153,6 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
openConversationInternal, openConversationInternal,
sentAt, sentAt,
snippet, snippet,
style,
to, to,
}) => { }) => {
const onClickItem = useCallback(() => { const onClickItem = useCallback(() => {
@ -167,7 +160,7 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
}, [openConversationInternal, conversationId, id]); }, [openConversationInternal, conversationId, id]);
if (!from || !to) { if (!from || !to) {
return <div style={style} />; return <div />;
} }
const isNoteToSelf = from.isMe && to.isMe; const isNoteToSelf = from.isMe && to.isMe;
@ -213,7 +206,6 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
phoneNumber={from.phoneNumber} phoneNumber={from.phoneNumber}
profileName={from.profileName} profileName={from.profileName}
sharedGroupNames={from.sharedGroupNames} sharedGroupNames={from.sharedGroupNames}
style={style}
title={from.title} title={from.title}
unblurredAvatarPath={from.unblurredAvatarPath} unblurredAvatarPath={from.unblurredAvatarPath}
/> />

View file

@ -1,12 +1,10 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react'; import React, { FunctionComponent } from 'react';
type PropsType = { type PropsType = Record<string, never>;
style: CSSProperties;
};
export const SearchResultsLoadingFakeHeader: FunctionComponent<PropsType> = ({ export const SearchResultsLoadingFakeHeader: FunctionComponent<PropsType> = () => (
style, <div className="module-SearchResultsLoadingFakeHeader" />
}) => <div className="module-SearchResultsLoadingFakeHeader" style={style} />; );

View file

@ -1,16 +1,12 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react'; import React, { FunctionComponent } from 'react';
type PropsType = { type PropsType = Record<string, never>;
style: CSSProperties;
};
export const SearchResultsLoadingFakeRow: FunctionComponent<PropsType> = ({ export const SearchResultsLoadingFakeRow: FunctionComponent<PropsType> = () => (
style, <div className="module-SearchResultsLoadingFakeRow">
}) => (
<div className="module-SearchResultsLoadingFakeRow" style={style}>
<div className="module-SearchResultsLoadingFakeRow__avatar" /> <div className="module-SearchResultsLoadingFakeRow__avatar" />
<div className="module-SearchResultsLoadingFakeRow__content"> <div className="module-SearchResultsLoadingFakeRow__content">
<div className="module-SearchResultsLoadingFakeRow__content__header" /> <div className="module-SearchResultsLoadingFakeRow__content__header" />

View file

@ -1,7 +1,7 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react'; import React, { FunctionComponent, useCallback } from 'react';
import { import {
BaseConversationListItem, BaseConversationListItem,
@ -19,18 +19,21 @@ type PropsData = {
type PropsHousekeeping = { type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
style: CSSProperties; onClick: (phoneNumber: string) => void;
onClick: () => void;
}; };
export type Props = PropsData & PropsHousekeeping; export type Props = PropsData & PropsHousekeeping;
export const StartNewConversation: FunctionComponent<Props> = React.memo( export const StartNewConversation: FunctionComponent<Props> = React.memo(
({ i18n, onClick, phoneNumber, style }) => { ({ i18n, onClick, phoneNumber }) => {
const messageText = ( const messageText = (
<div className={TEXT_CLASS_NAME}>{i18n('startConversation')}</div> <div className={TEXT_CLASS_NAME}>{i18n('startConversation')}</div>
); );
const boundOnClick = useCallback(() => {
onClick(phoneNumber);
}, [onClick, phoneNumber]);
return ( return (
<BaseConversationListItem <BaseConversationListItem
acceptedMessageRequest={false} acceptedMessageRequest={false}
@ -41,10 +44,9 @@ export const StartNewConversation: FunctionComponent<Props> = React.memo(
isMe={false} isMe={false}
isSelected={false} isSelected={false}
messageText={messageText} messageText={messageText}
onClick={onClick} onClick={boundOnClick}
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
sharedGroupNames={[]} sharedGroupNames={[]}
style={style}
title={phoneNumber} title={phoneNumber}
/> />
); );

View file

@ -357,21 +357,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return { return {
sentAt: this.get('sent_at'), sentAt: this.get('sent_at'),
receivedAt: this.getReceivedAt(), receivedAt: this.getReceivedAt(),
message: getPropsForMessage( message: getPropsForMessage(this.attributes, {
this.attributes, conversationSelector: findAndFormatContact,
findAndFormatContact,
ourConversationId, ourConversationId,
window.textsecure.storage.user.getNumber(), ourNumber: window.textsecure.storage.user.getNumber(),
this.OUR_UUID, ourUuid: this.OUR_UUID,
undefined, regionCode: window.storage.get('regionCode', 'ZZ'),
undefined, accountSelector: (identifier?: string) => {
window.storage.get('regionCode', 'ZZ'),
(identifier?: string) => {
const state = window.reduxStore.getState(); const state = window.reduxStore.getState();
const accountSelector = getAccountSelector(state); const accountSelector = getAccountSelector(state);
return accountSelector(identifier); return accountSelector(identifier);
} },
), }),
errors, errors,
contacts, contacts,
}; };
@ -621,12 +618,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (isCallHistory(attributes)) { if (isCallHistory(attributes)) {
const state = window.reduxStore.getState(); const state = window.reduxStore.getState();
const callingNotification = getPropsForCallHistory( const callingNotification = getPropsForCallHistory(attributes, {
attributes, conversationSelector: findAndFormatContact,
findAndFormatContact, callSelector: getCallSelector(state),
getCallSelector(state), activeCall: getActiveCall(state),
getActiveCall(state) });
);
if (callingNotification) { if (callingNotification) {
return { return {
text: getCallingNotificationText(callingNotification, window.i18n), text: getCallingNotificationText(callingNotification, window.i18n),
@ -679,10 +675,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const body = (this.get('body') || '').trim(); const body = (this.get('body') || '').trim();
const { attributes } = this; const { attributes } = this;
const bodyRanges = processBodyRanges( const bodyRanges = processBodyRanges(attributes, {
attributes.bodyRanges, conversationSelector: findAndFormatContact,
findAndFormatContact });
);
if (bodyRanges) { if (bodyRanges) {
return getTextWithMentions(bodyRanges, body); return getTextWithMentions(bodyRanges, body);
} }
@ -696,10 +691,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
let modifiedText = text; let modifiedText = text;
const bodyRanges = processBodyRanges( const bodyRanges = processBodyRanges(attributes, {
attributes.bodyRanges, conversationSelector: findAndFormatContact,
findAndFormatContact });
);
if (bodyRanges && bodyRanges.length) { if (bodyRanges && bodyRanges.length) {
modifiedText = getTextWithMentions(bodyRanges, modifiedText); modifiedText = getTextWithMentions(bodyRanges, modifiedText);

View file

@ -153,10 +153,15 @@ export type ConversationType = {
isSelected?: boolean; isSelected?: boolean;
isFetchingUUID?: boolean; isFetchingUUID?: boolean;
typingContact?: { typingContact?: {
acceptedMessageRequest: boolean;
avatarPath?: string; avatarPath?: string;
color?: AvatarColorType;
isMe: boolean;
name?: string; name?: string;
phoneNumber?: string; phoneNumber?: string;
profileName?: string; profileName?: string;
sharedGroupNames: Array<string>;
title: string;
} | null; } | null;
recentMediaItems?: Array<MediaItemType>; recentMediaItems?: Array<MediaItemType>;
profileSharing?: boolean; profileSharing?: boolean;

View file

@ -712,19 +712,18 @@ export const getMessageSelector = createSelector(
return undefined; return undefined;
} }
return messageSelector( return messageSelector(message, {
message,
conversationSelector, conversationSelector,
ourConversationId, ourConversationId,
ourNumber, ourNumber,
ourUuid, ourUuid,
regionCode, regionCode,
selectedMessage ? selectedMessage.id : undefined, selectedMessageId: selectedMessage?.id,
selectedMessage ? selectedMessage.counter : undefined, selectedMessageCounter: selectedMessage?.counter,
callSelector, callSelector,
activeCall, activeCall,
accountSelector accountSelector,
); });
}; };
} }
); );

View file

@ -1,7 +1,17 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isNumber, isObject, map, omit, reduce } from 'lodash'; import {
identity,
isEqual,
isNumber,
isObject,
map,
omit,
pick,
reduce,
} from 'lodash';
import { createSelector, createSelectorCreator } from 'reselect';
import filesize from 'filesize'; import filesize from 'filesize';
import { import {
@ -29,7 +39,7 @@ import { QuotedAttachmentType } from '../../components/conversation/Quote';
import { getDomain, isStickerPack } from '../../types/LinkPreview'; import { getDomain, isStickerPack } from '../../types/LinkPreview';
import { ContactType, contactSelector } from '../../types/Contact'; import { ContactType, contactSelector } from '../../types/Contact';
import { BodyRangesType } from '../../types/Util'; import { AssertProps, BodyRangesType } from '../../types/Util';
import { LinkPreviewType } from '../../types/message/LinkPreviews'; import { LinkPreviewType } from '../../types/message/LinkPreviews';
import { ConversationColors } from '../../types/Colors'; import { ConversationColors } from '../../types/Colors';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
@ -37,6 +47,7 @@ import { SignalService as Proto } from '../../protobuf';
import { AttachmentType, isVoiceMessage } from '../../types/Attachment'; import { AttachmentType, isVoiceMessage } from '../../types/Attachment';
import { CallingNotificationType } from '../../util/callingNotification'; import { CallingNotificationType } from '../../util/callingNotification';
import { memoizeByRoot } from '../../util/memoizeByRoot';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { isNotNil } from '../../util/isNotNil'; import { isNotNil } from '../../util/isNotNil';
import { isMoreRecentThan } from '../../util/timestamp'; import { isMoreRecentThan } from '../../util/timestamp';
@ -78,46 +89,40 @@ type PropsForUnsupportedMessage = {
contact: FormattedContact; contact: FormattedContact;
}; };
export type GetPropsForBubbleOptions = Readonly<{
conversationSelector: GetConversationByIdType;
ourConversationId: string;
ourNumber?: string;
ourUuid?: string;
selectedMessageId?: string;
selectedMessageCounter?: number;
regionCode: string;
callSelector: CallSelectorType;
activeCall?: CallStateType;
accountSelector: (identifier?: string) => boolean;
}>;
// Top-level prop generation for the message bubble // Top-level prop generation for the message bubble
export function getPropsForBubble( export function getPropsForBubble(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType, options: GetPropsForBubbleOptions
ourConversationId: string,
ourNumber: string | undefined,
ourUuid: string | undefined,
regionCode: string,
selectedMessageId: string | undefined,
selectedMessageCounter: number | undefined,
callSelector: CallSelectorType,
activeCall: CallStateType | undefined,
accountSelector: (identifier?: string) => boolean
): TimelineItemType { ): TimelineItemType {
if (isUnsupportedMessage(message)) { if (isUnsupportedMessage(message)) {
return { return {
type: 'unsupportedMessage', type: 'unsupportedMessage',
data: getPropsForUnsupportedMessage( data: getPropsForUnsupportedMessage(message, options),
message,
conversationSelector,
ourConversationId,
ourNumber,
ourUuid
),
}; };
} }
if (isGroupV2Change(message)) { if (isGroupV2Change(message)) {
return { return {
type: 'groupV2Change', type: 'groupV2Change',
data: getPropsForGroupV2Change( data: getPropsForGroupV2Change(message, options),
message,
conversationSelector,
ourConversationId
),
}; };
} }
if (isGroupV1Migration(message)) { if (isGroupV1Migration(message)) {
return { return {
type: 'groupV1Migration', type: 'groupV1Migration',
data: getPropsForGroupV1Migration(message, conversationSelector), data: getPropsForGroupV1Migration(message, options),
}; };
} }
if (isMessageHistoryUnsynced(message)) { if (isMessageHistoryUnsynced(message)) {
@ -129,35 +134,25 @@ export function getPropsForBubble(
if (isExpirationTimerUpdate(message)) { if (isExpirationTimerUpdate(message)) {
return { return {
type: 'timerNotification', type: 'timerNotification',
data: getPropsForTimerNotification( data: getPropsForTimerNotification(message, options),
message,
conversationSelector,
ourConversationId
),
}; };
} }
if (isKeyChange(message)) { if (isKeyChange(message)) {
return { return {
type: 'safetyNumberNotification', type: 'safetyNumberNotification',
data: getPropsForSafetyNumberNotification(message, conversationSelector), data: getPropsForSafetyNumberNotification(message, options),
}; };
} }
if (isVerifiedChange(message)) { if (isVerifiedChange(message)) {
return { return {
type: 'verificationNotification', type: 'verificationNotification',
data: getPropsForVerificationNotification(message, conversationSelector), data: getPropsForVerificationNotification(message, options),
}; };
} }
if (isGroupUpdate(message)) { if (isGroupUpdate(message)) {
return { return {
type: 'groupNotification', type: 'groupNotification',
data: getPropsForGroupNotification( data: getPropsForGroupNotification(message, options),
message,
conversationSelector,
ourConversationId,
ourNumber,
ourUuid
),
}; };
} }
if (isEndSession(message)) { if (isEndSession(message)) {
@ -169,18 +164,13 @@ export function getPropsForBubble(
if (isCallHistory(message)) { if (isCallHistory(message)) {
return { return {
type: 'callHistory', type: 'callHistory',
data: getPropsForCallHistory( data: getPropsForCallHistory(message, options),
message,
conversationSelector,
callSelector,
activeCall
),
}; };
} }
if (isProfileChange(message)) { if (isProfileChange(message)) {
return { return {
type: 'profileChange', type: 'profileChange',
data: getPropsForProfileChange(message, conversationSelector), data: getPropsForProfileChange(message, options),
}; };
} }
if (isUniversalTimerNotification(message)) { if (isUniversalTimerNotification(message)) {
@ -192,7 +182,7 @@ export function getPropsForBubble(
if (isChangeNumberNotification(message)) { if (isChangeNumberNotification(message)) {
return { return {
type: 'changeNumberNotification', type: 'changeNumberNotification',
data: getPropsForChangeNumberNotification(message, conversationSelector), data: getPropsForChangeNumberNotification(message, options),
}; };
} }
if (isChatSessionRefreshed(message)) { if (isChatSessionRefreshed(message)) {
@ -204,23 +194,13 @@ export function getPropsForBubble(
if (isDeliveryIssue(message)) { if (isDeliveryIssue(message)) {
return { return {
type: 'deliveryIssue', type: 'deliveryIssue',
data: getPropsForDeliveryIssue(message, conversationSelector), data: getPropsForDeliveryIssue(message, options),
}; };
} }
return { return {
type: 'message', type: 'message',
data: getPropsForMessage( data: getPropsForMessage(message, options),
message,
conversationSelector,
ourConversationId,
ourNumber,
ourUuid,
selectedMessageId,
selectedMessageCounter,
regionCode,
accountSelector
),
}; };
} }
@ -310,12 +290,20 @@ export function getContactId(
return conversation.id; return conversation.id;
} }
export type GetContactOptions = Pick<
GetPropsForBubbleOptions,
'conversationSelector' | 'ourConversationId' | 'ourNumber' | 'ourUuid'
>;
// TODO: DESKTOP-2145
export function getContact( export function getContact(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType, {
ourConversationId: string, conversationSelector,
ourNumber: string | undefined, ourConversationId,
ourUuid: string | undefined ourNumber,
ourUuid,
}: GetContactOptions
): ConversationType { ): ConversationType {
const source = getSource(message, ourNumber); const source = getSource(message, ourNumber);
const sourceUuid = getSourceUuid(message, ourUuid); const sourceUuid = getSourceUuid(message, ourUuid);
@ -336,112 +324,257 @@ export function getConversation(
// Message // Message
export function getPropsForMessage( export const getAttachmentsForMessage = createSelectorCreator(memoizeByRoot)(
message: MessageAttributesType, // `memoizeByRoot` requirement
conversationSelector: GetConversationByIdType, identity,
ourConversationId: string,
ourNumber: string | undefined,
ourUuid: string | undefined,
selectedMessageId: string | undefined,
selectedMessageCounter: number | undefined,
regionCode: string,
accountSelector: (identifier?: string) => boolean
): Omit<PropsForMessage, 'renderingContext'> {
const contact = getContact(
message,
conversationSelector,
ourConversationId,
ourNumber,
ourUuid
);
const { expireTimer, expirationStartTimestamp } = message; ({ sticker }: MessageAttributesType) => sticker,
const expirationLength = expireTimer ? expireTimer * 1000 : undefined; ({ attachments }: MessageAttributesType) => attachments,
const expirationTimestamp = (
expirationStartTimestamp && expirationLength _: MessageAttributesType,
? expirationStartTimestamp + expirationLength sticker: MessageAttributesType['sticker'],
: undefined; attachments: MessageAttributesType['attachments'] = []
): Array<AttachmentType> => {
if (sticker && sticker.data) {
const { data } = sticker;
const conversation = getConversation(message, conversationSelector); // We don't show anything if we don't have the sticker or the blurhash...
const isGroup = conversation.type === 'group'; if (!data.blurHash && (data.pending || !data.path)) {
const { sticker } = message; return [];
}
const isMessageTapToView = isTapToView(message); return [
{
...data,
// We want to show the blurhash for stickers, not the spinner
pending: false,
url: data.path
? window.Signal.Migrations.getAbsoluteAttachmentPath(data.path)
: undefined,
},
];
}
const reactions = (message.reactions || []).map(re => { return attachments
const c = conversationSelector(re.fromId); .filter(attachment => !attachment.error)
.map(attachment => getPropsForAttachment(attachment))
return { .filter(isNotNil);
emoji: re.emoji,
timestamp: re.timestamp,
from: c,
};
});
const selectedReaction = (
(message.reactions || []).find(re => re.fromId === ourConversationId) || {}
).emoji;
const isSelected = message.id === selectedMessageId;
return {
attachments: getAttachmentsForMessage(message),
author: contact,
bodyRanges: processBodyRanges(message.bodyRanges, conversationSelector),
canDeleteForEveryone: canDeleteForEveryone(message),
canDownload: canDownload(message, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector),
contact: getPropsForEmbeddedContact(message, regionCode, accountSelector),
conversationColor: conversation?.conversationColor ?? ConversationColors[0],
conversationId: message.conversationId,
conversationType: isGroup ? 'group' : 'direct',
customColor: conversation?.customColor,
deletedForEveryone: message.deletedForEveryone || false,
direction: isIncoming(message) ? 'incoming' : 'outgoing',
expirationLength,
expirationTimestamp,
id: message.id,
isBlocked: conversation.isBlocked || false,
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
isSelected,
isSelectedCounter: isSelected ? selectedMessageCounter : undefined,
isSticker: Boolean(sticker),
isTapToView: isMessageTapToView,
isTapToViewError:
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
isTapToViewExpired: isMessageTapToView && message.isErased,
previews: getPropsForPreview(message),
quote: getPropsForQuote(message, conversationSelector, ourConversationId),
reactions,
selectedReaction,
status: getMessagePropStatus(message, ourConversationId),
text: createNonBreakingLastSeparator(message.body),
textPending: message.bodyPending,
timestamp: message.sent_at,
};
}
export function processBodyRanges(
bodyRanges: BodyRangesType | undefined,
conversationSelector: GetConversationByIdType
): BodyRangesType | undefined {
if (!bodyRanges) {
return undefined;
} }
);
return bodyRanges export const processBodyRanges = createSelectorCreator(memoizeByRoot, isEqual)(
.filter(range => range.mentionUuid) // `memoizeByRoot` requirement
.map(range => { identity,
const conversation = conversationSelector(range.mentionUuid);
(
{ bodyRanges }: Pick<MessageAttributesType, 'bodyRanges'>,
{ conversationSelector }: { conversationSelector: GetConversationByIdType }
): BodyRangesType | undefined => {
if (!bodyRanges) {
return undefined;
}
return bodyRanges
.filter(range => range.mentionUuid)
.map(range => {
const conversation = conversationSelector(range.mentionUuid);
return {
...range,
conversationID: conversation.id,
replacementText: conversation.title,
};
})
.sort((a, b) => b.start - a.start);
},
(_: MessageAttributesType, ranges?: BodyRangesType) => ranges
);
export const getAuthorForMessage = createSelectorCreator(
memoizeByRoot,
isEqual
)(
// `memoizeByRoot` requirement
identity,
(
message: MessageAttributesType,
options: GetContactOptions
): PropsData['author'] => {
const unsafe = pick(getContact(message, options), [
'acceptedMessageRequest',
'avatarPath',
'color',
'id',
'isMe',
'name',
'phoneNumber',
'profileName',
'sharedGroupNames',
'title',
'unblurredAvatarPath',
]);
const safe: AssertProps<PropsData['author'], typeof unsafe> = unsafe;
return safe;
},
(_: MessageAttributesType, author: PropsData['author']) => author
);
export const getPreviewsForMessage = createSelectorCreator(memoizeByRoot)(
// `memoizeByRoot` requirement
identity,
({ preview }: MessageAttributesType) => preview,
(
_: MessageAttributesType,
previews: MessageAttributesType['preview'] = []
): Array<LinkPreviewType> => {
return previews.map(preview => ({
...preview,
isStickerPack: isStickerPack(preview.url),
domain: getDomain(preview.url),
image: preview.image ? getPropsForAttachment(preview.image) : null,
}));
}
);
export const getReactionsForMessage = createSelectorCreator(
memoizeByRoot,
isEqual
)(
// `memoizeByRoot` requirement
identity,
(
{ reactions = [] }: MessageAttributesType,
{ conversationSelector }: { conversationSelector: GetConversationByIdType }
) => {
return reactions.map(re => {
const c = conversationSelector(re.fromId);
type From = NonNullable<PropsData['reactions']>[0]['from'];
const unsafe = pick(c, [
'acceptedMessageRequest',
'avatarPath',
'color',
'id',
'isMe',
'name',
'phoneNumber',
'profileName',
'sharedGroupNames',
'title',
]);
const from: AssertProps<From, typeof unsafe> = unsafe;
return { return {
...range, emoji: re.emoji,
conversationID: conversation.id, timestamp: re.timestamp,
replacementText: conversation.title, from,
}; };
}) });
.sort((a, b) => b.start - a.start); },
}
(_: MessageAttributesType, reactions: PropsData['reactions']) => reactions
);
export type GetPropsForMessageOptions = Pick<
GetPropsForBubbleOptions,
| 'conversationSelector'
| 'ourConversationId'
| 'selectedMessageId'
| 'selectedMessageCounter'
| 'regionCode'
| 'accountSelector'
>;
export const getPropsForMessage = createSelector(
(message: MessageAttributesType) => message,
getAttachmentsForMessage,
processBodyRanges,
getAuthorForMessage,
getPreviewsForMessage,
getReactionsForMessage,
(_: unknown, options: GetPropsForMessageOptions) => options,
(
message: MessageAttributesType,
attachments: Array<AttachmentType>,
bodyRanges: BodyRangesType | undefined,
author: PropsData['author'],
previews: Array<LinkPreviewType>,
reactions: PropsData['reactions'],
{
conversationSelector,
ourConversationId,
selectedMessageId,
selectedMessageCounter,
regionCode,
accountSelector,
}: GetPropsForMessageOptions
): Omit<PropsForMessage, 'renderingContext'> => {
const { expireTimer, expirationStartTimestamp } = message;
const expirationLength = expireTimer ? expireTimer * 1000 : undefined;
const expirationTimestamp =
expirationStartTimestamp && expirationLength
? expirationStartTimestamp + expirationLength
: undefined;
const conversation = getConversation(message, conversationSelector);
const isGroup = conversation.type === 'group';
const { sticker } = message;
const isMessageTapToView = isTapToView(message);
const selectedReaction = (
(message.reactions || []).find(re => re.fromId === ourConversationId) ||
{}
).emoji;
const isSelected = message.id === selectedMessageId;
return {
attachments,
author,
bodyRanges,
canDeleteForEveryone: canDeleteForEveryone(message),
canDownload: canDownload(message, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector),
contact: getPropsForEmbeddedContact(message, regionCode, accountSelector),
conversationColor:
conversation?.conversationColor ?? ConversationColors[0],
conversationId: message.conversationId,
conversationType: isGroup ? 'group' : 'direct',
customColor: conversation?.customColor,
deletedForEveryone: message.deletedForEveryone || false,
direction: isIncoming(message) ? 'incoming' : 'outgoing',
expirationLength,
expirationTimestamp,
id: message.id,
isBlocked: conversation.isBlocked || false,
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
isSelected,
isSelectedCounter: isSelected ? selectedMessageCounter : undefined,
isSticker: Boolean(sticker),
isTapToView: isMessageTapToView,
isTapToViewError:
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
isTapToViewExpired: isMessageTapToView && message.isErased,
previews,
quote: getPropsForQuote(message, conversationSelector, ourConversationId),
reactions,
selectedReaction,
status: getMessagePropStatus(message, ourConversationId),
text: createNonBreakingLastSeparator(message.body),
textPending: message.bodyPending,
timestamp: message.sent_at,
};
}
);
// Unsupported Message // Unsupported Message
@ -458,10 +591,7 @@ export function isUnsupportedMessage(message: MessageAttributesType): boolean {
function getPropsForUnsupportedMessage( function getPropsForUnsupportedMessage(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType, options: GetContactOptions
ourConversationId: string,
ourNumber: string | undefined,
ourUuid: string | undefined
): PropsForUnsupportedMessage { ): PropsForUnsupportedMessage {
const CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT; const CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT;
@ -474,13 +604,7 @@ function getPropsForUnsupportedMessage(
return { return {
canProcessNow, canProcessNow,
contact: getContact( contact: getContact(message, options),
message,
conversationSelector,
ourConversationId,
ourNumber,
ourUuid
),
}; };
} }
@ -492,8 +616,7 @@ export function isGroupV2Change(message: MessageAttributesType): boolean {
function getPropsForGroupV2Change( function getPropsForGroupV2Change(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType, { conversationSelector, ourConversationId }: GetPropsForBubbleOptions
ourConversationId: string
): GroupsV2Props { ): GroupsV2Props {
const change = message.groupV2Change; const change = message.groupV2Change;
@ -518,7 +641,7 @@ export function isGroupV1Migration(message: MessageAttributesType): boolean {
function getPropsForGroupV1Migration( function getPropsForGroupV1Migration(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType { conversationSelector }: GetPropsForBubbleOptions
): GroupV1MigrationPropsType { ): GroupV1MigrationPropsType {
const migration = message.groupMigration; const migration = message.groupMigration;
if (!migration) { if (!migration) {
@ -581,8 +704,7 @@ export function isExpirationTimerUpdate(
function getPropsForTimerNotification( function getPropsForTimerNotification(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType, { ourConversationId, conversationSelector }: GetPropsForBubbleOptions
ourConversationId: string
): TimerNotificationProps { ): TimerNotificationProps {
const timerUpdate = message.expirationTimerUpdate; const timerUpdate = message.expirationTimerUpdate;
if (!timerUpdate) { if (!timerUpdate) {
@ -633,7 +755,7 @@ export function isKeyChange(message: MessageAttributesType): boolean {
function getPropsForSafetyNumberNotification( function getPropsForSafetyNumberNotification(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType { conversationSelector }: GetPropsForBubbleOptions
): SafetyNumberNotificationProps { ): SafetyNumberNotificationProps {
const conversation = getConversation(message, conversationSelector); const conversation = getConversation(message, conversationSelector);
const isGroup = conversation?.type === 'group'; const isGroup = conversation?.type === 'group';
@ -654,7 +776,7 @@ export function isVerifiedChange(message: MessageAttributesType): boolean {
function getPropsForVerificationNotification( function getPropsForVerificationNotification(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType { conversationSelector }: GetPropsForBubbleOptions
): VerificationNotificationProps { ): VerificationNotificationProps {
const type = message.verified ? 'markVerified' : 'markNotVerified'; const type = message.verified ? 'markVerified' : 'markNotVerified';
const isLocal = message.local || false; const isLocal = message.local || false;
@ -677,10 +799,7 @@ export function isGroupUpdate(
function getPropsForGroupNotification( function getPropsForGroupNotification(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType, options: GetContactOptions
ourConversationId: string,
ourNumber: string | undefined,
ourUuid: string | undefined
): GroupNotificationProps { ): GroupNotificationProps {
const groupUpdate = message.group_update; const groupUpdate = message.group_update;
if (!groupUpdate) { if (!groupUpdate) {
@ -689,6 +808,8 @@ function getPropsForGroupNotification(
); );
} }
const { conversationSelector } = options;
const changes = []; const changes = [];
if ( if (
@ -741,13 +862,7 @@ function getPropsForGroupNotification(
}); });
} }
const from = getContact( const from = getContact(message, options);
message,
conversationSelector,
ourConversationId,
ourNumber,
ourUuid
);
return { return {
from, from,
@ -771,11 +886,18 @@ export function isCallHistory(message: MessageAttributesType): boolean {
return message.type === 'call-history'; return message.type === 'call-history';
} }
export type GetPropsForCallHistoryOptions = Pick<
GetPropsForBubbleOptions,
'conversationSelector' | 'callSelector' | 'activeCall'
>;
export function getPropsForCallHistory( export function getPropsForCallHistory(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType, {
callSelector: CallSelectorType, conversationSelector,
activeCall: CallStateType | undefined callSelector,
activeCall,
}: GetPropsForCallHistoryOptions
): CallingNotificationType { ): CallingNotificationType {
const { callHistoryDetails } = message; const { callHistoryDetails } = message;
if (!callHistoryDetails) { if (!callHistoryDetails) {
@ -833,7 +955,7 @@ export function isProfileChange(message: MessageAttributesType): boolean {
function getPropsForProfileChange( function getPropsForProfileChange(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType { conversationSelector }: GetPropsForBubbleOptions
): ProfileChangeNotificationPropsType { ): ProfileChangeNotificationPropsType {
const change = message.profileChange; const change = message.profileChange;
const { changedId } = message; const { changedId } = message;
@ -869,7 +991,7 @@ export function isChangeNumberNotification(
function getPropsForChangeNumberNotification( function getPropsForChangeNumberNotification(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType { conversationSelector }: GetPropsForBubbleOptions
): ChangeNumberNotificationProps { ): ChangeNumberNotificationProps {
return { return {
sender: conversationSelector(message.sourceUuid), sender: conversationSelector(message.sourceUuid),
@ -895,7 +1017,7 @@ export function isDeliveryIssue(message: MessageAttributesType): boolean {
function getPropsForDeliveryIssue( function getPropsForDeliveryIssue(
message: MessageAttributesType, message: MessageAttributesType,
conversationSelector: GetConversationByIdType { conversationSelector }: GetPropsForBubbleOptions
): DeliveryIssuePropsType { ): DeliveryIssuePropsType {
const sender = conversationSelector(message.sourceUuid); const sender = conversationSelector(message.sourceUuid);
const conversation = conversationSelector(message.conversationId); const conversation = conversationSelector(message.conversationId);
@ -1046,19 +1168,6 @@ export function getPropsForAttachment(
}; };
} }
function getPropsForPreview(
message: MessageAttributesType
): Array<LinkPreviewType> {
const previews = message.preview || [];
return previews.map(preview => ({
...preview,
isStickerPack: isStickerPack(preview.url),
domain: getDomain(preview.url),
image: preview.image ? getPropsForAttachment(preview.image) : null,
}));
}
export function getPropsForQuote( export function getPropsForQuote(
message: Pick<MessageAttributesType, 'conversationId' | 'quote'>, message: Pick<MessageAttributesType, 'conversationId' | 'quote'>,
conversationSelector: GetConversationByIdType, conversationSelector: GetConversationByIdType,
@ -1072,7 +1181,6 @@ export function getPropsForQuote(
const { const {
author, author,
authorUuid, authorUuid,
bodyRanges,
id: sentAt, id: sentAt,
isViewOnce, isViewOnce,
referencedMessageNotFound, referencedMessageNotFound,
@ -1097,7 +1205,7 @@ export function getPropsForQuote(
authorPhoneNumber, authorPhoneNumber,
authorProfileName, authorProfileName,
authorTitle, authorTitle,
bodyRanges: processBodyRanges(bodyRanges, conversationSelector), bodyRanges: processBodyRanges(quote, { conversationSelector }),
conversationColor: conversation.conversationColor ?? ConversationColors[0], conversationColor: conversation.conversationColor ?? ConversationColors[0],
customColor: conversation.customColor, customColor: conversation.customColor,
isFromMe, isFromMe,
@ -1240,37 +1348,6 @@ export function canDownload(
return true; return true;
} }
export function getAttachmentsForMessage(
message: MessageAttributesType
): Array<AttachmentType> {
const { sticker } = message;
if (sticker && sticker.data) {
const { data } = sticker;
// We don't show anything if we don't have the sticker or the blurhash...
if (!data.blurHash && (data.pending || !data.path)) {
return [];
}
return [
{
...data,
// We want to show the blurhash for stickers, not the spinner
pending: false,
url: data.path
? window.Signal.Migrations.getAbsoluteAttachmentPath(data.path)
: undefined,
},
];
}
const attachments = message.attachments || [];
return attachments
.filter(attachment => !attachment.error)
.map(attachment => getPropsForAttachment(attachment))
.filter(isNotNil);
}
export function getLastChallengeError( export function getLastChallengeError(
message: Pick<MessageAttributesType, 'errors'> message: Pick<MessageAttributesType, 'errors'>
): ShallowChallengeError | undefined { ): ShallowChallengeError | undefined {

View file

@ -1,7 +1,7 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties } from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { get } from 'lodash'; import { get } from 'lodash';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
@ -59,11 +59,8 @@ function renderExpiredBuildDialog(): JSX.Element {
function renderMainHeader(): JSX.Element { function renderMainHeader(): JSX.Element {
return <SmartMainHeader />; return <SmartMainHeader />;
} }
function renderMessageSearchResult( function renderMessageSearchResult(id: string): JSX.Element {
id: string, return <FilteredSmartMessageSearchResult id={id} />;
style: CSSProperties
): JSX.Element {
return <FilteredSmartMessageSearchResult id={id} style={style} />;
} }
function renderNetworkStatus(): JSX.Element { function renderNetworkStatus(): JSX.Element {
return <SmartNetworkStatus />; return <SmartNetworkStatus />;

View file

@ -8,6 +8,7 @@ import memoizee from 'memoizee';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { import {
PropsActionsType as TimelineActionsType,
ContactSpoofingReviewPropType, ContactSpoofingReviewPropType,
Timeline, Timeline,
WarningType as TimelineWarningType, WarningType as TimelineWarningType,
@ -44,13 +45,6 @@ import {
} from '../../util/groupMemberNameCollisions'; } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ContactSpoofingType } from '../../util/contactSpoofing';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
/* eslint-disable @typescript-eslint/no-explicit-any */
const FilteredSmartTimelineItem = SmartTimelineItem as any;
const FilteredSmartTypingBubble = SmartTypingBubble as any;
/* eslint-enable @typescript-eslint/no-explicit-any */
type ExternalProps = { type ExternalProps = {
id: string; id: string;
@ -72,10 +66,10 @@ function renderItem(
messageId: string, messageId: string,
conversationId: string, conversationId: string,
onHeightChange: (messageId: string) => unknown, onHeightChange: (messageId: string) => unknown,
actionProps: Record<string, unknown> actionProps: TimelineActionsType
): JSX.Element { ): JSX.Element {
return ( return (
<FilteredSmartTimelineItem <SmartTimelineItem
{...actionProps} {...actionProps}
conversationId={conversationId} conversationId={conversationId}
id={messageId} id={messageId}
@ -109,7 +103,7 @@ function renderLoadingRow(id: string): JSX.Element {
return <SmartTimelineLoadingRow id={id} />; return <SmartTimelineLoadingRow id={id} />;
} }
function renderTypingBubble(id: string): JSX.Element { function renderTypingBubble(id: string): JSX.Element {
return <FilteredSmartTypingBubble id={id} />; return <SmartTypingBubble id={id} />;
} }
const getWarning = ( const getWarning = (

View file

@ -4,6 +4,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { TypingBubble } from '../../components/conversation/TypingBubble'; import { TypingBubble } from '../../components/conversation/TypingBubble';
import { strictAssert } from '../../util/assert';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
@ -21,6 +22,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
throw new Error(`Did not find conversation ${id} in state!`); throw new Error(`Did not find conversation ${id} in state!`);
} }
strictAssert(conversation.typingContact, 'Missing typingContact');
return { return {
...conversation.typingContact, ...conversation.typingContact,
conversationType: conversation.type, conversationType: conversation.type,

View file

@ -1236,6 +1236,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1260,6 +1261,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1284,6 +1286,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1308,6 +1311,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1332,6 +1336,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1380,6 +1385,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1405,6 +1411,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1430,6 +1437,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1480,6 +1488,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1504,6 +1513,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1528,6 +1538,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1553,6 +1564,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },
@ -1577,6 +1589,7 @@ describe('both/state/selectors/conversations', () => {
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
typingContact: { typingContact: {
...getDefaultConversation(),
name: 'Someone There', name: 'Someone There',
phoneNumber: '+18005551111', phoneNumber: '+18005551111',
}, },

View file

@ -0,0 +1,62 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { memoizeByRoot } from '../../util/memoizeByRoot';
class Root {}
describe('memoizeByRoot', () => {
it('should memoize by last passed arguments', () => {
const root = new Root();
const stub = sinon.stub();
stub.withArgs(sinon.match.same(root), 1).returns(1);
stub.withArgs(sinon.match.same(root), 2).returns(2);
const fn = memoizeByRoot(stub);
assert.strictEqual(fn(root, 1), 1);
assert.strictEqual(fn(root, 1), 1);
assert.isTrue(stub.calledOnce);
assert.strictEqual(fn(root, 2), 2);
assert.strictEqual(fn(root, 2), 2);
assert.isTrue(stub.calledTwice);
assert.strictEqual(fn(root, 1), 1);
assert.strictEqual(fn(root, 1), 1);
assert.isTrue(stub.calledThrice);
});
it('should memoize results by root', () => {
const rootA = new Root();
const rootB = new Root();
const stub = sinon.stub();
stub.withArgs(sinon.match.same(rootA), 1).returns(1);
stub.withArgs(sinon.match.same(rootA), 2).returns(2);
stub.withArgs(sinon.match.same(rootB), 1).returns(3);
stub.withArgs(sinon.match.same(rootB), 2).returns(4);
const fn = memoizeByRoot(stub);
assert.strictEqual(fn(rootA, 1), 1);
assert.strictEqual(fn(rootB, 1), 3);
assert.strictEqual(fn(rootA, 1), 1);
assert.strictEqual(fn(rootB, 1), 3);
assert.isTrue(stub.calledTwice);
assert.strictEqual(fn(rootA, 2), 2);
assert.strictEqual(fn(rootB, 2), 4);
assert.strictEqual(fn(rootA, 2), 2);
assert.strictEqual(fn(rootB, 2), 4);
assert.strictEqual(stub.callCount, 4);
assert.strictEqual(fn(rootA, 1), 1);
assert.strictEqual(fn(rootB, 1), 3);
assert.strictEqual(stub.callCount, 6);
});
});

View file

@ -35,3 +35,19 @@ export enum ScrollBehavior {
Default = 'default', Default = 'default',
Hard = 'hard', Hard = 'hard',
} }
type InternalAssertProps<
Result,
Value,
Missing = Omit<Result, keyof Value>
> = keyof Missing extends never
? Result
: Result &
{
[key in keyof Required<Missing>]: [
never,
'AssertProps: missing property'
];
};
export type AssertProps<Result, Value> = InternalAssertProps<Result, Value>;

47
ts/util/memoizeByRoot.ts Normal file
View file

@ -0,0 +1,47 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { defaultMemoize } from 'reselect';
import { strictAssert } from './assert';
// The difference between the function below and `defaultMemoize` from
// `reselect` is that it supports multiple "root" states. `reselect` is designed
// to interact with a single redux store and by default it memoizes only the
// last result of the selector (matched by its arguments). This works well when
// applied to singular entities living in the redux's state, but we need to
// apply selector to multitide of conversations and messages.
//
// The way it works is that it adds an extra memoization step that uses the
// first argument ("root") as a key in a weak map, and then applies the default
// `reselect`'s memoization function to the rest of the arguments. This way
// we essentially get a weak map of selectors by the "root".
// eslint-disable-next-line @typescript-eslint/ban-types
export function memoizeByRoot<F extends Function>(
fn: F,
equalityCheck?: <T>(a: T, b: T, index: number) => boolean
): F {
// eslint-disable-next-line @typescript-eslint/ban-types
const cache = new WeakMap<object, Function>();
const wrap = (root: unknown, ...rest: Array<unknown>): unknown => {
strictAssert(
typeof root === 'object' && root !== null,
'Root is not object'
);
let partial = cache.get(root);
if (!partial) {
partial = defaultMemoize((...args: Array<unknown>): unknown => {
return fn(root, ...args);
}, equalityCheck);
cache.set(root, partial);
}
return partial(...rest);
};
return (wrap as unknown) as F;
}

View file

@ -4,6 +4,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import nodePath from 'path'; import nodePath from 'path';
import { unstable_batchedUpdates as batchedUpdates } from 'react-dom';
import { import {
AttachmentDraftType, AttachmentDraftType,
AttachmentType, AttachmentType,
@ -3922,25 +3924,27 @@ Whisper.ConversationView = Whisper.View.extend({
window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
model.sendMessage( batchedUpdates(() => {
message, model.sendMessage(
attachments, message,
this.quote, attachments,
this.getLinkPreview(), this.quote,
undefined, // sticker this.getLinkPreview(),
mentions, undefined, // sticker
{ mentions,
sendHQImages, {
timestamp, sendHQImages,
} timestamp,
); }
);
this.compositionApi.current.reset(); this.compositionApi.current.reset();
model.setMarkedUnread(false); model.setMarkedUnread(false);
this.setQuoteMessage(null); this.setQuoteMessage(null);
this.resetLinkPreview(); this.resetLinkPreview();
this.clearAttachments(); this.clearAttachments();
window.reduxActions.composer.resetComposer(); window.reduxActions.composer.resetComposer();
});
} catch (error) { } catch (error) {
window.log.error( window.log.error(
'Error pulling attached files before send', 'Error pulling attached files before send',