diff --git a/ts/components/PureComponentProfiler.tsx b/ts/components/PureComponentProfiler.tsx new file mode 100644 index 0000000000..f05822b0d6 --- /dev/null +++ b/ts/components/PureComponentProfiler.tsx @@ -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, + State extends Record +> extends React.Component { + 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; + } +} diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index f3697a6d69..74cac3a0c9 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -584,7 +584,7 @@ function ReplyOrReactionMessage({ conversationType="group" direction="incoming" deletedForEveryone={reply.deletedForEveryone} - menu={undefined} + renderMenu={undefined} onContextMenu={onContextMenu} getPreferredBadge={getPreferredBadge} i18n={i18n} diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index af65748e5e..d0e47a2fee 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -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 = { 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) => void; + + item?: never; }; export type PropsHousekeeping = { @@ -2531,7 +2533,7 @@ export class Message extends React.PureComponent { 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 { {this.renderError()} {this.renderAvatar()} {this.renderContainer()} - {menu} + {renderMenu?.()} ); } diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 62599ca09e..a762257636 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -37,7 +37,7 @@ const defaultMessage: MessageDataPropsType = { direction: 'incoming', id: 'my-message', renderingContext: 'storybook', - menu: undefined, + renderMenu: undefined, isBlocked: false, isMessageRequestAccepted: true, previews: [], diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 4bd714075c..935e2b8736 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -324,7 +324,7 @@ export class MessageDetail extends React.Component { contactNameColor={contactNameColor} containerElementRef={this.messageContainerRef} containerWidthBreakpoint={WidthBreakpoint.Wide} - menu={undefined} + renderMenu={undefined} disableScroll displayLimit={Number.MAX_SAFE_INTEGER} showLightboxForViewOnceMedia={showLightboxForViewOnceMedia} diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 64ff357126..4c172f0b25 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -214,6 +214,7 @@ export class TimelineItem extends React.PureComponent { shouldRenderDateHeader, startCallingLobby, theme, + ...reducedProps } = this.props; if (!item) { @@ -230,7 +231,7 @@ export class TimelineItem extends React.PureComponent { if (item.type === 'message') { itemContents = ( { if (item.type === 'unsupportedMessage') { notification = ( - + ); } else if (item.type === 'callHistory') { notification = ( @@ -262,26 +263,26 @@ export class TimelineItem extends React.PureComponent { ); } else if (item.type === 'chatSessionRefreshed') { notification = ( - + ); } else if (item.type === 'deliveryIssue') { notification = ( ); } else if (item.type === 'timerNotification') { notification = ( - + ); } else if (item.type === 'universalTimerNotification') { notification = renderUniversalTimerNotification(); } else if (item.type === 'changeNumberNotification') { notification = ( @@ -289,7 +290,7 @@ export class TimelineItem extends React.PureComponent { } else if (item.type === 'safetyNumberNotification') { notification = ( @@ -297,27 +298,33 @@ export class TimelineItem extends React.PureComponent { } else if (item.type === 'verificationNotification') { notification = ( ); } else if (item.type === 'groupNotification') { notification = ( - + ); } else if (item.type === 'groupV2Change') { notification = ( - + ); } else if (item.type === 'groupV1Migration') { notification = ( - + ); } else if (item.type === 'conversationMerge') { notification = ( @@ -325,17 +332,19 @@ export class TimelineItem extends React.PureComponent { } else if (item.type === 'phoneNumberDiscovery') { notification = ( ); } else if (item.type === 'resetSessionNotification') { - notification = ; + notification = ( + + ); } else if (item.type === 'profileChange') { notification = ( @@ -343,7 +352,7 @@ export class TimelineItem extends React.PureComponent { } else if (item.type === 'paymentEvent') { notification = ( diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index c2190a3317..da62c97fdb 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -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 { - return { - name: 'preventOverflow', - options: { - altAxis: true, - boundary: containerElementRef.current || undefined, - padding: { - bottom: 16, - left: 8, - right: 8, - top: 16, + const popperPreventOverflowModifier = + useCallback((): Partial => { + 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): 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): 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 ( + + + {reactionPickerRoot && + createPortal( + + {({ ref, style }) => + renderReactionPicker({ + ref, + style, + selected: selectedReaction, + onClose: toggleReactionPicker, + onPick: emoji => { + toggleReactionPicker(true); + reactToMessage(id, { + emoji, + remove: emoji === selectedReaction, + }); + }, + renderEmojiPicker, + }) + } + , + reactionPickerRoot + )} + + ); + }, [ + 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={ - - - {reactionPickerRoot && - createPortal( - - {({ ref, style }) => - renderReactionPicker({ - ref, - style, - selected: selectedReaction, - onClose: toggleReactionPicker, - onPick: emoji => { - toggleReactionPicker(true); - reactToMessage(id, { - emoji, - remove: emoji === selectedReaction, - }); - }, - renderEmojiPicker, - }) - } - , - reactionPickerRoot - )} - - } + renderMenu={renderMenu} /> diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 11e34d4938..2f975ca505 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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) { diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index 08eaf274cf..7ba1407ca0 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -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 = []; 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)); } }; diff --git a/ts/state/ducks/items.ts b/ts/state/ducks/items.ts index 6f5b4c91a8..d451356ea0 100644 --- a/ts/state/ducks/items.ts +++ b/ts/state/ducks/items.ts @@ -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,