@mentions receive support

This commit is contained in:
Josh Perez 2020-09-16 18:42:48 -04:00 committed by Josh Perez
parent c126a71864
commit 9657c38987
18 changed files with 555 additions and 23 deletions

View File

@ -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()

View File

@ -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,

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View File

@ -253,3 +253,16 @@ story.add('Muted Conversation', () => {
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} />;
});

View 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} />;
});

View 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);
};

View File

@ -45,6 +45,7 @@ const createProps = (overrideProps: Partial<Props> = {}): 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);
});

View File

@ -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<LinkPreviewType>;
@ -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<Props, State> {
direction,
disableScroll,
i18n,
openConversation,
quote,
scrollToQuotedMessage,
} = this.props;
@ -940,6 +943,8 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
public renderText() {
const {
bodyRanges,
deletedForEveryone,
direction,
i18n,
openConversation,
status,
text,
textPending,
@ -1075,8 +1082,11 @@ export class Message extends React.PureComponent<Props, State> {
)}
>
<MessageBody
text={contents || ''}
bodyRanges={bodyRanges}
direction={direction}
i18n={i18n}
openConversation={openConversation}
text={contents || ''}
textPending={textPending}
/>
</div>

View File

@ -14,11 +14,13 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/MessageBody', module);
const createProps = (overrideProps: Partial<Props> = {}): 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 <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} />;
});

View File

@ -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 `<a>` tags. */
disableLinks?: boolean;
i18n: LocalizerType;
bodyRanges?: BodyRangesType;
openConversation?: OpenConversationActionType;
}
const renderNewLines: RenderTextCallbackType = ({
text: textWithNewLines,
key,
}) => <AddNewLines key={key} text={textWithNewLines} />;
const renderEmoji = ({
text,
key,
@ -49,6 +57,27 @@ const renderEmoji = ({
* them for you.
*/
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 {
const { i18n, textPending } = this.props;
@ -67,6 +96,7 @@ export class MessageBody extends React.Component<Props> {
public render() {
const {
bodyRanges,
text,
textPending,
disableJumbomoji,
@ -74,7 +104,10 @@ export class MessageBody extends React.Component<Props> {
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<Props> {
text: textWithPending,
sizeClass,
key: 0,
renderNonEmoji: renderNewLines,
renderNonEmoji: this.renderNewLines,
})
);
}
@ -97,7 +130,7 @@ export class MessageBody extends React.Component<Props> {
text: nonLinkText,
sizeClass,
key,
renderNonEmoji: renderNewLines,
renderNonEmoji: this.renderNewLines,
});
}}
/>

View File

@ -106,6 +106,7 @@ const createProps = (overrideProps: Partial<Props> = {}): 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 <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} />;
});

View File

@ -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<Props, State> {
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<Props, State> {
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>
);
}

8
ts/textsecure.d.ts vendored
View File

@ -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<DataMessageClass.Quote.QuotedAttachment>;
bodyRanges?: Array<DataMessageClass.BodyRange>;
}
class BodyRange {
start?: number;
length?: number;
mentionUuid?: string;
}
class Reaction {

View File

@ -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;

View File

@ -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;

View File

@ -13069,7 +13069,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public audioRef: React.RefObject<HTMLAudioElement> = 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<HTMLDivElement> = 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"
},