add quick shortcut emoji feature and tests

Signed-off-by: macekj <macekj@umich.edu>
This commit is contained in:
macekj 2020-11-17 17:36:58 -05:00
parent 8eadf6b183
commit ba8d02a808
5 changed files with 107 additions and 6 deletions

View file

@ -34,7 +34,7 @@ const LIMIT = 20;
// Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase
// anchored to only match from the start of parts otherwise it'll show emoji suggestions whilst typing matrix IDs
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|(?:^|\\s):[+-\\w]*:?)$', 'g');
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|(?:^|\\s|(?<=^\\+)):[+-\\w]*:?)$', 'g');
interface IEmojiShort {
emoji: IEmoji;

View file

@ -47,7 +47,7 @@ import AutocompleteWrapperModel from "../../../editor/autocomplete";
import DocumentPosition from "../../../editor/position";
import {ICompletion} from "../../../autocomplete/Autocompleter";
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s|(?<=^\\+))(' + EMOTICON_REGEX.source + ')\\s$');
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
@ -524,7 +524,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && (
return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" ||
part.type === "pill-candidate" ||
part.type === "command"

View file

@ -43,6 +43,8 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import EMOJI_REGEX from 'emojibase-regex';
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -88,6 +90,25 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
return content;
}
// exported for tests
export function isQuickReaction(model) {
const parts = model.parts;
if (parts.length == 0) return false;
let text = parts[0].text;
text += parts[1] ? parts[1].text : "";
// shortcut takes the form "+:emoji:" or "+ :emoji:""
// can be in 1 or 2 parts
if (parts.length <= 2) {
const hasShortcut = text.startsWith("+") || text.startsWith("+ ");
const emojiMatch = text.match(EMOJI_REGEX);
if (hasShortcut && emojiMatch && emojiMatch.length == 1) {
return emojiMatch[0] === text.substring(1) ||
emojiMatch[0] === text.substring(2);
}
}
return false;
}
export default class SendMessageComposer extends React.Component {
static propTypes = {
room: PropTypes.object.isRequired,
@ -216,6 +237,41 @@ export default class SendMessageComposer extends React.Component {
return false;
}
_sendQuickReaction() {
const timeline = this.props.room.getLiveTimeline();
const events = timeline.getEvents();
const reaction = this.model.parts[1].text;
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].getType() === "m.room.message") {
let shouldReact = true;
const lastMessage = events[i];
const userId = MatrixClientPeg.get().getUserId();
const messageReactions = this.props.room.getUnfilteredTimelineSet()
.getRelationsForEvent(lastMessage.getId(), "m.annotation", "m.reaction");
// if we have already sent this reaction, don't redact but don't re-send
if (messageReactions) {
const myReactionEvents = messageReactions.getAnnotationsBySender()[userId] || [];
const myReactionKeys = [...myReactionEvents]
.filter(event => !event.isRedacted())
.map(event => event.getRelation().key);
shouldReact = !myReactionKeys.includes(reaction);
}
if (shouldReact) {
MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": lastMessage.getId(),
"key": reaction,
},
});
dis.dispatch({action: "message_sent"});
}
break;
}
}
}
_getSlashCommand() {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
@ -303,6 +359,11 @@ export default class SendMessageComposer extends React.Component {
}
}
if (isQuickReaction(this.model)) {
shouldSend = false;
this._sendQuickReaction();
}
const replyToEvent = this.props.replyToEvent;
if (shouldSend) {
const startTime = CountlyAnalytics.getTimestamp();

View file

@ -190,7 +190,9 @@ abstract class PlainBasePart extends BasePart {
return true;
}
// only split if the previous character is a space
return this._text[offset - 1] !== " ";
// or if it is a + and this is a :
return this._text[offset - 1] !== " " &&
(this._text[offset - 1] !== "+" || chr !== ":");
}
return true;
}

View file

@ -18,8 +18,10 @@ import Adapter from "enzyme-adapter-react-16";
import { configure, mount } from "enzyme";
import React from "react";
import {act} from "react-dom/test-utils";
import SendMessageComposer, {createMessageContent} from "../../../../src/components/views/rooms/SendMessageComposer";
import SendMessageComposer, {
createMessageContent,
isQuickReaction,
} from "../../../../src/components/views/rooms/SendMessageComposer";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import EditorModel from "../../../../src/editor/model";
import {createPartCreator, createRenderer} from "../../../editor/mock";
@ -227,6 +229,42 @@ describe('<SendMessageComposer/>', () => {
});
});
});
describe("isQuickReaction", () => {
it("correctly detects quick reaction", () => {
const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("+😊", "insertText", {offset: 3, atNodeEnd: true});
const isReaction = isQuickReaction(model);
expect(isReaction).toBeTruthy();
});
it("correctly detects quick reaction with space", () => {
const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("+ 😊", "insertText", {offset: 4, atNodeEnd: true});
const isReaction = isQuickReaction(model);
expect(isReaction).toBeTruthy();
});
it("correctly rejects quick reaction with extra text", () => {
const model = new EditorModel([], createPartCreator(), createRenderer());
const model2 = new EditorModel([], createPartCreator(), createRenderer());
const model3 = new EditorModel([], createPartCreator(), createRenderer());
const model4 = new EditorModel([], createPartCreator(), createRenderer());
model.update("+😊hello", "insertText", {offset: 8, atNodeEnd: true});
model2.update(" +😊", "insertText", {offset: 4, atNodeEnd: true});
model3.update("+ 😊😊", "insertText", {offset: 6, atNodeEnd: true});
model4.update("+smiley", "insertText", {offset: 7, atNodeEnd: true});
expect(isQuickReaction(model)).toBeFalsy();
expect(isQuickReaction(model2)).toBeFalsy();
expect(isQuickReaction(model3)).toBeFalsy();
expect(isQuickReaction(model4)).toBeFalsy();
});
});
});