Fix message retry and search results with mentions
This commit is contained in:
parent
9e2411ce30
commit
44dfd28017
10 changed files with 254 additions and 21 deletions
|
@ -65,6 +65,8 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
|
|||
onClickContactCheckbox: action('onClickContactCheckbox'),
|
||||
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
|
||||
<MessageSearchResult
|
||||
body="Lorem ipsum wow"
|
||||
bodyRanges={[]}
|
||||
conversationId="marc-convo"
|
||||
from={defaultConversations[0]}
|
||||
i18n={i18n}
|
||||
|
|
|
@ -91,6 +91,8 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
renderMainHeader: () => <div />,
|
||||
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
|
||||
<MessageSearchResult
|
||||
body="Lorem ipsum wow"
|
||||
bodyRanges={[]}
|
||||
conversationId="marc-convo"
|
||||
from={defaultConversations[0]}
|
||||
i18n={i18n}
|
||||
|
|
|
@ -18,6 +18,7 @@ const story = storiesOf('Components/MessageBodyHighlight', module);
|
|||
story.addDecorator((withKnobs as any)({ escapeHTML: false }));
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
bodyRanges: overrideProps.bodyRanges || [],
|
||||
i18n,
|
||||
text: text('text', overrideProps.text || ''),
|
||||
});
|
||||
|
@ -47,6 +48,23 @@ story.add('Two Replacements', () => {
|
|||
return <MessageBodyHighlight {...props} />;
|
||||
});
|
||||
|
||||
story.add('Two Replacements with an @mention', () => {
|
||||
const props = createProps({
|
||||
bodyRanges: [
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: '0ca40892-7b1a-11eb-9439-0242ac130002',
|
||||
replacementText: 'Jin Sakai',
|
||||
start: 52,
|
||||
},
|
||||
],
|
||||
text:
|
||||
'Begin <<left>>Inside #1<<right>> \uFFFC@52 This is between the two <<left>>Inside #2<<right>> End.',
|
||||
});
|
||||
|
||||
return <MessageBodyHighlight {...props} />;
|
||||
});
|
||||
|
||||
story.add('Emoji + Newlines + URLs', () => {
|
||||
const props = createProps({
|
||||
text:
|
||||
|
|
|
@ -4,25 +4,27 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { MESSAGE_TEXT_CLASS_NAME } from './BaseConversationListItem';
|
||||
import { AtMentionify } from '../conversation/AtMentionify';
|
||||
import { MessageBody } from '../conversation/MessageBody';
|
||||
import { Emojify } from '../conversation/Emojify';
|
||||
import { AddNewLines } from '../conversation/AddNewLines';
|
||||
|
||||
import { SizeClassType } from '../emoji/lib';
|
||||
|
||||
import { LocalizerType, RenderTextCallbackType } from '../../types/Util';
|
||||
import {
|
||||
BodyRangesType,
|
||||
LocalizerType,
|
||||
RenderTextCallbackType,
|
||||
} from '../../types/Util';
|
||||
|
||||
const CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__message-search-result-contents`;
|
||||
|
||||
export type Props = {
|
||||
bodyRanges: BodyRangesType;
|
||||
text: string;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
const renderNewLines: RenderTextCallbackType = ({ text, key }) => (
|
||||
<AddNewLines key={key} text={text} />
|
||||
);
|
||||
|
||||
const renderEmoji = ({
|
||||
text,
|
||||
key,
|
||||
|
@ -44,18 +46,42 @@ const renderEmoji = ({
|
|||
);
|
||||
|
||||
export class MessageBodyHighlight extends React.Component<Props> {
|
||||
private readonly renderNewLines: RenderTextCallbackType = ({
|
||||
text: textWithNewLines,
|
||||
key,
|
||||
}) => {
|
||||
const { bodyRanges } = this.props;
|
||||
return (
|
||||
<AddNewLines
|
||||
key={key}
|
||||
text={textWithNewLines}
|
||||
renderNonNewLine={({ text, key: innerKey }) => (
|
||||
<AtMentionify bodyRanges={bodyRanges} key={innerKey} text={text} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderContents(): ReactNode {
|
||||
const { text, i18n } = this.props;
|
||||
const { bodyRanges, text, i18n } = this.props;
|
||||
const results: Array<JSX.Element> = [];
|
||||
const FIND_BEGIN_END = /<<left>>(.+?)<<right>>/g;
|
||||
|
||||
let match = FIND_BEGIN_END.exec(text);
|
||||
const processedText = AtMentionify.preprocessMentions(text, bodyRanges);
|
||||
|
||||
let match = FIND_BEGIN_END.exec(processedText);
|
||||
let last = 0;
|
||||
let count = 1;
|
||||
|
||||
if (!match) {
|
||||
return (
|
||||
<MessageBody disableJumbomoji disableLinks text={text} i18n={i18n} />
|
||||
<MessageBody
|
||||
bodyRanges={bodyRanges}
|
||||
disableJumbomoji
|
||||
disableLinks
|
||||
text={text}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -63,7 +89,7 @@ export class MessageBodyHighlight extends React.Component<Props> {
|
|||
|
||||
while (match) {
|
||||
if (last < match.index) {
|
||||
const beforeText = text.slice(last, match.index);
|
||||
const beforeText = processedText.slice(last, match.index);
|
||||
count += 1;
|
||||
results.push(
|
||||
renderEmoji({
|
||||
|
@ -71,7 +97,7 @@ export class MessageBodyHighlight extends React.Component<Props> {
|
|||
sizeClass,
|
||||
key: count,
|
||||
i18n,
|
||||
renderNonEmoji: renderNewLines,
|
||||
renderNonEmoji: this.renderNewLines,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -85,24 +111,24 @@ export class MessageBodyHighlight extends React.Component<Props> {
|
|||
sizeClass,
|
||||
key: count,
|
||||
i18n,
|
||||
renderNonEmoji: renderNewLines,
|
||||
renderNonEmoji: this.renderNewLines,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
|
||||
last = FIND_BEGIN_END.lastIndex;
|
||||
match = FIND_BEGIN_END.exec(text);
|
||||
match = FIND_BEGIN_END.exec(processedText);
|
||||
}
|
||||
|
||||
if (last < text.length) {
|
||||
if (last < processedText.length) {
|
||||
count += 1;
|
||||
results.push(
|
||||
renderEmoji({
|
||||
text: text.slice(last),
|
||||
text: processedText.slice(last),
|
||||
sizeClass,
|
||||
key: count,
|
||||
i18n,
|
||||
renderNonEmoji: renderNewLines,
|
||||
renderNonEmoji: this.renderNewLines,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -43,6 +43,8 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
'snippet',
|
||||
overrideProps.snippet || "What's <<left>>going<<right>> on?"
|
||||
),
|
||||
body: text('body', overrideProps.body || "What's going on?"),
|
||||
bodyRanges: overrideProps.bodyRanges || [],
|
||||
from: overrideProps.from as PropsType['from'],
|
||||
to: overrideProps.to as PropsType['to'],
|
||||
isSelected: boolean('isSelected', overrideProps.isSelected || false),
|
||||
|
@ -141,3 +143,97 @@ story.add('Empty (should be invalid)', () => {
|
|||
|
||||
return <MessageSearchResult {...props} />;
|
||||
});
|
||||
|
||||
story.add('@mention', () => {
|
||||
const props = createProps({
|
||||
body:
|
||||
'moss banana twine sound lake zoo brain count vacuum work stairs try power forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia bayonet vector orange forge diary zebra turtle rise front \uFFFC',
|
||||
bodyRanges: [
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
|
||||
replacementText: 'Shoe',
|
||||
start: 113,
|
||||
},
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
|
||||
replacementText: 'Shoe',
|
||||
start: 237,
|
||||
},
|
||||
],
|
||||
from: someone,
|
||||
to: me,
|
||||
snippet:
|
||||
'...forget hair dry diary years no <<left>>results<<right>> \uFFFC <<left>>elephant<<right>> sorry umbrella potato igloo kangaroo home Georgia...',
|
||||
});
|
||||
|
||||
return <MessageSearchResult {...props} />;
|
||||
});
|
||||
|
||||
story.add('@mention regexp', () => {
|
||||
const props = createProps({
|
||||
body:
|
||||
'\uFFFC This is a (long) /text/ ^$ that is ... specially **crafted** to (test) our regexp escaping mechanism! Making sure that the code we write works in all sorts of scenarios',
|
||||
bodyRanges: [
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
|
||||
replacementText: 'RegExp',
|
||||
start: 0,
|
||||
},
|
||||
],
|
||||
from: someone,
|
||||
to: me,
|
||||
snippet:
|
||||
'\uFFFC This is a (long) /text/ ^$ that is ... <<left>>specially<<right>> **crafted** to (test) our regexp escaping mechanism...',
|
||||
});
|
||||
|
||||
return <MessageSearchResult {...props} />;
|
||||
});
|
||||
|
||||
story.add('@mention no-matches', () => {
|
||||
const props = createProps({
|
||||
body: '\uFFFC hello',
|
||||
bodyRanges: [
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
|
||||
replacementText: 'Neo',
|
||||
start: 0,
|
||||
},
|
||||
],
|
||||
from: someone,
|
||||
to: me,
|
||||
snippet: '\uFFFC hello',
|
||||
});
|
||||
|
||||
return <MessageSearchResult {...props} />;
|
||||
});
|
||||
|
||||
story.add('@mention no-matches', () => {
|
||||
const props = createProps({
|
||||
body:
|
||||
'moss banana twine sound lake zoo brain count vacuum work stairs try power forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia bayonet vector orange forge diary zebra turtle rise front \uFFFC',
|
||||
bodyRanges: [
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
|
||||
replacementText: 'Shoe',
|
||||
start: 113,
|
||||
},
|
||||
{
|
||||
length: 1,
|
||||
mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8',
|
||||
replacementText: 'Shoe',
|
||||
start: 237,
|
||||
},
|
||||
],
|
||||
from: someone,
|
||||
to: me,
|
||||
snippet:
|
||||
'...forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia...',
|
||||
});
|
||||
|
||||
return <MessageSearchResult {...props} />;
|
||||
});
|
||||
|
|
|
@ -7,11 +7,13 @@ import React, {
|
|||
FunctionComponent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { escapeRegExp } from 'lodash';
|
||||
|
||||
import { MessageBodyHighlight } from './MessageBodyHighlight';
|
||||
import { ContactName } from '../conversation/ContactName';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { assert } from '../../util/assert';
|
||||
import { BodyRangesType, LocalizerType } from '../../types/Util';
|
||||
import { ColorType } from '../../types/Colors';
|
||||
import { BaseConversationListItem } from './BaseConversationListItem';
|
||||
|
||||
|
@ -24,6 +26,8 @@ export type PropsDataType = {
|
|||
sentAt?: number;
|
||||
|
||||
snippet: string;
|
||||
body: string;
|
||||
bodyRanges: BodyRangesType;
|
||||
|
||||
from: {
|
||||
phoneNumber?: string;
|
||||
|
@ -78,17 +82,77 @@ const renderPerson = (
|
|||
/>
|
||||
);
|
||||
|
||||
// This function exists because bodyRanges tells us the character position
|
||||
// where the at-mention starts at according to the full body text. The snippet
|
||||
// we get back is a portion of the text and we don't know where it starts. This
|
||||
// function will find the relevant bodyRanges that apply to the snippet and
|
||||
// then update the proper start position of each body range.
|
||||
function getFilteredBodyRanges(
|
||||
snippet: string,
|
||||
body: string,
|
||||
bodyRanges: BodyRangesType
|
||||
): BodyRangesType {
|
||||
// Find where the snippet starts in the full text
|
||||
const stripped = snippet
|
||||
.replace(/<<left>>/g, '')
|
||||
.replace(/<<right>>/g, '')
|
||||
.replace(/^.../, '')
|
||||
.replace(/...$/, '');
|
||||
const rx = new RegExp(escapeRegExp(stripped));
|
||||
const match = rx.exec(body);
|
||||
|
||||
assert(Boolean(match), `No match found for "${snippet}" inside "${body}"`);
|
||||
|
||||
const delta = match ? match.index + snippet.length : 0;
|
||||
|
||||
// Filters out the @mentions that are present inside the snippet
|
||||
const filteredBodyRanges = bodyRanges.filter(bodyRange => {
|
||||
return bodyRange.start < delta;
|
||||
});
|
||||
|
||||
const snippetBodyRanges = [];
|
||||
const MENTIONS_REGEX = /\uFFFC/g;
|
||||
|
||||
let bodyRangeMatch = MENTIONS_REGEX.exec(snippet);
|
||||
let i = 0;
|
||||
|
||||
// Find the start position within the snippet so these can later be
|
||||
// encoded and rendered correctly.
|
||||
while (bodyRangeMatch) {
|
||||
const bodyRange = filteredBodyRanges[i];
|
||||
|
||||
if (bodyRange) {
|
||||
snippetBodyRanges.push({
|
||||
...bodyRange,
|
||||
start: bodyRangeMatch.index,
|
||||
});
|
||||
} else {
|
||||
assert(
|
||||
false,
|
||||
`Body range does not exist? Count: ${i}, Length: ${filteredBodyRanges.length}`
|
||||
);
|
||||
}
|
||||
|
||||
bodyRangeMatch = MENTIONS_REGEX.exec(snippet);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return snippetBodyRanges;
|
||||
}
|
||||
|
||||
export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
||||
({
|
||||
id,
|
||||
body,
|
||||
bodyRanges,
|
||||
conversationId,
|
||||
from,
|
||||
to,
|
||||
sentAt,
|
||||
i18n,
|
||||
id,
|
||||
openConversationInternal,
|
||||
style,
|
||||
sentAt,
|
||||
snippet,
|
||||
style,
|
||||
to,
|
||||
}) => {
|
||||
const onClickItem = useCallback(() => {
|
||||
openConversationInternal({ conversationId, messageId: id });
|
||||
|
@ -113,7 +177,14 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
|||
);
|
||||
}
|
||||
|
||||
const messageText = <MessageBodyHighlight text={snippet} i18n={i18n} />;
|
||||
const snippetBodyRanges = getFilteredBodyRanges(snippet, body, bodyRanges);
|
||||
const messageText = (
|
||||
<MessageBodyHighlight
|
||||
text={snippet}
|
||||
bodyRanges={snippetBodyRanges}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseConversationListItem
|
||||
|
|
|
@ -2114,6 +2114,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
preview: previewWithData,
|
||||
sticker: stickerWithData,
|
||||
expireTimer: this.get('expireTimer'),
|
||||
mentions: this.get('bodyRanges'),
|
||||
profileKey,
|
||||
groupV2,
|
||||
group: groupV2
|
||||
|
|
|
@ -7,6 +7,7 @@ import { normalize } from '../../types/PhoneNumber';
|
|||
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
|
||||
import dataInterface from '../../sql/Client';
|
||||
import { makeLookup } from '../../util/makeLookup';
|
||||
import { BodyRangesType } from '../../types/Util';
|
||||
|
||||
import {
|
||||
ConversationUnloadedActionType,
|
||||
|
@ -28,6 +29,8 @@ const {
|
|||
|
||||
export type MessageSearchResultType = MessageType & {
|
||||
snippet: string;
|
||||
body: string;
|
||||
bodyRanges: BodyRangesType;
|
||||
};
|
||||
|
||||
export type MessageSearchResultLookupType = {
|
||||
|
|
|
@ -125,6 +125,8 @@ export function _messageSearchResultSelector(
|
|||
conversationId: message.conversationId,
|
||||
sentAt: message.sent_at,
|
||||
snippet: message.snippet,
|
||||
bodyRanges: message.bodyRanges,
|
||||
body: message.body,
|
||||
|
||||
isSelected: Boolean(selectedMessageId && message.id === selectedMessageId),
|
||||
isSearchingInConversation: Boolean(searchConversationId),
|
||||
|
|
|
@ -44,6 +44,8 @@ describe('both/state/selectors/search', () => {
|
|||
function getDefaultSearchMessage(id: string): MessageSearchResultType {
|
||||
return {
|
||||
...getDefaultMessage(id),
|
||||
body: 'foo bar',
|
||||
bodyRanges: [],
|
||||
snippet: 'foo bar',
|
||||
};
|
||||
}
|
||||
|
@ -77,6 +79,8 @@ describe('both/state/selectors/search', () => {
|
|||
...getDefaultMessage(id),
|
||||
type: 'keychange' as const,
|
||||
snippet: 'snippet',
|
||||
body: 'snippet',
|
||||
bodyRanges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -114,6 +118,8 @@ describe('both/state/selectors/search', () => {
|
|||
sourceUuid: fromId,
|
||||
conversationId: toId,
|
||||
snippet: 'snippet',
|
||||
body: 'snippet',
|
||||
bodyRanges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -129,6 +135,8 @@ describe('both/state/selectors/search', () => {
|
|||
conversationId: toId,
|
||||
sentAt: undefined,
|
||||
snippet: 'snippet',
|
||||
body: 'snippet',
|
||||
bodyRanges: [],
|
||||
|
||||
isSelected: false,
|
||||
isSearchingInConversation: false,
|
||||
|
@ -165,6 +173,8 @@ describe('both/state/selectors/search', () => {
|
|||
type: 'outgoing' as const,
|
||||
conversationId: toId,
|
||||
snippet: 'snippet',
|
||||
body: 'snippet',
|
||||
bodyRanges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -180,6 +190,8 @@ describe('both/state/selectors/search', () => {
|
|||
conversationId: toId,
|
||||
sentAt: undefined,
|
||||
snippet: 'snippet',
|
||||
body: 'snippet',
|
||||
bodyRanges: [],
|
||||
|
||||
isSelected: false,
|
||||
isSearchingInConversation: false,
|
||||
|
|
Loading…
Reference in a new issue