@mentions receive support
This commit is contained in:
parent
c126a71864
commit
9657c38987
18 changed files with 555 additions and 23 deletions
|
@ -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} />;
|
||||
});
|
||||
|
|
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',
|
||||
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);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue