diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 48f2e2a39b..d83e2e964a 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -50,7 +50,8 @@ import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from import { replaceableComponent } from "../../../utils/replaceableComponent"; // matches emoticons which follow the start of a line or whitespace -const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); +const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$'); +export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$'); const IS_MAC = navigator.platform.indexOf("Mac") !== -1; @@ -161,7 +162,7 @@ export default class BasicMessageEditor extends React.Component } } - private replaceEmoticon = (caretPosition: DocumentPosition): number => { + public replaceEmoticon(caretPosition: DocumentPosition, regex: RegExp): number { const { model } = this.props; const range = model.startRange(caretPosition); // expand range max 8 characters backwards from caretPosition, @@ -170,9 +171,9 @@ export default class BasicMessageEditor extends React.Component range.expandBackwardsWhile((index, offset) => { const part = model.parts[index]; n -= 1; - return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate); + return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type); }); - const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text); + const emoticonMatch = regex.exec(range.text); if (emoticonMatch) { const query = emoticonMatch[1].replace("-", ""); // try both exact match and lower-case, this means that xd won't match xD but :P will match :p @@ -180,18 +181,23 @@ export default class BasicMessageEditor extends React.Component if (data) { const { partCreator } = model; - const hasPrecedingSpace = emoticonMatch[0][0] === " "; + const moveStart = emoticonMatch[0][0] === " " ? 1 : 0; + const moveEnd = emoticonMatch[0].length - emoticonMatch.length - moveStart; + // we need the range to only comprise of the emoticon // because we'll replace the whole range with an emoji, // so move the start forward to the start of the emoticon. // Take + 1 because index is reported without the possible preceding space. - range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0)); + range.moveStartForwards(emoticonMatch.index + moveStart); + // and move end backwards so that we don't replace the trailing space/newline + range.moveEndBackwards(moveEnd); + // this returns the amount of added/removed characters during the replace // so the caret position can be adjusted. - return range.replace([partCreator.plain(data.unicode + " ")]); + return range.replace([partCreator.plain(data.unicode)]); } } - }; + } private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => { renderModel(this.editorRef.current, this.props.model); @@ -607,8 +613,7 @@ export default class BasicMessageEditor extends React.Component }; private configureEmoticonAutoReplace = (): void => { - const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); - this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null); + this.props.model.setTransformCallback(this.transform); }; private configureShouldShowPillAvatar = (): void => { @@ -621,6 +626,11 @@ export default class BasicMessageEditor extends React.Component this.setState({ surroundWith }); }; + private transform = (documentPosition: DocumentPosition): void => { + const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); + if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE); + }; + componentWillUnmount() { document.removeEventListener("selectionchange", this.onSelectionChange); this.editorRef.current.removeEventListener("input", this.onInput, true); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index bb5d537895..b2fca33dfe 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -31,8 +31,8 @@ import { textSerialize, unescapeMessage, } from '../../../editor/serialize'; +import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer"; import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts'; -import BasicMessageComposer from "./BasicMessageComposer"; import ReplyThread from "../elements/ReplyThread"; import { findEditableEvent } from '../../../utils/EventUtils'; import SendHistoryManager from "../../../SendHistoryManager"; @@ -347,15 +347,24 @@ export default class SendMessageComposer extends React.Component { } public async sendMessage(): Promise { - if (this.model.isEmpty) { + const model = this.model; + + if (model.isEmpty) { return; } + // Replace emoticon at the end of the message + if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { + const caret = this.editorRef.current?.getCaret(); + const position = model.positionForOffset(caret.offset, caret.atNodeEnd); + this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON); + } + const replyToEvent = this.props.replyToEvent; let shouldSend = true; let content; - if (!containsEmote(this.model) && this.isSlashCommand()) { + if (!containsEmote(model) && this.isSlashCommand()) { const [cmd, args, commandText] = this.getSlashCommand(); if (cmd) { if (cmd.category === CommandCategories.messages) { @@ -400,7 +409,7 @@ export default class SendMessageComposer extends React.Component { } } - if (isQuickReaction(this.model)) { + if (isQuickReaction(model)) { shouldSend = false; this.sendQuickReaction(); } @@ -410,7 +419,7 @@ export default class SendMessageComposer extends React.Component { const { roomId } = this.props.room; if (!content) { content = createMessageContent( - this.model, + model, replyToEvent, this.props.replyInThread, this.props.permalinkCreator, @@ -446,9 +455,9 @@ export default class SendMessageComposer extends React.Component { CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); } - this.sendHistoryManager.save(this.model, replyToEvent); + this.sendHistoryManager.save(model, replyToEvent); // clear composer - this.model.reset([]); + model.reset([]); this.editorRef.current?.clearUndoHistory(); this.editorRef.current?.focus(); this.clearStoredEditorState(); diff --git a/src/editor/range.ts b/src/editor/range.ts index 13776177a7..4336a15130 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -32,13 +32,20 @@ export default class Range { this._end = bIsLarger ? positionB : positionA; } - public moveStart(delta: number): void { + public moveStartForwards(delta: number): void { this._start = this._start.forwardsWhile(this.model, () => { delta -= 1; return delta >= 0; }); } + public moveEndBackwards(delta: number): void { + this._end = this._end.backwardsWhile(this.model, () => { + delta -= 1; + return delta >= 0; + }); + } + public trim(): void { this._start = this._start.forwardsWhile(this.model, whitespacePredicate); this._end = this._end.backwardsWhile(this.model, whitespacePredicate);