From 9657c38987a333348088e2e93b7eb1bb163a3c1c Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Wed, 16 Sep 2020 18:42:48 -0400 Subject: [PATCH] @mentions receive support --- js/models/conversations.js | 1 + js/models/messages.js | 60 +++++++++- protos/SignalService.proto | 14 ++- stylesheets/_modules.scss | 40 +++++++ stylesheets/_variables.scss | 1 + .../ConversationListItem.stories.tsx | 13 +++ .../conversation/AtMentionify.stories.tsx | 91 +++++++++++++++ ts/components/conversation/AtMentionify.tsx | 104 ++++++++++++++++++ .../conversation/Message.stories.tsx | 17 +++ ts/components/conversation/Message.tsx | 14 ++- .../conversation/MessageBody.stories.tsx | 77 +++++++++++++ ts/components/conversation/MessageBody.tsx | 51 +++++++-- ts/components/conversation/Quote.stories.tsx | 39 +++++++ ts/components/conversation/Quote.tsx | 23 +++- ts/textsecure.d.ts | 8 ++ ts/textsecure/SendMessage.ts | 11 +- ts/types/Util.ts | 8 ++ ts/util/lint/exceptions.json | 6 +- 18 files changed, 555 insertions(+), 23 deletions(-) create mode 100644 ts/components/conversation/AtMentionify.stories.tsx create mode 100644 ts/components/conversation/AtMentionify.tsx diff --git a/js/models/conversations.js b/js/models/conversations.js index 036862eb2a..2e98052154 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1552,6 +1552,7 @@ return { author: contact.get('e164'), authorUuid: contact.get('uuid'), + bodyRanges: quotedMessage.get('bodyRanges'), id: quotedMessage.get('sent_at'), text: body || embeddedContactName, attachments: quotedMessage.isTapToView() diff --git a/js/models/messages.js b/js/models/messages.js index a456de4789..ca6fe7a74c 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -660,9 +660,49 @@ isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'), deletedForEveryone: this.get('deletedForEveryone') || false, + bodyRanges: this.processBodyRanges(), }; }, + processBodyRanges(bodyRanges = this.get('bodyRanges')) { + if (!bodyRanges) { + return; + } + + // eslint-disable-next-line consistent-return + return ( + bodyRanges + .map(range => { + if (range.mentionUuid) { + const contactID = ConversationController.ensureContactIds({ + uuid: range.mentionUuid, + }); + const conversation = this.findContact(contactID); + + return { + ...range, + conversationID: contactID, + replacementText: conversation.getTitle(), + }; + } + + return null; + }) + .filter(Boolean) + // sorting in a descending order so that we can safely replace the + // positions in the text + .sort((a, b) => b.start - a.start) + ); + }, + + getTextWithMentionStrings(bodyRanges, text) { + return bodyRanges.reduce((str, range) => { + const textBegin = str.substr(0, range.start); + const textEnd = str.substr(range.start + range.length, str.length); + return `${textBegin}@${range.replacementText}${textEnd}`; + }, text); + }, + // Dependencies of prop-generation functions findAndFormatContact(identifier) { if (!identifier) { @@ -822,9 +862,12 @@ const { author, authorUuid, + bodyRanges, id: sentAt, referencedMessageNotFound, + text, } = quote; + const contact = (author || authorUuid) && ConversationController.get( @@ -845,10 +888,11 @@ const firstAttachment = quote.attachments && quote.attachments[0]; return { - text: this.createNonBreakingLastSeparator(quote.text), + text: this.createNonBreakingLastSeparator(text), attachment: firstAttachment ? this.processQuoteAttachment(firstAttachment) : null, + bodyRanges: this.processBodyRanges(bodyRanges), isFromMe, sentAt, authorId: author, @@ -1154,16 +1198,25 @@ getNotificationText() /* : string */ { const { text, emoji } = this.getNotificationData(); + let modifiedText = text; + + const hasMentions = Boolean(this.get('bodyRanges')); + + if (hasMentions) { + const bodyRanges = this.processBodyRanges(); + modifiedText = this.getTextWithMentionStrings(bodyRanges, modifiedText); + } + // Linux emoji support is mixed, so we disable it. (Note that this doesn't touch // the `text`, which can contain emoji.) const shouldIncludeEmoji = Boolean(emoji) && !Signal.OS.isLinux(); if (shouldIncludeEmoji) { return i18n('message--getNotificationText--text-with-emoji', { - text, + text: modifiedText, emoji, }); } - return text; + return modifiedText; }, // General @@ -2567,6 +2620,7 @@ id: window.getGuid(), attachments: dataMessage.attachments, body: dataMessage.body, + bodyRanges: dataMessage.bodyRanges, contact: dataMessage.contact, conversationId: conversation.id, decrypted_at: now, diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 037a09af6e..7598c1bed8 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -116,6 +116,7 @@ message DataMessage { optional string authorUuid = 5; optional string text = 3; repeated QuotedAttachment attachments = 4; + repeated BodyRange bodyRanges = 6; } message Contact { @@ -212,6 +213,15 @@ message DataMessage { optional uint64 targetSentTimestamp = 1; } + message BodyRange { + optional uint32 start = 1; + optional uint32 length = 2; + + // oneof associatedValue { + optional string mentionUuid = 3; + //} + } + enum ProtocolVersion { option allow_alias = true; @@ -221,7 +231,8 @@ message DataMessage { VIEW_ONCE_VIDEO = 3; REACTIONS = 4; CDN_SELECTOR_ATTACHMENTS = 5; - CURRENT = 5; + MENTIONS = 6; + CURRENT = 6; } optional string body = 1; @@ -240,6 +251,7 @@ message DataMessage { optional bool isViewOnce = 14; optional Reaction reaction = 16; optional Delete delete = 17; + repeated BodyRange bodyRanges = 18; } message NullMessage { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 61191dcfb5..aecb5b7722 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5265,6 +5265,46 @@ button.module-image__border-overlay:focus { font-weight: bold; } +.module-message-body__at-mention { + background-color: $color-black-alpha-40; + border-radius: 4px; + cursor: pointer; + display: inline-block; + padding-left: 4px; + padding-right: 4px; + + &:focus { + border: solid 1px $color-black; + outline: none; + } +} + +.module-message-body__at-mention--incoming { + @include ios-theme { + @include light-theme { + background-color: $color-gray-20; + } + + @include dark-theme { + background-color: $color-gray-60; + } + } +} + +.module-message-body__at-mention--outgoing { + @include light-theme { + background-color: $color-gray-20; + } + + @include dark-theme { + background-color: $color-gray-60; + } + + @include ios-theme { + background-color: $ultramarine-brand-dark; + } +} + // Module: Search Results .module-search-results { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 21dffebab0..f1255b5ef2 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -13,6 +13,7 @@ $color-white: #ffffff; $color-gray-02: #f6f6f6; $color-gray-05: #e9e9e9; $color-gray-15: #dedede; +$color-gray-20: #c6c6c6; $color-gray-25: #b9b9b9; $color-gray-45: #848484; $color-gray-60: #5e5e5e; diff --git a/ts/components/ConversationListItem.stories.tsx b/ts/components/ConversationListItem.stories.tsx index c891e3a493..0c8227d741 100644 --- a/ts/components/ConversationListItem.stories.tsx +++ b/ts/components/ConversationListItem.stories.tsx @@ -253,3 +253,16 @@ story.add('Muted Conversation', () => { return ; }); + +story.add('At Mention', () => { + const props = createProps({ + title: 'The Rebellion', + type: 'group', + lastMessage: { + text: '@Leia Organa I know', + status: 'read', + }, + }); + + return ; +}); diff --git a/ts/components/conversation/AtMentionify.stories.tsx b/ts/components/conversation/AtMentionify.stories.tsx new file mode 100644 index 0000000000..5eec2d8a40 --- /dev/null +++ b/ts/components/conversation/AtMentionify.stories.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; + +import { action } from '@storybook/addon-actions'; +import { select, text } from '@storybook/addon-knobs'; +import { storiesOf } from '@storybook/react'; + +import { AtMentionify, Props } from './AtMentionify'; + +const story = storiesOf('Components/Conversation/AtMentionify', module); + +const createProps = (overrideProps: Partial = {}): Props => ({ + bodyRanges: overrideProps.bodyRanges, + direction: select( + 'direction', + { incoming: 'incoming', outgoing: 'outgoing' }, + overrideProps.direction || 'incoming' + ), + openConversation: action('openConversation'), + text: text('text', overrideProps.text || ''), +}); + +story.add('No @mentions', () => { + const props = createProps({ + text: 'Hello World', + }); + + return ; +}); + +story.add('Multiple @Mentions', () => { + const bodyRanges = [ + { + start: 4, + length: 1, + mentionUuid: 'abc', + replacementText: 'Professor Farnsworth', + }, + { + start: 2, + length: 1, + mentionUuid: 'def', + replacementText: 'Philip J Fry', + }, + { + start: 0, + length: 1, + mentionUuid: 'xyz', + replacementText: 'Yancy Fry', + }, + ]; + const props = createProps({ + bodyRanges, + direction: 'outgoing', + text: AtMentionify.preprocessMentions('\uFFFC \uFFFC \uFFFC', bodyRanges), + }); + + return ; +}); + +story.add('Complex @mentions', () => { + const bodyRanges = [ + { + start: 80, + length: 1, + mentionUuid: 'ioe', + replacementText: 'Cereal Killer', + }, + { + start: 78, + length: 1, + mentionUuid: 'fdr', + replacementText: 'Acid Burn', + }, + { + start: 4, + length: 1, + mentionUuid: 'ope', + replacementText: 'Zero Cool', + }, + ]; + + const props = createProps({ + bodyRanges, + text: AtMentionify.preprocessMentions( + 'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC', + bodyRanges + ), + }); + + return ; +}); diff --git a/ts/components/conversation/AtMentionify.tsx b/ts/components/conversation/AtMentionify.tsx new file mode 100644 index 0000000000..34403840b3 --- /dev/null +++ b/ts/components/conversation/AtMentionify.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Emojify } from './Emojify'; +import { BodyRangesType } from '../../types/Util'; + +export type Props = { + bodyRanges?: BodyRangesType; + direction?: 'incoming' | 'outgoing'; + openConversation?: (conversationId: string, messageId?: string) => void; + text: string; +}; + +export const AtMentionify = ({ + bodyRanges, + direction, + openConversation, + text, +}: Props): JSX.Element => { + if (!bodyRanges) { + return <>{text}; + } + + const MENTIONS_REGEX = /(\uFFFC@(\d+))/g; + + let match = MENTIONS_REGEX.exec(text); + let last = 0; + + const rangeStarts = new Map(); + bodyRanges.forEach(range => { + rangeStarts.set(range.start, range); + }); + + const results = []; + while (match) { + if (last < match.index) { + const textWithNoMentions = text.slice(last, match.index); + results.push(textWithNoMentions); + } + + const rangeStart = Number(match[2]); + const range = rangeStarts.get(rangeStart); + + if (range) { + results.push( + { + if (openConversation && range.conversationID) { + openConversation(range.conversationID); + } + }} + onKeyUp={e => { + if ( + e.target === e.currentTarget && + e.keyCode === 13 && + openConversation && + range.conversationID + ) { + openConversation(range.conversationID); + } + }} + tabIndex={0} + role="link" + > + @ + + + ); + } + + last = MENTIONS_REGEX.lastIndex; + match = MENTIONS_REGEX.exec(text); + } + + if (last < text.length) { + results.push(text.slice(last)); + } + + return <>{results}; +}; + +// At-mentions need to be pre-processed before being pushed through the +// AtMentionify component, this is due to bodyRanges containing start+length +// values that operate on the raw string. The text has to be passed through +// other components before being rendered in the , components +// such as Linkify, and Emojify. These components receive the text prop as a +// string, therefore we're unable to mark it up with DOM nodes prior to handing +// it off to them. This function will encode the "start" position into the text +// string so we can later pull it off when rendering the @mention. +AtMentionify.preprocessMentions = ( + text: string, + bodyRanges?: BodyRangesType +): string => { + if (!bodyRanges || !bodyRanges.length) { + return text; + } + + return bodyRanges.reduce((str, range) => { + const textBegin = str.substr(0, range.start); + const encodedMention = `\uFFFC@${range.start}`; + const textEnd = str.substr(range.start + range.length, str.length); + return `${textBegin}${encodedMention}${textEnd}`; + }, text); +}; diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 4a6660193f..b87e25a4ca 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -45,6 +45,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ authorColor: overrideProps.authorColor || 'blue', authorAvatarPath: overrideProps.authorAvatarPath, authorTitle: text('authorTitle', overrideProps.authorTitle || ''), + bodyRanges: overrideProps.bodyRanges, canReply: true, clearSelectedMessage: action('clearSelectedMessage'), collapseMetadata: overrideProps.collapseMetadata, @@ -769,3 +770,19 @@ story.add('Colors', () => { ); }); + +story.add('@Mentions', () => { + const props = createProps({ + bodyRanges: [ + { + start: 0, + length: 1, + mentionUuid: 'zap', + replacementText: 'Zapp Brannigan', + }, + ], + text: '\uFFFC This Is It. The Moment We Should Have Trained For.', + }); + + return renderBothDirections(props); +}); diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index ae7dfcd75a..06fba11b20 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -40,7 +40,7 @@ import { ContactType } from '../../types/Contact'; import { getIncrement } from '../../util/timer'; import { isFileDangerous } from '../../util/isFileDangerous'; -import { LocalizerType } from '../../types/Util'; +import { BodyRangesType, LocalizerType } from '../../types/Util'; import { ColorType } from '../../types/Colors'; import { createRefMerger } from '../_util'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; @@ -116,6 +116,7 @@ export type PropsData = { authorTitle: string; authorName?: string; authorColor?: ColorType; + bodyRanges?: BodyRangesType; referencedMessageNotFound: boolean; }; previews: Array; @@ -135,6 +136,7 @@ export type PropsData = { deletedForEveryone?: boolean; canReply: boolean; + bodyRanges?: BodyRangesType; }; export type PropsHousekeeping = { @@ -905,6 +907,7 @@ export class Message extends React.PureComponent { direction, disableScroll, i18n, + openConversation, quote, scrollToQuotedMessage, } = this.props; @@ -940,6 +943,8 @@ export class Message extends React.PureComponent { authorName={quote.authorName} authorColor={quoteColor} authorTitle={quote.authorTitle} + bodyRanges={quote.bodyRanges} + openConversation={openConversation} referencedMessageNotFound={referencedMessageNotFound} isFromMe={quote.isFromMe} withContentAbove={withContentAbove} @@ -1045,9 +1050,11 @@ export class Message extends React.PureComponent { public renderText() { const { + bodyRanges, deletedForEveryone, direction, i18n, + openConversation, status, text, textPending, @@ -1075,8 +1082,11 @@ export class Message extends React.PureComponent { )} > diff --git a/ts/components/conversation/MessageBody.stories.tsx b/ts/components/conversation/MessageBody.stories.tsx index e79da34917..f000b4017b 100644 --- a/ts/components/conversation/MessageBody.stories.tsx +++ b/ts/components/conversation/MessageBody.stories.tsx @@ -14,11 +14,13 @@ const i18n = setupI18n('en', enMessages); const story = storiesOf('Components/Conversation/MessageBody', module); const createProps = (overrideProps: Partial = {}): Props => ({ + bodyRanges: overrideProps.bodyRanges, disableJumbomoji: boolean( 'disableJumbomoji', overrideProps.disableJumbomoji || false ), disableLinks: boolean('disableLinks', overrideProps.disableLinks || false), + direction: 'incoming', i18n, text: text('text', overrideProps.text || ''), textPending: boolean('textPending', overrideProps.textPending || false), @@ -92,3 +94,78 @@ story.add('Text Pending', () => { return ; }); + +story.add('@Mention', () => { + const props = createProps({ + bodyRanges: [ + { + start: 5, + length: 1, + mentionUuid: 'tuv', + replacementText: 'Bender B Rodriguez 🤖', + }, + ], + text: + 'Like \uFFFC once said: My story is a lot like yours, only more interesting because it involves robots', + }); + + return ; +}); + +story.add('Multiple @Mentions', () => { + const props = createProps({ + bodyRanges: [ + { + start: 4, + length: 1, + mentionUuid: 'abc', + replacementText: 'Professor Farnsworth', + }, + { + start: 2, + length: 1, + mentionUuid: 'def', + replacementText: 'Philip J Fry', + }, + { + start: 0, + length: 1, + mentionUuid: 'xyz', + replacementText: 'Yancy Fry', + }, + ], + text: '\uFFFC \uFFFC \uFFFC', + }); + + return ; +}); + +story.add('Complex MessageBody', () => { + const props = createProps({ + bodyRanges: [ + { + start: 80, + length: 1, + mentionUuid: 'xox', + replacementText: 'Cereal Killer', + }, + { + start: 78, + length: 1, + mentionUuid: 'wer', + replacementText: 'Acid Burn', + }, + { + start: 4, + length: 1, + mentionUuid: 'ldo', + replacementText: 'Zero Cool', + }, + ], + direction: 'outgoing', + text: + 'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC', + }); + + return ; +}); diff --git a/ts/components/conversation/MessageBody.tsx b/ts/components/conversation/MessageBody.tsx index 9a537f2a43..178c976589 100644 --- a/ts/components/conversation/MessageBody.tsx +++ b/ts/components/conversation/MessageBody.tsx @@ -1,13 +1,24 @@ import React from 'react'; import { getSizeClass, SizeClassType } from '../emoji/lib'; +import { AtMentionify } from './AtMentionify'; import { Emojify } from './Emojify'; import { AddNewLines } from './AddNewLines'; import { Linkify } from './Linkify'; -import { LocalizerType, RenderTextCallbackType } from '../../types/Util'; +import { + BodyRangesType, + LocalizerType, + RenderTextCallbackType, +} from '../../types/Util'; + +type OpenConversationActionType = ( + conversationId: string, + messageId?: string +) => void; export interface Props { + direction?: 'incoming' | 'outgoing'; text: string; textPending?: boolean; /** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */ @@ -15,13 +26,10 @@ export interface Props { /** If set, links will be left alone instead of turned into clickable `` tags. */ disableLinks?: boolean; i18n: LocalizerType; + bodyRanges?: BodyRangesType; + openConversation?: OpenConversationActionType; } -const renderNewLines: RenderTextCallbackType = ({ - text: textWithNewLines, - key, -}) => ; - const renderEmoji = ({ text, key, @@ -49,6 +57,27 @@ const renderEmoji = ({ * them for you. */ export class MessageBody extends React.Component { + private readonly renderNewLines: RenderTextCallbackType = ({ + text: textWithNewLines, + key, + }) => { + const { bodyRanges, direction, openConversation } = this.props; + return ( + ( + + )} + /> + ); + }; + public addDownloading(jsx: JSX.Element): JSX.Element { const { i18n, textPending } = this.props; @@ -67,6 +96,7 @@ export class MessageBody extends React.Component { public render() { const { + bodyRanges, text, textPending, disableJumbomoji, @@ -74,7 +104,10 @@ export class MessageBody extends React.Component { i18n, } = this.props; const sizeClass = disableJumbomoji ? undefined : getSizeClass(text); - const textWithPending = textPending ? `${text}...` : text; + const textWithPending = AtMentionify.preprocessMentions( + textPending ? `${text}...` : text, + bodyRanges + ); if (disableLinks) { return this.addDownloading( @@ -83,7 +116,7 @@ export class MessageBody extends React.Component { text: textWithPending, sizeClass, key: 0, - renderNonEmoji: renderNewLines, + renderNonEmoji: this.renderNewLines, }) ); } @@ -97,7 +130,7 @@ export class MessageBody extends React.Component { text: nonLinkText, sizeClass, key, - renderNonEmoji: renderNewLines, + renderNonEmoji: this.renderNewLines, }); }} /> diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index be4f6bcb19..0d4b0e657f 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -106,6 +106,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ isIncoming: boolean('isIncoming', overrideProps.isIncoming || false), onClick: action('onClick'), onClose: action('onClose'), + openConversation: action('openConversation'), referencedMessageNotFound: boolean( 'referencedMessageNotFound', overrideProps.referencedMessageNotFound || false @@ -358,3 +359,41 @@ story.add('Missing Text & Attachment', () => { return ; }); + +story.add('@mention + outgoing + another author', () => { + const props = createProps({ + authorTitle: 'Tony Stark', + text: '@Captain America Lunch later?', + }); + + return ; +}); + +story.add('@mention + outgoing + me', () => { + const props = createProps({ + isFromMe: true, + text: '@Captain America Lunch later?', + }); + + return ; +}); + +story.add('@mention + incoming + another author', () => { + const props = createProps({ + authorTitle: 'Captain America', + isIncoming: true, + text: '@Tony Stark sure', + }); + + return ; +}); + +story.add('@mention + incoming + me', () => { + const props = createProps({ + isFromMe: true, + isIncoming: true, + text: '@Tony Stark sure', + }); + + return ; +}); diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index a1ae413c4f..6f7e8bba36 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -7,7 +7,7 @@ import * as MIME from '../../../ts/types/MIME'; import * as GoogleChrome from '../../../ts/util/GoogleChrome'; import { MessageBody } from './MessageBody'; -import { LocalizerType } from '../../types/Util'; +import { BodyRangesType, LocalizerType } from '../../types/Util'; import { ColorType } from '../../types/Colors'; import { ContactName } from './ContactName'; @@ -18,12 +18,14 @@ export interface Props { authorProfileName?: string; authorName?: string; authorColor?: ColorType; + bodyRanges?: BodyRangesType; i18n: LocalizerType; isFromMe: boolean; isIncoming: boolean; withContentAbove: boolean; onClick?: () => void; onClose?: () => void; + openConversation: (conversationId: string, messageId?: string) => void; text: string; referencedMessageNotFound: boolean; } @@ -228,8 +230,15 @@ export class Quote extends React.Component { return null; } - public renderText() { - const { i18n, text, attachment, isIncoming } = this.props; + public renderText(): JSX.Element | null { + const { + bodyRanges, + i18n, + text, + attachment, + isIncoming, + openConversation, + } = this.props; if (text) { return ( @@ -240,7 +249,13 @@ export class Quote extends React.Component { isIncoming ? 'module-quote__primary__text--incoming' : null )} > - + ); } diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index bb5de6b4b0..ee6f9a8f70 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -577,6 +577,7 @@ export declare namespace DataMessageClass { static VIEW_ONCE: number; static VIEW_ONCE_VIDEO: number; static REACTIONS: number; + static MENTIONS: number; static CURRENT: number; } @@ -587,6 +588,13 @@ export declare namespace DataMessageClass { authorUuid?: string; text?: string; attachments?: Array; + bodyRanges?: Array; + } + + class BodyRange { + start?: number; + length?: number; + mentionUuid?: string; } class Reaction { diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 0646cec13c..2cd766142f 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -30,6 +30,7 @@ import { StorageServiceCredentials, } from '../textsecure.d'; import { MessageError, SignedPreKeyRotationError } from './Errors'; +import { BodyRangesType } from '../types/Util'; function stringToArrayBuffer(str: string): ArrayBuffer { if (typeof str !== 'string') { @@ -258,7 +259,7 @@ class Message { } if (this.quote) { const { QuotedAttachment } = window.textsecure.protobuf.DataMessage.Quote; - const { Quote } = window.textsecure.protobuf.DataMessage; + const { BodyRange, Quote } = window.textsecure.protobuf.DataMessage; proto.quote = new Quote(); const { quote } = proto; @@ -279,6 +280,14 @@ class Message { return quotedAttachment; } ); + const bodyRanges: BodyRangesType = this.quote.bodyRanges || []; + quote.bodyRanges = bodyRanges.map(range => { + const bodyRange = new BodyRange(); + bodyRange.start = range.start; + bodyRange.length = range.length; + bodyRange.mentionUuid = range.mentionUuid; + return bodyRange; + }); } if (this.expireTimer) { proto.expireTimer = this.expireTimer; diff --git a/ts/types/Util.ts b/ts/types/Util.ts index 2ec3d816f6..15787a1679 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -1,3 +1,11 @@ +export type BodyRangesType = Array<{ + start: number; + length: number; + mentionUuid: string; + replacementText: string; + conversationID?: string; +}>; + export type RenderTextCallbackType = (options: { text: string; key: number; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 2a8f5c7e27..96694bbabb 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -13069,7 +13069,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public audioRef: React.RefObject = React.createRef();", - "lineNumber": 210, + "lineNumber": 212, "reasonCategory": "usageTrusted", "updated": "2020-08-28T19:36:40.817Z" }, @@ -13077,7 +13077,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public focusRef: React.RefObject = React.createRef();", - "lineNumber": 211, + "lineNumber": 213, "reasonCategory": "usageTrusted", "updated": "2020-09-11T17:24:56.124Z", "reasonDetail": "Used for managing focus only" @@ -13086,7 +13086,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " > = React.createRef();", - "lineNumber": 214, + "lineNumber": 216, "reasonCategory": "usageTrusted", "updated": "2020-08-28T19:36:40.817Z" },