diff --git a/res/css/_components.scss b/res/css/_components.scss index 8f27c1c4ca..aaa12ca083 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/structures/_FileDropTarget.scss b/res/css/structures/_FileDropTarget.scss new file mode 100644 index 0000000000..b5963385bf --- /dev/null +++ b/res/css/structures/_FileDropTarget.scss @@ -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; +} diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index bb4622b7da..a6b2970ddf 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -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%; diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index 31ddbcafda..119a906e30 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -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 { diff --git a/src/@types/groups.ts b/src/@types/groups.ts new file mode 100644 index 0000000000..d3a7455e2f --- /dev/null +++ b/src/@types/groups.ts @@ -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; + total_room_count_estimate: number; + }; + user: { + is_privileged: boolean; + is_public: boolean; + is_publicised: boolean; + membership: string; + }; + users_section: { + users: unknown[]; + roles: Record; + total_user_count_estimate: number; + }; +} +/* eslint-enable camelcase */ diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index caaaf7e9a1..364cd2040c 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -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 { @@ -566,7 +566,7 @@ export default class ContentMessages { private sendContentToRoom( file: File, roomId: string, - relation: IEventRelation, + relation: IEventRelation | undefined, matrixClient: MatrixClient, promBefore: Promise, ) { diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 3d1289d198..2f574351c9 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -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); diff --git a/src/components/structures/FileDropTarget.tsx b/src/components/structures/FileDropTarget.tsx new file mode 100644 index 0000000000..6032de6e58 --- /dev/null +++ b/src/components/structures/FileDropTarget.tsx @@ -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 = ({ parent, onFileDrop }) => { + const [state, setState] = useState({ + 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
+ + { _t("Drop file here to upload") } +
; + } + + return null; +}; + +export default FileDropTarget; diff --git a/src/components/structures/LegacyCommunityPreview.tsx b/src/components/structures/LegacyCommunityPreview.tsx index 32bafeb3fa..7d7f1b90fb 100644 --- a/src/components/structures/LegacyCommunityPreview.tsx +++ b/src/components/structures/LegacyCommunityPreview.tsx @@ -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; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index eef11174c4..b02d1d0b33 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -396,8 +396,7 @@ class LoggedInView extends React.Component { 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, diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 73d0dad0e0..b49b02d7b0 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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 { shouldPeek: true, membersLoaded: !llMembers, numUnreadMessages: 0, - draggingFile: false, searching: false, searchResults: null, callState: null, @@ -272,7 +269,6 @@ export class RoomView extends React.Component { 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 { } 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 { // 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 { 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 { 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 { }); } + 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 { } } - let fileDropTarget = null; - if (this.state.draggingFile) { - fileDropTarget = ( -
- - { _t("Drop file here to upload") } -
- ); - } - // 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 { let mainSplitBody = { auxPanel }
- { fileDropTarget } + { topUnreadMessagesBar } { jumpToBottom } { messagePanel } diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index e5faebb1b8..3ff5d7371c 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -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; diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index c26c64428c..f66842792f 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -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 { static contextType = RoomContext; + public context!: React.ContextType; private dispatcherRef: string; - private timelinePanelRef: React.RefObject = React.createRef(); + private timelinePanelRef = createRef(); + private cardRef = createRef(); private readonly layoutWatcherRef: string; constructor(props: IProps) { @@ -206,6 +211,27 @@ export default class ThreadView extends React.Component { } }; + 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
{ _t("Thread") } @@ -240,18 +266,32 @@ export default class ThreadView extends React.Component { 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 { onClose={this.props.onClose} withoutScrollContainer={true} header={this.renderThreadViewHeader()} + ref={this.cardRef} + onKeyDown={this.onKeyDown} > - { this.state.thread && ( + { this.state.thread &&
+ { onUserScroll={this.onScroll} onPaginationRequest={this.onPaginationRequest} /> - ) } +
} { ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && ( diff --git a/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx index cefffe888a..b02166a3f3 100644 --- a/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx +++ b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx @@ -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; - total_room_count_estimate: number; - }; - user: { - is_privileged: boolean; - is_public: boolean; - is_publicised: boolean; - membership: string; - }; - users_section: { - users: unknown[]; - roles: Record; - total_user_count_estimate: number; - }; -} -/* eslint-enable camelcase */ - enum Progress { NotStarted, ValidatingInputs, diff --git a/src/components/views/elements/ReplyChain.tsx b/src/components/views/elements/ReplyChain.tsx index 21918d8969..b4f2606d59 100644 --- a/src/components/views/elements/ReplyChain.tsx +++ b/src/components/views/elements/ReplyChain.tsx @@ -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 { 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 . 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 - 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, '
'); - } - - // 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 = `
In reply to ${mxid}` - + `
${html}
`; - 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 = `
In reply to ${mxid}` - + `
sent an image.
`; - body = `> <${mxid}> sent an image.\n\n`; - break; - case 'm.video': - html = `
In reply to ${mxid}` - + `
sent a video.
`; - body = `> <${mxid}> sent a video.\n\n`; - break; - case 'm.audio': - html = `
In reply to ${mxid}` - + `
sent an audio file.
`; - body = `> <${mxid}> sent an audio file.\n\n`; - break; - case 'm.file': - html = `
In reply to ${mxid}` - + `
sent a file.
`; - body = `> <${mxid}> sent a file.\n\n`; - break; - case 'm.emote': { - html = `
In reply to * ` - + `${mxid}
${html}
`; - 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 { private async initialize(): Promise { 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 { private async getNextEvent(ev: MatrixEvent): Promise { 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 { } ; } else if (this.props.forExport) { - const eventId = ReplyChain.getParentEventId(this.props.parentEv); + const eventId = getParentEventId(this.props.parentEv); header =

{ _t("In reply to this message", {}, diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 83ba6acf98..b842664d1e 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -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 { 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; diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx index f45e82e952..3d76f72b3f 100644 --- a/src/components/views/right_panel/BaseCard.tsx +++ b/src/components/views/right_panel/BaseCard.tsx @@ -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; } interface IGroupProps { @@ -47,7 +49,7 @@ export const Group: React.FC = ({ className, title, children }) =>

; }; -const BaseCard: React.FC = ({ +const BaseCard: React.FC = forwardRef(({ closeLabel, onClose, onBack, @@ -56,7 +58,8 @@ const BaseCard: React.FC = ({ footer, withoutScrollContainer, children, -}) => { + onKeyDown, +}, ref) => { let backButton; const cardHistory = RightPanelStore.instance.roomPhaseHistory; if (cardHistory.length > 1) { @@ -87,7 +90,7 @@ const BaseCard: React.FC = ({ return ( -
+
{ backButton } { closeButton } @@ -98,6 +101,6 @@ const BaseCard: React.FC = ({
); -}; +}); export default BaseCard; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 13218d1297..f58140636c 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -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 { ? 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) ? boolean; @@ -189,50 +189,37 @@ interface IUploadButtonProps { relation?: IEventRelation | null; } -class UploadButton extends React.Component { - private uploadInput = React.createRef(); - private dispatcherRef: string; +const UploadButton = ({ roomId, relation }: IUploadButtonProps) => { + const cli = useContext(MatrixClientContext); + const roomContext = useContext(RoomContext); + const overflowMenuCloser = useContext(OverflowMenuContext); + const uploadInput = useRef(); - 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) => { + useDispatcher(dis, payload => { + if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") { + onUploadClick(); + } + }); + + const onUploadFileInputChange = (ev: React.ChangeEvent) => { 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 { ev.target.value = ''; }; - render() { - const uploadInputStyle = { display: 'none' }; - return <> - - - ; - } -} + const uploadInputStyle = { display: 'none' }; + return <> + + + ; +}; function showStickersButton(props: IProps): ReactElement { return ( diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 15854e5340..65b2b70f68 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -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({ shouldPeek: true, membersLoaded: false, numUnreadMessages: 0, - draggingFile: false, searching: false, guestsCanJoin: false, canPeek: false, @@ -62,7 +61,6 @@ const RoomContext = createContext({ showAvatarChanges: true, showDisplaynameChanges: true, matrixClientIsReady: false, - dragCounter: 0, timelineRenderingType: TimelineRenderingType.Room, threadId: undefined, liveTimeline: undefined, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 49c6f30bb7..f26df700ed 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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 register to use this functionality": "You must register 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", diff --git a/src/stores/GroupFilterOrderStore.js b/src/stores/GroupFilterOrderStore.js index 42c5a91f16..51b0ebee63 100644 --- a/src/stores/GroupFilterOrderStore.js +++ b/src/stores/GroupFilterOrderStore.js @@ -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, diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts index c1c11a5c6f..4212f421e0 100644 --- a/src/stores/room-list/previews/MessageEventPreview.ts +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -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 } diff --git a/src/utils/Reply.ts b/src/utils/Reply.ts new file mode 100644 index 0000000000..0eccc356df --- /dev/null +++ b/src/utils/Reply.ts @@ -0,0 +1,191 @@ +/* +Copyright 2021 Šimon Brandner + +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 . 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 + 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, '
'); + } + + // 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 = `
In reply to ${mxid}` + + `
${html}
`; + 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 = `
In reply to ${mxid}` + + `
sent an image.
`; + body = `> <${mxid}> sent an image.\n\n`; + break; + case 'm.video': + html = `
In reply to ${mxid}` + + `
sent a video.
`; + body = `> <${mxid}> sent a video.\n\n`; + break; + case 'm.audio': + html = `
In reply to ${mxid}` + + `
sent an audio file.
`; + body = `> <${mxid}> sent an audio file.\n\n`; + break; + case 'm.file': + html = `
In reply to ${mxid}` + + `
sent a file.
`; + body = `> <${mxid}> sent a file.\n\n`; + break; + case 'm.emote': { + html = `
In reply to * ` + + `${mxid}
${html}
`; + 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]; + } +} diff --git a/test/components/views/elements/ReplyChain-test.js b/test/components/views/elements/ReplyChain-test.js index 35344929e4..64784835e1 100644 --- a/test/components/views/elements/ReplyChain-test.js +++ b/test/components/views/elements/ReplyChain-test.js @@ -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); }); }); }); diff --git a/test/components/views/rooms/MessageComposerButtons-test.tsx b/test/components/views/rooms/MessageComposerButtons-test.tsx index 78bf64496e..7eba700422 100644 --- a/test/components/views/rooms/MessageComposerButtons-test.tsx +++ b/test/components/views/rooms/MessageComposerButtons-test.tsx @@ -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, }; diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 6b87c14f5c..52d6743d0f 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -45,7 +45,6 @@ describe('', () => { shouldPeek: true, membersLoaded: false, numUnreadMessages: 0, - draggingFile: false, searching: false, guestsCanJoin: false, canPeek: false, @@ -72,7 +71,6 @@ describe('', () => { showAvatarChanges: true, showDisplaynameChanges: true, matrixClientIsReady: false, - dragCounter: 0, timelineRenderingType: TimelineRenderingType.Room, liveTimeline: undefined, };