Send @mentions
This commit is contained in:
parent
63c4cf9430
commit
53c89aa40f
28 changed files with 1728 additions and 107 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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
3
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
};
|
56
ts/quill/mentions/blot.tsx
Normal file
56
ts/quill/mentions/blot.tsx
Normal 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);
|
||||
}
|
||||
}
|
300
ts/quill/mentions/completion.tsx
Normal file
300
ts/quill/mentions/completion.tsx
Normal 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);
|
||||
}
|
||||
}
|
50
ts/quill/mentions/matchers.ts
Normal file
50
ts/quill/mentions/matchers.ts
Normal 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
89
ts/quill/util.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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>;
|
||||
|
|
|
@ -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),
|
||||
|
|
354
ts/test/quill/mentions/completion_test.tsx
Normal file
354
ts/test/quill/mentions/completion_test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
187
ts/test/quill/mentions/matchers_test.ts
Normal file
187
ts/test/quill/mentions/matchers_test.ts
Normal 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
164
ts/test/quill/util_test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
45
ts/test/util/getTextWithMentions_test.ts
Normal file
45
ts/test/util/getTextWithMentions_test.ts
Normal 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
1
ts/textsecure.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
4
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue