Optimize Message rendering

This commit is contained in:
Fedor Indutny 2022-12-19 14:33:55 -08:00 committed by GitHub
parent 2d6f13a8c5
commit 5a98fc2f4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 242 additions and 133 deletions

View file

@ -0,0 +1,55 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable no-console */
import React from 'react';
export abstract class PureComponentProfiler<
Props extends Record<string, unknown>,
State extends Record<string, unknown>
> extends React.Component<Props, State> {
public override shouldComponentUpdate(
nextProps: Props,
nextState: State
): boolean {
console.group(`PureComponentProfiler(${this.props.id})`);
const propKeys = new Set([
...Object.keys(nextProps),
...Object.keys(this.props),
]);
const stateKeys = new Set([
...Object.keys(nextState ?? {}),
...Object.keys(this.state ?? {}),
]);
let result = false;
for (const key of propKeys) {
if (nextProps[key] !== this.props[key]) {
console.error(
`propUpdated(${key})`,
this.props[key],
'=>',
nextProps[key]
);
result = true;
}
}
for (const key of stateKeys) {
if (nextState[key] !== this.state[key]) {
console.error(
`stateUpdated(${key}):`,
this.state[key],
'=>',
nextState[key]
);
result = true;
}
}
console.groupEnd();
return result;
}
}

View file

@ -584,7 +584,7 @@ function ReplyOrReactionMessage({
conversationType="group"
direction="incoming"
deletedForEveryone={reply.deletedForEveryone}
menu={undefined}
renderMenu={undefined}
onContextMenu={onContextMenu}
getPreferredBadge={getPreferredBadge}
i18n={i18n}

View file

@ -92,7 +92,7 @@ import { Emojify } from './Emojify';
import { getPaymentEventDescription } from '../../messages/helpers';
import { PanelType } from '../../types/Panels';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
delivered: 24,
@ -274,8 +274,10 @@ export type PropsData = {
isMessageRequestAccepted: boolean;
bodyRanges?: HydratedBodyRangesType;
menu: JSX.Element | undefined;
renderMenu?: () => JSX.Element | undefined;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
item?: never;
};
export type PropsHousekeeping = {
@ -2531,7 +2533,7 @@ export class Message extends React.PureComponent<Props, State> {
isSticker,
shouldCollapseAbove,
shouldCollapseBelow,
menu,
renderMenu,
onKeyDown,
} = this.props;
const { expired, expiring, isSelected, imageBroken } = this.state;
@ -2565,7 +2567,7 @@ export class Message extends React.PureComponent<Props, State> {
{this.renderError()}
{this.renderAvatar()}
{this.renderContainer()}
{menu}
{renderMenu?.()}
</div>
);
}

View file

@ -37,7 +37,7 @@ const defaultMessage: MessageDataPropsType = {
direction: 'incoming',
id: 'my-message',
renderingContext: 'storybook',
menu: undefined,
renderMenu: undefined,
isBlocked: false,
isMessageRequestAccepted: true,
previews: [],

View file

@ -324,7 +324,7 @@ export class MessageDetail extends React.Component<Props> {
contactNameColor={contactNameColor}
containerElementRef={this.messageContainerRef}
containerWidthBreakpoint={WidthBreakpoint.Wide}
menu={undefined}
renderMenu={undefined}
disableScroll
displayLimit={Number.MAX_SAFE_INTEGER}
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}

View file

@ -214,6 +214,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
shouldRenderDateHeader,
startCallingLobby,
theme,
...reducedProps
} = this.props;
if (!item) {
@ -230,7 +231,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
if (item.type === 'message') {
itemContents = (
<TimelineMessage
{...this.props}
{...reducedProps}
{...item.data}
isSelected={isSelected}
shouldCollapseAbove={shouldCollapseAbove}
@ -247,7 +248,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
if (item.type === 'unsupportedMessage') {
notification = (
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
<UnsupportedMessage {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'callHistory') {
notification = (
@ -262,26 +263,26 @@ export class TimelineItem extends React.PureComponent<PropsType> {
);
} else if (item.type === 'chatSessionRefreshed') {
notification = (
<ChatSessionRefreshedNotification {...this.props} i18n={i18n} />
<ChatSessionRefreshedNotification {...reducedProps} i18n={i18n} />
);
} else if (item.type === 'deliveryIssue') {
notification = (
<DeliveryIssueNotification
{...item.data}
{...this.props}
{...reducedProps}
i18n={i18n}
/>
);
} else if (item.type === 'timerNotification') {
notification = (
<TimerNotification {...this.props} {...item.data} i18n={i18n} />
<TimerNotification {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'universalTimerNotification') {
notification = renderUniversalTimerNotification();
} else if (item.type === 'changeNumberNotification') {
notification = (
<ChangeNumberNotification
{...this.props}
{...reducedProps}
{...item.data}
i18n={i18n}
/>
@ -289,7 +290,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
} else if (item.type === 'safetyNumberNotification') {
notification = (
<SafetyNumberNotification
{...this.props}
{...reducedProps}
{...item.data}
i18n={i18n}
/>
@ -297,27 +298,33 @@ export class TimelineItem extends React.PureComponent<PropsType> {
} else if (item.type === 'verificationNotification') {
notification = (
<VerificationNotification
{...this.props}
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'groupNotification') {
notification = (
<GroupNotification {...this.props} {...item.data} i18n={i18n} />
<GroupNotification {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'groupV2Change') {
notification = (
<GroupV2Change {...this.props} {...item.data} i18n={i18n} />
<GroupV2Change {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'groupV1Migration') {
notification = (
<GroupV1Migration {...this.props} {...item.data} i18n={i18n} />
<GroupV1Migration
{...reducedProps}
{...item.data}
i18n={i18n}
getPreferredBadge={getPreferredBadge}
theme={theme}
/>
);
} else if (item.type === 'conversationMerge') {
notification = (
<ConversationMergeNotification
{...this.props}
{...reducedProps}
{...item.data}
i18n={i18n}
/>
@ -325,17 +332,19 @@ export class TimelineItem extends React.PureComponent<PropsType> {
} else if (item.type === 'phoneNumberDiscovery') {
notification = (
<PhoneNumberDiscoveryNotification
{...this.props}
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'resetSessionNotification') {
notification = <ResetSessionNotification {...this.props} i18n={i18n} />;
notification = (
<ResetSessionNotification {...reducedProps} i18n={i18n} />
);
} else if (item.type === 'profileChange') {
notification = (
<ProfileChangeNotification
{...this.props}
{...reducedProps}
{...item.data}
i18n={i18n}
/>
@ -343,7 +352,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
} else if (item.type === 'paymentEvent') {
notification = (
<PaymentEventNotification
{...this.props}
{...reducedProps}
{...item.data}
i18n={i18n}
/>

View file

@ -3,7 +3,7 @@
import classNames from 'classnames';
import { noop } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { Ref } from 'react';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import ReactDOM, { createPortal } from 'react-dom';
@ -106,6 +106,8 @@ export function TimelineMessage(props: Props): JSX.Element {
showMessageDetail,
text,
timestamp,
kickOffAttachmentDownload,
saveAttachment,
} = props;
const [reactionPickerRoot, setReactionPickerRoot] = useState<
@ -116,27 +118,28 @@ export function TimelineMessage(props: Props): JSX.Element {
const isWindowWidthNotNarrow =
containerWidthBreakpoint !== WidthBreakpoint.Narrow;
function popperPreventOverflowModifier(): Partial<PreventOverflowModifier> {
return {
name: 'preventOverflow',
options: {
altAxis: true,
boundary: containerElementRef.current || undefined,
padding: {
bottom: 16,
left: 8,
right: 8,
top: 16,
const popperPreventOverflowModifier =
useCallback((): Partial<PreventOverflowModifier> => {
return {
name: 'preventOverflow',
options: {
altAxis: true,
boundary: containerElementRef.current || undefined,
padding: {
bottom: 16,
left: 8,
right: 8,
top: 16,
},
},
},
};
}
};
}, [containerElementRef]);
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
const triggerId = String(id || `${author.id}-${timestamp}`);
const toggleReactionPicker = React.useCallback(
const toggleReactionPicker = useCallback(
(onlyRemove = false): void => {
if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot);
@ -173,42 +176,46 @@ export function TimelineMessage(props: Props): JSX.Element {
};
});
const openGenericAttachment = (event?: React.MouseEvent): void => {
const { kickOffAttachmentDownload, saveAttachment } = props;
const openGenericAttachment = useCallback(
(event?: React.MouseEvent): void => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!attachments || attachments.length !== 1) {
return;
}
if (!attachments || attachments.length !== 1) {
return;
}
const attachment = attachments[0];
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
return;
}
const attachment = attachments[0];
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
return;
}
saveAttachment(attachment, timestamp);
},
[kickOffAttachmentDownload, saveAttachment, attachments, id, timestamp]
);
saveAttachment(attachment, timestamp);
};
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
return;
}
if (event.target instanceof HTMLAnchorElement) {
return;
}
if (menuTriggerRef.current) {
menuTriggerRef.current.handleContextClick(event);
}
};
const handleContextMenu = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>): void => {
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
return;
}
if (event.target instanceof HTMLAnchorElement) {
return;
}
if (menuTriggerRef.current) {
menuTriggerRef.current.handleContextClick(event);
}
},
[menuTriggerRef]
);
const canForward =
!isTapToView && !deletedForEveryone && !giftBadge && !contact && !payment;
@ -229,11 +236,18 @@ export function TimelineMessage(props: Props): JSX.Element {
? openGenericAttachment
: undefined;
const handleReplyToMessage = canReply
? () => setQuoteByMessageId(conversationId, id)
: undefined;
const handleReplyToMessage = useCallback(() => {
if (!canReply) {
return;
}
setQuoteByMessageId(conversationId, id);
}, [canReply, conversationId, id, setQuoteByMessageId]);
const handleReact = canReact ? () => toggleReactionPicker() : undefined;
const handleReact = useCallback(() => {
if (canReact) {
toggleReactionPicker();
}
}, [canReact, toggleReactionPicker]);
const [hasDOEConfirmation, setHasDOEConfirmation] = useState(false);
const [hasDeleteConfirmation, setHasDeleteConfirmation] = useState(false);
@ -252,6 +266,71 @@ export function TimelineMessage(props: Props): JSX.Element {
};
}, [isSelected, toggleReactionPickerKeyboard]);
const renderMenu = useCallback(() => {
return (
<Manager>
<MessageMenu
i18n={i18n}
triggerId={triggerId}
isWindowWidthNotNarrow={isWindowWidthNotNarrow}
direction={direction}
menuTriggerRef={menuTriggerRef}
showMenu={handleContextMenu}
onDownload={handleDownload}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
/>
{reactionPickerRoot &&
createPortal(
<Popper
placement="top"
modifiers={[
offsetDistanceModifier(4),
popperPreventOverflowModifier(),
]}
>
{({ ref, style }) =>
renderReactionPicker({
ref,
style,
selected: selectedReaction,
onClose: toggleReactionPicker,
onPick: emoji => {
toggleReactionPicker(true);
reactToMessage(id, {
emoji,
remove: emoji === selectedReaction,
});
},
renderEmojiPicker,
})
}
</Popper>,
reactionPickerRoot
)}
</Manager>
);
}, [
i18n,
triggerId,
isWindowWidthNotNarrow,
direction,
menuTriggerRef,
handleContextMenu,
handleDownload,
handleReplyToMessage,
handleReact,
reactionPickerRoot,
popperPreventOverflowModifier,
renderReactionPicker,
selectedReaction,
reactToMessage,
renderEmojiPicker,
toggleReactionPicker,
id,
]);
return (
<>
{hasDOEConfirmation && canDeleteForEveryone && (
@ -305,49 +384,7 @@ export function TimelineMessage(props: Props): JSX.Element {
{...props}
renderingContext="conversation/TimelineItem"
onContextMenu={handleContextMenu}
menu={
<Manager>
<MessageMenu
i18n={i18n}
triggerId={triggerId}
isWindowWidthNotNarrow={isWindowWidthNotNarrow}
direction={direction}
menuTriggerRef={menuTriggerRef}
showMenu={handleContextMenu}
onDownload={handleDownload}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
/>
{reactionPickerRoot &&
createPortal(
<Popper
placement="top"
modifiers={[
offsetDistanceModifier(4),
popperPreventOverflowModifier(),
]}
>
{({ ref, style }) =>
renderReactionPicker({
ref,
style,
selected: selectedReaction,
onClose: toggleReactionPicker,
onPick: emoji => {
toggleReactionPicker(true);
reactToMessage(id, {
emoji,
remove: emoji === selectedReaction,
});
},
renderEmojiPicker,
})
}
</Popper>,
reactionPickerRoot
)}
</Manager>
}
renderMenu={renderMenu}
/>
</div>

View file

@ -4101,6 +4101,10 @@ export class ConversationModel extends window.Backbone
// Perform asynchronous tasks before entering the batching mode
await this.beforeAddSingleMessage(model);
if (sticker) {
await addStickerPackReference(model.id, sticker.packId);
}
this.isInReduxBatch = true;
batchDispatch(() => {
try {
@ -4146,10 +4150,6 @@ export class ConversationModel extends window.Backbone
}
});
if (sticker) {
await addStickerPackReference(model.id, sticker.packId);
}
const renderDuration = Date.now() - renderStart;
if (renderDuration > SEND_REPORTING_THRESHOLD_MS) {

View file

@ -226,20 +226,22 @@ function sendMultiMediaMessage(
toastType,
},
});
dispatch(setComposerDisabledState(false));
return;
}
if (
!message.length &&
!hasDraftAttachments(conversation.attributes.draftAttachments, {
includePending: false,
}) &&
!voiceNoteAttachment
) {
dispatch(setComposerDisabledState(false));
return;
}
try {
if (
!message.length &&
!hasDraftAttachments(conversation.attributes.draftAttachments, {
includePending: false,
}) &&
!voiceNoteAttachment
) {
return;
}
let attachments: Array<AttachmentType> = [];
if (voiceNoteAttachment) {
attachments = [voiceNoteAttachment];
@ -285,6 +287,7 @@ function sendMultiMediaMessage(
undefined
);
dispatch(resetComposer());
dispatch(setComposerDisabledState(false));
},
}
);
@ -293,7 +296,6 @@ function sendMultiMediaMessage(
'Error pulling attached files before send',
Errors.toLogFormat(error)
);
} finally {
dispatch(setComposerDisabledState(false));
}
};

View file

@ -291,6 +291,10 @@ export function reducer(
if (action.type === 'items/PUT_EXTERNAL') {
const { payload } = action;
if (state[payload.key] === payload.value) {
return state;
}
return {
...state,
[payload.key]: payload.value,