Wire up drag-drop file uploads for the thread view (#7860)

This commit is contained in:
Michael Telatynski 2022-02-22 11:14:56 +00:00 committed by GitHub
parent 42e9ea4540
commit 8fccef86d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 616 additions and 482 deletions

View file

@ -10,6 +10,7 @@
@import "./structures/_ContextualMenu.scss";
@import "./structures/_CreateRoom.scss";
@import "./structures/_CustomRoomTagPanel.scss";
@import "./structures/_FileDropTarget.scss";
@import "./structures/_FilePanel.scss";
@import "./structures/_GenericErrorPage.scss";
@import "./structures/_GroupFilterPanel.scss";
@ -141,11 +142,11 @@
@import "./views/elements/_Dropdown.scss";
@import "./views/elements/_EditableItemList.scss";
@import "./views/elements/_ErrorBoundary.scss";
@import "./views/elements/_GenericEventListSummary.scss";
@import "./views/elements/_EventTilePreview.scss";
@import "./views/elements/_ExternalLink.scss";
@import "./views/elements/_FacePile.scss";
@import "./views/elements/_Field.scss";
@import "./views/elements/_GenericEventListSummary.scss";
@import "./views/elements/_ImageView.scss";
@import "./views/elements/_InfoTooltip.scss";
@import "./views/elements/_InlineSpinner.scss";
@ -183,9 +184,9 @@
@import "./views/messages/_CallEvent.scss";
@import "./views/messages/_CreateEvent.scss";
@import "./views/messages/_DateSeparator.scss";
@import "./views/messages/_JumpToDatePicker.scss";
@import "./views/messages/_EventTileBubble.scss";
@import "./views/messages/_HiddenBody.scss";
@import "./views/messages/_JumpToDatePicker.scss";
@import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss";

View 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;
}

View file

