diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 1c9097b87..ed4b0efec 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -6,8 +6,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { get } from 'lodash'; import classNames from 'classnames'; import type { - BodyRangeType, - BodyRangesType, + DraftBodyRangesType, LocalizerType, ThemeType, } from '../types/Util'; @@ -116,7 +115,7 @@ export type OwnProps = Readonly<{ onSelectMediaQuality(isHQ: boolean): unknown; onSendMessage(options: { draftAttachments?: ReadonlyArray; - mentions?: BodyRangesType; + mentions?: DraftBodyRangesType; message?: string; timestamp?: number; voiceNoteAttachment?: InMemoryAttachmentDraftType; @@ -276,7 +275,7 @@ export const CompositionArea = ({ }, [inputApiRef, setLarge]); const handleSubmit = useCallback( - (message: string, mentions: Array, timestamp: number) => { + (message: string, mentions: DraftBodyRangesType, timestamp: number) => { emojiButtonRef.current?.close(); onSendMessage({ draftAttachments, diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 4dea728cc..35fb20420 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -14,7 +14,11 @@ import { MentionCompletion } from '../quill/mentions/completion'; import { EmojiBlot, EmojiCompletion } from '../quill/emoji'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import { convertShortName } from './emoji/lib'; -import type { LocalizerType, BodyRangeType, ThemeType } from '../types/Util'; +import type { + LocalizerType, + DraftBodyRangesType, + ThemeType, +} from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import { isValidUuid } from '../types/UUID'; @@ -71,7 +75,7 @@ export type Props = Readonly<{ inputApi?: React.MutableRefObject; skinTone?: EmojiPickDataType['skinTone']; draftText?: string; - draftBodyRanges?: Array; + draftBodyRanges?: DraftBodyRangesType; moduleClassName?: string; theme: ThemeType; placeholder?: string; @@ -80,14 +84,14 @@ export type Props = Readonly<{ onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?( messageText: string, - bodyRanges: Array, + bodyRanges: DraftBodyRangesType, caretLocation?: number ): unknown; onTextTooLong(): unknown; onPickEmoji(o: EmojiPickDataType): unknown; onSubmit( message: string, - mentions: Array, + mentions: DraftBodyRangesType, timestamp: number ): unknown; onScroll?: (ev: React.UIEvent) => void; @@ -143,7 +147,7 @@ export function CompositionInput(props: Props): React.ReactElement { const generateDelta = ( text: string, - bodyRanges: Array + bodyRanges: DraftBodyRangesType ): Delta => { const initialOps = [{ insert: text }]; const opsWithMentions = insertMentionOps(initialOps, bodyRanges); @@ -152,7 +156,7 @@ export function CompositionInput(props: Props): React.ReactElement { return new Delta(opsWithEmojis); }; - const getTextAndMentions = (): [string, Array] => { + const getTextAndMentions = (): [string, DraftBodyRangesType] => { const quill = quillRef.current; if (quill === undefined) { diff --git a/ts/components/CompositionTextArea.tsx b/ts/components/CompositionTextArea.tsx index ef9395f89..a5f509d65 100644 --- a/ts/components/CompositionTextArea.tsx +++ b/ts/components/CompositionTextArea.tsx @@ -9,7 +9,7 @@ import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; import type { InputApi } from './CompositionInput'; import { CompositionInput } from './CompositionInput'; import { EmojiButton } from './emoji/EmojiButton'; -import type { BodyRangeType, ThemeType } from '../types/Util'; +import type { DraftBodyRangesType, ThemeType } from '../types/Util'; import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import * as grapheme from '../util/grapheme'; @@ -24,13 +24,13 @@ export type CompositionTextAreaProps = { onPickEmoji: (e: EmojiPickDataType) => void; onChange: ( messageText: string, - bodyRanges: Array, + bodyRanges: DraftBodyRangesType, caretLocation?: number | undefined ) => void; onSetSkinTone: (tone: number) => void; onSubmit: ( message: string, - mentions: Array, + mentions: DraftBodyRangesType, timestamp: number ) => void; onTextTooLong: () => void; @@ -88,7 +88,7 @@ export const CompositionTextArea = ({ const handleChange = React.useCallback( ( newValue: string, - bodyRanges: Array, + bodyRanges: DraftBodyRangesType, caretLocation?: number | undefined ) => { const inputEl = inputApiRef.current; diff --git a/ts/components/ForwardMessageModal.tsx b/ts/components/ForwardMessageModal.tsx index 64c97eeba..68792a4d2 100644 --- a/ts/components/ForwardMessageModal.tsx +++ b/ts/components/ForwardMessageModal.tsx @@ -24,7 +24,11 @@ import { ConversationList, RowType } from './ConversationList'; import type { ConversationType } from '../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; -import type { BodyRangeType, LocalizerType, ThemeType } from '../types/Util'; +import type { + DraftBodyRangesType, + LocalizerType, + ThemeType, +} from '../types/Util'; import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea'; import { ModalHost } from './ModalHost'; import { SearchInput } from './SearchInput'; @@ -54,7 +58,7 @@ export type DataPropsType = { onClose: () => void; onEditorStateChange: ( messageText: string, - bodyRanges: Array, + bodyRanges: DraftBodyRangesType, caretLocation?: number ) => unknown; theme: ThemeType; diff --git a/ts/components/Intl.tsx b/ts/components/Intl.tsx index 29bc22264..e77b4ecda 100644 --- a/ts/components/Intl.tsx +++ b/ts/components/Intl.tsx @@ -27,13 +27,11 @@ export type Props = { renderText?: RenderTextCallbackType; }; -export class Intl extends React.Component { - public static defaultProps: Partial = { - renderText: ({ text, key }) => ( - {text} - ), - }; +const defaultRenderText: RenderTextCallbackType = ({ text, key }) => ( + {text} +); +export class Intl extends React.Component { public getComponent( index: number, placeholderName: string, @@ -74,7 +72,7 @@ export class Intl extends React.Component { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types public override render() { - const { components, id, i18n, renderText } = this.props; + const { components, id, i18n, renderText = defaultRenderText } = this.props; if (!id) { log.error('Error: Intl id prop not provided'); @@ -96,12 +94,6 @@ export class Intl extends React.Component { > = []; const FIND_REPLACEMENTS = /\$([^$]+)\$/g; - // We have to do this, because renderText is not required in our Props object, - // but it is always provided via defaultProps. - if (!renderText) { - return null; - } - if (Array.isArray(components) && components.length > 1) { throw new Error( 'Array syntax is not supported with more than one placeholder' diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 98162cfa1..13540988f 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -10,7 +10,7 @@ import React, { useState, } from 'react'; import classNames from 'classnames'; -import type { BodyRangeType, LocalizerType } from '../types/Util'; +import type { DraftBodyRangesType, LocalizerType } from '../types/Util'; import type { ContextMenuOptionType } from './ContextMenu'; import type { ConversationType } from '../state/ducks/conversations'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; @@ -80,7 +80,7 @@ export type PropsType = { onReactToStory: (emoji: string, story: StoryViewType) => unknown; onReplyToStory: ( message: string, - mentions: Array, + mentions: DraftBodyRangesType, timestamp: number, story: StoryViewType ) => unknown; diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 1de1cbc00..bbc050382 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -10,8 +10,10 @@ import React, { } from 'react'; import classNames from 'classnames'; import { usePopper } from 'react-popper'; +import { noop } from 'lodash'; + import type { AttachmentType } from '../types/Attachment'; -import type { BodyRangeType, LocalizerType } from '../types/Util'; +import type { DraftBodyRangesType, LocalizerType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { InputApi } from './CompositionInput'; @@ -56,7 +58,8 @@ const MESSAGE_DEFAULT_PROPS = { markAttachmentAsCorrupted: shouldNeverBeCalled, markViewed: shouldNeverBeCalled, messageExpanded: shouldNeverBeCalled, - openConversation: shouldNeverBeCalled, + // Called when clicking mention, but shouldn't do anything. + openConversation: noop, openGiftBadge: shouldNeverBeCalled, openLink: shouldNeverBeCalled, previews: [], @@ -90,7 +93,7 @@ export type PropsType = { onReact: (emoji: string) => unknown; onReply: ( message: string, - mentions: Array, + mentions: DraftBodyRangesType, timestamp: number ) => unknown; onSetSkinTone: (tone: number) => unknown; @@ -315,6 +318,7 @@ export const StoryViewsNRepliesModal = ({ key={reply.id} i18n={i18n} reply={reply} + reactionEmoji={reply.reactionEmoji} getPreferredBadge={getPreferredBadge} /> ) : ( @@ -504,12 +508,14 @@ export const StoryViewsNRepliesModal = ({ type ReactionProps = { i18n: LocalizerType; reply: ReplyType; + reactionEmoji: string; getPreferredBadge: PreferredBadgeSelectorType; }; const Reaction = ({ i18n, reply, + reactionEmoji, getPreferredBadge, }: ReactionProps): JSX.Element => { // TODO: DESKTOP-4503 - reactions delete/doe @@ -546,7 +552,7 @@ const Reaction = ({ /> - + ); }; diff --git a/ts/components/conversation/AddNewLines.tsx b/ts/components/conversation/AddNewLines.tsx index 813d9ef6f..38a58102f 100644 --- a/ts/components/conversation/AddNewLines.tsx +++ b/ts/components/conversation/AddNewLines.tsx @@ -11,27 +11,18 @@ export type Props = { renderNonNewLine?: RenderTextCallbackType; }; -export class AddNewLines extends React.Component { - public static defaultProps: Partial = { - renderNonNewLine: ({ text }) => text, - }; +const defaultRenderNonNewLine: RenderTextCallbackType = ({ text }) => text; +export class AddNewLines extends React.Component { public override render(): | JSX.Element | string | null | Array { - const { text, renderNonNewLine } = this.props; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const results: Array = []; + const { text, renderNonNewLine = defaultRenderNonNewLine } = this.props; + const results: Array = []; const FIND_NEWLINES = /\n/g; - // We have to do this, because renderNonNewLine is not required in our Props object, - // but it is always provided via defaultProps. - if (!renderNonNewLine) { - return null; - } - let match = FIND_NEWLINES.exec(text); let last = 0; let count = 1; diff --git a/ts/components/conversation/AtMentionify.stories.tsx b/ts/components/conversation/AtMentionify.stories.tsx index 9c4f4d1ef..10dd21e16 100644 --- a/ts/components/conversation/AtMentionify.stories.tsx +++ b/ts/components/conversation/AtMentionify.stories.tsx @@ -43,18 +43,21 @@ export const MultipleMentions = (): JSX.Element => { length: 1, mentionUuid: 'abc', replacementText: 'Professor Farnsworth', + conversationID: 'x', }, { start: 2, length: 1, mentionUuid: 'def', replacementText: 'Philip J Fry', + conversationID: 'x', }, { start: 0, length: 1, mentionUuid: 'xyz', replacementText: 'Yancy Fry', + conversationID: 'x', }, ]; const props = createProps({ @@ -77,18 +80,21 @@ export const ComplexMentions = (): JSX.Element => { length: 1, mentionUuid: 'ioe', replacementText: 'Cereal Killer', + conversationID: 'x', }, { start: 78, length: 1, mentionUuid: 'fdr', replacementText: 'Acid Burn', + conversationID: 'x', }, { start: 4, length: 1, mentionUuid: 'ope', replacementText: 'Zero Cool', + conversationID: 'x', }, ]; diff --git a/ts/components/conversation/AtMentionify.tsx b/ts/components/conversation/AtMentionify.tsx index 82501064e..1943c58c9 100644 --- a/ts/components/conversation/AtMentionify.tsx +++ b/ts/components/conversation/AtMentionify.tsx @@ -4,10 +4,14 @@ import React from 'react'; import { sortBy } from 'lodash'; import { Emojify } from './Emojify'; -import type { BodyRangesType } from '../../types/Util'; +import type { + BodyRangesType, + HydratedBodyRangeType, + HydratedBodyRangesType, +} from '../../types/Util'; export type Props = { - bodyRanges?: BodyRangesType; + bodyRanges?: HydratedBodyRangesType; direction?: 'incoming' | 'outgoing'; openConversation?: (conversationId: string, messageId?: string) => void; text: string; @@ -28,7 +32,7 @@ export const AtMentionify = ({ let match = MENTIONS_REGEX.exec(text); let last = 0; - const rangeStarts = new Map(); + const rangeStarts = new Map(); bodyRanges.forEach(range => { rangeStarts.set(range.start, range); }); @@ -49,7 +53,7 @@ export const AtMentionify = ({ className={`MessageBody__at-mention MessageBody__at-mention--${direction}`} key={range.start} onClick={() => { - if (openConversation && range.conversationID) { + if (openConversation) { openConversation(range.conversationID); } }} @@ -57,8 +61,7 @@ export const AtMentionify = ({ if ( e.target === e.currentTarget && e.keyCode === 13 && - openConversation && - range.conversationID + openConversation ) { openConversation(range.conversationID); } diff --git a/ts/components/conversation/ChangeNumberNotification.tsx b/ts/components/conversation/ChangeNumberNotification.tsx index 2024ec4a7..7fccedd82 100644 --- a/ts/components/conversation/ChangeNumberNotification.tsx +++ b/ts/components/conversation/ChangeNumberNotification.tsx @@ -32,7 +32,7 @@ export const ChangeNumberNotification: React.FC = props => { , + sender: , }} i18n={i18n} /> diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.tsx index cb815364b..8e82594fc 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialog.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialog.tsx @@ -308,6 +308,22 @@ export const ContactSpoofingReviewDialog: FunctionComponent< conversationInfo.conversation.profileName || conversationInfo.conversation.title; + let callout: JSX.Element | undefined; + if (oldName && oldName !== newName) { + callout = ( +
+ , + newName: , + }} + /> +
+ ); + } + return ( <> {index !== 0 &&
} @@ -318,18 +334,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent< i18n={i18n} theme={theme} > - {Boolean(oldName) && oldName !== newName && ( -
- , - newName: , - }} - /> -
- )} + {callout} {button && (
{button} diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx index a34fd56bd..d0e1ca1e2 100644 --- a/ts/components/conversation/Emojify.tsx +++ b/ts/components/conversation/Emojify.tsx @@ -49,19 +49,15 @@ export type Props = { renderNonEmoji?: RenderTextCallbackType; }; +const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text; + export class Emojify extends React.Component { - public static defaultProps: Partial = { - renderNonEmoji: ({ text }) => text, - }; - public override render(): null | Array { - const { text, sizeClass, renderNonEmoji } = this.props; - - // We have to do this, because renderNonEmoji is not required in our Props object, - // but it is always provided via defaultProps. - if (!renderNonEmoji) { - return null; - } + const { + text, + sizeClass, + renderNonEmoji = defaultRenderNonEmoji, + } = this.props; return splitByEmoji(text).map(({ type, value: match }, index) => { if (type === 'emoji') { diff --git a/ts/components/conversation/Linkify.tsx b/ts/components/conversation/Linkify.tsx index f5cadfcd5..163c86e70 100644 --- a/ts/components/conversation/Linkify.tsx +++ b/ts/components/conversation/Linkify.tsx @@ -321,28 +321,20 @@ export type Props = { const SUPPORTED_PROTOCOLS = /^(http|https):/i; -export class Linkify extends React.Component { - public static defaultProps: Partial = { - renderNonLink: ({ text }) => text, - }; +const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text; +export class Linkify extends React.Component { public override render(): | JSX.Element | string | null | Array { - const { text, renderNonLink } = this.props; + const { text, renderNonLink = defaultRenderNonLink } = this.props; if (!shouldLinkifyMessage(text)) { return text; } - // We have to do this, because renderNonLink is not required in our Props object, - // but it is always provided via defaultProps. - if (!renderNonLink) { - return null; - } - const chunkData: Array<{ chunk: string; matchData: ReadonlyArray; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index c2595ffb4..334c0074d 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -63,7 +63,7 @@ import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary'; import { isFileDangerous } from '../../util/isFileDangerous'; import { missingCaseError } from '../../util/missingCaseError'; import type { - BodyRangesType, + HydratedBodyRangesType, LocalizerType, ThemeType, } from '../../types/Util'; @@ -228,7 +228,7 @@ export type PropsData = { authorProfileName?: string; authorTitle: string; authorName?: string; - bodyRanges?: BodyRangesType; + bodyRanges?: HydratedBodyRangesType; referencedMessageNotFound: boolean; isViewOnce: boolean; isGiftBadge: boolean; @@ -261,7 +261,7 @@ export type PropsData = { canDeleteForEveryone: boolean; isBlocked: boolean; isMessageRequestAccepted: boolean; - bodyRanges?: BodyRangesType; + bodyRanges?: HydratedBodyRangesType; menu: JSX.Element | undefined; onKeyDown?: (event: React.KeyboardEvent) => void; diff --git a/ts/components/conversation/MessageBody.stories.tsx b/ts/components/conversation/MessageBody.stories.tsx index 56eb7db9a..87b0e4705 100644 --- a/ts/components/conversation/MessageBody.stories.tsx +++ b/ts/components/conversation/MessageBody.stories.tsx @@ -114,6 +114,7 @@ export const Mention = (): JSX.Element => { length: 1, mentionUuid: 'tuv', replacementText: 'Bender B Rodriguez 🤖', + conversationID: 'x', }, ], text: 'Like \uFFFC once said: My story is a lot like yours, only more interesting because it involves robots', @@ -135,18 +136,21 @@ export const MultipleMentions = (): JSX.Element => { length: 1, mentionUuid: 'def', replacementText: 'Philip J Fry', + conversationID: 'x', }, { start: 4, length: 1, mentionUuid: 'abc', replacementText: 'Professor Farnsworth', + conversationID: 'x', }, { start: 0, length: 1, mentionUuid: 'xyz', replacementText: 'Yancy Fry', + conversationID: 'x', }, ], text: '\uFFFC \uFFFC \uFFFC', @@ -168,18 +172,21 @@ export const ComplexMessageBody = (): JSX.Element => { length: 1, mentionUuid: 'wer', replacementText: 'Acid Burn', + conversationID: 'x', }, { start: 80, length: 1, mentionUuid: 'xox', replacementText: 'Cereal Killer', + conversationID: 'x', }, { start: 4, length: 1, mentionUuid: 'ldo', replacementText: 'Zero Cool', + conversationID: 'x', }, ], direction: 'outgoing', diff --git a/ts/components/conversation/MessageBody.tsx b/ts/components/conversation/MessageBody.tsx index 8311d590b..2a342df87 100644 --- a/ts/components/conversation/MessageBody.tsx +++ b/ts/components/conversation/MessageBody.tsx @@ -14,7 +14,7 @@ import { AddNewLines } from './AddNewLines'; import { Linkify } from './Linkify'; import type { - BodyRangesType, + HydratedBodyRangesType, LocalizerType, RenderTextCallbackType, } from '../../types/Util'; @@ -34,7 +34,7 @@ export type Props = { /** If set, links will be left alone instead of turned into clickable `` tags. */ disableLinks?: boolean; i18n: LocalizerType; - bodyRanges?: BodyRangesType; + bodyRanges?: HydratedBodyRangesType; onIncreaseTextLength?: () => unknown; openConversation?: OpenConversationActionType; kickOffBodyDownload?: () => void; diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 8b2170d1f..d169b2734 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -11,7 +11,7 @@ import * as GoogleChrome from '../../util/GoogleChrome'; import { MessageBody } from './MessageBody'; import type { AttachmentType, ThumbnailType } from '../../types/Attachment'; -import type { BodyRangesType, LocalizerType } from '../../types/Util'; +import type { HydratedBodyRangesType, LocalizerType } from '../../types/Util'; import type { ConversationColorType, CustomColorType, @@ -27,7 +27,7 @@ export type Props = { authorTitle: string; conversationColor: ConversationColorType; customColor?: CustomColorType; - bodyRanges?: BodyRangesType; + bodyRanges?: HydratedBodyRangesType; i18n: LocalizerType; isFromMe: boolean; isIncoming?: boolean; diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index d897a6845..352de89d8 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -1606,6 +1606,7 @@ Mentions.args = { length: 1, mentionUuid: 'zap', replacementText: 'Zapp Brannigan', + conversationID: 'x', }, ], text: '\uFFFC This Is It. The Moment We Should Have Trained For.', diff --git a/ts/components/conversation/media-gallery/DocumentListItem.tsx b/ts/components/conversation/media-gallery/DocumentListItem.tsx index 8a8b65141..d26700152 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.tsx @@ -19,12 +19,8 @@ type Props = { }; export class DocumentListItem extends React.Component { - public static defaultProps: Partial = { - shouldShowSeparator: true, - }; - public override render(): JSX.Element { - const { shouldShowSeparator } = this.props; + const { shouldShowSeparator = true } = this.props; return (
{ length: 1, mentionUuid: '0ca40892-7b1a-11eb-9439-0242ac130002', replacementText: 'Jin Sakai', + conversationID: 'x', start: 33, }, ], diff --git a/ts/components/conversationList/MessageBodyHighlight.tsx b/ts/components/conversationList/MessageBodyHighlight.tsx index 2417e790a..3e3af52f3 100644 --- a/ts/components/conversationList/MessageBodyHighlight.tsx +++ b/ts/components/conversationList/MessageBodyHighlight.tsx @@ -13,7 +13,7 @@ import { AddNewLines } from '../conversation/AddNewLines'; import type { SizeClassType } from '../emoji/lib'; import type { - BodyRangesType, + HydratedBodyRangesType, LocalizerType, RenderTextCallbackType, } from '../../types/Util'; @@ -21,7 +21,7 @@ import type { const CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__message-search-result-contents`; export type Props = { - bodyRanges: BodyRangesType; + bodyRanges: HydratedBodyRangesType; text: string; i18n: LocalizerType; }; diff --git a/ts/components/conversationList/MessageSearchResult.stories.tsx b/ts/components/conversationList/MessageSearchResult.stories.tsx index 096a17a50..e3b116835 100644 --- a/ts/components/conversationList/MessageSearchResult.stories.tsx +++ b/ts/components/conversationList/MessageSearchResult.stories.tsx @@ -206,12 +206,14 @@ export const Mention = (): JSX.Element => { length: 1, mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', replacementText: 'Shoe', + conversationID: 'x', start: 113, }, { length: 1, mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', replacementText: 'Shoe', + conversationID: 'x', start: 237, }, ], @@ -236,6 +238,7 @@ export const MentionRegexp = (): JSX.Element => { length: 1, mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', replacementText: 'RegExp', + conversationID: 'x', start: 0, }, ], @@ -260,6 +263,7 @@ export const MentionNoMatches = (): JSX.Element => { length: 1, mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', replacementText: 'Neo', + conversationID: 'x', start: 0, }, ], @@ -283,12 +287,14 @@ export const _MentionNoMatches = (): JSX.Element => { length: 1, mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', replacementText: 'Shoe', + conversationID: 'x', start: 113, }, { length: 1, mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', replacementText: 'Shoe', + conversationID: 'x', start: 237, }, ], @@ -313,12 +319,14 @@ export const DoubleMention = (): JSX.Element => { length: 1, mentionUuid: '9eb2eb65-992a-4909-a2a5-18c56bd7648f', replacementText: 'Alice', + conversationID: 'x', start: 4, }, { length: 1, mentionUuid: '755ec61b-1590-48da-b003-3e57b2b54448', replacementText: 'Bob', + conversationID: 'x', start: 6, }, ], diff --git a/ts/components/conversationList/MessageSearchResult.tsx b/ts/components/conversationList/MessageSearchResult.tsx index 1de30b507..8e77f2d33 100644 --- a/ts/components/conversationList/MessageSearchResult.tsx +++ b/ts/components/conversationList/MessageSearchResult.tsx @@ -10,7 +10,7 @@ import { ContactName } from '../conversation/ContactName'; import { assertDev } from '../../util/assert'; import type { - BodyRangesType, + HydratedBodyRangesType, LocalizerType, ThemeType, } from '../../types/Util'; @@ -31,7 +31,7 @@ export type PropsDataType = { snippet: string; body: string; - bodyRanges: BodyRangesType; + bodyRanges: HydratedBodyRangesType; from: Pick< ConversationType, @@ -82,8 +82,8 @@ const renderPerson = ( function getFilteredBodyRanges( snippet: string, body: string, - bodyRanges: BodyRangesType -): BodyRangesType { + bodyRanges: HydratedBodyRangesType +): HydratedBodyRangesType { if (!bodyRanges.length) { return []; } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 06fd03f50..d10b35f40 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -6,7 +6,7 @@ import * as Backbone from 'backbone'; import type { GroupV2ChangeType } from './groups'; -import type { BodyRangeType, BodyRangesType } from './types/Util'; +import type { DraftBodyRangesType, BodyRangesType } from './types/Util'; import type { CallHistoryDetailsFromDiskType } from './types/Calling'; import type { CustomColorType, ConversationColorType } from './types/Colors'; import type { DeviceType } from './textsecure/Types.d'; @@ -279,7 +279,7 @@ export type ConversationAttributesType = { firstUnregisteredAt?: number; draftChanged?: boolean; draftAttachments?: Array; - draftBodyRanges?: Array; + draftBodyRanges?: DraftBodyRangesType; draftTimestamp?: number | null; hideStory?: boolean; inbox_position?: number; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 07752cb10..fb558ff22 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -51,7 +51,7 @@ import * as expirationTimer from '../util/expirationTimer'; import { getUserLanguages } from '../util/userLanguages'; import type { ReactionType } from '../types/Reactions'; -import { UUID, UUIDKind } from '../types/UUID'; +import { isValidUuid, UUID, UUIDKind } from '../types/UUID'; import * as reactionUtil from '../reactions/util'; import * as Stickers from '../types/Stickers'; import * as Errors from '../types/errors'; @@ -2002,22 +2002,30 @@ export class MessageModel extends window.Backbone.Model { id, attachments: quote.attachments.slice(), - bodyRanges: quote.bodyRanges.map(({ start, length, mentionUuid }) => { - strictAssert( - start != null, - 'Received quote with a bodyRange.start == null' - ); - strictAssert( - length != null, - 'Received quote with a bodyRange.length == null' - ); + bodyRanges: quote.bodyRanges + .map(({ start, length, mentionUuid }) => { + strictAssert( + start != null, + 'Received quote with a bodyRange.start == null' + ); + strictAssert( + length != null, + 'Received quote with a bodyRange.length == null' + ); + if (!isValidUuid(mentionUuid)) { + log.warn( + `copyFromQuotedMessage: invalid mentionUuid ${mentionUuid}` + ); + return undefined; + } - return { - start, - length, - mentionUuid: dropNull(mentionUuid), - }; - }), + return { + start, + length, + mentionUuid, + }; + }) + .filter(isNotNil), // Just placeholder values for the fields referencedMessageNotFound: false, diff --git a/ts/quill/mentions/blot.tsx b/ts/quill/mentions/blot.tsx index 9d904c7bb..555695b83 100644 --- a/ts/quill/mentions/blot.tsx +++ b/ts/quill/mentions/blot.tsx @@ -35,7 +35,7 @@ export class MentionBlot extends Embed { const { uuid, title } = node.dataset; if (uuid === undefined || title === undefined) { throw new Error( - `Failed to make MentionBlot with uuid: ${uuid} and title: ${title}` + `Failed to make MentionBlot with uuid: ${uuid}, title: ${title}` ); } diff --git a/ts/quill/util.ts b/ts/quill/util.ts index 766cc2a15..6fdb32dd7 100644 --- a/ts/quill/util.ts +++ b/ts/quill/util.ts @@ -6,7 +6,7 @@ import Delta from 'quill-delta'; import type { LeafBlot, DeltaOperation } from 'quill'; import type Op from 'quill-delta/dist/Op'; -import type { BodyRangeType } from '../types/Util'; +import type { DraftBodyRangeType, DraftBodyRangesType } from '../types/Util'; import type { MentionBlot } from './mentions/blot'; export type MentionBlotValue = { @@ -61,8 +61,8 @@ export const getTextFromOps = (ops: Array): string => export const getTextAndMentionsFromOps = ( ops: Array -): [string, Array] => { - const mentions: Array = []; +): [string, DraftBodyRangesType] => { + const mentions: Array = []; const text = ops .reduce((acc, op, index) => { @@ -168,15 +168,17 @@ export const getDeltaToRemoveStaleMentions = ( export const insertMentionOps = ( incomingOps: Array, - bodyRanges: Array + bodyRanges: DraftBodyRangesType ): Array => { const ops = [...incomingOps]; + const sortableBodyRanges: Array = bodyRanges.slice(); + // Working backwards through bodyRanges (to avoid offsetting later mentions), // Shift off the op with the text to the left of the last mention, // Insert a mention based on the current bodyRange, // Unshift the mention and surrounding text to leave the ops ready for the next range - bodyRanges + sortableBodyRanges .sort((a, b) => b.start - a.start) .forEach(({ start, length, mentionUuid, replacementText }) => { const op = ops.shift(); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index f9c4eac21..b30557beb 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -44,7 +44,7 @@ import type { ConversationAttributesType, MessageAttributesType, } from '../../model-types.d'; -import type { BodyRangeType } from '../../types/Util'; +import type { DraftBodyRangesType } from '../../types/Util'; import { CallMode } from '../../types/Calling'; import type { MediaItemType } from '../../types/MediaItem'; import type { UUIDStringType } from '../../types/UUID'; @@ -203,7 +203,7 @@ export type ConversationType = { shouldShowDraft?: boolean; draftText?: string | null; - draftBodyRanges?: Array; + draftBodyRanges?: DraftBodyRangesType; draftPreview?: string; sharedGroupNames: Array; diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 0989b4e28..9af32768c 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -6,7 +6,7 @@ import { isEqual, pick } from 'lodash'; import * as Errors from '../../types/errors'; import type { AttachmentType } from '../../types/Attachment'; -import type { BodyRangeType } from '../../types/Util'; +import type { DraftBodyRangesType } from '../../types/Util'; import type { ConversationModel } from '../../models/conversations'; import type { MessageAttributesType } from '../../model-types.d'; import type { @@ -500,7 +500,7 @@ function reactToStory( function replyToStory( conversationId: string, messageBody: string, - mentions: Array, + mentions: DraftBodyRangesType, timestamp: number, story: StoryViewType ): ThunkAction { diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index e2052a907..514f8d7c9 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -37,7 +37,7 @@ import type { UUIDStringType } from '../../types/UUID'; import type { EmbeddedContactType } from '../../types/EmbeddedContact'; import { embeddedContactSelector } from '../../types/EmbeddedContact'; -import type { AssertProps, BodyRangesType } from '../../types/Util'; +import type { AssertProps, HydratedBodyRangesType } from '../../types/Util'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { getMentionsRegex } from '../../types/Message'; import { CallMode } from '../../types/Calling'; @@ -289,7 +289,7 @@ export const processBodyRanges = createSelectorCreator(memoizeByRoot, isEqual)( ( { bodyRanges }: Pick, { conversationSelector }: { conversationSelector: GetConversationByIdType } - ): BodyRangesType | undefined => { + ): HydratedBodyRangesType | undefined => { if (!bodyRanges) { return undefined; } @@ -307,7 +307,7 @@ export const processBodyRanges = createSelectorCreator(memoizeByRoot, isEqual)( }) .sort((a, b) => b.start - a.start); }, - (_, ranges): undefined | BodyRangesType => ranges + (_, ranges): undefined | HydratedBodyRangesType => ranges ); const getAuthorForMessage = createSelectorCreator(memoizeByRoot)( @@ -780,7 +780,7 @@ export const getPropsForMessage: ( ( _, attachments: Array, - bodyRanges: BodyRangesType | undefined, + bodyRanges: HydratedBodyRangesType | undefined, author: PropsData['author'], previews: Array, reactions: PropsData['reactions'], diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index cc7e2fba6..ce593bef0 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -28,7 +28,7 @@ import { getConversationSelector, } from './conversations'; -import type { BodyRangeType } from '../../types/Util'; +import type { BodyRangeType, HydratedBodyRangeType } from '../../types/Util'; import * as log from '../../logging/log'; import { getOwn } from '../../util/getOwn'; @@ -173,14 +173,17 @@ export const getCachedSelectorForMessageSearchResult = createSelector( conversationId: message.conversationId, sentAt: message.sent_at, snippet: message.snippet || '', - bodyRanges: bodyRanges.map((bodyRange: BodyRangeType) => { - const conversation = conversationSelector(bodyRange.mentionUuid); + bodyRanges: bodyRanges.map( + (bodyRange: BodyRangeType): HydratedBodyRangeType => { + const conversation = conversationSelector(bodyRange.mentionUuid); - return { - ...bodyRange, - replacementText: conversation.title, - }; - }), + return { + ...bodyRange, + conversationID: conversation.id, + replacementText: conversation.title, + }; + } + ), body: message.body || '', isSelected: Boolean( diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index 787561514..0020782b7 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -296,15 +296,20 @@ export const getStoryReplies = createSelector( ? me : conversationSelector(reply.sourceUuid || reply.source); + const { bodyRanges } = reply; + return { author: getAvatarData(conversation), - ...pick(reply, [ - 'body', - 'bodyRanges', - 'deletedForEveryone', - 'id', - 'timestamp', - ]), + ...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']), + bodyRanges: bodyRanges?.map(bodyRange => { + const mentionConvo = conversationSelector(bodyRange.mentionUuid); + + return { + ...bodyRange, + conversationID: mentionConvo.id, + replacementText: mentionConvo.title, + }; + }), reactionEmoji: reply.storyReaction?.emoji, contactNameColor: contactNameColorSelector( reply.conversationId, diff --git a/ts/state/smart/ForwardMessageModal.tsx b/ts/state/smart/ForwardMessageModal.tsx index aea8a55c4..2491242dd 100644 --- a/ts/state/smart/ForwardMessageModal.tsx +++ b/ts/state/smart/ForwardMessageModal.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import type { BodyRangeType } from '../../types/Util'; +import type { DraftBodyRangesType } from '../../types/Util'; import type { ForwardMessagePropsType } from '../ducks/globalModals'; import type { StateType } from '../reducer'; import * as log from '../../logging/log'; @@ -121,7 +121,7 @@ export function SmartForwardMessageModal(): JSX.Element | null { onClose={closeModal} onEditorStateChange={( messageText: string, - _: Array, + _: DraftBodyRangesType, caretLocation?: number ) => { if (!attachments.length) { diff --git a/ts/test-node/util/getTextWithMentions_test.ts b/ts/test-node/util/getTextWithMentions_test.ts index aca9200cd..ea73f8810 100644 --- a/ts/test-node/util/getTextWithMentions_test.ts +++ b/ts/test-node/util/getTextWithMentions_test.ts @@ -12,6 +12,7 @@ describe('getTextWithMentions', () => { length: 1, mentionUuid: 'abcdef', replacementText: 'fred', + conversationID: 'x', start: 4, }, ]; @@ -28,12 +29,14 @@ describe('getTextWithMentions', () => { length: 1, mentionUuid: 'blarg', replacementText: 'jerry', + conversationID: 'x', start: 0, }, { length: 1, mentionUuid: 'abcdef', replacementText: 'fred', + conversationID: 'x', start: 7, }, ]; diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index dd5bb0e01..e748cf2e8 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { AttachmentType } from './Attachment'; -import type { BodyRangesType, LocalizerType } from './Util'; +import type { HydratedBodyRangesType, LocalizerType } from './Util'; import type { ContactNameColorType } from './Colors'; import type { ConversationType } from '../state/ducks/conversations'; import type { ReadStatus } from '../messages/MessageReadStatus'; @@ -25,7 +25,7 @@ export type ReplyType = { | 'title' >; body?: string; - bodyRanges?: BodyRangesType; + bodyRanges?: HydratedBodyRangesType; contactNameColor?: ContactNameColorType; conversationId: string; deletedForEveryone?: boolean; diff --git a/ts/types/Util.ts b/ts/types/Util.ts index e12c7ca41..a960f5d98 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -4,15 +4,31 @@ import type { IntlShape } from 'react-intl'; import type { UUIDStringType } from './UUID'; +// Cold storage of body ranges + export type BodyRangeType = { start: number; length: number; - mentionUuid?: string; - replacementText?: string; - conversationID?: string; + mentionUuid: string; }; -export type BodyRangesType = Array; +export type BodyRangesType = ReadonlyArray; + +// Used exclusive in CompositionArea and related conversation_view.tsx calls. + +export type DraftBodyRangeType = BodyRangeType & { + replacementText: string; +}; + +export type DraftBodyRangesType = ReadonlyArray; + +// Fully hydrated body range to be used in UI components. + +export type HydratedBodyRangeType = DraftBodyRangeType & { + conversationID: string; +}; + +export type HydratedBodyRangesType = ReadonlyArray; export type StoryContextType = { authorUuid?: UUIDStringType; diff --git a/ts/util/getTextWithMentions.ts b/ts/util/getTextWithMentions.ts index e8e23a02d..5589261a4 100644 --- a/ts/util/getTextWithMentions.ts +++ b/ts/util/getTextWithMentions.ts @@ -1,13 +1,14 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { BodyRangesType } from '../types/Util'; +import type { DraftBodyRangeType, DraftBodyRangesType } from '../types/Util'; export function getTextWithMentions( - bodyRanges: BodyRangesType, + bodyRanges: DraftBodyRangesType, text: string ): string { - return bodyRanges + const sortableBodyRanges: Array = bodyRanges.slice(); + return sortableBodyRanges .sort((a, b) => b.start - a.start) .reduce((acc, { start, length, replacementText }) => { const left = acc.slice(0, start); diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 07eb6d8c1..121489f02 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -12,7 +12,7 @@ import { render } from 'mustache'; import type { AttachmentType } from '../types/Attachment'; import { isGIF } from '../types/Attachment'; import * as Stickers from '../types/Stickers'; -import type { BodyRangeType, BodyRangesType } from '../types/Util'; +import type { DraftBodyRangesType } from '../types/Util'; import type { MIMEType } from '../types/MIME'; import type { ConversationModel } from '../models/conversations'; import type { @@ -202,7 +202,7 @@ const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; export class ConversationView extends window.Backbone.View { private debouncedSaveDraft: ( messageText: string, - bodyRanges: Array + bodyRanges: DraftBodyRangesType ) => Promise; private lazyUpdateVerified: () => void; @@ -544,7 +544,7 @@ export class ConversationView extends window.Backbone.View { this.sendStickerMessage({ packId, stickerId }), onEditorStateChange: ( msg: string, - bodyRanges: Array, + bodyRanges: DraftBodyRangesType, caretLocation?: number ) => this.onEditorStateChange(msg, bodyRanges, caretLocation), onTextTooLong: () => showToast(ToastMessageBodyTooLong), @@ -621,7 +621,7 @@ export class ConversationView extends window.Backbone.View { voiceNoteAttachment, }: { draftAttachments?: ReadonlyArray; - mentions?: BodyRangesType; + mentions?: DraftBodyRangesType; message?: string; timestamp?: number; voiceNoteAttachment?: AttachmentType; @@ -2490,7 +2490,7 @@ export class ConversationView extends window.Backbone.View { async sendMessage( message = '', - mentions: BodyRangesType = [], + mentions: DraftBodyRangesType = [], options: { draftAttachments?: ReadonlyArray; timestamp?: number; @@ -2589,7 +2589,7 @@ export class ConversationView extends window.Backbone.View { onEditorStateChange( messageText: string, - bodyRanges: Array, + bodyRanges: DraftBodyRangesType, caretLocation?: number ): void { this.maybeBumpTyping(messageText); @@ -2605,7 +2605,7 @@ export class ConversationView extends window.Backbone.View { async saveDraft( messageText: string, - bodyRanges: Array + bodyRanges: DraftBodyRangesType ): Promise { const { model }: { model: ConversationModel } = this;