mirror of
https://github.com/element-hq/element-web
synced 2024-11-29 04:48:50 +03:00
Wire up drag-drop file uploads for the thread view (#7860)
This commit is contained in:
parent
42e9ea4540
commit
8fccef86d8
30 changed files with 616 additions and 482 deletions
|
@ -10,6 +10,7 @@
|
||||||
@import "./structures/_ContextualMenu.scss";
|
@import "./structures/_ContextualMenu.scss";
|
||||||
@import "./structures/_CreateRoom.scss";
|
@import "./structures/_CreateRoom.scss";
|
||||||
@import "./structures/_CustomRoomTagPanel.scss";
|
@import "./structures/_CustomRoomTagPanel.scss";
|
||||||
|
@import "./structures/_FileDropTarget.scss";
|
||||||
@import "./structures/_FilePanel.scss";
|
@import "./structures/_FilePanel.scss";
|
||||||
@import "./structures/_GenericErrorPage.scss";
|
@import "./structures/_GenericErrorPage.scss";
|
||||||
@import "./structures/_GroupFilterPanel.scss";
|
@import "./structures/_GroupFilterPanel.scss";
|
||||||
|
@ -141,11 +142,11 @@
|
||||||
@import "./views/elements/_Dropdown.scss";
|
@import "./views/elements/_Dropdown.scss";
|
||||||
@import "./views/elements/_EditableItemList.scss";
|
@import "./views/elements/_EditableItemList.scss";
|
||||||
@import "./views/elements/_ErrorBoundary.scss";
|
@import "./views/elements/_ErrorBoundary.scss";
|
||||||
@import "./views/elements/_GenericEventListSummary.scss";
|
|
||||||
@import "./views/elements/_EventTilePreview.scss";
|
@import "./views/elements/_EventTilePreview.scss";
|
||||||
@import "./views/elements/_ExternalLink.scss";
|
@import "./views/elements/_ExternalLink.scss";
|
||||||
@import "./views/elements/_FacePile.scss";
|
@import "./views/elements/_FacePile.scss";
|
||||||
@import "./views/elements/_Field.scss";
|
@import "./views/elements/_Field.scss";
|
||||||
|
@import "./views/elements/_GenericEventListSummary.scss";
|
||||||
@import "./views/elements/_ImageView.scss";
|
@import "./views/elements/_ImageView.scss";
|
||||||
@import "./views/elements/_InfoTooltip.scss";
|
@import "./views/elements/_InfoTooltip.scss";
|
||||||
@import "./views/elements/_InlineSpinner.scss";
|
@import "./views/elements/_InlineSpinner.scss";
|
||||||
|
@ -183,9 +184,9 @@
|
||||||
@import "./views/messages/_CallEvent.scss";
|
@import "./views/messages/_CallEvent.scss";
|
||||||
@import "./views/messages/_CreateEvent.scss";
|
@import "./views/messages/_CreateEvent.scss";
|
||||||
@import "./views/messages/_DateSeparator.scss";
|
@import "./views/messages/_DateSeparator.scss";
|
||||||
@import "./views/messages/_JumpToDatePicker.scss";
|
|
||||||
@import "./views/messages/_EventTileBubble.scss";
|
@import "./views/messages/_EventTileBubble.scss";
|
||||||
@import "./views/messages/_HiddenBody.scss";
|
@import "./views/messages/_HiddenBody.scss";
|
||||||
|
@import "./views/messages/_JumpToDatePicker.scss";
|
||||||
@import "./views/messages/_MEmoteBody.scss";
|
@import "./views/messages/_MEmoteBody.scss";
|
||||||
@import "./views/messages/_MFileBody.scss";
|
@import "./views/messages/_MFileBody.scss";
|
||||||
@import "./views/messages/_MImageBody.scss";
|
@import "./views/messages/_MImageBody.scss";
|
||||||
|
|
65
res/css/structures/_FileDropTarget.scss
Normal file
65
res/css/structures/_FileDropTarget.scss
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@keyframes mx_FileDropTarget_animation {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_FileDropTarget {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
font-size: $font-18px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
background-color: $background;
|
||||||
|
opacity: 0.95;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3000;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
animation: mx_FileDropTarget_animation;
|
||||||
|
animation-duration: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mx_FileDropTarget_image_animation {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_FileDropTarget_image {
|
||||||
|
width: 32px;
|
||||||
|
animation: mx_FileDropTarget_image_animation;
|
||||||
|
animation-duration: 0.5s;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
|
@ -32,56 +32,6 @@ limitations under the License.
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes mx_RoomView_fileDropTarget_animation {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0.95;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomView_fileDropTarget {
|
|
||||||
min-width: 0px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
font-size: $font-18px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
background-color: $background;
|
|
||||||
opacity: 0.95;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
z-index: 3000;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
animation: mx_RoomView_fileDropTarget_animation;
|
|
||||||
animation-duration: 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes mx_RoomView_fileDropTarget_image_animation {
|
|
||||||
from {
|
|
||||||
transform: scaleX(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomView_fileDropTarget_image {
|
|
||||||
width: 32px;
|
|
||||||
animation: mx_RoomView_fileDropTarget_image_animation;
|
|
||||||
animation-duration: 0.5s;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomView_auxPanel {
|
.mx_RoomView_auxPanel {
|
||||||
min-width: 0px;
|
min-width: 0px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -106,11 +106,18 @@ limitations under the License.
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mx_ThreadView .mx_AutoHideScrollbar {
|
&.mx_ThreadView .mx_ThreadView_timelinePanelWrapper {
|
||||||
/* the scrollbar is 8px wide, and we want a 12px gap with the side of the
|
/* the scrollbar is 8px wide, and we want a 12px gap with the side of the
|
||||||
panel. Hence the magic number, 8+4=12 */
|
panel. Hence the magic number, 8+4=12 */
|
||||||
width: calc(100% - 4px);
|
width: calc(100% - 4px);
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0; // don't displace the composer
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
.mx_FileDropTarget {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_RoomView_MessageList {
|
.mx_RoomView_MessageList {
|
||||||
|
|
59
src/@types/groups.ts
Normal file
59
src/@types/groups.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CreateEventField = "io.element.migrated_from_community";
|
||||||
|
|
||||||
|
export interface IGroupRoom {
|
||||||
|
displayname: string;
|
||||||
|
name?: string;
|
||||||
|
roomId: string;
|
||||||
|
canonicalAlias?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
topic?: string;
|
||||||
|
numJoinedMembers?: number;
|
||||||
|
worldReadable?: boolean;
|
||||||
|
guestCanJoin?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
export interface IGroupSummary {
|
||||||
|
profile: {
|
||||||
|
avatar_url?: string;
|
||||||
|
is_openly_joinable?: boolean;
|
||||||
|
is_public?: boolean;
|
||||||
|
long_description: string;
|
||||||
|
name: string;
|
||||||
|
short_description: string;
|
||||||
|
};
|
||||||
|
rooms_section: {
|
||||||
|
rooms: unknown[];
|
||||||
|
categories: Record<string, unknown>;
|
||||||
|
total_room_count_estimate: number;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
is_privileged: boolean;
|
||||||
|
is_public: boolean;
|
||||||
|
is_publicised: boolean;
|
||||||
|
membership: string;
|
||||||
|
};
|
||||||
|
users_section: {
|
||||||
|
users: unknown[];
|
||||||
|
roles: Record<string, unknown>;
|
||||||
|
total_user_count_estimate: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/* eslint-enable camelcase */
|
|
@ -447,7 +447,7 @@ export default class ContentMessages {
|
||||||
public async sendContentListToRoom(
|
public async sendContentListToRoom(
|
||||||
files: File[],
|
files: File[],
|
||||||
roomId: string,
|
roomId: string,
|
||||||
relation: IEventRelation | null,
|
relation: IEventRelation | undefined,
|
||||||
matrixClient: MatrixClient,
|
matrixClient: MatrixClient,
|
||||||
context = TimelineRenderingType.Room,
|
context = TimelineRenderingType.Room,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
@ -566,7 +566,7 @@ export default class ContentMessages {
|
||||||
private sendContentToRoom(
|
private sendContentToRoom(
|
||||||
file: File,
|
file: File,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
relation: IEventRelation,
|
relation: IEventRelation | undefined,
|
||||||
matrixClient: MatrixClient,
|
matrixClient: MatrixClient,
|
||||||
promBefore: Promise<any>,
|
promBefore: Promise<any>,
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -32,9 +32,9 @@ import { IExtendedSanitizeOptions } from './@types/sanitize-html';
|
||||||
import SettingsStore from './settings/SettingsStore';
|
import SettingsStore from './settings/SettingsStore';
|
||||||
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
|
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
|
||||||
import { getEmojiFromUnicode } from "./emoji";
|
import { getEmojiFromUnicode } from "./emoji";
|
||||||
import ReplyChain from "./components/views/elements/ReplyChain";
|
|
||||||
import { mediaFromMxc } from "./customisations/Media";
|
import { mediaFromMxc } from "./customisations/Media";
|
||||||
import { ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from './linkify-matrix';
|
import { ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from './linkify-matrix';
|
||||||
|
import { stripHTMLReply, stripPlainReply } from './utils/Reply';
|
||||||
|
|
||||||
// Anything outside the basic multilingual plane will be a surrogate pair
|
// Anything outside the basic multilingual plane will be a surrogate pair
|
||||||
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
|
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
|
||||||
|
@ -501,8 +501,8 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
|
||||||
let formattedBody = typeof content.formatted_body === 'string' ? content.formatted_body : null;
|
let formattedBody = typeof content.formatted_body === 'string' ? content.formatted_body : null;
|
||||||
const plainBody = typeof content.body === 'string' ? content.body : "";
|
const plainBody = typeof content.body === 'string' ? content.body : "";
|
||||||
|
|
||||||
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyChain.stripHTMLReply(formattedBody);
|
if (opts.stripReplyFallback && formattedBody) formattedBody = stripHTMLReply(formattedBody);
|
||||||
strippedBody = opts.stripReplyFallback ? ReplyChain.stripPlainReply(plainBody) : plainBody;
|
strippedBody = opts.stripReplyFallback ? stripPlainReply(plainBody) : plainBody;
|
||||||
|
|
||||||
bodyHasEmoji = mightContainEmoji(isFormattedBody ? formattedBody : plainBody);
|
bodyHasEmoji = mightContainEmoji(isFormattedBody ? formattedBody : plainBody);
|
||||||
|
|
||||||
|
|
120
src/components/structures/FileDropTarget.tsx
Normal file
120
src/components/structures/FileDropTarget.tsx
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 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, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { _t } from "../../languageHandler";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
parent: HTMLElement;
|
||||||
|
onFileDrop(dataTransfer: DataTransfer): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
dragging: boolean;
|
||||||
|
counter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
|
||||||
|
const [state, setState] = useState<IState>({
|
||||||
|
dragging: false,
|
||||||
|
counter: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!parent || parent.ondrop) return;
|
||||||
|
|
||||||
|
const onDragEnter = (ev: DragEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
setState(state => ({
|
||||||
|
// We always increment the counter no matter the types, because dragging is
|
||||||
|
// still happening. If we didn't, the drag counter would get out of sync.
|
||||||
|
counter: state.counter + 1,
|
||||||
|
// See:
|
||||||
|
// https://docs.w3cub.com/dom/datatransfer/types
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
|
||||||
|
dragging: (
|
||||||
|
ev.dataTransfer.types.includes("Files") ||
|
||||||
|
ev.dataTransfer.types.includes("application/x-moz-file")
|
||||||
|
) ? true : state.dragging,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = (ev: DragEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
setState(state => ({
|
||||||
|
counter: state.counter - 1,
|
||||||
|
dragging: state.counter <= 1 ? false : state.dragging,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (ev: DragEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
ev.dataTransfer.dropEffect = "none";
|
||||||
|
|
||||||
|
// See:
|
||||||
|
// https://docs.w3cub.com/dom/datatransfer/types
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
|
||||||
|
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
|
||||||
|
ev.dataTransfer.dropEffect = "copy";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (ev: DragEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
onFileDrop(ev.dataTransfer);
|
||||||
|
|
||||||
|
setState(state => ({
|
||||||
|
dragging: false,
|
||||||
|
counter: state.counter - 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
parent.addEventListener("drop", onDrop);
|
||||||
|
parent.addEventListener("dragover", onDragOver);
|
||||||
|
parent.addEventListener("dragenter", onDragEnter);
|
||||||
|
parent.addEventListener("dragleave", onDragLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// disconnect the D&D event listeners from the room view. This
|
||||||
|
// is really just for hygiene - we're going to be
|
||||||
|
// deleted anyway, so it doesn't matter if the event listeners
|
||||||
|
// don't get cleaned up.
|
||||||
|
parent.removeEventListener("drop", onDrop);
|
||||||
|
parent.removeEventListener("dragover", onDragOver);
|
||||||
|
parent.removeEventListener("dragenter", onDragEnter);
|
||||||
|
parent.removeEventListener("dragleave", onDragLeave);
|
||||||
|
};
|
||||||
|
}, [parent, onFileDrop]);
|
||||||
|
|
||||||
|
if (state.dragging) {
|
||||||
|
return <div className="mx_FileDropTarget">
|
||||||
|
<img src={require("../../../res/img/upload-big.svg")} className="mx_FileDropTarget_image" alt="" />
|
||||||
|
{ _t("Drop file here to upload") }
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileDropTarget;
|
|
@ -20,7 +20,6 @@ import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||||
import { IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
|
|
||||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||||
import Spinner from "../views/elements/Spinner";
|
import Spinner from "../views/elements/Spinner";
|
||||||
import GroupAvatar from "../views/avatars/GroupAvatar";
|
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||||
|
@ -28,6 +27,7 @@ import { linkifyElement } from "../../HtmlUtils";
|
||||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||||
|
import { IGroupSummary } from "../../@types/groups";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
|
|
@ -396,8 +396,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||||
inputableElement.focus();
|
inputableElement.focus();
|
||||||
} else {
|
} else {
|
||||||
const inThread = !!document.activeElement.closest(".mx_ThreadView");
|
const inThread = !!document.activeElement.closest(".mx_ThreadView");
|
||||||
// refocusing during a paste event will make the
|
// refocusing during a paste event will make the paste end up in the newly focused element,
|
||||||
// paste end up in the newly focused element,
|
|
||||||
// so dispatch synchronously before paste happens
|
// so dispatch synchronously before paste happens
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: Action.FocusSendMessageComposer,
|
action: Action.FocusSendMessageComposer,
|
||||||
|
|
|
@ -19,7 +19,6 @@ limitations under the License.
|
||||||
|
|
||||||
// TODO: This component is enormous! There's several things which could stand-alone:
|
// TODO: This component is enormous! There's several things which could stand-alone:
|
||||||
// - Search results component
|
// - Search results component
|
||||||
// - Drag and drop
|
|
||||||
|
|
||||||
import React, { createRef } from 'react';
|
import React, { createRef } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -104,6 +103,7 @@ import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||||
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import { JoinRoomPayload } from "../../dispatcher/payloads/JoinRoomPayload";
|
import { JoinRoomPayload } from "../../dispatcher/payloads/JoinRoomPayload";
|
||||||
import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyncPreparedPayload';
|
import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyncPreparedPayload';
|
||||||
|
import FileDropTarget from './FileDropTarget';
|
||||||
|
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
let debuglog = function(msg: string) {};
|
let debuglog = function(msg: string) {};
|
||||||
|
@ -153,7 +153,6 @@ export interface IRoomState {
|
||||||
isInitialEventHighlighted?: boolean;
|
isInitialEventHighlighted?: boolean;
|
||||||
replyToEvent?: MatrixEvent;
|
replyToEvent?: MatrixEvent;
|
||||||
numUnreadMessages: number;
|
numUnreadMessages: number;
|
||||||
draggingFile: boolean;
|
|
||||||
searching: boolean;
|
searching: boolean;
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
searchScope?: SearchScope;
|
searchScope?: SearchScope;
|
||||||
|
@ -205,7 +204,6 @@ export interface IRoomState {
|
||||||
rejectError?: Error;
|
rejectError?: Error;
|
||||||
hasPinnedWidgets?: boolean;
|
hasPinnedWidgets?: boolean;
|
||||||
mainSplitContentType?: MainSplitContentType;
|
mainSplitContentType?: MainSplitContentType;
|
||||||
dragCounter: number;
|
|
||||||
// whether or not a spaces context switch brought us here,
|
// whether or not a spaces context switch brought us here,
|
||||||
// if it did we don't want the room to be marked as read as soon as it is loaded.
|
// if it did we don't want the room to be marked as read as soon as it is loaded.
|
||||||
wasContextSwitch?: boolean;
|
wasContextSwitch?: boolean;
|
||||||
|
@ -242,7 +240,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
shouldPeek: true,
|
shouldPeek: true,
|
||||||
membersLoaded: !llMembers,
|
membersLoaded: !llMembers,
|
||||||
numUnreadMessages: 0,
|
numUnreadMessages: 0,
|
||||||
draggingFile: false,
|
|
||||||
searching: false,
|
searching: false,
|
||||||
searchResults: null,
|
searchResults: null,
|
||||||
callState: null,
|
callState: null,
|
||||||
|
@ -272,7 +269,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
showDisplaynameChanges: true,
|
showDisplaynameChanges: true,
|
||||||
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
|
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
|
||||||
mainSplitContentType: MainSplitContentType.Timeline,
|
mainSplitContentType: MainSplitContentType.Timeline,
|
||||||
dragCounter: 0,
|
|
||||||
timelineRenderingType: TimelineRenderingType.Room,
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
liveTimeline: undefined,
|
liveTimeline: undefined,
|
||||||
};
|
};
|
||||||
|
@ -670,16 +666,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate() {
|
componentDidUpdate() {
|
||||||
if (this.roomView.current) {
|
|
||||||
const roomView = this.roomView.current;
|
|
||||||
if (!roomView.ondrop) {
|
|
||||||
roomView.addEventListener('drop', this.onDrop);
|
|
||||||
roomView.addEventListener('dragover', this.onDragOver);
|
|
||||||
roomView.addEventListener('dragenter', this.onDragEnter);
|
|
||||||
roomView.addEventListener('dragleave', this.onDragLeave);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: We check the ref here with a flag because componentDidMount, despite
|
// Note: We check the ref here with a flag because componentDidMount, despite
|
||||||
// documentation, does not define our messagePanel ref. It looks like our spinner
|
// documentation, does not define our messagePanel ref. It looks like our spinner
|
||||||
// in render() prevents the ref from being set on first mount, so we try and
|
// in render() prevents the ref from being set on first mount, so we try and
|
||||||
|
@ -714,17 +700,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
// stop tracking room changes to format permalinks
|
// stop tracking room changes to format permalinks
|
||||||
this.stopAllPermalinkCreators();
|
this.stopAllPermalinkCreators();
|
||||||
|
|
||||||
if (this.roomView.current) {
|
|
||||||
// disconnect the D&D event listeners from the room view. This
|
|
||||||
// is really just for hygiene - we're going to be
|
|
||||||
// deleted anyway, so it doesn't matter if the event listeners
|
|
||||||
// don't get cleaned up.
|
|
||||||
const roomView = this.roomView.current;
|
|
||||||
roomView.removeEventListener('drop', this.onDrop);
|
|
||||||
roomView.removeEventListener('dragover', this.onDragOver);
|
|
||||||
roomView.removeEventListener('dragenter', this.onDragEnter);
|
|
||||||
roomView.removeEventListener('dragleave', this.onDragLeave);
|
|
||||||
}
|
|
||||||
dis.unregister(this.dispatcherRef);
|
dis.unregister(this.dispatcherRef);
|
||||||
if (this.context) {
|
if (this.context) {
|
||||||
this.context.removeListener("Room", this.onRoom);
|
this.context.removeListener("Room", this.onRoom);
|
||||||
|
@ -813,10 +788,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
this.jumpToReadMarker();
|
this.jumpToReadMarker();
|
||||||
handled = true;
|
handled = true;
|
||||||
break;
|
break;
|
||||||
case KeyBindingAction.UploadFile:
|
case KeyBindingAction.UploadFile: {
|
||||||
dis.dispatch({ action: "upload_file" }, true);
|
dis.dispatch({
|
||||||
|
action: "upload_file",
|
||||||
|
context: TimelineRenderingType.Room,
|
||||||
|
}, true);
|
||||||
handled = true;
|
handled = true;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handled) {
|
if (handled) {
|
||||||
|
@ -1311,65 +1290,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
this.updateTopUnreadMessagesBar();
|
this.updateTopUnreadMessagesBar();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onDragEnter = ev => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
// We always increment the counter no matter the types, because dragging is
|
|
||||||
// still happening. If we didn't, the drag counter would get out of sync.
|
|
||||||
this.setState({ dragCounter: this.state.dragCounter + 1 });
|
|
||||||
|
|
||||||
// See:
|
|
||||||
// https://docs.w3cub.com/dom/datatransfer/types
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
|
|
||||||
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
|
|
||||||
this.setState({ draggingFile: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onDragLeave = ev => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
dragCounter: this.state.dragCounter - 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.state.dragCounter === 0) {
|
|
||||||
this.setState({
|
|
||||||
draggingFile: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onDragOver = ev => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
ev.dataTransfer.dropEffect = 'none';
|
|
||||||
|
|
||||||
// See:
|
|
||||||
// https://docs.w3cub.com/dom/datatransfer/types
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
|
|
||||||
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
|
|
||||||
ev.dataTransfer.dropEffect = 'copy';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onDrop = ev => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
ev.preventDefault();
|
|
||||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
|
||||||
ev.dataTransfer.files, this.state.room.roomId, null, this.context,
|
|
||||||
);
|
|
||||||
dis.fire(Action.FocusSendMessageComposer);
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
draggingFile: false,
|
|
||||||
dragCounter: this.state.dragCounter - 1,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private injectSticker(url: string, info: object, text: string, threadId: string | null) {
|
private injectSticker(url: string, info: object, text: string, threadId: string | null) {
|
||||||
if (this.context.isGuest()) {
|
if (this.context.isGuest()) {
|
||||||
dis.dispatch({ action: 'require_registration' });
|
dis.dispatch({ action: 'require_registration' });
|
||||||
|
@ -1802,6 +1722,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onFileDrop = (dataTransfer: DataTransfer) => ContentMessages.sharedInstance().sendContentListToRoom(
|
||||||
|
Array.from(dataTransfer.files),
|
||||||
|
this.state.room?.roomId ?? this.state.roomId,
|
||||||
|
null,
|
||||||
|
this.context,
|
||||||
|
TimelineRenderingType.Room,
|
||||||
|
);
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (!this.state.room) {
|
if (!this.state.room) {
|
||||||
const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading;
|
const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading;
|
||||||
|
@ -1902,19 +1830,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileDropTarget = null;
|
|
||||||
if (this.state.draggingFile) {
|
|
||||||
fileDropTarget = (
|
|
||||||
<div className="mx_RoomView_fileDropTarget">
|
|
||||||
<img
|
|
||||||
src={require("../../../res/img/upload-big.svg")}
|
|
||||||
className="mx_RoomView_fileDropTarget_image"
|
|
||||||
/>
|
|
||||||
{ _t("Drop file here to upload") }
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We have successfully loaded this room, and are not previewing.
|
// We have successfully loaded this room, and are not previewing.
|
||||||
// Display the "normal" room view.
|
// Display the "normal" room view.
|
||||||
|
|
||||||
|
@ -2171,7 +2086,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
let mainSplitBody = <React.Fragment>
|
let mainSplitBody = <React.Fragment>
|
||||||
{ auxPanel }
|
{ auxPanel }
|
||||||
<div className={timelineClasses}>
|
<div className={timelineClasses}>
|
||||||
{ fileDropTarget }
|
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
|
||||||
{ topUnreadMessagesBar }
|
{ topUnreadMessagesBar }
|
||||||
{ jumpToBottom }
|
{ jumpToBottom }
|
||||||
{ messagePanel }
|
{ messagePanel }
|
||||||
|
|
|
@ -74,7 +74,6 @@ import { BetaPill } from "../views/beta/BetaCard";
|
||||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||||
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
||||||
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
|
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
|
||||||
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
|
|
||||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||||
import Spinner from "../views/elements/Spinner";
|
import Spinner from "../views/elements/Spinner";
|
||||||
import GroupAvatar from "../views/avatars/GroupAvatar";
|
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||||
|
@ -85,6 +84,7 @@ import { UIComponent } from "../../settings/UIFeature";
|
||||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||||
import PosthogTrackers from "../../PosthogTrackers";
|
import PosthogTrackers from "../../PosthogTrackers";
|
||||||
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import { CreateEventField, IGroupSummary } from "../../@types/groups";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
space: Room;
|
space: Room;
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { createRef, KeyboardEvent } from 'react';
|
||||||
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
|
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
|
||||||
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
||||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||||
|
@ -46,6 +46,9 @@ import ThreadListContextMenu from '../views/context_menus/ThreadListContextMenu'
|
||||||
import RightPanelStore from '../../stores/right-panel/RightPanelStore';
|
import RightPanelStore from '../../stores/right-panel/RightPanelStore';
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import FileDropTarget from "./FileDropTarget";
|
||||||
|
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||||
|
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -68,9 +71,11 @@ interface IState {
|
||||||
@replaceableComponent("structures.ThreadView")
|
@replaceableComponent("structures.ThreadView")
|
||||||
export default class ThreadView extends React.Component<IProps, IState> {
|
export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
static contextType = RoomContext;
|
static contextType = RoomContext;
|
||||||
|
public context!: React.ContextType<typeof RoomContext>;
|
||||||
|
|
||||||
private dispatcherRef: string;
|
private dispatcherRef: string;
|
||||||
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
|
private timelinePanelRef = createRef<TimelinePanel>();
|
||||||
|
private cardRef = createRef<HTMLDivElement>();
|
||||||
private readonly layoutWatcherRef: string;
|
private readonly layoutWatcherRef: string;
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
|
@ -206,6 +211,27 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
const action = getKeyBindingsManager().getRoomAction(ev);
|
||||||
|
switch (action) {
|
||||||
|
case KeyBindingAction.UploadFile: {
|
||||||
|
dis.dispatch({
|
||||||
|
action: "upload_file",
|
||||||
|
context: TimelineRenderingType.Thread,
|
||||||
|
}, true);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private renderThreadViewHeader = (): JSX.Element => {
|
private renderThreadViewHeader = (): JSX.Element => {
|
||||||
return <div className="mx_ThreadPanel__header">
|
return <div className="mx_ThreadPanel__header">
|
||||||
<span>{ _t("Thread") }</span>
|
<span>{ _t("Thread") }</span>
|
||||||
|
@ -240,18 +266,32 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
return timelineWindow.paginate(direction, limit);
|
return timelineWindow.paginate(direction, limit);
|
||||||
};
|
};
|
||||||
|
|
||||||
public render(): JSX.Element {
|
private onFileDrop = (dataTransfer: DataTransfer) => {
|
||||||
const highlightedEventId = this.props.isInitialEventHighlighted
|
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||||
? this.props.initialEvent?.getId()
|
Array.from(dataTransfer.files),
|
||||||
: null;
|
this.props.mxEvent.getRoomId(),
|
||||||
|
this.threadRelation,
|
||||||
|
MatrixClientPeg.get(),
|
||||||
|
TimelineRenderingType.Thread,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const threadRelation: IEventRelation = {
|
private get threadRelation(): IEventRelation {
|
||||||
|
return {
|
||||||
"rel_type": RelationType.Thread,
|
"rel_type": RelationType.Thread,
|
||||||
"event_id": this.state.thread?.id,
|
"event_id": this.state.thread?.id,
|
||||||
"m.in_reply_to": {
|
"m.in_reply_to": {
|
||||||
"event_id": this.state.lastThreadReply?.getId() ?? this.state.thread?.id,
|
"event_id": this.state.lastThreadReply?.getId() ?? this.state.thread?.id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): JSX.Element {
|
||||||
|
const highlightedEventId = this.props.isInitialEventHighlighted
|
||||||
|
? this.props.initialEvent?.getId()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const threadRelation = this.threadRelation;
|
||||||
|
|
||||||
const messagePanelClassNames = classNames(
|
const messagePanelClassNames = classNames(
|
||||||
"mx_RoomView_messagePanel",
|
"mx_RoomView_messagePanel",
|
||||||
|
@ -272,8 +312,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
onClose={this.props.onClose}
|
onClose={this.props.onClose}
|
||||||
withoutScrollContainer={true}
|
withoutScrollContainer={true}
|
||||||
header={this.renderThreadViewHeader()}
|
header={this.renderThreadViewHeader()}
|
||||||
|
ref={this.cardRef}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
>
|
>
|
||||||
{ this.state.thread && (
|
{ this.state.thread && <div className="mx_ThreadView_timelinePanelWrapper">
|
||||||
|
<FileDropTarget parent={this.cardRef.current} onFileDrop={this.onFileDrop} />
|
||||||
<TimelinePanel
|
<TimelinePanel
|
||||||
ref={this.timelinePanelRef}
|
ref={this.timelinePanelRef}
|
||||||
showReadReceipts={false} // Hide the read receipts
|
showReadReceipts={false} // Hide the read receipts
|
||||||
|
@ -297,7 +340,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
onUserScroll={this.onScroll}
|
onUserScroll={this.onScroll}
|
||||||
onPaginationRequest={this.onPaginationRequest}
|
onPaginationRequest={this.onPaginationRequest}
|
||||||
/>
|
/>
|
||||||
) }
|
</div> }
|
||||||
|
|
||||||
{ ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
{ ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
||||||
<UploadBar room={this.props.room} relation={threadRelation} />
|
<UploadBar room={this.props.room} relation={threadRelation} />
|
||||||
|
|
|
@ -43,6 +43,7 @@ import TagOrderActions from "../../../actions/TagOrderActions";
|
||||||
import { inviteUsersToRoom } from "../../../RoomInvite";
|
import { inviteUsersToRoom } from "../../../RoomInvite";
|
||||||
import ProgressBar from "../elements/ProgressBar";
|
import ProgressBar from "../elements/ProgressBar";
|
||||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import { CreateEventField, IGroupRoom, IGroupSummary } from "../../../@types/groups";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
matrixClient: MatrixClient;
|
matrixClient: MatrixClient;
|
||||||
|
@ -50,50 +51,6 @@ interface IProps {
|
||||||
onFinished(spaceId?: string): void;
|
onFinished(spaceId?: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateEventField = "io.element.migrated_from_community";
|
|
||||||
|
|
||||||
interface IGroupRoom {
|
|
||||||
displayname: string;
|
|
||||||
name?: string;
|
|
||||||
roomId: string;
|
|
||||||
canonicalAlias?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
topic?: string;
|
|
||||||
numJoinedMembers?: number;
|
|
||||||
worldReadable?: boolean;
|
|
||||||
guestCanJoin?: boolean;
|
|
||||||
isPublic?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
|
||||||
export interface IGroupSummary {
|
|
||||||
profile: {
|
|
||||||
avatar_url?: string;
|
|
||||||
is_openly_joinable?: boolean;
|
|
||||||
is_public?: boolean;
|
|
||||||
long_description: string;
|
|
||||||
name: string;
|
|
||||||
short_description: string;
|
|
||||||
};
|
|
||||||
rooms_section: {
|
|
||||||
rooms: unknown[];
|
|
||||||
categories: Record<string, unknown>;
|
|
||||||
total_room_count_estimate: number;
|
|
||||||
};
|
|
||||||
user: {
|
|
||||||
is_privileged: boolean;
|
|
||||||
is_public: boolean;
|
|
||||||
is_publicised: boolean;
|
|
||||||
membership: string;
|
|
||||||
};
|
|
||||||
users_section: {
|
|
||||||
users: unknown[];
|
|
||||||
roles: Record<string, unknown>;
|
|
||||||
total_user_count_estimate: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/* eslint-enable camelcase */
|
|
||||||
|
|
||||||
enum Progress {
|
enum Progress {
|
||||||
NotStarted,
|
NotStarted,
|
||||||
ValidatingInputs,
|
ValidatingInputs,
|
||||||
|
|
|
@ -17,11 +17,8 @@ limitations under the License.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||||
import escapeHtml from "escape-html";
|
|
||||||
import sanitizeHtml from "sanitize-html";
|
|
||||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||||
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
|
||||||
import { Relations } from 'matrix-js-sdk/src/models/relations';
|
import { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||||
|
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
|
@ -32,12 +29,12 @@ import { Layout } from "../../../settings/enums/Layout";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
|
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import Spinner from './Spinner';
|
import Spinner from './Spinner';
|
||||||
import ReplyTile from "../rooms/ReplyTile";
|
import ReplyTile from "../rooms/ReplyTile";
|
||||||
import Pill from './Pill';
|
import Pill from './Pill';
|
||||||
import { ButtonEvent } from './AccessibleButton';
|
import { ButtonEvent } from './AccessibleButton';
|
||||||
|
import { getParentEventId } from '../../../utils/Reply';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This number is based on the previous behavior - if we have message of height
|
* This number is based on the previous behavior - if we have message of height
|
||||||
|
@ -97,174 +94,6 @@ export default class ReplyChain extends React.Component<IProps, IState> {
|
||||||
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
|
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getParentEventId(ev: MatrixEvent): string | undefined {
|
|
||||||
if (!ev || ev.isRedacted()) return;
|
|
||||||
if (ev.replyEventId) {
|
|
||||||
return ev.replyEventId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Part of Replies fallback support
|
|
||||||
public static stripPlainReply(body: string): string {
|
|
||||||
// Removes lines beginning with `> ` until you reach one that doesn't.
|
|
||||||
const lines = body.split('\n');
|
|
||||||
while (lines.length && lines[0].startsWith('> ')) lines.shift();
|
|
||||||
// Reply fallback has a blank line after it, so remove it to prevent leading newline
|
|
||||||
if (lines[0] === '') lines.shift();
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Part of Replies fallback support
|
|
||||||
public static stripHTMLReply(html: string): string {
|
|
||||||
// Sanitize the original HTML for inclusion in <mx-reply>. We allow
|
|
||||||
// any HTML, since the original sender could use special tags that we
|
|
||||||
// don't recognize, but want to pass along to any recipients who do
|
|
||||||
// recognize them -- recipients should be sanitizing before displaying
|
|
||||||
// anyways. However, we sanitize to 1) remove any mx-reply, so that we
|
|
||||||
// don't generate a nested mx-reply, and 2) make sure that the HTML is
|
|
||||||
// properly formatted (e.g. tags are closed where necessary)
|
|
||||||
return sanitizeHtml(
|
|
||||||
html,
|
|
||||||
{
|
|
||||||
allowedTags: false, // false means allow everything
|
|
||||||
allowedAttributes: false,
|
|
||||||
// we somehow can't allow all schemes, so we allow all that we
|
|
||||||
// know of and mxc (for img tags)
|
|
||||||
allowedSchemes: [...PERMITTED_URL_SCHEMES, 'mxc'],
|
|
||||||
exclusiveFilter: (frame) => frame.tag === "mx-reply",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Part of Replies fallback support
|
|
||||||
public static getNestedReplyText(
|
|
||||||
ev: MatrixEvent,
|
|
||||||
permalinkCreator: RoomPermalinkCreator,
|
|
||||||
): { body: string, html: string } | null {
|
|
||||||
if (!ev) return null;
|
|
||||||
|
|
||||||
let { body, formatted_body: html } = ev.getContent();
|
|
||||||
if (this.getParentEventId(ev)) {
|
|
||||||
if (body) body = this.stripPlainReply(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body) body = ""; // Always ensure we have a body, for reasons.
|
|
||||||
|
|
||||||
if (html) {
|
|
||||||
// sanitize the HTML before we put it in an <mx-reply>
|
|
||||||
html = this.stripHTMLReply(html);
|
|
||||||
} else {
|
|
||||||
// Escape the body to use as HTML below.
|
|
||||||
// We also run a nl2br over the result to fix the fallback representation. We do this
|
|
||||||
// after converting the text to safe HTML to avoid user-provided BR's from being converted.
|
|
||||||
html = escapeHtml(body).replace(/\n/g, '<br/>');
|
|
||||||
}
|
|
||||||
|
|
||||||
// dev note: do not rely on `body` being safe for HTML usage below.
|
|
||||||
|
|
||||||
const evLink = permalinkCreator.forEvent(ev.getId());
|
|
||||||
const userLink = makeUserPermalink(ev.getSender());
|
|
||||||
const mxid = ev.getSender();
|
|
||||||
|
|
||||||
// This fallback contains text that is explicitly EN.
|
|
||||||
switch (ev.getContent().msgtype) {
|
|
||||||
case 'm.text':
|
|
||||||
case 'm.notice': {
|
|
||||||
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
|
||||||
+ `<br>${html}</blockquote></mx-reply>`;
|
|
||||||
const lines = body.trim().split('\n');
|
|
||||||
if (lines.length > 0) {
|
|
||||||
lines[0] = `<${mxid}> ${lines[0]}`;
|
|
||||||
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'm.image':
|
|
||||||
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
|
||||||
+ `<br>sent an image.</blockquote></mx-reply>`;
|
|
||||||
body = `> <${mxid}> sent an image.\n\n`;
|
|
||||||
break;
|
|
||||||
case 'm.video':
|
|
||||||
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
|
||||||
+ `<br>sent a video.</blockquote></mx-reply>`;
|
|
||||||
body = `> <${mxid}> sent a video.\n\n`;
|
|
||||||
break;
|
|
||||||
case 'm.audio':
|
|
||||||
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
|
||||||
+ `<br>sent an audio file.</blockquote></mx-reply>`;
|
|
||||||
body = `> <${mxid}> sent an audio file.\n\n`;
|
|
||||||
break;
|
|
||||||
case 'm.file':
|
|
||||||
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
|
||||||
+ `<br>sent a file.</blockquote></mx-reply>`;
|
|
||||||
body = `> <${mxid}> sent a file.\n\n`;
|
|
||||||
break;
|
|
||||||
case 'm.emote': {
|
|
||||||
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> * `
|
|
||||||
+ `<a href="${userLink}">${mxid}</a><br>${html}</blockquote></mx-reply>`;
|
|
||||||
const lines = body.trim().split('\n');
|
|
||||||
if (lines.length > 0) {
|
|
||||||
lines[0] = `* <${mxid}> ${lines[0]}`;
|
|
||||||
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { body, html };
|
|
||||||
}
|
|
||||||
|
|
||||||
public static makeReplyMixIn(ev: MatrixEvent, renderIn?: string[]) {
|
|
||||||
if (!ev) return {};
|
|
||||||
|
|
||||||
const mixin: any = {
|
|
||||||
'm.relates_to': {
|
|
||||||
'm.in_reply_to': {
|
|
||||||
'event_id': ev.getId(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (renderIn) {
|
|
||||||
mixin['m.relates_to']['m.in_reply_to']['m.render_in'] = renderIn;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If the event replied is part of a thread
|
|
||||||
* Add the `m.thread` relation so that clients
|
|
||||||
* that know how to handle that relation will
|
|
||||||
* be able to render them more accurately
|
|
||||||
*/
|
|
||||||
if (ev.isThreadRelation) {
|
|
||||||
mixin['m.relates_to'] = {
|
|
||||||
...mixin['m.relates_to'],
|
|
||||||
rel_type: RelationType.Thread,
|
|
||||||
event_id: ev.threadRootId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return mixin;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static shouldDisplayReply(event: MatrixEvent, renderTarget?: string): boolean {
|
|
||||||
const parentExist = Boolean(ReplyChain.getParentEventId(event));
|
|
||||||
|
|
||||||
const relations = event.getRelation();
|
|
||||||
const renderIn = relations?.["m.in_reply_to"]?.["m.render_in"] ?? [];
|
|
||||||
|
|
||||||
const shouldRenderInTarget = !renderTarget || (renderIn.includes(renderTarget));
|
|
||||||
|
|
||||||
return parentExist && shouldRenderInTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getRenderInMixin(relation?: IEventRelation): string[] | undefined {
|
|
||||||
if (relation?.rel_type === RelationType.Thread) {
|
|
||||||
return [RelationType.Thread];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
this.trySetExpandableQuotes();
|
this.trySetExpandableQuotes();
|
||||||
|
@ -296,7 +125,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
|
||||||
private async initialize(): Promise<void> {
|
private async initialize(): Promise<void> {
|
||||||
const { parentEv } = this.props;
|
const { parentEv } = this.props;
|
||||||
// at time of making this component we checked that props.parentEv has a parentEventId
|
// at time of making this component we checked that props.parentEv has a parentEventId
|
||||||
const ev = await this.getEvent(ReplyChain.getParentEventId(parentEv));
|
const ev = await this.getEvent(getParentEventId(parentEv));
|
||||||
|
|
||||||
if (this.unmounted) return;
|
if (this.unmounted) return;
|
||||||
|
|
||||||
|
@ -314,7 +143,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
|
private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
|
||||||
try {
|
try {
|
||||||
const inReplyToEventId = ReplyChain.getParentEventId(ev);
|
const inReplyToEventId = getParentEventId(ev);
|
||||||
return await this.getEvent(inReplyToEventId);
|
return await this.getEvent(inReplyToEventId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -399,7 +228,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
</blockquote>;
|
</blockquote>;
|
||||||
} else if (this.props.forExport) {
|
} else if (this.props.forExport) {
|
||||||
const eventId = ReplyChain.getParentEventId(this.props.parentEv);
|
const eventId = getParentEventId(this.props.parentEv);
|
||||||
header = <p className="mx_ReplyChain_Export">
|
header = <p className="mx_ReplyChain_Export">
|
||||||
{ _t("In reply to <a>this message</a>",
|
{ _t("In reply to <a>this message</a>",
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -42,6 +42,7 @@ import ReplyChain from '../elements/ReplyChain';
|
||||||
import ReactionPicker from "../emojipicker/ReactionPicker";
|
import ReactionPicker from "../emojipicker/ReactionPicker";
|
||||||
import { CardContext } from '../right_panel/BaseCard';
|
import { CardContext } from '../right_panel/BaseCard';
|
||||||
import { showThread } from "../../../dispatcher/dispatch-actions/threads";
|
import { showThread } from "../../../dispatcher/dispatch-actions/threads";
|
||||||
|
import { shouldDisplayReply } from '../../../utils/Reply';
|
||||||
|
|
||||||
interface IOptionsButtonProps {
|
interface IOptionsButtonProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
|
@ -375,7 +376,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
||||||
toolbarOpts.push(cancelSendingButton);
|
toolbarOpts.push(cancelSendingButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.isQuoteExpanded !== undefined && ReplyChain.shouldDisplayReply(this.props.mxEvent)) {
|
if (this.props.isQuoteExpanded !== undefined && shouldDisplayReply(this.props.mxEvent)) {
|
||||||
const expandClassName = classNames({
|
const expandClassName = classNames({
|
||||||
'mx_MessageActionBar_maskButton': true,
|
'mx_MessageActionBar_maskButton': true,
|
||||||
'mx_MessageActionBar_expandMessageButton': !this.props.isQuoteExpanded,
|
'mx_MessageActionBar_expandMessageButton': !this.props.isQuoteExpanded,
|
||||||
|
|
|
@ -28,7 +28,6 @@ import { _t } from '../../../languageHandler';
|
||||||
import * as ContextMenu from '../../structures/ContextMenu';
|
import * as ContextMenu from '../../structures/ContextMenu';
|
||||||
import { ChevronFace, toRightOf } from '../../structures/ContextMenu';
|
import { ChevronFace, toRightOf } from '../../structures/ContextMenu';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import ReplyChain from "../elements/ReplyChain";
|
|
||||||
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
||||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||||
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||||
|
@ -48,6 +47,7 @@ import { IBodyProps } from "./IBodyProps";
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
import RoomContext from "../../../contexts/RoomContext";
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import { options as linkifyOpts } from "../../../linkify-matrix";
|
import { options as linkifyOpts } from "../../../linkify-matrix";
|
||||||
|
import { getParentEventId } from '../../../utils/Reply';
|
||||||
|
|
||||||
const MAX_HIGHLIGHT_LENGTH = 4096;
|
const MAX_HIGHLIGHT_LENGTH = 4096;
|
||||||
|
|
||||||
|
@ -557,7 +557,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||||
let isEmote = false;
|
let isEmote = false;
|
||||||
|
|
||||||
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
|
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
|
||||||
const stripReply = !mxEvent.replacingEvent() && !!ReplyChain.getParentEventId(mxEvent);
|
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
|
||||||
let body;
|
let body;
|
||||||
if (SettingsStore.isEnabled("feature_extensible_events")) {
|
if (SettingsStore.isEnabled("feature_extensible_events")) {
|
||||||
const extev = this.props.mxEvent.unstableExtensibleEvent as MessageEvent;
|
const extev = this.props.mxEvent.unstableExtensibleEvent as MessageEvent;
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ReactNode } from 'react';
|
import React, { forwardRef, ReactNode, KeyboardEvent, Ref } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||||
|
@ -32,7 +32,9 @@ interface IProps {
|
||||||
closeLabel?: string;
|
closeLabel?: string;
|
||||||
onClose?(ev: ButtonEvent): void;
|
onClose?(ev: ButtonEvent): void;
|
||||||
onBack?(ev: ButtonEvent): void;
|
onBack?(ev: ButtonEvent): void;
|
||||||
|
onKeyDown?(ev: KeyboardEvent): void;
|
||||||
cardState?: any;
|
cardState?: any;
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGroupProps {
|
interface IGroupProps {
|
||||||
|
@ -47,7 +49,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BaseCard: React.FC<IProps> = ({
|
const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(({
|
||||||
closeLabel,
|
closeLabel,
|
||||||
onClose,
|
onClose,
|
||||||
onBack,
|
onBack,
|
||||||
|
@ -56,7 +58,8 @@ const BaseCard: React.FC<IProps> = ({
|
||||||
footer,
|
footer,
|
||||||
withoutScrollContainer,
|
withoutScrollContainer,
|
||||||
children,
|
children,
|
||||||
}) => {
|
onKeyDown,
|
||||||
|
}, ref) => {
|
||||||
let backButton;
|
let backButton;
|
||||||
const cardHistory = RightPanelStore.instance.roomPhaseHistory;
|
const cardHistory = RightPanelStore.instance.roomPhaseHistory;
|
||||||
if (cardHistory.length > 1) {
|
if (cardHistory.length > 1) {
|
||||||
|
@ -87,7 +90,7 @@ const BaseCard: React.FC<IProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContext.Provider value={{ isCard: true }}>
|
<CardContext.Provider value={{ isCard: true }}>
|
||||||
<div className={classNames("mx_BaseCard", className)}>
|
<div className={classNames("mx_BaseCard", className)} ref={ref} onKeyDown={onKeyDown}>
|
||||||
<div className="mx_BaseCard_header">
|
<div className="mx_BaseCard_header">
|
||||||
{ backButton }
|
{ backButton }
|
||||||
{ closeButton }
|
{ closeButton }
|
||||||
|
@ -98,6 +101,6 @@ const BaseCard: React.FC<IProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</CardContext.Provider>
|
</CardContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default BaseCard;
|
export default BaseCard;
|
||||||
|
|
|
@ -78,6 +78,7 @@ import { copyPlaintext } from '../../../utils/strings';
|
||||||
import { DecryptionFailureTracker } from '../../../DecryptionFailureTracker';
|
import { DecryptionFailureTracker } from '../../../DecryptionFailureTracker';
|
||||||
import RedactedBody from '../messages/RedactedBody';
|
import RedactedBody from '../messages/RedactedBody';
|
||||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import { shouldDisplayReply } from '../../../utils/Reply';
|
||||||
|
|
||||||
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
|
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
|
||||||
|
|
||||||
|
@ -1390,8 +1391,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
||||||
? RelationType.Thread
|
? RelationType.Thread
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const replyChain = haveTileForEvent(this.props.mxEvent)
|
const replyChain = haveTileForEvent(this.props.mxEvent) && shouldDisplayReply(this.props.mxEvent, renderTarget)
|
||||||
&& ReplyChain.shouldDisplayReply(this.props.mxEvent, renderTarget)
|
|
||||||
? <ReplyChain
|
? <ReplyChain
|
||||||
parentEv={this.props.mxEvent}
|
parentEv={this.props.mxEvent}
|
||||||
onHeightChanged={this.props.onHeightChanged}
|
onHeightChanged={this.props.onHeightChanged}
|
||||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { IEventRelation } from "matrix-js-sdk/src/models/event";
|
import { IEventRelation } from "matrix-js-sdk/src/models/event";
|
||||||
import { M_POLL_START } from "matrix-events-sdk";
|
import { M_POLL_START } from "matrix-events-sdk";
|
||||||
import React, { createContext, ReactElement, useContext } from 'react';
|
import React, { createContext, ReactElement, useContext, useRef } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||||
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
||||||
|
@ -33,10 +33,10 @@ import LocationButton from '../location/LocationButton';
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import PollCreateDialog from "../elements/PollCreateDialog";
|
import PollCreateDialog from "../elements/PollCreateDialog";
|
||||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||||
import { ActionPayload } from '../../../dispatcher/payloads';
|
|
||||||
import ContentMessages from '../../../ContentMessages';
|
import ContentMessages from '../../../ContentMessages';
|
||||||
import MatrixClientContext from '../../../contexts/MatrixClientContext';
|
import MatrixClientContext from '../../../contexts/MatrixClientContext';
|
||||||
import RoomContext from '../../../contexts/RoomContext';
|
import RoomContext from '../../../contexts/RoomContext';
|
||||||
|
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
addEmoji: (emoji: string) => boolean;
|
addEmoji: (emoji: string) => boolean;
|
||||||
|
@ -189,50 +189,37 @@ interface IUploadButtonProps {
|
||||||
relation?: IEventRelation | null;
|
relation?: IEventRelation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UploadButton extends React.Component<IUploadButtonProps> {
|
const UploadButton = ({ roomId, relation }: IUploadButtonProps) => {
|
||||||
private uploadInput = React.createRef<HTMLInputElement>();
|
const cli = useContext(MatrixClientContext);
|
||||||
private dispatcherRef: string;
|
const roomContext = useContext(RoomContext);
|
||||||
|
const overflowMenuCloser = useContext(OverflowMenuContext);
|
||||||
|
const uploadInput = useRef<HTMLInputElement>();
|
||||||
|
|
||||||
constructor(props: IUploadButtonProps) {
|
const onUploadClick = () => {
|
||||||
super(props);
|
if (cli.isGuest()) {
|
||||||
|
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
dis.unregister(this.dispatcherRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onAction = (payload: ActionPayload) => {
|
|
||||||
if (payload.action === "upload_file") {
|
|
||||||
this.onUploadClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onUploadClick = () => {
|
|
||||||
if (MatrixClientPeg.get().isGuest()) {
|
|
||||||
dis.dispatch({ action: 'require_registration' });
|
dis.dispatch({ action: 'require_registration' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.uploadInput.current?.click();
|
uploadInput.current?.click();
|
||||||
|
overflowMenuCloser?.(); // close overflow menu
|
||||||
};
|
};
|
||||||
|
|
||||||
private onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
useDispatcher(dis, payload => {
|
||||||
|
if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") {
|
||||||
|
onUploadClick();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (ev.target.files.length === 0) return;
|
if (ev.target.files.length === 0) return;
|
||||||
|
|
||||||
// take a copy so we can safely reset the value of the form control
|
// Take a copy, so we can safely reset the value of the form control
|
||||||
// (Note it is a FileList: we can't use slice or sensible iteration).
|
|
||||||
const tfiles = [];
|
|
||||||
for (let i = 0; i < ev.target.files.length; ++i) {
|
|
||||||
tfiles.push(ev.target.files[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||||
tfiles,
|
Array.from(ev.target.files),
|
||||||
this.props.roomId,
|
roomId,
|
||||||
this.props.relation,
|
relation,
|
||||||
MatrixClientPeg.get(),
|
cli,
|
||||||
this.context.timelineRenderingType,
|
roomContext.timelineRenderingType,
|
||||||
);
|
);
|
||||||
|
|
||||||
// This is the onChange handler for a file form control, but we're
|
// This is the onChange handler for a file form control, but we're
|
||||||
|
@ -242,24 +229,22 @@ class UploadButton extends React.Component<IUploadButtonProps> {
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
const uploadInputStyle = { display: 'none' };
|
||||||
const uploadInputStyle = { display: 'none' };
|
return <>
|
||||||
return <>
|
<CollapsibleButton
|
||||||
<CollapsibleButton
|
className="mx_MessageComposer_button mx_MessageComposer_upload"
|
||||||
className="mx_MessageComposer_button mx_MessageComposer_upload"
|
onClick={onUploadClick}
|
||||||
onClick={this.onUploadClick}
|
title={_t('Attachment')}
|
||||||
title={_t('Attachment')}
|
/>
|
||||||
/>
|
<input
|
||||||
<input
|
ref={uploadInput}
|
||||||
ref={this.uploadInput}
|
type="file"
|
||||||
type="file"
|
style={uploadInputStyle}
|
||||||
style={uploadInputStyle}
|
multiple
|
||||||
multiple
|
onChange={onUploadFileInputChange}
|
||||||
onChange={this.onUploadFileInputChange}
|
/>
|
||||||
/>
|
</>;
|
||||||
</>;
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showStickersButton(props: IProps): ReactElement {
|
function showStickersButton(props: IProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -36,7 +36,6 @@ import {
|
||||||
} from '../../../editor/serialize';
|
} from '../../../editor/serialize';
|
||||||
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
||||||
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
|
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
|
||||||
import ReplyChain from "../elements/ReplyChain";
|
|
||||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||||
import SendHistoryManager from "../../../SendHistoryManager";
|
import SendHistoryManager from "../../../SendHistoryManager";
|
||||||
import { CommandCategories } from '../../../SlashCommands';
|
import { CommandCategories } from '../../../SlashCommands';
|
||||||
|
@ -58,6 +57,7 @@ import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload
|
||||||
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
|
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
|
||||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||||
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
||||||
|
import { getNestedReplyText, getRenderInMixin, makeReplyMixIn } from '../../../utils/Reply';
|
||||||
|
|
||||||
interface IAddReplyOpts {
|
interface IAddReplyOpts {
|
||||||
permalinkCreator?: RoomPermalinkCreator;
|
permalinkCreator?: RoomPermalinkCreator;
|
||||||
|
@ -72,13 +72,13 @@ function addReplyToMessageContent(
|
||||||
includeLegacyFallback: true,
|
includeLegacyFallback: true,
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const replyContent = ReplyChain.makeReplyMixIn(replyToEvent, opts.renderIn);
|
const replyContent = makeReplyMixIn(replyToEvent, opts.renderIn);
|
||||||
Object.assign(content, replyContent);
|
Object.assign(content, replyContent);
|
||||||
|
|
||||||
if (opts.includeLegacyFallback) {
|
if (opts.includeLegacyFallback) {
|
||||||
// Part of Replies fallback support - prepend the text we're sending
|
// Part of Replies fallback support - prepend the text we're sending
|
||||||
// with the text we're replying to
|
// with the text we're replying to
|
||||||
const nestedReply = ReplyChain.getNestedReplyText(replyToEvent, opts.permalinkCreator);
|
const nestedReply = getNestedReplyText(replyToEvent, opts.permalinkCreator);
|
||||||
if (nestedReply) {
|
if (nestedReply) {
|
||||||
if (content.formatted_body) {
|
if (content.formatted_body) {
|
||||||
content.formatted_body = nestedReply.html + content.formatted_body;
|
content.formatted_body = nestedReply.html + content.formatted_body;
|
||||||
|
@ -132,7 +132,7 @@ export function createMessageContent(
|
||||||
addReplyToMessageContent(content, replyToEvent, {
|
addReplyToMessageContent(content, replyToEvent, {
|
||||||
permalinkCreator,
|
permalinkCreator,
|
||||||
includeLegacyFallback: true,
|
includeLegacyFallback: true,
|
||||||
renderIn: ReplyChain.getRenderInMixin(relation),
|
renderIn: getRenderInMixin(relation),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -384,7 +384,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||||
addReplyToMessageContent(content, replyToEvent, {
|
addReplyToMessageContent(content, replyToEvent, {
|
||||||
permalinkCreator: this.props.permalinkCreator,
|
permalinkCreator: this.props.permalinkCreator,
|
||||||
includeLegacyFallback: true,
|
includeLegacyFallback: true,
|
||||||
renderIn: ReplyChain.getRenderInMixin(this.props.relation),
|
renderIn: getRenderInMixin(this.props.relation),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -32,13 +32,13 @@ import dis from "../../../../../dispatcher/dispatcher";
|
||||||
import GroupActions from "../../../../../actions/GroupActions";
|
import GroupActions from "../../../../../actions/GroupActions";
|
||||||
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
||||||
import { useDispatcher } from "../../../../../hooks/useDispatcher";
|
import { useDispatcher } from "../../../../../hooks/useDispatcher";
|
||||||
import { CreateEventField, IGroupSummary } from "../../../dialogs/CreateSpaceFromCommunityDialog";
|
|
||||||
import { createSpaceFromCommunity } from "../../../../../utils/space";
|
import { createSpaceFromCommunity } from "../../../../../utils/space";
|
||||||
import Spinner from "../../../elements/Spinner";
|
import Spinner from "../../../elements/Spinner";
|
||||||
import { UserTab } from "../../../dialogs/UserSettingsDialog";
|
import { UserTab } from "../../../dialogs/UserSettingsDialog";
|
||||||
import { OpenToTabPayload } from "../../../../../dispatcher/payloads/OpenToTabPayload";
|
import { OpenToTabPayload } from "../../../../../dispatcher/payloads/OpenToTabPayload";
|
||||||
import { Action } from "../../../../../dispatcher/actions";
|
import { Action } from "../../../../../dispatcher/actions";
|
||||||
import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import { CreateEventField, IGroupSummary } from '../../../../../@types/groups';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
closeSettingsFn(success: boolean): void;
|
closeSettingsFn(success: boolean): void;
|
||||||
|
|
|
@ -35,7 +35,6 @@ const RoomContext = createContext<IRoomState>({
|
||||||
shouldPeek: true,
|
shouldPeek: true,
|
||||||
membersLoaded: false,
|
membersLoaded: false,
|
||||||
numUnreadMessages: 0,
|
numUnreadMessages: 0,
|
||||||
draggingFile: false,
|
|
||||||
searching: false,
|
searching: false,
|
||||||
guestsCanJoin: false,
|
guestsCanJoin: false,
|
||||||
canPeek: false,
|
canPeek: false,
|
||||||
|
@ -62,7 +61,6 @@ const RoomContext = createContext<IRoomState>({
|
||||||
showAvatarChanges: true,
|
showAvatarChanges: true,
|
||||||
showDisplaynameChanges: true,
|
showDisplaynameChanges: true,
|
||||||
matrixClientIsReady: false,
|
matrixClientIsReady: false,
|
||||||
dragCounter: 0,
|
|
||||||
timelineRenderingType: TimelineRenderingType.Room,
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
threadId: undefined,
|
threadId: undefined,
|
||||||
liveTimeline: undefined,
|
liveTimeline: undefined,
|
||||||
|
|
|
@ -2985,6 +2985,7 @@
|
||||||
"Pause": "Pause",
|
"Pause": "Pause",
|
||||||
"Play": "Play",
|
"Play": "Play",
|
||||||
"Couldn't load page": "Couldn't load page",
|
"Couldn't load page": "Couldn't load page",
|
||||||
|
"Drop file here to upload": "Drop file here to upload",
|
||||||
"You must <a>register</a> to use this functionality": "You must <a>register</a> to use this functionality",
|
"You must <a>register</a> to use this functionality": "You must <a>register</a> to use this functionality",
|
||||||
"You must join the room to see its files": "You must join the room to see its files",
|
"You must join the room to see its files": "You must join the room to see its files",
|
||||||
"No files visible in this room": "No files visible in this room",
|
"No files visible in this room": "No files visible in this room",
|
||||||
|
@ -3127,7 +3128,6 @@
|
||||||
"Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(",
|
"Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(",
|
||||||
"No more results": "No more results",
|
"No more results": "No more results",
|
||||||
"Failed to reject invite": "Failed to reject invite",
|
"Failed to reject invite": "Failed to reject invite",
|
||||||
"Drop file here to upload": "Drop file here to upload",
|
|
||||||
"You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.",
|
"You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.",
|
||||||
"You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.",
|
"You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.",
|
||||||
"Joining": "Joining",
|
"Joining": "Joining",
|
||||||
|
|
|
@ -23,7 +23,7 @@ import Analytics from '../Analytics';
|
||||||
import * as RoomNotifs from "../RoomNotifs";
|
import * as RoomNotifs from "../RoomNotifs";
|
||||||
import { MatrixClientPeg } from '../MatrixClientPeg';
|
import { MatrixClientPeg } from '../MatrixClientPeg';
|
||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
import { CreateEventField } from "../components/views/dialogs/CreateSpaceFromCommunityDialog";
|
import { CreateEventField } from "../@types/groups";
|
||||||
|
|
||||||
const INITIAL_STATE = {
|
const INITIAL_STATE = {
|
||||||
orderedTags: null,
|
orderedTags: null,
|
||||||
|
|
|
@ -20,8 +20,8 @@ import { IPreview } from "./IPreview";
|
||||||
import { TagID } from "../models";
|
import { TagID } from "../models";
|
||||||
import { _t, sanitizeForTranslation } from "../../../languageHandler";
|
import { _t, sanitizeForTranslation } from "../../../languageHandler";
|
||||||
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
|
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
|
||||||
import ReplyChain from "../../../components/views/elements/ReplyChain";
|
|
||||||
import { getHtmlText } from "../../../HtmlUtils";
|
import { getHtmlText } from "../../../HtmlUtils";
|
||||||
|
import { stripHTMLReply, stripPlainReply } from "../../../utils/Reply";
|
||||||
|
|
||||||
export class MessageEventPreview implements IPreview {
|
export class MessageEventPreview implements IPreview {
|
||||||
public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string {
|
public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string {
|
||||||
|
@ -44,13 +44,12 @@ export class MessageEventPreview implements IPreview {
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: Newer relations have a getRelation() function which is not compatible with replies.
|
// XXX: Newer relations have a getRelation() function which is not compatible with replies.
|
||||||
const mRelatesTo = event.getWireContent()['m.relates_to'];
|
if (event.getWireContent()['m.relates_to']?.['m.in_reply_to']) {
|
||||||
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
|
|
||||||
// If this is a reply, get the real reply and use that
|
// If this is a reply, get the real reply and use that
|
||||||
if (hasHtml) {
|
if (hasHtml) {
|
||||||
body = (ReplyChain.stripHTMLReply(body) || '').trim();
|
body = (stripHTMLReply(body) || '').trim();
|
||||||
} else {
|
} else {
|
||||||
body = (ReplyChain.stripPlainReply(body) || '').trim();
|
body = (stripPlainReply(body) || '').trim();
|
||||||
}
|
}
|
||||||
if (!body) return null; // invalid event, no preview
|
if (!body) return null; // invalid event, no preview
|
||||||
}
|
}
|
||||||
|
|
191
src/utils/Reply.ts
Normal file
191
src/utils/Reply.ts
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||||
|
|
||||||
|
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 { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
import { RelationType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
import escapeHtml from "escape-html";
|
||||||
|
|
||||||
|
import { PERMITTED_URL_SCHEMES } from "../HtmlUtils";
|
||||||
|
import { makeUserPermalink, RoomPermalinkCreator } from "./permalinks/Permalinks";
|
||||||
|
|
||||||
|
export function getParentEventId(ev: MatrixEvent): string | undefined {
|
||||||
|
if (!ev || ev.isRedacted()) return;
|
||||||
|
if (ev.replyEventId) {
|
||||||
|
return ev.replyEventId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part of Replies fallback support
|
||||||
|
export function stripPlainReply(body: string): string {
|
||||||
|
// Removes lines beginning with `> ` until you reach one that doesn't.
|
||||||
|
const lines = body.split('\n');
|
||||||
|
while (lines.length && lines[0].startsWith('> ')) lines.shift();
|
||||||
|
// Reply fallback has a blank line after it, so remove it to prevent leading newline
|
||||||
|
if (lines[0] === '') lines.shift();
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part of Replies fallback support
|
||||||
|
export function stripHTMLReply(html: string): string {
|
||||||
|
// Sanitize the original HTML for inclusion in <mx-reply>. We allow
|
||||||
|
// any HTML, since the original sender could use special tags that we
|
||||||
|
// don't recognize, but want to pass along to any recipients who do
|
||||||
|
// recognize them -- recipients should be sanitizing before displaying
|
||||||
|
// anyways. However, we sanitize to 1) remove any mx-reply, so that we
|
||||||
|
// don't generate a nested mx-reply, and 2) make sure that the HTML is
|
||||||
|
// properly formatted (e.g. tags are closed where necessary)
|
||||||
|
return sanitizeHtml(
|
||||||
|
html,
|
||||||
|
{
|
||||||
|
allowedTags: false, // false means allow everything
|
||||||
|
allowedAttributes: false,
|
||||||
|
// we somehow can't allow all schemes, so we allow all that we
|
||||||
|
// know of and mxc (for img tags)
|
||||||
|
allowedSchemes: [...PERMITTED_URL_SCHEMES, 'mxc'],
|
||||||
|
exclusiveFilter: (frame) => frame.tag === "mx-reply",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part of Replies fallback support
|
||||||
|
export function getNestedReplyText(
|
||||||
|
ev: MatrixEvent,
|
||||||
|
permalinkCreator: RoomPermalinkCreator,
|
||||||
|
): { body: string, html: string } | null {
|
||||||
|
if (!ev) return null;
|
||||||
|
|
||||||
|
let { body, formatted_body: html } = ev.getContent();
|
||||||
|
if (getParentEventId(ev)) {
|
||||||
|
if (body) body = stripPlainReply(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body) body = ""; // Always ensure we have a body, for reasons.
|
||||||
|
|
||||||
|
if (html) {
|
||||||
|
// sanitize the HTML before we put it in an <mx-reply>
|
||||||
|
html = stripHTMLReply(html);
|
||||||
|
} else {
|
||||||
|
// Escape the body to use as HTML below.
|
||||||
|
// We also run a nl2br over the result to fix the fallback representation. We do this
|
||||||
|
// after converting the text to safe HTML to avoid user-provided BR's from being converted.
|
||||||
|
html = escapeHtml(body).replace(/\n/g, '<br/>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// dev note: do not rely on `body` being safe for HTML usage below.
|
||||||
|
|
||||||
|
const evLink = permalinkCreator.forEvent(ev.getId());
|
||||||
|
const userLink = makeUserPermalink(ev.getSender());
|
||||||
|
const mxid = ev.getSender();
|
||||||
|
|
||||||
|
// This fallback contains text that is explicitly EN.
|
||||||
|
switch (ev.getContent().msgtype) {
|
||||||
|
case 'm.text':
|
||||||
|
case 'm.notice': {
|
||||||
|
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||||
|
+ `<br>${html}</blockquote></mx-reply>`;
|
||||||
|
const lines = body.trim().split('\n');
|
||||||
|
if (lines.length > 0) {
|
||||||
|
lines[0] = `<${mxid}> ${lines[0]}`;
|
||||||
|
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'm.image':
|
||||||
|
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||||
|
+ `<br>sent an image.</blockquote></mx-reply>`;
|
||||||
|
body = `> <${mxid}> sent an image.\n\n`;
|
||||||
|
break;
|
||||||
|
case 'm.video':
|
||||||
|
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||||
|
+ `<br>sent a video.</blockquote></mx-reply>`;
|
||||||
|
body = `> <${mxid}> sent a video.\n\n`;
|
||||||
|
break;
|
||||||
|
case 'm.audio':
|
||||||
|
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||||
|
+ `<br>sent an audio file.</blockquote></mx-reply>`;
|
||||||
|
body = `> <${mxid}> sent an audio file.\n\n`;
|
||||||
|
break;
|
||||||
|
case 'm.file':
|
||||||
|
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||||
|
+ `<br>sent a file.</blockquote></mx-reply>`;
|
||||||
|
body = `> <${mxid}> sent a file.\n\n`;
|
||||||
|
break;
|
||||||
|
case 'm.emote': {
|
||||||
|
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> * `
|
||||||
|
+ `<a href="${userLink}">${mxid}</a><br>${html}</blockquote></mx-reply>`;
|
||||||
|
const lines = body.trim().split('\n');
|
||||||
|
if (lines.length > 0) {
|
||||||
|
lines[0] = `* <${mxid}> ${lines[0]}`;
|
||||||
|
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { body, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeReplyMixIn(ev: MatrixEvent, renderIn?: string[]) {
|
||||||
|
if (!ev) return {};
|
||||||
|
|
||||||
|
const mixin: any = {
|
||||||
|
'm.relates_to': {
|
||||||
|
'm.in_reply_to': {
|
||||||
|
'event_id': ev.getId(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (renderIn) {
|
||||||
|
mixin['m.relates_to']['m.in_reply_to']['m.render_in'] = renderIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the event replied is part of a thread
|
||||||
|
* Add the `m.thread` relation so that clients
|
||||||
|
* that know how to handle that relation will
|
||||||
|
* be able to render them more accurately
|
||||||
|
*/
|
||||||
|
if (ev.isThreadRelation) {
|
||||||
|
mixin['m.relates_to'] = {
|
||||||
|
...mixin['m.relates_to'],
|
||||||
|
rel_type: RelationType.Thread,
|
||||||
|
event_id: ev.threadRootId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return mixin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldDisplayReply(event: MatrixEvent, renderTarget?: string): boolean {
|
||||||
|
const parentExist = Boolean(getParentEventId(event));
|
||||||
|
|
||||||
|
const relations = event.getRelation();
|
||||||
|
const renderIn = relations?.["m.in_reply_to"]?.["m.render_in"] ?? [];
|
||||||
|
|
||||||
|
const shouldRenderInTarget = !renderTarget || (renderIn.includes(renderTarget));
|
||||||
|
|
||||||
|
return parentExist && shouldRenderInTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRenderInMixin(relation?: IEventRelation): string[] | undefined {
|
||||||
|
if (relation?.rel_type === RelationType.Thread) {
|
||||||
|
return [RelationType.Thread];
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,22 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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 "../../../skinned-sdk";
|
import "../../../skinned-sdk";
|
||||||
import * as testUtils from '../../../test-utils';
|
import * as testUtils from '../../../test-utils';
|
||||||
import ReplyChain from '../../../../src/components/views/elements/ReplyChain';
|
import { getParentEventId } from "../../../../src/utils/Reply";
|
||||||
|
|
||||||
describe("ReplyChain", () => {
|
describe("ReplyChain", () => {
|
||||||
describe('getParentEventId', () => {
|
describe('getParentEventId', () => {
|
||||||
|
@ -21,7 +37,7 @@ describe("ReplyChain", () => {
|
||||||
room: "room_id",
|
room: "room_id",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ReplyChain.getParentEventId(originalEventWithRelation))
|
expect(getParentEventId(originalEventWithRelation))
|
||||||
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
|
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -65,7 +81,7 @@ describe("ReplyChain", () => {
|
||||||
originalEventWithRelation.makeReplaced(editEvent);
|
originalEventWithRelation.makeReplaced(editEvent);
|
||||||
|
|
||||||
// The relation should be pulled from the original event
|
// The relation should be pulled from the original event
|
||||||
expect(ReplyChain.getParentEventId(originalEventWithRelation))
|
expect(getParentEventId(originalEventWithRelation))
|
||||||
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
|
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -109,7 +125,7 @@ describe("ReplyChain", () => {
|
||||||
originalEvent.makeReplaced(editEvent);
|
originalEvent.makeReplaced(editEvent);
|
||||||
|
|
||||||
// The relation should be pulled from the edit event
|
// The relation should be pulled from the edit event
|
||||||
expect(ReplyChain.getParentEventId(originalEvent))
|
expect(getParentEventId(originalEvent))
|
||||||
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
|
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -158,7 +174,7 @@ describe("ReplyChain", () => {
|
||||||
originalEventWithRelation.makeReplaced(editEvent);
|
originalEventWithRelation.makeReplaced(editEvent);
|
||||||
|
|
||||||
// The relation should be pulled from the edit event
|
// The relation should be pulled from the edit event
|
||||||
expect(ReplyChain.getParentEventId(originalEventWithRelation)).toStrictEqual('$999');
|
expect(getParentEventId(originalEventWithRelation)).toStrictEqual('$999');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('able to clear relation reply from original event by providing empty relation field', () => {
|
it('able to clear relation reply from original event by providing empty relation field', () => {
|
||||||
|
@ -203,7 +219,7 @@ describe("ReplyChain", () => {
|
||||||
originalEventWithRelation.makeReplaced(editEvent);
|
originalEventWithRelation.makeReplaced(editEvent);
|
||||||
|
|
||||||
// The relation should be pulled from the edit event
|
// The relation should be pulled from the edit event
|
||||||
expect(ReplyChain.getParentEventId(originalEventWithRelation)).toStrictEqual(undefined);
|
expect(getParentEventId(originalEventWithRelation)).toStrictEqual(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -148,7 +148,6 @@ function createRoomState(room: Room): IRoomState {
|
||||||
shouldPeek: true,
|
shouldPeek: true,
|
||||||
membersLoaded: false,
|
membersLoaded: false,
|
||||||
numUnreadMessages: 0,
|
numUnreadMessages: 0,
|
||||||
draggingFile: false,
|
|
||||||
searching: false,
|
searching: false,
|
||||||
guestsCanJoin: false,
|
guestsCanJoin: false,
|
||||||
canPeek: false,
|
canPeek: false,
|
||||||
|
@ -175,7 +174,6 @@ function createRoomState(room: Room): IRoomState {
|
||||||
showAvatarChanges: true,
|
showAvatarChanges: true,
|
||||||
showDisplaynameChanges: true,
|
showDisplaynameChanges: true,
|
||||||
matrixClientIsReady: false,
|
matrixClientIsReady: false,
|
||||||
dragCounter: 0,
|
|
||||||
timelineRenderingType: TimelineRenderingType.Room,
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
liveTimeline: undefined,
|
liveTimeline: undefined,
|
||||||
};
|
};
|
||||||
|
|
|
@ -45,7 +45,6 @@ describe('<SendMessageComposer/>', () => {
|
||||||
shouldPeek: true,
|
shouldPeek: true,
|
||||||
membersLoaded: false,
|
membersLoaded: false,
|
||||||
numUnreadMessages: 0,
|
numUnreadMessages: 0,
|
||||||
draggingFile: false,
|
|
||||||
searching: false,
|
searching: false,
|
||||||
guestsCanJoin: false,
|
guestsCanJoin: false,
|
||||||
canPeek: false,
|
canPeek: false,
|
||||||
|
@ -72,7 +71,6 @@ describe('<SendMessageComposer/>', () => {
|
||||||
showAvatarChanges: true,
|
showAvatarChanges: true,
|
||||||
showDisplaynameChanges: true,
|
showDisplaynameChanges: true,
|
||||||
matrixClientIsReady: false,
|
matrixClientIsReady: false,
|
||||||
dragCounter: 0,
|
|
||||||
timelineRenderingType: TimelineRenderingType.Room,
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
liveTimeline: undefined,
|
liveTimeline: undefined,
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue