@mentions receive support
This commit is contained in:
parent
c126a71864
commit
9657c38987
18 changed files with 555 additions and 23 deletions
|
@ -1552,6 +1552,7 @@
|
||||||
return {
|
return {
|
||||||
author: contact.get('e164'),
|
author: contact.get('e164'),
|
||||||
authorUuid: contact.get('uuid'),
|
authorUuid: contact.get('uuid'),
|
||||||
|
bodyRanges: quotedMessage.get('bodyRanges'),
|
||||||
id: quotedMessage.get('sent_at'),
|
id: quotedMessage.get('sent_at'),
|
||||||
text: body || embeddedContactName,
|
text: body || embeddedContactName,
|
||||||
attachments: quotedMessage.isTapToView()
|
attachments: quotedMessage.isTapToView()
|
||||||
|
|
|
@ -660,9 +660,49 @@
|
||||||
isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'),
|
isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'),
|
||||||
|
|
||||||
deletedForEveryone: this.get('deletedForEveryone') || false,
|
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
|
// Dependencies of prop-generation functions
|
||||||
findAndFormatContact(identifier) {
|
findAndFormatContact(identifier) {
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
|
@ -822,9 +862,12 @@
|
||||||
const {
|
const {
|
||||||
author,
|
author,
|
||||||
authorUuid,
|
authorUuid,
|
||||||
|
bodyRanges,
|
||||||
id: sentAt,
|
id: sentAt,
|
||||||
referencedMessageNotFound,
|
referencedMessageNotFound,
|
||||||
|
text,
|
||||||
} = quote;
|
} = quote;
|
||||||
|
|
||||||
const contact =
|
const contact =
|
||||||
(author || authorUuid) &&
|
(author || authorUuid) &&
|
||||||
ConversationController.get(
|
ConversationController.get(
|
||||||
|
@ -845,10 +888,11 @@
|
||||||
const firstAttachment = quote.attachments && quote.attachments[0];
|
const firstAttachment = quote.attachments && quote.attachments[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: this.createNonBreakingLastSeparator(quote.text),
|
text: this.createNonBreakingLastSeparator(text),
|
||||||
attachment: firstAttachment
|
attachment: firstAttachment
|
||||||
? this.processQuoteAttachment(firstAttachment)
|
? this.processQuoteAttachment(firstAttachment)
|
||||||
: null,
|
: null,
|
||||||
|
bodyRanges: this.processBodyRanges(bodyRanges),
|
||||||
isFromMe,
|
isFromMe,
|
||||||
sentAt,
|
sentAt,
|
||||||
authorId: author,
|
authorId: author,
|
||||||
|
@ -1154,16 +1198,25 @@
|
||||||
getNotificationText() /* : string */ {
|
getNotificationText() /* : string */ {
|
||||||
const { text, emoji } = this.getNotificationData();
|
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
|
// Linux emoji support is mixed, so we disable it. (Note that this doesn't touch
|
||||||
// the `text`, which can contain emoji.)
|
// the `text`, which can contain emoji.)
|
||||||
const shouldIncludeEmoji = Boolean(emoji) && !Signal.OS.isLinux();
|
const shouldIncludeEmoji = Boolean(emoji) && !Signal.OS.isLinux();
|
||||||
if (shouldIncludeEmoji) {
|
if (shouldIncludeEmoji) {
|
||||||
return i18n('message--getNotificationText--text-with-emoji', {
|
return i18n('message--getNotificationText--text-with-emoji', {
|
||||||
text,
|
text: modifiedText,
|
||||||
emoji,
|
emoji,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return text;
|
return modifiedText;
|
||||||
},
|
},
|
||||||
|
|
||||||
// General
|
// General
|
||||||
|
@ -2567,6 +2620,7 @@
|
||||||
id: window.getGuid(),
|
id: window.getGuid(),
|
||||||
attachments: dataMessage.attachments,
|
attachments: dataMessage.attachments,
|
||||||
body: dataMessage.body,
|
body: dataMessage.body,
|
||||||
|
bodyRanges: dataMessage.bodyRanges,
|
||||||
contact: dataMessage.contact,
|
contact: dataMessage.contact,
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
decrypted_at: now,
|
decrypted_at: now,
|
||||||
|
|
|
@ -116,6 +116,7 @@ message DataMessage {
|
||||||
optional string authorUuid = 5;
|
optional string authorUuid = 5;
|
||||||
optional string text = 3;
|
optional string text = 3;
|
||||||
repeated QuotedAttachment attachments = 4;
|
repeated QuotedAttachment attachments = 4;
|
||||||
|
repeated BodyRange bodyRanges = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Contact {
|
message Contact {
|
||||||
|
@ -212,6 +213,15 @@ message DataMessage {
|
||||||
optional uint64 targetSentTimestamp = 1;
|
optional uint64 targetSentTimestamp = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message BodyRange {
|
||||||
|
optional uint32 start = 1;
|
||||||
|
optional uint32 length = 2;
|
||||||
|
|
||||||
|
// oneof associatedValue {
|
||||||
|
optional string mentionUuid = 3;
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
|
||||||
enum ProtocolVersion {
|
enum ProtocolVersion {
|
||||||
option allow_alias = true;
|
option allow_alias = true;
|
||||||
|
|
||||||
|
@ -221,7 +231,8 @@ message DataMessage {
|
||||||
VIEW_ONCE_VIDEO = 3;
|
VIEW_ONCE_VIDEO = 3;
|
||||||
REACTIONS = 4;
|
REACTIONS = 4;
|
||||||
CDN_SELECTOR_ATTACHMENTS = 5;
|
CDN_SELECTOR_ATTACHMENTS = 5;
|
||||||
CURRENT = 5;
|
MENTIONS = 6;
|
||||||
|
CURRENT = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
optional string body = 1;
|
optional string body = 1;
|
||||||
|
@ -240,6 +251,7 @@ message DataMessage {
|
||||||
optional bool isViewOnce = 14;
|
optional bool isViewOnce = 14;
|
||||||
optional Reaction reaction = 16;
|
optional Reaction reaction = 16;
|
||||||
optional Delete delete = 17;
|
optional Delete delete = 17;
|
||||||
|
repeated BodyRange bodyRanges = 18;
|
||||||
}
|
}
|
||||||
|
|
||||||
message NullMessage {
|
message NullMessage {
|
||||||
|
|
|
@ -5265,6 +5265,46 @@ button.module-image__border-overlay:focus {
|
||||||
font-weight: bold;
|
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
|
||||||
|
|
||||||
.module-search-results {
|
.module-search-results {
|
||||||
|
|
|
@ -13,6 +13,7 @@ $color-white: #ffffff;
|
||||||
$color-gray-02: #f6f6f6;
|
$color-gray-02: #f6f6f6;
|
||||||
$color-gray-05: #e9e9e9;
|
$color-gray-05: #e9e9e9;
|
||||||
$color-gray-15: #dedede;
|
$color-gray-15: #dedede;
|
||||||
|
$color-gray-20: #c6c6c6;
|
||||||
$color-gray-25: #b9b9b9;
|
$color-gray-25: #b9b9b9;
|
||||||
$color-gray-45: #848484;
|
$color-gray-45: #848484;
|
||||||
$color-gray-60: #5e5e5e;
|
$color-gray-60: #5e5e5e;
|
||||||
|
|
|
@ -253,3 +253,16 @@ story.add('Muted Conversation', () => {
|
||||||
|
|
||||||
return <ConversationListItem {...props} muteExpiresAt={muteExpiresAt} />;
|
return <ConversationListItem {...props} muteExpiresAt={muteExpiresAt} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
story.add('At Mention', () => {
|
||||||
|
const props = createProps({
|
||||||
|
title: 'The Rebellion',
|
||||||
|
type: 'group',
|
||||||
|
lastMessage: {
|
||||||
|
text: '@Leia Organa I know',
|
||||||
|
status: 'read',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return <ConversationListItem {...props} />;
|
||||||
|
});
|
||||||
|
|
91
ts/components/conversation/AtMentionify.stories.tsx
Normal file
91
ts/components/conversation/AtMentionify.stories.tsx
Normal file
|
@ -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> = {}): 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 <AtMentionify {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 <AtMentionify {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 <AtMentionify {...props} />;
|
||||||
|
});
|
104
ts/components/conversation/AtMentionify.tsx
Normal file
104
ts/components/conversation/AtMentionify.tsx
Normal file
|
@ -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(
|
||||||
|
<span
|
||||||
|
className={`module-message-body__at-mention module-message-body__at-mention--${direction}`}
|
||||||
|
key={range.start}
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
@
|
||||||
|
<Emojify text={range.replacementText} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <MessageBody />, 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);
|
||||||
|
};
|
|
@ -45,6 +45,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
authorColor: overrideProps.authorColor || 'blue',
|
authorColor: overrideProps.authorColor || 'blue',
|
||||||
authorAvatarPath: overrideProps.authorAvatarPath,
|
authorAvatarPath: overrideProps.authorAvatarPath,
|
||||||
authorTitle: text('authorTitle', overrideProps.authorTitle || ''),
|
authorTitle: text('authorTitle', overrideProps.authorTitle || ''),
|
||||||
|
bodyRanges: overrideProps.bodyRanges,
|
||||||
canReply: true,
|
canReply: true,
|
||||||
clearSelectedMessage: action('clearSelectedMessage'),
|
clearSelectedMessage: action('clearSelectedMessage'),
|
||||||
collapseMetadata: overrideProps.collapseMetadata,
|
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);
|
||||||
|
});
|
||||||
|
|
|
@ -40,7 +40,7 @@ import { ContactType } from '../../types/Contact';
|
||||||
|
|
||||||
import { getIncrement } from '../../util/timer';
|
import { getIncrement } from '../../util/timer';
|
||||||
import { isFileDangerous } from '../../util/isFileDangerous';
|
import { isFileDangerous } from '../../util/isFileDangerous';
|
||||||
import { LocalizerType } from '../../types/Util';
|
import { BodyRangesType, LocalizerType } from '../../types/Util';
|
||||||
import { ColorType } from '../../types/Colors';
|
import { ColorType } from '../../types/Colors';
|
||||||
import { createRefMerger } from '../_util';
|
import { createRefMerger } from '../_util';
|
||||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
||||||
|
@ -116,6 +116,7 @@ export type PropsData = {
|
||||||
authorTitle: string;
|
authorTitle: string;
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
authorColor?: ColorType;
|
authorColor?: ColorType;
|
||||||
|
bodyRanges?: BodyRangesType;
|
||||||
referencedMessageNotFound: boolean;
|
referencedMessageNotFound: boolean;
|
||||||
};
|
};
|
||||||
previews: Array<LinkPreviewType>;
|
previews: Array<LinkPreviewType>;
|
||||||
|
@ -135,6 +136,7 @@ export type PropsData = {
|
||||||
deletedForEveryone?: boolean;
|
deletedForEveryone?: boolean;
|
||||||
|
|
||||||
canReply: boolean;
|
canReply: boolean;
|
||||||
|
bodyRanges?: BodyRangesType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsHousekeeping = {
|
export type PropsHousekeeping = {
|
||||||
|
@ -905,6 +907,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
direction,
|
direction,
|
||||||
disableScroll,
|
disableScroll,
|
||||||
i18n,
|
i18n,
|
||||||
|
openConversation,
|
||||||
quote,
|
quote,
|
||||||
scrollToQuotedMessage,
|
scrollToQuotedMessage,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
@ -940,6 +943,8 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
authorName={quote.authorName}
|
authorName={quote.authorName}
|
||||||
authorColor={quoteColor}
|
authorColor={quoteColor}
|
||||||
authorTitle={quote.authorTitle}
|
authorTitle={quote.authorTitle}
|
||||||
|
bodyRanges={quote.bodyRanges}
|
||||||
|
openConversation={openConversation}
|
||||||
referencedMessageNotFound={referencedMessageNotFound}
|
referencedMessageNotFound={referencedMessageNotFound}
|
||||||
isFromMe={quote.isFromMe}
|
isFromMe={quote.isFromMe}
|
||||||
withContentAbove={withContentAbove}
|
withContentAbove={withContentAbove}
|
||||||
|
@ -1045,9 +1050,11 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
public renderText() {
|
public renderText() {
|
||||||
const {
|
const {
|
||||||
|
bodyRanges,
|
||||||
deletedForEveryone,
|
deletedForEveryone,
|
||||||
direction,
|
direction,
|
||||||
i18n,
|
i18n,
|
||||||
|
openConversation,
|
||||||
status,
|
status,
|
||||||
text,
|
text,
|
||||||
textPending,
|
textPending,
|
||||||
|
@ -1075,8 +1082,11 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageBody
|
<MessageBody
|
||||||
text={contents || ''}
|
bodyRanges={bodyRanges}
|
||||||
|
direction={direction}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
openConversation={openConversation}
|
||||||
|
text={contents || ''}
|
||||||
textPending={textPending}
|
textPending={textPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,11 +14,13 @@ const i18n = setupI18n('en', enMessages);
|
||||||
const story = storiesOf('Components/Conversation/MessageBody', module);
|
const story = storiesOf('Components/Conversation/MessageBody', module);
|
||||||
|
|
||||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
|
bodyRanges: overrideProps.bodyRanges,
|
||||||
disableJumbomoji: boolean(
|
disableJumbomoji: boolean(
|
||||||
'disableJumbomoji',
|
'disableJumbomoji',
|
||||||
overrideProps.disableJumbomoji || false
|
overrideProps.disableJumbomoji || false
|
||||||
),
|
),
|
||||||
disableLinks: boolean('disableLinks', overrideProps.disableLinks || false),
|
disableLinks: boolean('disableLinks', overrideProps.disableLinks || false),
|
||||||
|
direction: 'incoming',
|
||||||
i18n,
|
i18n,
|
||||||
text: text('text', overrideProps.text || ''),
|
text: text('text', overrideProps.text || ''),
|
||||||
textPending: boolean('textPending', overrideProps.textPending || false),
|
textPending: boolean('textPending', overrideProps.textPending || false),
|
||||||
|
@ -92,3 +94,78 @@ story.add('Text Pending', () => {
|
||||||
|
|
||||||
return <MessageBody {...props} />;
|
return <MessageBody {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 <MessageBody {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 <MessageBody {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 <MessageBody {...props} />;
|
||||||
|
});
|
||||||
|
|
|
@ -1,13 +1,24 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { getSizeClass, SizeClassType } from '../emoji/lib';
|
import { getSizeClass, SizeClassType } from '../emoji/lib';
|
||||||
|
import { AtMentionify } from './AtMentionify';
|
||||||
import { Emojify } from './Emojify';
|
import { Emojify } from './Emojify';
|
||||||
import { AddNewLines } from './AddNewLines';
|
import { AddNewLines } from './AddNewLines';
|
||||||
import { Linkify } from './Linkify';
|
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 {
|
export interface Props {
|
||||||
|
direction?: 'incoming' | 'outgoing';
|
||||||
text: string;
|
text: string;
|
||||||
textPending?: boolean;
|
textPending?: boolean;
|
||||||
/** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */
|
/** 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 `<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;
|
||||||
|
openConversation?: OpenConversationActionType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderNewLines: RenderTextCallbackType = ({
|
|
||||||
text: textWithNewLines,
|
|
||||||
key,
|
|
||||||
}) => <AddNewLines key={key} text={textWithNewLines} />;
|
|
||||||
|
|
||||||
const renderEmoji = ({
|
const renderEmoji = ({
|
||||||
text,
|
text,
|
||||||
key,
|
key,
|
||||||
|
@ -49,6 +57,27 @@ const renderEmoji = ({
|
||||||
* them for you.
|
* them for you.
|
||||||
*/
|
*/
|
||||||
export class MessageBody extends React.Component<Props> {
|
export class MessageBody extends React.Component<Props> {
|
||||||
|
private readonly renderNewLines: RenderTextCallbackType = ({
|
||||||
|
text: textWithNewLines,
|
||||||
|
key,
|
||||||
|
}) => {
|
||||||
|
const { bodyRanges, direction, openConversation } = this.props;
|
||||||
|
return (
|
||||||
|
<AddNewLines
|
||||||
|
key={key}
|
||||||
|
text={textWithNewLines}
|
||||||
|
renderNonNewLine={({ text }) => (
|
||||||
|
<AtMentionify
|
||||||
|
direction={direction}
|
||||||
|
text={text}
|
||||||
|
bodyRanges={bodyRanges}
|
||||||
|
openConversation={openConversation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
public addDownloading(jsx: JSX.Element): JSX.Element {
|
public addDownloading(jsx: JSX.Element): JSX.Element {
|
||||||
const { i18n, textPending } = this.props;
|
const { i18n, textPending } = this.props;
|
||||||
|
|
||||||
|
@ -67,6 +96,7 @@ export class MessageBody extends React.Component<Props> {
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const {
|
const {
|
||||||
|
bodyRanges,
|
||||||
text,
|
text,
|
||||||
textPending,
|
textPending,
|
||||||
disableJumbomoji,
|
disableJumbomoji,
|
||||||
|
@ -74,7 +104,10 @@ export class MessageBody extends React.Component<Props> {
|
||||||
i18n,
|
i18n,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
|
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
|
||||||
const textWithPending = textPending ? `${text}...` : text;
|
const textWithPending = AtMentionify.preprocessMentions(
|
||||||
|
textPending ? `${text}...` : text,
|
||||||
|
bodyRanges
|
||||||
|
);
|
||||||
|
|
||||||
if (disableLinks) {
|
if (disableLinks) {
|
||||||
return this.addDownloading(
|
return this.addDownloading(
|
||||||
|
@ -83,7 +116,7 @@ export class MessageBody extends React.Component<Props> {
|
||||||
text: textWithPending,
|
text: textWithPending,
|
||||||
sizeClass,
|
sizeClass,
|
||||||
key: 0,
|
key: 0,
|
||||||
renderNonEmoji: renderNewLines,
|
renderNonEmoji: this.renderNewLines,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -97,7 +130,7 @@ export class MessageBody extends React.Component<Props> {
|
||||||
text: nonLinkText,
|
text: nonLinkText,
|
||||||
sizeClass,
|
sizeClass,
|
||||||
key,
|
key,
|
||||||
renderNonEmoji: renderNewLines,
|
renderNonEmoji: this.renderNewLines,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -106,6 +106,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
isIncoming: boolean('isIncoming', overrideProps.isIncoming || false),
|
isIncoming: boolean('isIncoming', overrideProps.isIncoming || false),
|
||||||
onClick: action('onClick'),
|
onClick: action('onClick'),
|
||||||
onClose: action('onClose'),
|
onClose: action('onClose'),
|
||||||
|
openConversation: action('openConversation'),
|
||||||
referencedMessageNotFound: boolean(
|
referencedMessageNotFound: boolean(
|
||||||
'referencedMessageNotFound',
|
'referencedMessageNotFound',
|
||||||
overrideProps.referencedMessageNotFound || false
|
overrideProps.referencedMessageNotFound || false
|
||||||
|
@ -358,3 +359,41 @@ story.add('Missing Text & Attachment', () => {
|
||||||
|
|
||||||
return <Quote {...props} />;
|
return <Quote {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
story.add('@mention + outgoing + another author', () => {
|
||||||
|
const props = createProps({
|
||||||
|
authorTitle: 'Tony Stark',
|
||||||
|
text: '@Captain America Lunch later?',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Quote {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
story.add('@mention + outgoing + me', () => {
|
||||||
|
const props = createProps({
|
||||||
|
isFromMe: true,
|
||||||
|
text: '@Captain America Lunch later?',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Quote {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
story.add('@mention + incoming + another author', () => {
|
||||||
|
const props = createProps({
|
||||||
|
authorTitle: 'Captain America',
|
||||||
|
isIncoming: true,
|
||||||
|
text: '@Tony Stark sure',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Quote {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
story.add('@mention + incoming + me', () => {
|
||||||
|
const props = createProps({
|
||||||
|
isFromMe: true,
|
||||||
|
isIncoming: true,
|
||||||
|
text: '@Tony Stark sure',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Quote {...props} />;
|
||||||
|
});
|
||||||
|
|
|
@ -7,7 +7,7 @@ import * as MIME from '../../../ts/types/MIME';
|
||||||
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
|
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
|
||||||
|
|
||||||
import { MessageBody } from './MessageBody';
|
import { MessageBody } from './MessageBody';
|
||||||
import { LocalizerType } from '../../types/Util';
|
import { BodyRangesType, LocalizerType } from '../../types/Util';
|
||||||
import { ColorType } from '../../types/Colors';
|
import { ColorType } from '../../types/Colors';
|
||||||
import { ContactName } from './ContactName';
|
import { ContactName } from './ContactName';
|
||||||
|
|
||||||
|
@ -18,12 +18,14 @@ export interface Props {
|
||||||
authorProfileName?: string;
|
authorProfileName?: string;
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
authorColor?: ColorType;
|
authorColor?: ColorType;
|
||||||
|
bodyRanges?: BodyRangesType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isFromMe: boolean;
|
isFromMe: boolean;
|
||||||
isIncoming: boolean;
|
isIncoming: boolean;
|
||||||
withContentAbove: boolean;
|
withContentAbove: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
openConversation: (conversationId: string, messageId?: string) => void;
|
||||||
text: string;
|
text: string;
|
||||||
referencedMessageNotFound: boolean;
|
referencedMessageNotFound: boolean;
|
||||||
}
|
}
|
||||||
|
@ -228,8 +230,15 @@ export class Quote extends React.Component<Props, State> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderText() {
|
public renderText(): JSX.Element | null {
|
||||||
const { i18n, text, attachment, isIncoming } = this.props;
|
const {
|
||||||
|
bodyRanges,
|
||||||
|
i18n,
|
||||||
|
text,
|
||||||
|
attachment,
|
||||||
|
isIncoming,
|
||||||
|
openConversation,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
return (
|
return (
|
||||||
|
@ -240,7 +249,13 @@ export class Quote extends React.Component<Props, State> {
|
||||||
isIncoming ? 'module-quote__primary__text--incoming' : null
|
isIncoming ? 'module-quote__primary__text--incoming' : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageBody text={text} disableLinks={true} i18n={i18n} />
|
<MessageBody
|
||||||
|
disableLinks
|
||||||
|
text={text}
|
||||||
|
i18n={i18n}
|
||||||
|
bodyRanges={bodyRanges}
|
||||||
|
openConversation={openConversation}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
8
ts/textsecure.d.ts
vendored
8
ts/textsecure.d.ts
vendored
|
@ -577,6 +577,7 @@ export declare namespace DataMessageClass {
|
||||||
static VIEW_ONCE: number;
|
static VIEW_ONCE: number;
|
||||||
static VIEW_ONCE_VIDEO: number;
|
static VIEW_ONCE_VIDEO: number;
|
||||||
static REACTIONS: number;
|
static REACTIONS: number;
|
||||||
|
static MENTIONS: number;
|
||||||
static CURRENT: number;
|
static CURRENT: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -587,6 +588,13 @@ export declare namespace DataMessageClass {
|
||||||
authorUuid?: string;
|
authorUuid?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
attachments?: Array<DataMessageClass.Quote.QuotedAttachment>;
|
attachments?: Array<DataMessageClass.Quote.QuotedAttachment>;
|
||||||
|
bodyRanges?: Array<DataMessageClass.BodyRange>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BodyRange {
|
||||||
|
start?: number;
|
||||||
|
length?: number;
|
||||||
|
mentionUuid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Reaction {
|
class Reaction {
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
StorageServiceCredentials,
|
StorageServiceCredentials,
|
||||||
} from '../textsecure.d';
|
} from '../textsecure.d';
|
||||||
import { MessageError, SignedPreKeyRotationError } from './Errors';
|
import { MessageError, SignedPreKeyRotationError } from './Errors';
|
||||||
|
import { BodyRangesType } from '../types/Util';
|
||||||
|
|
||||||
function stringToArrayBuffer(str: string): ArrayBuffer {
|
function stringToArrayBuffer(str: string): ArrayBuffer {
|
||||||
if (typeof str !== 'string') {
|
if (typeof str !== 'string') {
|
||||||
|
@ -258,7 +259,7 @@ class Message {
|
||||||
}
|
}
|
||||||
if (this.quote) {
|
if (this.quote) {
|
||||||
const { QuotedAttachment } = window.textsecure.protobuf.DataMessage.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();
|
proto.quote = new Quote();
|
||||||
const { quote } = proto;
|
const { quote } = proto;
|
||||||
|
@ -279,6 +280,14 @@ class Message {
|
||||||
return quotedAttachment;
|
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) {
|
if (this.expireTimer) {
|
||||||
proto.expireTimer = this.expireTimer;
|
proto.expireTimer = this.expireTimer;
|
||||||
|
|
|
@ -1,3 +1,11 @@
|
||||||
|
export type BodyRangesType = Array<{
|
||||||
|
start: number;
|
||||||
|
length: number;
|
||||||
|
mentionUuid: string;
|
||||||
|
replacementText: string;
|
||||||
|
conversationID?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type RenderTextCallbackType = (options: {
|
export type RenderTextCallbackType = (options: {
|
||||||
text: string;
|
text: string;
|
||||||
key: number;
|
key: number;
|
||||||
|
|
|
@ -13069,7 +13069,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/Message.tsx",
|
"path": "ts/components/conversation/Message.tsx",
|
||||||
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
|
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
|
||||||
"lineNumber": 210,
|
"lineNumber": 212,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-08-28T19:36:40.817Z"
|
"updated": "2020-08-28T19:36:40.817Z"
|
||||||
},
|
},
|
||||||
|
@ -13077,7 +13077,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/Message.tsx",
|
"path": "ts/components/conversation/Message.tsx",
|
||||||
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
||||||
"lineNumber": 211,
|
"lineNumber": 213,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-09-11T17:24:56.124Z",
|
"updated": "2020-09-11T17:24:56.124Z",
|
||||||
"reasonDetail": "Used for managing focus only"
|
"reasonDetail": "Used for managing focus only"
|
||||||
|
@ -13086,7 +13086,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/Message.tsx",
|
"path": "ts/components/conversation/Message.tsx",
|
||||||
"line": " > = React.createRef();",
|
"line": " > = React.createRef();",
|
||||||
"lineNumber": 214,
|
"lineNumber": 216,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-08-28T19:36:40.817Z"
|
"updated": "2020-08-28T19:36:40.817Z"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue