Hydrate body ranges for story replies

This commit is contained in:
Fedor Indutny 2022-11-09 20:59:36 -08:00 committed by GitHub
parent 9f85db3fd8
commit be6e988a95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 221 additions and 172 deletions

View file

@ -6,8 +6,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { get } from 'lodash'; import { get } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import type { import type {
BodyRangeType, DraftBodyRangesType,
BodyRangesType,
LocalizerType, LocalizerType,
ThemeType, ThemeType,
} from '../types/Util'; } from '../types/Util';
@ -116,7 +115,7 @@ export type OwnProps = Readonly<{
onSelectMediaQuality(isHQ: boolean): unknown; onSelectMediaQuality(isHQ: boolean): unknown;
onSendMessage(options: { onSendMessage(options: {
draftAttachments?: ReadonlyArray<AttachmentDraftType>; draftAttachments?: ReadonlyArray<AttachmentDraftType>;
mentions?: BodyRangesType; mentions?: DraftBodyRangesType;
message?: string; message?: string;
timestamp?: number; timestamp?: number;
voiceNoteAttachment?: InMemoryAttachmentDraftType; voiceNoteAttachment?: InMemoryAttachmentDraftType;
@ -276,7 +275,7 @@ export const CompositionArea = ({
}, [inputApiRef, setLarge]); }, [inputApiRef, setLarge]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: string, mentions: Array<BodyRangeType>, timestamp: number) => { (message: string, mentions: DraftBodyRangesType, timestamp: number) => {
emojiButtonRef.current?.close(); emojiButtonRef.current?.close();
onSendMessage({ onSendMessage({
draftAttachments, draftAttachments,

View file

@ -14,7 +14,11 @@ import { MentionCompletion } from '../quill/mentions/completion';
import { EmojiBlot, EmojiCompletion } from '../quill/emoji'; import { EmojiBlot, EmojiCompletion } from '../quill/emoji';
import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { EmojiPickDataType } from './emoji/EmojiPicker';
import { convertShortName } from './emoji/lib'; 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 { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import { isValidUuid } from '../types/UUID'; import { isValidUuid } from '../types/UUID';
@ -71,7 +75,7 @@ export type Props = Readonly<{
inputApi?: React.MutableRefObject<InputApi | undefined>; inputApi?: React.MutableRefObject<InputApi | undefined>;
skinTone?: EmojiPickDataType['skinTone']; skinTone?: EmojiPickDataType['skinTone'];
draftText?: string; draftText?: string;
draftBodyRanges?: Array<BodyRangeType>; draftBodyRanges?: DraftBodyRangesType;
moduleClassName?: string; moduleClassName?: string;
theme: ThemeType; theme: ThemeType;
placeholder?: string; placeholder?: string;
@ -80,14 +84,14 @@ export type Props = Readonly<{
onDirtyChange?(dirty: boolean): unknown; onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?( onEditorStateChange?(
messageText: string, messageText: string,
bodyRanges: Array<BodyRangeType>, bodyRanges: DraftBodyRangesType,
caretLocation?: number caretLocation?: number
): unknown; ): unknown;
onTextTooLong(): unknown; onTextTooLong(): unknown;
onPickEmoji(o: EmojiPickDataType): unknown; onPickEmoji(o: EmojiPickDataType): unknown;
onSubmit( onSubmit(
message: string, message: string,
mentions: Array<BodyRangeType>, mentions: DraftBodyRangesType,
timestamp: number timestamp: number
): unknown; ): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void; onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
@ -143,7 +147,7 @@ export function CompositionInput(props: Props): React.ReactElement {
const generateDelta = ( const generateDelta = (
text: string, text: string,
bodyRanges: Array<BodyRangeType> bodyRanges: DraftBodyRangesType
): Delta => { ): Delta => {
const initialOps = [{ insert: text }]; const initialOps = [{ insert: text }];
const opsWithMentions = insertMentionOps(initialOps, bodyRanges); const opsWithMentions = insertMentionOps(initialOps, bodyRanges);
@ -152,7 +156,7 @@ export function CompositionInput(props: Props): React.ReactElement {
return new Delta(opsWithEmojis); return new Delta(opsWithEmojis);
}; };
const getTextAndMentions = (): [string, Array<BodyRangeType>] => { const getTextAndMentions = (): [string, DraftBodyRangesType] => {
const quill = quillRef.current; const quill = quillRef.current;
if (quill === undefined) { if (quill === undefined) {

View file

@ -9,7 +9,7 @@ import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
import type { InputApi } from './CompositionInput'; import type { InputApi } from './CompositionInput';
import { CompositionInput } from './CompositionInput'; import { CompositionInput } from './CompositionInput';
import { EmojiButton } from './emoji/EmojiButton'; 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 { Props as EmojiButtonProps } from './emoji/EmojiButton';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme'; import * as grapheme from '../util/grapheme';
@ -24,13 +24,13 @@ export type CompositionTextAreaProps = {
onPickEmoji: (e: EmojiPickDataType) => void; onPickEmoji: (e: EmojiPickDataType) => void;
onChange: ( onChange: (
messageText: string, messageText: string,
bodyRanges: Array<BodyRangeType>, bodyRanges: DraftBodyRangesType,
caretLocation?: number | undefined caretLocation?: number | undefined
) => void; ) => void;
onSetSkinTone: (tone: number) => void; onSetSkinTone: (tone: number) => void;
onSubmit: ( onSubmit: (
message: string, message: string,
mentions: Array<BodyRangeType>, mentions: DraftBodyRangesType,
timestamp: number timestamp: number
) => void; ) => void;
onTextTooLong: () => void; onTextTooLong: () => void;
@ -88,7 +88,7 @@ export const CompositionTextArea = ({
const handleChange = React.useCallback( const handleChange = React.useCallback(
( (
newValue: string, newValue: string,
bodyRanges: Array<BodyRangeType>, bodyRanges: DraftBodyRangesType,
caretLocation?: number | undefined caretLocation?: number | undefined
) => { ) => {
const inputEl = inputApiRef.current; const inputEl = inputApiRef.current;

View file

@ -24,7 +24,11 @@ import { ConversationList, RowType } from './ConversationList';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; 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 type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { ModalHost } from './ModalHost'; import { ModalHost } from './ModalHost';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
@ -54,7 +58,7 @@ export type DataPropsType = {
onClose: () => void; onClose: () => void;
onEditorStateChange: ( onEditorStateChange: (
messageText: string, messageText: string,
bodyRanges: Array<BodyRangeType>, bodyRanges: DraftBodyRangesType,
caretLocation?: number caretLocation?: number
) => unknown; ) => unknown;
theme: ThemeType; theme: ThemeType;

View file

@ -27,13 +27,11 @@ export type Props = {
renderText?: RenderTextCallbackType; renderText?: RenderTextCallbackType;
}; };
export class Intl extends React.Component<Props> { const defaultRenderText: RenderTextCallbackType = ({ text, key }) => (
public static defaultProps: Partial<Props> = { <React.Fragment key={key}>{text}</React.Fragment>
renderText: ({ text, key }) => ( );
<React.Fragment key={key}>{text}</React.Fragment>
),
};
export class Intl extends React.Component<Props> {
public getComponent( public getComponent(
index: number, index: number,
placeholderName: string, placeholderName: string,
@ -74,7 +72,7 @@ export class Intl extends React.Component<Props> {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public override render() { public override render() {
const { components, id, i18n, renderText } = this.props; const { components, id, i18n, renderText = defaultRenderText } = this.props;
if (!id) { if (!id) {
log.error('Error: Intl id prop not provided'); log.error('Error: Intl id prop not provided');
@ -96,12 +94,6 @@ export class Intl extends React.Component<Props> {
> = []; > = [];
const FIND_REPLACEMENTS = /\$([^$]+)\$/g; 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) { if (Array.isArray(components) && components.length > 1) {
throw new Error( throw new Error(
'Array syntax is not supported with more than one placeholder' 'Array syntax is not supported with more than one placeholder'

View file

@ -10,7 +10,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import classNames from 'classnames'; 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 { ContextMenuOptionType } from './ContextMenu';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { EmojiPickDataType } from './emoji/EmojiPicker';
@ -80,7 +80,7 @@ export type PropsType = {
onReactToStory: (emoji: string, story: StoryViewType) => unknown; onReactToStory: (emoji: string, story: StoryViewType) => unknown;
onReplyToStory: ( onReplyToStory: (
message: string, message: string,
mentions: Array<BodyRangeType>, mentions: DraftBodyRangesType,
timestamp: number, timestamp: number,
story: StoryViewType story: StoryViewType
) => unknown; ) => unknown;

View file

@ -10,8 +10,10 @@ import React, {
} from 'react'; } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { usePopper } from 'react-popper'; import { usePopper } from 'react-popper';
import { noop } from 'lodash';
import type { AttachmentType } from '../types/Attachment'; 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 { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { InputApi } from './CompositionInput'; import type { InputApi } from './CompositionInput';
@ -56,7 +58,8 @@ const MESSAGE_DEFAULT_PROPS = {
markAttachmentAsCorrupted: shouldNeverBeCalled, markAttachmentAsCorrupted: shouldNeverBeCalled,
markViewed: shouldNeverBeCalled, markViewed: shouldNeverBeCalled,
messageExpanded: shouldNeverBeCalled, messageExpanded: shouldNeverBeCalled,
openConversation: shouldNeverBeCalled, // Called when clicking mention, but shouldn't do anything.
openConversation: noop,
openGiftBadge: shouldNeverBeCalled, openGiftBadge: shouldNeverBeCalled,
openLink: shouldNeverBeCalled, openLink: shouldNeverBeCalled,
previews: [], previews: [],
@ -90,7 +93,7 @@ export type PropsType = {
onReact: (emoji: string) => unknown; onReact: (emoji: string) => unknown;
onReply: ( onReply: (
message: string, message: string,
mentions: Array<BodyRangeType>, mentions: DraftBodyRangesType,
timestamp: number timestamp: number
) => unknown; ) => unknown;
onSetSkinTone: (tone: number) => unknown; onSetSkinTone: (tone: number) => unknown;
@ -315,6 +318,7 @@ export const StoryViewsNRepliesModal = ({
key={reply.id} key={reply.id}
i18n={i18n} i18n={i18n}
reply={reply} reply={reply}
reactionEmoji={reply.reactionEmoji}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
/> />
) : ( ) : (
@ -504,12 +508,14 @@ export const StoryViewsNRepliesModal = ({
type ReactionProps = { type ReactionProps = {
i18n: LocalizerType; i18n: LocalizerType;
reply: ReplyType; reply: ReplyType;
reactionEmoji: string;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
}; };
const Reaction = ({ const Reaction = ({
i18n, i18n,
reply, reply,
reactionEmoji,
getPreferredBadge, getPreferredBadge,
}: ReactionProps): JSX.Element => { }: ReactionProps): JSX.Element => {
// TODO: DESKTOP-4503 - reactions delete/doe // TODO: DESKTOP-4503 - reactions delete/doe
@ -546,7 +552,7 @@ const Reaction = ({
/> />
</div> </div>
</div> </div>
<Emojify text={reply.reactionEmoji} /> <Emojify text={reactionEmoji} />
</div> </div>
); );
}; };

View file

@ -11,27 +11,18 @@ export type Props = {
renderNonNewLine?: RenderTextCallbackType; renderNonNewLine?: RenderTextCallbackType;
}; };
export class AddNewLines extends React.Component<Props> { const defaultRenderNonNewLine: RenderTextCallbackType = ({ text }) => text;
public static defaultProps: Partial<Props> = {
renderNonNewLine: ({ text }) => text,
};
export class AddNewLines extends React.Component<Props> {
public override render(): public override render():
| JSX.Element | JSX.Element
| string | string
| null | null
| Array<JSX.Element | string | null> { | Array<JSX.Element | string | null> {
const { text, renderNonNewLine } = this.props; const { text, renderNonNewLine = defaultRenderNonNewLine } = this.props;
// eslint-disable-next-line @typescript-eslint/no-explicit-any const results: Array<JSX.Element | string> = [];
const results: Array<any> = [];
const FIND_NEWLINES = /\n/g; 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 match = FIND_NEWLINES.exec(text);
let last = 0; let last = 0;
let count = 1; let count = 1;

View file

@ -43,18 +43,21 @@ export const MultipleMentions = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: 'abc', mentionUuid: 'abc',
replacementText: 'Professor Farnsworth', replacementText: 'Professor Farnsworth',
conversationID: 'x',
}, },
{ {
start: 2, start: 2,
length: 1, length: 1,
mentionUuid: 'def', mentionUuid: 'def',
replacementText: 'Philip J Fry', replacementText: 'Philip J Fry',
conversationID: 'x',
}, },
{ {
start: 0, start: 0,
length: 1, length: 1,
mentionUuid: 'xyz', mentionUuid: 'xyz',
replacementText: 'Yancy Fry', replacementText: 'Yancy Fry',
conversationID: 'x',
}, },
]; ];
const props = createProps({ const props = createProps({
@ -77,18 +80,21 @@ export const ComplexMentions = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: 'ioe', mentionUuid: 'ioe',
replacementText: 'Cereal Killer', replacementText: 'Cereal Killer',
conversationID: 'x',
}, },
{ {
start: 78, start: 78,
length: 1, length: 1,
mentionUuid: 'fdr', mentionUuid: 'fdr',
replacementText: 'Acid Burn', replacementText: 'Acid Burn',
conversationID: 'x',
}, },
{ {
start: 4, start: 4,
length: 1, length: 1,
mentionUuid: 'ope', mentionUuid: 'ope',
replacementText: 'Zero Cool', replacementText: 'Zero Cool',
conversationID: 'x',
}, },
]; ];

View file

@ -4,10 +4,14 @@
import React from 'react'; import React from 'react';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { Emojify } from './Emojify'; import { Emojify } from './Emojify';
import type { BodyRangesType } from '../../types/Util'; import type {
BodyRangesType,
HydratedBodyRangeType,
HydratedBodyRangesType,
} from '../../types/Util';
export type Props = { export type Props = {
bodyRanges?: BodyRangesType; bodyRanges?: HydratedBodyRangesType;
direction?: 'incoming' | 'outgoing'; direction?: 'incoming' | 'outgoing';
openConversation?: (conversationId: string, messageId?: string) => void; openConversation?: (conversationId: string, messageId?: string) => void;
text: string; text: string;
@ -28,7 +32,7 @@ export const AtMentionify = ({
let match = MENTIONS_REGEX.exec(text); let match = MENTIONS_REGEX.exec(text);
let last = 0; let last = 0;
const rangeStarts = new Map(); const rangeStarts = new Map<number, HydratedBodyRangeType>();
bodyRanges.forEach(range => { bodyRanges.forEach(range => {
rangeStarts.set(range.start, range); rangeStarts.set(range.start, range);
}); });
@ -49,7 +53,7 @@ export const AtMentionify = ({
className={`MessageBody__at-mention MessageBody__at-mention--${direction}`} className={`MessageBody__at-mention MessageBody__at-mention--${direction}`}
key={range.start} key={range.start}
onClick={() => { onClick={() => {
if (openConversation && range.conversationID) { if (openConversation) {
openConversation(range.conversationID); openConversation(range.conversationID);
} }
}} }}
@ -57,8 +61,7 @@ export const AtMentionify = ({
if ( if (
e.target === e.currentTarget && e.target === e.currentTarget &&
e.keyCode === 13 && e.keyCode === 13 &&
openConversation && openConversation
range.conversationID
) { ) {
openConversation(range.conversationID); openConversation(range.conversationID);
} }

View file

@ -32,7 +32,7 @@ export const ChangeNumberNotification: React.FC<Props> = props => {
<Intl <Intl
id="ChangeNumber--notification" id="ChangeNumber--notification"
components={{ components={{
sender: <Emojify text={sender.title || sender.firstName} />, sender: <Emojify text={sender.title || sender.firstName || ''} />,
}} }}
i18n={i18n} i18n={i18n}
/> />

View file

@ -308,6 +308,22 @@ export const ContactSpoofingReviewDialog: FunctionComponent<
conversationInfo.conversation.profileName || conversationInfo.conversation.profileName ||
conversationInfo.conversation.title; conversationInfo.conversation.title;
let callout: JSX.Element | undefined;
if (oldName && oldName !== newName) {
callout = (
<div className="module-ContactSpoofingReviewDialogPerson__info__property module-ContactSpoofingReviewDialogPerson__info__property--callout">
<Intl
i18n={i18n}
id="ContactSpoofingReviewDialog__group__name-change-info"
components={{
oldName: <Emojify text={oldName} />,
newName: <Emojify text={newName} />,
}}
/>
</div>
);
}
return ( return (
<> <>
{index !== 0 && <hr />} {index !== 0 && <hr />}
@ -318,18 +334,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<
i18n={i18n} i18n={i18n}
theme={theme} theme={theme}
> >
{Boolean(oldName) && oldName !== newName && ( {callout}
<div className="module-ContactSpoofingReviewDialogPerson__info__property module-ContactSpoofingReviewDialogPerson__info__property--callout">
<Intl
i18n={i18n}
id="ContactSpoofingReviewDialog__group__name-change-info"
components={{
oldName: <Emojify text={oldName} />,
newName: <Emojify text={newName} />,
}}
/>
</div>
)}
{button && ( {button && (
<div className="module-ContactSpoofingReviewDialog__buttons"> <div className="module-ContactSpoofingReviewDialog__buttons">
{button} {button}

View file

@ -49,19 +49,15 @@ export type Props = {
renderNonEmoji?: RenderTextCallbackType; renderNonEmoji?: RenderTextCallbackType;
}; };
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
export class Emojify extends React.Component<Props> { export class Emojify extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
renderNonEmoji: ({ text }) => text,
};
public override render(): null | Array<JSX.Element | string | null> { public override render(): null | Array<JSX.Element | string | null> {
const { text, sizeClass, renderNonEmoji } = this.props; const {
text,
// We have to do this, because renderNonEmoji is not required in our Props object, sizeClass,
// but it is always provided via defaultProps. renderNonEmoji = defaultRenderNonEmoji,
if (!renderNonEmoji) { } = this.props;
return null;
}
return splitByEmoji(text).map(({ type, value: match }, index) => { return splitByEmoji(text).map(({ type, value: match }, index) => {
if (type === 'emoji') { if (type === 'emoji') {

View file

@ -321,28 +321,20 @@ export type Props = {
const SUPPORTED_PROTOCOLS = /^(http|https):/i; const SUPPORTED_PROTOCOLS = /^(http|https):/i;
export class Linkify extends React.Component<Props> { const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;
public static defaultProps: Partial<Props> = {
renderNonLink: ({ text }) => text,
};
export class Linkify extends React.Component<Props> {
public override render(): public override render():
| JSX.Element | JSX.Element
| string | string
| null | null
| Array<JSX.Element | string | null> { | Array<JSX.Element | string | null> {
const { text, renderNonLink } = this.props; const { text, renderNonLink = defaultRenderNonLink } = this.props;
if (!shouldLinkifyMessage(text)) { if (!shouldLinkifyMessage(text)) {
return 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<{ const chunkData: Array<{
chunk: string; chunk: string;
matchData: ReadonlyArray<LinkifyIt.Match>; matchData: ReadonlyArray<LinkifyIt.Match>;

View file

@ -63,7 +63,7 @@ import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
import { isFileDangerous } from '../../util/isFileDangerous'; import { isFileDangerous } from '../../util/isFileDangerous';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import type { import type {
BodyRangesType, HydratedBodyRangesType,
LocalizerType, LocalizerType,
ThemeType, ThemeType,
} from '../../types/Util'; } from '../../types/Util';
@ -228,7 +228,7 @@ export type PropsData = {
authorProfileName?: string; authorProfileName?: string;
authorTitle: string; authorTitle: string;
authorName?: string; authorName?: string;
bodyRanges?: BodyRangesType; bodyRanges?: HydratedBodyRangesType;
referencedMessageNotFound: boolean; referencedMessageNotFound: boolean;
isViewOnce: boolean; isViewOnce: boolean;
isGiftBadge: boolean; isGiftBadge: boolean;
@ -261,7 +261,7 @@ export type PropsData = {
canDeleteForEveryone: boolean; canDeleteForEveryone: boolean;
isBlocked: boolean; isBlocked: boolean;
isMessageRequestAccepted: boolean; isMessageRequestAccepted: boolean;
bodyRanges?: BodyRangesType; bodyRanges?: HydratedBodyRangesType;
menu: JSX.Element | undefined; menu: JSX.Element | undefined;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void; onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;

View file

@ -114,6 +114,7 @@ export const Mention = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: 'tuv', mentionUuid: 'tuv',
replacementText: 'Bender B Rodriguez 🤖', 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', 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, length: 1,
mentionUuid: 'def', mentionUuid: 'def',
replacementText: 'Philip J Fry', replacementText: 'Philip J Fry',
conversationID: 'x',
}, },
{ {
start: 4, start: 4,
length: 1, length: 1,
mentionUuid: 'abc', mentionUuid: 'abc',
replacementText: 'Professor Farnsworth', replacementText: 'Professor Farnsworth',
conversationID: 'x',
}, },
{ {
start: 0, start: 0,
length: 1, length: 1,
mentionUuid: 'xyz', mentionUuid: 'xyz',
replacementText: 'Yancy Fry', replacementText: 'Yancy Fry',
conversationID: 'x',
}, },
], ],
text: '\uFFFC \uFFFC \uFFFC', text: '\uFFFC \uFFFC \uFFFC',
@ -168,18 +172,21 @@ export const ComplexMessageBody = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: 'wer', mentionUuid: 'wer',
replacementText: 'Acid Burn', replacementText: 'Acid Burn',
conversationID: 'x',
}, },
{ {
start: 80, start: 80,
length: 1, length: 1,
mentionUuid: 'xox', mentionUuid: 'xox',
replacementText: 'Cereal Killer', replacementText: 'Cereal Killer',
conversationID: 'x',
}, },
{ {
start: 4, start: 4,
length: 1, length: 1,
mentionUuid: 'ldo', mentionUuid: 'ldo',
replacementText: 'Zero Cool', replacementText: 'Zero Cool',
conversationID: 'x',
}, },
], ],
direction: 'outgoing', direction: 'outgoing',

View file

@ -14,7 +14,7 @@ import { AddNewLines } from './AddNewLines';
import { Linkify } from './Linkify'; import { Linkify } from './Linkify';
import type { import type {
BodyRangesType, HydratedBodyRangesType,
LocalizerType, LocalizerType,
RenderTextCallbackType, RenderTextCallbackType,
} from '../../types/Util'; } from '../../types/Util';
@ -34,7 +34,7 @@ export type Props = {
/** If set, links will be left alone instead of turned into clickable `<a>` tags. */ /** If set, links will be left alone instead of turned into clickable `<a>` tags. */
disableLinks?: boolean; disableLinks?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
bodyRanges?: BodyRangesType; bodyRanges?: HydratedBodyRangesType;
onIncreaseTextLength?: () => unknown; onIncreaseTextLength?: () => unknown;
openConversation?: OpenConversationActionType; openConversation?: OpenConversationActionType;
kickOffBodyDownload?: () => void; kickOffBodyDownload?: () => void;

View file

@ -11,7 +11,7 @@ import * as GoogleChrome from '../../util/GoogleChrome';
import { MessageBody } from './MessageBody'; import { MessageBody } from './MessageBody';
import type { AttachmentType, ThumbnailType } from '../../types/Attachment'; import type { AttachmentType, ThumbnailType } from '../../types/Attachment';
import type { BodyRangesType, LocalizerType } from '../../types/Util'; import type { HydratedBodyRangesType, LocalizerType } from '../../types/Util';
import type { import type {
ConversationColorType, ConversationColorType,
CustomColorType, CustomColorType,
@ -27,7 +27,7 @@ export type Props = {
authorTitle: string; authorTitle: string;
conversationColor: ConversationColorType; conversationColor: ConversationColorType;
customColor?: CustomColorType; customColor?: CustomColorType;
bodyRanges?: BodyRangesType; bodyRanges?: HydratedBodyRangesType;
i18n: LocalizerType; i18n: LocalizerType;
isFromMe: boolean; isFromMe: boolean;
isIncoming?: boolean; isIncoming?: boolean;

View file

@ -1606,6 +1606,7 @@ Mentions.args = {
length: 1, length: 1,
mentionUuid: 'zap', mentionUuid: 'zap',
replacementText: 'Zapp Brannigan', replacementText: 'Zapp Brannigan',
conversationID: 'x',
}, },
], ],
text: '\uFFFC This Is It. The Moment We Should Have Trained For.', text: '\uFFFC This Is It. The Moment We Should Have Trained For.',

View file

@ -19,12 +19,8 @@ type Props = {
}; };
export class DocumentListItem extends React.Component<Props> { export class DocumentListItem extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
shouldShowSeparator: true,
};
public override render(): JSX.Element { public override render(): JSX.Element {
const { shouldShowSeparator } = this.props; const { shouldShowSeparator = true } = this.props;
return ( return (
<div <div

View file

@ -52,6 +52,7 @@ export const TwoReplacementsWithAnMention = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: '0ca40892-7b1a-11eb-9439-0242ac130002', mentionUuid: '0ca40892-7b1a-11eb-9439-0242ac130002',
replacementText: 'Jin Sakai', replacementText: 'Jin Sakai',
conversationID: 'x',
start: 33, start: 33,
}, },
], ],

View file

@ -13,7 +13,7 @@ import { AddNewLines } from '../conversation/AddNewLines';
import type { SizeClassType } from '../emoji/lib'; import type { SizeClassType } from '../emoji/lib';
import type { import type {
BodyRangesType, HydratedBodyRangesType,
LocalizerType, LocalizerType,
RenderTextCallbackType, RenderTextCallbackType,
} from '../../types/Util'; } from '../../types/Util';
@ -21,7 +21,7 @@ import type {
const CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__message-search-result-contents`; const CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__message-search-result-contents`;
export type Props = { export type Props = {
bodyRanges: BodyRangesType; bodyRanges: HydratedBodyRangesType;
text: string; text: string;
i18n: LocalizerType; i18n: LocalizerType;
}; };

View file

@ -206,12 +206,14 @@ export const Mention = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
replacementText: 'Shoe', replacementText: 'Shoe',
conversationID: 'x',
start: 113, start: 113,
}, },
{ {
length: 1, length: 1,
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
replacementText: 'Shoe', replacementText: 'Shoe',
conversationID: 'x',
start: 237, start: 237,
}, },
], ],
@ -236,6 +238,7 @@ export const MentionRegexp = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
replacementText: 'RegExp', replacementText: 'RegExp',
conversationID: 'x',
start: 0, start: 0,
}, },
], ],
@ -260,6 +263,7 @@ export const MentionNoMatches = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
replacementText: 'Neo', replacementText: 'Neo',
conversationID: 'x',
start: 0, start: 0,
}, },
], ],
@ -283,12 +287,14 @@ export const _MentionNoMatches = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
replacementText: 'Shoe', replacementText: 'Shoe',
conversationID: 'x',
start: 113, start: 113,
}, },
{ {
length: 1, length: 1,
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
replacementText: 'Shoe', replacementText: 'Shoe',
conversationID: 'x',
start: 237, start: 237,
}, },
], ],
@ -313,12 +319,14 @@ export const DoubleMention = (): JSX.Element => {
length: 1, length: 1,
mentionUuid: '9eb2eb65-992a-4909-a2a5-18c56bd7648f', mentionUuid: '9eb2eb65-992a-4909-a2a5-18c56bd7648f',
replacementText: 'Alice', replacementText: 'Alice',
conversationID: 'x',
start: 4, start: 4,
}, },
{ {
length: 1, length: 1,
mentionUuid: '755ec61b-1590-48da-b003-3e57b2b54448', mentionUuid: '755ec61b-1590-48da-b003-3e57b2b54448',
replacementText: 'Bob', replacementText: 'Bob',
conversationID: 'x',
start: 6, start: 6,
}, },
], ],

View file

@ -10,7 +10,7 @@ import { ContactName } from '../conversation/ContactName';
import { assertDev } from '../../util/assert'; import { assertDev } from '../../util/assert';
import type { import type {
BodyRangesType, HydratedBodyRangesType,
LocalizerType, LocalizerType,
ThemeType, ThemeType,
} from '../../types/Util'; } from '../../types/Util';
@ -31,7 +31,7 @@ export type PropsDataType = {
snippet: string; snippet: string;
body: string; body: string;
bodyRanges: BodyRangesType; bodyRanges: HydratedBodyRangesType;
from: Pick< from: Pick<
ConversationType, ConversationType,
@ -82,8 +82,8 @@ const renderPerson = (
function getFilteredBodyRanges( function getFilteredBodyRanges(
snippet: string, snippet: string,
body: string, body: string,
bodyRanges: BodyRangesType bodyRanges: HydratedBodyRangesType
): BodyRangesType { ): HydratedBodyRangesType {
if (!bodyRanges.length) { if (!bodyRanges.length) {
return []; return [];
} }

4
ts/model-types.d.ts vendored
View file

@ -6,7 +6,7 @@
import * as Backbone from 'backbone'; import * as Backbone from 'backbone';
import type { GroupV2ChangeType } from './groups'; 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 { CallHistoryDetailsFromDiskType } from './types/Calling';
import type { CustomColorType, ConversationColorType } from './types/Colors'; import type { CustomColorType, ConversationColorType } from './types/Colors';
import type { DeviceType } from './textsecure/Types.d'; import type { DeviceType } from './textsecure/Types.d';
@ -279,7 +279,7 @@ export type ConversationAttributesType = {
firstUnregisteredAt?: number; firstUnregisteredAt?: number;
draftChanged?: boolean; draftChanged?: boolean;
draftAttachments?: Array<AttachmentDraftType>; draftAttachments?: Array<AttachmentDraftType>;
draftBodyRanges?: Array<BodyRangeType>; draftBodyRanges?: DraftBodyRangesType;
draftTimestamp?: number | null; draftTimestamp?: number | null;
hideStory?: boolean; hideStory?: boolean;
inbox_position?: number; inbox_position?: number;

View file

@ -51,7 +51,7 @@ import * as expirationTimer from '../util/expirationTimer';
import { getUserLanguages } from '../util/userLanguages'; import { getUserLanguages } from '../util/userLanguages';
import type { ReactionType } from '../types/Reactions'; 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 reactionUtil from '../reactions/util';
import * as Stickers from '../types/Stickers'; import * as Stickers from '../types/Stickers';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
@ -2002,22 +2002,30 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
id, id,
attachments: quote.attachments.slice(), attachments: quote.attachments.slice(),
bodyRanges: quote.bodyRanges.map(({ start, length, mentionUuid }) => { bodyRanges: quote.bodyRanges
strictAssert( .map(({ start, length, mentionUuid }) => {
start != null, strictAssert(
'Received quote with a bodyRange.start == null' start != null,
); 'Received quote with a bodyRange.start == null'
strictAssert( );
length != null, strictAssert(
'Received quote with a bodyRange.length == null' length != null,
); 'Received quote with a bodyRange.length == null'
);
if (!isValidUuid(mentionUuid)) {
log.warn(
`copyFromQuotedMessage: invalid mentionUuid ${mentionUuid}`
);
return undefined;
}
return { return {
start, start,
length, length,
mentionUuid: dropNull(mentionUuid), mentionUuid,
}; };
}), })
.filter(isNotNil),
// Just placeholder values for the fields // Just placeholder values for the fields
referencedMessageNotFound: false, referencedMessageNotFound: false,

View file

@ -35,7 +35,7 @@ export class MentionBlot extends Embed {
const { uuid, title } = node.dataset; const { uuid, title } = node.dataset;
if (uuid === undefined || title === undefined) { if (uuid === undefined || title === undefined) {
throw new Error( throw new Error(
`Failed to make MentionBlot with uuid: ${uuid} and title: ${title}` `Failed to make MentionBlot with uuid: ${uuid}, title: ${title}`
); );
} }

View file

@ -6,7 +6,7 @@ import Delta from 'quill-delta';
import type { LeafBlot, DeltaOperation } from 'quill'; import type { LeafBlot, DeltaOperation } from 'quill';
import type Op from 'quill-delta/dist/Op'; 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'; import type { MentionBlot } from './mentions/blot';
export type MentionBlotValue = { export type MentionBlotValue = {
@ -61,8 +61,8 @@ export const getTextFromOps = (ops: Array<DeltaOperation>): string =>
export const getTextAndMentionsFromOps = ( export const getTextAndMentionsFromOps = (
ops: Array<Op> ops: Array<Op>
): [string, Array<BodyRangeType>] => { ): [string, DraftBodyRangesType] => {
const mentions: Array<BodyRangeType> = []; const mentions: Array<DraftBodyRangeType> = [];
const text = ops const text = ops
.reduce((acc, op, index) => { .reduce((acc, op, index) => {
@ -168,15 +168,17 @@ export const getDeltaToRemoveStaleMentions = (
export const insertMentionOps = ( export const insertMentionOps = (
incomingOps: Array<Op>, incomingOps: Array<Op>,
bodyRanges: Array<BodyRangeType> bodyRanges: DraftBodyRangesType
): Array<Op> => { ): Array<Op> => {
const ops = [...incomingOps]; const ops = [...incomingOps];
const sortableBodyRanges: Array<DraftBodyRangeType> = bodyRanges.slice();
// Working backwards through bodyRanges (to avoid offsetting later mentions), // Working backwards through bodyRanges (to avoid offsetting later mentions),
// Shift off the op with the text to the left of the last mention, // Shift off the op with the text to the left of the last mention,
// Insert a mention based on the current bodyRange, // Insert a mention based on the current bodyRange,
// Unshift the mention and surrounding text to leave the ops ready for the next range // Unshift the mention and surrounding text to leave the ops ready for the next range
bodyRanges sortableBodyRanges
.sort((a, b) => b.start - a.start) .sort((a, b) => b.start - a.start)
.forEach(({ start, length, mentionUuid, replacementText }) => { .forEach(({ start, length, mentionUuid, replacementText }) => {
const op = ops.shift(); const op = ops.shift();

View file

@ -44,7 +44,7 @@ import type {
ConversationAttributesType, ConversationAttributesType,
MessageAttributesType, MessageAttributesType,
} from '../../model-types.d'; } from '../../model-types.d';
import type { BodyRangeType } from '../../types/Util'; import type { DraftBodyRangesType } from '../../types/Util';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import type { MediaItemType } from '../../types/MediaItem'; import type { MediaItemType } from '../../types/MediaItem';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
@ -203,7 +203,7 @@ export type ConversationType = {
shouldShowDraft?: boolean; shouldShowDraft?: boolean;
draftText?: string | null; draftText?: string | null;
draftBodyRanges?: Array<BodyRangeType>; draftBodyRanges?: DraftBodyRangesType;
draftPreview?: string; draftPreview?: string;
sharedGroupNames: Array<string>; sharedGroupNames: Array<string>;

View file

@ -6,7 +6,7 @@ import { isEqual, pick } from 'lodash';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import type { AttachmentType } from '../../types/Attachment'; 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 { ConversationModel } from '../../models/conversations';
import type { MessageAttributesType } from '../../model-types.d'; import type { MessageAttributesType } from '../../model-types.d';
import type { import type {
@ -500,7 +500,7 @@ function reactToStory(
function replyToStory( function replyToStory(
conversationId: string, conversationId: string,
messageBody: string, messageBody: string,
mentions: Array<BodyRangeType>, mentions: DraftBodyRangesType,
timestamp: number, timestamp: number,
story: StoryViewType story: StoryViewType
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {

View file

@ -37,7 +37,7 @@ import type { UUIDStringType } from '../../types/UUID';
import type { EmbeddedContactType } from '../../types/EmbeddedContact'; import type { EmbeddedContactType } from '../../types/EmbeddedContact';
import { embeddedContactSelector } 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 type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getMentionsRegex } from '../../types/Message'; import { getMentionsRegex } from '../../types/Message';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
@ -289,7 +289,7 @@ export const processBodyRanges = createSelectorCreator(memoizeByRoot, isEqual)(
( (
{ bodyRanges }: Pick<MessageWithUIFieldsType, 'bodyRanges'>, { bodyRanges }: Pick<MessageWithUIFieldsType, 'bodyRanges'>,
{ conversationSelector }: { conversationSelector: GetConversationByIdType } { conversationSelector }: { conversationSelector: GetConversationByIdType }
): BodyRangesType | undefined => { ): HydratedBodyRangesType | undefined => {
if (!bodyRanges) { if (!bodyRanges) {
return undefined; return undefined;
} }
@ -307,7 +307,7 @@ export const processBodyRanges = createSelectorCreator(memoizeByRoot, isEqual)(
}) })
.sort((a, b) => b.start - a.start); .sort((a, b) => b.start - a.start);
}, },
(_, ranges): undefined | BodyRangesType => ranges (_, ranges): undefined | HydratedBodyRangesType => ranges
); );
const getAuthorForMessage = createSelectorCreator(memoizeByRoot)( const getAuthorForMessage = createSelectorCreator(memoizeByRoot)(
@ -780,7 +780,7 @@ export const getPropsForMessage: (
( (
_, _,
attachments: Array<AttachmentType>, attachments: Array<AttachmentType>,
bodyRanges: BodyRangesType | undefined, bodyRanges: HydratedBodyRangesType | undefined,
author: PropsData['author'], author: PropsData['author'],
previews: Array<LinkPreviewType>, previews: Array<LinkPreviewType>,
reactions: PropsData['reactions'], reactions: PropsData['reactions'],

View file

@ -28,7 +28,7 @@ import {
getConversationSelector, getConversationSelector,
} from './conversations'; } from './conversations';
import type { BodyRangeType } from '../../types/Util'; import type { BodyRangeType, HydratedBodyRangeType } from '../../types/Util';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
@ -173,14 +173,17 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
conversationId: message.conversationId, conversationId: message.conversationId,
sentAt: message.sent_at, sentAt: message.sent_at,
snippet: message.snippet || '', snippet: message.snippet || '',
bodyRanges: bodyRanges.map((bodyRange: BodyRangeType) => { bodyRanges: bodyRanges.map(
const conversation = conversationSelector(bodyRange.mentionUuid); (bodyRange: BodyRangeType): HydratedBodyRangeType => {
const conversation = conversationSelector(bodyRange.mentionUuid);
return { return {
...bodyRange, ...bodyRange,
replacementText: conversation.title, conversationID: conversation.id,
}; replacementText: conversation.title,
}), };
}
),
body: message.body || '', body: message.body || '',
isSelected: Boolean( isSelected: Boolean(

View file

@ -296,15 +296,20 @@ export const getStoryReplies = createSelector(
? me ? me
: conversationSelector(reply.sourceUuid || reply.source); : conversationSelector(reply.sourceUuid || reply.source);
const { bodyRanges } = reply;
return { return {
author: getAvatarData(conversation), author: getAvatarData(conversation),
...pick(reply, [ ...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']),
'body', bodyRanges: bodyRanges?.map(bodyRange => {
'bodyRanges', const mentionConvo = conversationSelector(bodyRange.mentionUuid);
'deletedForEveryone',
'id', return {
'timestamp', ...bodyRange,
]), conversationID: mentionConvo.id,
replacementText: mentionConvo.title,
};
}),
reactionEmoji: reply.storyReaction?.emoji, reactionEmoji: reply.storyReaction?.emoji,
contactNameColor: contactNameColorSelector( contactNameColor: contactNameColorSelector(
reply.conversationId, reply.conversationId,

View file

@ -3,7 +3,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; 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 { ForwardMessagePropsType } from '../ducks/globalModals';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
@ -121,7 +121,7 @@ export function SmartForwardMessageModal(): JSX.Element | null {
onClose={closeModal} onClose={closeModal}
onEditorStateChange={( onEditorStateChange={(
messageText: string, messageText: string,
_: Array<BodyRangeType>, _: DraftBodyRangesType,
caretLocation?: number caretLocation?: number
) => { ) => {
if (!attachments.length) { if (!attachments.length) {

View file

@ -12,6 +12,7 @@ describe('getTextWithMentions', () => {
length: 1, length: 1,
mentionUuid: 'abcdef', mentionUuid: 'abcdef',
replacementText: 'fred', replacementText: 'fred',
conversationID: 'x',
start: 4, start: 4,
}, },
]; ];
@ -28,12 +29,14 @@ describe('getTextWithMentions', () => {
length: 1, length: 1,
mentionUuid: 'blarg', mentionUuid: 'blarg',
replacementText: 'jerry', replacementText: 'jerry',
conversationID: 'x',
start: 0, start: 0,
}, },
{ {
length: 1, length: 1,
mentionUuid: 'abcdef', mentionUuid: 'abcdef',
replacementText: 'fred', replacementText: 'fred',
conversationID: 'x',
start: 7, start: 7,
}, },
]; ];

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from './Attachment'; import type { AttachmentType } from './Attachment';
import type { BodyRangesType, LocalizerType } from './Util'; import type { HydratedBodyRangesType, LocalizerType } from './Util';
import type { ContactNameColorType } from './Colors'; import type { ContactNameColorType } from './Colors';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { ReadStatus } from '../messages/MessageReadStatus'; import type { ReadStatus } from '../messages/MessageReadStatus';
@ -25,7 +25,7 @@ export type ReplyType = {
| 'title' | 'title'
>; >;
body?: string; body?: string;
bodyRanges?: BodyRangesType; bodyRanges?: HydratedBodyRangesType;
contactNameColor?: ContactNameColorType; contactNameColor?: ContactNameColorType;
conversationId: string; conversationId: string;
deletedForEveryone?: boolean; deletedForEveryone?: boolean;

View file

@ -4,15 +4,31 @@
import type { IntlShape } from 'react-intl'; import type { IntlShape } from 'react-intl';
import type { UUIDStringType } from './UUID'; import type { UUIDStringType } from './UUID';
// Cold storage of body ranges
export type BodyRangeType = { export type BodyRangeType = {
start: number; start: number;
length: number; length: number;
mentionUuid?: string; mentionUuid: string;
replacementText?: string;
conversationID?: string;
}; };
export type BodyRangesType = Array<BodyRangeType>; export type BodyRangesType = ReadonlyArray<BodyRangeType>;
// Used exclusive in CompositionArea and related conversation_view.tsx calls.
export type DraftBodyRangeType = BodyRangeType & {
replacementText: string;
};
export type DraftBodyRangesType = ReadonlyArray<DraftBodyRangeType>;
// Fully hydrated body range to be used in UI components.
export type HydratedBodyRangeType = DraftBodyRangeType & {
conversationID: string;
};
export type HydratedBodyRangesType = ReadonlyArray<HydratedBodyRangeType>;
export type StoryContextType = { export type StoryContextType = {
authorUuid?: UUIDStringType; authorUuid?: UUIDStringType;

View file

@ -1,13 +1,14 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { BodyRangesType } from '../types/Util'; import type { DraftBodyRangeType, DraftBodyRangesType } from '../types/Util';
export function getTextWithMentions( export function getTextWithMentions(
bodyRanges: BodyRangesType, bodyRanges: DraftBodyRangesType,
text: string text: string
): string { ): string {
return bodyRanges const sortableBodyRanges: Array<DraftBodyRangeType> = bodyRanges.slice();
return sortableBodyRanges
.sort((a, b) => b.start - a.start) .sort((a, b) => b.start - a.start)
.reduce((acc, { start, length, replacementText }) => { .reduce((acc, { start, length, replacementText }) => {
const left = acc.slice(0, start); const left = acc.slice(0, start);

View file

@ -12,7 +12,7 @@ import { render } from 'mustache';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import { isGIF } from '../types/Attachment'; import { isGIF } from '../types/Attachment';
import * as Stickers from '../types/Stickers'; 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 { MIMEType } from '../types/MIME';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import type { import type {
@ -202,7 +202,7 @@ const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
export class ConversationView extends window.Backbone.View<ConversationModel> { export class ConversationView extends window.Backbone.View<ConversationModel> {
private debouncedSaveDraft: ( private debouncedSaveDraft: (
messageText: string, messageText: string,
bodyRanges: Array<BodyRangeType> bodyRanges: DraftBodyRangesType
) => Promise<void>; ) => Promise<void>;
private lazyUpdateVerified: () => void; private lazyUpdateVerified: () => void;
@ -544,7 +544,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.sendStickerMessage({ packId, stickerId }), this.sendStickerMessage({ packId, stickerId }),
onEditorStateChange: ( onEditorStateChange: (
msg: string, msg: string,
bodyRanges: Array<BodyRangeType>, bodyRanges: DraftBodyRangesType,
caretLocation?: number caretLocation?: number
) => this.onEditorStateChange(msg, bodyRanges, caretLocation), ) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => showToast(ToastMessageBodyTooLong), onTextTooLong: () => showToast(ToastMessageBodyTooLong),
@ -621,7 +621,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
voiceNoteAttachment, voiceNoteAttachment,
}: { }: {
draftAttachments?: ReadonlyArray<AttachmentType>; draftAttachments?: ReadonlyArray<AttachmentType>;
mentions?: BodyRangesType; mentions?: DraftBodyRangesType;
message?: string; message?: string;
timestamp?: number; timestamp?: number;
voiceNoteAttachment?: AttachmentType; voiceNoteAttachment?: AttachmentType;
@ -2490,7 +2490,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
async sendMessage( async sendMessage(
message = '', message = '',
mentions: BodyRangesType = [], mentions: DraftBodyRangesType = [],
options: { options: {
draftAttachments?: ReadonlyArray<AttachmentType>; draftAttachments?: ReadonlyArray<AttachmentType>;
timestamp?: number; timestamp?: number;
@ -2589,7 +2589,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
onEditorStateChange( onEditorStateChange(
messageText: string, messageText: string,
bodyRanges: Array<BodyRangeType>, bodyRanges: DraftBodyRangesType,
caretLocation?: number caretLocation?: number
): void { ): void {
this.maybeBumpTyping(messageText); this.maybeBumpTyping(messageText);
@ -2605,7 +2605,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
async saveDraft( async saveDraft(
messageText: string, messageText: string,
bodyRanges: Array<BodyRangeType> bodyRanges: DraftBodyRangesType
): Promise<void> { ): Promise<void> {
const { model }: { model: ConversationModel } = this; const { model }: { model: ConversationModel } = this;