Send @mentions

This commit is contained in:
Chris Svenningsen 2020-11-02 17:19:52 -08:00 committed by Evan Hahn
parent 63c4cf9430
commit 53c89aa40f
28 changed files with 1728 additions and 107 deletions

View File

@ -8665,6 +8665,19 @@ button.module-image__border-overlay:focus {
}
}
&__at-mention {
background-color: $color-gray-20;
border-radius: 4px;
display: inline-block;
padding-left: 4px;
padding-right: 4px;
border: 1px solid transparent;
@include dark-theme {
background-color: $color-gray-60;
}
}
&__input {
border: none;
border-radius: 18px;
@ -8730,11 +8743,17 @@ button.module-image__border-overlay:focus {
}
}
&__emoji-suggestions {
padding: 12px 0;
&__suggestions {
padding: 0;
margin-bottom: 6px;
border-radius: 8px;
z-index: 2;
overflow: hidden;
&--scroller {
max-height: 300px;
overflow-y: auto;
}
@include popper-shadow();
@ -8747,7 +8766,7 @@ button.module-image__border-overlay:focus {
}
&__row {
height: 30px;
height: 34px;
padding: 0 12px;
display: flex;
flex-direction: row;
@ -8757,6 +8776,10 @@ button.module-image__border-overlay:focus {
border: none;
width: 100%;
&--mention {
height: 40px;
}
&:focus {
outline: 0;
}
@ -8788,6 +8811,11 @@ button.module-image__border-overlay:focus {
}
}
}
&__title {
padding-left: 8px;
}
stroke: $color-white;
}
}

View File