@ -32,56 +32,6 @@ limitations under the License.
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 {
min-width: 0px;
width: 100%;

View file

@ -106,11 +106,18 @@ limitations under the License.
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
panel. Hence the magic number, 8+4=12 */
width: calc(100% - 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 {

59
src/@types/groups.ts Normal file
View 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 */

View file

@ -447,7 +447,7 @@ export default class ContentMessages {
public async sendContentListToRoom(
files: File[],
roomId: string,
relation: IEventRelation | null,
relation: IEventRelation | undefined,
matrixClient: MatrixClient,
context = TimelineRenderingType.Room,
): Promise<void> {
@ -566,7 +566,7 @@ export default class ContentMessages {
private sendContentToRoom(
file: File,
roomId: string,
relation: IEventRelation,
relation: IEventRelation | undefined,
matrixClient: MatrixClient,
promBefore: Promise<any>,
) {

View file

@ -32,9 +32,9 @@ import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import SettingsStore from './settings/SettingsStore';
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { getEmojiFromUnicode } from "./emoji";
import ReplyChain from "./components/views/elements/ReplyChain";
import { mediaFromMxc } from "./customisations/Media";
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
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;
const plainBody = typeof content.body === 'string' ? content.body : "";
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyChain.stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? ReplyChain.stripPlainReply(plainBody) : plainBody;
if (opts.stripReplyFallback && formattedBody) formattedBody = stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? stripPlainReply(plainBody) : plainBody;
bodyHasEmoji = mightContainEmoji(isFormattedBody ? formattedBody : plainBody);

View 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;

View file

@ -20,7 +20,6 @@ import MatrixClientContext from "../../contexts/MatrixClientContext";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import { IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
@ -28,6 +27,7 @@ import { linkifyElement } from "../../HtmlUtils";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { IGroupSummary } from "../../@types/groups";
interface IProps {
groupId: string;

View file

@ -396,8 +396,7 @@ class LoggedInView extends React.Component<IProps, IState> {
inputableElement.focus();
} else {
const inThread = !!document.activeElement.closest(".mx_ThreadView");
// refocusing during a paste event will make the
// paste end up in the newly focused element,
// refocusing during a paste event will make the paste end up in the newly focused element,
// so dispatch synchronously before paste happens
dis.dispatch({
action: Action.FocusSendMessageComposer,

View file

@ -19,7 +19,6 @@ limitations under the License.
// TODO: This component is enormous! There's several things which could stand-alone:
// - Search results component
// - Drag and drop
import React, { createRef } from 'react';
import classNames from 'classnames';
@ -104,6 +103,7 @@ import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { JoinRoomPayload } from "../../dispatcher/payloads/JoinRoomPayload";
import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyncPreparedPayload';
import FileDropTarget from './FileDropTarget';
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -153,7 +153,6 @@ export interface IRoomState {
isInitialEventHighlighted?: boolean;
replyToEvent?: MatrixEvent;
numUnreadMessages: number;
draggingFile: boolean;
searching: boolean;
searchTerm?: string;
searchScope?: SearchScope;
@ -205,7 +204,6 @@ export interface IRoomState {
rejectError?: Error;
hasPinnedWidgets?: boolean;
mainSplitContentType?: MainSplitContentType;
dragCounter: number;
// 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.
wasContextSwitch?: boolean;
@ -242,7 +240,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
shouldPeek: true,
membersLoaded: !llMembers,
numUnreadMessages: 0,
draggingFile: false,
searching: false,
searchResults: null,
callState: null,
@ -272,7 +269,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
mainSplitContentType: MainSplitContentType.Timeline,
dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
};
@ -670,16 +666,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
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
// 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
@ -714,17 +700,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// stop tracking room changes to format permalinks
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);
if (this.context) {
this.context.removeListener("Room", this.onRoom);
@ -813,10 +788,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.jumpToReadMarker();
handled = true;
break;
case KeyBindingAction.UploadFile:
dis.dispatch({ action: "upload_file" }, true);
case KeyBindingAction.UploadFile: {
dis.dispatch({
action: "upload_file",
context: TimelineRenderingType.Room,
}, true);
handled = true;
break;
}
}
if (handled) {
@ -1311,65 +1290,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
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) {
if (this.context.isGuest()) {
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() {
if (!this.state.room) {
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.
// Display the "normal" room view.
@ -2171,7 +2086,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
let mainSplitBody = <React.Fragment>
{ auxPanel }
<div className={timelineClasses}>
{ fileDropTarget }
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }

View file

@ -74,7 +74,6 @@ import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
@ -85,6 +84,7 @@ import { UIComponent } from "../../settings/UIFeature";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import PosthogTrackers from "../../PosthogTrackers";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { CreateEventField, IGroupSummary } from "../../@types/groups";
interface IProps {
space: Room;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
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 { RelationType } from 'matrix-js-sdk/src/@types/event';
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 SettingsStore from "../../settings/SettingsStore";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import FileDropTarget from "./FileDropTarget";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
interface IProps {
room: Room;
@ -68,9 +71,11 @@ interface IState {
@replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
private timelinePanelRef = createRef<TimelinePanel>();
private cardRef = createRef<HTMLDivElement>();
private readonly layoutWatcherRef: string;
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 => {
return <div className="mx_ThreadPanel__header">
<span>{ _t("Thread") }</span>
@ -240,18 +266,32 @@ export default class ThreadView extends React.Component<IProps, IState> {
return timelineWindow.paginate(direction, limit);
};
public render(): JSX.Element {
const highlightedEventId = this.props.isInitialEventHighlighted
? this.props.initialEvent?.getId()
: null;
private onFileDrop = (dataTransfer: DataTransfer) => {
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(dataTransfer.files),
this.props.mxEvent.getRoomId(),
this.threadRelation,
MatrixClientPeg.get(),
TimelineRenderingType.Thread,
);
};
const threadRelation: IEventRelation = {
private get threadRelation(): IEventRelation {
return {
"rel_type": RelationType.Thread,
"event_id": this.state.thread?.id,
"m.in_reply_to": {
"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(
"mx_RoomView_messagePanel",
@ -272,8 +312,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
onClose={this.props.onClose}
withoutScrollContainer={true}
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
ref={this.timelinePanelRef}
showReadReceipts={false} // Hide the read receipts
@ -297,7 +340,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
onUserScroll={this.onScroll}
onPaginationRequest={this.onPaginationRequest}
/>
) }
</div> }
{ ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />

View file

@ -43,6 +43,7 @@ import TagOrderActions from "../../../actions/TagOrderActions";
import { inviteUsersToRoom } from "../../../RoomInvite";
import ProgressBar from "../elements/ProgressBar";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { CreateEventField, IGroupRoom, IGroupSummary } from "../../../@types/groups";
interface IProps {
matrixClient: MatrixClient;
@ -50,50 +51,6 @@ interface IProps {
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 {
NotStarted,
ValidatingInputs,

View file

@ -17,11 +17,8 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import escapeHtml from "escape-html";
import sanitizeHtml from "sanitize-html";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
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 { _t } from '../../../languageHandler';
@ -32,12 +29,12 @@ import { Layout } from "../../../settings/enums/Layout";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { Action } from "../../../dispatcher/actions";
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill';
import { ButtonEvent } from './AccessibleButton';
import { getParentEventId } from '../../../utils/Reply';
/**
* 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());
}
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() {
this.initialize();
this.trySetExpandableQuotes();
@ -296,7 +125,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
private async initialize(): Promise<void> {
const { parentEv } = this.props;
// 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;
@ -314,7 +143,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
try {
const inReplyToEventId = ReplyChain.getParentEventId(ev);
const inReplyToEventId = getParentEventId(ev);
return await this.getEvent(inReplyToEventId);
} catch (e) {
return null;
@ -399,7 +228,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
}
</blockquote>;
} else if (this.props.forExport) {
const eventId = ReplyChain.getParentEventId(this.props.parentEv);
const eventId = getParentEventId(this.props.parentEv);
header = <p className="mx_ReplyChain_Export">
{ _t("In reply to <a>this message</a>",
{},

View file

@ -42,6 +42,7 @@ import ReplyChain from '../elements/ReplyChain';
import ReactionPicker from "../emojipicker/ReactionPicker";
import { CardContext } from '../right_panel/BaseCard';
import { showThread } from "../../../dispatcher/dispatch-actions/threads";
import { shouldDisplayReply } from '../../../utils/Reply';
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
@ -375,7 +376,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
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({
'mx_MessageActionBar_maskButton': true,
'mx_MessageActionBar_expandMessageButton': !this.props.isQuoteExpanded,

View file

@ -28,7 +28,6 @@ import { _t } from '../../../languageHandler';
import * as ContextMenu from '../../structures/ContextMenu';
import { ChevronFace, toRightOf } from '../../structures/ContextMenu';
import SettingsStore from "../../../settings/SettingsStore";
import ReplyChain from "../elements/ReplyChain";
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
@ -48,6 +47,7 @@ import { IBodyProps } from "./IBodyProps";
import RoomContext from "../../../contexts/RoomContext";
import AccessibleButton from '../elements/AccessibleButton';
import { options as linkifyOpts } from "../../../linkify-matrix";
import { getParentEventId } from '../../../utils/Reply';
const MAX_HIGHLIGHT_LENGTH = 4096;
@ -557,7 +557,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
let isEmote = false;
// 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;
if (SettingsStore.isEnabled("feature_extensible_events")) {
const extev = this.props.mxEvent.unstableExtensibleEvent as MessageEvent;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from 'react';
import React, { forwardRef, ReactNode, KeyboardEvent, Ref } from 'react';
import classNames from 'classnames';
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
@ -32,7 +32,9 @@ interface IProps {
closeLabel?: string;
onClose?(ev: ButtonEvent): void;
onBack?(ev: ButtonEvent): void;
onKeyDown?(ev: KeyboardEvent): void;
cardState?: any;
ref?: Ref<HTMLDivElement>;
}
interface IGroupProps {
@ -47,7 +49,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
</div>;
};
const BaseCard: React.FC<IProps> = ({
const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(({
closeLabel,
onClose,
onBack,
@ -56,7 +58,8 @@ const BaseCard: React.FC<IProps> = ({
footer,
withoutScrollContainer,
children,
}) => {
onKeyDown,
}, ref) => {
let backButton;
const cardHistory = RightPanelStore.instance.roomPhaseHistory;
if (cardHistory.length > 1) {
@ -87,7 +90,7 @@ const BaseCard: React.FC<IProps> = ({
return (
<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">
{ backButton }
{ closeButton }
@ -98,6 +101,6 @@ const BaseCard: React.FC<IProps> = ({
</div>
</CardContext.Provider>
);
};
});
export default BaseCard;

View file

@ -78,6 +78,7 @@ import { copyPlaintext } from '../../../utils/strings';
import { DecryptionFailureTracker } from '../../../DecryptionFailureTracker';
import RedactedBody from '../messages/RedactedBody';
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { shouldDisplayReply } from '../../../utils/Reply';
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
: undefined;
const replyChain = haveTileForEvent(this.props.mxEvent)
&& ReplyChain.shouldDisplayReply(this.props.mxEvent, renderTarget)
const replyChain = haveTileForEvent(this.props.mxEvent) && shouldDisplayReply(this.props.mxEvent, renderTarget)
? <ReplyChain
parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged}

View file

@ -17,7 +17,7 @@ limitations under the License.
import classNames from 'classnames';
import { IEventRelation } from "matrix-js-sdk/src/models/event";
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 { MatrixClient } from 'matrix-js-sdk/src/client';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
@ -33,10 +33,10 @@ import LocationButton from '../location/LocationButton';
import Modal from "../../../Modal";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { ActionPayload } from '../../../dispatcher/payloads';
import ContentMessages from '../../../ContentMessages';
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import RoomContext from '../../../contexts/RoomContext';
import { useDispatcher } from "../../../hooks/useDispatcher";
interface IProps {
addEmoji: (emoji: string) => boolean;
@ -189,50 +189,37 @@ interface IUploadButtonProps {
relation?: IEventRelation | null;
}
class UploadButton extends React.Component<IUploadButtonProps> {
private uploadInput = React.createRef<HTMLInputElement>();
private dispatcherRef: string;
const UploadButton = ({ roomId, relation }: IUploadButtonProps) => {
const cli = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const overflowMenuCloser = useContext(OverflowMenuContext);
const uploadInput = useRef<HTMLInputElement>();
constructor(props: IUploadButtonProps) {
super(props);
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()) {
const onUploadClick = () => {
if (cli.isGuest()) {
dis.dispatch({ action: 'require_registration' });
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;
// 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]);
}
// Take a copy, so we can safely reset the value of the form control
ContentMessages.sharedInstance().sendContentListToRoom(
tfiles,
this.props.roomId,
this.props.relation,
MatrixClientPeg.get(),
this.context.timelineRenderingType,
Array.from(ev.target.files),
roomId,
relation,
cli,
roomContext.timelineRenderingType,
);
// 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 = '';
};
render() {
const uploadInputStyle = { display: 'none' };
return <>
<CollapsibleButton
className="mx_MessageComposer_button mx_MessageComposer_upload"
onClick={this.onUploadClick}
title={_t('Attachment')}
/>
<input
ref={this.uploadInput}
type="file"
style={uploadInputStyle}
multiple
onChange={this.onUploadFileInputChange}
/>
</>;
}
}
const uploadInputStyle = { display: 'none' };
return <>
<CollapsibleButton
className="mx_MessageComposer_button mx_MessageComposer_upload"
onClick={onUploadClick}
title={_t('Attachment')}
/>
<input
ref={uploadInput}
type="file"
style={uploadInputStyle}
multiple
onChange={onUploadFileInputChange}
/>
</>;
};
function showStickersButton(props: IProps): ReactElement {
return (

View file

@ -36,7 +36,6 @@ import {
} from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import ReplyChain from "../elements/ReplyChain";
import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories } from '../../../SlashCommands';
@ -58,6 +57,7 @@ import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { getNestedReplyText, getRenderInMixin, makeReplyMixIn } from '../../../utils/Reply';
interface IAddReplyOpts {
permalinkCreator?: RoomPermalinkCreator;
@ -72,13 +72,13 @@ function addReplyToMessageContent(
includeLegacyFallback: true,
},
): void {
const replyContent = ReplyChain.makeReplyMixIn(replyToEvent, opts.renderIn);
const replyContent = makeReplyMixIn(replyToEvent, opts.renderIn);
Object.assign(content, replyContent);
if (opts.includeLegacyFallback) {
// Part of Replies fallback support - prepend the text we're sending
// with the text we're replying to
const nestedReply = ReplyChain.getNestedReplyText(replyToEvent, opts.permalinkCreator);
const nestedReply = getNestedReplyText(replyToEvent, opts.permalinkCreator);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
@ -132,7 +132,7 @@ export function createMessageContent(
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
includeLegacyFallback: true,
renderIn: ReplyChain.getRenderInMixin(relation),
renderIn: getRenderInMixin(relation),
});
}
@ -384,7 +384,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator: this.props.permalinkCreator,
includeLegacyFallback: true,
renderIn: ReplyChain.getRenderInMixin(this.props.relation),
renderIn: getRenderInMixin(this.props.relation),
});
}
} else {

View file

@ -32,13 +32,13 @@ import dis from "../../../../../dispatcher/dispatcher";
import GroupActions from "../../../../../actions/GroupActions";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { CreateEventField, IGroupSummary } from "../../../dialogs/CreateSpaceFromCommunityDialog";
import { createSpaceFromCommunity } from "../../../../../utils/space";
import Spinner from "../../../elements/Spinner";
import { UserTab } from "../../../dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../../../../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../../../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayload";
import { CreateEventField, IGroupSummary } from '../../../../../@types/groups';
interface IProps {
closeSettingsFn(success: boolean): void;

View file

@ -35,7 +35,6 @@ const RoomContext = createContext<IRoomState>({
shouldPeek: true,
membersLoaded: false,
numUnreadMessages: 0,
draggingFile: false,
searching: false,
guestsCanJoin: false,
canPeek: false,
@ -62,7 +61,6 @@ const RoomContext = createContext<IRoomState>({
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: false,
dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
threadId: undefined,
liveTimeline: undefined,

View file

@ -2985,6 +2985,7 @@
"Pause": "Pause",
"Play": "Play",
"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 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",
@ -3127,7 +3128,6 @@
"Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(",
"No more results": "No more results",
"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.|one": "You have %(count)s unread notification in a prior version of this room.",
"Joining": "Joining",

View file

@ -23,7 +23,7 @@ import Analytics from '../Analytics';
import * as RoomNotifs from "../RoomNotifs";
import { MatrixClientPeg } from '../MatrixClientPeg';
import SettingsStore from "../settings/SettingsStore";
import { CreateEventField } from "../components/views/dialogs/CreateSpaceFromCommunityDialog";
import { CreateEventField } from "../@types/groups";
const INITIAL_STATE = {
orderedTags: null,

View file

@ -20,8 +20,8 @@ import { IPreview } from "./IPreview";
import { TagID } from "../models";
import { _t, sanitizeForTranslation } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import ReplyChain from "../../../components/views/elements/ReplyChain";
import { getHtmlText } from "../../../HtmlUtils";
import { stripHTMLReply, stripPlainReply } from "../../../utils/Reply";
export class MessageEventPreview implements IPreview {
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.
const mRelatesTo = event.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
if (event.getWireContent()['m.relates_to']?.['m.in_reply_to']) {
// If this is a reply, get the real reply and use that
if (hasHtml) {
body = (ReplyChain.stripHTMLReply(body) || '').trim();
body = (stripHTMLReply(body) || '').trim();
} else {
body = (ReplyChain.stripPlainReply(body) || '').trim();
body = (stripPlainReply(body) || '').trim();
}
if (!body) return null; // invalid event, no preview
}

191
src/utils/Reply.ts Normal file
View 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];
}
}

View file

@ -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 * as testUtils from '../../../test-utils';
import ReplyChain from '../../../../src/components/views/elements/ReplyChain';
import { getParentEventId } from "../../../../src/utils/Reply";
describe("ReplyChain", () => {
describe('getParentEventId', () => {
@ -21,7 +37,7 @@ describe("ReplyChain", () => {
room: "room_id",
});
expect(ReplyChain.getParentEventId(originalEventWithRelation))
expect(getParentEventId(originalEventWithRelation))
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
});
@ -65,7 +81,7 @@ describe("ReplyChain", () => {
originalEventWithRelation.makeReplaced(editEvent);
// The relation should be pulled from the original event
expect(ReplyChain.getParentEventId(originalEventWithRelation))
expect(getParentEventId(originalEventWithRelation))
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
});
@ -109,7 +125,7 @@ describe("ReplyChain", () => {
originalEvent.makeReplaced(editEvent);
// The relation should be pulled from the edit event
expect(ReplyChain.getParentEventId(originalEvent))
expect(getParentEventId(originalEvent))
.toStrictEqual('$qkjmFBTEc0VvfVyzq1CJuh1QZi_xDIgNEFjZ4Pq34og');
});
@ -158,7 +174,7 @@ describe("ReplyChain", () => {
originalEventWithRelation.makeReplaced(editEvent);
// 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', () => {
@ -203,7 +219,7 @@ describe("ReplyChain", () => {
originalEventWithRelation.makeReplaced(editEvent);
// The relation should be pulled from the edit event
expect(ReplyChain.getParentEventId(originalEventWithRelation)).toStrictEqual(undefined);
expect(getParentEventId(originalEventWithRelation)).toStrictEqual(undefined);
});
});
});

View file

@ -148,7 +148,6 @@ function createRoomState(room: Room): IRoomState {
shouldPeek: true,
membersLoaded: false,
numUnreadMessages: 0,
draggingFile: false,
searching: false,
guestsCanJoin: false,
canPeek: false,
@ -175,7 +174,6 @@ function createRoomState(room: Room): IRoomState {
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: false,
dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
};

View file

@ -45,7 +45,6 @@ describe('<SendMessageComposer/>', () => {
shouldPeek: true,
membersLoaded: false,
numUnreadMessages: 0,
draggingFile: false,
searching: false,
guestsCanJoin: false,
canPeek: false,
@ -72,7 +71,6 @@ describe('<SendMessageComposer/>', () => {
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: false,
dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
};