2019-08-06 18:03:44 +03:00
|
|
|
/*
|
|
|
|
Copyright 2019 New Vector Ltd
|
|
|
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
import React from 'react';
|
|
|
|
import PropTypes from 'prop-types';
|
2020-05-14 05:41:41 +03:00
|
|
|
import dis from '../../../dispatcher/dispatcher';
|
2019-08-06 18:03:44 +03:00
|
|
|
import EditorModel from '../../../editor/model';
|
2019-09-02 18:56:16 +03:00
|
|
|
import {
|
|
|
|
htmlSerializeIfNeeded,
|
|
|
|
textSerialize,
|
|
|
|
containsEmote,
|
|
|
|
stripEmoteCommand,
|
|
|
|
unescapeMessage,
|
2020-01-21 18:55:21 +03:00
|
|
|
startsWith,
|
|
|
|
stripPrefix,
|
2019-09-02 18:56:16 +03:00
|
|
|
} from '../../../editor/serialize';
|
2019-08-21 16:27:50 +03:00
|
|
|
import {CommandPartCreator} from '../../../editor/parts';
|
2019-08-06 18:03:44 +03:00
|
|
|
import BasicMessageComposer from "./BasicMessageComposer";
|
2019-08-20 12:41:13 +03:00
|
|
|
import ReplyThread from "../elements/ReplyThread";
|
2019-08-20 13:36:19 +03:00
|
|
|
import {parseEvent} from '../../../editor/deserialize';
|
2019-08-20 18:18:46 +03:00
|
|
|
import {findEditableEvent} from '../../../utils/EventUtils';
|
2019-08-21 16:34:49 +03:00
|
|
|
import SendHistoryManager from "../../../SendHistoryManager";
|
2021-02-25 22:39:20 +03:00
|
|
|
import {CommandCategories, getCommand} from '../../../SlashCommands';
|
2019-12-20 04:19:56 +03:00
|
|
|
import * as sdk from '../../../index';
|
2019-08-21 12:26:21 +03:00
|
|
|
import Modal from '../../../Modal';
|
2019-10-31 13:49:24 +03:00
|
|
|
import {_t, _td} from '../../../languageHandler';
|
2019-08-29 17:19:05 +03:00
|
|
|
import ContentMessages from '../../../ContentMessages';
|
2020-08-30 11:17:08 +03:00
|
|
|
import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard";
|
2019-12-17 20:26:12 +03:00
|
|
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
2020-03-19 02:23:36 +03:00
|
|
|
import RateLimitedFunc from '../../../ratelimitedfunc';
|
2020-06-03 04:07:46 +03:00
|
|
|
import {Action} from "../../../dispatcher/actions";
|
2020-12-08 01:12:26 +03:00
|
|
|
import {containsEmoji} from "../../../effects/utils";
|
2020-11-27 16:54:21 +03:00
|
|
|
import {CHAT_EFFECTS} from '../../../effects';
|
2020-08-30 11:17:08 +03:00
|
|
|
import SettingsStore from "../../../settings/SettingsStore";
|
2020-10-29 18:53:14 +03:00
|
|
|
import CountlyAnalytics from "../../../CountlyAnalytics";
|
2020-11-18 01:36:58 +03:00
|
|
|
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
|
|
|
import EMOJI_REGEX from 'emojibase-regex';
|
2021-03-09 06:12:00 +03:00
|
|
|
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
2019-08-20 12:41:13 +03:00
|
|
|
|
|
|
|
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
|
|
|
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
|
|
|
|
Object.assign(content, replyContent);
|
|
|
|
|
|
|
|
// Part of Replies fallback support - prepend the text we're sending
|
|
|
|
// with the text we're replying to
|
|
|
|
const nestedReply = ReplyThread.getNestedReplyText(repliedToEvent, permalinkCreator);
|
|
|
|
if (nestedReply) {
|
|
|
|
if (content.formatted_body) {
|
|
|
|
content.formatted_body = nestedReply.html + content.formatted_body;
|
|
|
|
}
|
|
|
|
content.body = nestedReply.body + content.body;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-22 14:56:27 +03:00
|
|
|
// exported for tests
|
2020-10-06 16:47:53 +03:00
|
|
|
export function createMessageContent(model, permalinkCreator, replyToEvent) {
|
2019-08-21 12:26:21 +03:00
|
|
|
const isEmote = containsEmote(model);
|
|
|
|
if (isEmote) {
|
|
|
|
model = stripEmoteCommand(model);
|
|
|
|
}
|
2020-01-21 18:55:21 +03:00
|
|
|
if (startsWith(model, "//")) {
|
|
|
|
model = stripPrefix(model, "/");
|
|
|
|
}
|
2019-09-02 18:53:14 +03:00
|
|
|
model = unescapeMessage(model);
|
2019-08-06 18:03:44 +03:00
|
|
|
|
|
|
|
const body = textSerialize(model);
|
|
|
|
const content = {
|
2019-08-21 12:26:21 +03:00
|
|
|
msgtype: isEmote ? "m.emote" : "m.text",
|
2019-08-20 12:41:13 +03:00
|
|
|
body: body,
|
2019-08-06 18:03:44 +03:00
|
|
|
};
|
2020-10-06 16:47:53 +03:00
|
|
|
const formattedBody = htmlSerializeIfNeeded(model, {forceHTML: !!replyToEvent});
|
2019-08-06 18:03:44 +03:00
|
|
|
if (formattedBody) {
|
|
|
|
content.format = "org.matrix.custom.html";
|
|
|
|
content.formatted_body = formattedBody;
|
|
|
|
}
|
2019-08-20 12:41:13 +03:00
|
|
|
|
2020-10-06 16:47:53 +03:00
|
|
|
if (replyToEvent) {
|
|
|
|
addReplyToMessageContent(content, replyToEvent, permalinkCreator);
|
2019-08-20 12:41:13 +03:00
|
|
|
}
|
|
|
|
|
2019-08-06 18:03:44 +03:00
|
|
|
return content;
|
|
|
|
}
|
|
|
|
|
2020-11-18 01:36:58 +03:00
|
|
|
// exported for tests
|
|
|
|
export function isQuickReaction(model) {
|
|
|
|
const parts = model.parts;
|
|
|
|
if (parts.length == 0) return false;
|
2020-12-02 23:01:44 +03:00
|
|
|
const text = textSerialize(model);
|
2020-11-18 01:36:58 +03:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2021-03-09 06:12:00 +03:00
|
|
|
@replaceableComponent("views.rooms.SendMessageComposer")
|
2019-08-06 18:03:44 +03:00
|
|
|
export default class SendMessageComposer extends React.Component {
|
|
|
|
static propTypes = {
|
2019-08-07 18:47:24 +03:00
|
|
|
room: PropTypes.object.isRequired,
|
|
|
|
placeholder: PropTypes.string,
|
|
|
|
permalinkCreator: PropTypes.object.isRequired,
|
2020-10-06 16:47:53 +03:00
|
|
|
replyToEvent: PropTypes.object,
|
2021-02-17 15:25:53 +03:00
|
|
|
onChange: PropTypes.func,
|
2021-03-16 07:16:58 +03:00
|
|
|
disabled: PropTypes.bool,
|
2019-08-06 18:03:44 +03:00
|
|
|
};
|
|
|
|
|
2019-12-17 20:26:12 +03:00
|
|
|
static contextType = MatrixClientContext;
|
2019-08-06 18:03:44 +03:00
|
|
|
|
2020-08-29 14:51:37 +03:00
|
|
|
constructor(props, context) {
|
|
|
|
super(props, context);
|
2019-08-06 18:03:44 +03:00
|
|
|
this.model = null;
|
|
|
|
this._editorRef = null;
|
2019-08-20 18:18:46 +03:00
|
|
|
this.currentlyComposedEditorState = null;
|
2020-10-07 02:09:09 +03:00
|
|
|
if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) {
|
2020-03-19 02:23:36 +03:00
|
|
|
this._prepareToEncrypt = new RateLimitedFunc(() => {
|
2020-10-07 02:09:09 +03:00
|
|
|
this.context.prepareToEncrypt(this.props.room);
|
2020-03-19 02:23:36 +03:00
|
|
|
}, 60000);
|
|
|
|
}
|
2020-10-06 16:47:53 +03:00
|
|
|
|
|
|
|
window.addEventListener("beforeunload", this._saveStoredEditorState);
|
2019-08-06 18:03:44 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
_setEditorRef = ref => {
|
|
|
|
this._editorRef = ref;
|
|
|
|
};
|
|
|
|
|
|
|
|
_onKeyDown = (event) => {
|
2019-09-25 11:33:52 +03:00
|
|
|
// ignore any keypress while doing IME compositions
|
2019-09-24 16:36:46 +03:00
|
|
|
if (this._editorRef.isComposing(event)) {
|
2019-09-24 16:32:30 +03:00
|
|
|
return;
|
|
|
|
}
|
2019-08-20 18:18:46 +03:00
|
|
|
const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
2020-08-30 11:17:08 +03:00
|
|
|
const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend');
|
2020-11-20 09:02:52 +03:00
|
|
|
const send = ctrlEnterToSend
|
|
|
|
? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event)
|
2020-08-30 11:17:08 +03:00
|
|
|
: event.key === Key.ENTER && !hasModifier;
|
|
|
|
if (send) {
|
2019-08-06 18:03:44 +03:00
|
|
|
this._sendMessage();
|
|
|
|
event.preventDefault();
|
2019-10-09 21:59:11 +03:00
|
|
|
} else if (event.key === Key.ARROW_UP) {
|
2019-08-20 18:18:46 +03:00
|
|
|
this.onVerticalArrow(event, true);
|
2019-10-09 21:59:11 +03:00
|
|
|
} else if (event.key === Key.ARROW_DOWN) {
|
2019-08-20 18:18:46 +03:00
|
|
|
this.onVerticalArrow(event, false);
|
2020-03-29 22:33:01 +03:00
|
|
|
} else if (event.key === Key.ESCAPE) {
|
|
|
|
dis.dispatch({
|
|
|
|
action: 'reply_to_event',
|
|
|
|
event: null,
|
|
|
|
});
|
2021-01-29 12:31:59 +03:00
|
|
|
} else if (this._prepareToEncrypt) {
|
|
|
|
// This needs to be last!
|
|
|
|
this._prepareToEncrypt();
|
2019-08-20 18:18:46 +03:00
|
|
|
}
|
2020-03-29 22:33:01 +03:00
|
|
|
};
|
2019-08-20 18:18:46 +03:00
|
|
|
|
|
|
|
onVerticalArrow(e, up) {
|
2020-03-19 22:07:33 +03:00
|
|
|
// arrows from an initial-caret composer navigates recent messages to edit
|
|
|
|
// ctrl-alt-arrows navigate send history
|
|
|
|
if (e.shiftKey || e.metaKey) return;
|
2019-08-20 18:18:46 +03:00
|
|
|
|
2020-03-19 22:07:33 +03:00
|
|
|
const shouldSelectHistory = e.altKey && e.ctrlKey;
|
2020-10-06 16:47:53 +03:00
|
|
|
const shouldEditLastMessage = !e.altKey && !e.ctrlKey && up && !this.props.replyToEvent;
|
2019-08-20 18:18:46 +03:00
|
|
|
|
|
|
|
if (shouldSelectHistory) {
|
|
|
|
// Try select composer history
|
|
|
|
const selected = this.selectSendHistory(up);
|
|
|
|
if (selected) {
|
|
|
|
// We're selecting history, so prevent the key event from doing anything else
|
|
|
|
e.preventDefault();
|
|
|
|
}
|
|
|
|
} else if (shouldEditLastMessage) {
|
|
|
|
// selection must be collapsed and caret at start
|
|
|
|
if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) {
|
|
|
|
const editEvent = findEditableEvent(this.props.room, false);
|
|
|
|
if (editEvent) {
|
|
|
|
// We're selecting history, so prevent the key event from doing anything else
|
|
|
|
e.preventDefault();
|
|
|
|
dis.dispatch({
|
|
|
|
action: 'edit_event',
|
|
|
|
event: editEvent,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-08-21 12:26:21 +03:00
|
|
|
// we keep sent messages/commands in a separate history (separate from undo history)
|
|
|
|
// so you can alt+up/down in them
|
2019-08-20 18:18:46 +03:00
|
|
|
selectSendHistory(up) {
|
|
|
|
const delta = up ? -1 : 1;
|
|
|
|
// True if we are not currently selecting history, but composing a message
|
|
|
|
if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) {
|
|
|
|
// We can't go any further - there isn't any more history, so nop.
|
|
|
|
if (!up) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.currentlyComposedEditorState = this.model.serializeParts();
|
|
|
|
} else if (this.sendHistoryManager.currentIndex + delta === this.sendHistoryManager.history.length) {
|
|
|
|
// True when we return to the message being composed currently
|
|
|
|
this.model.reset(this.currentlyComposedEditorState);
|
|
|
|
this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length;
|
|
|
|
return;
|
|
|
|
}
|
2020-10-06 16:47:53 +03:00
|
|
|
const {parts, replyEventId} = this.sendHistoryManager.getItem(delta);
|
|
|
|
dis.dispatch({
|
|
|
|
action: 'reply_to_event',
|
|
|
|
event: replyEventId ? this.props.room.findEventById(replyEventId) : null,
|
|
|
|
});
|
|
|
|
if (parts) {
|
|
|
|
this.model.reset(parts);
|
2019-08-20 18:18:46 +03:00
|
|
|
this._editorRef.focus();
|
2019-08-06 18:03:44 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-08-21 12:26:21 +03:00
|
|
|
_isSlashCommand() {
|
|
|
|
const parts = this.model.parts;
|
2019-09-25 18:30:01 +03:00
|
|
|
const firstPart = parts[0];
|
|
|
|
if (firstPart) {
|
2020-01-21 18:58:51 +03:00
|
|
|
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
2019-09-25 18:30:01 +03:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
// be extra resilient when somehow the AutocompleteWrapperModel or
|
|
|
|
// CommandPartCreator fails to insert a command part, so we don't send
|
|
|
|
// a command as a message
|
2020-01-22 17:24:10 +03:00
|
|
|
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
2020-01-21 18:58:51 +03:00
|
|
|
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
2019-09-25 18:30:01 +03:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
2019-08-21 12:26:21 +03:00
|
|
|
}
|
|
|
|
|
2020-11-18 01:36:58 +03:00
|
|
|
_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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-21 19:57:07 +03:00
|
|
|
_getSlashCommand() {
|
2019-08-21 12:26:21 +03:00
|
|
|
const commandText = this.model.parts.reduce((text, part) => {
|
2019-09-25 18:30:12 +03:00
|
|
|
// use mxid to textify user pills in a command
|
|
|
|
if (part.type === "user-pill") {
|
|
|
|
return text + part.resourceId;
|
|
|
|
}
|
2019-08-21 12:26:21 +03:00
|
|
|
return text + part.text;
|
|
|
|
}, "");
|
2021-02-25 22:39:20 +03:00
|
|
|
const {cmd, args} = getCommand(commandText);
|
|
|
|
return [cmd, args, commandText];
|
2020-01-21 19:57:07 +03:00
|
|
|
}
|
2019-10-31 13:49:24 +03:00
|
|
|
|
2021-02-25 22:39:20 +03:00
|
|
|
async _runSlashCommand(cmd, args) {
|
|
|
|
const result = cmd.run(this.props.room.roomId, args);
|
|
|
|
let messageContent;
|
|
|
|
let error = result.error;
|
|
|
|
if (result.promise) {
|
2020-01-21 19:57:07 +03:00
|
|
|
try {
|
2021-02-25 22:39:20 +03:00
|
|
|
if (cmd.category === CommandCategories.messages) {
|
|
|
|
// The command returns a modified message that we need to pass on
|
|
|
|
messageContent = await result.promise;
|
|
|
|
} else {
|
|
|
|
await result.promise;
|
|
|
|
}
|
2020-01-21 19:57:07 +03:00
|
|
|
} catch (err) {
|
|
|
|
error = err;
|
2019-08-21 12:26:21 +03:00
|
|
|
}
|
2020-01-21 19:57:07 +03:00
|
|
|
}
|
|
|
|
if (error) {
|
|
|
|
console.error("Command failure: %s", error);
|
|
|
|
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
|
|
// assume the error is a server error when the command is async
|
2021-02-25 22:39:20 +03:00
|
|
|
const isServerError = !!result.promise;
|
2020-01-21 19:57:07 +03:00
|
|
|
const title = isServerError ? _td("Server error") : _td("Command error");
|
2019-10-31 13:49:24 +03:00
|
|
|
|
2020-01-21 19:57:07 +03:00
|
|
|
let errText;
|
|
|
|
if (typeof error === 'string') {
|
|
|
|
errText = error;
|
|
|
|
} else if (error.message) {
|
|
|
|
errText = error.message;
|
2019-08-21 12:26:21 +03:00
|
|
|
} else {
|
2020-01-21 19:57:07 +03:00
|
|
|
errText = _t("Server unavailable, overloaded, or something else went wrong.");
|
2019-08-21 12:26:21 +03:00
|
|
|
}
|
2020-01-21 19:57:07 +03:00
|
|
|
|
|
|
|
Modal.createTrackedDialog(title, '', ErrorDialog, {
|
|
|
|
title: _t(title),
|
|
|
|
description: errText,
|
2020-01-21 19:50:04 +03:00
|
|
|
});
|
2020-01-21 19:57:07 +03:00
|
|
|
} else {
|
|
|
|
console.log("Command success.");
|
2021-02-25 22:39:20 +03:00
|
|
|
if (messageContent) return messageContent;
|
2019-08-21 12:26:21 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-21 19:50:04 +03:00
|
|
|
async _sendMessage() {
|
2019-09-02 15:36:31 +03:00
|
|
|
if (this.model.isEmpty) {
|
|
|
|
return;
|
|
|
|
}
|
2020-01-21 19:50:04 +03:00
|
|
|
|
2021-02-25 22:39:20 +03:00
|
|
|
const replyToEvent = this.props.replyToEvent;
|
2020-01-21 19:50:04 +03:00
|
|
|
let shouldSend = true;
|
2021-02-25 22:39:20 +03:00
|
|
|
let content;
|
2020-01-21 19:50:04 +03:00
|
|
|
|
2019-08-21 12:26:21 +03:00
|
|
|
if (!containsEmote(this.model) && this._isSlashCommand()) {
|
2021-02-25 22:39:20 +03:00
|
|
|
const [cmd, args, commandText] = this._getSlashCommand();
|
2020-01-21 19:57:07 +03:00
|
|
|
if (cmd) {
|
2021-02-25 22:39:20 +03:00
|
|
|
if (cmd.category === CommandCategories.messages) {
|
|
|
|
content = await this._runSlashCommand(cmd, args);
|
|
|
|
if (replyToEvent) {
|
|
|
|
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
this._runSlashCommand(cmd, args);
|
|
|
|
shouldSend = false;
|
|
|
|
}
|
2020-01-21 19:57:07 +03:00
|
|
|
} else {
|
2020-01-21 20:54:27 +03:00
|
|
|
// ask the user if their unknown command should be sent as a message
|
2020-01-21 19:57:07 +03:00
|
|
|
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
|
|
|
const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
|
|
|
|
title: _t("Unknown Command"),
|
2020-01-21 20:54:27 +03:00
|
|
|
description: <div>
|
|
|
|
<p>
|
|
|
|
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
|
|
|
|
</p>
|
|
|
|
<p>
|
2020-01-21 21:03:01 +03:00
|
|
|
{ _t("You can use <code>/help</code> to list available commands. " +
|
|
|
|
"Did you mean to send this as a message?", {}, {
|
2020-01-21 20:54:27 +03:00
|
|
|
code: t => <code>{ t }</code>,
|
|
|
|
}) }
|
|
|
|
</p>
|
|
|
|
<p>
|
2020-01-21 20:58:53 +03:00
|
|
|
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
|
2020-01-21 20:54:27 +03:00
|
|
|
code: t => <code>{ t }</code>,
|
|
|
|
}) }
|
|
|
|
</p>
|
|
|
|
</div>,
|
2020-01-21 19:57:07 +03:00
|
|
|
button: _t('Send as message'),
|
|
|
|
});
|
|
|
|
const [sendAnyway] = await finished;
|
|
|
|
// if !sendAnyway bail to let the user edit the composer and try again
|
|
|
|
if (!sendAnyway) return;
|
2020-01-21 19:50:04 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-18 01:36:58 +03:00
|
|
|
if (isQuickReaction(this.model)) {
|
|
|
|
shouldSend = false;
|
|
|
|
this._sendQuickReaction();
|
|
|
|
}
|
|
|
|
|
2020-01-21 19:50:04 +03:00
|
|
|
if (shouldSend) {
|
2020-10-29 18:53:14 +03:00
|
|
|
const startTime = CountlyAnalytics.getTimestamp();
|
2019-08-21 12:26:21 +03:00
|
|
|
const {roomId} = this.props.room;
|
2021-02-25 22:39:20 +03:00
|
|
|
if (!content) {
|
|
|
|
content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent);
|
|
|
|
}
|
2020-11-03 19:06:45 +03:00
|
|
|
// don't bother sending an empty message
|
|
|
|
if (!content.body.trim()) return;
|
|
|
|
|
2020-10-29 18:53:14 +03:00
|
|
|
const prom = this.context.sendMessage(roomId, content);
|
2020-10-06 16:47:53 +03:00
|
|
|
if (replyToEvent) {
|
2019-08-21 12:26:21 +03:00
|
|
|
// Clear reply_to_event as we put the message into the queue
|
|
|
|
// if the send fails, retry will handle resending.
|
|
|
|
dis.dispatch({
|
|
|
|
action: 'reply_to_event',
|
|
|
|
event: null,
|
|
|
|
});
|
|
|
|
}
|
2020-05-24 15:08:29 +03:00
|
|
|
dis.dispatch({action: "message_sent"});
|
2020-11-27 16:54:21 +03:00
|
|
|
CHAT_EFFECTS.forEach((effect) => {
|
2020-10-21 14:37:36 +03:00
|
|
|
if (containsEmoji(content, effect.emojis)) {
|
2020-10-21 14:56:58 +03:00
|
|
|
dis.dispatch({action: `effects.${effect.command}`});
|
|
|
|
}
|
2020-10-21 14:37:36 +03:00
|
|
|
});
|
2020-10-29 18:53:14 +03:00
|
|
|
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
|
2019-08-21 12:26:21 +03:00
|
|
|
}
|
2020-01-21 19:50:04 +03:00
|
|
|
|
2020-10-06 16:47:53 +03:00
|
|
|
this.sendHistoryManager.save(this.model, replyToEvent);
|
2019-08-21 12:26:21 +03:00
|
|
|
// clear composer
|
2019-08-06 18:03:44 +03:00
|
|
|
this.model.reset([]);
|
2019-08-20 18:15:52 +03:00
|
|
|
this._editorRef.clearUndoHistory();
|
2019-08-21 12:26:21 +03:00
|
|
|
this._editorRef.focus();
|
2019-08-21 18:42:09 +03:00
|
|
|
this._clearStoredEditorState();
|
2021-02-26 20:35:45 +03:00
|
|
|
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
|
|
|
|
dis.dispatch({action: "scroll_to_bottom"});
|
|
|
|
}
|
2019-08-06 18:03:44 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
2019-08-07 16:14:16 +03:00
|
|
|
dis.unregister(this.dispatcherRef);
|
2020-10-06 16:47:53 +03:00
|
|
|
window.removeEventListener("beforeunload", this._saveStoredEditorState);
|
|
|
|
this._saveStoredEditorState();
|
2019-08-06 18:03:44 +03:00
|
|
|
}
|
|
|
|
|
2020-03-31 23:12:52 +03:00
|
|
|
// TODO: [REACT-WARNING] Move this to constructor
|
2020-03-31 23:21:12 +03:00
|
|
|
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
2019-12-17 20:26:12 +03:00
|
|
|
const partCreator = new CommandPartCreator(this.props.room, this.context);
|
2019-08-21 18:42:09 +03:00
|
|
|
const parts = this._restoreStoredEditorState(partCreator) || [];
|
|
|
|
this.model = new EditorModel(parts, partCreator);
|
2019-08-07 16:14:16 +03:00
|
|
|
this.dispatcherRef = dis.register(this.onAction);
|
2020-10-08 11:51:31 +03:00
|
|
|
this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_history_');
|
2019-08-06 18:03:44 +03:00
|
|
|
}
|
|
|
|
|
2019-08-21 18:42:09 +03:00
|
|
|
get _editorStateKey() {
|
2020-10-08 11:51:31 +03:00
|
|
|
return `mx_cider_state_${this.props.room.roomId}`;
|
2019-08-21 18:42:09 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
_clearStoredEditorState() {
|
|
|
|
localStorage.removeItem(this._editorStateKey);
|
|
|
|
}
|
|
|
|
|
|
|
|
_restoreStoredEditorState(partCreator) {
|
|
|
|
const json = localStorage.getItem(this._editorStateKey);
|
|
|
|
if (json) {
|
2020-10-08 11:51:31 +03:00
|
|
|
try {
|
|
|
|
const {parts: serializedParts, replyEventId} = JSON.parse(json);
|
|
|
|
const parts = serializedParts.map(p => partCreator.deserializePart(p));
|
|
|
|
if (replyEventId) {
|
|
|
|
dis.dispatch({
|
|
|
|
action: 'reply_to_event',
|
|
|
|
event: this.props.room.findEventById(replyEventId),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return parts;
|
|
|
|
} catch (e) {
|
|
|
|
console.error(e);
|
2020-10-06 16:47:53 +03:00
|
|
|
}
|
2019-08-21 18:42:09 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_saveStoredEditorState = () => {
|
|
|
|
if (this.model.isEmpty) {
|
|
|
|
this._clearStoredEditorState();
|
|
|
|
} else {
|
2020-10-06 16:47:53 +03:00
|
|
|
const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
|
|
|
|
localStorage.setItem(this._editorStateKey, JSON.stringify(item));
|
2019-08-21 18:42:09 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-08-07 16:14:16 +03:00
|
|
|
onAction = (payload) => {
|
|
|
|
switch (payload.action) {
|
2019-08-07 16:14:50 +03:00
|
|
|
case 'reply_to_event':
|
2020-06-03 04:07:46 +03:00
|
|
|
case Action.FocusComposer:
|
2019-08-21 17:40:35 +03:00
|
|
|
this._editorRef && this._editorRef.focus();
|
2019-08-07 16:14:16 +03:00
|
|
|
break;
|
2019-08-20 14:51:06 +03:00
|
|
|
case 'insert_mention':
|
|
|
|
this._insertMention(payload.user_id);
|
2019-08-07 18:44:49 +03:00
|
|
|
break;
|
2019-08-20 14:51:06 +03:00
|
|
|
case 'quote':
|
|
|
|
this._insertQuotedMessage(payload.event);
|
2019-08-20 13:36:19 +03:00
|
|
|
break;
|
2019-12-19 12:25:51 +03:00
|
|
|
case 'insert_emoji':
|
|
|
|
this._insertEmoji(payload.emoji);
|
|
|
|
break;
|
2019-08-07 16:14:16 +03:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-08-20 14:51:06 +03:00
|
|
|
_insertMention(userId) {
|
2019-08-27 17:37:04 +03:00
|
|
|
const {model} = this;
|
|
|
|
const {partCreator} = model;
|
2019-08-20 14:51:06 +03:00
|
|
|
const member = this.props.room.getMember(userId);
|
|
|
|
const displayName = member ?
|
|
|
|
member.rawDisplayName : userId;
|
2019-08-27 17:37:04 +03:00
|
|
|
const caret = this._editorRef.getCaret();
|
|
|
|
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
2020-01-21 14:13:08 +03:00
|
|
|
// index is -1 if there are no parts but we only care for if this would be the part in position 0
|
|
|
|
const insertIndex = position.index > 0 ? position.index : 0;
|
2019-09-02 15:06:30 +03:00
|
|
|
const parts = partCreator.createMentionParts(insertIndex, displayName, userId);
|
2019-08-27 17:37:04 +03:00
|
|
|
model.transform(() => {
|
2019-09-02 15:06:30 +03:00
|
|
|
const addedLen = model.insert(parts, position);
|
2019-08-27 17:37:04 +03:00
|
|
|
return model.positionForOffset(caret.offset + addedLen, true);
|
|
|
|
});
|
2019-08-20 14:51:06 +03:00
|
|
|
// refocus on composer, as we just clicked "Mention"
|
2019-08-21 17:40:35 +03:00
|
|
|
this._editorRef && this._editorRef.focus();
|
2019-08-20 14:51:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
_insertQuotedMessage(event) {
|
2019-08-27 17:37:04 +03:00
|
|
|
const {model} = this;
|
|
|
|
const {partCreator} = model;
|
2019-08-20 14:51:06 +03:00
|
|
|
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
|
|
|
|
// add two newlines
|
|
|
|
quoteParts.push(partCreator.newline());
|
|
|
|
quoteParts.push(partCreator.newline());
|
2019-08-27 17:37:04 +03:00
|
|
|
model.transform(() => {
|
|
|
|
const addedLen = model.insert(quoteParts, model.positionForOffset(0));
|
|
|
|
return model.positionForOffset(addedLen, true);
|
|
|
|
});
|
2019-08-20 14:51:06 +03:00
|
|
|
// refocus on composer, as we just clicked "Quote"
|
2019-08-21 17:40:35 +03:00
|
|
|
this._editorRef && this._editorRef.focus();
|
2019-08-20 14:51:06 +03:00
|
|
|
}
|
|
|
|
|
2019-12-19 12:25:51 +03:00
|
|
|
_insertEmoji = (emoji) => {
|
|
|
|
const {model} = this;
|
|
|
|
const {partCreator} = model;
|
|
|
|
const caret = this._editorRef.getCaret();
|
|
|
|
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
|
|
|
model.transform(() => {
|
|
|
|
const addedLen = model.insert([partCreator.plain(emoji)], position);
|
|
|
|
return model.positionForOffset(caret.offset + addedLen, true);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2019-08-29 17:19:05 +03:00
|
|
|
_onPaste = (event) => {
|
|
|
|
const {clipboardData} = event;
|
2020-06-10 23:48:39 +03:00
|
|
|
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap
|
|
|
|
// in the clipboard as well as the content being copied.
|
|
|
|
if (clipboardData.files.length && !clipboardData.types.some(t => t === "text/plain")) {
|
2019-08-29 17:19:05 +03:00
|
|
|
// This actually not so much for 'files' as such (at time of writing
|
|
|
|
// neither chrome nor firefox let you paste a plain file copied
|
|
|
|
// from Finder) but more images copied from a different website
|
|
|
|
// / word processor etc.
|
|
|
|
ContentMessages.sharedInstance().sendContentListToRoom(
|
2019-12-17 20:26:12 +03:00
|
|
|
Array.from(clipboardData.files), this.props.room.roomId, this.context,
|
2019-08-29 17:19:05 +03:00
|
|
|
);
|
2020-06-01 17:00:55 +03:00
|
|
|
return true; // to skip internal onPaste handler
|
2019-08-29 17:19:05 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-12 17:16:07 +03:00
|
|
|
onChange = () => {
|
2021-02-17 15:32:48 +03:00
|
|
|
if (this.props.onChange) this.props.onChange(this.model);
|
2021-02-12 17:16:07 +03:00
|
|
|
}
|
|
|
|
|
2019-08-06 18:03:44 +03:00
|
|
|
render() {
|
|
|
|
return (
|
|
|
|
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>
|
|
|
|
<BasicMessageComposer
|
2021-02-12 17:16:07 +03:00
|
|
|
onChange={this.onChange}
|
2019-08-06 18:03:44 +03:00
|
|
|
ref={this._setEditorRef}
|
|
|
|
model={this.model}
|
|
|
|
room={this.props.room}
|
2019-08-06 18:53:23 +03:00
|
|
|
label={this.props.placeholder}
|
2019-08-06 18:52:47 +03:00
|
|
|
placeholder={this.props.placeholder}
|
2020-06-01 17:00:55 +03:00
|
|
|
onPaste={this._onPaste}
|
2021-03-16 07:16:58 +03:00
|
|
|
disabled={this.props.disabled}
|
2019-08-06 18:03:44 +03:00
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|