@ -36,9 +36,10 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
onSubmit: action('onSubmit'),
onEditorStateChange: action('onEditorStateChange'),
onTextTooLong: action('onTextTooLong'),
startingText: overrideProps.startingText || undefined,
draftText: overrideProps.draftText || undefined,
clearQuotedMessage: action('clearQuotedMessage'),
getQuotedMessage: action('getQuotedMessage'),
members: [],
// EmojiButton
onPickEmoji: action('onPickEmoji'),
onSetSkinTone: action('onSetSkinTone'),
@ -78,7 +79,7 @@ story.add('Default', () => {
story.add('Starting Text', () => {
const props = createProps({
startingText: "here's some starting text",
draftText: "here's some starting text",
});
return <CompositionArea {...props} />;

View File

@ -48,10 +48,12 @@ export type OwnProps = {
export type Props = Pick<
CompositionInputProps,
| 'members'
| 'onSubmit'
| 'onEditorStateChange'
| 'onTextTooLong'
| 'startingText'
| 'draftText'
| 'draftBodyRanges'
| 'clearQuotedMessage'
| 'getQuotedMessage'
> &
@ -93,9 +95,11 @@ export const CompositionArea = ({
compositionApi,
onEditorStateChange,
onTextTooLong,
startingText,
draftText,
draftBodyRanges,
clearQuotedMessage,
getQuotedMessage,
members,
// EmojiButton
onPickEmoji,
onSetSkinTone,
@ -133,7 +137,7 @@ export const CompositionArea = ({
title,
}: Props): JSX.Element => {
const [disabled, setDisabled] = React.useState(false);
const [showMic, setShowMic] = React.useState(!startingText);
const [showMic, setShowMic] = React.useState(!draftText);
const [micActive, setMicActive] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
const [large, setLarge] = React.useState(false);
@ -419,9 +423,11 @@ export const CompositionArea = ({
onTextTooLong={onTextTooLong}
onDirtyChange={setDirty}
skinTone={skinTone}
startingText={startingText}
draftText={draftText}
draftBodyRanges={draftBodyRanges}
clearQuotedMessage={clearQuotedMessage}
getQuotedMessage={getQuotedMessage}
members={members}
/>
</div>
{!large ? (

View File

@ -22,11 +22,13 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
onSubmit: action('onSubmit'),
onEditorStateChange: action('onEditorStateChange'),
onTextTooLong: action('onTextTooLong'),
startingText: overrideProps.startingText || undefined,
draftText: overrideProps.draftText || undefined,
draftBodyRanges: overrideProps.draftBodyRanges || [],
clearQuotedMessage: action('clearQuotedMessage'),
getQuotedMessage: action('getQuotedMessage'),
onPickEmoji: action('onPickEmoji'),
large: boolean('large', overrideProps.large || false),
members: overrideProps.members || [],
skinTone: select(
'skinTone',
{
@ -65,7 +67,7 @@ story.add('Disabled', () => {
story.add('Starting Text', () => {
const props = createProps({
startingText: "here's some starting text",
draftText: "here's some starting text",
});
return <CompositionInput {...props} />;
@ -73,7 +75,7 @@ story.add('Starting Text', () => {
story.add('Multiline Text', () => {
const props = createProps({
startingText: `here's some starting text
draftText: `here's some starting text
and more on another line
and yet another line
and yet another line
@ -89,7 +91,7 @@ and we're done`,
story.add('Emojis', () => {
const props = createProps({
startingText: `⁣😐😐😐😐😐😐😐
draftText: `⁣😐😐😐😐😐😐😐
😐😐😐😐😐😐😐
😐😐😐😂😐😐😐
😐😐😐😐😐😐😐
@ -98,3 +100,35 @@ story.add('Emojis', () => {
return <CompositionInput {...props} />;
});
story.add('Mentions', () => {
const props = createProps({
members: [
{
id: '0',
type: 'direct',
lastUpdated: 0,
title: 'Kate Beaton',
markedUnread: false,
},
{
id: '0',
type: 'direct',
lastUpdated: 0,
title: 'Parry Gripp',
markedUnread: false,
},
],
draftText: 'send _ a message',
draftBodyRanges: [
{
start: 5,
length: 1,
mentionUuid: '0',
replacementText: 'Kate Beaton',
},
],
});
return <CompositionInput {...props} />;
});

View File

@ -11,15 +11,25 @@ import { Manager, Reference } from 'react-popper';
import Quill, { KeyboardStatic, RangeStatic } from 'quill';
import Op from 'quill-delta/dist/Op';
import { MentionCompletion } from '../quill/mentions/completion';
import { EmojiBlot, EmojiCompletion } from '../quill/emoji';
import { LocalizerType } from '../types/Util';
import { EmojiPickDataType } from './emoji/EmojiPicker';
import { convertShortName } from './emoji/lib';
import { matchEmojiBlot, matchEmojiImage } from '../quill/matchImage';
import { LocalizerType, BodyRangeType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { MentionBlot } from '../quill/mentions/blot';
import {
matchEmojiImage,
matchEmojiBlot,
matchReactEmoji,
} from '../quill/emoji/matchers';
import { matchMention } from '../quill/mentions/matchers';
import { MemberRepository, getDeltaToRemoveStaleMentions } from '../quill/util';
Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/mention', MentionBlot);
Quill.register('modules/emojiCompletion', EmojiCompletion);
Quill.register('modules/mentionCompletion', MentionCompletion);
const Block = Quill.import('blots/block');
Block.tagName = 'DIV';
@ -62,12 +72,18 @@ export interface Props {
readonly large?: boolean;
readonly inputApi?: React.MutableRefObject<InputApi | undefined>;
readonly skinTone?: EmojiPickDataType['skinTone'];
readonly startingText?: string;
readonly draftText?: string;
readonly draftBodyRanges?: Array<BodyRangeType>;
members?: Array<ConversationType>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(messageText: string, caretLocation?: number): unknown;
onEditorStateChange?(
messageText: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
): unknown;
onTextTooLong(): unknown;
onPickEmoji(o: EmojiPickDataType): unknown;
onSubmit(message: string): unknown;
onSubmit(message: string, mentions: Array<BodyRangeType>): unknown;
getQuotedMessage(): unknown;
clearQuotedMessage(): unknown;
}
@ -83,7 +99,11 @@ export const CompositionInput: React.ComponentType<Props> = props => {
onPickEmoji,
onSubmit,
skinTone,
startingText,
draftText,
draftBodyRanges,
getQuotedMessage,
clearQuotedMessage,
members,
} = props;
const [emojiCompletionElement, setEmojiCompletionElement] = React.useState<
@ -93,51 +113,116 @@ export const CompositionInput: React.ComponentType<Props> = props => {
lastSelectionRange,
setLastSelectionRange,
] = React.useState<RangeStatic | null>(null);
const [
mentionCompletionElement,
setMentionCompletionElement,
] = React.useState<JSX.Element>();
const emojiCompletionRef = React.useRef<EmojiCompletion>();
const mentionCompletionRef = React.useRef<MentionCompletion>();
const quillRef = React.useRef<Quill>();
const scrollerRef = React.useRef<HTMLDivElement>(null);
const propsRef = React.useRef<Props>(props);
const memberRepositoryRef = React.useRef<MemberRepository>(
new MemberRepository()
);
const generateDelta = (text: string): Delta => {
const re = emojiRegex();
const ops: Array<Op> = [];
const insertMentionOps = (
incomingOps: Array<Op>,
bodyRanges: Array<BodyRangeType>
) => {
const ops = [...incomingOps];
let index = 0;
// Working backwards through bodyRanges (to avoid offsetting later mentions),
// Shift off the op with the text to the left of the last mention,
// Insert a mention based on the current bodyRange,
// Unshift the mention and surrounding text to leave the ops ready for the next range
bodyRanges
.sort((a, b) => b.start - a.start)
.forEach(({ start, length, mentionUuid, replacementText }) => {
const op = ops.shift();
let match: RegExpExecArray | null;
// eslint-disable-next-line no-cond-assign
while ((match = re.exec(text))) {
const [emoji] = match;
ops.push({ insert: text.slice(index, match.index) });
ops.push({ insert: { emoji } });
index = match.index + emoji.length;
}
if (op) {
const { insert } = op;
ops.push({ insert: text.slice(index, text.length) });
if (typeof insert === 'string') {
const left = insert.slice(0, start);
const right = insert.slice(start + length);
return new Delta(ops);
const mention = {
uuid: mentionUuid,
title: replacementText,
};
ops.unshift({ insert: right });
ops.unshift({ insert: { mention } });
ops.unshift({ insert: left });
} else {
ops.unshift(op);
}
}
});
return ops;
};
const getText = (): string => {
const insertEmojiOps = (incomingOps: Array<Op>): Array<Op> => {
return incomingOps.reduce((ops, op) => {
if (typeof op.insert === 'string') {
const text = op.insert;
const re = emojiRegex();
let index = 0;
let match: RegExpExecArray | null;
// eslint-disable-next-line no-cond-assign
while ((match = re.exec(text))) {
const [emoji] = match;
ops.push({ insert: text.slice(index, match.index) });
ops.push({ insert: { emoji } });
index = match.index + emoji.length;
}
ops.push({ insert: text.slice(index, text.length) });
} else {
ops.push(op);
}
return ops;
}, [] as Array<Op>);
};
const generateDelta = (
text: string,
bodyRanges: Array<BodyRangeType>
): Delta => {
const initialOps = [{ insert: text }];
const opsWithMentions = insertMentionOps(initialOps, bodyRanges);
const opsWithEmojis = insertEmojiOps(opsWithMentions);
return new Delta(opsWithEmojis);
};
const getTextAndMentions = (): [string, Array<BodyRangeType>] => {
const quill = quillRef.current;
if (quill === undefined) {
return '';
return ['', []];
}
const contents = quill.getContents();
if (contents === undefined) {
return '';
return ['', []];
}
const { ops } = contents;
if (ops === undefined) {
return '';
return ['', []];
}
const mentions: Array<BodyRangeType> = [];
const text = ops.reduce((acc, { insert }) => {
if (typeof insert === 'string') {
return acc + insert;
@ -147,10 +232,21 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return acc + insert.emoji;
}
if (insert.mention) {
mentions.push({
length: 1, // The length of `\uFFFC`
mentionUuid: insert.mention.uuid,
replacementText: insert.mention.title,
start: acc.length,
});
return `${acc}\uFFFC`;
}
return acc;
}, '');
return text.trim();
return [text.trim(), mentions];
};
const focus = () => {
@ -223,8 +319,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return;
}
const text = getText();
onSubmit(text.trim());
const [text, mentions] = getTextAndMentions();
window.log.info(`Submitting a message with ${mentions.length} mentions`);
onSubmit(text, mentions);
};
if (inputApi) {
@ -250,12 +348,13 @@ export const CompositionInput: React.ComponentType<Props> = props => {
const onEnter = () => {
const quill = quillRef.current;
const emojiCompletion = emojiCompletionRef.current;
const mentionCompletion = mentionCompletionRef.current;
if (quill === undefined) {
return false;
}
if (emojiCompletion === undefined) {
if (emojiCompletion === undefined || mentionCompletion === undefined) {
return false;
}
@ -264,7 +363,12 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return false;
}
if (propsRef.current.large) {
if (mentionCompletion.results.length) {
mentionCompletion.completeMention();
return false;
}
if (large) {
return true;
}
@ -276,12 +380,13 @@ export const CompositionInput: React.ComponentType<Props> = props => {
const onTab = () => {
const quill = quillRef.current;
const emojiCompletion = emojiCompletionRef.current;
const mentionCompletion = mentionCompletionRef.current;
if (quill === undefined) {
return false;
}
if (emojiCompletion === undefined) {
if (emojiCompletion === undefined || mentionCompletion === undefined) {
return false;
}
@ -290,6 +395,11 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return false;
}
if (mentionCompletion.results.length) {
mentionCompletion.completeMention();
return false;
}
return true;
};
@ -301,6 +411,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
}
const emojiCompletion = emojiCompletionRef.current;
const mentionCompletion = mentionCompletionRef.current;
if (emojiCompletion) {
if (emojiCompletion.results.length) {
@ -309,8 +420,15 @@ export const CompositionInput: React.ComponentType<Props> = props => {
}
}
if (propsRef.current.getQuotedMessage()) {
propsRef.current.clearQuotedMessage();
if (mentionCompletion) {
if (mentionCompletion.results.length) {
mentionCompletion.reset();
return false;
}
}
if (getQuotedMessage()) {
clearQuotedMessage();
return false;
}
@ -318,9 +436,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
};
const onChange = () => {
const text = getText();
const quill = quillRef.current;
const [text, mentions] = getTextAndMentions();
if (quill !== undefined) {
const historyModule: HistoryStatic = quill.getModule('history');
@ -332,8 +451,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
if (propsRef.current.onEditorStateChange) {
const selection = quill.getSelection();
propsRef.current.onEditorStateChange(
text,
mentions,
selection ? selection.index : undefined
);
}
@ -368,19 +489,56 @@ export const CompositionInput: React.ComponentType<Props> = props => {
React.useEffect(
() => () => {
const emojiCompletion = emojiCompletionRef.current;
const mentionCompletion = mentionCompletionRef.current;
if (emojiCompletion === undefined) {
return;
if (emojiCompletion !== undefined) {
emojiCompletion.destroy();
}
emojiCompletion.destroy();
if (mentionCompletion !== undefined) {
mentionCompletion.destroy();
}
},
[]
);
const removeStaleMentions = (currentMembers: Array<ConversationType>) => {
const quill = quillRef.current;
if (quill === undefined) {
return;
}
const { ops } = quill.getContents();
if (ops === undefined) {
return;
}
const currentMemberUuids = currentMembers
.map(m => m.uuid)
.filter((uuid): uuid is string => uuid !== undefined);
const newDelta = getDeltaToRemoveStaleMentions(ops, currentMemberUuids);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
quill.updateContents(newDelta as any);
};
const memberIds = members ? members.map(m => m.id) : [];
React.useEffect(() => {
memberRepositoryRef.current.updateMembers(members || []);
removeStaleMentions(members || []);
// We are still depending on members, but ESLint can't tell
// Comparing the actual members list does not work for a couple reasons:
// * Arrays with the same objects are not "equal" to React
// * We only care about added/removed members, ignoring other attributes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(memberIds)]);
const reactQuill = React.useMemo(
() => {
const delta = generateDelta(startingText || '');
const delta = generateDelta(draftText || '', draftBodyRanges || []);
return (
<ReactQuill
@ -393,6 +551,8 @@ export const CompositionInput: React.ComponentType<Props> = props => {
matchers: [
['IMG', matchEmojiImage],
['SPAN', matchEmojiBlot],
['SPAN', matchReactEmoji],
['SPAN', matchMention(memberRepositoryRef)],
],
},
keyboard: {
@ -411,8 +571,14 @@ export const CompositionInput: React.ComponentType<Props> = props => {
onPickEmoji,
skinTone,
},
mentionCompletion: {
me: members ? members.find(foo => foo.isMe) : undefined,
memberRepositoryRef,
setMentionPickerElement: setMentionCompletionElement,
i18n,
},
}}
formats={['emoji']}
formats={['emoji', 'mention']}
placeholder={i18n('sendMessage')}
readOnly={disabled}
ref={element => {
@ -435,7 +601,9 @@ export const CompositionInput: React.ComponentType<Props> = props => {
quill.scrollingContainer = scroller;
}
quill.setSelection(quill.getLength(), 0);
setTimeout(() => {
quill.setSelection(quill.getLength(), 0);
}, 0);
});
quill.on(
@ -449,6 +617,9 @@ export const CompositionInput: React.ComponentType<Props> = props => {
);
quillRef.current = quill;
emojiCompletionRef.current = quill.getModule('emojiCompletion');
mentionCompletionRef.current = quill.getModule(
'mentionCompletion'
);
}
}}
/>
@ -476,6 +647,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
>
{reactQuill}
{emojiCompletionElement}
{mentionCompletionElement}
</div>
</div>
)}

View File

@ -64,9 +64,13 @@ export const AtMentionify = ({
}}
tabIndex={0}
role="link"
data-id={range.conversationID}
data-title={range.replacementText}
>
@
<Emojify text={range.replacementText} />
<bdi>
@
<Emojify text={range.replacementText} />
</bdi>
</span>
);
}

3
ts/model-types.d.ts vendored
View File

@ -4,7 +4,7 @@
import * as Backbone from 'backbone';
import { GroupV2ChangeType } from './groups';
import { LocalizerType, BodyRangesType } from './types/Util';
import { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling';
import { ColorType } from './types/Colors';
import {
@ -147,6 +147,7 @@ export type ConversationAttributesType = {
color?: string;
discoveredUnregisteredAt: number;
draftAttachments: Array<unknown>;
draftBodyRanges: Array<BodyRangeType>;
draftTimestamp: number | null;
inbox_position: number;
isPinned: boolean;

View File

@ -31,6 +31,8 @@ import {
verifyAccessKey,
} from '../Crypto';
import { GroupChangeClass } from '../textsecure.d';
import { BodyRangesType } from '../types/Util';
import { getTextWithMentions } from '../util';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -124,6 +126,8 @@ export class ConversationModel extends window.Backbone.Model<
verifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus;
intlCollator = new Intl.Collator();
// eslint-disable-next-line class-methods-use-this
defaults(): Partial<ConversationAttributesType> {
return {
@ -727,8 +731,11 @@ export class ConversationModel extends window.Backbone.Model<
getDraftPreview(): string {
const draft = this.get('draft');
if (draft) {
return draft;
const bodyRanges = this.get('draftBodyRanges') || [];
return getTextWithMentions(bodyRanges, draft);
}
const draftAttachments = this.get('draftAttachments') || [];
@ -1094,6 +1101,7 @@ export class ConversationModel extends window.Backbone.Model<
const draftTimestamp = this.get('draftTimestamp');
const draftPreview = this.getDraftPreview();
const draftText = this.get('draft');
const draftBodyRanges = this.get('draftBodyRanges');
const shouldShowDraft = (this.hasDraft() &&
draftTimestamp &&
draftTimestamp >= timestamp) as boolean;
@ -1110,6 +1118,15 @@ export class ConversationModel extends window.Backbone.Model<
groupVersion = 2;
}
const members = this.isGroupV2()
? this.getMembers()
.sort((left, right) =>
sortConversationTitles(left, right, this.intlCollator)
)
.map(member => member.format())
.filter((member): member is ConversationType => member !== null)
: undefined;
// TODO: DESKTOP-720
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const result: ConversationType = {
@ -1125,6 +1142,7 @@ export class ConversationModel extends window.Backbone.Model<
canChangeTimer: this.canChangeTimer(),
avatarPath: this.getAvatarPath()!,
color,
draftBodyRanges,
draftPreview,
draftText,
firstName: this.get('profileName')!,
@ -1144,6 +1162,7 @@ export class ConversationModel extends window.Backbone.Model<
lastUpdated: this.get('timestamp')!,
left: Boolean(this.get('left')),
markedUnread: this.get('markedUnread')!,
members,
membersCount: this.isPrivate()
? undefined
: (this.get('membersV2')! || this.get('members')! || []).length,
@ -2116,7 +2135,7 @@ export class ConversationModel extends window.Backbone.Model<
getMembers(
options: { includePendingMembers?: boolean } = {}
): Array<WhatIsThis> {
): Array<ConversationModel> {
if (this.isPrivate()) {
return [this];
}
@ -2602,7 +2621,8 @@ export class ConversationModel extends window.Backbone.Model<
attachments: Array<WhatIsThis>,
quote: WhatIsThis,
preview: WhatIsThis,
sticker: WhatIsThis
sticker?: WhatIsThis,
mentions?: BodyRangesType
): void {
this.clearTypingTimers();
@ -2642,12 +2662,13 @@ export class ConversationModel extends window.Backbone.Model<
expireTimer,
recipients,
sticker,
bodyRanges: mentions,
});
if (this.isPrivate()) {
messageWithSchema.destination = destination;
}
const attributes = {
const attributes: MessageModel = {
...messageWithSchema,
id: window.getGuid(),
};
@ -2738,6 +2759,7 @@ export class ConversationModel extends window.Backbone.Model<
quote,
sticker,
timestamp: now,
mentions,
},
options
);
@ -4398,6 +4420,20 @@ window.Whisper.GroupMemberConversation = window.Backbone.Model.extend({
},
});
interface SortableByTitle {
getTitle: () => string;
}
const sortConversationTitles = (
left: SortableByTitle,
right: SortableByTitle,
collator: Intl.Collator
) => {
const leftLower = left.getTitle().toLowerCase();
const rightLower = right.getTitle().toLowerCase();
return collator.compare(leftLower, rightLower);
};
// We need a custom collection here to get the sorting we need
window.Whisper.GroupConversationCollection = window.Backbone.Collection.extend({
model: window.Whisper.GroupMemberConversation,
@ -4414,8 +4450,6 @@ window.Whisper.GroupConversationCollection = window.Backbone.Collection.extend({
return 1;
}
const leftLower = left.getTitle().toLowerCase();
const rightLower = right.getTitle().toLowerCase();
return this.collator.compare(leftLower, rightLower);
return sortConversationTitles(left, right, this.collator);
},
});

View File

@ -1938,7 +1938,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.get('deletedForEveryoneTimestamp'),
this.get('sent_at'),
this.get('expireTimer'),
profileKey
profileKey,
undefined, // flags
this.get('bodyRanges')
);
return this.sendSyncMessageOnly(dataMessage);
}
@ -2108,9 +2110,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
previewWithData,
stickerWithData,
null,
this.get('deletedForEveryoneTimestamp'),
this.get('sent_at'),
this.get('expireTimer'),
profileKey
profileKey,
undefined, // flags
this.get('bodyRanges')
);
return this.sendSyncMessageOnly(dataMessage);
}

View File

@ -96,6 +96,7 @@ export class EmojiCompletion {
this.quill.keyboard.addBinding({ key: 40 }, changeIndex(1)); // 40 = Down
this.quill.on('text-change', _.debounce(this.onTextChange.bind(this), 100));
this.quill.on('selection-change', this.onSelectionChange.bind(this));
}
destroy(): void {
@ -124,6 +125,11 @@ export class EmojiCompletion {
return ['', ''];
}
onSelectionChange(): void {
// Selection should never change while we're editing an emoji
this.reset();
}
onTextChange(): void {
const range = this.quill.getSelection();
@ -296,7 +302,7 @@ export class EmojiCompletion {
{({ ref, style }) => (
<div
ref={ref}
className="module-composition-input__emoji-suggestions"
className="module-composition-input__suggestions"
style={style}
role="listbox"
aria-expanded
@ -319,9 +325,9 @@ export class EmojiCompletion {
this.completeEmoji();
}}
className={classNames(
'module-composition-input__emoji-suggestions__row',
'module-composition-input__suggestions__row',
emojiResultsIndex === index
? 'module-composition-input__emoji-suggestions__row--selected'
? 'module-composition-input__suggestions__row--selected'
: null
)}
>
@ -330,7 +336,7 @@ export class EmojiCompletion {
size={16}
skinTone={this.options.skinTone}
/>
<div className="module-composition-input__emoji-suggestions__row__short-name">
<div className="module-composition-input__suggestions__row__short-name">
:{emoji.short_name}:
</div>
</button>

View File

@ -3,18 +3,6 @@
import Delta from 'quill-delta';
export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => {
if (node.classList.contains('emoji-blot')) {
const { emoji } = node.dataset;
return new Delta().insert({ emoji });
}
if (node.classList.contains('module-emoji')) {
const emoji = node.innerText.trim();
return new Delta().insert({ emoji });
}
return delta;
};
export const matchEmojiImage = (node: Element): Delta => {
if (node.classList.contains('emoji')) {
const emoji = node.getAttribute('title');
@ -22,3 +10,19 @@ export const matchEmojiImage = (node: Element): Delta => {
}
return new Delta();
};
export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => {
if (node.classList.contains('emoji-blot')) {
const { emoji } = node.dataset;
return new Delta().insert({ emoji });
}
return delta;
};
export const matchReactEmoji = (node: HTMLElement, delta: Delta): Delta => {
if (node.classList.contains('module-emoji')) {
const emoji = node.innerText.trim();
return new Delta().insert({ emoji });
}
return delta;
};

View File

@ -0,0 +1,56 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import Parchment from 'parchment';
import Quill from 'quill';
import { render } from 'react-dom';
import { Emojify } from '../../components/conversation/Emojify';
import { ConversationType } from '../../state/ducks/conversations';
const Embed: typeof Parchment.Embed = Quill.import('blots/embed');
type MentionBlotValue = { uuid?: string; title?: string };
export class MentionBlot extends Embed {
static blotName = 'mention';
static className = 'mention-blot';
static tagName = 'span';
static create(value: ConversationType): Node {
const node = super.create(undefined) as HTMLElement;
MentionBlot.buildSpan(value, node);
return node;
}
static value(node: HTMLElement): MentionBlotValue {
const { uuid, title } = node.dataset;
return {
uuid,
title,
};
}
static buildSpan(member: ConversationType, node: HTMLElement): void {
node.setAttribute('data-uuid', member.uuid || '');
node.setAttribute('data-title', member.title || '');
const mentionSpan = document.createElement('span');
render(
<span className="module-composition-input__at-mention">
<bdi>
@
<Emojify text={member.title} />
</bdi>
</span>,
mentionSpan
);
node.appendChild(mentionSpan);
}
}

View File

@ -0,0 +1,300 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Quill from 'quill';
import Delta from 'quill-delta';
import React, { RefObject } from 'react';
import { Popper } from 'react-popper';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import { ConversationType } from '../../state/ducks/conversations';
import { Avatar } from '../../components/Avatar';
import { LocalizerType } from '../../types/Util';
import { MemberRepository } from '../util';
export interface MentionCompletionOptions {
i18n: LocalizerType;
memberRepositoryRef: RefObject<MemberRepository>;
setMentionPickerElement: (element: JSX.Element | null) => void;
me?: ConversationType;
}
declare global {
interface HTMLElement {
// Webkit-specific
scrollIntoViewIfNeeded: (bringToCenter: boolean) => void;
}
}
const MENTION_REGEX = /(?:^|\W)@([-+\w]*)$/;
export class MentionCompletion {
results: Array<ConversationType>;
index: number;
root: HTMLDivElement;
quill: Quill;
options: MentionCompletionOptions;
suggestionListRef: RefObject<HTMLDivElement>;
constructor(quill: Quill, options: MentionCompletionOptions) {
this.results = [];
this.index = 0;
this.options = options;
this.root = document.body.appendChild(document.createElement('div'));
this.quill = quill;
this.suggestionListRef = React.createRef<HTMLDivElement>();
const clearResults = () => {
if (this.results.length) {
this.reset();
}
return true;
};
const changeIndex = (by: number) => (): boolean => {
if (this.results.length) {
this.changeIndex(by);
return false;
}
return true;
};
this.quill.keyboard.addBinding({ key: 37 }, clearResults); // Left Arrow
this.quill.keyboard.addBinding({ key: 38 }, changeIndex(-1)); // Up Arrow
this.quill.keyboard.addBinding({ key: 39 }, clearResults); // Right Arrow
this.quill.keyboard.addBinding({ key: 40 }, changeIndex(1)); // Down Arrow
this.quill.on('text-change', this.onTextChange.bind(this));
this.quill.on('selection-change', this.onSelectionChange.bind(this));
}
destroy(): void {
this.root.remove();
}
changeIndex(by: number): void {
this.index = (this.index + by + this.results.length) % this.results.length;
this.render();
const suggestionList = this.suggestionListRef.current;
if (suggestionList) {
const selectedElement = suggestionList.querySelector<HTMLElement>(
'[aria-selected="true"]'
);
if (selectedElement) {
selectedElement.scrollIntoViewIfNeeded(false);
}
}
}
getCurrentLeafTextPartitions(): [string, string] {
const range = this.quill.getSelection();
if (range) {
const [blot, blotIndex] = this.quill.getLeaf(range.index);
if (blot !== undefined && blot.text !== undefined) {
const leftLeafText = blot.text.substr(0, blotIndex);
const rightLeafText = blot.text.substr(blotIndex);
return [leftLeafText, rightLeafText];
}
}
return ['', ''];
}
onSelectionChange(): void {
// Selection should never change while we're editing a mention
this.reset();
}
onTextChange(): void {
const range = this.quill.getSelection();
if (!range) return;
const [leftLeafText] = this.getCurrentLeafTextPartitions();
const leftTokenTextMatch = MENTION_REGEX.exec(leftLeafText);
if (!leftTokenTextMatch) {
this.reset();
return;
}
const [, leftTokenText] = leftTokenTextMatch;
let results: Array<ConversationType> = [];
const memberRepository = this.options.memberRepositoryRef.current;
if (memberRepository) {
if (leftTokenText === '') {
results = memberRepository.getMembers(this.options.me);
} else {
const fullMentionText = leftTokenText;
results = memberRepository.search(fullMentionText, this.options.me);
}
}
if (!results.length) {
this.reset();
return;
}
this.results = results;
this.index = 0;
this.render();
}
completeMention(): void {
const range = this.quill.getSelection();
if (range === null) return;
const member = this.results[this.index];
const [leftLeafText] = this.getCurrentLeafTextPartitions();
const leftTokenTextMatch = MENTION_REGEX.exec(leftLeafText);
if (leftTokenTextMatch === null) return;
const [, leftTokenText] = leftTokenTextMatch;
this.insertMention(
member,
range.index - leftTokenText.length - 1,
leftTokenText.length + 1,
true
);
}
insertMention(
member: ConversationType,
index: number,
range: number,
withTrailingSpace = false
): void {
const mention = member;
const delta = new Delta()
.retain(index)
.delete(range)
.insert({ mention });
if (withTrailingSpace) {
this.quill.updateContents(delta.insert(' '), 'user');
this.quill.setSelection(index + 2, 0, 'user');
} else {
this.quill.updateContents(delta, 'user');
this.quill.setSelection(index + 1, 0, 'user');
}
this.reset();
}
reset(): void {
if (this.results.length) {
this.results = [];
this.index = 0;
this.render();
}
}
onUnmount(): void {
document.body.removeChild(this.root);
}
render(): void {
const { results: memberResults, index: memberResultsIndex } = this;
if (memberResults.length === 0) {
this.options.setMentionPickerElement(null);
return;
}
const element = createPortal(
<Popper
placement="top"
modifiers={{
width: {
enabled: true,
fn: oldData => {
const data = oldData;
const { width, left } = data.offsets.reference;
data.styles.width = `${width}px`;
data.offsets.popper.width = width;
data.offsets.popper.left = left;
return data;
},
order: 840,
},
}}
>
{({ ref, style }) => (
<div
ref={ref}
className="module-composition-input__suggestions"
style={style}
role="listbox"
aria-expanded
aria-activedescendant={`mention-result--${
memberResults.length ? memberResults[memberResultsIndex].name : ''
}`}
tabIndex={0}
>
<div
ref={this.suggestionListRef}
className="module-composition-input__suggestions--scroller"
>
{memberResults.map((member, index) => (
<button
type="button"
key={member.uuid}
id={`mention-result--${member.name}`}
role="option button"
aria-selected={memberResultsIndex === index}
onClick={() => {
this.index = index;
this.completeMention();
}}
className={classNames(
'module-composition-input__suggestions__row',
'module-composition-input__suggestions__row--mention',
memberResultsIndex === index
? 'module-composition-input__suggestions__row--selected'
: null
)}
>
<Avatar
avatarPath={member.avatarPath}
conversationType="direct"
i18n={this.options.i18n}
size={28}
title={member.title}
/>
<div className="module-composition-input__suggestions__title">
{member.title}
</div>
</button>
))}
</div>
</div>
)}
</Popper>,
this.root
);
this.options.setMentionPickerElement(element);
}
}

View File

@ -0,0 +1,50 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Delta from 'quill-delta';
import { RefObject } from 'react';
import { MemberRepository } from '../util';
export const matchMention = (
memberRepositoryRef: RefObject<MemberRepository>
) => (node: HTMLElement, delta: Delta): Delta => {
const memberRepository = memberRepositoryRef.current;
if (memberRepository) {
const { title } = node.dataset;
if (node.classList.contains('module-message-body__at-mention')) {
const { id } = node.dataset;
const conversation = memberRepository.getMemberById(id);
if (conversation && conversation.uuid) {
return new Delta().insert({
mention: {
title,
uuid: conversation.uuid,
},
});
}
return new Delta().insert(`@${title}`);
}
if (node.classList.contains('mention-blot')) {
const { uuid } = node.dataset;
const conversation = memberRepository.getMemberByUuid(uuid);
if (conversation && conversation.uuid) {
return new Delta().insert({
mention: {
title: title || conversation.title,
uuid: conversation.uuid,
},
});
}
return new Delta().insert(`@${title}`);
}
}
return delta;
};

89
ts/quill/util.ts Normal file
View File

@ -0,0 +1,89 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Fuse from 'fuse.js';
import Delta from 'quill-delta';
import { DeltaOperation } from 'quill';
import { ConversationType } from '../state/ducks/conversations';
const FUSE_OPTIONS = {
shouldSort: true,
threshold: 0.2,
maxPatternLength: 32,
minMatchCharLength: 1,
keys: ['name', 'firstName', 'profileName', 'title'],
};
export const getDeltaToRemoveStaleMentions = (
ops: Array<DeltaOperation>,
memberUuids: Array<string>
): Delta => {
const newOps = ops.reduce((memo, op) => {
if (op.insert) {
if (op.insert.mention && !memberUuids.includes(op.insert.mention.uuid)) {
const deleteOp = { delete: 1 };
const textOp = { insert: `@${op.insert.mention.title}` };
return [...memo, deleteOp, textOp];
}
if (typeof op.insert === 'string') {
const retainStringOp = { retain: op.insert.length };
return [...memo, retainStringOp];
}
const retainEmbedOp = { retain: 1 };
return [...memo, retainEmbedOp];
}
return [...memo, op];
}, Array<DeltaOperation>());
return new Delta(newOps);
};
export class MemberRepository {
private members: Array<ConversationType>;
private fuse: Fuse<ConversationType>;
constructor(members: Array<ConversationType> = []) {
this.members = members;
this.fuse = new Fuse<ConversationType>(this.members, FUSE_OPTIONS);
}
updateMembers(members: Array<ConversationType>): void {
this.members = members;
this.fuse = new Fuse(members, FUSE_OPTIONS);
}
getMembers(omit?: ConversationType): Array<ConversationType> {
if (omit) {
return this.members.filter(({ id }) => id !== omit.id);
}
return this.members;
}
getMemberById(id?: string): ConversationType | undefined {
return id
? this.members.find(({ id: memberId }) => memberId === id)
: undefined;
}
getMemberByUuid(uuid?: string): ConversationType | undefined {
return uuid
? this.members.find(({ uuid: memberUuid }) => memberUuid === uuid)
: undefined;
}
search(pattern: string, omit?: ConversationType): Array<ConversationType> {
const results = this.fuse.search(pattern);
if (omit) {
return results.filter(({ id }) => id !== omit.id);
}
return results;
}
}

View File

@ -17,6 +17,7 @@ import { trigger } from '../../shims/events';
import { NoopActionType } from './noop';
import { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Colors';
import { BodyRangeType } from '../../types/Util';
// State
@ -66,6 +67,7 @@ export type ConversationType = {
phoneNumber?: string;
membersCount?: number;
expireTimer?: number;
members?: Array<ConversationType>;
muteExpiresAt?: number;
type: ConversationTypeType;
isMe?: boolean;
@ -83,6 +85,7 @@ export type ConversationType = {
shouldShowDraft?: boolean;
draftText?: string | null;
draftBodyRanges?: Array<BodyRangeType>;
draftPreview?: string;
sharedGroupNames?: Array<string>;

View File

@ -37,7 +37,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
throw new Error(`Conversation id ${id} not found!`);
}
const { draftText } = conversation;
const { draftText, draftBodyRanges } = conversation;
const receivedPacks = getReceivedStickerPacks(state);
const installedPacks = getInstalledStickerPacks(state);
@ -61,7 +61,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return {
// Base
i18n: getIntl(state),
startingText: draftText,
draftText,
draftBodyRanges,
// Emojis
recentEmojis,
skinTone: get(state, ['items', 'skinTone'], 0),

View File

@ -0,0 +1,354 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { expect } from 'chai';
import sinon from 'sinon';
import { MutableRefObject } from 'react';
import {
MentionCompletion,
MentionCompletionOptions,
} from '../../../quill/mentions/completion';
import { ConversationType } from '../../../state/ducks/conversations';
import { MemberRepository } from '../../../quill/util';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalAsAny = global as any;
const me: ConversationType = {
id: '666777',
uuid: 'pqrstuv',
title: 'Fred Savage',
firstName: 'Fred',
profileName: 'Fred S.',
type: 'direct',
lastUpdated: Date.now(),
markedUnread: false,
};
const members: Array<ConversationType> = [
{
id: '555444',
uuid: 'abcdefg',
title: 'Mahershala Ali',
firstName: 'Mahershala',
profileName: 'Mahershala A.',
type: 'direct',
lastUpdated: Date.now(),
markedUnread: false,
},
{
id: '333222',
uuid: 'hijklmno',
title: 'Shia LaBeouf',
firstName: 'Shia',
profileName: 'Shia L.',
type: 'direct',
lastUpdated: Date.now(),
markedUnread: false,
},
me,
];
describe('mentionCompletion', () => {
let mentionCompletion: MentionCompletion;
const mockSetMentionPickerElement = sinon.spy();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockQuill: any;
beforeEach(function beforeEach() {
this.oldDocument = globalAsAny.document;
globalAsAny.document = {
body: {
appendChild: () => null,
},
createElement: () => null,
};
mockQuill = {
getLeaf: sinon.stub(),
getSelection: sinon.stub(),
keyboard: {
addBinding: sinon.stub(),
},
on: sinon.stub(),
setSelection: sinon.stub(),
updateContents: sinon.stub(),
};
const memberRepositoryRef: MutableRefObject<MemberRepository> = {
current: new MemberRepository(members),
};
const options: MentionCompletionOptions = {
i18n: sinon.stub(),
me,
memberRepositoryRef,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setMentionPickerElement: mockSetMentionPickerElement as any,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mentionCompletion = new MentionCompletion(mockQuill as any, options);
// Stub rendering to avoid missing DOM until we bring in Enzyme
mentionCompletion.render = sinon.stub();
});
afterEach(function afterEach() {
mockSetMentionPickerElement.resetHistory();
(mentionCompletion.render as sinon.SinonStub).resetHistory();
if (this.oldDocument === undefined) {
delete globalAsAny.document;
} else {
globalAsAny.document = this.oldDocument;
}
});
describe('getCurrentLeafTextPartitions', () => {
it('returns left and right text', () => {
mockQuill.getSelection.returns({ index: 0, length: 0 });
const blot = {
text: '@shia',
};
mockQuill.getLeaf.returns([blot, 3]);
const [
leftLeafText,
rightLeafText,
] = mentionCompletion.getCurrentLeafTextPartitions();
expect(leftLeafText).to.equal('@sh');
expect(rightLeafText).to.equal('ia');
});
});
describe('onTextChange', () => {
let insertMentionStub: sinon.SinonStub<
[ConversationType, number, number, (boolean | undefined)?],
void
>;
beforeEach(function beforeEach() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mentionCompletion.results = [{ title: 'Mahershala Ali' } as any];
mentionCompletion.index = 5;
insertMentionStub = sinon
.stub(mentionCompletion, 'insertMention')
.callThrough();
});
afterEach(function afterEach() {
insertMentionStub.restore();
});
describe('given a mention is not starting (no @)', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 3,
length: 0,
});
const blot = {
text: 'smi',
};
mockQuill.getLeaf.returns([blot, 3]);
mentionCompletion.onTextChange();
});
it('resets the completion', () => {
expect(mentionCompletion.results).to.have.lengthOf(0);
expect(mentionCompletion.index).to.equal(0);
});
});
describe('given an mention is starting but does not match a member', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 4,
length: 0,
});
const blot = {
text: '@nope',
};
mockQuill.getLeaf.returns([blot, 5]);
mentionCompletion.onTextChange();
});
it('resets the completion', () => {
expect(mentionCompletion.results).to.have.lengthOf(0);
expect(mentionCompletion.index).to.equal(0);
});
});
describe('given an mention is started without text', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 4,
length: 0,
});
const blot = {
text: '@',
};
mockQuill.getLeaf.returns([blot, 2]);
mentionCompletion.onTextChange();
});
it('stores all results, omitting `me`, and renders', () => {
expect(mentionCompletion.results).to.have.lengthOf(2);
expect((mentionCompletion.render as sinon.SinonStub).called).to.equal(
true
);
});
});
describe('given a mention is started and matches members', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 4,
length: 0,
});
const blot = {
text: '@sh',
};
mockQuill.getLeaf.returns([blot, 3]);
mentionCompletion.onTextChange();
});
it('stores the results, omitting `me`, and renders', () => {
expect(mentionCompletion.results).to.have.lengthOf(2);
expect((mentionCompletion.render as sinon.SinonStub).called).to.equal(
true
);
});
});
});
describe('completeMention', () => {
let insertMentionStub: sinon.SinonStub<
[ConversationType, number, number, (boolean | undefined)?],
void
>;
beforeEach(function beforeEach() {
mentionCompletion.results = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: 'Mahershala Ali' } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: 'Shia LaBeouf' } as any,
];
mentionCompletion.index = 1;
insertMentionStub = sinon.stub(mentionCompletion, 'insertMention');
});
describe('given a valid mention', () => {
const text = '@sh';
const index = text.length;
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index,
length: 0,
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
mentionCompletion.completeMention();
});
it('inserts the currently selected mention at the current cursor position', () => {
const [mention, insertIndex, range] = insertMentionStub.args[0];
expect(mention.title).to.equal('Shia LaBeouf');
expect(insertIndex).to.equal(0);
expect(range).to.equal(text.length);
});
});
describe('given a valid mention starting with a capital letter', () => {
const text = '@Sh';
const index = text.length;
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index,
length: 0,
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
mentionCompletion.completeMention();
});
it('inserts the currently selected mention at the current cursor position', () => {
const [mention, insertIndex, range] = insertMentionStub.args[0];
expect(mention.title).to.equal('Shia LaBeouf');
expect(insertIndex).to.equal(0);
expect(range).to.equal(text.length);
});
});
describe('given a valid mention inside a string', () => {
const text = 'foo @shia bar';
const index = 9;
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index,
length: 0,
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
mentionCompletion.completeMention();
});
it('inserts the currently selected mention at the current cursor position, replacing all mention text', () => {
const [mention, insertIndex, range] = insertMentionStub.args[0];
expect(mention.title).to.equal('Shia LaBeouf');
expect(insertIndex).to.equal(4);
expect(range).to.equal(5);
});
});
describe('given a valid mention is not present', () => {
const text = 'sh';
const index = text.length;
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index,
length: 0,
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
mentionCompletion.completeMention();
});
it('does not insert anything', () => {
expect(insertMentionStub.called).to.equal(false);
});
});
});
});

View File

@ -0,0 +1,187 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { RefObject } from 'react';
import Delta from 'quill-delta';
import { matchMention } from '../../../quill/mentions/matchers';
import { MemberRepository } from '../../../quill/util';
import { ConversationType } from '../../../state/ducks/conversations';
class FakeTokenList<T> extends Array<T> {
constructor(elements: Array<T>) {
super();
elements.forEach(element => this.push(element));
}
contains(searchElement: T) {
return this.includes(searchElement);
}
}
const createMockElement = (
className: string,
dataset: Record<string, string>
): HTMLElement =>
(({
classList: new FakeTokenList([className]),
dataset,
} as unknown) as HTMLElement);
const createMockAtMentionElement = (
dataset: Record<string, string>
): HTMLElement => createMockElement('module-message-body__at-mention', dataset);
const createMockMentionBlotElement = (
dataset: Record<string, string>
): HTMLElement => createMockElement('mention-blot', dataset);
const memberMahershala: ConversationType = {
id: '555444',
uuid: 'abcdefg',
title: 'Mahershala Ali',
firstName: 'Mahershala',
profileName: 'Mahershala A.',
type: 'direct',
lastUpdated: Date.now(),
markedUnread: false,
};
const memberShia: ConversationType = {
id: '333222',
uuid: 'hijklmno',
title: 'Shia LaBeouf',
firstName: 'Shia',
profileName: 'Shia L.',
type: 'direct',
lastUpdated: Date.now(),
markedUnread: false,
};
const members: Array<ConversationType> = [memberMahershala, memberShia];
const memberRepositoryRef: RefObject<MemberRepository> = {
current: new MemberRepository(members),
};
const matcher = matchMention(memberRepositoryRef);
interface Mention {
uuid: string;
title: string;
}
interface MentionInsert {
mention: Mention;
}
const isMention = (insert?: unknown): insert is MentionInsert => {
if (insert) {
if (Object.getOwnPropertyNames(insert).includes('mention')) return true;
}
return false;
};
const EMPTY_DELTA = new Delta();
describe('matchMention', () => {
it('handles an AtMentionify from clipboard', () => {
const result = matcher(
createMockAtMentionElement({
id: memberMahershala.id,
title: memberMahershala.title,
}),
EMPTY_DELTA
);
const { ops } = result;
assert.isNotEmpty(ops);
const [op] = ops;
const { insert } = op;
if (isMention(insert)) {
const { title, uuid } = insert.mention;
assert.equal(title, memberMahershala.title);
assert.equal(uuid, memberMahershala.uuid);
} else {
assert.fail('insert is invalid');
}
});
it('handles an MentionBlot from clipboard', () => {
const result = matcher(
createMockMentionBlotElement({
uuid: memberMahershala.uuid || '',
title: memberMahershala.title,
}),
EMPTY_DELTA
);
const { ops } = result;
assert.isNotEmpty(ops);
const [op] = ops;
const { insert } = op;
if (isMention(insert)) {
const { title, uuid } = insert.mention;
assert.equal(title, memberMahershala.title);
assert.equal(uuid, memberMahershala.uuid);
} else {
assert.fail('insert is invalid');
}
});
it('converts a missing AtMentionify to string', () => {
const result = matcher(
createMockAtMentionElement({
id: 'florp',
title: 'Nonexistent',
}),
EMPTY_DELTA
);
const { ops } = result;
assert.isNotEmpty(ops);
const [op] = ops;
const { insert } = op;
if (isMention(insert)) {
assert.fail('insert is invalid');
} else {
assert.equal(insert, '@Nonexistent');
}
});
it('converts a missing MentionBlot to string', () => {
const result = matcher(
createMockMentionBlotElement({
uuid: 'florp',
title: 'Nonexistent',
}),
EMPTY_DELTA
);
const { ops } = result;
assert.isNotEmpty(ops);
const [op] = ops;
const { insert } = op;
if (isMention(insert)) {
assert.fail('insert is invalid');
} else {
assert.equal(insert, '@Nonexistent');
}
});
it('passes other clipboard elements through', () => {
const result = matcher(createMockElement('ignore', {}), EMPTY_DELTA);
assert.equal(result, EMPTY_DELTA);
});
});

164
ts/test/quill/util_test.ts Normal file
View File

@ -0,0 +1,164 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
MemberRepository,
getDeltaToRemoveStaleMentions,
} from '../../quill/util';
import { ConversationType } from '../../state/ducks/conversations';
const members: Array<ConversationType> = [
{
id: '555444',
uuid: 'abcdefg',
title: 'Mahershala Ali',
firstName: 'Mahershala',
profileName: 'Mahershala A.',
type: 'direct',
lastUpdated: Date.now(),
markedUnread: false,
},
{
id: '333222',
uuid: 'hijklmno',
title: 'Shia LaBeouf',
firstName: 'Shia',
profileName: 'Shia L.',
type: 'direct',
lastUpdated: Date.now(),
markedUnread: false,
},
];
const singleMember: ConversationType = {
id: '666777',
uuid: 'pqrstuv',
title: 'Fred Savage',
firstName: 'Fred',
profileName: 'Fred S.',
type: 'direct',
lastUpdated: Date.now(),
markedUnread: false,
};
describe('MemberRepository', () => {
describe('#updateMembers', () => {
it('updates with given members', () => {
const memberRepository = new MemberRepository(members);
assert.deepEqual(memberRepository.getMembers(), members);
const updatedMembers = [...members, singleMember];
memberRepository.updateMembers(updatedMembers);
assert.deepEqual(memberRepository.getMembers(), updatedMembers);
});
});
describe('#getMemberById', () => {
it('returns undefined when there is no search id', () => {
const memberRepository = new MemberRepository(members);
assert.isUndefined(memberRepository.getMemberById());
});
it('returns a matched member', () => {
const memberRepository = new MemberRepository(members);
assert.isDefined(memberRepository.getMemberById('555444'));
});
it('returns undefined when it does not have the member', () => {
const memberRepository = new MemberRepository(members);
assert.isUndefined(memberRepository.getMemberById('nope'));
});
});
describe('#getMemberByUuid', () => {
it('returns undefined when there is no search uuid', () => {
const memberRepository = new MemberRepository(members);
assert.isUndefined(memberRepository.getMemberByUuid());
});
it('returns a matched member', () => {
const memberRepository = new MemberRepository(members);
assert.isDefined(memberRepository.getMemberByUuid('abcdefg'));
});
it('returns undefined when it does not have the member', () => {
const memberRepository = new MemberRepository(members);
assert.isUndefined(memberRepository.getMemberByUuid('nope'));
});
});
});
describe('getDeltaToRemoveStaleMentions', () => {
const memberUuids = ['abcdef', 'ghijkl'];
describe('given text', () => {
it('retains the text', () => {
const originalOps = [
{
insert: 'whoa, nobody here',
},
];
const { ops } = getDeltaToRemoveStaleMentions(originalOps, memberUuids);
assert.deepEqual(ops, [{ retain: 17 }]);
});
});
describe('given stale and valid mentions', () => {
it('retains the valid and replaces the stale', () => {
const originalOps = [
{
insert: {
mention: { uuid: '12345', title: 'Klaus' },
},
},
{ insert: { mention: { uuid: 'abcdef', title: 'Werner' } } },
];
const { ops } = getDeltaToRemoveStaleMentions(originalOps, memberUuids);
assert.deepEqual(ops, [
{ delete: 1 },
{ insert: '@Klaus' },
{ retain: 1 },
]);
});
});
describe('given emoji embeds', () => {
it('retains the embeds', () => {
const originalOps = [
{
insert: {
emoji: '😂',
},
},
{
insert: {
emoji: '🍋',
},
},
];
const { ops } = getDeltaToRemoveStaleMentions(originalOps, memberUuids);
assert.deepEqual(ops, [{ retain: 1 }, { retain: 1 }]);
});
});
describe('given other ops', () => {
it('passes them through', () => {
const originalOps = [
{
delete: 5,
},
];
const { ops } = getDeltaToRemoveStaleMentions(originalOps, memberUuids);
assert.deepEqual(ops, originalOps);
});
});
});

View File

@ -0,0 +1,45 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { expect } from 'chai';
import { getTextWithMentions } from '../../util/getTextWithMentions';
describe('getTextWithMentions', () => {
describe('given mention replacements', () => {
it('replaces them', () => {
const bodyRanges = [
{
length: 1,
mentionUuid: 'abcdef',
replacementText: 'fred',
start: 4,
},
];
const text = "Hey \uFFFC, I'm here";
expect(getTextWithMentions(bodyRanges, text)).to.eql(
"Hey @fred, I'm here"
);
});
it('sorts them to go from back to front', () => {
const bodyRanges = [
{
length: 1,
mentionUuid: 'blarg',
replacementText: 'jerry',
start: 0,
},
{
length: 1,
mentionUuid: 'abcdef',
replacementText: 'fred',
start: 7,
},
];
const text = "\uFFFC says \uFFFC, I'm here";
expect(getTextWithMentions(bodyRanges, text)).to.eql(
"@jerry says @fred, I'm here"
);
});
});
});

1
ts/textsecure.d.ts vendored
View File

@ -563,6 +563,7 @@ export declare class DataMessageClass {
isViewOnce?: boolean;
reaction?: DataMessageClass.Reaction;
delete?: DataMessageClass.Delete;
bodyRanges?: Array<DataMessageClass.BodyRange>;
}
// Note: we need to use namespaces to express nested classes in Typescript

View File

@ -123,6 +123,7 @@ type MessageOptionsType = {
reaction?: any;
deletedForEveryoneTimestamp?: number;
timestamp: number;
mentions?: BodyRangesType;
};
class Message {
@ -163,6 +164,8 @@ class Message {
deletedForEveryoneTimestamp?: number;
mentions?: BodyRangesType;
constructor(options: MessageOptionsType) {
this.attachments = options.attachments || [];
this.body = options.body;
@ -179,6 +182,7 @@ class Message {
this.reaction = options.reaction;
this.timestamp = options.timestamp;
this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp;
this.mentions = options.mentions;
if (!(this.recipients instanceof Array)) {
throw new Error('Invalid recipient list');
@ -252,6 +256,13 @@ class Message {
if (this.body) {
proto.body = this.body;
const mentionCount = this.mentions ? this.mentions.length : 0;
const placeholders = this.body.match(/\uFFFC/g);
const placeholderCount = placeholders ? placeholders.length : 0;
window.log.info(
`Sending a message with ${mentionCount} mentions and ${placeholderCount} placeholders`
);
}
if (this.flags) {
proto.flags = this.flags;
@ -342,6 +353,17 @@ class Message {
targetSentTimestamp: this.deletedForEveryoneTimestamp,
};
}
if (this.mentions) {
proto.requiredProtocolVersion =
window.textsecure.protobuf.DataMessage.ProtocolVersion.MENTIONS;
proto.bodyRanges = this.mentions.map(
({ start, length, mentionUuid }) => ({
start,
length,
mentionUuid,
})
);
}
this.dataMessage = proto;
return proto;
@ -1419,7 +1441,8 @@ export default class MessageSender {
timestamp: number,
expireTimer: number | undefined,
profileKey?: ArrayBuffer,
flags?: number
flags?: number,
mentions?: BodyRangesType
): Promise<ArrayBuffer> {
const attributes = {
recipients: [destination],
@ -1435,6 +1458,7 @@ export default class MessageSender {
expireTimer,
profileKey,
flags,
mentions,
};
return this.getMessageProtoObj(attributes);
@ -1584,6 +1608,7 @@ export default class MessageSender {
sticker,
deletedForEveryoneTimestamp,
timestamp,
mentions,
}: {
attachments?: Array<AttachmentType>;
expireTimer?: number;
@ -1597,6 +1622,7 @@ export default class MessageSender {
sticker?: any;
deletedForEveryoneTimestamp?: number;
timestamp: number;
mentions?: BodyRangesType;
},
options?: SendOptionsType
): Promise<CallbackResultType> {
@ -1642,6 +1668,7 @@ export default class MessageSender {
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
}
: undefined,
mentions,
};
if (recipients.length === 0) {

View File

@ -1,13 +1,15 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export type BodyRangesType = Array<{
export interface BodyRangeType {
start: number;
length: number;
mentionUuid: string;
replacementText: string;
conversationID?: string;
}>;
}
export type BodyRangesType = Array<BodyRangeType>;
export type RenderTextCallbackType = (options: {
text: string;

View File

@ -7,9 +7,11 @@ export function getTextWithMentions(
bodyRanges: BodyRangesType,
text: string
): string {
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);
return bodyRanges
.sort((a, b) => b.start - a.start)
.reduce((acc, { start, length, replacementText }) => {
const left = acc.slice(0, start);
const right = acc.slice(start + length);
return `${left}@${replacementText}${right}`;
}, text);
}

View File

@ -14535,7 +14535,7 @@
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';",
"lineNumber": 83,
"lineNumber": 85,
"reasonCategory": "usageTrusted",
"updated": "2020-06-03T19:23:21.195Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
@ -14544,16 +14544,25 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const emojiCompletionRef = React.useRef();",
"lineNumber": 35,
"lineNumber": 42,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const mentionCompletionRef = React.useRef();",
"lineNumber": 43,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T23:54:34.273Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const quillRef = React.useRef();",
"lineNumber": 36,
"lineNumber": 44,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
@ -14562,7 +14571,7 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const scrollerRef = React.useRef(null);",
"lineNumber": 37,
"lineNumber": 45,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used with Quill for scrolling."
@ -14571,11 +14580,20 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const propsRef = React.useRef(props);",
"lineNumber": 38,
"lineNumber": 46,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const memberRepositoryRef = React.useRef(new util_1.MemberRepository());",
"lineNumber": 47,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T23:56:13.482Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "jQuery-$(",
"path": "ts/components/Intl.js",
@ -14884,6 +14902,14 @@
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Only used to focus the element."
},
{
"rule": "React-createRef",
"path": "ts/quill/mentions/completion.js",
"line": " this.suggestionListRef = react_1.default.createRef();",
"lineNumber": 22,
"reasonCategory": "usageTrusted",
"updated": "2020-10-30T23:03:08.319Z"
},
{
"rule": "jQuery-wrap(",
"path": "ts/shims/textsecure.js",

View File

@ -574,9 +574,15 @@ Whisper.ConversationView = Whisper.View.extend({
onClickAddPack: () => this.showStickerManager(),
onPickSticker: (packId: string, stickerId: number) =>
this.sendStickerMessage({ packId, stickerId }),
onSubmit: (message: any) => this.sendMessage(message),
onEditorStateChange: (msg: any, caretLocation: any) =>
this.onEditorStateChange(msg, caretLocation),
onSubmit: (
message: any,
mentions: typeof window.Whisper.BodyRangesType
) => this.sendMessage(message, mentions),
onEditorStateChange: (
msg: string,
bodyRanges: Array<typeof window.Whisper.BodyRangeType>,
caretLocation: number
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
onChooseAttachment: this.onChooseAttachment.bind(this),
getQuotedMessage: () => this.model.get('quotedMessageId'),
@ -2969,7 +2975,7 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
async sendMessage(message = '', options = {}) {
async sendMessage(message = '', mentions = [], options = {}) {
this.sendStart = Date.now();
try {
@ -2979,7 +2985,7 @@ Whisper.ConversationView = Whisper.View.extend({
if (contacts && contacts.length) {
const sendAnyway = await this.showSendAnywayDialog(contacts);
if (sendAnyway) {
this.sendMessage(message, { force: true });
this.sendMessage(message, mentions, { force: true });
return;
}
@ -3047,7 +3053,9 @@ Whisper.ConversationView = Whisper.View.extend({
message,
attachments,
this.quote,
this.getLinkPreview()
this.getLinkPreview(),
undefined, // sticker
mentions
);
this.compositionApi.current.reset();
@ -3065,13 +3073,20 @@ Whisper.ConversationView = Whisper.View.extend({
}
},
onEditorStateChange(messageText: any, caretLocation: any) {
onEditorStateChange(
messageText: string,
bodyRanges: Array<typeof window.Whisper.BodyRangeType>,
caretLocation?: number
) {
this.maybeBumpTyping(messageText);
this.debouncedSaveDraft(messageText);
this.debouncedSaveDraft(messageText, bodyRanges);
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
},
async saveDraft(messageText: any) {
async saveDraft(
messageText: any,
bodyRanges: Array<typeof window.Whisper.BodyRangeType>
) {
const trimmed =
messageText && messageText.length > 0 ? messageText.trim() : '';
@ -3079,6 +3094,7 @@ Whisper.ConversationView = Whisper.View.extend({
this.model.set({
draft: null,
draftChanged: true,
draftBodyRanges: [],
});
await this.saveModel();
@ -3089,12 +3105,13 @@ Whisper.ConversationView = Whisper.View.extend({
this.model.set({
draft: messageText,
draftChanged: true,
draftBodyRanges: bodyRanges,
});
await this.saveModel();
}
},
maybeGrabLinkPreview(message: any, caretLocation: any) {
maybeGrabLinkPreview(message: string, caretLocation?: number) {
// Don't generate link previews if user has turned them off
if (!window.storage.get('linkPreviews', false)) {
return;

4
ts/window.d.ts vendored
View File

@ -26,7 +26,7 @@ import * as Groups from './groups';
import * as Crypto from './Crypto';
import * as RemoteConfig from './RemoteConfig';
import * as zkgroup from './util/zkgroup';
import { LocalizerType, BodyRangesType } from './types/Util';
import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling';
import { ColorType } from './types/Colors';
import { ConversationController } from './ConversationController';
@ -609,6 +609,8 @@ export type WhisperType = {
GroupMemberList: any;
KeyVerificationPanelView: any;
SafetyNumberChangeDialogView: any;
BodyRangesType: BodyRangesType;
BodyRangeType: BodyRangeType;
Notifications: {
removeBy: (filter: Partial<unknown>) => void;