diff --git a/CHANGELOG.md b/CHANGELOG.md index d459b4e94a..58d23e3413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,115 @@ +Changes in [3.20.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0) (2021-05-10) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0-rc.1...v3.20.0) + + * Upgrade to JS SDK 10.1.0 + * [Release] Don't use the event's metadata to calc the scale of an image + [\#6004](https://github.com/matrix-org/matrix-react-sdk/pull/6004) + +Changes in [3.20.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0-rc.1) (2021-05-04) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0...v3.20.0-rc.1) + + * Upgrade to JS SDK 10.1.0-rc.1 + * Translations update from Weblate + [\#5966](https://github.com/matrix-org/matrix-react-sdk/pull/5966) + * Fix more space panel layout and hover behaviour issues + [\#5965](https://github.com/matrix-org/matrix-react-sdk/pull/5965) + * Fix edge case with space panel alignment with subspaces on ff + [\#5964](https://github.com/matrix-org/matrix-react-sdk/pull/5964) + * Fix saving room pill part to history + [\#5951](https://github.com/matrix-org/matrix-react-sdk/pull/5951) + * Generate room preview even when minimized + [\#5948](https://github.com/matrix-org/matrix-react-sdk/pull/5948) + * Another change from recovery passphrase to Security Phrase + [\#5934](https://github.com/matrix-org/matrix-react-sdk/pull/5934) + * Sort rooms in the add existing to space dialog based on recency + [\#5943](https://github.com/matrix-org/matrix-react-sdk/pull/5943) + * Inhibit sending RR when context switching to a room + [\#5944](https://github.com/matrix-org/matrix-react-sdk/pull/5944) + * Prevent room list keyboard handling from landing focus on hidden nodes + [\#5950](https://github.com/matrix-org/matrix-react-sdk/pull/5950) + * Make the text filter search all spaces instead of just the selected one + [\#5942](https://github.com/matrix-org/matrix-react-sdk/pull/5942) + * Enable indent rule and fix indent + [\#5931](https://github.com/matrix-org/matrix-react-sdk/pull/5931) + * Prevent peeking members from reacting + [\#5946](https://github.com/matrix-org/matrix-react-sdk/pull/5946) + * Disallow inline display maths + [\#5939](https://github.com/matrix-org/matrix-react-sdk/pull/5939) + * Space creation prompt user to add existing rooms for "Just Me" spaces + [\#5923](https://github.com/matrix-org/matrix-react-sdk/pull/5923) + * Add test coverage collection script + [\#5937](https://github.com/matrix-org/matrix-react-sdk/pull/5937) + * Fix joining room using via servers regression + [\#5936](https://github.com/matrix-org/matrix-react-sdk/pull/5936) + * Revert "Fixes the two Todays problem in Redaction" + [\#5938](https://github.com/matrix-org/matrix-react-sdk/pull/5938) + * Handle encoded matrix URLs + [\#5903](https://github.com/matrix-org/matrix-react-sdk/pull/5903) + * Render ignored users setting regardless of if there are any + [\#5860](https://github.com/matrix-org/matrix-react-sdk/pull/5860) + * Fix inserting trailing colon after mention/pill + [\#5830](https://github.com/matrix-org/matrix-react-sdk/pull/5830) + * Fixes the two Todays problem in Redaction + [\#5917](https://github.com/matrix-org/matrix-react-sdk/pull/5917) + * Fix page up/down scrolling only half a page + [\#5920](https://github.com/matrix-org/matrix-react-sdk/pull/5920) + * Voice messages: Composer controls + [\#5935](https://github.com/matrix-org/matrix-react-sdk/pull/5935) + * Support MSC3086 asserted identity + [\#5886](https://github.com/matrix-org/matrix-react-sdk/pull/5886) + * Handle possible edge case with getting stuck in "unsent messages" bar + [\#5930](https://github.com/matrix-org/matrix-react-sdk/pull/5930) + * Fix suggested rooms not showing up regression from room list optimisation + [\#5932](https://github.com/matrix-org/matrix-react-sdk/pull/5932) + * Broadcast language change to ElectronPlatform + [\#5913](https://github.com/matrix-org/matrix-react-sdk/pull/5913) + * Fix VoIP PIP frame color + [\#5701](https://github.com/matrix-org/matrix-react-sdk/pull/5701) + * Convert some Flow-typed files to TypeScript + [\#5912](https://github.com/matrix-org/matrix-react-sdk/pull/5912) + * Initial SpaceStore tests work + [\#5906](https://github.com/matrix-org/matrix-react-sdk/pull/5906) + * Fix issues with space hierarchy in layout and with incompatible servers + [\#5926](https://github.com/matrix-org/matrix-react-sdk/pull/5926) + * Scale all mxc thumbs using device pixel ratio for hidpi + [\#5928](https://github.com/matrix-org/matrix-react-sdk/pull/5928) + * Fix add existing to space dialog no longer showing rooms for public spaces + [\#5918](https://github.com/matrix-org/matrix-react-sdk/pull/5918) + * Disable spaces context switching for when exploring a space + [\#5924](https://github.com/matrix-org/matrix-react-sdk/pull/5924) + * Autofocus search box in the add existing to space dialog + [\#5921](https://github.com/matrix-org/matrix-react-sdk/pull/5921) + * Use label element in add existing to space dialog for easier hit target + [\#5922](https://github.com/matrix-org/matrix-react-sdk/pull/5922) + * Dynamic max and min zoom in the new ImageView + [\#5916](https://github.com/matrix-org/matrix-react-sdk/pull/5916) + * Improve message error states + [\#5897](https://github.com/matrix-org/matrix-react-sdk/pull/5897) + * Check for null room in `VisibilityProvider` + [\#5914](https://github.com/matrix-org/matrix-react-sdk/pull/5914) + * Add unit tests for various collection-based utility functions + [\#5910](https://github.com/matrix-org/matrix-react-sdk/pull/5910) + * Spaces visual fixes + [\#5909](https://github.com/matrix-org/matrix-react-sdk/pull/5909) + * Remove reliance on DOM API to generated message preview + [\#5908](https://github.com/matrix-org/matrix-react-sdk/pull/5908) + * Expand upon voice message event & include overall waveform + [\#5888](https://github.com/matrix-org/matrix-react-sdk/pull/5888) + * Use floats for image background opacity + [\#5905](https://github.com/matrix-org/matrix-react-sdk/pull/5905) + * Show invites to spaces at the top of the space panel + [\#5902](https://github.com/matrix-org/matrix-react-sdk/pull/5902) + * Improve edge cases with spaces context switching + [\#5899](https://github.com/matrix-org/matrix-react-sdk/pull/5899) + * Fix spaces notification dots wrongly including upgraded (hidden) rooms + [\#5900](https://github.com/matrix-org/matrix-react-sdk/pull/5900) + * Iterate the spaces face pile design + [\#5898](https://github.com/matrix-org/matrix-react-sdk/pull/5898) + * Fix alignment issue with nested spaces being cut off wrong + [\#5890](https://github.com/matrix-org/matrix-react-sdk/pull/5890) + Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0) diff --git a/package.json b/package.json index be195e2e9e..342591081f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.19.0", + "version": "3.20.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -97,7 +97,7 @@ "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", - "sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", + "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 555ee38d17..00f2c4c826 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -545,11 +545,13 @@ export default class MessagePanel extends React.Component { } if (!grouper) { const wantTile = this._shouldShowEvent(mxEv); + const isGrouped = false; if (wantTile) { // make sure we unpack the array returned by _getTilesForEvent, // otherwise react will auto-generate keys and we will end up // replacing all of the DOM elements every time we paginate. - ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile)); + ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped, + nextEvent, nextTile)); prevEvent = mxEv; } @@ -570,7 +572,7 @@ export default class MessagePanel extends React.Component { _${mxEv.getId()}`) !== null; } - _getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) { + _getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) { const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); const EventTile = sdk.getComponent('rooms.EventTile'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); @@ -593,7 +595,7 @@ export default class MessagePanel extends React.Component { // do we need a date separator since the last event? const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator) { + if (wantsDateSeparator && !isGrouped) { const dateSeparator =
  • ; ret.push(dateSeparator); } @@ -977,9 +979,9 @@ class CreationGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - const panel = this.panel; const ret = []; + const isGrouped = true; const createEvent = this.createEvent; const lastShownEvent = this.lastShownEvent; @@ -993,12 +995,12 @@ class CreationGrouper { // If this m.room.create event should be shown (room upgrade) then show it before the summary if (panel._shouldShowEvent(createEvent)) { // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered - ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); + ret.push(...panel._getTilesForEvent(createEvent, createEvent)); } for (const ejected of this.ejectedEvents) { ret.push(...panel._getTilesForEvent( - createEvent, ejected, createEvent === lastShownEvent, + createEvent, ejected, createEvent === lastShownEvent, isGrouped, )); } @@ -1007,7 +1009,7 @@ class CreationGrouper { // of EventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one const ev = this.events[this.events.length - 1]; @@ -1092,7 +1094,7 @@ class RedactionGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - + const isGrouped = true; const panel = this.panel; const ret = []; const lastShownEvent = this.lastShownEvent; @@ -1112,7 +1114,8 @@ class RedactionGrouper { let eventTiles = this.events.map((e, i) => { senders.add(e.sender); const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile); + return panel._getTilesForEvent( + prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1191,7 +1194,7 @@ class MemberGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); - + const isGrouped = true; const panel = this.panel; const lastShownEvent = this.lastShownEvent; const ret = []; @@ -1224,7 +1227,7 @@ class MemberGrouper { // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 34682877e0..bda46aef07 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -27,8 +27,8 @@ import { Action } from "../../dispatcher/actions"; import RoomListStore from "../../stores/room-list/RoomListStore"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; -import {replaceableComponent} from "../../utils/replaceableComponent"; -import SpaceStore, {UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../stores/SpaceStore"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore"; interface IProps { isMinimized: boolean; diff --git a/src/components/views/dialogs/UploadConfirmDialog.js b/src/components/views/dialogs/UploadConfirmDialog.tsx similarity index 72% rename from src/components/views/dialogs/UploadConfirmDialog.js rename to src/components/views/dialogs/UploadConfirmDialog.tsx index 2ff16b9440..7f6bcd27d1 100644 --- a/src/components/views/dialogs/UploadConfirmDialog.js +++ b/src/components/views/dialogs/UploadConfirmDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,20 +16,23 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import filesize from "filesize"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getBlobSafeMimeType } from '../../../utils/blobs'; + +interface IProps { + file: File; + currentIndex: number; + totalFiles?: number; + onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void; +} @replaceableComponent("views.dialogs.UploadConfirmDialog") -export default class UploadConfirmDialog extends React.Component { - static propTypes = { - file: PropTypes.object.isRequired, - currentIndex: PropTypes.number, - totalFiles: PropTypes.number, - onFinished: PropTypes.func.isRequired, - } +export default class UploadConfirmDialog extends React.Component { + private objectUrl: string; + private mimeType: string; static defaultProps = { totalFiles: 1, @@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component { constructor(props) { super(props); - this._objectUrl = URL.createObjectURL(props.file); + // Create a fresh `Blob` for previewing (even though `File` already is + // one) so we can adjust the MIME type if needed. + this.mimeType = getBlobSafeMimeType(props.file.type); + const blob = new Blob([props.file], { type: + this.mimeType, + }); + this.objectUrl = URL.createObjectURL(blob); } componentWillUnmount() { - if (this._objectUrl) URL.revokeObjectURL(this._objectUrl); + if (this.objectUrl) URL.revokeObjectURL(this.objectUrl); } - _onCancelClick = () => { + private onCancelClick = () => { this.props.onFinished(false); } - _onUploadClick = () => { + private onUploadClick = () => { this.props.onFinished(true); } - _onUploadAllClick = () => { + private onUploadAllClick = () => { this.props.onFinished(true, true); } @@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component { } let preview; - if (this.props.file.type.startsWith('image/')) { + if (this.mimeType.startsWith('image/')) { preview =
    -
    +
    {this.props.file.name} ({filesize(this.props.file.size)})
    ; @@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component { let uploadAllButton; if (this.props.currentIndex + 1 < this.props.totalFiles) { - uploadAllButton = ; } @@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component { return ( @@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component { {uploadAllButton} diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index 05d487a9eb..f736736acc 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -108,8 +108,6 @@ export default class ImageView extends React.Component { window.addEventListener("resize", this.calculateZoom); // After the image loads for the first time we want to calculate the zoom this.image.current.addEventListener("load", this.calculateZoom); - // Try to precalculate the zoom from width and height props - this.calculateZoom(); } componentWillUnmount() { @@ -122,11 +120,8 @@ export default class ImageView extends React.Component { const image = this.image.current; const imageWrapper = this.imageWrapper.current; - const width = this.props.width || image.naturalWidth; - const height = this.props.height || image.naturalHeight; - - const zoomX = imageWrapper.clientWidth / width; - const zoomY = imageWrapper.clientHeight / height; + const zoomX = imageWrapper.clientWidth / image.naturalWidth; + const zoomY = imageWrapper.clientHeight / image.naturalHeight; // If the image is smaller in both dimensions set its the zoom to 1 to // display it in its original size diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 2fa5fd89d3..faf8860cad 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -187,9 +187,15 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) { verifyDevice(cli.getUser(userId), device); }; - const deviceName = device.ambiguous ? - (device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" : - device.getDisplayName(); + let deviceName; + if (!device.getDisplayName()?.trim()) { + deviceName = device.deviceId; + } else { + deviceName = device.ambiguous ? + device.getDisplayName() + " (" + device.deviceId + ")" : + device.getDisplayName(); + } + let trustedLabel = null; if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted"); diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 46999bb793..c607b7d2c3 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -16,7 +16,7 @@ limitations under the License. */ import React from 'react'; import * as sdk from '../../../index'; -import {_t} from '../../../languageHandler'; +import {_t, _td} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher/dispatcher'; import EditorModel from '../../../editor/model'; @@ -24,17 +24,19 @@ import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; import {findEditableEvent} from '../../../utils/EventUtils'; import {parseEvent} from '../../../editor/deserialize'; -import {PartCreator} from '../../../editor/parts'; +import {CommandPartCreator} from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import classNames from 'classnames'; import {EventStatus} from 'matrix-js-sdk/src/models/event'; import BasicMessageComposer from "./BasicMessageComposer"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {CommandCategories, getCommand} from '../../../SlashCommands'; import {Action} from "../../../dispatcher/actions"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager'; import {replaceableComponent} from "../../../utils/replaceableComponent"; import SendHistoryManager from '../../../SendHistoryManager'; +import Modal from '../../../Modal'; function _isReply(mxEvent) { const relatesTo = mxEvent.getContent()["m.relates_to"]; @@ -212,6 +214,22 @@ export default class EditMessageComposer extends React.Component { localStorage.setItem(this._editorStateKey, JSON.stringify(item)); } + _isSlashCommand() { + const parts = this.model.parts; + const firstPart = parts[0]; + if (firstPart) { + if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) { + return true; + } + + if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//") + && (firstPart.type === "plain" || firstPart.type === "pill-candidate")) { + return true; + } + } + return false; + } + _isContentModified(newContent) { // if nothing has changed then bail const oldContent = this.props.editState.getEvent().getContent(); @@ -224,19 +242,114 @@ export default class EditMessageComposer extends React.Component { return true; } - _sendEdit = () => { + _getSlashCommand() { + const commandText = this.model.parts.reduce((text, part) => { + // use mxid to textify user pills in a command + if (part.type === "user-pill") { + return text + part.resourceId; + } + return text + part.text; + }, ""); + const {cmd, args} = getCommand(commandText); + return [cmd, args, commandText]; + } + + async _runSlashCommand(cmd, args, roomId) { + const result = cmd.run(roomId, args); + let messageContent; + let error = result.error; + if (result.promise) { + try { + if (cmd.category === CommandCategories.messages) { + messageContent = await result.promise; + } else { + await result.promise; + } + } catch (err) { + error = err; + } + } + if (error) { + console.error("Command failure: %s", error); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + // assume the error is a server error when the command is async + const isServerError = !!result.promise; + const title = isServerError ? _td("Server error") : _td("Command error"); + + let errText; + if (typeof error === 'string') { + errText = error; + } else if (error.message) { + errText = error.message; + } else { + errText = _t("Server unavailable, overloaded, or something else went wrong."); + } + + Modal.createTrackedDialog(title, '', ErrorDialog, { + title: _t(title), + description: errText, + }); + } else { + console.log("Command success."); + if (messageContent) return messageContent; + } + } + + _sendEdit = async () => { const startTime = CountlyAnalytics.getTimestamp(); const editedEvent = this.props.editState.getEvent(); const editContent = createEditContent(this.model, editedEvent); const newContent = editContent["m.new_content"]; + + let shouldSend = true; + // If content is modified then send an updated event into the room if (this._isContentModified(newContent)) { const roomId = editedEvent.getRoomId(); - this._cancelPreviousPendingEdit(); - const prom = this.context.sendMessage(roomId, editContent); - dis.dispatch({action: "message_sent"}); - this._clearStoredEditorState(); - CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); + if (!containsEmote(this.model) && this._isSlashCommand()) { + const [cmd, args, commandText] = this._getSlashCommand(); + if (cmd) { + if (cmd.category === CommandCategories.messages) { + editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId); + } else { + this._runSlashCommand(cmd, args, roomId); + shouldSend = false; + } + } else { + // ask the user if their unknown command should be sent as a message + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { + title: _t("Unknown Command"), + description:
    +

    + { _t("Unrecognised command: %(commandText)s", {commandText}) } +

    +

    + { _t("You can use /help to list available commands. " + + "Did you mean to send this as a message?", {}, { + code: t => { t }, + }) } +

    +

    + { _t("Hint: Begin your message with // to start it with a slash.", {}, { + code: t => { t }, + }) } +

    +
    , + button: _t('Send as message'), + }); + const [sendAnyway] = await finished; + // if !sendAnyway bail to let the user edit the composer and try again + if (!sendAnyway) return; + } + } + if (shouldSend) { + this._cancelPreviousPendingEdit(); + const prom = this.context.sendMessage(roomId, editContent); + this._clearStoredEditorState(); + dis.dispatch({action: "message_sent"}); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); + } } // close the event editing and focus composer @@ -278,13 +391,14 @@ export default class EditMessageComposer extends React.Component { _createEditorModel() { const {editState} = this.props; const room = this._getRoom(); - const partCreator = new PartCreator(room, this.context); + const partCreator = new CommandPartCreator(room, this.context); let parts; if (editState.hasEditorState()) { // if restoring state from a previous editor, // restore serialized parts from the state parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p)); } else { + //otherwise, either restore serialized parts from localStorage or parse the body of the event parts = this._restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator); } this.model = new EditorModel(parts, partCreator); diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 36ab423885..411b0f9b5e 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -26,13 +26,11 @@ import {SpaceItem} from "./SpaceTreeLevel"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; import SpaceStore, { - HOME_SPACE, UPDATE_INVITED_SPACES, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES, } from "../../../stores/SpaceStore"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState"; import NotificationBadge from "../rooms/NotificationBadge"; import { RovingAccessibleButton, @@ -40,13 +38,15 @@ import { RovingTabIndexProvider, } from "../../../accessibility/RovingTabIndex"; import {Key} from "../../../Keyboard"; +import {RoomNotificationStateStore} from "../../../stores/notifications/RoomNotificationStateStore"; +import {NotificationState} from "../../../stores/notifications/NotificationState"; interface IButtonProps { space?: Room; className?: string; selected?: boolean; tooltip?: string; - notificationState?: SpaceNotificationState; + notificationState?: NotificationState; isNarrow?: boolean; onClick(): void; } @@ -212,8 +212,8 @@ const SpacePanel = () => { className="mx_SpaceButton_home" onClick={() => SpaceStore.instance.setActiveSpace(null)} selected={!activeSpace} - tooltip={_t("Home")} - notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)} + tooltip={_t("All rooms")} + notificationState={RoomNotificationStateStore.instance.globalState} isNarrow={isPanelCollapsed} /> { invites.map(s => /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", + "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", + "Send as message": "Send as message", "Edit message": "Edit message", "Mod": "Mod", "This event could not be displayed": "This event could not be displayed", @@ -1631,13 +1638,6 @@ "This Room": "This Room", "All Rooms": "All Rooms", "Search…": "Search…", - "Server error": "Server error", - "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", - "Unknown Command": "Unknown Command", - "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", - "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", - "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", - "Send as message": "Send as message", "Failed to connect to integration manager": "Failed to connect to integration manager", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "Add some now": "Add some now", @@ -2016,10 +2016,10 @@ "Continue with %(provider)s": "Continue with %(provider)s", "Sign in with single sign-on": "Sign in with single sign-on", "And %(count)s more...|other": "And %(count)s more...", + "Home": "Home", "Enter a server name": "Enter a server name", "Looks good": "Looks good", "Can't find this server or its room list": "Can't find this server or its room list", - "All rooms": "All rooms", "Your server": "Your server", "Are you sure you want to remove %(serverName)s": "Are you sure you want to remove %(serverName)s", "Remove server": "Remove server", diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 4423891c61..b1b8199f93 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -31,28 +31,23 @@ import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateS import {DefaultTagID} from "./room-list/models"; import {EnhancedMap, mapDiff} from "../utils/maps"; import {setHasDiff} from "../utils/sets"; -import {objectDiff} from "../utils/objects"; -import {arrayHasDiff} from "../utils/arrays"; import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory"; import RoomViewStore from "./RoomViewStore"; -type SpaceKey = string | symbol; - interface IState {} const ACTIVE_SPACE_LS_KEY = "mx_active_space"; -export const HOME_SPACE = Symbol("home-space"); export const SUGGESTED_ROOMS = Symbol("suggested-rooms"); export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces"); export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); -// Space Room ID/HOME_SPACE will be emitted when a Space's children change +// Space Room ID will be emitted when a Space's children change const MAX_SUGGESTED_ROOMS = 20; -const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "home_space"}`; +const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "ALL_ROOMS"}`; const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] return arr.reduce((result, room: Room) => { @@ -86,15 +81,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // The spaces representing the roots of the various tree-like hierarchies private rootSpaces: Room[] = []; - // The list of rooms not present in any currently joined spaces - private orphanedRooms = new Set(); // Map from room ID to set of spaces which list it as a child private parentMap = new EnhancedMap>(); - // Map from space key to SpaceNotificationState instance representing that space - private notificationStateMap = new Map(); + // Map from spaceId to SpaceNotificationState instance representing that space + private notificationStateMap = new Map(); // Map from space key to Set of room IDs that should be shown as part of that space's filter - private spaceFilteredRooms = new Map>(); - // The space currently selected in the Space Panel - if null then `Home` is selected + private spaceFilteredRooms = new Map>(); + // The space currently selected in the Space Panel - if null then All Rooms is selected private _activeSpace?: Room = null; private _suggestedRooms: ISpaceSummaryRoom[] = []; private _invitedSpaces = new Set(); @@ -244,7 +237,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getSpaceFilteredRoomIds = (space: Room | null): Set => { - return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); + if (!space) { + return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); + } + return this.spaceFilteredRooms.get(space.roomId) || new Set(); }; private rebuild = throttle(() => { @@ -275,7 +271,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); }); - const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren)); + const [rootSpaces] = partitionSpacesAndRooms(Array.from(unseenChildren)); // somewhat algorithm to handle full-cycles const detachedNodes = new Set(spaces); @@ -316,7 +312,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // rootSpaces.push(space); // }); - this.orphanedRooms = new Set(orphanedRooms); this.rootSpaces = rootSpaces; this.parentMap = backrefs; @@ -337,25 +332,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.rebuild(); } - private showInHomeSpace = (room: Room) => { - if (room.isSpaceRoom()) return false; - return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space - || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space - || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites - }; - - // Update a given room due to its tag changing (e.g DM-ness or Fav-ness) - // This can only change whether it shows up in the HOME_SPACE or not - private onRoomUpdate = (room: Room) => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(HOME_SPACE)?.add(room.roomId); - this.emit(HOME_SPACE); - } else if (!this.orphanedRooms.has(room.roomId)) { - this.spaceFilteredRooms.get(HOME_SPACE)?.delete(room.roomId); - this.emit(HOME_SPACE); - } - }; - private onSpaceMembersChange = (ev: MatrixEvent) => { // skip this update if we do not have a DM with this user if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return; @@ -369,16 +345,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldFilteredRooms = this.spaceFilteredRooms; this.spaceFilteredRooms = new Map(); - // put all room invites in the Home Space - const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); - this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); - - visibleRooms.forEach(room => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId); - } - }); - this.rootSpaces.forEach(s => { // traverse each space tree in DFS to build up the supersets as you go up, // reusing results from like subtrees. @@ -425,13 +391,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // Update NotificationStates this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { if (roomIds.has(room.roomId)) { - // Don't aggregate notifications for DMs except in the Home Space - if (s !== HOME_SPACE) { - return !DMRoomMap.shared().getUserIdForRoomId(room.roomId) - || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite); - } - - return true; + return !DMRoomMap.shared().getUserIdForRoomId(room.roomId) + || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite); } return false; @@ -513,8 +474,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // TODO confirm this after implementing parenting behaviour if (room.isSpaceRoom()) { this.onSpaceUpdate(); - } else { - this.onRoomUpdate(room); } this.emit(room.roomId); break; @@ -527,38 +486,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } }; - private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => { - if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) { - // If the room was in favourites and now isn't or the opposite then update its position in the trees - const oldTags = lastEvent?.getContent()?.tags || {}; - const newTags = ev.getContent()?.tags || {}; - if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) { - this.onRoomUpdate(room); - } - } - } - - private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => { - if (ev.getType() === EventType.Direct) { - const lastContent = lastEvent.getContent(); - const content = ev.getContent(); - - const diff = objectDiff>(lastContent, content); - // filter out keys which changed by reference only by checking whether the sets differ - const changed = diff.changed.filter(k => arrayHasDiff(lastContent[k], content[k])); - // DM tag changes, refresh relevant rooms - new Set([...diff.added, ...diff.removed, ...changed]).forEach(roomId => { - const room = this.matrixClient?.getRoom(roomId); - if (room) { - this.onRoomUpdate(room); - } - }); - } - }; - protected async reset() { this.rootSpaces = []; - this.orphanedRooms = new Set(); this.parentMap = new EnhancedMap(); this.notificationStateMap = new Map(); this.spaceFilteredRooms = new Map(); @@ -573,8 +502,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.removeListener("Room", this.onRoom); this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("RoomState.events", this.onRoomState); - this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); - this.matrixClient.removeListener("accountData", this.onAccountData); } await this.reset(); } @@ -584,8 +511,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.on("Room", this.onRoom); this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("RoomState.events", this.onRoomState); - this.matrixClient.on("Room.accountData", this.onRoomAccountData); - this.matrixClient.on("accountData", this.onAccountData); await this.onSpaceUpdate(); // trigger an initial update @@ -610,7 +535,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // Don't context switch when navigating to the space room // as it will cause you to end up in the wrong room this.setActiveSpace(room, false); - } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) { + } else if (this.activeSpace && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) { this.switchToRelatedSpace(roomId); } @@ -628,7 +553,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } } - public getNotificationState(key: SpaceKey): SpaceNotificationState { + public getNotificationState(key: string): SpaceNotificationState { if (this.notificationStateMap.has(key)) { return this.notificationStateMap.get(key); } diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 58eb6ed317..a23401e4c9 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -680,7 +680,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { promise = this.recalculatePrefiltering(); } else { this.filterConditions.push(filter); - // Runtime filters with spaces disable prefiltering for the search all spaces effect + // Runtime filters with spaces disable prefiltering for the search all spaces feature if (SettingsStore.getValue("feature_spaces")) { // this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below // this way the runtime filters are only evaluated on one dataset and not both. @@ -712,10 +712,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient { if (this.algorithm) { this.algorithm.removeFilterCondition(filter); - // Runtime filters with spaces disable prefiltering for the search all spaces effect - if (SettingsStore.getValue("feature_spaces")) { - promise = this.recalculatePrefiltering(); - } + } + // Runtime filters with spaces disable prefiltering for the search all spaces feature + if (SettingsStore.getValue("feature_spaces")) { + promise = this.recalculatePrefiltering(); } } idx = this.prefilterConditions.indexOf(filter); diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index 13e1d83901..0b1b78bc75 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -24,26 +24,34 @@ import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; * Watches for changes in spaces to manage the filter on the provided RoomListStore */ export class SpaceWatcher { - private filter = new SpaceFilterCondition(); + private filter: SpaceFilterCondition; private activeSpace: Room = SpaceStore.instance.activeSpace; constructor(private store: RoomListStoreClass) { - this.updateFilter(); // get the filter into a consistent state - store.addFilter(this.filter); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); } - private onSelectedSpaceUpdated = (activeSpace: Room) => { + private onSelectedSpaceUpdated = (activeSpace?: Room) => { this.activeSpace = activeSpace; - this.updateFilter(); + + if (this.filter) { + if (activeSpace) { + this.updateFilter(); + } else { + this.store.removeFilter(this.filter); + this.filter = null; + } + } else if (activeSpace) { + this.filter = new SpaceFilterCondition(); + this.updateFilter(); + this.store.addFilter(this.filter); + } }; private updateFilter = () => { - if (this.activeSpace) { - SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => { - this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded(); - }); - } + SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => { + this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded(); + }); this.filter.updateSpace(this.activeSpace); }; } diff --git a/src/stores/room-list/filters/SpaceFilterCondition.ts b/src/stores/room-list/filters/SpaceFilterCondition.ts index 43bdcb3879..6a06bee0d8 100644 --- a/src/stores/room-list/filters/SpaceFilterCondition.ts +++ b/src/stores/room-list/filters/SpaceFilterCondition.ts @@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition"; import { IDestroyable } from "../../../utils/IDestroyable"; -import SpaceStore, {HOME_SPACE} from "../../SpaceStore"; +import SpaceStore from "../../SpaceStore"; import { setHasDiff } from "../../../utils/sets"; /** @@ -55,10 +55,12 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi } }; - private getSpaceEventKey = (space: Room | null) => space ? space.roomId : HOME_SPACE; + private getSpaceEventKey = (space: Room) => space.roomId; public updateSpace(space: Room) { - SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate); + if (this.space) { + SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate); + } SpaceStore.instance.on(this.getSpaceEventKey(this.space = space), this.onStoreUpdate); this.onStoreUpdate(); // initial update from the change to the space } diff --git a/src/utils/DecryptFile.ts b/src/utils/DecryptFile.ts index 93cedbc707..d073393170 100644 --- a/src/utils/DecryptFile.ts +++ b/src/utils/DecryptFile.ts @@ -17,63 +17,8 @@ limitations under the License. // Pull in the encryption lib so that we can decrypt attachments. import encrypt from 'browser-encrypt-attachment'; import {mediaFromContent} from "../customisations/Media"; -import {IEncryptedFile} from "../customisations/models/IMediaEventContent"; - -// WARNING: We have to be very careful about what mime-types we allow into blobs, -// as for performance reasons these are now rendered via URL.createObjectURL() -// rather than by converting into data: URIs. -// -// This means that the content is rendered using the origin of the script which -// called createObjectURL(), and so if the content contains any scripting then it -// will pose a XSS vulnerability when the browser renders it. This is particularly -// bad if the user right-clicks the URI and pastes it into a new window or tab, -// as the blob will then execute with access to Element's full JS environment(!) -// -// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 -// for details. -// -// We mitigate this by only allowing mime-types into blobs which we know don't -// contain any scripting, and instantiate all others as application/octet-stream -// regardless of what mime-type the event claimed. Even if the payload itself -// is some malicious HTML, the fact we instantiate it with a media mimetype or -// application/octet-stream means the browser doesn't try to render it as such. -// -// One interesting edge case is image/svg+xml, which empirically *is* rendered -// correctly if the blob is set to the src attribute of an img tag (for thumbnails) -// *even if the mimetype is application/octet-stream*. However, empirically JS -// in the SVG isn't executed in this scenario, so we seem to be okay. -// -// Tested on Chrome 65 and Firefox 60 -// -// The list below is taken mainly from -// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats -// N.B. Matrix doesn't currently specify which mimetypes are valid in given -// events, so we pick the ones which HTML5 browsers should be able to display -// -// For the record, mime-types which must NEVER enter this list below include: -// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. - -const ALLOWED_BLOB_MIMETYPES = [ - 'image/jpeg', - 'image/gif', - 'image/png', - - 'video/mp4', - 'video/webm', - 'video/ogg', - - 'audio/mp4', - 'audio/webm', - 'audio/aac', - 'audio/mpeg', - 'audio/ogg', - 'audio/wave', - 'audio/wav', - 'audio/x-wav', - 'audio/x-pn-wav', - 'audio/flac', - 'audio/x-flac', -]; +import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; +import { getBlobSafeMimeType } from "./blobs"; /** * Decrypt a file attached to a matrix event. @@ -100,9 +45,7 @@ export function decryptFile(file: IEncryptedFile): Promise { // browser (e.g. by copying the URI into a new tab or window.) // See warning at top of file. let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : ''; - if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { - mimetype = 'application/octet-stream'; - } + mimetype = getBlobSafeMimeType(mimetype); return new Blob([dataArray], {type: mimetype}); }); diff --git a/src/utils/blobs.ts b/src/utils/blobs.ts new file mode 100644 index 0000000000..4e073a3936 --- /dev/null +++ b/src/utils/blobs.ts @@ -0,0 +1,78 @@ +/* +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. +*/ + +// WARNING: We have to be very careful about what mime-types we allow into blobs, +// as for performance reasons these are now rendered via URL.createObjectURL() +// rather than by converting into data: URIs. +// +// This means that the content is rendered using the origin of the script which +// called createObjectURL(), and so if the content contains any scripting then it +// will pose a XSS vulnerability when the browser renders it. This is particularly +// bad if the user right-clicks the URI and pastes it into a new window or tab, +// as the blob will then execute with access to Element's full JS environment(!) +// +// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 +// for details. +// +// We mitigate this by only allowing mime-types into blobs which we know don't +// contain any scripting, and instantiate all others as application/octet-stream +// regardless of what mime-type the event claimed. Even if the payload itself +// is some malicious HTML, the fact we instantiate it with a media mimetype or +// application/octet-stream means the browser doesn't try to render it as such. +// +// One interesting edge case is image/svg+xml, which empirically *is* rendered +// correctly if the blob is set to the src attribute of an img tag (for thumbnails) +// *even if the mimetype is application/octet-stream*. However, empirically JS +// in the SVG isn't executed in this scenario, so we seem to be okay. +// +// Tested on Chrome 65 and Firefox 60 +// +// The list below is taken mainly from +// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats +// N.B. Matrix doesn't currently specify which mimetypes are valid in given +// events, so we pick the ones which HTML5 browsers should be able to display +// +// For the record, mime-types which must NEVER enter this list below include: +// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. + +const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', + + 'video/mp4', + 'video/webm', + 'video/ogg', + + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; + +export function getBlobSafeMimeType(mimetype: string): string { + if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { + return 'application/octet-stream'; + } + return mimetype; +} diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 7347ff2658..dc70e3f7f6 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -77,7 +77,7 @@ describe('MessagePanel', function() { DMRoomMap.makeShared(); }); - afterEach(function() { + afterEach(function () { clock.uninstall(); }); @@ -88,7 +88,21 @@ describe('MessagePanel', function() { events.push(test_utils.mkMessage( { event: true, room: "!room:id", user: "@user:id", - ts: ts0 + i*1000, + ts: ts0 + i * 1000, + })); + } + return events; + } + + // Just to avoid breaking Dateseparator tests that might run at 00hrs + function mkOneDayEvents() { + const events = []; + const ts0 = Date.parse('09 May 2004 00:12:00 GMT'); + for (let i = 0; i < 10; i++) { + events.push(test_utils.mkMessage( + { + event: true, room: "!room:id", user: "@user:id", + ts: ts0 + i * 1000, })); } return events; @@ -104,7 +118,7 @@ describe('MessagePanel', function() { let i = 0; events.push(test_utils.mkMessage({ event: true, room: "!room:id", user: "@user:id", - ts: ts0 + ++i*1000, + ts: ts0 + ++i * 1000, })); for (i = 0; i < 10; i++) { @@ -151,7 +165,7 @@ describe('MessagePanel', function() { }, getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, - ts: ts0 + i*1000, + ts: ts0 + i * 1000, mship: 'join', prevMship: 'join', name: 'A user', @@ -250,7 +264,6 @@ describe('MessagePanel', function() { }), ]; } - function isReadMarkerVisible(rmContainer) { return rmContainer && rmContainer.children.length > 0; } @@ -437,4 +450,17 @@ describe('MessagePanel', function() { // read marker should be hidden given props and at the last event expect(isReadMarkerVisible(rm)).toBeFalsy(); }); + + it('should render Date separators for the events', function () { + const events = mkOneDayEvents(); + const res = mount( + , + ); + const Dates = res.find(sdk.getComponent('messages.DateSeparator')); + + expect(Dates.length).toEqual(1); + }); }); diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index aef788647d..20c48c29db 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -101,6 +101,7 @@ const invite1 = "!invite1:server"; const invite2 = "!invite2:server"; const room1 = "!room1:server"; const room2 = "!room2:server"; +const room3 = "!room3:server"; const space1 = "!space1:server"; const space2 = "!space2:server"; const space3 = "!space3:server"; @@ -361,8 +362,8 @@ describe("SpaceStore", () => { expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); }); - it("home space does not contain rooms/low priority from rooms within spaces", () => { - expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy(); + it("home space does contain rooms/low priority even if they are also shown in a space", () => { + expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy(); }); it("space contains child rooms", () => { @@ -614,8 +615,8 @@ describe("SpaceStore", () => { describe("space auto switching tests", () => { beforeEach(async () => { - [room1, room2, orphan1].forEach(mkRoom); - mkSpace(space1, [room1, room2]); + [room1, room2, room3, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2, room3]); mkSpace(space2, [room1, room2]); client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ @@ -641,15 +642,15 @@ describe("SpaceStore", () => { it("switch to canonical parent space for room", async () => { viewRoom(room1); - await store.setActiveSpace(null, false); + await store.setActiveSpace(client.getRoom(space2), false); viewRoom(room2); expect(store.activeSpace).toBe(client.getRoom(space2)); }); it("switch to first containing space for room", async () => { viewRoom(room2); - await store.setActiveSpace(null, false); - viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2), false); + viewRoom(room3); expect(store.activeSpace).toBe(client.getRoom(space1)); }); @@ -659,6 +660,13 @@ describe("SpaceStore", () => { viewRoom(orphan1); expect(store.activeSpace).toBeNull(); }); + + it("when switching rooms in the all rooms home space don't switch to related space", async () => { + viewRoom(room2); + await store.setActiveSpace(null, false); + viewRoom(room1); + expect(store.activeSpace).toBeNull(); + }); }); describe("traverseSpace", () => { diff --git a/yarn.lock b/yarn.lock index 8b4ac35d6a..0d9b2bdd3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4226,9 +4226,9 @@ hoist-non-react-statics@^3.3.0: react-is "^16.7.0" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hosted-git-info@^3.0.6: version "3.0.7" @@ -5677,8 +5677,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "10.0.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/c8f69c0b7937b9064938c134d708c4d064b71315" + version "10.1.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2d73805ca3d8c5a140fe05e574f826696de1656a" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -6307,6 +6307,11 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha1-8r0iH2zJcKk42IVWq8WJyqqiveE= + parse5-htmlparser2-tree-adapter@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" @@ -7248,17 +7253,18 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -"sanitize-html@github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db": - version "2.0.0-rc.3" - resolved "https://codeload.github.com/apostrophecms/sanitize-html/tar.gz/3c7f93f2058f696f5359e3e58d464161647226db" +sanitize-html@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.3.3.tgz#3db382c9a621cce4c46d90f10c64f1e9da9e8353" + integrity sha512-DCFXPt7Di0c6JUnlT90eIgrjs6TsJl/8HYU3KLdmrVclFN4O0heTcVbJiMa23OKVr6aR051XYtsgd8EWwEBwUA== dependencies: deepmerge "^4.2.2" escape-string-regexp "^4.0.0" - htmlparser2 "^4.1.0" + htmlparser2 "^6.0.0" is-plain-object "^5.0.0" klona "^2.0.3" + parse-srcset "^1.0.2" postcss "^8.0.2" - srcset "^3.0.0" saxes@^5.0.0: version "5.0.1" @@ -7515,11 +7521,6 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -srcset@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/srcset/-/srcset-3.0.0.tgz#8afd8b971362dfc129ae9c1a99b3897301ce6441" - integrity sha512-D59vF08Qzu/C4GAOXVgMTLfgryt5fyWo93FZyhEWANo0PokFz/iWdDe13mX3O5TRf6l8vMTqckAfR4zPiaH0yQ== - sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"