781 lines
32 KiB
TypeScript
781 lines
32 KiB
TypeScript
/*
|
|
Copyright 2019 - 2023 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, { createRef, KeyboardEvent, SyntheticEvent } from "react";
|
|
import EMOJI_REGEX from "emojibase-regex";
|
|
import { IContent, MatrixEvent, IEventRelation, IMentions, Room } from "matrix-js-sdk/src/matrix";
|
|
import { DebouncedFunc, throttle } from "lodash";
|
|
import { EventType, MsgType, RelationType } from "matrix-js-sdk/src/@types/event";
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
|
|
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
|
|
|
import dis from "../../../dispatcher/dispatcher";
|
|
import EditorModel from "../../../editor/model";
|
|
import {
|
|
containsEmote,
|
|
htmlSerializeIfNeeded,
|
|
startsWith,
|
|
stripEmoteCommand,
|
|
stripPrefix,
|
|
textSerialize,
|
|
unescapeMessage,
|
|
} from "../../../editor/serialize";
|
|
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
|
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from "../../../editor/parts";
|
|
import { findEditableEvent } from "../../../utils/EventUtils";
|
|
import SendHistoryManager from "../../../SendHistoryManager";
|
|
import { CommandCategories } from "../../../SlashCommands";
|
|
import ContentMessages from "../../../ContentMessages";
|
|
import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
|
import { Action } from "../../../dispatcher/actions";
|
|
import { containsEmoji } from "../../../effects/utils";
|
|
import { CHAT_EFFECTS } from "../../../effects";
|
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
|
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
|
import SettingsStore from "../../../settings/SettingsStore";
|
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
|
import { ActionPayload } from "../../../dispatcher/payloads";
|
|
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
|
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
|
import DocumentPosition from "../../../editor/position";
|
|
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
|
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
|
|
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
|
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
|
import { addReplyToMessageContent } from "../../../utils/Reply";
|
|
import { doMaybeLocalRoomAction } from "../../../utils/local-room";
|
|
import { Caret } from "../../../editor/caret";
|
|
import { IDiff } from "../../../editor/diff";
|
|
import { getBlobSafeMimeType } from "../../../utils/blobs";
|
|
|
|
/**
|
|
* Build the mentions information based on the editor model (and any related events):
|
|
*
|
|
* 1. Search the model parts for room or user pills and fill in the mentions object.
|
|
* 2. If this is a reply to another event, include any user mentions from that
|
|
* (but do not include a room mention).
|
|
*
|
|
* @param sender - The Matrix ID of the user sending the event.
|
|
* @param content - The event content.
|
|
* @param model - The editor model to search for mentions, null if there is no editor.
|
|
* @param replyToEvent - The event being replied to or undefined if it is not a reply.
|
|
* @param editedContent - The content of the parent event being edited.
|
|
*/
|
|
export function attachMentions(
|
|
sender: string,
|
|
content: IContent,
|
|
model: EditorModel | null,
|
|
replyToEvent: MatrixEvent | undefined,
|
|
editedContent: IContent | null = null,
|
|
): void {
|
|
// If this feature is disabled, do nothing.
|
|
if (!SettingsStore.getValue("feature_intentional_mentions")) {
|
|
return;
|
|
}
|
|
|
|
// The mentions property *always* gets included to disable legacy push rules.
|
|
const mentions: IMentions = (content["m.mentions"] = {});
|
|
|
|
const userMentions = new Set<string>();
|
|
let roomMention = false;
|
|
|
|
// If there's a reply, initialize the mentioned users as the sender of that
|
|
// event + any mentioned users in that event.
|
|
if (replyToEvent) {
|
|
userMentions.add(replyToEvent.sender!.userId);
|
|
// TODO What do we do if the reply event *doeesn't* have this property?
|
|
// Try to fish out replies from the contents?
|
|
const userIds = replyToEvent.getContent()["m.mentions"]?.user_ids;
|
|
if (Array.isArray(userIds)) {
|
|
userIds.forEach((userId) => userMentions.add(userId));
|
|
}
|
|
}
|
|
|
|
// If user provided content is available, check to see if any users are mentioned.
|
|
if (model) {
|
|
// Add any mentioned users in the current content.
|
|
for (const part of model.parts) {
|
|
if (part.type === Type.UserPill) {
|
|
userMentions.add(part.resourceId);
|
|
} else if (part.type === Type.AtRoomPill) {
|
|
roomMention = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure the *current* user isn't listed in the mentioned users.
|
|
userMentions.delete(sender);
|
|
|
|
// Finally, if this event is editing a previous event, only include users who
|
|
// were not previously mentioned and a room mention if the previous event was
|
|
// not a room mention.
|
|
if (editedContent) {
|
|
// First, the new event content gets the *full* set of users.
|
|
const newContent = content["m.new_content"];
|
|
const newMentions: IMentions = (newContent["m.mentions"] = {});
|
|
|
|
// Only include the users/room if there is any content.
|
|
if (userMentions.size) {
|
|
newMentions.user_ids = [...userMentions];
|
|
}
|
|
if (roomMention) {
|
|
newMentions.room = true;
|
|
}
|
|
|
|
// Fetch the mentions from the original event and remove any previously
|
|
// mentioned users.
|
|
const prevMentions = editedContent["m.mentions"];
|
|
if (Array.isArray(prevMentions?.user_ids)) {
|
|
prevMentions!.user_ids.forEach((userId) => userMentions.delete(userId));
|
|
}
|
|
|
|
// If the original event mentioned the room, nothing to do here.
|
|
if (prevMentions?.room) {
|
|
roomMention = false;
|
|
}
|
|
}
|
|
|
|
// Only include the users/room if there is any content.
|
|
if (userMentions.size) {
|
|
mentions.user_ids = [...userMentions];
|
|
}
|
|
if (roomMention) {
|
|
mentions.room = true;
|
|
}
|
|
}
|
|
|
|
// Merges favouring the given relation
|
|
export function attachRelation(content: IContent, relation?: IEventRelation): void {
|
|
if (relation) {
|
|
content["m.relates_to"] = {
|
|
...(content["m.relates_to"] || {}),
|
|
...relation,
|
|
};
|
|
}
|
|
}
|
|
|
|
// exported for tests
|
|
export function createMessageContent(
|
|
sender: string,
|
|
model: EditorModel,
|
|
replyToEvent: MatrixEvent | undefined,
|
|
relation: IEventRelation | undefined,
|
|
permalinkCreator?: RoomPermalinkCreator,
|
|
includeReplyLegacyFallback = true,
|
|
): IContent {
|
|
const isEmote = containsEmote(model);
|
|
if (isEmote) {
|
|
model = stripEmoteCommand(model);
|
|
}
|
|
if (startsWith(model, "//")) {
|
|
model = stripPrefix(model, "/");
|
|
}
|
|
model = unescapeMessage(model);
|
|
|
|
const body = textSerialize(model);
|
|
|
|
const content: IContent = {
|
|
msgtype: isEmote ? MsgType.Emote : MsgType.Text,
|
|
body: body,
|
|
};
|
|
const formattedBody = htmlSerializeIfNeeded(model, {
|
|
forceHTML: !!replyToEvent,
|
|
useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
|
|
});
|
|
if (formattedBody) {
|
|
content.format = "org.matrix.custom.html";
|
|
content.formatted_body = formattedBody;
|
|
}
|
|
|
|
// Build the mentions property and add it to the event content.
|
|
attachMentions(sender, content, model, replyToEvent);
|
|
|
|
attachRelation(content, relation);
|
|
if (replyToEvent) {
|
|
addReplyToMessageContent(content, replyToEvent, {
|
|
permalinkCreator,
|
|
includeLegacyFallback: includeReplyLegacyFallback,
|
|
});
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
// exported for tests
|
|
export function isQuickReaction(model: EditorModel): boolean {
|
|
const parts = model.parts;
|
|
if (parts.length == 0) return false;
|
|
const text = textSerialize(model);
|
|
// 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;
|
|
}
|
|
|
|
interface ISendMessageComposerProps extends MatrixClientProps {
|
|
room: Room;
|
|
placeholder?: string;
|
|
permalinkCreator?: RoomPermalinkCreator;
|
|
relation?: IEventRelation;
|
|
replyToEvent?: MatrixEvent;
|
|
disabled?: boolean;
|
|
onChange?(model: EditorModel): void;
|
|
includeReplyLegacyFallback?: boolean;
|
|
toggleStickerPickerOpen: () => void;
|
|
}
|
|
|
|
export class SendMessageComposer extends React.Component<ISendMessageComposerProps> {
|
|
public static contextType = RoomContext;
|
|
public context!: React.ContextType<typeof RoomContext>;
|
|
|
|
private readonly prepareToEncrypt?: DebouncedFunc<() => void>;
|
|
private readonly editorRef = createRef<BasicMessageComposer>();
|
|
private model: EditorModel;
|
|
private currentlyComposedEditorState: SerializedPart[] | null = null;
|
|
private dispatcherRef: string;
|
|
private sendHistoryManager: SendHistoryManager;
|
|
|
|
public static defaultProps = {
|
|
includeReplyLegacyFallback: true,
|
|
};
|
|
|
|
public constructor(props: ISendMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
|
|
super(props, context);
|
|
this.context = context; // otherwise React will only set it prior to render due to type def above
|
|
|
|
if (this.props.mxClient.isCryptoEnabled() && this.props.mxClient.isRoomEncrypted(this.props.room.roomId)) {
|
|
this.prepareToEncrypt = throttle(
|
|
() => {
|
|
this.props.mxClient.prepareToEncrypt(this.props.room);
|
|
},
|
|
60000,
|
|
{ leading: true, trailing: false },
|
|
);
|
|
}
|
|
|
|
window.addEventListener("beforeunload", this.saveStoredEditorState);
|
|
|
|
const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient);
|
|
const parts = this.restoreStoredEditorState(partCreator) || [];
|
|
this.model = new EditorModel(parts, partCreator);
|
|
this.dispatcherRef = dis.register(this.onAction);
|
|
this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, "mx_cider_history_");
|
|
}
|
|
|
|
public componentDidUpdate(prevProps: ISendMessageComposerProps): void {
|
|
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
|
|
const differentEventTarget = this.props.relation?.event_id !== prevProps.relation?.event_id;
|
|
|
|
const threadChanged = replyingToThread && differentEventTarget;
|
|
if (threadChanged) {
|
|
const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient);
|
|
const parts = this.restoreStoredEditorState(partCreator) || [];
|
|
this.model.reset(parts);
|
|
this.editorRef.current?.focus();
|
|
}
|
|
}
|
|
|
|
private onKeyDown = (event: KeyboardEvent): void => {
|
|
// ignore any keypress while doing IME compositions
|
|
if (this.editorRef.current?.isComposing(event)) {
|
|
return;
|
|
}
|
|
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
|
|
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
|
switch (action) {
|
|
case KeyBindingAction.SendMessage:
|
|
this.sendMessage();
|
|
event.preventDefault();
|
|
break;
|
|
case KeyBindingAction.SelectPrevSendHistory:
|
|
case KeyBindingAction.SelectNextSendHistory: {
|
|
// Try select composer history
|
|
const selected = this.selectSendHistory(action === KeyBindingAction.SelectPrevSendHistory);
|
|
if (selected) {
|
|
// We're selecting history, so prevent the key event from doing anything else
|
|
event.preventDefault();
|
|
}
|
|
break;
|
|
}
|
|
case KeyBindingAction.ShowStickerPicker: {
|
|
if (!SettingsStore.getValue("MessageComposerInput.showStickersButton")) {
|
|
return; // Do nothing if there is no Stickers button
|
|
}
|
|
this.props.toggleStickerPickerOpen();
|
|
event.preventDefault();
|
|
break;
|
|
}
|
|
case KeyBindingAction.EditPrevMessage:
|
|
// selection must be collapsed and caret at start
|
|
if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) {
|
|
const events = this.context.liveTimeline
|
|
?.getEvents()
|
|
.concat(replyingToThread ? [] : this.props.room.getPendingEvents());
|
|
const editEvent = events
|
|
? findEditableEvent({
|
|
events,
|
|
isForward: false,
|
|
matrixClient: MatrixClientPeg.safeGet(),
|
|
})
|
|
: undefined;
|
|
if (editEvent) {
|
|
// We're selecting history, so prevent the key event from doing anything else
|
|
event.preventDefault();
|
|
dis.dispatch({
|
|
action: Action.EditEvent,
|
|
event: editEvent,
|
|
timelineRenderingType: this.context.timelineRenderingType,
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
case KeyBindingAction.CancelReplyOrEdit:
|
|
if (!!this.context.replyToEvent) {
|
|
dis.dispatch({
|
|
action: "reply_to_event",
|
|
event: null,
|
|
context: this.context.timelineRenderingType,
|
|
});
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
// we keep sent messages/commands in a separate history (separate from undo history)
|
|
// so you can alt+up/down in them
|
|
private selectSendHistory(up: boolean): boolean {
|
|
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 false;
|
|
}
|
|
this.currentlyComposedEditorState = this.model.serializeParts();
|
|
} else if (
|
|
this.currentlyComposedEditorState &&
|
|
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 true;
|
|
}
|
|
const { parts, replyEventId } = this.sendHistoryManager.getItem(delta);
|
|
dis.dispatch({
|
|
action: "reply_to_event",
|
|
event: replyEventId ? this.props.room.findEventById(replyEventId) : null,
|
|
context: this.context.timelineRenderingType,
|
|
});
|
|
if (parts) {
|
|
this.model.reset(parts);
|
|
this.editorRef.current?.focus();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private sendQuickReaction(): void {
|
|
const timeline = this.context.liveTimeline;
|
|
if (!timeline) return;
|
|
const events = timeline.getEvents();
|
|
const reaction = this.model.parts[1].text;
|
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
if (events[i].getType() === EventType.RoomMessage) {
|
|
let shouldReact = true;
|
|
const lastMessage = events[i];
|
|
const userId = MatrixClientPeg.safeGet().getSafeUserId();
|
|
const messageReactions = this.props.room.relations.getChildEventsForEvent(
|
|
lastMessage.getId()!,
|
|
RelationType.Annotation,
|
|
EventType.Reaction,
|
|
);
|
|
|
|
// if we have already sent this reaction, don't redact but don't re-send
|
|
if (messageReactions) {
|
|
const myReactionEvents =
|
|
messageReactions.getAnnotationsBySender()?.[userId] || new Set<MatrixEvent>();
|
|
const myReactionKeys = [...myReactionEvents]
|
|
.filter((event) => !event.isRedacted())
|
|
.map((event) => event.getRelation()?.key);
|
|
shouldReact = !myReactionKeys.includes(reaction);
|
|
}
|
|
if (shouldReact) {
|
|
MatrixClientPeg.safeGet().sendEvent(lastMessage.getRoomId()!, EventType.Reaction, {
|
|
"m.relates_to": {
|
|
rel_type: RelationType.Annotation,
|
|
event_id: lastMessage.getId(),
|
|
key: reaction,
|
|
},
|
|
});
|
|
dis.dispatch({ action: "message_sent" });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public async sendMessage(): Promise<void> {
|
|
const model = this.model;
|
|
|
|
if (model.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
const posthogEvent: ComposerEvent = {
|
|
eventName: "Composer",
|
|
isEditing: false,
|
|
isLocation: false,
|
|
isReply: !!this.props.replyToEvent,
|
|
inThread: this.props.relation?.rel_type === THREAD_RELATION_TYPE.name,
|
|
};
|
|
if (posthogEvent.inThread && this.props.relation!.event_id) {
|
|
const threadRoot = this.props.room.findEventById(this.props.relation!.event_id);
|
|
posthogEvent.startsThread = threadRoot?.getThread()?.events.length === 1;
|
|
}
|
|
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
|
|
|
|
// Replace emoticon at the end of the message
|
|
if (SettingsStore.getValue("MessageComposerInput.autoReplaceEmoji")) {
|
|
const indexOfLastPart = model.parts.length - 1;
|
|
const positionInLastPart = model.parts[indexOfLastPart].text.length;
|
|
this.editorRef.current?.replaceEmoticon(
|
|
new DocumentPosition(indexOfLastPart, positionInLastPart),
|
|
REGEX_EMOTICON,
|
|
);
|
|
}
|
|
|
|
const replyToEvent = this.props.replyToEvent;
|
|
let shouldSend = true;
|
|
let content: IContent | null = null;
|
|
|
|
if (!containsEmote(model) && isSlashCommand(this.model)) {
|
|
const [cmd, args, commandText] = getSlashCommand(this.model);
|
|
if (cmd) {
|
|
const threadId =
|
|
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation?.event_id : null;
|
|
|
|
let commandSuccessful: boolean;
|
|
[content, commandSuccessful] = await runSlashCommand(
|
|
MatrixClientPeg.safeGet(),
|
|
cmd,
|
|
args,
|
|
this.props.room.roomId,
|
|
threadId ?? null,
|
|
);
|
|
if (!commandSuccessful) {
|
|
return; // errored
|
|
}
|
|
|
|
if (content && [CommandCategories.messages, CommandCategories.effects].includes(cmd.category)) {
|
|
// Attach any mentions which might be contained in the command content.
|
|
attachMentions(this.props.mxClient.getSafeUserId(), content, model, replyToEvent);
|
|
attachRelation(content, this.props.relation);
|
|
if (replyToEvent) {
|
|
addReplyToMessageContent(content, replyToEvent, {
|
|
permalinkCreator: this.props.permalinkCreator,
|
|
// Exclude the legacy fallback for custom event types such as those used by /fireworks
|
|
includeLegacyFallback: content.msgtype?.startsWith("m.") ?? true,
|
|
});
|
|
}
|
|
} else {
|
|
shouldSend = false;
|
|
}
|
|
} else {
|
|
const sendAnyway = await shouldSendAnyway(commandText);
|
|
// re-focus the composer after QuestionDialog is closed
|
|
dis.dispatch({
|
|
action: Action.FocusAComposer,
|
|
context: this.context.timelineRenderingType,
|
|
});
|
|
// if !sendAnyway bail to let the user edit the composer and try again
|
|
if (!sendAnyway) return;
|
|
}
|
|
}
|
|
|
|
if (isQuickReaction(model)) {
|
|
shouldSend = false;
|
|
this.sendQuickReaction();
|
|
}
|
|
|
|
if (shouldSend) {
|
|
const { roomId } = this.props.room;
|
|
if (!content) {
|
|
content = createMessageContent(
|
|
this.props.mxClient.getSafeUserId(),
|
|
model,
|
|
replyToEvent,
|
|
this.props.relation,
|
|
this.props.permalinkCreator,
|
|
this.props.includeReplyLegacyFallback,
|
|
);
|
|
}
|
|
// don't bother sending an empty message
|
|
if (!content.body.trim()) return;
|
|
|
|
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
|
decorateStartSendingTime(content);
|
|
}
|
|
|
|
const threadId =
|
|
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null;
|
|
|
|
const prom = doMaybeLocalRoomAction(
|
|
roomId,
|
|
(actualRoomId: string) => this.props.mxClient.sendMessage(actualRoomId, threadId ?? null, content!),
|
|
this.props.mxClient,
|
|
);
|
|
if (replyToEvent) {
|
|
// 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,
|
|
context: this.context.timelineRenderingType,
|
|
});
|
|
}
|
|
dis.dispatch({ action: "message_sent" });
|
|
CHAT_EFFECTS.forEach((effect) => {
|
|
if (containsEmoji(content!, effect.emojis)) {
|
|
// For initial threads launch, chat effects are disabled
|
|
// see #19731
|
|
const isNotThread = this.props.relation?.rel_type !== THREAD_RELATION_TYPE.name;
|
|
if (isNotThread) {
|
|
dis.dispatch({ action: `effects.${effect.command}` });
|
|
}
|
|
}
|
|
});
|
|
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
|
prom.then((resp) => {
|
|
sendRoundTripMetric(this.props.mxClient, roomId, resp.event_id);
|
|
});
|
|
}
|
|
}
|
|
|
|
this.sendHistoryManager.save(model, replyToEvent);
|
|
// clear composer
|
|
model.reset([]);
|
|
this.editorRef.current?.clearUndoHistory();
|
|
this.editorRef.current?.focus();
|
|
this.clearStoredEditorState();
|
|
if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
|
|
dis.dispatch({
|
|
action: "scroll_to_bottom",
|
|
timelineRenderingType: this.context.timelineRenderingType,
|
|
});
|
|
}
|
|
}
|
|
|
|
public componentWillUnmount(): void {
|
|
dis.unregister(this.dispatcherRef);
|
|
window.removeEventListener("beforeunload", this.saveStoredEditorState);
|
|
this.saveStoredEditorState();
|
|
}
|
|
|
|
private get editorStateKey(): string {
|
|
let key = `mx_cider_state_${this.props.room.roomId}`;
|
|
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) {
|
|
key += `_${this.props.relation.event_id}`;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
private clearStoredEditorState(): void {
|
|
localStorage.removeItem(this.editorStateKey);
|
|
}
|
|
|
|
private restoreStoredEditorState(partCreator: PartCreator): Part[] | null {
|
|
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
|
|
if (replyingToThread) {
|
|
return null;
|
|
}
|
|
|
|
const json = localStorage.getItem(this.editorStateKey);
|
|
if (json) {
|
|
try {
|
|
const { parts: serializedParts, replyEventId } = JSON.parse(json);
|
|
const parts: Part[] = serializedParts.map((p: SerializedPart) => partCreator.deserializePart(p));
|
|
if (replyEventId) {
|
|
dis.dispatch({
|
|
action: "reply_to_event",
|
|
event: this.props.room.findEventById(replyEventId),
|
|
context: this.context.timelineRenderingType,
|
|
});
|
|
}
|
|
return parts;
|
|
} catch (e) {
|
|
logger.error(e);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// should save state when editor has contents or reply is open
|
|
private shouldSaveStoredEditorState = (): boolean => {
|
|
return !this.model.isEmpty || !!this.props.replyToEvent;
|
|
};
|
|
|
|
private saveStoredEditorState = (): void => {
|
|
if (this.shouldSaveStoredEditorState()) {
|
|
const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
|
|
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
|
|
} else {
|
|
this.clearStoredEditorState();
|
|
}
|
|
};
|
|
|
|
private onAction = (payload: ActionPayload): void => {
|
|
// don't let the user into the composer if it is disabled - all of these branches lead
|
|
// to the cursor being in the composer
|
|
if (this.props.disabled) return;
|
|
|
|
switch (payload.action) {
|
|
case "reply_to_event":
|
|
case Action.FocusSendMessageComposer:
|
|
if ((payload.context ?? TimelineRenderingType.Room) === this.context.timelineRenderingType) {
|
|
this.editorRef.current?.focus();
|
|
}
|
|
break;
|
|
case Action.ComposerInsert:
|
|
if (payload.timelineRenderingType !== this.context.timelineRenderingType) break;
|
|
if (payload.composerType !== ComposerType.Send) break;
|
|
|
|
if (payload.userId) {
|
|
this.editorRef.current?.insertMention(payload.userId);
|
|
} else if (payload.event) {
|
|
this.editorRef.current?.insertQuotedMessage(payload.event);
|
|
} else if (payload.text) {
|
|
this.editorRef.current?.insertPlaintext(payload.text);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
private onPaste = (event: Event | SyntheticEvent, data: DataTransfer): boolean => {
|
|
// Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap
|
|
// in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
|
|
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
|
|
// it puts the filename in as text/plain which we want to ignore.
|
|
if (data.files.length && !data.types.includes("text/rtf")) {
|
|
ContentMessages.sharedInstance().sendContentListToRoom(
|
|
Array.from(data.files),
|
|
this.props.room.roomId,
|
|
this.props.relation,
|
|
this.props.mxClient,
|
|
this.context.timelineRenderingType,
|
|
);
|
|
return true; // to skip internal onPaste handler
|
|
}
|
|
|
|
// Safari `Insert from iPhone or iPad`
|
|
// data.getData("text/html") returns a string like: <img src="blob:https://...">
|
|
if (data.types.includes("text/html")) {
|
|
const imgElementStr = data.getData("text/html");
|
|
const parser = new DOMParser();
|
|
const imgDoc = parser.parseFromString(imgElementStr, "text/html");
|
|
|
|
if (
|
|
imgDoc.getElementsByTagName("img").length !== 1 ||
|
|
!imgDoc.querySelector("img")?.src.startsWith("blob:") ||
|
|
imgDoc.childNodes.length !== 1
|
|
) {
|
|
console.log("Failed to handle pasted content as Safari inserted content");
|
|
|
|
// Fallback to internal onPaste handler
|
|
return false;
|
|
}
|
|
const imgSrc = imgDoc!.querySelector("img")!.src;
|
|
|
|
fetch(imgSrc).then(
|
|
(response) => {
|
|
response.blob().then(
|
|
(imgBlob) => {
|
|
const type = imgBlob.type;
|
|
const safetype = getBlobSafeMimeType(type);
|
|
const ext = type.split("/")[1];
|
|
const parts = response.url.split("/");
|
|
const filename = parts[parts.length - 1];
|
|
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
|
ContentMessages.sharedInstance().sendContentToRoom(
|
|
file,
|
|
this.props.room.roomId,
|
|
this.props.relation,
|
|
this.props.mxClient,
|
|
this.context.replyToEvent,
|
|
);
|
|
},
|
|
(error) => {
|
|
console.log(error);
|
|
},
|
|
);
|
|
},
|
|
(error) => {
|
|
console.log(error);
|
|
},
|
|
);
|
|
|
|
// Skip internal onPaste handler
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
private onChange = (selection?: Caret, inputType?: string, diff?: IDiff): void => {
|
|
// We call this in here rather than onKeyDown as that would trip it on global shortcuts e.g. Ctrl-k also
|
|
if (!!diff) {
|
|
this.prepareToEncrypt?.();
|
|
}
|
|
|
|
this.props.onChange?.(this.model);
|
|
};
|
|
|
|
private focusComposer = (): void => {
|
|
this.editorRef.current?.focus();
|
|
};
|
|
|
|
public render(): React.ReactNode {
|
|
const threadId =
|
|
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : undefined;
|
|
return (
|
|
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this.onKeyDown}>
|
|
<BasicMessageComposer
|
|
onChange={this.onChange}
|
|
ref={this.editorRef}
|
|
model={this.model}
|
|
room={this.props.room}
|
|
threadId={threadId}
|
|
label={this.props.placeholder}
|
|
placeholder={this.props.placeholder}
|
|
onPaste={this.onPaste}
|
|
disabled={this.props.disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
|
|
export default SendMessageComposerWithMatrixClient;
|