Fix a number of emoji bugs in message composer

This commit is contained in:
Ken Powers 2019-10-31 15:32:10 -04:00 committed by Scott Nonnenberg
parent fd5af8bb62
commit 8659f1dd23
6 changed files with 237 additions and 87 deletions

View file

@ -1,11 +1,9 @@
const { take } = require('lodash'); const { take } = require('lodash');
const { getRecentEmojis } = require('./data'); const { getRecentEmojis } = require('./data');
const { replaceColons } = require('../../ts/components/emoji/lib');
module.exports = { module.exports = {
getInitialState, getInitialState,
load, load,
replaceColons,
}; };
let initialState = null; let initialState = null;

View file

@ -5875,6 +5875,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
.module-emoji { .module-emoji {
display: block; display: block;
color: transparent; color: transparent;
font-family: auto;
@include light-theme() { @include light-theme() {
caret-color: $color-gray-90; caret-color: $color-gray-90;

View file

@ -23,8 +23,9 @@ import { Emoji } from './emoji/Emoji';
import { EmojiPickDataType } from './emoji/EmojiPicker'; import { EmojiPickDataType } from './emoji/EmojiPicker';
import { import {
convertShortName, convertShortName,
DataFromEmojiText,
EmojiData, EmojiData,
replaceColons, emojiToData,
search, search,
} from './emoji/lib'; } from './emoji/lib';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
@ -56,9 +57,11 @@ export type InputApi = {
submit: () => void; submit: () => void;
}; };
export type SelectionKeys = 'Shift-End' | 'End' | 'Shift-Home' | 'Home';
export type CompositionInputEditorCommand = export type CompositionInputEditorCommand =
| DraftEditorCommand | DraftEditorCommand
| ('enter-emoji' | 'next-emoji' | 'prev-emoji' | 'submit'); | ('enter-emoji' | 'next-emoji' | 'prev-emoji' | 'submit')
| SelectionKeys;
function getTrimmedMatchAtIndex(str: string, index: number, pattern: RegExp) { function getTrimmedMatchAtIndex(str: string, index: number, pattern: RegExp) {
let match; let match;
@ -117,20 +120,119 @@ function getLengthOfSelectedText(state: EditorState): number {
return length; return length;
} }
function getWordAtIndex(str: string, index: number) { function getWordAtIndex(
str: string,
index: number
): { start: number; end: number; word: string } {
const start = str const start = str
.slice(0, index + 1) .slice(0, index + 1)
.replace(/\s+$/, '') .replace(/\s+$/, '')
.search(/\S+$/); .search(/\S+$/);
const end = str.slice(index).search(/(?:[^a-z0-9-_+]|$)/) + index;
let end =
str
.slice(index)
.split('')
.findIndex(c => /[^a-z0-9-_]/i.test(c) || c === ':') + index;
const endChar = str[end];
if (/\w|:/.test(endChar)) {
end += 1;
}
const word = str.slice(start, end);
if (word === ':') {
return getWordAtIndex(str, index + 1);
}
return { return {
start, start,
end, end,
word: str.slice(start, end), word,
}; };
} }
// Replace bare (non-entitied) emojis with draft entities
function replaceBareEmojis(state: EditorState, focus: boolean): EditorState {
// Track emoji positions
const selections: Array<[SelectionState, DataFromEmojiText]> = [];
const content = state.getCurrentContent();
const initialSelection = state.getSelection();
let selectionOffset = 0;
content.getBlockMap().forEach(block => {
if (!block) {
return;
}
const pat = emojiRegex();
const text = block.getText();
let match;
// tslint:disable-next-line
while ((match = pat.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
const blockKey = block.getKey();
const blockSelection = SelectionState.createEmpty(blockKey).merge({
anchorOffset: start,
focusOffset: end,
}) as SelectionState;
const emojiData = emojiToData(match[0]);
// If there is not entity at this location and emoji data exists for the emoji at this location, track it for replacement
if (!block.getEntityAt(start) && emojiData) {
selections.push([blockSelection, emojiData]);
}
}
});
const newContent = selections.reduce(
(accContent, [sel, { shortName, tone }]) => {
const emojiContent = convertShortName(shortName);
const emojiEntityKey = accContent
.createEntity('emoji', 'IMMUTABLE', {
shortName: shortName,
skinTone: tone,
})
.getLastCreatedEntityKey();
// Keep track of selection offsets caused by replaced emojis
if (sel.getAnchorOffset() < initialSelection.getAnchorOffset()) {
selectionOffset += Math.abs(
sel.getAnchorOffset() - sel.getFocusOffset()
);
}
return Modifier.replaceText(
accContent,
sel,
emojiContent,
undefined,
emojiEntityKey
);
},
content
);
const pushState = EditorState.push(
state,
newContent,
'replace-emoji' as EditorChangeType
);
if (focus) {
const newSelection = initialSelection.merge({
anchorOffset: initialSelection.getAnchorOffset() + selectionOffset,
focusOffset: initialSelection.getFocusOffset() + selectionOffset,
}) as SelectionState;
return EditorState.forceSelection(pushState, newSelection);
}
return pushState;
}
const compositeDecorator = new CompositeDecorator([ const compositeDecorator = new CompositeDecorator([
{ {
strategy: (block, cb) => { strategy: (block, cb) => {
@ -187,18 +289,15 @@ const getInitialEditorState = (startingText?: string) => {
return EditorState.createEmpty(compositeDecorator); return EditorState.createEmpty(compositeDecorator);
} }
const end = startingText.length; const state = replaceBareEmojis(
const state = EditorState.createWithContent( EditorState.createWithContent(
ContentState.createFromText(startingText), ContentState.createFromText(startingText),
compositeDecorator compositeDecorator
),
false
); );
const selection = state.getSelection();
const selectionAtEnd = selection.merge({
anchorOffset: end,
focusOffset: end,
}) as SelectionState;
return EditorState.forceSelection(state, selectionAtEnd); return EditorState.moveFocusToEnd(state);
}; };
// tslint:disable-next-line max-func-body-length // tslint:disable-next-line max-func-body-length
@ -231,6 +330,7 @@ export const CompositionInput = ({
const focusRef = React.useRef(false); const focusRef = React.useRef(false);
const editorStateRef = React.useRef<EditorState>(editorRenderState); const editorStateRef = React.useRef<EditorState>(editorRenderState);
const rootElRef = React.useRef<HTMLDivElement>(); const rootElRef = React.useRef<HTMLDivElement>();
const latestKeyRef = React.useRef<string>();
// This function sets editorState and also keeps a reference to the newly set // This function sets editorState and also keeps a reference to the newly set
// state so we can reference the state in effects and callbacks without // state so we can reference the state in effects and callbacks without
@ -309,7 +409,7 @@ export const CompositionInput = ({
// Update the state to indicate emojiable text at the current position. // Update the state to indicate emojiable text at the current position.
const newSearchText = match ? match.trim().substr(1) : ''; const newSearchText = match ? match.trim().substr(1) : '';
if (newSearchText.endsWith(':')) { if (newSearchText.endsWith(':') && latestKeyRef.current === ':') {
const bareText = trimEnd(newSearchText, ':'); const bareText = trimEnd(newSearchText, ':');
const emoji = head(search(bareText)); const emoji = head(search(bareText));
if (emoji && bareText === emoji.short_name) { if (emoji && bareText === emoji.short_name) {
@ -328,12 +428,15 @@ export const CompositionInput = ({
resetEmojiResults(); resetEmojiResults();
} }
const modifiedState = replaceBareEmojis(newState, focusRef.current);
// Finally, update the editor state // Finally, update the editor state
setAndTrackEditorState(newState); setAndTrackEditorState(modifiedState);
updateExternalStateListeners(newState); updateExternalStateListeners(modifiedState);
}, },
[ [
focusRef, focusRef,
latestKeyRef,
resetEmojiResults, resetEmojiResults,
setAndTrackEditorState, setAndTrackEditorState,
setSearchText, setSearchText,
@ -399,8 +502,7 @@ export const CompositionInput = ({
() => { () => {
const { current: state } = editorStateRef; const { current: state } = editorStateRef;
const text = state.getCurrentContent().getPlainText(); const text = state.getCurrentContent().getPlainText();
const emojidText = replaceColons(text); const trimmedText = text.trim();
const trimmedText = emojidText.trim();
onSubmit(trimmedText); onSubmit(trimmedText);
}, },
[editorStateRef, onSubmit] [editorStateRef, onSubmit]
@ -453,8 +555,36 @@ export const CompositionInput = ({
[emojiResultsIndex, emojiResults] [emojiResultsIndex, emojiResults]
); );
const setCursor = React.useCallback(
(key: SelectionKeys) => {
const { current: state } = editorStateRef;
const selection = state.getSelection();
const offset =
key === 'Shift-Home' || key === 'Home'
? 0
: state
.getCurrentContent()
.getBlockForKey(selection.getAnchorKey())
.getText().length;
const desc: { focusOffset?: number; anchorOffset?: number } = {
focusOffset: offset,
};
if (key === 'Home' || key === 'End') {
desc.anchorOffset = offset;
}
const newSelection = selection.merge(desc) as SelectionState;
setAndTrackEditorState(EditorState.forceSelection(state, newSelection));
},
[editorStateRef, setAndTrackEditorState]
);
const handleEditorArrowKey = React.useCallback( const handleEditorArrowKey = React.useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
latestKeyRef.current = e.key;
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
selectEmojiResult('prev', e); selectEmojiResult('prev', e);
} }
@ -462,8 +592,26 @@ export const CompositionInput = ({
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
selectEmojiResult('next', e); selectEmojiResult('next', e);
} }
if (e.key === 'ArrowLeft' && e.metaKey) {
e.preventDefault();
if (e.shiftKey) {
setCursor('Shift-Home');
} else {
setCursor('Home');
}
}
if (e.key === 'ArrowRight' && e.metaKey) {
e.preventDefault();
if (e.shiftKey) {
setCursor('Shift-End');
} else {
setCursor('End');
}
}
}, },
[selectEmojiResult] [latestKeyRef, selectEmojiResult, setCursor]
); );
const handleEscapeKey = React.useCallback( const handleEscapeKey = React.useCallback(
@ -503,24 +651,18 @@ export const CompositionInput = ({
.getLastCreatedEntityKey(); .getLastCreatedEntityKey();
const word = getWordAtCaret(); const word = getWordAtCaret();
let newContent = replaceWord let newContent = Modifier.replaceText(
? Modifier.replaceText( oldContent,
oldContent, replaceWord
selection.merge({ ? (selection.merge({
anchorOffset: word.start, anchorOffset: word.start,
focusOffset: word.end, focusOffset: word.end,
}) as SelectionState, }) as SelectionState)
emojiContent, : selection,
undefined, emojiContent,
emojiEntityKey undefined,
) emojiEntityKey
: Modifier.insertText( );
oldContent,
selection,
emojiContent,
undefined,
emojiEntityKey
);
const afterSelection = newContent.getSelectionAfter(); const afterSelection = newContent.getSelectionAfter();
@ -611,6 +753,15 @@ export const CompositionInput = ({
selectEmojiResult('prev'); selectEmojiResult('prev');
} }
if (
command === 'Shift-End' ||
command === 'End' ||
command === 'Shift-Home' ||
command === 'Home'
) {
setCursor(command);
}
return 'not-handled'; return 'not-handled';
}, },
[ [
@ -619,6 +770,7 @@ export const CompositionInput = ({
resetEmojiResults, resetEmojiResults,
selectEmojiResult, selectEmojiResult,
setAndTrackEditorState, setAndTrackEditorState,
setCursor,
skinTone, skinTone,
submit, submit,
] ]
@ -637,7 +789,10 @@ export const CompositionInput = ({
); );
const editorKeybindingFn = React.useCallback( const editorKeybindingFn = React.useCallback(
// tslint:disable-next-line cyclomatic-complexity
(e: React.KeyboardEvent): CompositionInputEditorCommand | null => { (e: React.KeyboardEvent): CompositionInputEditorCommand | null => {
latestKeyRef.current = e.key;
if (e.key === 'Enter' && emojiResults.length > 0) { if (e.key === 'Enter' && emojiResults.length > 0) {
e.preventDefault(); e.preventDefault();
@ -654,6 +809,30 @@ export const CompositionInput = ({
return 'submit'; return 'submit';
} }
if (e.shiftKey && e.key === 'End') {
e.preventDefault();
return 'Shift-End';
}
if (e.key === 'End') {
e.preventDefault();
return 'End';
}
if (e.shiftKey && e.key === 'Home') {
e.preventDefault();
return 'Shift-Home';
}
if (e.key === 'Home') {
e.preventDefault();
return 'Home';
}
if (e.key === 'n' && e.ctrlKey) { if (e.key === 'n' && e.ctrlKey) {
e.preventDefault(); e.preventDefault();
@ -680,7 +859,7 @@ export const CompositionInput = ({
return getDefaultKeyBinding(e); return getDefaultKeyBinding(e);
}, },
[emojiResults, large] [latestKeyRef, emojiResults, large]
); );
// Create popper root // Create popper root
@ -732,6 +911,7 @@ export const CompositionInput = ({
setAndTrackEditorState( setAndTrackEditorState(
EditorState.forceSelection(oldState, oldState.getSelection()) EditorState.forceSelection(oldState, oldState.getSelection())
); );
onFocus();
}; };
rootEl.addEventListener('focusin', onFocusIn); rootEl.addEventListener('focusin', onFocusIn);
@ -743,7 +923,7 @@ export const CompositionInput = ({
return noop; return noop;
}, },
[editorStateRef, rootElRef, setAndTrackEditorState] [editorStateRef, onFocus, rootElRef, setAndTrackEditorState]
); );
if (inputApi) { if (inputApi) {
@ -780,6 +960,8 @@ export const CompositionInput = ({
placeholder={i18n('sendMessage')} placeholder={i18n('sendMessage')}
onUpArrow={handleEditorArrowKey} onUpArrow={handleEditorArrowKey}
onDownArrow={handleEditorArrowKey} onDownArrow={handleEditorArrowKey}
onLeftArrow={handleEditorArrowKey}
onRightArrow={handleEditorArrowKey}
onEscape={handleEscapeKey} onEscape={handleEscapeKey}
onTab={onTab} onTab={onTab}
handleKeyCommand={handleEditorCommand} handleKeyCommand={handleEditorCommand}

View file

@ -38,6 +38,11 @@ export type EmojiSkinVariation = {
has_img_messenger: boolean; has_img_messenger: boolean;
}; };
export type DataFromEmojiText = {
shortName: string;
tone?: SkinToneKey;
};
export type EmojiData = { export type EmojiData = {
name: string; name: string;
unified: string; unified: string;
@ -116,6 +121,7 @@ export const preloadImages = async () => {
const dataByShortName = keyBy(data, 'short_name'); const dataByShortName = keyBy(data, 'short_name');
const imageByEmoji: { [key: string]: string } = {}; const imageByEmoji: { [key: string]: string } = {};
const dataByEmoji: { [key: string]: DataFromEmojiText } = {};
export const dataByCategory = mapValues( export const dataByCategory = mapValues(
groupBy(data, ({ category }) => { groupBy(data, ({ category }) => {
@ -242,20 +248,8 @@ export function emojiToImage(emoji: string): string | undefined {
return imageByEmoji[emoji]; return imageByEmoji[emoji];
} }
export function replaceColons(str: string) { export function emojiToData(emoji: string): DataFromEmojiText | undefined {
return str.replace(/:[a-z0-9-_+]+:(?::skin-tone-[1-5]:)?/gi, m => { return dataByEmoji[emoji];
const [shortName = '', skinTone = '0'] = m
.replace('skin-tone-', '')
.toLowerCase()
.split(':')
.filter(Boolean);
if (shortName && isShortName(shortName)) {
return convertShortName(shortName, parseInt(skinTone, 10));
}
return m;
});
} }
function getCountOfAllMatches(str: string, regex: RegExp) { function getCountOfAllMatches(str: string, regex: RegExp) {
@ -305,12 +299,17 @@ data.forEach(emoji => {
} }
imageByEmoji[convertShortName(short_name)] = makeImagePath(image); imageByEmoji[convertShortName(short_name)] = makeImagePath(image);
dataByEmoji[convertShortName(short_name)] = { shortName: short_name };
if (skin_variations) { if (skin_variations) {
Object.entries(skin_variations).forEach(([tone, variation]) => { Object.entries(skin_variations).forEach(([tone, variation]) => {
imageByEmoji[ imageByEmoji[
convertShortName(short_name, tone as SkinToneKey) convertShortName(short_name, tone as SkinToneKey)
] = makeImagePath(variation.image); ] = makeImagePath(variation.image);
dataByEmoji[convertShortName(short_name, tone as SkinToneKey)] = {
shortName: short_name,
tone: tone as SkinToneKey,
};
}); });
} }
}); });

View file

@ -1,30 +0,0 @@
import { assert } from 'chai';
import { replaceColons } from '../../../components/emoji/lib';
describe('replaceColons', () => {
it('replaces known emoji short names between colons', () => {
const anEmoji = replaceColons('hello :grinning:');
assert.equal(anEmoji, 'hello 😀');
});
it('understands skin tone modifiers', () => {
const skinToneModifierEmoji = replaceColons('hello :wave::skin-tone-5:!');
assert.equal(skinToneModifierEmoji, 'hello 👋🏿!');
});
it('passes through strings with no colons', () => {
const noEmoji = replaceColons('hello');
assert.equal(noEmoji, 'hello');
});
it('ignores unknown emoji', () => {
const unknownEmoji = replaceColons(':Unknown: :unknown:');
assert.equal(unknownEmoji, ':Unknown: :unknown:');
});
it('converts short names to lowercase before matching them', () => {
const emojiWithCaps = replaceColons('hello :Grinning:');
assert.equal(emojiWithCaps, 'hello 😀');
});
});

View file

@ -231,9 +231,9 @@
"rule": "jQuery-load(", "rule": "jQuery-load(",
"path": "js/modules/emojis.js", "path": "js/modules/emojis.js",
"line": "async function load() {", "line": "async function load() {",
"lineNumber": 13, "lineNumber": 11,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2019-05-23T22:27:53.554Z" "updated": "2019-10-31T17:28:08.684Z"
}, },
{ {
"rule": "jQuery-load(", "rule": "jQuery-load(",