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"
},