diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 0ae59da09a..4f9826391a 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -10,6 +10,8 @@ on: jobs: end-to-end: runs-on: ubuntu-latest + env: + PR_NUMBER: ${{github.event.number}} container: vectorim/element-web-ci-e2etests-env:latest steps: - name: Checkout code diff --git a/.stylelintrc.js b/.stylelintrc.js index 0e6de7000f..c044b19a63 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -17,6 +17,7 @@ module.exports = { "selector-list-comma-newline-after": null, "at-rule-no-unknown": null, "no-descending-specificity": null, + "no-empty-first-line": true, "scss/at-rule-no-unknown": [true, { // https://github.com/vector-im/element-web/issues/10544 "ignoreAtRules": ["define-mixin"], diff --git a/package.json b/package.json index 2f59ba4504..6245b2c34e 100644 --- a/package.json +++ b/package.json @@ -82,8 +82,8 @@ "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", - "matrix-js-sdk": "12.4.0", - "matrix-widget-api": "^0.1.0-beta.15", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-widget-api": "^0.1.0-beta.16", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", diff --git a/res/css/_components.scss b/res/css/_components.scss index 566b84a7c8..ffaec43b68 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -132,6 +132,7 @@ @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; +@import "./views/elements/_EventTilePreview.scss"; @import "./views/elements/_FacePile.scss"; @import "./views/elements/_Field.scss"; @import "./views/elements/_ImageView.scss"; diff --git a/res/css/views/elements/_EventTilePreview.scss b/res/css/views/elements/_EventTilePreview.scss new file mode 100644 index 0000000000..6bb726168f --- /dev/null +++ b/res/css/views/elements/_EventTilePreview.scss @@ -0,0 +1,22 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_EventTilePreview_loader { + &.mx_IRCLayout, + &.mx_GroupLayout { + padding: 9px 0; + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index c6983ae631..6805036e3d 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -92,6 +92,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/message-bar/reply.svg'); } +.mx_MessageActionBar_threadButton::after { + mask-image: url('$(res)/img/element-icons/message/thread.svg'); +} + .mx_MessageActionBar_editButton::after { mask-image: url('$(res)/img/element-icons/room/message-bar/edit.svg'); } diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index fcb83dfd7a..7ac4787111 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -232,6 +232,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/files.svg'); } +.mx_RoomSummaryCard_icon_threads::before { + mask-image: url('$(res)/img/element-icons/message/thread.svg'); +} + .mx_RoomSummaryCard_icon_share::before { mask-image: url('$(res)/img/element-icons/room/share.svg'); } diff --git a/res/css/views/rooms/_Autocomplete.scss b/res/css/views/rooms/_Autocomplete.scss index bebe0b47c9..8d2b338d9d 100644 --- a/res/css/views/rooms/_Autocomplete.scss +++ b/res/css/views/rooms/_Autocomplete.scss @@ -10,6 +10,7 @@ max-height: 35vh; overflow: clip; display: flex; + flex-direction: column; box-shadow: 0px -16px 32px $composer-shadow-color; } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 447c878f14..351abc5cd9 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -643,6 +643,7 @@ $hover-select-border: 4px; // Remove some of the default tile padding so that the error is centered margin-right: 0; + .mx_EventTile_line { padding-left: 0; margin-right: 0; @@ -674,3 +675,62 @@ $hover-select-border: 4px; margin-right: 0; } } + +.mx_ThreadInfo:hover { + cursor: pointer; +} + +.mx_ThreadView { + display: flex; + flex-direction: column; + + .mx_ScrollPanel { + margin-top: 20px; + + .mx_RoomView_MessageList { + padding: 0; + } + } + + .mx_EventTile_senderDetails { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + + a { + flex: 1; + min-width: none; + max-width: 100%; + display: flex; + align-items: center; + + .mx_SenderProfile { + flex: 1; + } + } + } + + .mx_ThreadView_List { + flex: 1; + overflow: scroll; + } + + .mx_EventTile_roomName { + display: none; + } + + .mx_EventTile_line { + padding-left: 0 !important; + order: 10 !important; + } + + .mx_EventTile { + width: 100%; + display: flex; + flex-direction: column; + margin-top: 0; + padding-bottom: 5px; + margin-bottom: 5px; + } +} diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index d164fac8f2..9445242306 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -340,3 +340,19 @@ limitations under the License. height: 50px; } } + +/** + * Unstable compact mode + */ + +.mx_MessageComposer.mx_MessageComposer--compact { + margin-right: 0; + + .mx_MessageComposer_wrapper { + padding: 0; + } + + .mx_MessageComposer_button:last-child { + margin-right: 0; + } +} diff --git a/res/img/element-icons/message/thread.svg b/res/img/element-icons/message/thread.svg new file mode 100644 index 0000000000..b4a7cc0066 --- /dev/null +++ b/res/img/element-icons/message/thread.svg @@ -0,0 +1,4 @@ + + + + diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 0990af70ce..ec021236d9 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -10,6 +10,7 @@ defbranch="$3" rm -r "$defrepo" || true +# A function that clones a branch of a repo based on the org, repo and branch clone() { org=$1 repo=$2 @@ -21,45 +22,38 @@ clone() { fi } -# Try the PR author's branch in case it exists on the deps as well. -# First we check if GITHUB_HEAD_REF is defined, -# Then we check if BUILDKITE_BRANCH is defined, -# if they aren't we can assume this is a Netlify build -if [ -n "$GITHUB_HEAD_REF" ]; then - head=$GITHUB_HEAD_REF -elif [ -n "$BUILDKITE_BRANCH" ]; then - head=$BUILDKITE_BRANCH -else - # Netlify doesn't give us info about the fork so we have to get it from GitHub API - apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/" - apiEndpoint+=$REVIEW_ID - head=$(curl $apiEndpoint | jq -r '.head.label') -fi +# A function that gets info about a PR from the GitHub API based on its number +getPRInfo() { + number=$1 + if [ -n "$number" ]; then + echo "Getting info about a PR with number $number" -# If head is set, it will contain on Buildkite either: -# * "branch" when the author's branch and target branch are in the same repo -# * "fork:branch" when the author's branch is in their fork or if this is a Netlify build -# We can split on `:` into an array to check. -# For GitHub Actions we need to inspect GITHUB_REPOSITORY and GITHUB_ACTOR -# to determine whether the branch is from a fork or not -BRANCH_ARRAY=(${head//:/ }) -if [[ "${#BRANCH_ARRAY[@]}" == "1" ]]; then + apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/" + apiEndpoint+=$number - if [ -n "$GITHUB_HEAD_REF" ]; then - if [[ "$GITHUB_REPOSITORY" == "$deforg"* ]]; then - clone $deforg $defrepo $GITHUB_HEAD_REF - else - REPO_ARRAY=(${GITHUB_REPOSITORY//\// }) - clone $REPO_ARRAY[0] $defrepo $GITHUB_HEAD_REF - fi - else - clone $deforg $defrepo $BUILDKITE_BRANCH + head=$(curl $apiEndpoint | jq -r '.head.label') fi +} -elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then - clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]} +# Some CIs don't give us enough info, so we just get the PR number and ask the +# GH API for more info - "fork:branch". Some give us this directly. +if [ -n "$BUILDKITE_BRANCH" ]; then + # BuildKite + head=$BUILDKITE_BRANCH +elif [ -n "$PR_NUMBER" ]; then + # GitHub + getPRInfo $PR_NUMBER +elif [ -n "$REVIEW_ID" ]; then + # Netlify + getPRInfo $REVIEW_ID fi +# $head will always be in the format "fork:branch", so we split it by ":" into +# an array. The first element will then be the fork and the second the branch. +# Based on that we clone +BRANCH_ARRAY=(${head//:/ }) +clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]} + # Try the target branch of the push or PR. if [ -n $GITHUB_BASE_REF ]; then clone $deforg $defrepo $GITHUB_BASE_REF diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index f43351aab2..7d0ff560b7 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -213,6 +213,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours + opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread"); // Connect the matrix client to the dispatcher and setting handlers MatrixActionCreators.start(this.matrixClient); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 902d2a0921..531dc9fbe9 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1016,6 +1016,7 @@ export default class MatrixChat extends React.PureComponent { this.setStateForNewView({ view: Views.LOGGED_IN, justRegistered, + currentRoomId: null, }); this.setPage(PageTypes.HomePage); this.notifyNewScreen('home'); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 1691d90651..8bf1f5bd5f 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -173,6 +173,8 @@ interface IProps { onUnfillRequest?(backwards: boolean, scrollToken: string): void; getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations; + + hideThreadedMessages?: boolean; } interface IState { @@ -265,6 +267,9 @@ export default class MessagePanel extends React.Component { componentDidMount() { this.calculateRoomMembersCount(); this.props.room?.on("RoomState.members", this.calculateRoomMembersCount); + if (SettingsStore.getValue("feature_thread")) { + this.props.room?.getThreads().forEach(thread => thread.fetchReplyChain()); + } this.isMounted = true; } @@ -443,6 +448,12 @@ export default class MessagePanel extends React.Component { // Always show highlighted event if (this.props.highlightedEventId === mxEv.getId()) return true; + if (mxEv.replyEventId + && this.props.hideThreadedMessages + && SettingsStore.getValue("feature_thread")) { + return false; + } + return !shouldHideEvent(mxEv, this.context); } diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 95d70e913a..67634c63d2 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -45,17 +45,21 @@ import GroupRoomInfo from "../views/groups/GroupRoomInfo"; import UserInfo from "../views/right_panel/UserInfo"; import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; import FilePanel from "./FilePanel"; +import ThreadView from "./ThreadView"; +import ThreadPanel from "./ThreadPanel"; import NotificationPanel from "./NotificationPanel"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; import { throttle } from 'lodash'; import SpaceStore from "../../stores/SpaceStore"; +import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; interface IProps { room?: Room; // if showing panels for a given room, this is set groupId?: string; // if showing panels for a given group, this is set user?: User; // used if we know the user ahead of opening the panel resizeNotifier: ResizeNotifier; + permalinkCreator?: RoomPermalinkCreator; } interface IState { @@ -309,6 +313,22 @@ export default class RightPanel extends React.Component { panel = ; break; + case RightPanelPhases.ThreadView: + panel = ; + break; + + case RightPanelPhases.ThreadPanel: + panel = ; + break; + case RightPanelPhases.RoomSummary: panel = ; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 474b99262d..baf557ee18 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -2052,7 +2052,10 @@ export default class RoomView extends React.Component { const showRightPanel = this.state.room && this.state.showRightPanel; const rightPanel = showRightPanel - ? + ? : null; const timelineClasses = classNames("mx_RoomView_timeline", { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 5c9662dcf7..767d6632aa 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -16,7 +16,7 @@ limitations under the License. import React, { RefObject, useContext, useRef, useState } from "react"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import { Preset, JoinRule } from "matrix-js-sdk/src/@types/partials"; +import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials"; import { Room } from "matrix-js-sdk/src/models/room"; import { EventSubscription } from "fbemitter"; @@ -505,11 +505,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setError(""); setBusy(true); try { + const isPublic = space.getJoinRule() === JoinRule.Public; 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, + preset: isPublic ? Preset.PublicChat : Preset.PrivateChat, name, }, spinner: false, @@ -517,6 +518,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { andView: false, inlineErrors: true, parentSpace: space, + joinRule: !isPublic ? JoinRule.Restricted : undefined, suggested: true, }); })); diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx new file mode 100644 index 0000000000..a0bccfdce9 --- /dev/null +++ b/src/components/structures/ThreadPanel.tsx @@ -0,0 +1,93 @@ +/* +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 { MatrixEvent, Room } from 'matrix-js-sdk/src'; +import { Thread } from 'matrix-js-sdk/src/models/thread'; + +import BaseCard from "../views/right_panel/BaseCard"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../MatrixClientPeg'; + +import ResizeNotifier from '../../utils/ResizeNotifier'; +import EventTile from '../views/rooms/EventTile'; + +interface IProps { + roomId: string; + onClose: () => void; + resizeNotifier: ResizeNotifier; +} + +interface IState { + threads?: Thread[]; +} + +@replaceableComponent("structures.ThreadView") +export default class ThreadPanel extends React.Component { + private room: Room; + + constructor(props: IProps) { + super(props); + this.room = MatrixClientPeg.get().getRoom(this.props.roomId); + } + + public componentDidMount(): void { + this.room.on("Thread.update", this.onThreadEventReceived); + this.room.on("Thread.ready", this.onThreadEventReceived); + } + + public componentWillUnmount(): void { + this.room.removeListener("Thread.update", this.onThreadEventReceived); + this.room.removeListener("Thread.ready", this.onThreadEventReceived); + } + + private onThreadEventReceived = () => this.updateThreads(); + + private updateThreads = (callback?: () => void): void => { + this.setState({ + threads: this.room.getThreads(), + }, callback); + }; + + private renderEventTile(event: MatrixEvent): JSX.Element { + return ; + } + + public render(): JSX.Element { + return ( + + { + this.state?.threads.map((thread: Thread) => { + if (thread.ready) { + return this.renderEventTile(thread.rootEvent); + } + }) + } + + ); + } +} diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx new file mode 100644 index 0000000000..94f3f26261 --- /dev/null +++ b/src/components/structures/ThreadView.tsx @@ -0,0 +1,148 @@ +/* +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 { MatrixEvent, Room } from 'matrix-js-sdk/src'; +import { Thread } from 'matrix-js-sdk/src/models/thread'; + +import BaseCard from "../views/right_panel/BaseCard"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import { replaceableComponent } from "../../utils/replaceableComponent"; + +import ResizeNotifier from '../../utils/ResizeNotifier'; +import { TileShape } from '../views/rooms/EventTile'; +import MessageComposer from '../views/rooms/MessageComposer'; +import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; +import { Layout } from '../../settings/Layout'; +import TimelinePanel from './TimelinePanel'; +import dis from "../../dispatcher/dispatcher"; +import { ActionPayload } from '../../dispatcher/payloads'; +import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload'; +import { Action } from '../../dispatcher/actions'; + +interface IProps { + room: Room; + onClose: () => void; + resizeNotifier: ResizeNotifier; + mxEvent: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; +} + +interface IState { + replyToEvent?: MatrixEvent; + thread?: Thread; +} + +@replaceableComponent("structures.ThreadView") +export default class ThreadView extends React.Component { + private dispatcherRef: string; + + constructor(props: IProps) { + super(props); + this.state = {}; + } + + public componentDidMount(): void { + this.setupThread(this.props.mxEvent); + this.dispatcherRef = dis.register(this.onAction); + } + + public componentWillUnmount(): void { + this.teardownThread(); + dis.unregister(this.dispatcherRef); + } + + public componentDidUpdate(prevProps) { + if (prevProps.mxEvent !== this.props.mxEvent) { + this.teardownThread(); + this.setupThread(this.props.mxEvent); + } + + if (prevProps.room !== this.props.room) { + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomSummary, + }); + } + } + + private onAction = (payload: ActionPayload): void => { + if (payload.phase == RightPanelPhases.ThreadView && payload.event) { + if (payload.event !== this.props.mxEvent) { + this.teardownThread(); + this.setupThread(payload.event); + } + } + }; + + private setupThread = (mxEv: MatrixEvent) => { + const thread = mxEv.getThread(); + if (thread) { + thread.on("Thread.update", this.updateThread); + thread.once("Thread.ready", this.updateThread); + this.updateThread(thread); + } + }; + + private teardownThread = () => { + if (this.state.thread) { + this.state.thread.removeListener("Thread.update", this.updateThread); + this.state.thread.removeListener("Thread.ready", this.updateThread); + } + }; + + private updateThread = (thread?: Thread) => { + if (thread) { + this.setState({ thread }); + } else { + this.forceUpdate(); + } + }; + + public render(): JSX.Element { + return ( + + { this.state.thread && ( + empty} + alwaysShowTimestamps={true} + layout={Layout.Group} + hideThreadedMessages={false} + /> + ) } + + + ); + } +} diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index f62676a4fc..e5fa6967dc 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -126,6 +126,8 @@ interface IProps { // callback which is called when we wish to paginate the timeline window. onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise; + + hideThreadedMessages?: boolean; } interface IState { @@ -214,6 +216,7 @@ class TimelinePanel extends React.Component { timelineCap: Number.MAX_VALUE, className: 'mx_RoomView_messagePanel', sendReadReceiptOnLoad: true, + hideThreadedMessages: true, }; private lastRRSentEventId: string = undefined; @@ -1511,6 +1514,7 @@ class TimelinePanel extends React.Component { showReactions={this.props.showReactions} layout={this.props.layout} enableFlair={SettingsStore.getValue(UIFeature.Flair)} + hideThreadedMessages={this.props.hideThreadedMessages} /> ); } diff --git a/src/components/views/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx index 03927c7d62..d80245918f 100644 --- a/src/components/views/dialogs/CreateSubspaceDialog.tsx +++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx @@ -79,7 +79,7 @@ const CreateSubspaceDialog: React.FC = ({ space, onAddExistingSpaceClick } try { - await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace }); + await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule }); onFinished(true); } catch (e) { diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx index c184664df3..366adb887c 100644 --- a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx +++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.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. @@ -20,6 +20,7 @@ import { _t } from "../../../languageHandler"; import { IDialogProps } from "./IDialogProps"; import { Capability, + isTimelineCapability, Widget, WidgetEventCapability, WidgetKind, @@ -30,6 +31,7 @@ import DialogButtons from "../elements/DialogButtons"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import { CapabilityText } from "../../../widgets/CapabilityText"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { lexicographicCompare } from "matrix-js-sdk/src/utils"; interface IProps extends IDialogProps { requestedCapabilities: Set; @@ -91,7 +93,20 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent< } public render() { - const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => { + // We specifically order the timeline capabilities down to the bottom. The capability text + // generation cares strongly about this. + const orderedCapabilities = Object.entries(this.state.booleanStates).sort(([capA], [capB]) => { + const isTimelineA = isTimelineCapability(capA); + const isTimelineB = isTimelineCapability(capB); + + if (!isTimelineA && !isTimelineB) return lexicographicCompare(capA, capB); + if (isTimelineA && !isTimelineB) return 1; + if (!isTimelineA && isTimelineB) return -1; + if (isTimelineA && isTimelineB) return lexicographicCompare(capA, capB); + + return 0; + }); + const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => { const text = CapabilityText.for(cap, this.props.widgetKind); const byline = text.byline ? { text.byline } diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index 68a70133e6..a7ebf40c3a 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -25,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { Layout } from "../../../settings/Layout"; import { UIFeature } from "../../../settings/UIFeature"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Spinner from './Spinner'; interface IProps { /** @@ -45,7 +46,7 @@ interface IProps { /** * The ID of the displayed user */ - userId: string; + userId?: string; /** * The display name of the displayed user @@ -118,13 +119,16 @@ export default class EventTilePreview extends React.Component { } public render() { - const event = this.fakeEvent(this.state); - const className = classnames(this.props.className, { "mx_IRCLayout": this.props.layout == Layout.IRC, "mx_GroupLayout": this.props.layout == Layout.Group, + "mx_EventTilePreview_loader": !this.props.userId, }); + if (!this.props.userId) return
; + + const event = this.fakeEvent(this.state); + return
{ const avatar = ( { }); return
-
{ this.props.reason }
+
{ this.props.htmlReason ? sanitizedHtmlNode(this.props.htmlReason) : this.props.reason }
diff --git a/src/components/views/elements/ReplyThread.tsx b/src/components/views/elements/ReplyThread.tsx index 0eb795e257..d061d52f46 100644 --- a/src/components/views/elements/ReplyThread.tsx +++ b/src/components/views/elements/ReplyThread.tsx @@ -19,6 +19,7 @@ import React from 'react'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { UNSTABLE_ELEMENT_REPLY_IN_THREAD } from "matrix-js-sdk/src/@types/event"; import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import SettingsStore from "../../../settings/SettingsStore"; import { Layout } from "../../../settings/Layout"; @@ -206,15 +207,28 @@ export default class ReplyThread extends React.Component { return { body, html }; } - public static makeReplyMixIn(ev: MatrixEvent) { + public static makeReplyMixIn(ev: MatrixEvent, replyInThread: boolean) { if (!ev) return {}; - return { + + const replyMixin = { 'm.relates_to': { 'm.in_reply_to': { 'event_id': ev.getId(), }, }, }; + + /** + * @experimental + * Rendering hint for threads, only attached if true to make + * sure that Element does not start sending that property for all events + */ + if (replyInThread) { + const inReplyTo = replyMixin['m.relates_to']['m.in_reply_to']; + inReplyTo[UNSTABLE_ELEMENT_REPLY_IN_THREAD.name] = replyInThread; + } + + return replyMixin; } public static makeThread( diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index 0884db7101..9cc995a140 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -173,16 +173,16 @@ class EmojiPicker extends React.Component { }; private onChangeFilter = (filter: string) => { - filter = filter.toLowerCase(); // filter is case insensitive stored lower-case + const lcFilter = filter.toLowerCase().trim(); // filter is case insensitive for (const cat of this.categories) { let emojis; // If the new filter string includes the old filter string, we don't have to re-filter the whole dataset. - if (filter.includes(this.state.filter)) { + if (lcFilter.includes(this.state.filter)) { emojis = this.memoizedDataByCategory[cat.id]; } else { emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id]; } - emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, filter)); + emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, lcFilter)); this.memoizedDataByCategory[cat.id] = emojis; cat.enabled = emojis.length > 0; // The setState below doesn't re-render the header and we already have the refs for updateVisibility, so... @@ -194,9 +194,12 @@ class EmojiPicker extends React.Component { setTimeout(this.updateVisibility, 0); }; - private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => - [emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)] - .some(x => x?.includes(filter)); + private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => { + return emoji.annotation.toLowerCase().includes(filter) || + emoji.emoticon?.toLowerCase().includes(filter) || + emoji.shortcodes.some(x => x.toLowerCase().includes(filter)) || + emoji.unicode.split(ZERO_WIDTH_JOINER).includes(filter); + }; private onEnterFilter = () => { const btn = this.bodyRef.current.querySelector(".mx_EmojiPicker_item"); diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 7fe0eca697..cb8ea7a50d 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -23,6 +23,8 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; +import { Action } from '../../../dispatcher/actions'; +import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import RoomContext from "../../../contexts/RoomContext"; @@ -34,6 +36,7 @@ import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import DownloadActionButton from "./DownloadActionButton"; +import SettingsStore from '../../../settings/SettingsStore'; const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -170,6 +173,17 @@ export default class MessageActionBar extends React.PureComponent { }); }; + onThreadClick = () => { + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.ThreadView, + allowClose: false, + refireParams: { + event: this.props.mxEvent, + }, + }); + } + onEditClick = (ev) => { dis.dispatch({ action: 'edit_event', @@ -254,12 +268,22 @@ export default class MessageActionBar extends React.PureComponent { // The only catch is we do the reply button first so that we can make sure the react // button is the very first button without having to do length checks for `splice()`. if (this.context.canReply) { - toolbarOpts.splice(0, 0, ); + toolbarOpts.splice(0, 0, <> + + { SettingsStore.getValue("feature_thread") && ( + + ) } + ); } if (this.context.canReact) { toolbarOpts.splice(0, 0, { }); }; +const onRoomThreadsClick = () => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.ThreadPanel, + }); +}; + const onRoomSettingsClick = () => { defaultDispatcher.dispatch({ action: "open_room_settings" }); }; @@ -273,6 +280,11 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { + { SettingsStore.getValue("feature_thread") && ( + + ) } diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index b7e067ee93..7a3767deb7 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -43,11 +43,6 @@ import QuestionDialog from "../dialogs/QuestionDialog"; import { ActionPayload } from "../../../dispatcher/payloads"; import AccessibleButton from '../elements/AccessibleButton'; -function eventIsReply(mxEvent: MatrixEvent): boolean { - const relatesTo = mxEvent.getContent()["m.relates_to"]; - return !!(relatesTo && relatesTo["m.in_reply_to"]); -} - function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; if (!html) { @@ -72,7 +67,7 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte if (isEmote) { model = stripEmoteCommand(model); } - const isReply = eventIsReply(editedEvent); + const isReply = !!editedEvent.replyEventId; let plainPrefix = ""; let htmlPrefix = ""; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index dd954e46ce..935a349b10 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -21,6 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from "matrix-js-sdk/src/models/relations"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { Thread } from 'matrix-js-sdk/src/models/thread'; import ReplyThread from "../elements/ReplyThread"; import { _t } from '../../../languageHandler'; @@ -55,6 +56,8 @@ import ReadReceiptMarker from "./ReadReceiptMarker"; import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from '../messages/ReactionsRow'; import { getEventDisplayInfo } from '../../../utils/EventUtils'; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; +import SettingsStore from "../../../settings/SettingsStore"; const eventTileTypes = { [EventType.RoomMessage]: 'messages.MessageEvent', @@ -299,6 +302,9 @@ interface IProps { // whether or not to display the sender hideSender?: boolean; + + // whether or not to display thread info + showThreadInfo?: boolean; } interface IState { @@ -315,6 +321,8 @@ interface IState { reactions: Relations; hover: boolean; + + thread?: Thread; } @replaceableComponent("views.rooms.EventTile") @@ -351,6 +359,8 @@ export default class EventTile extends React.Component { reactions: this.getReactions(), hover: false, + + thread: this.props.mxEvent?.getThread(), }; // don't do RR animations until we are mounted @@ -451,8 +461,20 @@ export default class EventTile extends React.Component { client.on("Room.receipt", this.onRoomReceipt); this.isListeningForReceipts = true; } + + if (SettingsStore.getValue("feature_thread")) { + this.props.mxEvent.once("Thread.ready", this.updateThread); + this.props.mxEvent.on("Thread.update", this.updateThread); + } } + private updateThread = (thread) => { + this.setState({ + thread, + }); + this.forceUpdate(); + }; + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line UNSAFE_componentWillReceiveProps(nextProps) { @@ -463,7 +485,7 @@ export default class EventTile extends React.Component { } } - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate(nextProps, nextState, nextContext) { if (objectHasDiff(this.state, nextState)) { return true; } @@ -491,6 +513,43 @@ export default class EventTile extends React.Component { } } + private renderThreadInfo(): React.ReactNode { + if (!SettingsStore.getValue("feature_thread")) { + return null; + } + + const thread = this.state.thread; + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + if (!thread || this.props.showThreadInfo === false) { + return null; + } + + const avatars = Array.from(thread.participants).map((mxId: string) => { + const member = room.getMember(mxId); + return ; + }); + + return ( +
{ + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.ThreadView, + refireParams: { + event: this.props.mxEvent, + }, + }); + }} + > + + { avatars } + + { thread.length - 1 } { thread.length === 2 ? 'reply' : 'replies' } +
+ ); + } + private onRoomReceipt = (ev, room) => { // ignore events for other rooms const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); @@ -1180,6 +1239,7 @@ export default class EventTile extends React.Component { { keyRequestInfo } { actionBar } { this.props.layout === Layout.IRC && (reactionsRow) } + { this.renderThreadInfo() }
{ this.props.layout !== Layout.IRC && (reactionsRow) } { msgOption } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 8455e9aa11..466675ac64 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -183,7 +183,10 @@ interface IProps { resizeNotifier: ResizeNotifier; permalinkCreator: RoomPermalinkCreator; replyToEvent?: MatrixEvent; + replyInThread?: boolean; + showReplyPreview?: boolean; e2eStatus?: E2EStatus; + compact?: boolean; } interface IState { @@ -201,6 +204,12 @@ export default class MessageComposer extends React.Component { private messageComposerInput: SendMessageComposer; private voiceRecordingButton: VoiceRecordComposerTile; + static defaultProps = { + replyInThread: false, + showReplyPreview: true, + compact: false, + }; + constructor(props) { super(props); VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate); @@ -362,7 +371,7 @@ export default class MessageComposer extends React.Component { render() { const controls = [ - this.state.me ? : null, + this.state.me && !this.props.compact ? : null, this.props.e2eStatus ? : null, @@ -376,6 +385,7 @@ export default class MessageComposer extends React.Component { room={this.props.room} placeholder={this.renderPlaceholderText()} permalinkCreator={this.props.permalinkCreator} + replyInThread={this.props.replyInThread} replyToEvent={this.props.replyToEvent} onChange={this.onChange} disabled={this.state.haveRecording} @@ -450,11 +460,19 @@ export default class MessageComposer extends React.Component { />; } + const classes = classNames({ + "mx_MessageComposer": true, + "mx_GroupLayout": true, + "mx_MessageComposer--compact": this.props.compact, + }); + return ( -
+
{ recordingTooltip }
- + { this.props.showReplyPreview && ( + + ) }
{ controls }
diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js index b8a4315e2d..89b493595f 100644 --- a/src/components/views/rooms/RoomPreviewBar.js +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -28,6 +28,8 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import InviteReason from "../elements/InviteReason"; +const MemberEventHtmlReasonField = "io.element.html_reason"; + const MessageCase = Object.freeze({ NotLoggedIn: "NotLoggedIn", Joining: "Joining", @@ -492,9 +494,13 @@ export default class RoomPreviewBar extends React.Component { } const myUserId = MatrixClientPeg.get().getUserId(); - const reason = this.props.room.currentState.getMember(myUserId).events.member.event.content.reason; - if (reason) { - reasonElement = ; + const memberEventContent = this.props.room.currentState.getMember(myUserId).events.member.getContent(); + + if (memberEventContent.reason) { + reasonElement = ; } primaryActionHandler = this.props.onJoinClick; diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 205320fb68..aca397b6b2 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -57,15 +57,16 @@ import { ActionPayload } from "../../../dispatcher/payloads"; function addReplyToMessageContent( content: IContent, - repliedToEvent: MatrixEvent, + replyToEvent: MatrixEvent, + replyInThread: boolean, permalinkCreator: RoomPermalinkCreator, ): void { - const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); + const replyContent = ReplyThread.makeReplyMixIn(replyToEvent, replyInThread); Object.assign(content, replyContent); // Part of Replies fallback support - prepend the text we're sending // with the text we're replying to - const nestedReply = ReplyThread.getNestedReplyText(repliedToEvent, permalinkCreator); + const nestedReply = ReplyThread.getNestedReplyText(replyToEvent, permalinkCreator); if (nestedReply) { if (content.formatted_body) { content.formatted_body = nestedReply.html + content.formatted_body; @@ -77,8 +78,9 @@ function addReplyToMessageContent( // exported for tests export function createMessageContent( model: EditorModel, - permalinkCreator: RoomPermalinkCreator, replyToEvent: MatrixEvent, + replyInThread: boolean, + permalinkCreator: RoomPermalinkCreator, ): IContent { const isEmote = containsEmote(model); if (isEmote) { @@ -101,7 +103,7 @@ export function createMessageContent( } if (replyToEvent) { - addReplyToMessageContent(content, replyToEvent, permalinkCreator); + addReplyToMessageContent(content, replyToEvent, replyInThread, permalinkCreator); } return content; @@ -129,6 +131,7 @@ interface IProps { room: Room; placeholder?: string; permalinkCreator: RoomPermalinkCreator; + replyInThread?: boolean; replyToEvent?: MatrixEvent; disabled?: boolean; onChange?(model: EditorModel): void; @@ -357,7 +360,12 @@ export default class SendMessageComposer extends React.Component { if (cmd.category === CommandCategories.messages) { content = await this.runSlashCommand(cmd, args); if (replyToEvent) { - addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator); + addReplyToMessageContent( + content, + replyToEvent, + this.props.replyInThread, + this.props.permalinkCreator, + ); } } else { this.runSlashCommand(cmd, args); @@ -400,7 +408,12 @@ export default class SendMessageComposer extends React.Component { const startTime = CountlyAnalytics.getTimestamp(); const { roomId } = this.props.room; if (!content) { - content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent); + content = createMessageContent( + this.model, + replyToEvent, + this.props.replyInThread, + this.props.permalinkCreator, + ); } // don't bother sending an empty message if (!content.body.trim()) return; diff --git a/src/components/views/settings/LayoutSwitcher.tsx b/src/components/views/settings/LayoutSwitcher.tsx index dd7accf9a8..ad8abd0033 100644 --- a/src/components/views/settings/LayoutSwitcher.tsx +++ b/src/components/views/settings/LayoutSwitcher.tsx @@ -26,7 +26,7 @@ import { Layout } from "../../../settings/Layout"; import { SettingLevel } from "../../../settings/SettingLevel"; interface IProps { - userId: string; + userId?: string; displayName: string; avatarUrl: string; messagePreviewText: string; diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index cbf0b7916c..bc54a8155c 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -67,7 +67,7 @@ interface IState extends IThemeState { showAdvanced: boolean; layout: Layout; // User profile data for the message preview - userId: string; + userId?: string; displayName: string; avatarUrl: string; } @@ -92,8 +92,8 @@ export default class AppearanceUserSettingsTab extends React.Component { { space.canInvite(MatrixClientPeg.get()?.getUserId()) ? { - showRoomInviteDialog(space.roomId); if (onFinished) onFinished(); + showRoomInviteDialog(space.roomId); }} >

{ _t("Invite people") }

diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ede8694545..c265ed4bb9 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -601,6 +601,8 @@ "See when anyone posts a sticker to your active room": "See when anyone posts a sticker to your active room", "with an empty state key": "with an empty state key", "with state key %(stateKey)s": "with state key %(stateKey)s", + "The above, but in any room you are joined or invited to as well": "The above, but in any room you are joined or invited to as well", + "The above, but in as well": "The above, but in as well", "Send %(eventType)s events as you in this room": "Send %(eventType)s events as you in this room", "See %(eventType)s events posted to this room": "See %(eventType)s events posted to this room", "Send %(eventType)s events as you in your active room": "Send %(eventType)s events as you in your active room", @@ -806,6 +808,7 @@ "Render LaTeX maths in messages": "Render LaTeX maths in messages", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "Message Pinning": "Message Pinning", + "Threaded messaging": "Threaded messaging", "Custom user status messages": "Custom user status messages", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Render simple counters in room header": "Render simple counters in room header", @@ -1803,6 +1806,7 @@ "%(count)s people|other": "%(count)s people", "%(count)s people|one": "%(count)s person", "Show files": "Show files", + "Show threads": "Show threads", "Share room": "Share room", "Room settings": "Room settings", "Trusted": "Trusted", @@ -1922,6 +1926,7 @@ "React": "React", "Edit": "Edit", "Reply": "Reply", + "Thread": "Thread", "Message Actions": "Message Actions", "Download %(text)s": "Download %(text)s", "Error decrypting attachment": "Error decrypting attachment", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 28c5b1353f..a0261e9994 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -211,6 +211,15 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_thread": { + isFeature: true, + // Requires a reload as we change an option flag on the `js-sdk` + // And the entire sync history needs to be parsed again + controller: new ReloadOnChangeController(), + displayName: _td("Threaded messaging"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_custom_status": { isFeature: true, displayName: _td("Custom user status messages"), diff --git a/src/stores/RightPanelStorePhases.ts b/src/stores/RightPanelStorePhases.ts index d62f6c6110..96a585b676 100644 --- a/src/stores/RightPanelStorePhases.ts +++ b/src/stores/RightPanelStorePhases.ts @@ -37,6 +37,10 @@ export enum RightPanelPhases { SpaceMemberList = "SpaceMemberList", SpaceMemberInfo = "SpaceMemberInfo", Space3pidMemberInfo = "Space3pidMemberInfo", + + // Thread stuff + ThreadView = "ThreadView", + ThreadPanel = "ThreadPanel", } // These are the phases that are safe to persist (the ones that don't require additional diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index c08c66714b..8a7d51b60a 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -145,9 +145,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._allRoomsInHome; } - public async setActiveRoomInSpace(space: Room | null): Promise { + public setActiveRoomInSpace(space: Room | null): void { if (space && !space.isSpaceRoom()) return; - if (space !== this.activeSpace) await this.setActiveSpace(space); + if (space !== this.activeSpace) this.setActiveSpace(space); if (space) { const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications(); @@ -190,7 +190,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { * @param contextSwitch whether to switch the user's context, * should not be done when the space switch is done implicitly due to another event like switching room. */ - public async setActiveSpace(space: Room | null, contextSwitch = true) { + public setActiveSpace(space: Room | null, contextSwitch = true) { if (space === this.activeSpace || (space && !space.isSpaceRoom())) return; this._activeSpace = space; @@ -293,11 +293,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } if (space) { - const suggestedRooms = await this.fetchSuggestedRooms(space); - if (this._activeSpace === space) { - this._suggestedRooms = suggestedRooms; - this.emit(SUGGESTED_ROOMS, this._suggestedRooms); - } + this.loadSuggestedRooms(space); + } + } + + private async loadSuggestedRooms(space) { + const suggestedRooms = await this.fetchSuggestedRooms(space); + if (this._activeSpace === space) { + this._suggestedRooms = suggestedRooms; + this.emit(SUGGESTED_ROOMS, this._suggestedRooms); } } @@ -666,6 +670,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.onSpaceUpdate(); this.emit(room.roomId); } + + if (room === this.activeSpace && // current space + this.matrixClient.getRoom(ev.getStateKey())?.getMyMembership() !== "join" && // target not joined + ev.getPrevContent().suggested !== ev.getContent().suggested // suggested flag changed + ) { + this.loadSuggestedRooms(room); + } + break; case EventType.SpaceParent: @@ -678,12 +690,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } this.emit(room.roomId); break; + } + }; - case EventType.RoomMember: - if (room.isSpaceRoom()) { - this.onSpaceMembersChange(ev); - } - break; + // listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then + private onRoomStateMembers = (ev: MatrixEvent) => { + const room = this.matrixClient.getRoom(ev.getRoomId()); + if (room?.isSpaceRoom()) { + this.onSpaceMembersChange(ev); } }; @@ -743,6 +757,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("RoomState.events", this.onRoomState); + this.matrixClient.removeListener("RoomState.members", this.onRoomStateMembers); this.matrixClient.removeListener("accountData", this.onAccountData); } await this.reset(); @@ -754,6 +769,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("Room.accountData", this.onRoomAccountData); this.matrixClient.on("RoomState.events", this.onRoomState); + this.matrixClient.on("RoomState.members", this.onRoomStateMembers); this.matrixClient.on("accountData", this.onAccountData); this.matrixClient.getCapabilities().then(capabilities => { diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index daa1e0e787..d3c886d4b8 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020, 2021 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. @@ -55,6 +55,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { ELEMENT_CLIENT_ID } from "../../identifiers"; import { getUserLanguage } from "../../languageHandler"; import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables"; +import { arrayFastClone } from "../../utils/arrays"; // TODO: Destroy all of this code @@ -146,6 +147,7 @@ export class StopGapWidget extends EventEmitter { private scalarToken: string; private roomId?: string; private kind: WidgetKind; + private readUpToMap: {[roomId: string]: string} = {}; // room ID to event ID constructor(private appTileProps: IAppTileProps) { super(); @@ -294,6 +296,14 @@ export class StopGapWidget extends EventEmitter { this.messaging.transport.reply(ev.detail, {}); }); + // Populate the map of "read up to" events for this widget with the current event in every room. + // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget + // requests timeline capabilities in other rooms down the road. It's just easier to manage here. + for (const room of MatrixClientPeg.get().getRooms()) { + // Timelines are most recent last + this.readUpToMap[room.roomId] = arrayFastClone(room.getLiveTimeline().getEvents()).reverse()[0].getId(); + } + // Attach listeners for feeding events - the underlying widget classes handle permissions for us MatrixClientPeg.get().on('event', this.onEvent); MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted); @@ -408,21 +418,56 @@ export class StopGapWidget extends EventEmitter { private onEvent = (ev: MatrixEvent) => { MatrixClientPeg.get().decryptEventIfNeeded(ev); if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; - if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); }; private onEventDecrypted = (ev: MatrixEvent) => { if (ev.isDecryptionFailure()) return; - if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); }; private feedEvent(ev: MatrixEvent) { if (!this.messaging) return; + // Check to see if this event would be before or after our "read up to" marker. If it's + // before, or we can't decide, then we assume the widget will have already seen the event. + // If the event is after, or we don't have a marker for the room, then we'll send it through. + // + // This approach of "read up to" prevents widgets receiving decryption spam from startup or + // receiving out-of-order events from backfill and such. + const upToEventId = this.readUpToMap[ev.getRoomId()]; + if (upToEventId) { + // Small optimization for exact match (prevent search) + if (upToEventId === ev.getId()) { + return; + } + + let isBeforeMark = true; + + // Timelines are most recent last, so reverse the order and limit ourselves to 100 events + // to avoid overusing the CPU. + const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline(); + const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100); + + for (const timelineEvent of events) { + if (timelineEvent.getId() === upToEventId) { + break; + } else if (timelineEvent.getId() === ev.getId()) { + isBeforeMark = false; + break; + } + } + + if (isBeforeMark) { + // Ignore the event: it is before our interest. + return; + } + } + + this.readUpToMap[ev.getRoomId()] = ev.getId(); + const raw = ev.getEffectiveEvent(); - this.messaging.feedEvent(raw).catch(e => { + this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => { console.error("Error sending event to widget: ", e); }); } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index d473ecf3b1..91a4cf6642 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.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. @@ -23,6 +23,7 @@ import { MatrixCapabilities, OpenIDRequestState, SimpleObservable, + Symbols, Widget, WidgetDriver, WidgetEventCapability, @@ -42,7 +43,8 @@ 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"; +import { IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk"; // TODO: Purge this from the universe @@ -133,9 +135,14 @@ export class StopGapWidgetDriver extends WidgetDriver { return allAllowed; } - public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise { + public async sendEvent( + eventType: string, + content: any, + stateKey: string = null, + targetRoomId: string = null, + ): Promise { const client = MatrixClientPeg.get(); - const roomId = ActiveRoomObserver.activeRoomId; + const roomId = targetRoomId || ActiveRoomObserver.activeRoomId; if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); @@ -162,48 +169,68 @@ 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 - + private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] { 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"); + if (!client) throw new Error("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 || ev.isState()) continue; - if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue; - results.push(ev); - } - - return results.map(e => e.getEffectiveEvent()); + const targetRooms = roomIds + ? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r))) + : [client.getRoom(ActiveRoomObserver.activeRoomId)]; + return targetRooms.filter(r => !!r); } - public async readStateEvents(eventType: string, stateKey: string | undefined, limit: number): Promise { - limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice + public async readRoomEvents( + eventType: string, + msgtype: string | undefined, + limitPerRoom: number, + roomIds: (string | Symbols.AnyRoom)[] = null, + ): Promise { + limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary - 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 rooms = this.pickRooms(roomIds); + const allResults: IEvent[] = []; + for (const room of rooms) { + 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 >= limitPerRoom) break; - const results: MatrixEvent[] = []; - const state: Map = 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())); + const ev = events[i]; + if (ev.getType() !== eventType || ev.isState()) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue; + results.push(ev); } - } - return results.slice(0, limit).map(e => e.event); + results.forEach(e => allResults.push(e.getEffectiveEvent())); + } + return allResults; + } + + public async readStateEvents( + eventType: string, + stateKey: string | undefined, + limitPerRoom: number, + roomIds: (string | Symbols.AnyRoom)[] = null, + ): Promise { + limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + + const rooms = this.pickRooms(roomIds); + const allResults: IEvent[] = []; + for (const room of rooms) { + const results: MatrixEvent[] = []; + const state: Map = 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())); + } + } + + results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent())); + } + return allResults; } public async askOpenID(observer: SimpleObservable) { diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx index 63e34eea7a..8c13a4b2fc 100644 --- a/src/widgets/CapabilityText.tsx +++ b/src/widgets/CapabilityText.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. @@ -14,11 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Capability, EventDirection, MatrixCapabilities, WidgetEventCapability, WidgetKind } from "matrix-widget-api"; +import { + Capability, + EventDirection, + getTimelineRoomIDFromCapability, + isTimelineCapability, + isTimelineCapabilityFor, + MatrixCapabilities, Symbols, + WidgetEventCapability, + WidgetKind, +} from "matrix-widget-api"; import { _t, _td, TranslatedString } from "../languageHandler"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { ElementWidgetCapabilities } from "../stores/widgets/ElementWidgetCapabilities"; import React from "react"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import TextWithTooltip from "../components/views/elements/TextWithTooltip"; type GENERIC_WIDGET_KIND = "generic"; // eslint-disable-line @typescript-eslint/naming-convention const GENERIC_WIDGET_KIND: GENERIC_WIDGET_KIND = "generic"; @@ -138,8 +149,31 @@ export class CapabilityText { if (textForKind[GENERIC_WIDGET_KIND]) return { primary: _t(textForKind[GENERIC_WIDGET_KIND]) }; // ... we'll fall through to the generic capability processing at the end of this - // function if we fail to locate a simple string and the capability isn't for an - // event. + // function if we fail to generate a string for the capability. + } + + // Try to handle timeline capabilities. The text here implies that the caller has sorted + // the timeline caps to the end for UI purposes. + if (isTimelineCapability(capability)) { + if (isTimelineCapabilityFor(capability, Symbols.AnyRoom)) { + return { primary: _t("The above, but in any room you are joined or invited to as well") }; + } else { + const roomId = getTimelineRoomIDFromCapability(capability); + const room = MatrixClientPeg.get().getRoom(roomId); + return { + primary: _t("The above, but in as well", {}, { + Room: () => { + if (room) { + return + { room.name } + ; + } else { + return { roomId }; + } + }, + }), + }; + } } // We didn't have a super simple line of text, so try processing the capability as the diff --git a/test/components/views/rooms/SendMessageComposer-test.js b/test/components/views/rooms/SendMessageComposer-test.js index 0c4bde76a8..db5b55df90 100644 --- a/test/components/views/rooms/SendMessageComposer-test.js +++ b/test/components/views/rooms/SendMessageComposer-test.js @@ -46,7 +46,7 @@ describe('', () => { const model = new EditorModel([], createPartCreator(), createRenderer()); model.update("hello world", "insertText", { offset: 11, atNodeEnd: true }); - const content = createMessageContent(model, permalinkCreator); + const content = createMessageContent(model, null, false, permalinkCreator); expect(content).toEqual({ body: "hello world", @@ -58,7 +58,7 @@ describe('', () => { const model = new EditorModel([], createPartCreator(), createRenderer()); model.update("hello *world*", "insertText", { offset: 13, atNodeEnd: true }); - const content = createMessageContent(model, permalinkCreator); + const content = createMessageContent(model, null, false, permalinkCreator); expect(content).toEqual({ body: "hello *world*", @@ -72,7 +72,7 @@ describe('', () => { const model = new EditorModel([], createPartCreator(), createRenderer()); model.update("/me blinks __quickly__", "insertText", { offset: 22, atNodeEnd: true }); - const content = createMessageContent(model, permalinkCreator); + const content = createMessageContent(model, null, false, permalinkCreator); expect(content).toEqual({ body: "blinks __quickly__", @@ -86,7 +86,7 @@ describe('', () => { const model = new EditorModel([], createPartCreator(), createRenderer()); model.update("//dev/null is my favourite place", "insertText", { offset: 32, atNodeEnd: true }); - const content = createMessageContent(model, permalinkCreator); + const content = createMessageContent(model, null, false, permalinkCreator); expect(content).toEqual({ body: "/dev/null is my favourite place", diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index 2e823aa72b..7cfd97b234 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -562,7 +562,7 @@ describe("SpaceStore", () => { ]); mkSpace(space3).getMyMembership.mockReturnValue("invite"); await run(); - await store.setActiveSpace(null); + store.setActiveSpace(null); expect(store.activeSpace).toBe(null); }); afterEach(() => { @@ -570,31 +570,31 @@ describe("SpaceStore", () => { }); it("switch to home space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); fn.mockClear(); - await store.setActiveSpace(null); + store.setActiveSpace(null); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, null); expect(store.activeSpace).toBe(null); }); it("switch to invited space", async () => { const space = client.getRoom(space3); - await store.setActiveSpace(space); + store.setActiveSpace(space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(store.activeSpace).toBe(space); }); it("switch to top level space", async () => { const space = client.getRoom(space1); - await store.setActiveSpace(space); + store.setActiveSpace(space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(store.activeSpace).toBe(space); }); it("switch to subspace", async () => { const space = client.getRoom(space2); - await store.setActiveSpace(space); + store.setActiveSpace(space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(store.activeSpace).toBe(space); }); @@ -602,7 +602,7 @@ describe("SpaceStore", () => { it("switch to unknown space is a nop", async () => { expect(store.activeSpace).toBe(null); const space = client.getRoom(room1); // not a space - await store.setActiveSpace(space); + store.setActiveSpace(space); expect(fn).not.toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(store.activeSpace).toBe(null); }); @@ -635,59 +635,59 @@ describe("SpaceStore", () => { }; it("last viewed room in target space is the current viewed and in both spaces", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room2); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); viewRoom(room2); - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); expect(getCurrentRoom()).toBe(room2); }); it("last viewed room in target space is in the current space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room2); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); expect(getCurrentRoom()).toBe(space2); - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); expect(getCurrentRoom()).toBe(room2); }); it("last viewed room in target space is not in the current space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); viewRoom(room2); - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); expect(getCurrentRoom()).toBe(room1); }); it("last viewed room is target space is not known", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); localStorage.setItem(`mx_space_context_${space2}`, orphan2); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); expect(getCurrentRoom()).toBe(space2); }); it("last viewed room is target space is no longer in that space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); localStorage.setItem(`mx_space_context_${space2}`, room1); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); expect(getCurrentRoom()).toBe(space2); // Space home instead of room1 }); it("no last viewed room in target space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); expect(getCurrentRoom()).toBe(space2); }); it("no last viewed room in home space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); - await store.setActiveSpace(null); + store.setActiveSpace(null); expect(getCurrentRoom()).toBeNull(); // Home }); }); @@ -715,28 +715,28 @@ describe("SpaceStore", () => { it("no switch required, room is in current space", async () => { viewRoom(room1); - await store.setActiveSpace(client.getRoom(space1), false); + store.setActiveSpace(client.getRoom(space1), false); viewRoom(room2); expect(store.activeSpace).toBe(client.getRoom(space1)); }); it("switch to canonical parent space for room", async () => { viewRoom(room1); - await store.setActiveSpace(client.getRoom(space2), false); + 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(client.getRoom(space2), false); + store.setActiveSpace(client.getRoom(space2), false); viewRoom(room3); expect(store.activeSpace).toBe(client.getRoom(space1)); }); it("switch to home for orphaned room", async () => { viewRoom(room1); - await store.setActiveSpace(client.getRoom(space1), false); + store.setActiveSpace(client.getRoom(space1), false); viewRoom(orphan1); expect(store.activeSpace).toBeNull(); }); @@ -744,7 +744,7 @@ describe("SpaceStore", () => { it("when switching rooms in the all rooms home space don't switch to related space", async () => { await setShowAllRooms(true); viewRoom(room2); - await store.setActiveSpace(null, false); + store.setActiveSpace(null, false); viewRoom(room1); expect(store.activeSpace).toBeNull(); }); diff --git a/test/stores/room-list/SpaceWatcher-test.ts b/test/stores/room-list/SpaceWatcher-test.ts index 85f79c75b6..474c279fdd 100644 --- a/test/stores/room-list/SpaceWatcher-test.ts +++ b/test/stores/room-list/SpaceWatcher-test.ts @@ -57,7 +57,7 @@ describe("SpaceWatcher", () => { beforeEach(async () => { filter = null; store.removeAllListeners(); - await store.setActiveSpace(null); + store.setActiveSpace(null); client.getVisibleRooms.mockReturnValue(rooms = []); space1 = mkSpace(space1Id); @@ -95,7 +95,7 @@ describe("SpaceWatcher", () => { await setShowAllRooms(true); new SpaceWatcher(mockRoomListStore); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); @@ -114,7 +114,7 @@ describe("SpaceWatcher", () => { await setShowAllRooms(false); new SpaceWatcher(mockRoomListStore); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); @@ -124,22 +124,22 @@ describe("SpaceWatcher", () => { await setShowAllRooms(true); new SpaceWatcher(mockRoomListStore); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); - await SpaceStore.instance.setActiveSpace(null); + SpaceStore.instance.setActiveSpace(null); expect(filter).toBeNull(); }); it("updates filter correctly for space -> home transition", async () => { await setShowAllRooms(false); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); new SpaceWatcher(mockRoomListStore); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); - await SpaceStore.instance.setActiveSpace(null); + SpaceStore.instance.setActiveSpace(null); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(null); @@ -147,12 +147,12 @@ describe("SpaceWatcher", () => { it("updates filter correctly for space -> space transition", async () => { await setShowAllRooms(false); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); new SpaceWatcher(mockRoomListStore); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); - await SpaceStore.instance.setActiveSpace(space2); + SpaceStore.instance.setActiveSpace(space2); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space2); @@ -160,7 +160,7 @@ describe("SpaceWatcher", () => { it("doesn't change filter when changing showAllRooms mode to true", async () => { await setShowAllRooms(false); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); new SpaceWatcher(mockRoomListStore); expect(filter).toBeInstanceOf(SpaceFilterCondition); @@ -173,7 +173,7 @@ describe("SpaceWatcher", () => { it("doesn't change filter when changing showAllRooms mode to false", async () => { await setShowAllRooms(true); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); new SpaceWatcher(mockRoomListStore); expect(filter).toBeInstanceOf(SpaceFilterCondition); diff --git a/yarn.lock b/yarn.lock index 6dd1d269c3..f70f0e75c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5791,10 +5791,9 @@ mathml-tag-names@^2.1.3: resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== -matrix-js-sdk@12.4.0: +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": version "12.4.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.4.0.tgz#ff60306f9a9e39fd1ae6c7e501001f80eb779dd7" - integrity sha512-KamHmvNle4mkdErmNgVsGIL3n8/zgPe60DLVaEA2t4aSNwQLEmRS+oVpIgsO3ZrUivBvn4oc9sBqxP0OIl6kUg== + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2783d162b77d6629c574f35e88bea9ae29765c34" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5828,10 +5827,10 @@ matrix-react-test-utils@^0.2.3: "@babel/traverse" "^7.13.17" walk "^2.3.14" -matrix-widget-api@^0.1.0-beta.15: - version "0.1.0-beta.15" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.15.tgz#b02511f93fe1a3634868b6e246d736107f182745" - integrity sha512-sWmtb8ZarSbHVbk5ni7IHBR9jOh7m1+5R4soky0fEO9VKl+MN7skT0+qNux3J9WuUAu2D80dZW9xPUT9cxfxbg== +matrix-widget-api@^0.1.0-beta.16: + version "0.1.0-beta.16" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.16.tgz#32655f05cab48239b97fe4111a1d0858f2aad61a" + integrity sha512-9zqaNLaM14YDHfFb7WGSUOivGOjYw+w5Su84ZfOl6A4IUy1xT9QPp0nsSA8wNfz0LpxOIPn3nuoF8Tn/40F5tg== dependencies: "@types/events" "^3.0.0" events "^3.2.0"