diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d23e3413..2582668ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,113 @@ +Changes in [3.21.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0) (2021-05-17) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0-rc.1...v3.21.0) + +## Security notice + +matrix-react-sdk 3.21.0 fixes a low severity issue (GHSA-8796-gc9j-63rv) +related to file upload. When uploading a file, the local file preview can lead +to execution of scripts embedded in the uploaded file, but only after several +user interactions to open the preview in a separate tab. This only impacts the +local user while in the process of uploading. It cannot be exploited remotely +or by other users. Thanks to [Muhammad Zaid Ghifari](https://github.com/MR-ZHEEV) +for responsibly disclosing this via Matrix's Security Disclosure Policy. + +## All changes + + * Upgrade to JS SDK 11.0.0 + * [Release] Add missing space on beta feedback dialog + [\#6019](https://github.com/matrix-org/matrix-react-sdk/pull/6019) + * [Release] Add feedback mechanism for beta features, namely Spaces + [\#6013](https://github.com/matrix-org/matrix-react-sdk/pull/6013) + * Add feedback mechanism for beta features, namely Spaces + [\#6012](https://github.com/matrix-org/matrix-react-sdk/pull/6012) + +Changes in [3.21.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0-rc.1) (2021-05-11) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0...v3.21.0-rc.1) + + * Upgrade to JS SDK 11.0.0-rc.1 + * Add disclaimer about subspaces being experimental in add existing dialog + [\#5978](https://github.com/matrix-org/matrix-react-sdk/pull/5978) + * Spaces Beta release + [\#5933](https://github.com/matrix-org/matrix-react-sdk/pull/5933) + * Improve permissions error when adding new server to room directory + [\#6009](https://github.com/matrix-org/matrix-react-sdk/pull/6009) + * Allow user to progress through space creation & setup using Enter + [\#6006](https://github.com/matrix-org/matrix-react-sdk/pull/6006) + * Upgrade sanitize types + [\#6008](https://github.com/matrix-org/matrix-react-sdk/pull/6008) + * Upgrade `cheerio` and resolve type errors + [\#6007](https://github.com/matrix-org/matrix-react-sdk/pull/6007) + * Add slash commands support to edit message composer + [\#5865](https://github.com/matrix-org/matrix-react-sdk/pull/5865) + * Fix the two todays problem + [\#5940](https://github.com/matrix-org/matrix-react-sdk/pull/5940) + * Switch the Home Space out for an All rooms space + [\#5969](https://github.com/matrix-org/matrix-react-sdk/pull/5969) + * Show device ID in UserInfo when there is no device name + [\#5985](https://github.com/matrix-org/matrix-react-sdk/pull/5985) + * Switch back to release version of `sanitize-html` + [\#6005](https://github.com/matrix-org/matrix-react-sdk/pull/6005) + * Bump hosted-git-info from 2.8.8 to 2.8.9 + [\#5998](https://github.com/matrix-org/matrix-react-sdk/pull/5998) + * Don't use the event's metadata to calc the scale of an image + [\#5982](https://github.com/matrix-org/matrix-react-sdk/pull/5982) + * Adjust MIME type of upload confirmation if needed + [\#5981](https://github.com/matrix-org/matrix-react-sdk/pull/5981) + * Forbid redaction of encryption events + [\#5991](https://github.com/matrix-org/matrix-react-sdk/pull/5991) + * Fix voice message playback being squished up against send button + [\#5988](https://github.com/matrix-org/matrix-react-sdk/pull/5988) + * Improve style of notification badges on the space panel + [\#5983](https://github.com/matrix-org/matrix-react-sdk/pull/5983) + * Add dev dependency for parse5 typings + [\#5990](https://github.com/matrix-org/matrix-react-sdk/pull/5990) + * Iterate Spaces admin UX around room management + [\#5977](https://github.com/matrix-org/matrix-react-sdk/pull/5977) + * Guard all isSpaceRoom calls behind the labs flag + [\#5979](https://github.com/matrix-org/matrix-react-sdk/pull/5979) + * Bump lodash from 4.17.20 to 4.17.21 + [\#5986](https://github.com/matrix-org/matrix-react-sdk/pull/5986) + * Bump lodash from 4.17.19 to 4.17.21 in /test/end-to-end-tests + [\#5987](https://github.com/matrix-org/matrix-react-sdk/pull/5987) + * Bump ua-parser-js from 0.7.23 to 0.7.28 + [\#5984](https://github.com/matrix-org/matrix-react-sdk/pull/5984) + * Update visual style of plain files in the timeline + [\#5971](https://github.com/matrix-org/matrix-react-sdk/pull/5971) + * Support for multiple streams (not MSC3077) + [\#5833](https://github.com/matrix-org/matrix-react-sdk/pull/5833) + * Update space ordering behaviour to match updates in MSC + [\#5963](https://github.com/matrix-org/matrix-react-sdk/pull/5963) + * Improve performance of search all spaces and space switching + [\#5976](https://github.com/matrix-org/matrix-react-sdk/pull/5976) + * Update colours and sizing for voice messages + [\#5970](https://github.com/matrix-org/matrix-react-sdk/pull/5970) + * Update link to Android SDK + [\#5973](https://github.com/matrix-org/matrix-react-sdk/pull/5973) + * Add cleanup functions for image view + [\#5962](https://github.com/matrix-org/matrix-react-sdk/pull/5962) + * Add a note about sharing your IP in P2P calls + [\#5961](https://github.com/matrix-org/matrix-react-sdk/pull/5961) + * Only aggregate DM notifications on the Space Panel in the Home Space + [\#5968](https://github.com/matrix-org/matrix-react-sdk/pull/5968) + * Add retry mechanism and progress bar to add existing to space dialog + [\#5975](https://github.com/matrix-org/matrix-react-sdk/pull/5975) + * Warn on access token reveal + [\#5755](https://github.com/matrix-org/matrix-react-sdk/pull/5755) + * Fix newly joined room appearing under the wrong space + [\#5945](https://github.com/matrix-org/matrix-react-sdk/pull/5945) + * Early rendering for voice messages in the timeline + [\#5955](https://github.com/matrix-org/matrix-react-sdk/pull/5955) + * Calculate the real waveform in the Playback class for voice messages + [\#5956](https://github.com/matrix-org/matrix-react-sdk/pull/5956) + * Don't recurse on arrayFastResample + [\#5957](https://github.com/matrix-org/matrix-react-sdk/pull/5957) + * Support a dark theme for voice messages + [\#5958](https://github.com/matrix-org/matrix-react-sdk/pull/5958) + * Handle no/blocked microphones in voice messages + [\#5959](https://github.com/matrix-org/matrix-react-sdk/pull/5959) + 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) diff --git a/package.json b/package.json index 5a32cf2c5a..f8d94e2d21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.20.0", + "version": "3.21.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -80,7 +80,7 @@ "linkifyjs": "^2.1.9", "lodash": "^4.17.20", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.13", + "matrix-widget-api": "^0.1.0-beta.14", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 91947be76a..2776c477fc 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -54,7 +54,8 @@ limitations under the License. display: flex; margin-top: 12px; - .mx_BaseAvatar { + // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling + .mx_DecoratedRoomAvatar { margin-right: 12px; } @@ -75,6 +76,10 @@ limitations under the License. } .mx_AddExistingToSpace_section_spaces { + .mx_BaseAvatar { + margin-right: 12px; + } + .mx_BaseAvatar_image { border-radius: 8px; } @@ -105,6 +110,90 @@ limitations under the License. mask-position: center; } } + + .mx_AddExistingToSpace_footer { + display: flex; + margin-top: 20px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + .mx_ProgressBar { + height: 8px; + width: 100%; + + @mixin ProgressBarBorderRadius 8px; + } + + .mx_AddExistingToSpace_progressText { + margin-top: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + } + + > * { + vertical-align: middle; + } + } + + .mx_AddExistingToSpace_error { + padding-left: 12px; + + > img { + align-self: center; + } + + .mx_AddExistingToSpace_errorHeading { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $notice-primary-color; + } + + .mx_AddExistingToSpace_errorCaption { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $primary-fg-color; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + padding: 8px 36px; + } + + .mx_AddExistingToSpace_retryButton { + margin-left: 12px; + padding-left: 24px; + position: relative; + + &::before { + content: ''; + position: absolute; + background-color: $primary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + left: 0; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } } .mx_AddExistingToSpaceDialog { @@ -189,88 +278,4 @@ limitations under the License. .mx_AddExistingToSpace { display: contents; } - - .mx_AddExistingToSpaceDialog_footer { - display: flex; - margin-top: 20px; - - > span { - flex-grow: 1; - font-size: $font-12px; - line-height: $font-15px; - color: $secondary-fg-color; - - .mx_ProgressBar { - height: 8px; - width: 100%; - - @mixin ProgressBarBorderRadius 8px; - } - - .mx_AddExistingToSpaceDialog_progressText { - margin-top: 8px; - font-size: $font-15px; - line-height: $font-24px; - color: $primary-fg-color; - } - - > * { - vertical-align: middle; - } - } - - .mx_AddExistingToSpaceDialog_error { - padding-left: 12px; - - > img { - align-self: center; - } - - .mx_AddExistingToSpaceDialog_errorHeading { - font-weight: $font-semi-bold; - font-size: $font-15px; - line-height: $font-18px; - color: $notice-primary-color; - } - - .mx_AddExistingToSpaceDialog_errorCaption { - margin-top: 4px; - font-size: $font-12px; - line-height: $font-15px; - color: $primary-fg-color; - } - } - - .mx_AccessibleButton { - display: inline-block; - align-self: center; - } - - .mx_AccessibleButton_kind_primary { - padding: 8px 36px; - } - - .mx_AddExistingToSpaceDialog_retryButton { - margin-left: 12px; - padding-left: 24px; - position: relative; - - &::before { - content: ''; - position: absolute; - background-color: $primary-fg-color; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/retry.svg'); - width: 18px; - height: 18px; - left: 0; - } - } - - .mx_AccessibleButton_kind_link { - padding: 0; - } - } } diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index 244439bf74..e05065eb02 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -20,7 +20,8 @@ limitations under the License. .mx_ReactionsRow_addReactionButton { position: relative; - display: none; // show on hover of the .mx_EventTile + display: inline-block; + visibility: hidden; // show on hover of the .mx_EventTile width: 24px; height: 24px; vertical-align: middle; @@ -39,7 +40,7 @@ limitations under the License. } &.mx_ReactionsRow_addReactionButton_active { - display: inline-block; // keep showing whilst the context menu is shown + visibility: visible; // keep showing whilst the context menu is shown } &:hover, &.mx_ReactionsRow_addReactionButton_active { @@ -51,7 +52,7 @@ limitations under the License. } .mx_EventTile:hover .mx_ReactionsRow_addReactionButton { - display: inline-block; + visibility: visible; } .mx_ReactionsRow_showAll { diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 9d52e40819..1aafa8da0e 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -98,7 +98,7 @@ limitations under the License. position: relative; width: 24px; height: 24px; - border-radius: 32px; + border-radius: 8px; &::before { content: ''; @@ -114,6 +114,11 @@ limitations under the License. } } + .mx_RoomSublist_auxButton:hover, + .mx_RoomSublist_menuButton:hover { + background: $roomlist-button-bg-color; + } + // Hide the menu button by default .mx_RoomSublist_menuButton { visibility: hidden; diff --git a/src/Unread.js b/src/Unread.js index 12c15eb6af..25c425aa9a 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -40,6 +40,8 @@ export function eventTriggersUnreadCount(ev) { return false; } else if (ev.getType() == 'm.room.server_acl') { return false; + } else if (ev.isRedacted()) { + return false; } return haveTileForEvent(ev); } diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b49a90d175..4cb537f318 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -167,7 +167,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn const onKeyDownHandler = useCallback((ev) => { let handled = false; // Don't interfere with input default keydown behaviour - if (handleHomeEnd && ev.target.tagName !== "INPUT") { + if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { // check if we actually have any items switch (ev.key) { case Key.HOME: diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 8618fc3a77..5409825f45 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -26,6 +26,8 @@ import EmojiProvider from './EmojiProvider'; import NotifProvider from './NotifProvider'; import {timeout} from "../utils/promise"; import AutocompleteProvider, {ICommand} from "./AutocompleteProvider"; +import SettingsStore from "../settings/SettingsStore"; +import SpaceProvider from "./SpaceProvider"; export interface ISelectionRange { beginning?: boolean; // whether the selection is in the first block of the editor or not @@ -56,6 +58,11 @@ const PROVIDERS = [ DuckDuckGoProvider, ]; +// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here +if (SettingsStore.getValue("feature_spaces")) { + PROVIDERS.push(SpaceProvider); +} + // Providers will get rejected if they take longer than this. const PROVIDER_COMPLETION_TIMEOUT = 3000; diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 249c069080..ad55b19101 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -1,8 +1,7 @@ /* Copyright 2016 Aviral Dasgupta -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2017, 2018, 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. @@ -17,17 +16,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React from "react"; +import {uniqBy, sortBy} from "lodash"; import Room from "matrix-js-sdk/src/models/room"; + import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; -import * as sdk from '../index'; import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; -import {uniqBy, sortBy} from "lodash"; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; +import SettingsStore from "../settings/SettingsStore"; const ROOM_REGEX = /\B#\S*/g; @@ -49,7 +50,7 @@ function matcherObject(room: Room, displayedAlias: string, matchName = "") { } export default class RoomProvider extends AutocompleteProvider { - matcher: QueryMatcher; + protected matcher: QueryMatcher; constructor() { super(ROOM_REGEX); @@ -58,20 +59,28 @@ export default class RoomProvider extends AutocompleteProvider { }); } + protected getRooms() { + const cli = MatrixClientPeg.get(); + let rooms = cli.getVisibleRooms(); + + if (SettingsStore.getValue("feature_spaces")) { + rooms = rooms.filter(r => !r.isSpaceRoom()); + } + + return rooms; + } + async getCompletions( query: string, selection: ISelectionRange, force = false, limit = -1, ): Promise { - const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); - - const client = MatrixClientPeg.get(); let completions = []; const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => { + let matcherObjects = this.getRooms().reduce((aliases, room) => { if (room.getCanonicalAlias()) { aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name)); } @@ -115,7 +124,7 @@ export default class RoomProvider extends AutocompleteProvider { ), range, }; - }).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4); + }).filter((completion) => !!completion.completion && completion.completion.length > 0); } return completions; } diff --git a/src/autocomplete/SpaceProvider.tsx b/src/autocomplete/SpaceProvider.tsx new file mode 100644 index 0000000000..0361a2c91e --- /dev/null +++ b/src/autocomplete/SpaceProvider.tsx @@ -0,0 +1,43 @@ +/* +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 React from "react"; + +import { _t } from '../languageHandler'; +import {MatrixClientPeg} from '../MatrixClientPeg'; +import RoomProvider from "./RoomProvider"; + +export default class SpaceProvider extends RoomProvider { + protected getRooms() { + return MatrixClientPeg.get().getVisibleRooms().filter(r => r.isSpaceRoom()); + } + + getName() { + return _t("Spaces"); + } + + renderCompletions(completions: React.ReactNode[]): React.ReactNode { + return ( +
+ { completions } +
+ ); + } +} diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 9d9d57d8a6..ad0f75e162 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -222,10 +222,12 @@ export class ContextMenu extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { + // don't let keyboard handling escape the context menu + ev.stopPropagation(); + if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); - ev.stopPropagation(); ev.preventDefault(); } return; @@ -258,7 +260,6 @@ export class ContextMenu extends React.PureComponent { if (handled) { // consume all other keys in context menu - ev.stopPropagation(); ev.preventDefault(); } }; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index d5e4b092e2..bb7c1f9642 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -50,6 +50,9 @@ class FilePanel extends React.Component { if (room?.roomId !== this.props?.roomId) return; if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return; + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(ev); + if (ev.isBeingDecrypted()) { this.decryptingEvents.add(ev.getId()); } else { diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index c4b9696807..ad5c759f0d 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -27,7 +27,7 @@ import CallMediaHandler from '../../CallMediaHandler'; import { fixupColorFonts } from '../../utils/FontManager'; import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; -import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg'; +import { IMatrixClientCreds } from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; import TagOrderActions from '../../actions/TagOrderActions'; @@ -219,16 +219,6 @@ class LoggedInView extends React.Component { }); }; - // Child components assume that the client peg will not be null, so give them some - // sort of assurance here by only allowing a re-render if the client is truthy. - // - // This is required because `LoggedInView` maintains its own state and if this state - // updates after the client peg has been made null (during logout), then it will - // attempt to re-render and the children will throw errors. - shouldComponentUpdate() { - return Boolean(MatrixClientPeg.get()); - } - canResetTimelineInRoom = (roomId) => { if (!this._roomView.current) { return true; diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 73a2a3c4b6..d1071a9e19 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -473,7 +473,7 @@ export default class MessagePanel extends React.Component { } get _roomHasPendingEdit() { - return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); + return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); } _getEventTiles() { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index c0f3c59457..dbfba13297 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -811,7 +811,7 @@ export default class RoomView extends React.Component { }; private onEvent = (ev) => { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || ev.shouldAttemptDecryption()) return; this.handleEffects(ev); }; diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index ed0ae1afe7..56ee9dd62a 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -52,8 +52,6 @@ import {useStateToggle} from "../../hooks/useStateToggle"; import SpaceStore from "../../stores/SpaceStore"; import FacePile from "../views/elements/FacePile"; import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog"; -import {sleep} from "../../utils/promise"; -import {calculateRoomVia} from "../../utils/permalinks/Permalinks"; import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, @@ -78,6 +76,7 @@ interface IProps { interface IState { phase: Phase; + createdRooms?: boolean; // internal state for the creation wizard showRightPanel: boolean; myMembership: string; } @@ -461,7 +460,8 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setError(""); setBusy(true); try { - await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => { + const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); + await Promise.all(filteredRoomNames.map(name => { return createRoom({ createOpts: { preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, @@ -474,7 +474,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { parentSpace: space, }); })); - onFinished(); + onFinished(filteredRoomNames.length > 0); } catch (e) { console.error("Failed to create initial space rooms", e); setError(_t("Failed to create initial space rooms")); @@ -484,7 +484,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { let onClick = (ev) => { ev.preventDefault(); - onFinished(); + onFinished(false); }; let buttonLabel = _t("Skip for now"); if (roomNames.some(name => name.trim())) { @@ -517,39 +517,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { }; const SpaceAddExistingRooms = ({ space, onFinished }) => { - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - let onClick = onFinished; - let buttonLabel = _t("Skip for now"); - if (selectedToAdd.size > 0) { - onClick = async () => { - setBusy(true); - - for (const room of selectedToAdd) { - const via = calculateRoomVia(room); - try { - await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => { - if (e.errcode === "M_LIMIT_EXCEEDED") { - await sleep(e.data.retry_after_ms); - return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry - } - - throw e; - }); - } catch (e) { - console.error("Failed to add rooms to space", e); - setError(_t("Failed to add rooms to space")); - break; - } - } - setBusy(false); - }; - buttonLabel = busy ? _t("Adding...") : _t("Add"); - } - return

{ _t("What do you want to organise?") }

@@ -557,35 +524,24 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => { "no one will be informed. You can add more later.") }
- { error &&
{ error }
} - { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} + emptySelectionButton={ + + { _t("Skip for now") } + + } + onFinished={onFinished} />
- - { buttonLabel } - +
; }; -const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished }) => { +const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => { return

{ _t("Share %(name)s", { name: justCreatedOpts?.createOpts?.name || space.name, @@ -598,7 +554,7 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished }) => {
- { _t("Go to my first room") } + { createdRooms ? _t("Go to my first room") : _t("Go to my space") }
@@ -891,13 +847,14 @@ export default class SpaceRoomView extends React.PureComponent { _t("Let's create a room for each of them.") + "\n" + _t("You can add more later too, including already existing ones.") } - onFinished={() => this.setState({ phase: Phase.PublicShare })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })} />; case Phase.PublicShare: return ; case Phase.PrivateScope: @@ -919,7 +876,7 @@ export default class SpaceRoomView extends React.PureComponent { title={_t("What projects are you working on?")} description={_t("We'll create rooms for each of them. " + "You can add more later too, including already existing ones.")} - onFinished={() => this.setState({ phase: Phase.Landing })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })} />; case Phase.PrivateExistingRooms: return { - if (event.shouldAttemptDecryption()) { - event.attemptDecryption(MatrixClientPeg.get()._crypto); - } + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(event); }); const firstVisibleEventIndex = this._checkForPreJoinUISI(events); diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index e95022687a..f15538eabf 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -20,7 +20,6 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { User } from "matrix-js-sdk/src/models/user"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { TagID } from '../../../stores/room-list/models'; import RoomAvatar from "./RoomAvatar"; import NotificationBadge from '../rooms/NotificationBadge'; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; @@ -35,7 +34,6 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps { room: Room; avatarSize: number; - tag: TagID; displayBadge?: boolean; forceCount?: boolean; oobData?: object; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 8ac68e99b7..af52fbce6c 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext, useMemo, useState} from "react"; +import React, {ReactNode, useContext, useMemo, useState} from "react"; import classNames from "classnames"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -37,6 +37,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import ProgressBar from "../elements/ProgressBar"; import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -46,7 +47,10 @@ interface IProps extends IDialogProps { const Entry = ({ room, checked, onChange }) => { return

; } else { + let button = emptySelectionButton; + if (!button || selectedToAdd.size > 0) { + button = + { _t("Add") } + ; + } + footer = <> -
{ _t("Want to add a new room instead?") }
- onCreateRoomClick(cli, space)} kind="link"> - { _t("Create a new room") } - + { footerPrompt }
- - { _t("Add") } - + { button } ; } + const onChange = !busy && !error ? (checked, room) => { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + } : null; + + return
+ + + { rooms.length > 0 ? ( +
+

{ _t("Rooms") }

+ { rooms.map(room => { + return { + onChange(checked, room); + } : null} + />; + }) } +
+ ) : undefined } + + { spaces.length > 0 ? ( +
+

{ _t("Spaces") }

+
+
{ _t("Feeling experimental?") }
+
{ _t("You can add existing spaces to a space.") }
+
+ { spaces.map(space => { + return { + onChange(checked, space); + } : null} + />; + }) } +
+ ) : null } + + { dms.length > 0 ? ( +
+

{ _t("Direct Messages") }

+ { dms.map(room => { + return { + onChange(checked, room); + } : null} + />; + }) } +
+ ) : null } + + { spaces.length + rooms.length + dms.length < 1 ? + { _t("No results") } + : undefined } +
+ +
+ { footer } +
+
; +}; + +const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { + const [selectedSpace, setSelectedSpace] = useState(space); + const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); + + let spaceOptionSection; + if (existingSubspaces.length > 0) { + const options = [space, ...existingSubspaces].map((space) => { + const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { + mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, + }); + return
+ + { space.name || getDisplayAliasForRoom(space) || space.roomId } +
; + }); + + spaceOptionSection = ( + { + setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space); + }} + value={selectedSpace.roomId} + label={_t("Space selection")} + > + { options } + + ); + } else { + spaceOptionSection =
+ { space.name || getDisplayAliasForRoom(space) || space.roomId } +
; + } + + const title = + +
+

