Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit e0b99b5

Browse files
authored
Merge pull request #3342 from matrix-org/bwindels/cider-replace-emoticons
Auto-replace emoticons with emojis in new composer
2 parents 08339ab + f10e1d7 commit e0b99b5

File tree

6 files changed

+444
-13
lines changed

6 files changed

+444
-13
lines changed

src/components/views/rooms/BasicMessageComposer.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import {autoCompleteCreator} from '../../../editor/parts';
2525
import {renderModel} from '../../../editor/render';
2626
import {Room} from 'matrix-js-sdk';
2727
import TypingStore from "../../../stores/TypingStore";
28+
import EMOJIBASE from 'emojibase-data/en/compact.json';
29+
import SettingsStore from "../../../settings/SettingsStore";
30+
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
31+
32+
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
2833

2934
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
3035

@@ -70,6 +75,35 @@ export default class BasicMessageEditor extends React.Component {
7075
this._modifiedFlag = false;
7176
}
7277

78+
_replaceEmoticon = (caret, inputType, diff) => {
79+
const {model} = this.props;
80+
const range = model.startRange(caret);
81+
// expand range max 8 characters backwards from caret,
82+
// as a space to look for an emoticon
83+
let n = 8;
84+
range.expandBackwardsWhile((index, offset) => {
85+
const part = model.parts[index];
86+
n -= 1;
87+
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
88+
});
89+
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
90+
if (emoticonMatch) {
91+
const query = emoticonMatch[1].toLowerCase().replace("-", "");
92+
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
93+
if (data) {
94+
const hasPrecedingSpace = emoticonMatch[0][0] === " ";
95+
// we need the range to only comprise of the emoticon
96+
// because we'll replace the whole range with an emoji,
97+
// so move the start forward to the start of the emoticon.
98+
// Take + 1 because index is reported without the possible preceding space.
99+
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0));
100+
// this returns the amount of added/removed characters during the replace
101+
// so the caret position can be adjusted.
102+
return range.replace([this.props.model.partCreator.plain(data.unicode + " ")]);
103+
}
104+
}
105+
}
106+
73107
_updateEditorState = (caret, inputType, diff) => {
74108
renderModel(this._editorRef, this.props.model);
75109
if (caret) {
@@ -262,6 +296,9 @@ export default class BasicMessageEditor extends React.Component {
262296
componentDidMount() {
263297
const model = this.props.model;
264298
model.setUpdateCallback(this._updateEditorState);
299+
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
300+
model.setTransformCallback(this._replaceEmoticon);
301+
}
265302
const partCreator = model.partCreator;
266303
// TODO: does this allow us to get rid of EditorStateTransfer?
267304
// not really, but we could not serialize the parts, and just change the autoCompleter

src/editor/model.js

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@ limitations under the License.
1616
*/
1717

1818
import {diffAtCaret, diffDeletion} from "./diff";
19+
import DocumentPosition from "./position";
20+
import Range from "./range";
21+
22+
/**
23+
* @callback ModelCallback
24+
* @param {DocumentPosition?} caretPosition the position where the caret should be position
25+
* @param {string?} inputType the inputType of the DOM input event
26+
* @param {object?} diff an object with `removed` and `added` strings
27+
*/
28+
29+
/**
30+
* @callback TransformCallback
31+
* @param {DocumentPosition?} caretPosition the position where the caret should be position
32+
* @param {string?} inputType the inputType of the DOM input event
33+
* @param {object?} diff an object with `removed` and `added` strings
34+
* @return {Number?} addedLen how many characters were added/removed (-) before the caret during the transformation step.
35+
* This is used to adjust the caret position.
36+
*/
1937

2038
export default class EditorModel {
2139
constructor(parts, partCreator, updateCallback = null) {
@@ -24,9 +42,26 @@ export default class EditorModel {
2442
this._activePartIdx = null;
2543
this._autoComplete = null;
2644
this._autoCompletePartIdx = null;
45+
this._transformCallback = null;
2746
this.setUpdateCallback(updateCallback);
47+
this._updateInProgress = false;
48+
}
49+
50+
/**
51+
* Set a callback for the transformation step.
52+
* While processing an update, right before calling the update callback,
53+
* a transform callback can be called, which serves to do modifications
54+
* on the model that can span multiple parts. Also see `startRange()`.
55+
* @param {TransformCallback} transformCallback
56+
*/
57+
setTransformCallback(transformCallback) {
58+
this._transformCallback = transformCallback;
2859
}
2960

61+
/**
62+
* Set a callback for rerendering the model after it has been updated.
63+
* @param {ModelCallback} updateCallback
64+
*/
3065
setUpdateCallback(updateCallback) {
3166
this._updateCallback = updateCallback;
3267
}
@@ -131,6 +166,7 @@ export default class EditorModel {
131166
}
132167

133168
update(newValue, inputType, caret) {
169+
this._updateInProgress = true;
134170
const diff = this._diff(newValue, inputType, caret);
135171
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
136172
let removedOffsetDecrease = 0;
@@ -145,11 +181,21 @@ export default class EditorModel {
145181
}
146182
this._mergeAdjacentParts();
147183
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
148-
const newPosition = this.positionForOffset(caretOffset, true);
184+
let newPosition = this.positionForOffset(caretOffset, true);
149185
this._setActivePart(newPosition, canOpenAutoComplete);
186+
if (this._transformCallback) {
187+
const transformAddedLen = this._transform(newPosition, inputType, diff);
188+
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
189+
}
190+
this._updateInProgress = false;
150191
this._updateCallback(newPosition, inputType, diff);
151192
}
152193

194+
_transform(newPosition, inputType, diff) {
195+
const result = this._transformCallback(newPosition, inputType, diff);
196+
return Number.isFinite(result) ? result : 0;
197+
}
198+
153199
_setActivePart(pos, canOpenAutoComplete) {
154200
const {index} = pos;
155201
const part = this._parts[index];
@@ -197,7 +243,7 @@ export default class EditorModel {
197243
this._updateCallback(pos);
198244
}
199245

200-
_mergeAdjacentParts(docPos) {
246+
_mergeAdjacentParts() {
201247
let prevPart;
202248
for (let i = 0; i < this._parts.length; ++i) {
203249
let part = this._parts[i];
@@ -339,19 +385,39 @@ export default class EditorModel {
339385

340386
return new DocumentPosition(index, totalOffset - currentOffset);
341387
}
342-
}
343388

344-
class DocumentPosition {
345-
constructor(index, offset) {
346-
this._index = index;
347-
this._offset = offset;
348-
}
349-
350-
get index() {
351-
return this._index;
389+
/**
390+
* Starts a range, which can span across multiple parts, to find and replace text.
391+
* @param {DocumentPosition} position where to start the range
392+
* @return {Range}
393+
*/
394+
startRange(position) {
395+
return new Range(this, position);
352396
}
353397

354-
get offset() {
355-
return this._offset;
398+
// called from Range.replace
399+
replaceRange(startPosition, endPosition, parts) {
400+
const newStartPartIndex = this._splitAt(startPosition);
401+
const idxDiff = newStartPartIndex - startPosition.index;
402+
// if both position are in the same part, and we split it at start position,
403+
// the offset of the end position needs to be decreased by the offset of the start position
404+
const removedOffset = startPosition.index === endPosition.index ? startPosition.offset : 0;
405+
const adjustedEndPosition = new DocumentPosition(
406+
endPosition.index + idxDiff,
407+
endPosition.offset - removedOffset,
408+
);
409+
const newEndPartIndex = this._splitAt(adjustedEndPosition);
410+
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
411+
this._removePart(i);
412+
}
413+
let insertIdx = newStartPartIndex;
414+
for (const part of parts) {
415+
this._insertPart(insertIdx, part);
416+
insertIdx += 1;
417+
}
418+
this._mergeAdjacentParts();
419+
if (!this._updateInProgress) {
420+
this._updateCallback();
421+
}
356422
}
357423
}

src/editor/position.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
Copyright 2019 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
export default class DocumentPosition {
18+
constructor(index, offset) {
19+
this._index = index;
20+
this._offset = offset;
21+
}
22+
23+
get index() {
24+
return this._index;
25+
}
26+
27+
get offset() {
28+
return this._offset;
29+
}
30+
31+
compare(otherPos) {
32+
if (this._index === otherPos._index) {
33+
return this._offset - otherPos._offset;
34+
} else {
35+
return this._index - otherPos._index;
36+
}
37+
}
38+
39+
iteratePartsBetween(other, model, callback) {
40+
if (this.index === -1 || other.index === -1) {
41+
return;
42+
}
43+
const [startPos, endPos] = this.compare(other) < 0 ? [this, other] : [other, this];
44+
if (startPos.index === endPos.index) {
45+
callback(model.parts[this.index], startPos.offset, endPos.offset);
46+
} else {
47+
const firstPart = model.parts[startPos.index];
48+
callback(firstPart, startPos.offset, firstPart.text.length);
49+
for (let i = startPos.index + 1; i < endPos.index; ++i) {
50+
const part = model.parts[i];
51+
callback(part, 0, part.text.length);
52+
}
53+
const lastPart = model.parts[endPos.index];
54+
callback(lastPart, 0, endPos.offset);
55+
}
56+
}
57+
58+
forwardsWhile(model, predicate) {
59+
if (this.index === -1) {
60+
return this;
61+
}
62+
63+
let {index, offset} = this;
64+
const {parts} = model;
65+
while (index < parts.length) {
66+
const part = parts[index];
67+
while (offset < part.text.length) {
68+
if (!predicate(index, offset, part)) {
69+
return new DocumentPosition(index, offset);
70+
}
71+
offset += 1;
72+
}
73+
// end reached
74+
if (index === (parts.length - 1)) {
75+
return new DocumentPosition(index, offset);
76+
} else {
77+
index += 1;
78+
offset = 0;
79+
}
80+
}
81+
}
82+
83+
backwardsWhile(model, predicate) {
84+
if (this.index === -1) {
85+
return this;
86+
}
87+
88+
let {index, offset} = this;
89+
const parts = model.parts;
90+
while (index >= 0) {
91+
const part = parts[index];
92+
while (offset > 0) {
93+
if (!predicate(index, offset - 1, part)) {
94+
return new DocumentPosition(index, offset);
95+
}
96+
offset -= 1;
97+
}
98+
// start reached
99+
if (index === 0) {
100+
return new DocumentPosition(index, offset);
101+
} else {
102+
index -= 1;
103+
offset = parts[index].text.length;
104+
}
105+
}
106+
}
107+
}

src/editor/range.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
Copyright 2019 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
export default class Range {
18+
constructor(model, startPosition, endPosition = startPosition) {
19+
this._model = model;
20+
this._start = startPosition;
21+
this._end = endPosition;
22+
}
23+
24+
moveStart(delta) {
25+
this._start = this._start.forwardsWhile(this._model, () => {
26+
delta -= 1;
27+
return delta >= 0;
28+
});
29+
}
30+
31+
expandBackwardsWhile(predicate) {
32+
this._start = this._start.backwardsWhile(this._model, predicate);
33+
}
34+
35+
get text() {
36+
let text = "";
37+
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
38+
const t = part.text.substring(startIdx, endIdx);
39+
text = text + t;
40+
});
41+
return text;
42+
}
43+
44+
replace(parts) {
45+
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
46+
let oldLength = 0;
47+
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
48+
oldLength += endIdx - startIdx;
49+
});
50+
this._model.replaceRange(this._start, this._end, parts);
51+
return newLength - oldLength;
52+
}
53+
}

0 commit comments

Comments
 (0)