From ba8d02a808ec039fc6f9cc29bcd53479564f53c1 Mon Sep 17 00:00:00 2001 From: macekj Date: Tue, 17 Nov 2020 17:36:58 -0500 Subject: [PATCH 1/4] add quick shortcut emoji feature and tests Signed-off-by: macekj --- src/autocomplete/EmojiProvider.tsx | 2 +- .../views/rooms/BasicMessageComposer.tsx | 4 +- .../views/rooms/SendMessageComposer.js | 61 +++++++++++++++++++ src/editor/parts.ts | 4 +- .../views/rooms/SendMessageComposer-test.js | 42 ++++++++++++- 5 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 705474f8d0..d4791d69f1 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -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; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 311a4734fd..43316e90f2 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -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 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" diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 78b1dd85db..4f5243a765 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -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(); diff --git a/src/editor/parts.ts b/src/editor/parts.ts index 5ed0c0529f..8a7ccfcb7b 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -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; } diff --git a/test/components/views/rooms/SendMessageComposer-test.js b/test/components/views/rooms/SendMessageComposer-test.js index 83a9388609..6eeac7ceea 100644 --- a/test/components/views/rooms/SendMessageComposer-test.js +++ b/test/components/views/rooms/SendMessageComposer-test.js @@ -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('', () => { }); }); }); + + 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(); + }); + }); }); From 2ffdfaef68bb2ae1e5d2fbcc7d1a2ee658f4dcb6 Mon Sep 17 00:00:00 2001 From: macekj Date: Tue, 24 Nov 2020 11:42:53 -0500 Subject: [PATCH 2/4] remove unnecessary lookbehind and comment emoticon regex Signed-off-by: macekj --- src/components/views/rooms/BasicMessageComposer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 43316e90f2..1fa2ad681f 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -47,7 +47,8 @@ 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$'); +// matches emoticons which follow the start of a line, whitespace, or a plus at the start of a line +const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s|^\\+)(' + EMOTICON_REGEX.source + ')\\s$'); const IS_MAC = navigator.platform.indexOf("Mac") !== -1; From 200c061968a12c2fb0a5d53e0d7ad6f857ab35d0 Mon Sep 17 00:00:00 2001 From: macekj Date: Fri, 27 Nov 2020 19:41:45 -0500 Subject: [PATCH 3/4] remove unnecessary plus checks in emoji regexes Signed-off-by: macekj --- src/autocomplete/EmojiProvider.tsx | 2 +- src/components/views/rooms/BasicMessageComposer.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index d4791d69f1..705474f8d0 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -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; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 1fa2ad681f..2ececdeaed 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -47,8 +47,8 @@ import AutocompleteWrapperModel from "../../../editor/autocomplete"; import DocumentPosition from "../../../editor/position"; import {ICompletion} from "../../../autocomplete/Autocompleter"; -// matches emoticons which follow the start of a line, whitespace, or a plus at the start of a line -const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s|^\\+)(' + EMOTICON_REGEX.source + ')\\s$'); +// matches emoticons which follow the start of a line or whitespace +const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); const IS_MAC = navigator.platform.indexOf("Mac") !== -1; From 27a853c5861d6d6668369b5ba194981fa3ce0a8d Mon Sep 17 00:00:00 2001 From: macekj Date: Wed, 2 Dec 2020 15:01:44 -0500 Subject: [PATCH 4/4] use textSerialize function to get model text --- src/components/views/rooms/SendMessageComposer.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 4f5243a765..5e0611a953 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -94,8 +94,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) { 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 : ""; + const text = textSerialize(model); // shortcut takes the form "+:emoji:" or "+ :emoji:"" // can be in 1 or 2 parts if (parts.length <= 2) {