{ _t("Add existing rooms") }

+ { spaceOptionSection } +
+
; + return = ({ matrixClient: cli, space, { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - } : null} + onFinished={onFinished} + footerPrompt={<> +
{ _t("Want to add a new room instead?") }
+ onCreateRoomClick(cli, space)} kind="link"> + { _t("Create a new room") } + + } />
-
- { footer } -
onFinished(false)} />
; }; diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 59eaab7b81..0c474b160c 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 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. @@ -16,7 +16,7 @@ limitations under the License. import * as React from 'react'; import BaseDialog from './BaseDialog'; -import { _t } from '../../../languageHandler'; +import { _t, getUserLanguage } from '../../../languageHandler'; import AccessibleButton from "../elements/AccessibleButton"; import { ClientWidgetApi, @@ -39,6 +39,8 @@ import {OwnProfileStore} from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {ELEMENT_CLIENT_ID} from "../../../identifiers"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps { widgetDefinition: IModalWidgetOpenRequestData; @@ -129,6 +131,9 @@ export default class ModalWidgetDialog extends React.PureComponent

diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index ffe513581b..e09b39f4c7 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -345,6 +345,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent = ({ roomWidth }) => { effect = new Effect(options); effectsRef.current[name] = effect; } catch (err) { - console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err); + console.warn(`Unable to load effect module at '../../../effects/${name}.`, err); } } return effect; diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index 183012b4f9..df73e1a8cb 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -207,6 +207,7 @@ export default class ImageView extends React.Component { a.href = this.props.src; a.download = this.props.name; a.target = "_blank"; + a.rel = "noreferrer noopener"; a.click(); }; diff --git a/src/components/views/elements/LanguageDropdown.js b/src/components/views/elements/LanguageDropdown.js index b8734c5afb..9420061a74 100644 --- a/src/components/views/elements/LanguageDropdown.js +++ b/src/components/views/elements/LanguageDropdown.js @@ -58,13 +58,8 @@ export default class LanguageDropdown extends React.Component { // If no value is given, we start with the first // country selected, but our parent component // doesn't know this, therefore we do this. - const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true); - if (language) { - this.props.onOptionChange(language); - } else { - const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser()); - this.props.onOptionChange(language); - } + const language = languageHandler.getUserLanguage(); + this.props.onOptionChange(language); } } diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index b2f7f8a692..37737519ce 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -31,6 +31,7 @@ import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessi import {replaceableComponent} from "../../../utils/replaceableComponent"; import {canCancel} from "../context_menus/MessageContextMenu"; import Resend from "../../../Resend"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -122,6 +123,10 @@ export default class MessageActionBar extends React.PureComponent { if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) { this.props.mxEvent.on("Event.status", this.onSent); } + + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(this.props.mxEvent); + if (this.props.mxEvent.isBeingDecrypted()) { this.props.mxEvent.once("Event.decrypted", this.onDecrypted); } diff --git a/src/components/views/messages/ReactionsRow.tsx b/src/components/views/messages/ReactionsRow.tsx index e1fcbe5364..8809302088 100644 --- a/src/components/views/messages/ReactionsRow.tsx +++ b/src/components/views/messages/ReactionsRow.tsx @@ -50,6 +50,10 @@ const ReactButton = ({ mxEvent, reactions }: IProps) => { })} title={_t("Add reaction")} onClick={openMenu} + onContextMenu={e => { + e.preventDefault(); + openMenu(); + }} isExpanded={menuDisplayed} inputRef={button} /> @@ -174,6 +178,8 @@ export default class ReactionsRow extends React.PureComponent { />; }).filter(item => !!item); + if (!items.length) return null; + // Show the first MAX_ITEMS if there are MAX_ITEMS + 1 or more items. // The "+ 1" ensure that the "show all" reveals something that takes up // more space than the button itself. diff --git a/src/components/views/messages/ViewSourceEvent.js b/src/components/views/messages/ViewSourceEvent.js index adc7a248cd..2ec567c5ad 100644 --- a/src/components/views/messages/ViewSourceEvent.js +++ b/src/components/views/messages/ViewSourceEvent.js @@ -18,6 +18,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; @replaceableComponent("views.messages.ViewSourceEvent") export default class ViewSourceEvent extends React.PureComponent { @@ -36,6 +37,10 @@ export default class ViewSourceEvent extends React.PureComponent { componentDidMount() { const {mxEvent} = this.props; + + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(mxEvent); + if (mxEvent.isBeingDecrypted()) { mxEvent.once("Event.decrypted", () => this.forceUpdate()); } diff --git a/src/components/views/rooms/RoomBreadcrumbs.tsx b/src/components/views/rooms/RoomBreadcrumbs.tsx index ea0ff233da..00f13209a2 100644 --- a/src/components/views/rooms/RoomBreadcrumbs.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs.tsx @@ -23,11 +23,9 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import Analytics from "../../../Analytics"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { CSSTransition } from "react-transition-group"; -import RoomListStore from "../../../stores/room-list/RoomListStore"; -import { DefaultTagID } from "../../../stores/room-list/models"; import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; import Toolbar from "../../../accessibility/Toolbar"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { } @@ -84,8 +82,6 @@ export default class RoomBreadcrumbs extends React.PureComponent public render(): React.ReactElement { const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => { - const roomTags = RoomListStore.instance.getTagsForRoom(r); - const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0]; return ( diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index f856f7f6ef..6d3b50c10d 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -27,7 +27,6 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import {DefaultTagID} from "../../../stores/room-list/models"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; @@ -177,7 +176,6 @@ export default class RoomHeader extends React.Component { roomAvatar = ; diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 7303f36489..edd644fd65 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -576,7 +576,6 @@ export default class RoomTile extends React.PureComponent { const roomAvatar = ; diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx index fa81b75525..207360c2c2 100644 --- a/src/components/views/spaces/SpacePublicShare.tsx +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -23,6 +23,7 @@ import {copyPlaintext} from "../../../utils/strings"; import {sleep} from "../../../utils/promise"; import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"; import {showRoomInviteDialog} from "../../../RoomInvite"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; interface IProps { space: Room; @@ -50,7 +51,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => {

{ _t("Share invite link") }

{ copiedText } - { showRoomInviteDialog(space.roomId); @@ -59,7 +60,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => { >

{ _t("Invite people") }

{ _t("Invite with email or username") } -
+ : null } ; }; diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index f0a0605584..f34baf256b 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -209,7 +209,7 @@ export class SpaceItem extends React.PureComponent { const userId = this.context.getUserId(); let inviteOption; - if (this.props.space.canInvite(userId)) { + if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) { inviteOption = ( { - // Track percentages to very coarse precision, otherwise 0.002 ends up highlighting a bar. - const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1)); + // Track percentages to a general precision to avoid over-waking the component. + const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3)); this.setState({progress}); }; diff --git a/src/effects/effect.ts b/src/effects/effect.ts new file mode 100644 index 0000000000..9011b07b61 --- /dev/null +++ b/src/effects/effect.ts @@ -0,0 +1,43 @@ +/* + Copyright 2020 Nurjin Jafar + Copyright 2020 Nordeck IT + Consulting GmbH. + + 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 type Effect = { + /** + * one or more emojis that will trigger this effect + */ + emojis: Array; + /** + * the matrix message type that will trigger this effect + */ + msgType: string; + /** + * the room command to trigger this effect + */ + command: string; + /** + * a function that returns the translated description of the effect + */ + description: () => string; + /** + * a function that returns the translated fallback message. this message will be shown if the user did not provide a custom message + */ + fallbackMessage: () => string; + /** + * animation options + */ + options: TOptions; +} diff --git a/src/effects/index.ts b/src/effects/index.ts index a22948ebcf..8ecb80020d 100644 --- a/src/effects/index.ts +++ b/src/effects/index.ts @@ -15,80 +15,11 @@ limitations under the License. */ import { _t, _td } from "../languageHandler"; - -export type Effect = { - /** - * one or more emojis that will trigger this effect - */ - emojis: Array; - /** - * the matrix message type that will trigger this effect - */ - msgType: string; - /** - * the room command to trigger this effect - */ - command: string; - /** - * a function that returns the translated description of the effect - */ - description: () => string; - /** - * a function that returns the translated fallback message. this message will be shown if the user did not provide a custom message - */ - fallbackMessage: () => string; - /** - * animation options - */ - options: TOptions; -} - -type ConfettiOptions = { - /** - * max confetti count - */ - maxCount: number; - /** - * particle animation speed - */ - speed: number; - /** - * the confetti animation frame interval in milliseconds - */ - frameInterval: number; - /** - * the alpha opacity of the confetti (between 0 and 1, where 1 is opaque and 0 is invisible) - */ - alpha: number; - /** - * use gradient instead of solid particle color - */ - gradient: boolean; -}; -type FireworksOptions = { - /** - * max fireworks count - */ - maxCount: number; - /** - * gravity value that firework adds to shift from it's start position - */ - gravity: number; -} -type SnowfallOptions = { - /** - * The maximum number of snowflakes to render at a given time - */ - maxCount: number; - /** - * The amount of gravity to apply to the snowflakes - */ - gravity: number; - /** - * The amount of drift (horizontal sway) to apply to the snowflakes. Each snowflake varies. - */ - maxDrift: number; -} +import { ConfettiOptions } from "./confetti"; +import { Effect } from "./effect"; +import { FireworksOptions } from "./fireworks"; +import { SnowfallOptions } from "./snowfall"; +import { SpaceInvadersOptions } from "./spaceinvaders"; /** * This configuration defines room effects that can be triggered by custom message types and emojis @@ -131,6 +62,17 @@ export const CHAT_EFFECTS: Array> = [ maxDrift: 5, }, } as Effect, + { + emojis: ["👾", "🌌"], + msgType: "io.element.effects.space_invaders", + command: "spaceinvaders", + description: () => _td("Sends the given message with a space themed effect"), + fallbackMessage: () => _t("sends space invaders") + " 👾", + options: { + maxCount: 50, + gravity: 0.01, + }, + } as Effect, ]; diff --git a/src/effects/spaceinvaders/index.ts b/src/effects/spaceinvaders/index.ts new file mode 100644 index 0000000000..9520e8366a --- /dev/null +++ b/src/effects/spaceinvaders/index.ts @@ -0,0 +1,119 @@ +/* + 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 ICanvasEffect from '../ICanvasEffect'; +import { arrayFastClone } from "../../utils/arrays"; + +export type SpaceInvadersOptions = { + /** + * The maximum number of invaders to render at a given time + */ + maxCount: number; + /** + * The amount of gravity to apply to the invaders + */ + gravity: number; +} + +type Invader = { + x: number; + y: number; + xCol: number; + gravity: number; +} + +export const DefaultOptions: SpaceInvadersOptions = { + maxCount: 50, + gravity: 0.005, +}; + +const KEY_FRAME_INTERVAL = 15; // 15ms, roughly +const GLYPH = "👾"; + +export default class SpaceInvaders implements ICanvasEffect { + private readonly options: SpaceInvadersOptions; + + constructor(options: { [key: string]: any }) { + this.options = {...DefaultOptions, ...options}; + } + + private context: CanvasRenderingContext2D | null = null; + private particles: Array = []; + private lastAnimationTime: number; + + public isRunning: boolean; + + public start = async (canvas: HTMLCanvasElement, timeout = 3000) => { + if (!canvas) { + return; + } + this.context = canvas.getContext('2d'); + this.particles = []; + const count = this.options.maxCount; + while (this.particles.length < count) { + this.particles.push(this.resetParticle({} as Invader, canvas.width, canvas.height)); + } + this.isRunning = true; + requestAnimationFrame(this.renderLoop); + if (timeout) { + window.setTimeout(this.stop, timeout); + } + } + + public stop = async () => { + this.isRunning = false; + } + + private resetParticle = (particle: Invader, width: number, height: number): Invader => { + particle.x = Math.random() * width; + particle.y = Math.random() * -height; + particle.xCol = particle.x; + particle.gravity = this.options.gravity + (Math.random() * 6) + 4; + return particle; + } + + private renderLoop = (): void => { + if (!this.context || !this.context.canvas) { + return; + } + if (this.particles.length === 0) { + this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); + } else { + const timeDelta = Date.now() - this.lastAnimationTime; + if (timeDelta >= KEY_FRAME_INTERVAL || !this.lastAnimationTime) { + // Clear the screen first + this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); + + this.lastAnimationTime = Date.now(); + this.animateAndRenderInvaders(); + } + requestAnimationFrame(this.renderLoop); + } + }; + + private animateAndRenderInvaders() { + if (!this.context || !this.context.canvas) { + return; + } + this.context.font = "50px Twemoji"; + for (const particle of arrayFastClone(this.particles)) { + particle.y += particle.gravity; + + this.context.save(); + this.context.fillText(GLYPH, particle.x, particle.y); + this.context.restore(); + } + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a0e33fe075..d1fe791319 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -600,6 +600,10 @@ "See when the avatar changes in this room": "See when the avatar changes in this room", "Change the avatar of your active room": "Change the avatar of your active room", "See when the avatar changes in your active room": "See when the avatar changes in your active room", + "Kick, ban, or invite people to this room, and make you leave": "Kick, ban, or invite people to this room, and make you leave", + "See when people join, leave, or are invited to this room": "See when people join, leave, or are invited to this room", + "Kick, ban, or invite people to your active room, and make you leave": "Kick, ban, or invite people to your active room, and make you leave", + "See when people join, leave, or are invited to your active room": "See when people join, leave, or are invited to your active room", "Send stickers to this room as you": "Send stickers to this room as you", "See when a sticker is posted in this room": "See when a sticker is posted in this room", "Send stickers to your active room as you": "Send stickers to your active room as you", @@ -880,6 +884,8 @@ "sends fireworks": "sends fireworks", "Sends the given message with snowfall": "Sends the given message with snowfall", "sends snowfall": "sends snowfall", + "Sends the given message with a space themed effect": "Sends the given message with a space themed effect", + "sends space invaders": "sends space invaders", "unknown person": "unknown person", "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consulting with %(transferTarget)s. Transfer to %(transferee)s", "You held the call Switch": "You held the call Switch", @@ -2033,15 +2039,15 @@ "Add a new server...": "Add a new server...", "%(networkName)s rooms": "%(networkName)s rooms", "Matrix rooms": "Matrix rooms", + "Not all selected were added": "Not all selected were added", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", "Filter your rooms and spaces": "Filter your rooms and spaces", "Feeling experimental?": "Feeling experimental?", "You can add existing spaces to a space.": "You can add existing spaces to a space.", "Direct Messages": "Direct Messages", "Space selection": "Space selection", "Add existing rooms": "Add existing rooms", - "Not all selected were added": "Not all selected were added", - "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", - "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", "Want to add a new room instead?": "Want to add a new room instead?", "Create a new room": "Create a new room", "Matrix ID": "Matrix ID", @@ -2704,13 +2710,12 @@ "Failed to create initial space rooms": "Failed to create initial space rooms", "Skip for now": "Skip for now", "Creating rooms...": "Creating rooms...", - "Failed to add rooms to space": "Failed to add rooms to space", - "Adding...": "Adding...", "What do you want to organise?": "What do you want to organise?", "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.", "Share %(name)s": "Share %(name)s", "It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.", "Go to my first room": "Go to my first room", + "Go to my space": "Go to my space", "Who are you working with?": "Who are you working with?", "Make sure the right people have access to %(name)s": "Make sure the right people have access to %(name)s", "Just me": "Just me", @@ -2834,6 +2839,7 @@ "Room Notification": "Room Notification", "Notification Autocomplete": "Notification Autocomplete", "Room Autocomplete": "Room Autocomplete", + "Space Autocomplete": "Space Autocomplete", "Users": "Users", "User Autocomplete": "User Autocomplete", "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.", diff --git a/src/identifiers.ts b/src/identifiers.ts new file mode 100644 index 0000000000..cc8b2fee4d --- /dev/null +++ b/src/identifiers.ts @@ -0,0 +1,17 @@ +/* + * 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 ELEMENT_CLIENT_ID = "io.element.web"; diff --git a/src/indexing/EventIndex.js b/src/indexing/EventIndex.js index 857dc5b248..ed4418140b 100644 --- a/src/indexing/EventIndex.js +++ b/src/indexing/EventIndex.js @@ -177,8 +177,10 @@ export default class EventIndex extends EventEmitter { * listener. */ onRoomTimeline = async (ev, room, toStartOfTimeline, removed, data) => { + const client = MatrixClientPeg.get(); + // We only index encrypted rooms locally. - if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) return; + if (!client.isRoomEncrypted(room.roomId)) return; // If it isn't a live event or if it's redacted there's nothing to // do. @@ -187,10 +189,7 @@ export default class EventIndex extends EventEmitter { return; } - if (ev.isBeingDecrypted()) { - // XXX: Private member access - await ev._decryptionPromise; - } + await client.decryptEventIfNeeded(ev); await this.addLiveEventToIndex(ev); } @@ -518,19 +517,10 @@ export default class EventIndex extends EventEmitter { const decryptionPromises = matrixEvents .filter(event => event.isEncrypted()) .map(event => { - if (event.shouldAttemptDecryption()) { - return event.attemptDecryption(client._crypto, { - isRetry: true, - emit: false, - }); - } else { - // TODO the decryption promise is a private property, this - // should either be made public or we should convert the - // event that gets fired when decryption is done into a - // promise using the once event emitter method: - // https://nodejs.org/api/events.html#events_events_once_emitter_name - return event._decryptionPromise; - } + return client.decryptEventIfNeeded(event, { + isRetry: true, + emit: false, + }); }); // Let us wait for all the events to get decrypted. diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index b61f57d4b3..4a3fb30886 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -56,6 +56,15 @@ export function newTranslatableError(message: string) { return error; } +export function getUserLanguage(): string { + const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true); + if (language) { + return language; + } else { + return normalizeLanguageKey(getLanguageFromBrowser()); + } +} + // Function which only purpose is to mark that a string is translatable // Does not actually do anything. It's helpful for automatic extraction of translatable strings export function _td(s: string): string { @@ -455,10 +464,14 @@ function getLangsJson(): Promise { request( { method: "GET", url }, (err, response, body) => { - if (err || response.status < 200 || response.status >= 300) { + if (err) { reject(err); return; } + if (response.status < 200 || response.status >= 300) { + reject(new Error(`Failed to load ${url}, got ${response.status}`)); + return; + } resolve(JSON.parse(body)); }, ); @@ -498,10 +511,14 @@ function getLanguage(langPath: string): Promise { request( { method: "GET", url: langPath }, (err, response, body) => { - if (err || response.status < 200 || response.status >= 300) { + if (err) { reject(err); return; } + if (response.status < 200 || response.status >= 300) { + reject(new Error(`Failed to load ${langPath}, got ${response.status}`)); + return; + } resolve(weblateToCounterpart(JSON.parse(body))); }, ); diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index b1b8199f93..ba2b91aa2c 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -62,7 +62,7 @@ export const getOrder = (order: string, creationTs: number, roomId: string): Arr if (typeof order === "string" && Array.from(order).every((c: string) => { const charCode = c.charCodeAt(0); - return charCode >= 0x20 && charCode <= 0x7F; + return charCode >= 0x20 && charCode <= 0x7E; })) { validatedOrder = order; } diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 024c484c41..c50db43d08 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -34,6 +34,7 @@ import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import SettingsStore from "../../../settings/SettingsStore"; import { VisibilityProvider } from "../filters/VisibilityProvider"; +import { MultiLock } from "../../../utils/MultiLock"; /** * Fired when the Algorithm has determined a list has been updated. @@ -77,6 +78,7 @@ export class Algorithm extends EventEmitter { } = {}; private allowedByFilter: Map = new Map(); private allowedRoomsByFilters: Set = new Set(); + private handlerLock = new MultiLock(); /** * Set to true to suspend emissions of algorithm updates. @@ -679,191 +681,204 @@ export class Algorithm extends EventEmitter { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Handle room update for ${room.roomId} called with cause ${cause}`); + console.log(`Acquiring lock for ${room.roomId} with cause ${cause}`); } - if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); - - // Note: check the isSticky against the room ID just in case the reference is wrong - const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId; - if (cause === RoomUpdateCause.NewRoom) { - const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room; - const roomTags = this.roomIdsToTags[room.roomId]; - const hasTags = roomTags && roomTags.length > 0; - - // Don't change the cause if the last sticky room is being re-added. If we fail to - // pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus - // lose the room. - if (hasTags && !isForLastSticky) { - console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); - cause = RoomUpdateCause.PossibleTagChange; + const release = await this.handlerLock.acquire(room.roomId); + try { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Handle room update for ${room.roomId} called with cause ${cause}`); } + if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); - // Check to see if the room is known first - let knownRoomRef = this.rooms.includes(room); - if (hasTags && !knownRoomRef) { - console.warn(`${room.roomId} might be a reference change - attempting to update reference`); - this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r); - knownRoomRef = this.rooms.includes(room); - if (!knownRoomRef) { - console.warn(`${room.roomId} is still not referenced. It may be sticky.`); + // Note: check the isSticky against the room ID just in case the reference is wrong + const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId; + if (cause === RoomUpdateCause.NewRoom) { + const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room; + const roomTags = this.roomIdsToTags[room.roomId]; + const hasTags = roomTags && roomTags.length > 0; + + // Don't change the cause if the last sticky room is being re-added. If we fail to + // pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus + // lose the room. + if (hasTags && !isForLastSticky) { + console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); + cause = RoomUpdateCause.PossibleTagChange; + } + + // Check to see if the room is known first + let knownRoomRef = this.rooms.includes(room); + if (hasTags && !knownRoomRef) { + console.warn(`${room.roomId} might be a reference change - attempting to update reference`); + this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r); + knownRoomRef = this.rooms.includes(room); + if (!knownRoomRef) { + console.warn(`${room.roomId} is still not referenced. It may be sticky.`); + } + } + + // If we have tags for a room and don't have the room referenced, something went horribly + // wrong - the reference should have been updated above. + if (hasTags && !knownRoomRef && !isSticky) { + throw new Error(`${room.roomId} is missing from room array but is known`); + } + + // Like above, update the reference to the sticky room if we need to + if (hasTags && isSticky) { + // Go directly in and set the sticky room's new reference, being careful not + // to trigger a sticky room update ourselves. + this._stickyRoom.room = room; + } + + // If after all that we're still a NewRoom update, add the room if applicable. + // We don't do this for the sticky room (because it causes duplication issues) + // or if we know about the reference (as it should be replaced). + if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) { + this.rooms.push(room); } } - // If we have tags for a room and don't have the room referenced, something went horribly - // wrong - the reference should have been updated above. - if (hasTags && !knownRoomRef && !isSticky) { - throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`); - } + let didTagChange = false; + if (cause === RoomUpdateCause.PossibleTagChange) { + const oldTags = this.roomIdsToTags[room.roomId] || []; + const newTags = this.getTagsForRoom(room); + const diff = arrayDiff(oldTags, newTags); + if (diff.removed.length > 0 || diff.added.length > 0) { + for (const rmTag of diff.removed) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Removing ${room.roomId} from ${rmTag}`); + } + const algorithm: OrderingAlgorithm = this.algorithms[rmTag]; + if (!algorithm) throw new Error(`No algorithm for ${rmTag}`); + await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved); + this._cachedRooms[rmTag] = algorithm.orderedRooms; + this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list + this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed + } + for (const addTag of diff.added) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Adding ${room.roomId} to ${addTag}`); + } + const algorithm: OrderingAlgorithm = this.algorithms[addTag]; + if (!algorithm) throw new Error(`No algorithm for ${addTag}`); + await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom); + this._cachedRooms[addTag] = algorithm.orderedRooms; + } - // Like above, update the reference to the sticky room if we need to - if (hasTags && isSticky) { - // Go directly in and set the sticky room's new reference, being careful not - // to trigger a sticky room update ourselves. - this._stickyRoom.room = room; - } + // Update the tag map so we don't regen it in a moment + this.roomIdsToTags[room.roomId] = newTags; - // If after all that we're still a NewRoom update, add the room if applicable. - // We don't do this for the sticky room (because it causes duplication issues) - // or if we know about the reference (as it should be replaced). - if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) { - this.rooms.push(room); - } - } - - let didTagChange = false; - if (cause === RoomUpdateCause.PossibleTagChange) { - const oldTags = this.roomIdsToTags[room.roomId] || []; - const newTags = this.getTagsForRoom(room); - const diff = arrayDiff(oldTags, newTags); - if (diff.removed.length > 0 || diff.added.length > 0) { - for (const rmTag of diff.removed) { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Removing ${room.roomId} from ${rmTag}`); + console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`); } - const algorithm: OrderingAlgorithm = this.algorithms[rmTag]; - if (!algorithm) throw new Error(`No algorithm for ${rmTag}`); - await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved); - this._cachedRooms[rmTag] = algorithm.orderedRooms; - this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list - this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed - } - for (const addTag of diff.added) { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Adding ${room.roomId} to ${addTag}`); - } - const algorithm: OrderingAlgorithm = this.algorithms[addTag]; - if (!algorithm) throw new Error(`No algorithm for ${addTag}`); - await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom); - this._cachedRooms[addTag] = algorithm.orderedRooms; - } - - // Update the tag map so we don't regen it in a moment - this.roomIdsToTags[room.roomId] = newTags; - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`); - } - cause = RoomUpdateCause.Timeline; - didTagChange = true; - } else { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`); - } - cause = RoomUpdateCause.Timeline; - } - - if (didTagChange && isSticky) { - // Manually update the tag for the sticky room without triggering a sticky room - // update. The update will be handled implicitly by the sticky room handling and - // requires no changes on our part, if we're in the middle of a sticky room change. - if (this._lastStickyRoom) { - this._stickyRoom = { - room, - tag: this.roomIdsToTags[room.roomId][0], - position: 0, // right at the top as it changed tags - }; + cause = RoomUpdateCause.Timeline; + didTagChange = true; } else { - // We have to clear the lock as the sticky room change will trigger updates. - await this.setStickyRoom(room); + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`); + } + cause = RoomUpdateCause.Timeline; + } + + if (didTagChange && isSticky) { + // Manually update the tag for the sticky room without triggering a sticky room + // update. The update will be handled implicitly by the sticky room handling and + // requires no changes on our part, if we're in the middle of a sticky room change. + if (this._lastStickyRoom) { + this._stickyRoom = { + room, + tag: this.roomIdsToTags[room.roomId][0], + position: 0, // right at the top as it changed tags + }; + } else { + // We have to clear the lock as the sticky room change will trigger updates. + await this.setStickyRoom(room); + } } } - } - // If the update is for a room change which might be the sticky room, prevent it. We - // need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though - // as the sticky room relies on this. - if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { - if (this.stickyRoom === room) { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`); + // If the update is for a room change which might be the sticky room, prevent it. We + // need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though + // as the sticky room relies on this. + if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { + if (this.stickyRoom === room) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.warn( + `[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`, + ); + } + return false; } - return false; } - } - if (!this.roomIdsToTags[room.roomId]) { - if (CAUSES_REQUIRING_ROOM.includes(cause)) { + if (!this.roomIdsToTags[room.roomId]) { + if (CAUSES_REQUIRING_ROOM.includes(cause)) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`); + } + return false; + } + if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`); + console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`); + } + + // Get the tags for the room and populate the cache + const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); + + // "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(), + // which means we should *always* have a tag to go off of. + if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); + + this.roomIdsToTags[room.roomId] = roomTags; + + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags); } - return false; } if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`); + console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`); } - // Get the tags for the room and populate the cache - const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); + const tags = this.roomIdsToTags[room.roomId]; + if (!tags) { + console.warn(`No tags known for "${room.name}" (${room.roomId})`); + return false; + } - // "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(), - // which means we should *always* have a tag to go off of. - if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); + let changed = didTagChange; + for (const tag of tags) { + const algorithm: OrderingAlgorithm = this.algorithms[tag]; + if (!algorithm) throw new Error(`No algorithm for ${tag}`); - this.roomIdsToTags[room.roomId] = roomTags; + await algorithm.handleRoomUpdate(room, cause); + this._cachedRooms[tag] = algorithm.orderedRooms; + + // Flag that we've done something + this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list + this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed + changed = true; + } if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags); + console.log( + `[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`, + ); } + return changed; + } finally { + release(); } - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`); - } - - const tags = this.roomIdsToTags[room.roomId]; - if (!tags) { - console.warn(`No tags known for "${room.name}" (${room.roomId})`); - return false; - } - - let changed = didTagChange; - for (const tag of tags) { - const algorithm: OrderingAlgorithm = this.algorithms[tag]; - if (!algorithm) throw new Error(`No algorithm for ${tag}`); - - await algorithm.handleRoomUpdate(room, cause); - this._cachedRooms[tag] = algorithm.orderedRooms; - - // Flag that we've done something - this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list - this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed - changed = true; - } - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`); - } - return changed; } } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index b0a76a35af..397d637125 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020, 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. @@ -52,6 +52,8 @@ import {getCustomTheme} from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { ELEMENT_CLIENT_ID } from "../../identifiers"; +import { getUserLanguage } from "../../languageHandler"; // TODO: Destroy all of this code @@ -194,6 +196,9 @@ export class StopGapWidget extends EventEmitter { currentUserId: MatrixClientPeg.get().getUserId(), userDisplayName: OwnProfileStore.instance.displayName, userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), + clientId: ELEMENT_CLIENT_ID, + clientTheme: SettingsStore.getValue("theme"), + clientLanguage: getUserLanguage(), }, opts?.asPopout); const parsed = new URL(templated); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 8a286d909b..25e81c47a2 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -44,6 +44,7 @@ import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; import {tryTransformPermalinkToLocalHref} from "../../utils/permalinks/Permalinks"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; // TODO: Purge this from the universe @@ -144,6 +145,52 @@ export class StopGapWidgetDriver extends WidgetDriver { return {roomId, eventId: r.event_id}; } + public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise { + limit = limit > 0 ? Math.min(limit, 25) : 25; // arbitrary choice + + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + const room = client.getRoom(roomId); + if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client"); + + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); // timelines are most recent last + for (let i = events.length - 1; i > 0; i--) { + if (results.length >= limit) break; + + const ev = events[i]; + if (ev.getType() !== eventType) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue; + results.push(ev); + } + + return results.map(e => e.event); + } + + public async readStateEvents( + eventType: string, stateKey: string | undefined, limit: number, + ): Promise { + limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice + + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + const room = client.getRoom(roomId); + if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client"); + + const results: MatrixEvent[] = []; + const state = room.currentState.events.get(eventType); + if (state) { + if (stateKey === "" || !!stateKey) { + const forKey = state.get(stateKey); + if (forKey) results.push(forKey); + } else { + results.push(...Array.from(state.values())); + } + } + + return results.slice(0, limit).map(e => e.event); + } + public async askOpenID(observer: SimpleObservable) { const oidcState = WidgetPermissionStore.instance.getOIDCState( this.forWidget, this.forWidgetKind, this.inRoomId, diff --git a/src/utils/MultiLock.ts b/src/utils/MultiLock.ts new file mode 100644 index 0000000000..507a924dda --- /dev/null +++ b/src/utils/MultiLock.ts @@ -0,0 +1,30 @@ +/* +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 { EnhancedMap } from "./maps"; +import AwaitLock from "await-lock"; + +export type DoneFn = () => void; + +export class MultiLock { + private locks = new EnhancedMap(); + + public async acquire(key: string): Promise { + const lock = this.locks.getOrCreate(key, new AwaitLock()); + await lock.acquireAsync(); + return () => lock.release(); + } +} diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 56bce5b2da..e527f43c29 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -75,7 +75,8 @@ export function arraySmoothingResample(input: number[], points: number): number[ for (let i = 1; i < input.length - 1; i += 2) { const prevPoint = input[i - 1]; const nextPoint = input[i + 1]; - const average = (prevPoint + nextPoint) / 2; + const currPoint = input[i]; + const average = (prevPoint + nextPoint + currPoint) / 3; samples.push(average); } input = samples; diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts index 5488ed6b84..61da435151 100644 --- a/src/voice/Playback.ts +++ b/src/voice/Playback.ts @@ -15,12 +15,13 @@ limitations under the License. */ import EventEmitter from "events"; -import {UPDATE_EVENT} from "../stores/AsyncStore"; -import {arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample} from "../utils/arrays"; -import {SimpleObservable} from "matrix-widget-api"; -import {IDestroyable} from "../utils/IDestroyable"; -import {PlaybackClock} from "./PlaybackClock"; -import {createAudioContext, decodeOgg} from "./compat"; +import { UPDATE_EVENT } from "../stores/AsyncStore"; +import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays"; +import { SimpleObservable } from "matrix-widget-api"; +import { IDestroyable } from "../utils/IDestroyable"; +import { PlaybackClock } from "./PlaybackClock"; +import { createAudioContext, decodeOgg } from "./compat"; +import { clamp } from "../utils/numbers"; export enum PlaybackState { Decoding = "decoding", @@ -33,9 +34,20 @@ export const PLAYBACK_WAVEFORM_SAMPLES = 39; const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); function makePlaybackWaveform(input: number[]): number[] { - // We use a smoothing resample to keep the rough shape of the waveform the user will be seeing. We - // then rescale so the user can see the waveform properly (loud noises == 100%). - return arrayRescale(arraySmoothingResample(input, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); + // First, convert negative amplitudes to positive so we don't detect zero as "noisy". + const noiseWaveform = input.map(v => Math.abs(v)); + + // Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. + // We also rescale the waveform to be 0-1 for the remaining function logic. + const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); + + // Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled + // waveform. Most speech happens below the 0.5 mark. + const filtered = resampled.map(v => clamp(v, 0.1, 0.5)); + + // Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something + // sensible. This is what we return to keep our contract of "values between zero and one". + return arrayRescale(filtered, 0, 1); } export class Playback extends EventEmitter implements IDestroyable { @@ -126,6 +138,7 @@ export class Playback extends EventEmitter implements IDestroyable { this.waveformObservable.update(this.resampledWaveform); this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore + this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update this.clock.durationSeconds = this.audioBuf.duration; } diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts index 06d6381691..d6d36e861f 100644 --- a/src/voice/PlaybackClock.ts +++ b/src/voice/PlaybackClock.ts @@ -54,6 +54,15 @@ export class PlaybackClock implements IDestroyable { } }; + /** + * Mark the time in the audio context where the clip starts/has been loaded. + * This is to ensure the clock isn't skewed into thinking it is ~0.5s into + * a clip when the duration is set. + */ + public flagLoadTime() { + this.clipStart = this.context.currentTime; + } + public flagStart() { if (this.stopped) { this.clipStart = this.context.currentTime; diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx index 273d22dc81..05e6c59083 100644 --- a/src/widgets/CapabilityText.tsx +++ b/src/widgets/CapabilityText.tsx @@ -96,6 +96,16 @@ export class CapabilityText { [EventDirection.Receive]: _td("See when the avatar changes in your active room"), }, }, + [EventType.RoomMember]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Kick, ban, or invite people to this room, and make you leave"), + [EventDirection.Receive]: _td("See when people join, leave, or are invited to this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Kick, ban, or invite people to your active room, and make you leave"), + [EventDirection.Receive]: _td("See when people join, leave, or are invited to your active room"), + }, + }, }; private static nonStateSendRecvCaps: ISendRecvStaticCapText = { diff --git a/test/test-utils.js b/test/test-utils.js index 6dc02463a5..953693a820 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -95,6 +95,7 @@ export function createTestClient() { getItem: jest.fn(), }, }, + decryptEventIfNeeded: () => Promise.resolve(), }; } diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts index b55de3b73b..5974915965 100644 --- a/test/utils/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -73,10 +73,10 @@ describe('arrays', () => { // we'd be feeding a thousand values in and seeing what a curve of 250 values looks like, // but that's not really feasible to manually verify accuracy. [ - {input: [2, 2, 0, 2, 2, 0, 2, 2, 0], output: [1, 1, 2, 1]}, // Odd -> Even - {input: [2, 2, 0, 2, 2, 0, 2, 2, 0], output: [1, 1, 2]}, // Odd -> Odd - {input: [2, 2, 0, 2, 2, 0, 2, 2], output: [1, 1, 2]}, // Even -> Odd - {input: [2, 2, 0, 2, 2, 0, 2, 2], output: [1, 2]}, // Even -> Even + {input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3, 3]}, // Odd -> Even + {input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3]}, // Odd -> Odd + {input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3, 3]}, // Even -> Odd + {input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3]}, // Even -> Even ].forEach((c, i) => expectSample(i, c.input, c.output, true)); }); diff --git a/yarn.lock b/yarn.lock index 7712ac507a..19c0646d32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5670,8 +5670,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "10.1.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2d73805ca3d8c5a140fe05e574f826696de1656a" + version "11.0.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/52a893a8116d60bb76f1b015d3161a15404b3628" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5704,10 +5704,10 @@ matrix-react-test-utils@^0.2.2: "@babel/traverse" "^7.13.17" walk "^2.3.14" -matrix-widget-api@^0.1.0-beta.13: - version "0.1.0-beta.13" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.13.tgz#ebddc83eaef39bbb87b621a02a35902e1a29b9ef" - integrity sha512-DJAvuX2E7gxc/a9rtJPDh17ba9xGIOAoBHcWirNTN3KGodzsrZ+Ns+M/BREFWMwGS5yEBZko5eq7uhXStEbnyQ== +matrix-widget-api@^0.1.0-beta.14: + version "0.1.0-beta.14" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.14.tgz#e38beed71c5ebd62c1ac1d79ef262d7150b42c70" + integrity sha512-5tC6LO1vCblKg/Hfzf5U1eHPz1nHUZIobAm3gkEKV5vpYPgRpr8KdkLiGB78VZid0tB17CVtAb4VKI8CQ3lhAQ== dependencies: "@types/events" "^3.0.0" events "^3.2.0"