diff --git a/ts/components/Profiler.tsx b/ts/components/Profiler.tsx new file mode 100644 index 000000000000..986382e92334 --- /dev/null +++ b/ts/components/Profiler.tsx @@ -0,0 +1,55 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactNode } from 'react'; + +type InternalPropsType = Readonly<{ + id: string; + children: ReactNode; + + onRender( + id: string, + phase: 'mount' | 'update', + actualDuration: number, + baseDuration: number, + startTime: number, + commitTime: number, + interactions: Set + ): void; +}>; + +const Fallback: React.FC = ({ children }) => { + return <>{children}; +}; + +const BaseProfiler: React.FC = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (React as any).unstable_Profiler || Fallback; + +export type PropsType = Readonly<{ + id: string; + children: ReactNode; +}>; + +const onRender: InternalPropsType['onRender'] = ( + id, + phase, + actual, + base, + start, + commit +) => { + window.log.info( + `Profiler.tsx(${id}): actual=${actual.toFixed(1)}ms phase=${phase} ` + + `base=${base.toFixed(1)}ms start=${start.toFixed(1)}ms ` + + `commit=${commit.toFixed(1)}ms` + ); +}; + +export const Profiler: React.FC = ({ id, children }) => { + return ( + + {children} + + ); +}; diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 14716fa38211..c95bedf52b78 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -11,7 +11,7 @@ import { pick, reduce, } from 'lodash'; -import { createSelector, createSelectorCreator } from 'reselect'; +import { createSelectorCreator } from 'reselect'; import filesize from 'filesize'; import { @@ -102,108 +102,6 @@ export type GetPropsForBubbleOptions = Readonly<{ accountSelector: (identifier?: string) => boolean; }>; -// Top-level prop generation for the message bubble -export function getPropsForBubble( - message: MessageAttributesType, - options: GetPropsForBubbleOptions -): TimelineItemType { - if (isUnsupportedMessage(message)) { - return { - type: 'unsupportedMessage', - data: getPropsForUnsupportedMessage(message, options), - }; - } - if (isGroupV2Change(message)) { - return { - type: 'groupV2Change', - data: getPropsForGroupV2Change(message, options), - }; - } - if (isGroupV1Migration(message)) { - return { - type: 'groupV1Migration', - data: getPropsForGroupV1Migration(message, options), - }; - } - if (isMessageHistoryUnsynced(message)) { - return { - type: 'linkNotification', - data: null, - }; - } - if (isExpirationTimerUpdate(message)) { - return { - type: 'timerNotification', - data: getPropsForTimerNotification(message, options), - }; - } - if (isKeyChange(message)) { - return { - type: 'safetyNumberNotification', - data: getPropsForSafetyNumberNotification(message, options), - }; - } - if (isVerifiedChange(message)) { - return { - type: 'verificationNotification', - data: getPropsForVerificationNotification(message, options), - }; - } - if (isGroupUpdate(message)) { - return { - type: 'groupNotification', - data: getPropsForGroupNotification(message, options), - }; - } - if (isEndSession(message)) { - return { - type: 'resetSessionNotification', - data: null, - }; - } - if (isCallHistory(message)) { - return { - type: 'callHistory', - data: getPropsForCallHistory(message, options), - }; - } - if (isProfileChange(message)) { - return { - type: 'profileChange', - data: getPropsForProfileChange(message, options), - }; - } - if (isUniversalTimerNotification(message)) { - return { - type: 'universalTimerNotification', - data: null, - }; - } - if (isChangeNumberNotification(message)) { - return { - type: 'changeNumberNotification', - data: getPropsForChangeNumberNotification(message, options), - }; - } - if (isChatSessionRefreshed(message)) { - return { - type: 'chatSessionRefreshed', - data: null, - }; - } - if (isDeliveryIssue(message)) { - return { - type: 'deliveryIssue', - data: getPropsForDeliveryIssue(message, options), - }; - } - - return { - type: 'message', - data: getPropsForMessage(message, options), - }; -} - export function isIncoming( message: Pick ): boolean { @@ -483,6 +381,70 @@ export const getReactionsForMessage = createSelectorCreator( (_: MessageAttributesType, reactions: PropsData['reactions']) => reactions ); +export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)( + // `memoizeByRoot` requirement + identity, + + ( + message: Pick, + { + conversationSelector, + ourConversationId, + }: { + conversationSelector: GetConversationByIdType; + ourConversationId?: string; + } + ): PropsData['quote'] => { + const { quote } = message; + if (!quote) { + return undefined; + } + + const { + author, + authorUuid, + id: sentAt, + isViewOnce, + referencedMessageNotFound, + text, + } = quote; + + const contact = conversationSelector(authorUuid || author); + + const authorId = contact.id; + const authorName = contact.name; + const authorPhoneNumber = contact.phoneNumber; + const authorProfileName = contact.profileName; + const authorTitle = contact.title; + const isFromMe = authorId === ourConversationId; + + const firstAttachment = quote.attachments && quote.attachments[0]; + const conversation = getConversation(message, conversationSelector); + + return { + authorId, + authorName, + authorPhoneNumber, + authorProfileName, + authorTitle, + bodyRanges: processBodyRanges(quote, { conversationSelector }), + conversationColor: + conversation.conversationColor ?? ConversationColors[0], + customColor: conversation.customColor, + isFromMe, + rawAttachment: firstAttachment + ? processQuoteAttachment(firstAttachment) + : undefined, + isViewOnce, + referencedMessageNotFound, + sentAt: Number(sentAt), + text: createNonBreakingLastSeparator(text), + }; + }, + + (_: unknown, quote: PropsData['quote']) => quote +); + export type GetPropsForMessageOptions = Pick< GetPropsForBubbleOptions, | 'conversationSelector' @@ -493,30 +455,51 @@ export type GetPropsForMessageOptions = Pick< | 'accountSelector' >; -export const getPropsForMessage = createSelector( - (message: MessageAttributesType) => message, - getAttachmentsForMessage, - processBodyRanges, - getAuthorForMessage, - getPreviewsForMessage, - getReactionsForMessage, - (_: unknown, options: GetPropsForMessageOptions) => options, +type ShallowPropsType = Pick< + PropsForMessage, + | 'canDeleteForEveryone' + | 'canDownload' + | 'canReply' + | 'contact' + | 'conversationColor' + | 'conversationId' + | 'conversationType' + | 'customColor' + | 'deletedForEveryone' + | 'direction' + | 'expirationLength' + | 'expirationTimestamp' + | 'id' + | 'isBlocked' + | 'isMessageRequestAccepted' + | 'isSelected' + | 'isSelectedCounter' + | 'isSticker' + | 'isTapToView' + | 'isTapToViewError' + | 'isTapToViewExpired' + | 'selectedReaction' + | 'status' + | 'text' + | 'textPending' + | 'timestamp' +>; + +const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( + // `memoizeByRoot` requirement + identity, + ( message: MessageAttributesType, - attachments: Array, - bodyRanges: BodyRangesType | undefined, - author: PropsData['author'], - previews: Array, - reactions: PropsData['reactions'], { + accountSelector, conversationSelector, ourConversationId, + regionCode, selectedMessageId, selectedMessageCounter, - regionCode, - accountSelector, }: GetPropsForMessageOptions - ): Omit => { + ): ShallowPropsType => { const { expireTimer, expirationStartTimestamp } = message; const expirationLength = expireTimer ? expireTimer * 1000 : undefined; const expirationTimestamp = @@ -530,17 +513,14 @@ export const getPropsForMessage = createSelector( const isMessageTapToView = isTapToView(message); + const isSelected = message.id === selectedMessageId; + 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), @@ -564,18 +544,160 @@ export const getPropsForMessage = createSelector( 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, }; + }, + + (_: unknown, props: ShallowPropsType) => props +); + +export const getPropsForMessage = createSelectorCreator(memoizeByRoot)( + // `memoizeByRoot` requirement + identity, + + getAttachmentsForMessage, + processBodyRanges, + getAuthorForMessage, + getPreviewsForMessage, + getReactionsForMessage, + getPropsForQuote, + getShallowPropsForMessage, + ( + _: unknown, + attachments: Array, + bodyRanges: BodyRangesType | undefined, + author: PropsData['author'], + previews: Array, + reactions: PropsData['reactions'], + quote: PropsData['quote'], + shallowProps: ShallowPropsType + ): Omit => { + return { + attachments, + author, + bodyRanges, + previews, + quote, + reactions, + ...shallowProps, + }; } ); +export const getBubblePropsForMessage = createSelectorCreator(memoizeByRoot)( + // `memoizeByRoot` requirement + identity, + + getPropsForMessage, + (_: unknown, data: ReturnType) => ({ + type: 'message' as const, + data, + }) +); + +// Top-level prop generation for the message bubble +export function getPropsForBubble( + message: MessageAttributesType, + options: GetPropsForBubbleOptions +): TimelineItemType { + if (isUnsupportedMessage(message)) { + return { + type: 'unsupportedMessage', + data: getPropsForUnsupportedMessage(message, options), + }; + } + if (isGroupV2Change(message)) { + return { + type: 'groupV2Change', + data: getPropsForGroupV2Change(message, options), + }; + } + if (isGroupV1Migration(message)) { + return { + type: 'groupV1Migration', + data: getPropsForGroupV1Migration(message, options), + }; + } + if (isMessageHistoryUnsynced(message)) { + return { + type: 'linkNotification', + data: null, + }; + } + if (isExpirationTimerUpdate(message)) { + return { + type: 'timerNotification', + data: getPropsForTimerNotification(message, options), + }; + } + if (isKeyChange(message)) { + return { + type: 'safetyNumberNotification', + data: getPropsForSafetyNumberNotification(message, options), + }; + } + if (isVerifiedChange(message)) { + return { + type: 'verificationNotification', + data: getPropsForVerificationNotification(message, options), + }; + } + if (isGroupUpdate(message)) { + return { + type: 'groupNotification', + data: getPropsForGroupNotification(message, options), + }; + } + if (isEndSession(message)) { + return { + type: 'resetSessionNotification', + data: null, + }; + } + if (isCallHistory(message)) { + return { + type: 'callHistory', + data: getPropsForCallHistory(message, options), + }; + } + if (isProfileChange(message)) { + return { + type: 'profileChange', + data: getPropsForProfileChange(message, options), + }; + } + if (isUniversalTimerNotification(message)) { + return { + type: 'universalTimerNotification', + data: null, + }; + } + if (isChangeNumberNotification(message)) { + return { + type: 'changeNumberNotification', + data: getPropsForChangeNumberNotification(message, options), + }; + } + if (isChatSessionRefreshed(message)) { + return { + type: 'chatSessionRefreshed', + data: null, + }; + } + if (isDeliveryIssue(message)) { + return { + type: 'deliveryIssue', + data: getPropsForDeliveryIssue(message, options), + }; + } + + return getBubblePropsForMessage(message, options); +} + // Unsupported Message export function isUnsupportedMessage(message: MessageAttributesType): boolean { @@ -1168,57 +1290,6 @@ export function getPropsForAttachment( }; } -export function getPropsForQuote( - message: Pick, - conversationSelector: GetConversationByIdType, - ourConversationId: string | undefined -): PropsData['quote'] { - const { quote } = message; - if (!quote) { - return undefined; - } - - const { - author, - authorUuid, - id: sentAt, - isViewOnce, - referencedMessageNotFound, - text, - } = quote; - - const contact = conversationSelector(authorUuid || author); - - const authorId = contact.id; - const authorName = contact.name; - const authorPhoneNumber = contact.phoneNumber; - const authorProfileName = contact.profileName; - const authorTitle = contact.title; - const isFromMe = authorId === ourConversationId; - - const firstAttachment = quote.attachments && quote.attachments[0]; - const conversation = getConversation(message, conversationSelector); - - return { - authorId, - authorName, - authorPhoneNumber, - authorProfileName, - authorTitle, - bodyRanges: processBodyRanges(quote, { conversationSelector }), - conversationColor: conversation.conversationColor ?? ConversationColors[0], - customColor: conversation.customColor, - isFromMe, - rawAttachment: firstAttachment - ? processQuoteAttachment(firstAttachment) - : undefined, - isViewOnce, - referencedMessageNotFound, - sentAt: Number(sentAt), - text: createNonBreakingLastSeparator(text), - }; -} - function processQuoteAttachment( attachment: AttachmentType ): QuotedAttachmentType { diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 88b8432271de..be0b1e9a9671 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -87,11 +87,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { linkPreviewResult, // Quote quotedMessageProps: quotedMessage - ? getPropsForQuote( - quotedMessage, + ? getPropsForQuote(quotedMessage, { conversationSelector, - getUserConversationId(state) - ) + ourConversationId: getUserConversationId(state), + }) : undefined, onClickQuotedMessage: () => onClickQuotedMessage(quotedMessage?.quote?.messageId),