diff --git a/.github/workflows/typecheck.yaml b/.github/workflows/typecheck.yaml
new file mode 100644
index 0000000000..2e08418cf6
--- /dev/null
+++ b/.github/workflows/typecheck.yaml
@@ -0,0 +1,24 @@
+name: Type Check
+on:
+ pull_request:
+ branches: [develop]
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - uses: c-hive/gha-yarn-cache@v2
+ - name: Install Deps
+ run: "./scripts/ci/install-deps.sh --ignore-scripts"
+ - name: Typecheck
+ run: "yarn run lint:types"
+ - name: Switch js-sdk to release mode
+ run: |
+ scripts/ci/js-sdk-to-release.js
+ cd node_modules/matrix-js-sdk
+ yarn install
+ yarn run build:compile
+ yarn run build:types
+ - name: Typecheck (release mode)
+ run: "yarn run lint:types"
+
diff --git a/package.json b/package.json
index 6245b2c34e..9798503e9e 100644
--- a/package.json
+++ b/package.json
@@ -151,7 +151,7 @@
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
- "allchange": "^1.0.2",
+ "allchange": "^1.0.3",
"babel-jest": "^26.6.3",
"chokidar": "^3.5.1",
"concurrently": "^5.3.0",
diff --git a/scripts/ci/js-sdk-to-release.js b/scripts/ci/js-sdk-to-release.js
new file mode 100755
index 0000000000..e1fecfde03
--- /dev/null
+++ b/scripts/ci/js-sdk-to-release.js
@@ -0,0 +1,17 @@
+#!/usr/bin/env node
+
+const fsProm = require('fs/promises');
+
+const PKGJSON = 'node_modules/matrix-js-sdk/package.json';
+
+async function main() {
+ const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8'));
+ for (const field of ['main', 'typings']) {
+ if (pkgJson["matrix_lib_"+field] !== undefined) {
+ pkgJson[field] = pkgJson["matrix_lib_"+field];
+ }
+ }
+ await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2));
+}
+
+main();
diff --git a/scripts/ci/js-sdk-to-release.sh b/scripts/ci/js-sdk-to-release.sh
deleted file mode 100755
index a03165bd82..0000000000
--- a/scripts/ci/js-sdk-to-release.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/sh
-
-# This changes the js-sdk into 'release mode', that is:
-# * The entry point for the library is the babel-compiled lib/index.js rather than src/index.ts
-# * There's a 'typings' entry referencing the types output by tsc
-# We do this so we can test that each PR still builds / type checks correctly when built
-# against the released js-sdk, because if you do things like `import { User } from 'matrix-js-sdk';`
-# rather than `import { User } from 'matrix-js-sdk/src/models/user';` it will work fine with the
-# js-sdk in development mode but then break at release time.
-# We can't use the last release of the js-sdk though: it might not be up to date enough.
-
-cd node_modules/matrix-js-sdk
-for i in main typings
-do
- lib_value=$(jq -r ".matrix_lib_$i" package.json)
- if [ "$lib_value" != "null" ]; then
- jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json
- fi
-done
-yarn run build:compile
-yarn run build:types
diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts
index a4ffa1aabf..611b88938a 100644
--- a/src/audio/PlaybackQueue.ts
+++ b/src/audio/PlaybackQueue.ts
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk";
+import { MatrixClient } from "matrix-js-sdk/src/client";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { Room } from "matrix-js-sdk/src/models/room";
import { Playback, PlaybackState } from "./Playback";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
diff --git a/src/components/structures/CallEventGrouper.ts b/src/components/structures/CallEventGrouper.ts
index 7f0324029e..84e004d1de 100644
--- a/src/components/structures/CallEventGrouper.ts
+++ b/src/components/structures/CallEventGrouper.ts
@@ -105,8 +105,12 @@ export default class CallEventGrouper extends EventEmitter {
return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId());
}
- private get callId(): string {
- return [...this.events][0].getContent().call_id;
+ private get callId(): string | undefined {
+ return [...this.events][0]?.getContent()?.call_id;
+ }
+
+ private get roomId(): string | undefined {
+ return [...this.events][0]?.getRoomId();
}
private onSilencedCallsChanged = () => {
@@ -119,18 +123,24 @@ export default class CallEventGrouper extends EventEmitter {
};
public answerCall = () => {
- this.call?.answer();
+ defaultDispatcher.dispatch({
+ action: 'answer',
+ room_id: this.roomId,
+ });
};
public rejectCall = () => {
- this.call?.reject();
+ defaultDispatcher.dispatch({
+ action: 'reject',
+ room_id: this.roomId,
+ });
};
public callBack = () => {
defaultDispatcher.dispatch({
action: 'place_call',
type: this.isVoice ? CallType.Voice : CallType.Video,
- room_id: [...this.events][0]?.getRoomId(),
+ room_id: this.roomId,
});
};
diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx
index d955271249..9a2ebd45e2 100644
--- a/src/components/structures/LeftPanel.tsx
+++ b/src/components/structures/LeftPanel.tsx
@@ -399,7 +399,9 @@ export default class LeftPanel extends React.Component {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
})}
onClick={this.onExplore}
- title={_t("Explore rooms")}
+ title={this.state.activeSpace
+ ? _t("Explore %(spaceName)s", { spaceName: this.state.activeSpace.name })
+ : _t("Explore rooms")}
/>
);
diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx
index 114d020c66..32a875557c 100644
--- a/src/components/structures/RightPanel.tsx
+++ b/src/components/structures/RightPanel.tsx
@@ -271,7 +271,7 @@ export default class RightPanel extends React.Component {
case RightPanelPhases.EncryptionPanel:
panel = {
/>;
}
+ const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
+ "mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
+ });
+
+ // if statusBar does not exist then statusBarArea is blank and takes up unnecessary space on the screen
+ // show statusBarArea only if statusBar is present
+ const statusBarArea = statusBar && ;
+
const roomVersionRecommendation = this.state.upgradeRecommendation;
const showRoomUpgradeBar = (
roomVersionRecommendation &&
@@ -2045,10 +2058,6 @@ export default class RoomView extends React.Component {
/>);
}
- const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
- "mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
- });
-
const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel
? {
{ messagePanel }
{ searchResultsPanel }
-
+ { statusBarArea }
{ previewBar }
{ messageComposer }
diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx
index 3da00e71aa..28c35eef8f 100644
--- a/src/components/views/context_menus/SpaceContextMenu.tsx
+++ b/src/components/views/context_menus/SpaceContextMenu.tsx
@@ -168,7 +168,7 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
defaultDispatcher.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
- refireParams: { space: space },
+ refireParams: { space },
});
onFinished();
};
diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx
index a426dce5c7..a73f0a595b 100644
--- a/src/components/views/dialogs/RoomSettingsDialog.tsx
+++ b/src/components/views/dialogs/RoomSettingsDialog.tsx
@@ -79,7 +79,10 @@ export default class RoomSettingsDialog extends React.Component {
ROOM_SECURITY_TAB,
_td("Security & Privacy"),
"mx_RoomSettingsDialog_securityIcon",
- ,
+ this.props.onFinished(true)}
+ />,
));
tabs.push(new Tab(
ROOM_ROLES_TAB,
diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx
index 0f716ed010..8f352610e0 100644
--- a/src/components/views/messages/EncryptionEvent.tsx
+++ b/src/components/views/messages/EncryptionEvent.tsx
@@ -16,26 +16,38 @@ limitations under the License.
import React, { forwardRef, useContext } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { IRoomEncryption } from "matrix-js-sdk/src/crypto/RoomList";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import EventTileBubble from "./EventTileBubble";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DMRoomMap from "../../../utils/DMRoomMap";
+import { objectHasDiff } from "../../../utils/objects";
interface IProps {
mxEvent: MatrixEvent;
}
+const ALGORITHM = "m.megolm.v1.aes-sha2";
+
const EncryptionEvent = forwardRef(({ mxEvent }, ref) => {
const cli = useContext(MatrixClientContext);
const roomId = mxEvent.getRoomId();
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
- if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
+ const prevContent = mxEvent.getPrevContent() as IRoomEncryption;
+ const content = mxEvent.getContent();
+
+ // if no change happened then skip rendering this, a shallow check is enough as all known fields are top-level.
+ if (!objectHasDiff(prevContent, content)) return null; // nop
+
+ if (content.algorithm === ALGORITHM && isRoomEncrypted) {
let subtitle: string;
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
- if (dmPartner) {
+ if (prevContent.algorithm === ALGORITHM) {
+ subtitle = _t("Some encryption parameters have been changed.");
+ } else if (dmPartner) {
const displayName = cli?.getRoom(roomId)?.getMember(dmPartner)?.rawDisplayName || dmPartner;
subtitle = _t("Messages here are end-to-end encrypted. " +
"Verify %(displayName)s in their profile - tap on their avatar.", { displayName });
@@ -49,7 +61,9 @@ const EncryptionEvent = forwardRef(({ mxEvent }, ref) =>
title={_t("Encryption enabled")}
subtitle={subtitle}
/>;
- } else if (isRoomEncrypted) {
+ }
+
+ if (isRoomEncrypted) {
return = (props: IProps) => {
// state to show a spinner immediately after clicking "start verification",
// before we have a request
const [isRequesting, setRequesting] = useState(false);
- const [phase, setPhase] = useState(request && request.phase);
+ const [phase, setPhase] = useState(request?.phase);
useEffect(() => {
setRequest(verificationRequest);
if (verificationRequest) {
diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx
index d15f349d62..f90643f1df 100644
--- a/src/components/views/right_panel/UserInfo.tsx
+++ b/src/components/views/right_panel/UserInfo.tsx
@@ -1278,7 +1278,9 @@ const BasicUserInfo: React.FC<{
// hide the Roles section for DMs as it doesn't make sense there
if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) {
memberDetails =
-
{ _t("Role") }
+
{ _t("Role in ", {}, {
+ RoomName: () => { room.name },
+ }) }
= ({
// We have no previousPhase for when viewing a UserInfo from a Group or without a Room at this time
if (room && phase === RightPanelPhases.EncryptionPanel) {
previousPhase = RightPanelPhases.RoomMemberInfo;
- refireParams = { member: member };
+ refireParams = { member };
+ } else if (room?.isSpaceRoom() && SpaceStore.spacesEnabled) {
+ previousPhase = previousPhase = RightPanelPhases.SpaceMemberList;
+ refireParams = { space: room };
} else if (room) {
- previousPhase = previousPhase = SpaceStore.spacesEnabled && room.isSpaceRoom()
- ? RightPanelPhases.SpaceMemberList
- : RightPanelPhases.RoomMemberList;
+ previousPhase = RightPanelPhases.RoomMemberList;
}
const onEncryptionPanelClose = () => {
diff --git a/src/components/views/right_panel/VerificationPanel.tsx b/src/components/views/right_panel/VerificationPanel.tsx
index 395bdc21e0..a29bdea90b 100644
--- a/src/components/views/right_panel/VerificationPanel.tsx
+++ b/src/components/views/right_panel/VerificationPanel.tsx
@@ -29,43 +29,27 @@ import VerificationQRCode from "../elements/crypto/VerificationQRCode";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import E2EIcon from "../rooms/E2EIcon";
-import {
- PHASE_READY,
- PHASE_DONE,
- PHASE_STARTED,
- PHASE_CANCELLED,
-} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
+import { Phase } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import Spinner from "../elements/Spinner";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from "../elements/AccessibleButton";
import VerificationShowSas from "../verification/VerificationShowSas";
-// XXX: Should be defined in matrix-js-sdk
-enum VerificationPhase {
- PHASE_UNSENT,
- PHASE_REQUESTED,
- PHASE_READY,
- PHASE_DONE,
- PHASE_STARTED,
- PHASE_CANCELLED,
-}
-
interface IProps {
layout: string;
request: VerificationRequest;
member: RoomMember | User;
- phase: VerificationPhase;
+ phase: Phase;
onClose: () => void;
isRoomEncrypted: boolean;
inDialog: boolean;
- key: number;
}
interface IState {
- sasEvent?: SAS;
+ sasEvent?: SAS["sasEvent"];
emojiButtonClicked?: boolean;
reciprocateButtonClicked?: boolean;
- reciprocateQREvent?: ReciprocateQRCode;
+ reciprocateQREvent?: ReciprocateQRCode["reciprocateQREvent"];
}
@replaceableComponent("views.right_panel.VerificationPanel")
@@ -321,9 +305,9 @@ export default class VerificationPanel extends React.PureComponent {
const { request } = this.props;
- const { sasEvent, reciprocateQREvent } = request.verifier;
+ const sasEvent = (request.verifier as SAS).sasEvent;
+ const reciprocateQREvent = (request.verifier as ReciprocateQRCode).reciprocateQREvent;
request.verifier.off('show_sas', this.updateVerifierState);
request.verifier.off('show_reciprocate_qr', this.updateVerifierState);
this.setState({ sasEvent, reciprocateQREvent });
@@ -402,7 +387,8 @@ export default class VerificationPanel extends React.PureComponent void;
@@ -522,20 +523,23 @@ export default class RoomList extends React.PureComponent {
} else if (
this.props.activeSpace?.canInvite(userId) || this.props.activeSpace?.getMyMembership() === "join"
) {
+ const spaceName = this.props.activeSpace.name;
explorePrompt =
{ _t("Quick actions") }
- { this.props.activeSpace.canInvite(userId) &&
{ _t("Invite people") }
- }
- { this.props.activeSpace.getMyMembership() === "join" &&
}
+ { this.props.activeSpace.getMyMembership() === "join" &&
{ _t("Explore rooms") }
- }
+ }
;
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
const unfilteredLists = RoomListStore.instance.unfilteredLists;
diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index 081b1a8698..5cb76ebc25 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -39,9 +39,12 @@ import { arrayHasDiff } from "../../../../../utils/arrays";
import SettingsFlag from '../../../elements/SettingsFlag';
import createRoom, { IOpts } from '../../../../../createRoom';
import CreateRoomDialog from '../../../dialogs/CreateRoomDialog';
+import dis from "../../../../../dispatcher/dispatcher";
+import { ROOM_SECURITY_TAB } from "../../../dialogs/RoomSettingsDialog";
interface IProps {
roomId: string;
+ closeSettingsFn: () => void;
}
interface IState {
@@ -220,9 +223,20 @@ export default class SecurityRoomSettingsTab extends React.Component {
+ onFinished: async (resp) => {
if (!resp?.continue) return;
- upgradeRoom(room, targetVersion, resp.invite);
+ const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
+ this.props.closeSettingsFn();
+ // switch to the new room in the background
+ dis.dispatch({
+ action: "view_room",
+ room_id: roomId,
+ });
+ // open new settings on this tab
+ dis.dispatch({
+ action: "open_room_settings",
+ initial_tab_id: ROOM_SECURITY_TAB,
+ });
},
});
return;
diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx
index d2e09c0d69..d223f5b6a6 100644
--- a/src/components/views/spaces/SpacePanel.tsx
+++ b/src/components/views/spaces/SpacePanel.tsx
@@ -195,12 +195,10 @@ const InnerSpacePanel = React.memo(({ children, isPanelCo
{ (provided, snapshot) => (
{
}, []);
const onKeyDown = (ev: React.KeyboardEvent) => {
+ if (ev.defaultPrevented) return;
+
let handled = true;
switch (ev.key) {
diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx
index 399c137e97..dda58ae944 100644
--- a/src/components/views/spaces/SpaceTreeLevel.tsx
+++ b/src/components/views/spaces/SpaceTreeLevel.tsx
@@ -29,7 +29,6 @@ import RoomAvatar from "../avatars/RoomAvatar";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
import NotificationBadge from "../rooms/NotificationBadge";
-import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import { _t } from "../../../languageHandler";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { toRightOf, useContextMenu } from "../../structures/ContextMenu";
@@ -40,8 +39,11 @@ import { NotificationColor } from "../../../stores/notifications/NotificationCol
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
+import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
+import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd";
+import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
-interface IButtonProps extends Omit, "title"> {
+interface IButtonProps extends Omit, "title"> {
space?: Room;
className?: string;
selected?: boolean;
@@ -68,7 +70,9 @@ export const SpaceButton: React.FC = ({
ContextMenuComponent,
...props
}) => {
- const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
+ const [menuDisplayed, ref, openMenu, closeMenu] = useContextMenu();
+ const [onFocus, isActive, handle] = useRovingTabIndex(ref);
+ const tabIndex = isActive ? 0 : -1;
let avatar = ;
if (space) {
@@ -88,6 +92,7 @@ export const SpaceButton: React.FC = ({
forceCount={false}
notification={notificationState}
aria-label={ariaLabel}
+ tabIndex={tabIndex}
/>
;
}
@@ -102,7 +107,7 @@ export const SpaceButton: React.FC = ({
}
return (
- = ({
onContextMenu={openMenu}
forceHide={!isNarrow || menuDisplayed}
inputRef={handle}
+ tabIndex={tabIndex}
+ onFocus={onFocus}
>
{ children }
@@ -130,7 +137,7 @@ export const SpaceButton: React.FC = ({
{ contextMenu }
-
+
);
};
@@ -142,6 +149,7 @@ interface IItemProps extends InputHTMLAttributes {
onExpand?: Function;
parents?: Set;
innerRef?: LegacyRef;
+ dragHandleProps?: DraggableProvidedDragHandleProps;
}
interface IItemState {
@@ -270,8 +278,10 @@ export class SpaceItem extends React.PureComponent {
? StaticNotificationState.forSymbol("!", NotificationColor.Red)
: SpaceStore.instance.getNotificationState(space.roomId);
+ const hasChildren = this.state.childSpaces?.length;
+
let childItems;
- if (this.state.childSpaces?.length && !collapsed) {
+ if (hasChildren && !collapsed) {
childItems = {
/>;
}
- const toggleCollapseButton = this.state.childSpaces?.length ?
+ const toggleCollapseButton = hasChildren ?
{
aria-label={collapsed ? _t("Expand") : _t("Collapse")}
/> : null;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { tabIndex, ...dragHandleProps } = this.props.dragHandleProps || {};
+
return (
-
+
void;
onCancel: () => void;
- sas: SAS.sas;
+ sas: IGeneratedSas;
isSelf?: boolean;
inDialog?: boolean; // whether this component is being shown in a dialog and to use DialogButtons
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 1200042f2f..a2262a6afa 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1650,6 +1650,7 @@
"Start a new chat": "Start a new chat",
"Explore all public rooms": "Explore all public rooms",
"Quick actions": "Quick actions",
+ "Explore %(spaceName)s": "Explore %(spaceName)s",
"Use the + to make a new room or explore existing ones below": "Use the + to make a new room or explore existing ones below",
"%(count)s results in all spaces|other": "%(count)s results in all spaces",
"%(count)s results in all spaces|one": "%(count)s result in all spaces",
@@ -1871,7 +1872,7 @@
"Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?",
"Deactivate user": "Deactivate user",
"Failed to deactivate user": "Failed to deactivate user",
- "Role": "Role",
+ "Role in ": "Role in ",
"This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.",
"Edit devices": "Edit devices",
"Security": "Security",
@@ -1927,6 +1928,7 @@
"Decrypting": "Decrypting",
"Download": "Download",
"View Source": "View Source",
+ "Some encryption parameters have been changed.": "Some encryption parameters have been changed.",
"Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
"Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.",
"Encryption enabled": "Encryption enabled",
diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
index ff99b38fe3..f49d51454b 100644
--- a/src/stores/SpaceStore.tsx
+++ b/src/stores/SpaceStore.tsx
@@ -257,7 +257,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
"go to that room's Security & Privacy settings.") }
{ /* Reuses classes from TabbedView for simplicity, non-interactive */ }
-
+
{ _t("General") }
@@ -366,16 +366,22 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
}
public getParents(roomId: string, canonicalOnly = false): Room[] {
+ const userId = this.matrixClient?.getUserId();
const room = this.matrixClient?.getRoom(roomId);
return room?.currentState.getStateEvents(EventType.SpaceParent)
- .filter(ev => {
+ .map(ev => {
const content = ev.getContent();
- if (!content?.via?.length) return false;
- // TODO apply permissions check to verify that the parent mapping is valid
- if (canonicalOnly && !content?.canonical) return false;
- return true;
+ if (Array.isArray(content?.via) && (!canonicalOnly || content?.canonical)) {
+ const parent = this.matrixClient.getRoom(ev.getStateKey());
+ // only respect the relationship if the sender has sufficient permissions in the parent to set
+ // child relations, as per MSC1772.
+ // https://github.com/matrix-org/matrix-doc/blob/main/proposals/1772-groups-as-rooms.md#relationship-between-rooms-and-spaces
+ if (parent?.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
+ return parent;
+ }
+ }
+ // else implicit undefined which causes this element to be filtered out
})
- .map(ev => this.matrixClient.getRoom(ev.getStateKey()))
.filter(Boolean) || [];
}
@@ -530,6 +536,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
});
}
+ const hiddenChildren = new EnhancedMap>();
+ visibleRooms.forEach(room => {
+ if (room.getMyMembership() !== "join") return;
+ this.getParents(room.roomId).forEach(parent => {
+ hiddenChildren.getOrCreate(parent.roomId, new Set()).add(room.roomId);
+ });
+ });
+
this.rootSpaces.forEach(s => {
// traverse each space tree in DFS to build up the supersets as you go up,
// reusing results from like subtrees.
@@ -559,6 +573,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
roomIds.add(roomId);
});
});
+ hiddenChildren.get(spaceId)?.forEach(roomId => {
+ roomIds.add(roomId);
+ });
this.spaceFilteredRooms.set(spaceId, roomIds);
return roomIds;
};
@@ -690,6 +707,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
}
this.emit(room.roomId);
break;
+
+ case EventType.RoomPowerLevels:
+ if (room.isSpaceRoom()) {
+ this.onRoomsUpdate();
+ }
+ break;
}
};
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
index 91a4cf6642..058a605380 100644
--- a/src/stores/widgets/StopGapWidgetDriver.ts
+++ b/src/stores/widgets/StopGapWidgetDriver.ts
@@ -44,7 +44,7 @@ import { containsEmoji } from "../../effects/utils";
import dis from "../../dispatcher/dispatcher";
import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks";
import { IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event";
-import { Room } from "matrix-js-sdk";
+import { Room } from "matrix-js-sdk/src/models/room";
// TODO: Purge this from the universe
diff --git a/src/utils/RoomUpgrade.ts b/src/utils/RoomUpgrade.ts
index e632ec6345..366f49d892 100644
--- a/src/utils/RoomUpgrade.ts
+++ b/src/utils/RoomUpgrade.ts
@@ -22,6 +22,7 @@ import Modal from "../Modal";
import { _t } from "../languageHandler";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import SpaceStore from "../stores/SpaceStore";
+import Spinner from "../components/views/elements/Spinner";
export async function upgradeRoom(
room: Room,
@@ -29,8 +30,10 @@ export async function upgradeRoom(
inviteUsers = false,
handleError = true,
updateSpaces = true,
+ awaitRoom = false,
): Promise {
const cli = room.client;
+ const spinnerModal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner");
let newRoomId: string;
try {
@@ -46,27 +49,36 @@ export async function upgradeRoom(
throw e;
}
- // We have to wait for the js-sdk to give us the room back so
- // we can more effectively abuse the MultiInviter behaviour
- // which heavily relies on the Room object being available.
- if (inviteUsers) {
- const checkForUpgradeFn = async (newRoom: Room): Promise => {
- // The upgradePromise should be done by the time we await it here.
- if (newRoom.roomId !== newRoomId) return;
-
- const toInvite = [
- ...room.getMembersWithMembership("join"),
- ...room.getMembersWithMembership("invite"),
- ].map(m => m.userId).filter(m => m !== cli.getUserId());
-
- if (toInvite.length > 0) {
- // Errors are handled internally to this function
- await inviteUsersToRoom(newRoomId, toInvite);
+ if (awaitRoom || inviteUsers) {
+ await new Promise(resolve => {
+ // already have the room
+ if (room.client.getRoom(newRoomId)) {
+ resolve();
+ return;
}
- cli.removeListener('Room', checkForUpgradeFn);
- };
- cli.on('Room', checkForUpgradeFn);
+ // We have to wait for the js-sdk to give us the room back so
+ // we can more effectively abuse the MultiInviter behaviour
+ // which heavily relies on the Room object being available.
+ const checkForRoomFn = (newRoom: Room) => {
+ if (newRoom.roomId !== newRoomId) return;
+ resolve();
+ cli.off("Room", checkForRoomFn);
+ };
+ cli.on("Room", checkForRoomFn);
+ });
+ }
+
+ if (inviteUsers) {
+ const toInvite = [
+ ...room.getMembersWithMembership("join"),
+ ...room.getMembersWithMembership("invite"),
+ ].map(m => m.userId).filter(m => m !== cli.getUserId());
+
+ if (toInvite.length > 0) {
+ // Errors are handled internally to this function
+ await inviteUsersToRoom(newRoomId, toInvite);
+ }
}
if (updateSpaces) {
@@ -89,5 +101,6 @@ export async function upgradeRoom(
}
}
+ spinnerModal.close();
return newRoomId;
}
diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts
index 7cfd97b234..698bd01370 100644
--- a/test/stores/SpaceStore-test.ts
+++ b/test/stores/SpaceStore-test.ts
@@ -276,10 +276,12 @@ describe("SpaceStore", () => {
describe("test fixture 1", () => {
beforeEach(async () => {
- [fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1].forEach(mkRoom);
+ [fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1, room2, room3]
+ .forEach(mkRoom);
mkSpace(space1, [fav1, room1]);
mkSpace(space2, [fav1, fav2, fav3, room1]);
mkSpace(space3, [invite2]);
+ // client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId));
[fav1, fav2, fav3].forEach(roomId => {
client.getRoom(roomId).tags = {
@@ -329,6 +331,48 @@ describe("SpaceStore", () => {
]);
// dmPartner3 is not in any common spaces with you
+ // room 2 claims to be a child of space2 and is so via a valid m.space.parent
+ const cliRoom2 = client.getRoom(room2);
+ cliRoom2.currentState.getStateEvents.mockImplementation(testUtils.mockStateEventImplementation([
+ mkEvent({
+ event: true,
+ type: EventType.SpaceParent,
+ room: room2,
+ user: client.getUserId(),
+ skey: space2,
+ content: { via: [], canonical: true },
+ ts: Date.now(),
+ }),
+ ]));
+ const cliSpace2 = client.getRoom(space2);
+ cliSpace2.currentState.maySendStateEvent.mockImplementation((evType: string, userId: string) => {
+ if (evType === EventType.SpaceChild) {
+ return userId === client.getUserId();
+ }
+ return true;
+ });
+
+ // room 3 claims to be a child of space3 but is not due to invalid m.space.parent (permissions)
+ const cliRoom3 = client.getRoom(room3);
+ cliRoom3.currentState.getStateEvents.mockImplementation(testUtils.mockStateEventImplementation([
+ mkEvent({
+ event: true,
+ type: EventType.SpaceParent,
+ room: room3,
+ user: client.getUserId(),
+ skey: space3,
+ content: { via: [], canonical: true },
+ ts: Date.now(),
+ }),
+ ]));
+ const cliSpace3 = client.getRoom(space3);
+ cliSpace3.currentState.maySendStateEvent.mockImplementation((evType: string, userId: string) => {
+ if (evType === EventType.SpaceChild) {
+ return false;
+ }
+ return true;
+ });
+
await run();
});
@@ -445,6 +489,14 @@ describe("SpaceStore", () => {
expect(store.getNotificationState(space2).rooms.map(r => r.roomId).includes(room1)).toBeTruthy();
expect(store.getNotificationState(space3).rooms.map(r => r.roomId).includes(room1)).toBeFalsy();
});
+
+ it("honours m.space.parent if sender has permission in parent space", () => {
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space2)).has(room2)).toBeTruthy();
+ });
+
+ it("does not honour m.space.parent if sender does not have permission in parent space", () => {
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space3)).has(room3)).toBeFalsy();
+ });
});
});
diff --git a/yarn.lock b/yarn.lock
index f70f0e75c1..e0b4a403b8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2036,10 +2036,10 @@ ajv@^8.0.1:
require-from-string "^2.0.2"
uri-js "^4.2.2"
-allchange@^1.0.2:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.0.2.tgz#86b9190e12b7ede4f230ae763cbd504c48fd907b"
- integrity sha512-qJv1t2yvBThkes8g/dPMt8CGu+04U+q5QjCJn2Ngp92edZU8DJBfKGyGXo7w1iV48LVuQKQDfMsdIWhP7zHdlQ==
+allchange@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.0.3.tgz#f8814ddfbcfe39a01bf4570778ee7e6d9ff0ebb3"
+ integrity sha512-UZkfz5SkNEMFQFLr8vZcXHaph2EbJxmkVNF5Nt6D9RIa5pmAar7oAMfNdda714jg7IQijvaFty5PYazXLgd5WA==
dependencies:
"@actions/core" "^1.4.0"
"@actions/github" "^5.0.0"