From bf66a720546cbc549a74dddabc9e303e3d41e51f Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 18 Jun 2021 10:05:46 +0100
Subject: [PATCH 01/48] Move JoinRule, GuestAccess, HistoryVisibility enums
 into the js-sdk

---
 .../tabs/room/SecurityRoomSettingsTab.tsx     | 30 ++++---------------
 1 file changed, 6 insertions(+), 24 deletions(-)

diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index 02bbcfb751..e0add2cd05 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -15,39 +15,21 @@ limitations under the License.
 */
 
 import React from 'react';
+import { JoinRule, GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials";
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
-import {_t} from "../../../../../languageHandler";
-import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
+
+import { _t } from "../../../../../languageHandler";
+import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
 import * as sdk from "../../../../..";
 import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
 import Modal from "../../../../../Modal";
 import QuestionDialog from "../../../dialogs/QuestionDialog";
 import StyledRadioGroup from '../../../elements/StyledRadioGroup';
-import {SettingLevel} from "../../../../../settings/SettingLevel";
+import { SettingLevel } from "../../../../../settings/SettingLevel";
 import SettingsStore from "../../../../../settings/SettingsStore";
-import {UIFeature} from "../../../../../settings/UIFeature";
+import { UIFeature } from "../../../../../settings/UIFeature";
 import { replaceableComponent } from "../../../../../utils/replaceableComponent";
 
-// Knock and private are reserved keywords which are not yet implemented.
-enum JoinRule {
-    Public = "public",
-    Knock = "knock",
-    Invite = "invite",
-    Private = "private",
-}
-
-enum GuestAccess {
-    CanJoin = "can_join",
-    Forbidden = "forbidden",
-}
-
-enum HistoryVisibility {
-    Invited = "invited",
-    Joined = "joined",
-    Shared = "shared",
-    WorldReadable = "world_readable",
-}
-
 interface IProps {
     roomId: string;
 }

From 18cafeb2211aa897bd3137386710d7355f2c6427 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 18 Jun 2021 11:40:02 +0100
Subject: [PATCH 02/48] Add ability to disable entire StyledRadioGroup

---
 .../views/elements/StyledRadioGroup.tsx           | 15 ++++++++++++---
 1 file changed, 12 insertions(+), 3 deletions(-)

diff --git a/src/components/views/elements/StyledRadioGroup.tsx b/src/components/views/elements/StyledRadioGroup.tsx
index 6b9e992f92..40ba0212dc 100644
--- a/src/components/views/elements/StyledRadioGroup.tsx
+++ b/src/components/views/elements/StyledRadioGroup.tsx
@@ -19,7 +19,7 @@ import classNames from "classnames";
 
 import StyledRadioButton from "./StyledRadioButton";
 
-interface IDefinition<T extends string> {
+export interface IDefinition<T extends string> {
     value: T;
     className?: string;
     disabled?: boolean;
@@ -34,10 +34,19 @@ interface IProps<T extends string> {
     definitions: IDefinition<T>[];
     value?: T; // if not provided no options will be selected
     outlined?: boolean;
+    disabled?: boolean;
     onChange(newValue: T): void;
 }
 
-function StyledRadioGroup<T extends string>({name, definitions, value, className, outlined, onChange}: IProps<T>) {
+function StyledRadioGroup<T extends string>({
+    name,
+    definitions,
+    value,
+    className,
+    outlined,
+    disabled,
+    onChange,
+}: IProps<T>) {
     const _onChange = e => {
         onChange(e.target.value);
     };
@@ -50,7 +59,7 @@ function StyledRadioGroup<T extends string>({name, definitions, value, className
                 checked={d.checked !== undefined ? d.checked : d.value === value}
                 name={name}
                 value={d.value}
-                disabled={d.disabled}
+                disabled={d.disabled ?? disabled}
                 outlined={outlined}
             >
                 {d.label}

From e508ff003be378c0291496b1ba618b0aab0d61c3 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 18 Jun 2021 11:43:36 +0100
Subject: [PATCH 03/48] Clean up typing to Security Room Settings

---
 .../tabs/room/SecurityRoomSettingsTab.tsx     | 135 ++++++++++--------
 1 file changed, 72 insertions(+), 63 deletions(-)

diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index e0add2cd05..a9dfd67ca9 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -15,8 +15,10 @@ limitations under the License.
 */
 
 import React from 'react';
-import { JoinRule, GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials";
+import { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials";
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { IRoomVersionsCapability } from 'matrix-js-sdk/src/client';
+import { EventType } from 'matrix-js-sdk/src/@types/event';
 
 import { _t } from "../../../../../languageHandler";
 import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
@@ -24,7 +26,7 @@ import * as sdk from "../../../../..";
 import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
 import Modal from "../../../../../Modal";
 import QuestionDialog from "../../../dialogs/QuestionDialog";
-import StyledRadioGroup from '../../../elements/StyledRadioGroup';
+import StyledRadioGroup, { IDefinition } from '../../../elements/StyledRadioGroup';
 import { SettingLevel } from "../../../../../settings/SettingLevel";
 import SettingsStore from "../../../../../settings/SettingsStore";
 import { UIFeature } from "../../../../../settings/UIFeature";
@@ -42,6 +44,12 @@ interface IState {
     encrypted: boolean;
 }
 
+enum RoomVisibility {
+    InviteOnly = "invite_only",
+    PublicNoGuests = "public_no_guests",
+    PublicWithGuests = "public_with_guests",
+}
+
 @replaceableComponent("views.settings.tabs.room.SecurityRoomSettingsTab")
 export default class SecurityRoomSettingsTab extends React.Component<IProps, IState> {
     constructor(props) {
@@ -57,31 +65,33 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
     }
 
     // TODO: [REACT-WARNING] Move this to constructor
-    async UNSAFE_componentWillMount() { // eslint-disable-line camelcase
-        MatrixClientPeg.get().on("RoomState.events", this.onStateEvent);
+    UNSAFE_componentWillMount() { // eslint-disable-line camelcase
+        const cli = MatrixClientPeg.get();
+        cli.on("RoomState.events", this.onStateEvent);
 
-        const room = MatrixClientPeg.get().getRoom(this.props.roomId);
+        const room = cli.getRoom(this.props.roomId);
         const state = room.currentState;
 
         const joinRule: JoinRule = this.pullContentPropertyFromEvent(
-            state.getStateEvents("m.room.join_rules", ""),
+            state.getStateEvents(EventType.RoomJoinRules, ""),
             'join_rule',
             JoinRule.Invite,
         );
         const guestAccess: GuestAccess = this.pullContentPropertyFromEvent(
-            state.getStateEvents("m.room.guest_access", ""),
+            state.getStateEvents(EventType.RoomGuestAccess, ""),
             'guest_access',
             GuestAccess.Forbidden,
         );
         const history: HistoryVisibility = this.pullContentPropertyFromEvent(
-            state.getStateEvents("m.room.history_visibility", ""),
+            state.getStateEvents(EventType.RoomHistoryVisibility, ""),
             'history_visibility',
             HistoryVisibility.Shared,
         );
+
         const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
-        this.setState({joinRule, guestAccess, history, encrypted});
-        const hasAliases = await this.hasAliases();
-        this.setState({hasAliases});
+        this.setState({ joinRule, guestAccess, history, encrypted });
+
+        this.hasAliases().then(hasAliases => this.setState({ hasAliases }));
     }
 
     private pullContentPropertyFromEvent<T>(event: MatrixEvent, key: string, defaultValue: T): T {
@@ -94,13 +104,13 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
     }
 
     private onStateEvent = (e: MatrixEvent) => {
-        const refreshWhenTypes = [
-            'm.room.join_rules',
-            'm.room.guest_access',
-            'm.room.history_visibility',
-            'm.room.encryption',
+        const refreshWhenTypes: EventType[] = [
+            EventType.RoomJoinRules,
+            EventType.RoomGuestAccess,
+            EventType.RoomHistoryVisibility,
+            EventType.RoomEncryption,
         ];
-        if (refreshWhenTypes.includes(e.getType())) this.forceUpdate();
+        if (refreshWhenTypes.includes(e.getType() as EventType)) this.forceUpdate();
     };
 
     private onEncryptionChange = (e: React.ChangeEvent) => {
@@ -126,7 +136,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
                 const beforeEncrypted = this.state.encrypted;
                 this.setState({encrypted: true});
                 MatrixClientPeg.get().sendStateEvent(
-                    this.props.roomId, "m.room.encryption",
+                    this.props.roomId, EventType.RoomEncryption,
                     { algorithm: "m.megolm.v1.aes-sha2" },
                 ).catch((e) => {
                     console.error(e);
@@ -140,25 +150,21 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         e.preventDefault();
         e.stopPropagation();
 
-        const joinRule = JoinRule.Invite;
         const guestAccess = GuestAccess.CanJoin;
 
-        const beforeJoinRule = this.state.joinRule;
         const beforeGuestAccess = this.state.guestAccess;
-        this.setState({joinRule, guestAccess});
+        this.setState({ guestAccess });
 
         const client = MatrixClientPeg.get();
-        client.sendStateEvent(this.props.roomId, "m.room.join_rules", {join_rule: joinRule}, "").catch((e) => {
-            console.error(e);
-            this.setState({joinRule: beforeJoinRule});
-        });
-        client.sendStateEvent(this.props.roomId, "m.room.guest_access", {guest_access: guestAccess}, "").catch((e) => {
+        client.sendStateEvent(this.props.roomId, EventType.RoomGuestAccess, {
+            guest_access: guestAccess,
+        }, "").catch((e) => {
             console.error(e);
             this.setState({guestAccess: beforeGuestAccess});
         });
     };
 
-    private onRoomAccessRadioToggle = (roomAccess: string) => {
+    private onRoomAccessRadioToggle = (roomAccess: RoomVisibility) => {
         //                         join_rule
         //                      INVITE  |  PUBLIC
         //        ----------------------+----------------
@@ -176,14 +182,14 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         let guestAccess = GuestAccess.CanJoin;
 
         switch (roomAccess) {
-            case "invite_only":
+            case RoomVisibility.InviteOnly:
                 // no change - use defaults above
                 break;
-            case "public_no_guests":
+            case RoomVisibility.PublicNoGuests:
                 joinRule = JoinRule.Public;
                 guestAccess = GuestAccess.Forbidden;
                 break;
-            case "public_with_guests":
+            case RoomVisibility.PublicWithGuests:
                 joinRule = JoinRule.Public;
                 guestAccess = GuestAccess.CanJoin;
                 break;
@@ -194,11 +200,15 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         this.setState({joinRule, guestAccess});
 
         const client = MatrixClientPeg.get();
-        client.sendStateEvent(this.props.roomId, "m.room.join_rules", {join_rule: joinRule}, "").catch((e) => {
+        client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, {
+            join_rule: joinRule,
+        }, "").catch((e) => {
             console.error(e);
             this.setState({joinRule: beforeJoinRule});
         });
-        client.sendStateEvent(this.props.roomId, "m.room.guest_access", {guest_access: guestAccess}, "").catch((e) => {
+        client.sendStateEvent(this.props.roomId, EventType.RoomGuestAccess, {
+            guest_access: guestAccess,
+        }, "").catch((e) => {
             console.error(e);
             this.setState({guestAccess: beforeGuestAccess});
         });
@@ -207,7 +217,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
     private onHistoryRadioToggle = (history: HistoryVisibility) => {
         const beforeHistory = this.state.history;
         this.setState({history: history});
-        MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.history_visibility", {
+        MatrixClientPeg.get().sendStateEvent(this.props.roomId, EventType.RoomHistoryVisibility, {
             history_visibility: history,
         }, "").catch((e) => {
             console.error(e);
@@ -227,7 +237,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
             return Array.isArray(localAliases) && localAliases.length !== 0;
         } else {
             const room = cli.getRoom(this.props.roomId);
-            const aliasEvents = room.currentState.getStateEvents("m.room.aliases") || [];
+            const aliasEvents = room.currentState.getStateEvents(EventType.RoomAliases) || [];
             const hasAliases = !!aliasEvents.find((ev) => (ev.getContent().aliases || []).length > 0);
             return hasAliases;
         }
@@ -239,11 +249,11 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         const joinRule = this.state.joinRule;
         const guestAccess = this.state.guestAccess;
 
-        const canChangeAccess = room.currentState.mayClientSendStateEvent("m.room.join_rules", client)
-            && room.currentState.mayClientSendStateEvent("m.room.guest_access", client);
+        const canChangeAccess = room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, client)
+            && room.currentState.mayClientSendStateEvent(EventType.RoomGuestAccess, client);
 
         let guestWarning = null;
-        if (joinRule !== 'public' && guestAccess === 'forbidden') {
+        if (joinRule !== JoinRule.Public && guestAccess === GuestAccess.Forbidden) {
             guestWarning = (
                 <div className='mx_SecurityRoomSettingsTab_warning'>
                     <img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
@@ -256,7 +266,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         }
 
         let aliasWarning = null;
-        if (joinRule === 'public' && !this.state.hasAliases) {
+        if (joinRule === JoinRule.Public && !this.state.hasAliases) {
             aliasWarning = (
                 <div className='mx_SecurityRoomSettingsTab_warning'>
                     <img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
@@ -267,34 +277,33 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
             );
         }
 
+        const radioDefinitions: IDefinition<RoomVisibility>[] = [
+            {
+                value: RoomVisibility.InviteOnly,
+                label: _t('Only people who have been invited'),
+                checked: joinRule !== JoinRule.Public && joinRule !== JoinRule.Restricted,
+            },
+            {
+                value: RoomVisibility.PublicNoGuests,
+                label: _t('Anyone who knows the room\'s link, apart from guests'),
+                checked: joinRule === JoinRule.Public && guestAccess !== GuestAccess.CanJoin,
+            },
+            {
+                value: RoomVisibility.PublicWithGuests,
+                label: _t("Anyone who knows the room's link, including guests"),
+                checked: joinRule === JoinRule.Public && guestAccess === GuestAccess.CanJoin,
+            },
+        ];
+
         return (
             <div>
-                {guestWarning}
-                {aliasWarning}
+                { guestWarning }
+                { aliasWarning }
                 <StyledRadioGroup
                     name="roomVis"
-                    value={joinRule}
                     onChange={this.onRoomAccessRadioToggle}
-                    definitions={[
-                        {
-                            value: "invite_only",
-                            disabled: !canChangeAccess,
-                            label: _t('Only people who have been invited'),
-                            checked: joinRule !== "public",
-                        },
-                        {
-                            value: "public_no_guests",
-                            disabled: !canChangeAccess,
-                            label: _t('Anyone who knows the room\'s link, apart from guests'),
-                            checked: joinRule === "public" && guestAccess !== "can_join",
-                        },
-                        {
-                            value: "public_with_guests",
-                            disabled: !canChangeAccess,
-                            label: _t("Anyone who knows the room's link, including guests"),
-                            checked: joinRule === "public" && guestAccess === "can_join",
-                        },
-                    ]}
+                    definitions={radioDefinitions}
+                    disabled={!canChangeAccess}
                 />
             </div>
         );
@@ -304,7 +313,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         const client = MatrixClientPeg.get();
         const history = this.state.history;
         const state = client.getRoom(this.props.roomId).currentState;
-        const canChangeHistory = state.mayClientSendStateEvent('m.room.history_visibility', client);
+        const canChangeHistory = state.mayClientSendStateEvent(EventType.RoomHistoryVisibility, client);
 
         return (
             <div>
@@ -349,7 +358,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         const client = MatrixClientPeg.get();
         const room = client.getRoom(this.props.roomId);
         const isEncrypted = this.state.encrypted;
-        const hasEncryptionPermission = room.currentState.mayClientSendStateEvent("m.room.encryption", client);
+        const hasEncryptionPermission = room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, client);
         const canEnableEncryption = !isEncrypted && hasEncryptionPermission;
 
         let encryptionSettings = null;

From 566b8af2a4357ae2fb9fca28814dc7d4a36d37aa Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 18 Jun 2021 12:18:23 +0100
Subject: [PATCH 04/48] Initial support for MSC3083 via MSC3244

---
 .../tabs/room/SecurityRoomSettingsTab.tsx     | 33 +++++++++++++++----
 src/createRoom.ts                             | 28 +++++++++++++---
 2 files changed, 51 insertions(+), 10 deletions(-)

diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index a9dfd67ca9..2913fb1036 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -42,12 +42,14 @@ interface IState {
     history: HistoryVisibility;
     hasAliases: boolean;
     encrypted: boolean;
+    roomVersionsCapability?: IRoomVersionsCapability;
 }
 
 enum RoomVisibility {
     InviteOnly = "invite_only",
     PublicNoGuests = "public_no_guests",
     PublicWithGuests = "public_with_guests",
+    Restricted = "restricted",
 }
 
 @replaceableComponent("views.settings.tabs.room.SecurityRoomSettingsTab")
@@ -92,6 +94,9 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         this.setState({ joinRule, guestAccess, history, encrypted });
 
         this.hasAliases().then(hasAliases => this.setState({ hasAliases }));
+        cli.getCapabilities().then(capabilities => this.setState({
+            roomVersionsCapability: capabilities["m.room_versions"],
+        }));
     }
 
     private pullContentPropertyFromEvent<T>(event: MatrixEvent, key: string, defaultValue: T): T {
@@ -166,12 +171,12 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
 
     private onRoomAccessRadioToggle = (roomAccess: RoomVisibility) => {
         //                         join_rule
-        //                      INVITE  |  PUBLIC
-        //        ----------------------+----------------
-        // guest  CAN_JOIN   | inv_only | pub_with_guest
-        // access ----------------------+----------------
-        //        FORBIDDEN  | inv_only | pub_no_guest
-        //        ----------------------+----------------
+        //                      INVITE  |  PUBLIC        | RESTRICTED
+        //        -----------+----------+----------------+-------------
+        // guest  CAN_JOIN   | inv_only | pub_with_guest | restricted
+        // access -----------+----------+----------------+-------------
+        //        FORBIDDEN  | inv_only | pub_no_guest   | restricted
+        //        -----------+----------+----------------+-------------
 
         // we always set guests can_join here as it makes no sense to have
         // an invite-only room that guests can't join.  If you explicitly
@@ -185,6 +190,9 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
             case RoomVisibility.InviteOnly:
                 // no change - use defaults above
                 break;
+            case RoomVisibility.Restricted:
+                joinRule = JoinRule.Restricted;
+                break;
             case RoomVisibility.PublicNoGuests:
                 joinRule = JoinRule.Public;
                 guestAccess = GuestAccess.Forbidden;
@@ -295,6 +303,19 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
             },
         ];
 
+        const roomCapabilities = this.state.roomVersionsCapability?.["org.matrix.msc3244.room_capabilities"];
+        if (roomCapabilities?.["restricted"]) {
+            if (Array.isArray(roomCapabilities["restricted"]?.support) &&
+                roomCapabilities["restricted"].support.includes(room.getVersion() ?? "1")
+            ) {
+                radioDefinitions.unshift({
+                    value: RoomVisibility.Restricted,
+                    label: _t("Only people in certain spaces or those who have been invited (TODO copy)"),
+                    checked: joinRule === JoinRule.Restricted,
+                });
+            }
+        }
+
         return (
             <div>
                 { guestWarning }
diff --git a/src/createRoom.ts b/src/createRoom.ts
index 2641492588..0b51613846 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -18,6 +18,8 @@ limitations under the License.
 import { MatrixClient } from "matrix-js-sdk/src/client";
 import { Room } from "matrix-js-sdk/src/models/room";
 import { EventType } from "matrix-js-sdk/src/@types/event";
+import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
+import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
 
 import { MatrixClientPeg } from './MatrixClientPeg';
 import Modal from './Modal';
@@ -35,8 +37,6 @@ import { VIRTUAL_ROOM_EVENT_TYPE } from "./CallHandler";
 import SpaceStore from "./stores/SpaceStore";
 import { makeSpaceParentEvent } from "./utils/space";
 import { Action } from "./dispatcher/actions"
-import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
-import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
 
 // we define a number of interfaces which take their names from the js-sdk
 /* eslint-disable camelcase */
@@ -72,7 +72,7 @@ export interface IOpts {
  * @returns {Promise} which resolves to the room id, or null if the
  * action was aborted or failed.
  */
-export default function createRoom(opts: IOpts): Promise<string | null> {
+export default async function createRoom(opts: IOpts): Promise<string | null> {
     opts = opts || {};
     if (opts.spinner === undefined) opts.spinner = true;
     if (opts.guestAccess === undefined) opts.guestAccess = true;
@@ -86,7 +86,7 @@ export default function createRoom(opts: IOpts): Promise<string | null> {
     const client = MatrixClientPeg.get();
     if (client.isGuest()) {
         dis.dispatch({action: 'require_registration'});
-        return Promise.resolve(null);
+        return null;
     }
 
     const defaultPreset = opts.dmUserId ? Preset.TrustedPrivateChat : Preset.PrivateChat;
@@ -150,6 +150,26 @@ export default function createRoom(opts: IOpts): Promise<string | null> {
                 "history_visibility": opts.createOpts.preset === Preset.PublicChat ? "world_readable" : "invited",
             },
         });
+
+        if (opts.parentSpace.getJoinRule() !== JoinRule.Public && opts.createOpts.preset !== Preset.PublicChat) {
+            const serverCapabilities = await client.getCapabilities();
+            const roomCapabilities = serverCapabilities?.["m.room_versions"]?.["org.matrix.msc3244.room_capabilities"];
+            if (roomCapabilities?.["restricted"]) {
+                opts.createOpts.room_version = roomCapabilities?.["restricted"].preferred;
+
+                opts.createOpts.initial_state.push({
+                    type: EventType.RoomJoinRules,
+                    content: {
+                        "join_rule": JoinRule.Restricted,
+                        "allow": [{
+                            "type": "m.room_membership",
+                            "room_id": opts.parentSpace.roomId,
+                        }],
+                        "authorised_servers": [client.getDomain()], // TODO this might want tweaking
+                    },
+                })
+            }
+        }
     }
 
     let modal;

From d0dc5cf347f596fc7919ef0f4d88bd1b00aaf463 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 22 Jun 2021 16:07:05 +0100
Subject: [PATCH 05/48] Update early MSC3083 support

---
 src/createRoom.ts | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/createRoom.ts b/src/createRoom.ts
index 0b51613846..6a14dc005d 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -19,7 +19,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
 import { Room } from "matrix-js-sdk/src/models/room";
 import { EventType } from "matrix-js-sdk/src/@types/event";
 import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
-import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
+import { JoinRule, Preset, RestrictedAllowType, Visibility } from "matrix-js-sdk/src/@types/partials";
 
 import { MatrixClientPeg } from './MatrixClientPeg';
 import Modal from './Modal';
@@ -162,10 +162,9 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
                     content: {
                         "join_rule": JoinRule.Restricted,
                         "allow": [{
-                            "type": "m.room_membership",
+                            "type": RestrictedAllowType.RoomMembership,
                             "room_id": opts.parentSpace.roomId,
                         }],
-                        "authorised_servers": [client.getDomain()], // TODO this might want tweaking
                     },
                 })
             }

From e8f0412fe30abe28036e421f22ea12079f0332ca Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 2 Jul 2021 14:51:55 +0100
Subject: [PATCH 06/48] Add way to manage Restricted join rule in Room Settings

---
 res/css/_components.scss                      |   1 +
 .../_ManageRestrictedJoinRuleDialog.scss      | 147 +++++++
 res/css/views/settings/tabs/_SettingsTab.scss |   4 +-
 .../tabs/room/_SecurityRoomSettingsTab.scss   |  86 +++-
 src/SlashCommands.tsx                         |  49 +--
 src/components/views/avatars/RoomAvatar.tsx   |  21 +-
 .../ManageRestrictedJoinRuleDialog.tsx        | 182 ++++++++
 ...Dialog.js => RoomUpgradeWarningDialog.tsx} |  97 +++--
 .../views/elements/StyledRadioGroup.tsx       |   6 +-
 .../tabs/room/SecurityRoomSettingsTab.tsx     | 396 +++++++++++-------
 src/createRoom.ts                             |  16 +-
 src/i18n/strings/en_EN.json                   |  43 +-
 src/utils/RoomUpgrade.ts                      |  74 ++++
 13 files changed, 857 insertions(+), 265 deletions(-)
 create mode 100644 res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss
 create mode 100644 src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
 rename src/components/views/dialogs/{RoomUpgradeWarningDialog.js => RoomUpgradeWarningDialog.tsx} (59%)
 create mode 100644 src/utils/RoomUpgrade.ts

diff --git a/res/css/_components.scss b/res/css/_components.scss
index 1517527034..7463f92037 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -87,6 +87,7 @@
 @import "./views/dialogs/_IncomingSasDialog.scss";
 @import "./views/dialogs/_InviteDialog.scss";
 @import "./views/dialogs/_KeyboardShortcutsDialog.scss";
+@import "./views/dialogs/_ManageRestrictedJoinRuleDialog.scss";
 @import "./views/dialogs/_MessageEditHistoryDialog.scss";
 @import "./views/dialogs/_ModalWidgetDialog.scss";
 @import "./views/dialogs/_NewSessionReviewDialog.scss";
diff --git a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss
new file mode 100644
index 0000000000..6606f78a8a
--- /dev/null
+++ b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss
@@ -0,0 +1,147 @@
+/*
+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_ManageRestrictedJoinRuleDialog_wrapper {
+    .mx_Dialog {
+        display: flex;
+        flex-direction: column;
+    }
+}
+
+.mx_ManageRestrictedJoinRuleDialog {
+    width: 480px;
+    color: $primary-fg-color;
+    display: flex;
+    flex-direction: column;
+    flex-wrap: nowrap;
+    min-height: 0;
+    height: 60vh;
+
+    .mx_SearchBox {
+        // To match the space around the title
+        margin: 0 0 15px 0;
+        flex-grow: 0;
+    }
+
+    .mx_ManageRestrictedJoinRuleDialog_content {
+        flex-grow: 1;
+    }
+
+    .mx_ManageRestrictedJoinRuleDialog_noResults {
+        display: block;
+        margin-top: 24px;
+    }
+
+    .mx_ManageRestrictedJoinRuleDialog_section {
+        &:not(:first-child) {
+            margin-top: 24px;
+        }
+
+        > h3 {
+            margin: 0;
+            color: $secondary-fg-color;
+            font-size: $font-12px;
+            font-weight: $font-semi-bold;
+            line-height: $font-15px;
+        }
+
+        .mx_ManageRestrictedJoinRuleDialog_entry {
+            display: flex;
+            margin-top: 12px;
+
+            > div {
+                flex-grow: 1;
+            }
+
+            img.mx_RoomAvatar_isSpaceRoom,
+            .mx_RoomAvatar_isSpaceRoom img {
+                border-radius: 4px;
+            }
+
+            .mx_ManageRestrictedJoinRuleDialog_entry_name {
+                margin: 0 8px;
+                font-size: $font-15px;
+                line-height: 30px;
+                flex-grow: 1;
+                overflow: hidden;
+                white-space: nowrap;
+                text-overflow: ellipsis;
+            }
+
+            .mx_ManageRestrictedJoinRuleDialog_entry_description {
+                margin-top: 8px;
+                font-size: $font-12px;
+                line-height: $font-15px;
+                color: $tertiary-fg-color;
+            }
+
+            .mx_Checkbox {
+                align-items: center;
+            }
+        }
+    }
+
+    .mx_ManageRestrictedJoinRuleDialog_section_spaces {
+        .mx_BaseAvatar {
+            margin-right: 12px;
+        }
+
+        .mx_BaseAvatar_image {
+            border-radius: 8px;
+        }
+    }
+
+    .mx_ManageRestrictedJoinRuleDialog_section_experimental {
+        position: relative;
+        border-radius: 8px;
+        margin: 12px 0;
+        padding: 8px 8px 8px 42px;
+        background-color: $header-panel-bg-color;
+
+        font-size: $font-12px;
+        line-height: $font-15px;
+        color: $secondary-fg-color;
+
+        &::before {
+            content: '';
+            position: absolute;
+            left: 10px;
+            top: calc(50% - 8px); // vertical centering
+            height: 16px;
+            width: 16px;
+            background-color: $secondary-fg-color;
+            mask-repeat: no-repeat;
+            mask-size: contain;
+            mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
+            mask-position: center;
+        }
+    }
+
+    .mx_ManageRestrictedJoinRuleDialog_footer {
+        display: flex;
+        margin-top: 20px;
+        align-self: end;
+
+        .mx_AccessibleButton {
+            display: inline-block;
+            align-self: center;
+
+            & + .mx_AccessibleButton {
+                margin-left: 24px;
+            }
+        }
+    }
+}
diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss
index 892f5fe744..0d679af4e5 100644
--- a/res/css/views/settings/tabs/_SettingsTab.scss
+++ b/res/css/views/settings/tabs/_SettingsTab.scss
@@ -47,14 +47,14 @@ limitations under the License.
     color: $settings-subsection-fg-color;
     font-size: $font-14px;
     display: block;
-    margin: 10px 100px 10px 0; // Align with the rest of the view
+    margin: 10px 80px 10px 0; // Align with the rest of the view
 }
 
 .mx_SettingsTab_section {
     margin-bottom: 24px;
 
     .mx_SettingsFlag {
-        margin-right: 100px;
+        margin-right: 80px;
         margin-bottom: 10px;
     }
 
diff --git a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss
index 23dcc532b2..2aab201352 100644
--- a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss
+++ b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss
@@ -14,6 +14,44 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+.mx_SecurityRoomSettingsTab {
+    .mx_SettingsTab_showAdvanced {
+        padding: 0;
+        margin-bottom: 16px;
+    }
+
+    .mx_SecurityRoomSettingsTab_spacesWithAccess {
+        > h4 {
+            color: $secondary-fg-color;
+            font-weight: $font-semi-bold;
+            font-size: $font-12px;
+            line-height: $font-15px;
+            text-transform: uppercase;
+        }
+
+        > span {
+            font-weight: 500;
+            font-size: $font-14px;
+            line-height: 32px; // matches height of avatar for v-align
+            color: $secondary-fg-color;
+            display: inline-block;
+
+            img.mx_RoomAvatar_isSpaceRoom,
+            .mx_RoomAvatar_isSpaceRoom img {
+                border-radius: 8px;
+            }
+
+            .mx_BaseAvatar {
+                margin-right: 8px;
+            }
+
+            & + span {
+                margin-left: 16px;
+            }
+        }
+    }
+}
+
 .mx_SecurityRoomSettingsTab_warning {
     display: block;
 
@@ -26,5 +64,51 @@ limitations under the License.
 }
 
 .mx_SecurityRoomSettingsTab_encryptionSection {
-    margin-bottom: 25px;
+    padding-bottom: 24px;
+    border-bottom: 1px solid $menu-border-color;
+    margin-bottom: 32px;
+}
+
+.mx_SecurityRoomSettingsTab_upgradeRequired {
+    margin-left: 16px;
+    padding: 4px 16px;
+    border: 1px solid $accent-color;
+    border-radius: 8px;
+    color: $accent-color;
+    font-size: $font-12px;
+    line-height: $font-15px;
+}
+
+.mx_SecurityRoomSettingsTab_joinRule {
+    .mx_RadioButton {
+        padding-top: 16px;
+        margin-bottom: 8px;
+
+        .mx_RadioButton_content {
+            margin-left: 14px;
+            font-weight: $font-semi-bold;
+            font-size: $font-15px;
+            line-height: $font-24px;
+            color: $primary-fg-color;
+            display: block;
+        }
+    }
+
+    > span {
+        display: inline-block;
+        margin-left: 34px;
+        margin-bottom: 16px;
+        font-size: $font-15px;
+        line-height: $font-24px;
+        color: $secondary-fg-color;
+
+        & + .mx_RadioButton {
+            border-top: 1px solid $menu-border-color;
+        }
+    }
+
+    .mx_AccessibleButton_kind_link {
+        padding: 0;
+        font-size: inherit;
+    }
 }
diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx
index 128ca9e5e2..b8f500ba84 100644
--- a/src/SlashCommands.tsx
+++ b/src/SlashCommands.tsx
@@ -35,7 +35,6 @@ import { getAddressType } from './UserAddress';
 import { abbreviateUrl } from './utils/UrlUtils';
 import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
 import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks";
-import { inviteUsersToRoom } from "./RoomInvite";
 import { WidgetType } from "./widgets/WidgetType";
 import { Jitsi } from "./widgets/Jitsi";
 import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
@@ -50,6 +49,7 @@ import { UIFeature } from "./settings/UIFeature";
 import { CHAT_EFFECTS } from "./effects";
 import CallHandler from "./CallHandler";
 import { guessAndSetDMRoom } from "./Rooms";
+import { upgradeRoom } from './utils/RoomUpgrade';
 
 // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
 interface HTMLInputEvent extends Event {
@@ -276,51 +276,8 @@ export const Commands = [
                     /*isPriority=*/false, /*isStatic=*/true);
 
                 return success(finished.then(async ([resp]) => {
-                    if (!resp.continue) return;
-
-                    let checkForUpgradeFn;
-                    try {
-                        const upgradePromise = cli.upgradeRoom(roomId, args);
-
-                        // 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 (resp.invite) {
-                            checkForUpgradeFn = async (newRoom) => {
-                                // The upgradePromise should be done by the time we await it here.
-                                const { replacement_room: newRoomId } = await upgradePromise;
-                                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);
-                                }
-
-                                cli.removeListener('Room', checkForUpgradeFn);
-                            };
-                            cli.on('Room', checkForUpgradeFn);
-                        }
-
-                        // We have to await after so that the checkForUpgradesFn has a proper reference
-                        // to the new room's ID.
-                        await upgradePromise;
-                    } catch (e) {
-                        console.error(e);
-
-                        if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn);
-
-                        const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
-                        Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, {
-                            title: _t('Error upgrading room'),
-                            description: _t(
-                                'Double check that your server supports the room version chosen and try again.'),
-                        });
-                    }
+                    if (!resp?.continue) return;
+                    await upgradeRoom(room, args, resp.invite);
                 }));
             }
             return reject(this.getUsage());
diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx
index 8ac8de8233..bd776953a6 100644
--- a/src/components/views/avatars/RoomAvatar.tsx
+++ b/src/components/views/avatars/RoomAvatar.tsx
@@ -13,9 +13,11 @@ 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, { ComponentProps } from 'react';
 import { Room } from 'matrix-js-sdk/src/models/room';
 import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
+import classNames from "classnames";
 
 import BaseAvatar from './BaseAvatar';
 import ImageView from '../elements/ImageView';
@@ -31,11 +33,14 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
     // oobData.avatarUrl should be set (else there
     // would be nowhere to get the avatar from)
     room?: Room;
-    oobData?: IOOBData;
+    oobData?: IOOBData & {
+        roomId?: string;
+    };
     width?: number;
     height?: number;
     resizeMethod?: ResizeMethod;
     viewAvatarOnClick?: boolean;
+    className?: string;
     onClick?(): void;
 }
 
@@ -128,14 +133,16 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
     };
 
     public render() {
-        const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
-
-        const roomName = room ? room.name : oobData.name;
+        const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
 
         return (
-            <BaseAvatar {...otherProps}
-                name={roomName}
-                idName={room ? room.roomId : null}
+            <BaseAvatar
+                {...otherProps}
+                className={classNames(className, {
+                    mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
+                })}
+                name={room ? room.name : oobData.name}
+                idName={room ? room.roomId : oobData.roomId}
                 urls={this.state.urls}
                 onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
             />
diff --git a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
new file mode 100644
index 0000000000..79a6fb7f24
--- /dev/null
+++ b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
@@ -0,0 +1,182 @@
+/*
+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, { useMemo, useState } from "react";
+import { Room } from "matrix-js-sdk/src/models/room";
+
+import { _t } from '../../../languageHandler';
+import { IDialogProps } from "./IDialogProps";
+import BaseDialog from "./BaseDialog";
+import SearchBox from "../../structures/SearchBox";
+import SpaceStore from "../../../stores/SpaceStore";
+import RoomAvatar from "../avatars/RoomAvatar";
+import AccessibleButton from "../elements/AccessibleButton";
+import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
+import StyledCheckbox from "../elements/StyledCheckbox";
+import MatrixClientContext from "../../../contexts/MatrixClientContext";
+
+interface IProps extends IDialogProps {
+    room: Room;
+    selected?: string[];
+}
+
+const Entry = ({ room, checked, onChange }) => {
+    const localRoom = room instanceof Room;
+
+    let description;
+    if (localRoom) {
+        description = _t("%(count)s members", { count: room.getJoinedMemberCount() });
+        const numChildRooms = SpaceStore.instance.getChildRooms(room.roomId).length;
+        if (numChildRooms > 0) {
+            description += " · " + _t("%(count)s rooms", { count: numChildRooms });
+        }
+    }
+
+    return <label className="mx_ManageRestrictedJoinRuleDialog_entry">
+        <div>
+            <div>
+                { localRoom
+                    ? <RoomAvatar room={room} height={20} width={20} />
+                    : <RoomAvatar oobData={room} height={20} width={20} />
+                }
+                <span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{ room.name }</span>
+            </div>
+            { description && <div className="mx_ManageRestrictedJoinRuleDialog_entry_description">
+                { description }
+            </div> }
+        </div>
+        <StyledCheckbox
+            onChange={onChange ? (e) => onChange(e.target.checked) : null}
+            checked={checked}
+            disabled={!onChange}
+        />
+    </label>;
+};
+
+const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [], onFinished }) => {
+    const cli = room.client;
+    const [newSelected, setNewSelected] = useState(new Set<string>(selected));
+    const [query, setQuery] = useState("");
+    const lcQuery = query.toLowerCase().trim();
+
+    const [spacesContainingRoom, otherEntries] = useMemo(() => {
+        const spaces = cli.getVisibleRooms().filter(r => r.getMyMembership() === "join" && r.isSpaceRoom());
+        return [
+            spaces.filter(r => SpaceStore.instance.getSpaceFilteredRoomIds(r).has(room.roomId)),
+            selected.map(roomId => {
+                const room = cli.getRoom(roomId);
+                if (!room) {
+                    return { roomId, name: roomId } as Room;
+                }
+                if (room.getMyMembership() !== "join" || !room.isSpaceRoom()) {
+                    return room;
+                }
+            }).filter(Boolean),
+        ];
+    }, [cli, selected, room.roomId]);
+
+    const [filteredSpacesContainingRooms, filteredOtherEntries] = useMemo(() => [
+        spacesContainingRoom.filter(r => r.name.toLowerCase().includes(lcQuery)),
+        otherEntries.filter(r => r.name.toLowerCase().includes(lcQuery)),
+    ], [spacesContainingRoom, otherEntries, lcQuery]);
+
+    const onChange = (checked: boolean, room: Room): void => {
+        if (checked) {
+            newSelected.add(room.roomId);
+        } else {
+            newSelected.delete(room.roomId);
+        }
+        setNewSelected(new Set(newSelected));
+    };
+
+    return <BaseDialog
+        title={_t("Select spaces")}
+        className="mx_ManageRestrictedJoinRuleDialog"
+        onFinished={onFinished}
+        fixedWidth={false}
+    >
+        <p>
+            { _t("Decide which spaces can access this room. " +
+                "If a space is selected its members will be able to find and join <RoomName/>.", {}, {
+                RoomName: () => <b>{ room.name }</b>,
+            })}
+        </p>
+        <MatrixClientContext.Provider value={cli}>
+            <SearchBox
+                className="mx_textinput_icon mx_textinput_search"
+                placeholder={ _t("Search spaces") }
+                onSearch={setQuery}
+                autoComplete={true}
+                autoFocus={true}
+            />
+            <AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">
+                { filteredSpacesContainingRooms.length > 0 ? (
+                    <div className="mx_ManageRestrictedJoinRuleDialog_section">
+                        <h3>{ _t("Spaces you know that contain this room") }</h3>
+                        { filteredSpacesContainingRooms.map(space => {
+                            return <Entry
+                                key={space.roomId}
+                                room={space}
+                                checked={newSelected.has(space.roomId)}
+                                onChange={(checked: boolean) => {
+                                    onChange(checked, space);
+                                }}
+                            />;
+                        }) }
+                    </div>
+                ) : undefined }
+
+                { filteredOtherEntries.length > 0 ? (
+                    <div className="mx_ManageRestrictedJoinRuleDialog_section">
+                        <h3>{ _t("Other spaces or rooms you might not know") }</h3>
+                        <div className="mx_ManageRestrictedJoinRuleDialog_section_experimental">
+                            <div>{ _t("These are likely ones other room admins are a part of.") }</div>
+                        </div>
+                        { filteredOtherEntries.map(space => {
+                            return <Entry
+                                key={space.roomId}
+                                room={space}
+                                checked={newSelected.has(space.roomId)}
+                                onChange={(checked: boolean) => {
+                                    onChange(checked, space);
+                                }}
+                            />;
+                        }) }
+                    </div>
+                ) : null }
+
+                { filteredSpacesContainingRooms.length + filteredOtherEntries.length < 1
+                    ? <span className="mx_ManageRestrictedJoinRuleDialog_noResults">
+                        { _t("No results") }
+                    </span>
+                    : undefined
+                }
+            </AutoHideScrollbar>
+
+            <div className="mx_ManageRestrictedJoinRuleDialog_footer">
+                <AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
+                    { _t("Cancel") }
+                </AccessibleButton>
+                <AccessibleButton kind="primary" onClick={() => onFinished(Array.from(newSelected))}>
+                    { _t("Confirm") }
+                </AccessibleButton>
+            </div>
+        </MatrixClientContext.Provider>
+    </BaseDialog>;
+};
+
+export default ManageRestrictedJoinRuleDialog;
+
diff --git a/src/components/views/dialogs/RoomUpgradeWarningDialog.js b/src/components/views/dialogs/RoomUpgradeWarningDialog.tsx
similarity index 59%
rename from src/components/views/dialogs/RoomUpgradeWarningDialog.js
rename to src/components/views/dialogs/RoomUpgradeWarningDialog.tsx
index c73edcd871..6bc770c05b 100644
--- a/src/components/views/dialogs/RoomUpgradeWarningDialog.js
+++ b/src/components/views/dialogs/RoomUpgradeWarningDialog.tsx
@@ -1,5 +1,5 @@
 /*
-Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+Copyright 2019 - 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,86 +14,95 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { ReactNode } from 'react';
+import { EventType } from 'matrix-js-sdk/src/@types/event';
+import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
+
 import { _t } from "../../../languageHandler";
 import SdkConfig from "../../../SdkConfig";
-import * as sdk from "../../../index";
 import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
 import Modal from "../../../Modal";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { IDialogProps } from "./IDialogProps";
+import BugReportDialog from './BugReportDialog';
+import BaseDialog from "./BaseDialog";
+import DialogButtons from "../elements/DialogButtons";
+
+interface IProps extends IDialogProps {
+    roomId: string;
+    targetVersion: string;
+    description?: ReactNode;
+}
+
+interface IState {
+    inviteUsersToNewRoom: boolean;
+}
 
 @replaceableComponent("views.dialogs.RoomUpgradeWarningDialog")
-export default class RoomUpgradeWarningDialog extends React.Component {
-    static propTypes = {
-        onFinished: PropTypes.func.isRequired,
-        roomId: PropTypes.string.isRequired,
-        targetVersion: PropTypes.string.isRequired,
-    };
+export default class RoomUpgradeWarningDialog extends React.Component<IProps, IState> {
+    private readonly isPrivate: boolean;
+    private readonly currentVersion: string;
 
     constructor(props) {
         super(props);
 
         const room = MatrixClientPeg.get().getRoom(this.props.roomId);
-        const joinRules = room ? room.currentState.getStateEvents("m.room.join_rules", "") : null;
-        const isPrivate = joinRules ? joinRules.getContent()['join_rule'] !== 'public' : true;
+        const joinRules = room?.currentState.getStateEvents(EventType.RoomJoinRules, "");
+        this.isPrivate = joinRules?.getContent()['join_rule'] !== JoinRule.Public ?? true;
+        this.currentVersion = room?.getVersion() || "1";
+
         this.state = {
-            currentVersion: room ? room.getVersion() : "1",
-            isPrivate,
             inviteUsersToNewRoom: true,
         };
     }
 
-    _onContinue = () => {
-        this.props.onFinished({ continue: true, invite: this.state.isPrivate && this.state.inviteUsersToNewRoom });
+    private onContinue = () => {
+        this.props.onFinished({ continue: true, invite: this.isPrivate && this.state.inviteUsersToNewRoom });
     };
 
-    _onCancel = () => {
+    private onCancel = () => {
         this.props.onFinished({ continue: false, invite: false });
     };
 
-    _onInviteUsersToggle = (newVal) => {
-        this.setState({ inviteUsersToNewRoom: newVal });
+    private onInviteUsersToggle = (inviteUsersToNewRoom: boolean) => {
+        this.setState({ inviteUsersToNewRoom });
     };
 
-    _openBugReportDialog = (e) => {
+    private openBugReportDialog = (e) => {
         e.preventDefault();
         e.stopPropagation();
 
-        const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
         Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
     };
 
     render() {
         const brand = SdkConfig.get().brand;
-        const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
-        const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
 
         let inviteToggle = null;
-        if (this.state.isPrivate) {
+        if (this.isPrivate) {
             inviteToggle = (
                 <LabelledToggleSwitch
                     value={this.state.inviteUsersToNewRoom}
-                    onChange={this._onInviteUsersToggle}
-                    label={_t("Automatically invite users")} />
+                    onChange={this.onInviteUsersToggle}
+                    label={_t("Automatically invite members from this room to the new one")} />
             );
         }
 
-        const title = this.state.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room");
+        const title = this.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room");
 
         let bugReports = (
             <p>
-                {_t(
+                { _t(
                     "This usually only affects how the room is processed on the server. If you're " +
                     "having problems with your %(brand)s, please report a bug.", { brand },
-                )}
+                ) }
             </p>
         );
         if (SdkConfig.get().bug_report_endpoint_url) {
             bugReports = (
                 <p>
-                    {_t(
+                    { _t(
                         "This usually only affects how the room is processed on the server. If you're " +
                         "having problems with your %(brand)s, please <a>report a bug</a>.",
                         {
@@ -101,10 +110,10 @@ export default class RoomUpgradeWarningDialog extends React.Component {
                         },
                         {
                             "a": (sub) => {
-                                return <a href='#' onClick={this._openBugReportDialog}>{sub}</a>;
+                                return <a href='#' onClick={this.openBugReportDialog}>{sub}</a>;
                             },
                         },
-                    )}
+                    ) }
                 </p>
             );
         }
@@ -119,29 +128,37 @@ export default class RoomUpgradeWarningDialog extends React.Component {
             >
                 <div>
                     <p>
-                        {_t(
+                        { this.props.description || _t(
                             "Upgrading a room is an advanced action and is usually recommended when a room " +
                             "is unstable due to bugs, missing features or security vulnerabilities.",
-                        )}
+                        ) }
                     </p>
-                    {bugReports}
+                    <p>
+                        { _t(
+                            "<b>Please note upgrading will make a new version of the room</b>. " +
+                            "All current messages will stay in this archived room.", {}, {
+                                b: sub => <b>{ sub }</b>,
+                            },
+                        ) }
+                    </p>
+                    { bugReports }
                     <p>
                         {_t(
                             "You'll upgrade this room from <oldVersion /> to <newVersion />.",
                             {},
                             {
-                                oldVersion: () => <code>{this.state.currentVersion}</code>,
-                                newVersion: () => <code>{this.props.targetVersion}</code>,
+                                oldVersion: () => <code>{ this.currentVersion }</code>,
+                                newVersion: () => <code>{ this.props.targetVersion }</code>,
                             },
                         )}
                     </p>
-                    {inviteToggle}
+                    { inviteToggle }
                 </div>
                 <DialogButtons
                     primaryButton={_t("Upgrade")}
-                    onPrimaryButtonClick={this._onContinue}
+                    onPrimaryButtonClick={this.onContinue}
                     cancelButton={_t("Cancel")}
-                    onCancel={this._onCancel}
+                    onCancel={this.onCancel}
                 />
             </BaseDialog>
         );
diff --git a/src/components/views/elements/StyledRadioGroup.tsx b/src/components/views/elements/StyledRadioGroup.tsx
index af152b82da..efd278c991 100644
--- a/src/components/views/elements/StyledRadioGroup.tsx
+++ b/src/components/views/elements/StyledRadioGroup.tsx
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from "react";
+import React, { ReactNode } from "react";
 import classNames from "classnames";
 
 import StyledRadioButton from "./StyledRadioButton";
@@ -23,8 +23,8 @@ export interface IDefinition<T extends string> {
     value: T;
     className?: string;
     disabled?: boolean;
-    label: React.ReactChild;
-    description?: React.ReactChild;
+    label: ReactNode;
+    description?: ReactNode;
     checked?: boolean; // If provided it will override the value comparison done in the group
 }
 
diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index 2c2c336d24..34f5b8c94c 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -15,9 +15,8 @@ limitations under the License.
 */
 
 import React from 'react';
-import { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
-import { IRoomVersionsCapability } from 'matrix-js-sdk/src/client';
+import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "matrix-js-sdk/src/@types/partials";
+import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { EventType } from 'matrix-js-sdk/src/@types/event';
 
 import { _t } from "../../../../../languageHandler";
@@ -31,6 +30,12 @@ import { SettingLevel } from "../../../../../settings/SettingLevel";
 import SettingsStore from "../../../../../settings/SettingsStore";
 import { UIFeature } from "../../../../../settings/UIFeature";
 import { replaceableComponent } from "../../../../../utils/replaceableComponent";
+import AccessibleButton from "../../../elements/AccessibleButton";
+import SpaceStore from "../../../../../stores/SpaceStore";
+import RoomAvatar from "../../../avatars/RoomAvatar";
+import ManageRestrictedJoinRuleDialog from '../../../dialogs/ManageRestrictedJoinRuleDialog';
+import RoomUpgradeWarningDialog from '../../../dialogs/RoomUpgradeWarningDialog';
+import { upgradeRoom } from "../../../../../utils/RoomUpgrade";
 
 interface IProps {
     roomId: string;
@@ -38,18 +43,14 @@ interface IProps {
 
 interface IState {
     joinRule: JoinRule;
+    restrictedAllowRoomIds?: string[];
     guestAccess: GuestAccess;
     history: HistoryVisibility;
     hasAliases: boolean;
     encrypted: boolean;
-    roomVersionsCapability?: IRoomVersionsCapability;
-}
-
-enum RoomVisibility {
-    InviteOnly = "invite_only",
-    PublicNoGuests = "public_no_guests",
-    PublicWithGuests = "public_with_guests",
-    Restricted = "restricted",
+    roomSupportsRestricted?: boolean;
+    preferredRestrictionVersion?: string;
+    showAdvancedSection: boolean;
 }
 
 @replaceableComponent("views.settings.tabs.room.SecurityRoomSettingsTab")
@@ -59,10 +60,11 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
 
         this.state = {
             joinRule: JoinRule.Invite,
-            guestAccess: GuestAccess.CanJoin,
+            guestAccess: GuestAccess.Forbidden,
             history: HistoryVisibility.Shared,
             hasAliases: false,
             encrypted: false,
+            showAdvancedSection: false,
         };
     }
 
@@ -74,34 +76,47 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         const room = cli.getRoom(this.props.roomId);
         const state = room.currentState;
 
-        const joinRule: JoinRule = this.pullContentPropertyFromEvent(
-            state.getStateEvents(EventType.RoomJoinRules, ""),
+        const joinRuleEvent = state.getStateEvents(EventType.RoomJoinRules, "");
+        const joinRule: JoinRule = this.pullContentPropertyFromEvent<JoinRule>(
+            joinRuleEvent,
             'join_rule',
             JoinRule.Invite,
         );
-        const guestAccess: GuestAccess = this.pullContentPropertyFromEvent(
+        const restrictedAllowRoomIds = joinRule === JoinRule.Restricted
+            ? joinRuleEvent?.getContent().allow
+                ?.filter(a => a.type === RestrictedAllowType.RoomMembership)
+                ?.map(a => a.room_id)
+            : undefined;
+
+        const guestAccess: GuestAccess = this.pullContentPropertyFromEvent<GuestAccess>(
             state.getStateEvents(EventType.RoomGuestAccess, ""),
             'guest_access',
             GuestAccess.Forbidden,
         );
-        const history: HistoryVisibility = this.pullContentPropertyFromEvent(
+        const history: HistoryVisibility = this.pullContentPropertyFromEvent<HistoryVisibility>(
             state.getStateEvents(EventType.RoomHistoryVisibility, ""),
             'history_visibility',
             HistoryVisibility.Shared,
         );
 
         const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
-        this.setState({ joinRule, guestAccess, history, encrypted });
+        this.setState({ joinRule, restrictedAllowRoomIds, guestAccess, history, encrypted });
 
         this.hasAliases().then(hasAliases => this.setState({ hasAliases }));
-        cli.getCapabilities().then(capabilities => this.setState({
-            roomVersionsCapability: capabilities["m.room_versions"],
-        }));
+        cli.getCapabilities().then(capabilities => {
+            const roomCapabilities = capabilities["org.matrix.msc3244.room_capabilities"];
+            const roomSupportsRestricted = roomCapabilities && Array.isArray(roomCapabilities["restricted"]?.support) &&
+                roomCapabilities["restricted"].support.includes(room.getVersion());
+            const preferredRestrictionVersion = roomSupportsRestricted
+                ? roomCapabilities?.["restricted"].preferred
+                : undefined;
+
+            this.setState({ roomSupportsRestricted, preferredRestrictionVersion });
+        });
     }
 
     private pullContentPropertyFromEvent<T>(event: MatrixEvent, key: string, defaultValue: T): T {
-        if (!event || !event.getContent()) return defaultValue;
-        return event.getContent()[key] || defaultValue;
+        return event?.getContent()[key] || defaultValue;
     }
 
     componentWillUnmount() {
@@ -151,81 +166,80 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         });
     };
 
-    private fixGuestAccess = (e: React.MouseEvent) => {
-        e.preventDefault();
-        e.stopPropagation();
+    private onJoinRuleChange = (joinRule: JoinRule) => {
+        if (joinRule === JoinRule.Restricted &&
+            !this.state.roomSupportsRestricted &&
+            this.state.preferredRestrictionVersion
+        ) {
+            const cli = MatrixClientPeg.get();
+            const roomId = this.props.roomId;
+            const room = cli.getRoom(roomId);
+            const targetVersion = this.state.preferredRestrictionVersion;
+            const activeSpace = SpaceStore.instance.activeSpace;
+            Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
+                roomId,
+                targetVersion,
+                description: _t("This upgrade will allow members of selected spaces " +
+                    "access to this room without an invite."),
+                onFinished: async (resp) => {
+                    if (!resp?.continue) return;
+                    const { replacement_room: newRoomId } = await upgradeRoom(room, targetVersion, resp.invite);
 
-        const guestAccess = GuestAccess.CanJoin;
+                    const content: IContent = {
+                        join_rule: JoinRule.Restricted,
+                    };
 
+                    if (activeSpace) {
+                        content.allow = [{
+                            "type": RestrictedAllowType.RoomMembership,
+                            "room_id": activeSpace.roomId,
+                        }];
+                    }
+
+                    cli.sendStateEvent(newRoomId, EventType.RoomJoinRules, content);
+                },
+            });
+            return;
+        }
+
+        const beforeJoinRule = this.state.joinRule;
+        this.setState({ joinRule });
+
+        const client = MatrixClientPeg.get();
+        client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, {
+            join_rule: joinRule,
+        }, "").catch((e) => {
+            console.error(e);
+            this.setState({ joinRule: beforeJoinRule });
+        });
+    };
+
+    private onRestrictedRoomIdsChange = (restrictedAllowRoomIds: string[]) => {
+        const beforeRestrictedAllowRoomIds = this.state.restrictedAllowRoomIds;
+        this.setState({ restrictedAllowRoomIds });
+
+        const client = MatrixClientPeg.get();
+        client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, {
+            join_rule: JoinRule.Restricted,
+            allow: restrictedAllowRoomIds.map(roomId => ({
+                "type": RestrictedAllowType.RoomMembership,
+                "room_id": roomId,
+            })),
+        }, "").catch((e) => {
+            console.error(e);
+            this.setState({ restrictedAllowRoomIds: beforeRestrictedAllowRoomIds });
+        });
+    };
+
+    private onGuestAccessChange = (allowed: boolean) => {
+        const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden;
         const beforeGuestAccess = this.state.guestAccess;
         this.setState({ guestAccess });
 
         const client = MatrixClientPeg.get();
-        client.sendStateEvent(
-            this.props.roomId,
-            EventType.RoomGuestAccess,
-            { guest_access: guestAccess, },
-            "",
-        ).catch((e) => {
-            console.error(e);
-            this.setState({ guestAccess: beforeGuestAccess });
-        });
-    };
-
-    private onRoomAccessRadioToggle = (roomAccess: RoomVisibility) => {
-        //                         join_rule
-        //                      INVITE  |  PUBLIC        | RESTRICTED
-        //        -----------+----------+----------------+-------------
-        // guest  CAN_JOIN   | inv_only | pub_with_guest | restricted
-        // access -----------+----------+----------------+-------------
-        //        FORBIDDEN  | inv_only | pub_no_guest   | restricted
-        //        -----------+----------+----------------+-------------
-
-        // we always set guests can_join here as it makes no sense to have
-        // an invite-only room that guests can't join.  If you explicitly
-        // invite them, you clearly want them to join, whether they're a
-        // guest or not.  In practice, guest_access should probably have
-        // been implemented as part of the join_rules enum.
-        let joinRule = JoinRule.Invite;
-        let guestAccess = GuestAccess.CanJoin;
-
-        switch (roomAccess) {
-            case RoomVisibility.InviteOnly:
-                // no change - use defaults above
-                break;
-            case RoomVisibility.Restricted:
-                joinRule = JoinRule.Restricted;
-                break;
-            case RoomVisibility.PublicNoGuests:
-                joinRule = JoinRule.Public;
-                guestAccess = GuestAccess.Forbidden;
-                break;
-            case RoomVisibility.PublicWithGuests:
-                joinRule = JoinRule.Public;
-                guestAccess = GuestAccess.CanJoin;
-                break;
-        }
-
-        const beforeJoinRule = this.state.joinRule;
-        const beforeGuestAccess = this.state.guestAccess;
-        this.setState({ joinRule, guestAccess });
-
-        const client = MatrixClientPeg.get();
-        client.sendStateEvent(
-            this.props.roomId,
-            EventType.RoomJoinRules, {
-            join_rule: joinRule,
-        }, "",
-        ).catch((e) => {
-            console.error(e);
-            this.setState({ joinRule: beforeJoinRule });
-        });
-        client.sendStateEvent(
-            this.props.roomId,
-            EventType.RoomGuestAccess, {
+        client.sendStateEvent(this.props.roomId, EventType.RoomGuestAccess, {
             guest_access: guestAccess,
-        }, "",
-        ).catch((e) => {
+        }, "").catch((e) => {
             console.error(e);
             this.setState({ guestAccess: beforeGuestAccess });
         });
@@ -260,27 +274,25 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         }
     }
 
-    private renderRoomAccess() {
+    private onEditRestrictedClick = () => {
+        const matrixClient = MatrixClientPeg.get();
+        Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, {
+            matrixClient,
+            room: matrixClient.getRoom(this.props.roomId),
+            selected: this.state.restrictedAllowRoomIds,
+            onFinished: (restrictedAllowRoomIds?: string[]) => {
+                if (!Array.isArray(restrictedAllowRoomIds)) return;
+                this.onRestrictedRoomIdsChange(restrictedAllowRoomIds);
+            },
+        }, "mx_ManageRestrictedJoinRuleDialog_wrapper");
+    };
+
+    private renderJoinRule() {
         const client = MatrixClientPeg.get();
         const room = client.getRoom(this.props.roomId);
         const joinRule = this.state.joinRule;
-        const guestAccess = this.state.guestAccess;
 
-        const canChangeAccess = room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, client)
-            && room.currentState.mayClientSendStateEvent(EventType.RoomGuestAccess, client);
-
-        let guestWarning = null;
-        if (joinRule !== JoinRule.Public && guestAccess === GuestAccess.Forbidden) {
-            guestWarning = (
-                <div className='mx_SecurityRoomSettingsTab_warning'>
-                    <img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
-                    <span>
-                        {_t("Guests cannot join this room even if explicitly invited.")}&nbsp;
-                        <a href="" onClick={this.fixGuestAccess}>{_t("Click here to fix")}</a>
-                    </span>
-                </div>
-            );
-        }
+        const canChangeJoinRule = room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, client);
 
         let aliasWarning = null;
         if (joinRule === JoinRule.Public && !this.state.hasAliases) {
@@ -294,46 +306,98 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
             );
         }
 
-        const radioDefinitions: IDefinition<RoomVisibility>[] = [
-            {
-                value: RoomVisibility.InviteOnly,
-                label: _t('Only people who have been invited'),
-                checked: joinRule !== JoinRule.Public && joinRule !== JoinRule.Restricted,
-            },
-            {
-                value: RoomVisibility.PublicNoGuests,
-                label: _t('Anyone who knows the room\'s link, apart from guests'),
-                checked: joinRule === JoinRule.Public && guestAccess !== GuestAccess.CanJoin,
-            },
-            {
-                value: RoomVisibility.PublicWithGuests,
-                label: _t("Anyone who knows the room's link, including guests"),
-                checked: joinRule === JoinRule.Public && guestAccess === GuestAccess.CanJoin,
-            },
-        ];
+        const radioDefinitions: IDefinition<JoinRule>[] = [{
+            value: JoinRule.Invite,
+            label: _t("Private (invite only)"),
+            description: _t("Only invited people can join."),
+        }, {
+            value: JoinRule.Public,
+            label: _t("Public (anyone)"),
+            description: _t("Anyone can find and join."),
+        }];
 
-        const roomCapabilities = this.state.roomVersionsCapability?.["org.matrix.msc3244.room_capabilities"];
-        if (roomCapabilities?.["restricted"]) {
-            if (Array.isArray(roomCapabilities["restricted"]?.support) &&
-                roomCapabilities["restricted"].support.includes(room.getVersion() ?? "1")
-            ) {
-                radioDefinitions.unshift({
-                    value: RoomVisibility.Restricted,
-                    label: _t("Only people in certain spaces or those who have been invited (TODO copy)"),
-                    checked: joinRule === JoinRule.Restricted,
-                });
+        if (this.state.roomSupportsRestricted ||
+            this.state.preferredRestrictionVersion ||
+            joinRule === JoinRule.Restricted
+        ) {
+            let upgradeRequiredPill;
+            if (this.state.preferredRestrictionVersion) {
+                upgradeRequiredPill = <span className="mx_SecurityRoomSettingsTab_upgradeRequired">
+                    { _t("Upgrade required") }
+                </span>;
             }
+
+            let description;
+            if (joinRule === JoinRule.Restricted) {
+                let spacesWhichCanAccess;
+                if (this.state.restrictedAllowRoomIds?.length) {
+                    const shownSpaces = this.state.restrictedAllowRoomIds
+                        .map(roomId => client.getRoom(roomId))
+                        .filter(Boolean)
+                        .slice(0, 4);
+
+                    spacesWhichCanAccess = <div className="mx_SecurityRoomSettingsTab_spacesWithAccess">
+                        <h4>{ _t("Spaces with access") }</h4>
+                        { shownSpaces.map(room => {
+                            return <span key={room.roomId}>
+                                <RoomAvatar room={room} height={32} width={32} />
+                                { room.name }
+                            </span>;
+                        })}
+                        { shownSpaces.length < this.state.restrictedAllowRoomIds.length && <span>
+                            { _t("& %(count)s more", {
+                                count: this.state.restrictedAllowRoomIds.length - shownSpaces.length,
+                            }) }
+                        </span> }
+                    </div>;
+                }
+
+                description = <div>
+                    <span>
+                        { _t("Anyone in a space can find and join. <a>Edit which spaces can access here.</a>", {}, {
+                            a: sub => <AccessibleButton
+                                disabled={!canChangeJoinRule}
+                                onClick={this.onEditRestrictedClick}
+                                kind="link"
+                            >
+                                { sub }
+                            </AccessibleButton>,
+                        }) }
+                    </span>
+                    { spacesWhichCanAccess }
+                </div>;
+            } else if (SpaceStore.instance.activeSpace) {
+                description = _t("Anyone in %(spaceName)s can find and join. You can select other spaces too.", {
+                    spaceName: SpaceStore.instance.activeSpace.name,
+                });
+            } else {
+                description = _t("Anyone in a space can find and join. You can select multiple spaces.");
+            }
+
+            radioDefinitions.splice(1, 0, {
+                value: JoinRule.Restricted,
+                label: <>
+                    { _t("Space members") }
+                    { upgradeRequiredPill }
+                </>,
+                description,
+            });
         }
 
         return (
-            <div>
-                { guestWarning }
+            <div className="mx_SecurityRoomSettingsTab_joinRule">
+                <div className="mx_SettingsTab_subsectionText">
+                    <span>{ _t("Decide who can view and join %(roomName)s.", {
+                        roomName: client.getRoom(this.props.roomId)?.name,
+                    }) }</span>
+                </div>
                 { aliasWarning }
                 <StyledRadioGroup
-                    name="roomVis"
-                    onChange={this.onRoomAccessRadioToggle}
+                    name="joinRule"
+                    value={joinRule}
+                    onChange={this.onJoinRuleChange}
                     definitions={radioDefinitions}
-                    disabled={!canChangeAccess}
+                    disabled={!canChangeJoinRule}
                 />
             </div>
         );
@@ -382,6 +446,30 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         );
     }
 
+    private toggleAdvancedSection = () => {
+        this.setState({ showAdvancedSection: !this.state.showAdvancedSection });
+    };
+
+    private renderAdvanced() {
+        const client = MatrixClientPeg.get();
+        const guestAccess = this.state.guestAccess;
+        const state = client.getRoom(this.props.roomId).currentState;
+        const canSetGuestAccess = state.mayClientSendStateEvent(EventType.RoomGuestAccess, client);
+
+        return <>
+            <LabelledToggleSwitch
+                value={guestAccess === GuestAccess.CanJoin}
+                onChange={this.onGuestAccessChange}
+                disabled={!canSetGuestAccess}
+                label={_t("Enable guest access")}
+            />
+            <p>
+                { _t("People with supported clients will be able to join " +
+                    "the room without having a registered account.") }
+            </p>
+        </>;
+    }
+
     render() {
         const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
 
@@ -413,27 +501,39 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
 
         return (
             <div className="mx_SettingsTab mx_SecurityRoomSettingsTab">
-                <div className="mx_SettingsTab_heading">{_t("Security & Privacy")}</div>
+                <div className="mx_SettingsTab_heading">{ _t("Security & Privacy") }</div>
 
-                <span className='mx_SettingsTab_subheading'>{_t("Encryption")}</span>
+                <span className='mx_SettingsTab_subheading'>{ _t("Encryption") }</span>
                 <div className='mx_SettingsTab_section mx_SecurityRoomSettingsTab_encryptionSection'>
                     <div>
                         <div className='mx_SettingsTab_subsectionText'>
-                            <span>{_t("Once enabled, encryption cannot be disabled.")}</span>
+                            <span>{ _t("Once enabled, encryption cannot be disabled.") }</span>
                         </div>
-                        <LabelledToggleSwitch value={isEncrypted} onChange={this.onEncryptionChange}
-                            label={_t("Encrypted")} disabled={!canEnableEncryption}
+                        <LabelledToggleSwitch
+                            value={isEncrypted}
+                            onChange={this.onEncryptionChange}
+                            label={_t("Encrypted")}
+                            disabled={!canEnableEncryption}
                         />
                     </div>
-                    {encryptionSettings}
+                    { encryptionSettings }
                 </div>
 
-                <span className='mx_SettingsTab_subheading'>{_t("Who can access this room?")}</span>
+                <span className='mx_SettingsTab_subheading'>{_t("Access")}</span>
                 <div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
-                    {this.renderRoomAccess()}
+                    { this.renderJoinRule() }
                 </div>
 
-                {historySection}
+                <AccessibleButton
+                    onClick={this.toggleAdvancedSection}
+                    kind="link"
+                    className="mx_SettingsTab_showAdvanced"
+                >
+                    { this.state.showAdvancedSection ? _t("Hide advanced") : _t("Show advanced") }
+                </AccessibleButton>
+                { this.state.showAdvancedSection && this.renderAdvanced() }
+
+                { historySection }
             </div>
         );
     }
diff --git a/src/createRoom.ts b/src/createRoom.ts
index f8a2665704..0a88e2cef7 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -37,8 +37,6 @@ import { VIRTUAL_ROOM_EVENT_TYPE } from "./CallHandler";
 import SpaceStore from "./stores/SpaceStore";
 import { makeSpaceParentEvent } from "./utils/space";
 import { Action } from "./dispatcher/actions";
-import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
-import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
 
 // we define a number of interfaces which take their names from the js-sdk
 /* eslint-disable camelcase */
@@ -53,6 +51,7 @@ export interface IOpts {
     andView?: boolean;
     associatedWithCommunity?: string;
     parentSpace?: Room;
+    joinRule?: JoinRule;
 }
 
 /**
@@ -153,10 +152,10 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
             },
         });
 
-        if (opts.parentSpace.getJoinRule() !== JoinRule.Public && opts.createOpts.preset !== Preset.PublicChat) {
+        if (opts.joinRule === JoinRule.Restricted) {
             const serverCapabilities = await client.getCapabilities();
             const roomCapabilities = serverCapabilities?.["m.room_versions"]?.["org.matrix.msc3244.room_capabilities"];
-            if (roomCapabilities?.["restricted"]) {
+            if (roomCapabilities?.["restricted"]?.preferred) {
                 opts.createOpts.room_version = roomCapabilities?.["restricted"].preferred;
 
                 opts.createOpts.initial_state.push({
@@ -168,11 +167,18 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
                             "room_id": opts.parentSpace.roomId,
                         }],
                     },
-                })
+                });
             }
         }
     }
 
+    if (opts.joinRule !== JoinRule.Restricted) {
+        opts.createOpts.initial_state.push({
+            type: EventType.RoomJoinRules,
+            content: { join_rule: opts.joinRule },
+        });
+    }
+
     let modal;
     if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
 
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 618d5763fa..860fea32d8 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -434,8 +434,6 @@
     "To use it, just wait for autocomplete results to load and tab through them.": "To use it, just wait for autocomplete results to load and tab through them.",
     "Upgrades a room to a new version": "Upgrades a room to a new version",
     "You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.",
-    "Error upgrading room": "Error upgrading room",
-    "Double check that your server supports the room version chosen and try again.": "Double check that your server supports the room version chosen and try again.",
     "Changes your display nickname": "Changes your display nickname",
     "Changes your display nickname in the current room only": "Changes your display nickname in the current room only",
     "Changes the avatar of the current room": "Changes the avatar of the current room",
@@ -728,6 +726,8 @@
     "Common names and surnames are easy to guess": "Common names and surnames are easy to guess",
     "Straight rows of keys are easy to guess": "Straight rows of keys are easy to guess",
     "Short keyboard patterns are easy to guess": "Short keyboard patterns are easy to guess",
+    "Error upgrading room": "Error upgrading room",
+    "Double check that your server supports the room version chosen and try again.": "Double check that your server supports the room version chosen and try again.",
     "Invite to %(spaceName)s": "Invite to %(spaceName)s",
     "Share your public space": "Share your public space",
     "Unknown App": "Unknown App",
@@ -1435,22 +1435,31 @@
     "Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room",
     "Enable encryption?": "Enable encryption?",
     "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
-    "Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.",
-    "Click here to fix": "Click here to fix",
+    "This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.",
     "To link to this room, please add an address.": "To link to this room, please add an address.",
-    "Only people who have been invited": "Only people who have been invited",
-    "Anyone who knows the room's link, apart from guests": "Anyone who knows the room's link, apart from guests",
-    "Anyone who knows the room's link, including guests": "Anyone who knows the room's link, including guests",
+    "Private (invite only)": "Private (invite only)",
+    "Only invited people can join.": "Only invited people can join.",
+    "Public (anyone)": "Public (anyone)",
+    "Anyone can find and join.": "Anyone can find and join.",
+    "Upgrade required": "Upgrade required",
+    "Spaces with access": "Spaces with access",
+    "& %(count)s more|other": "& %(count)s more",
+    "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>",
+    "Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Anyone in %(spaceName)s can find and join. You can select other spaces too.",
+    "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.",
+    "Space members": "Space members",
+    "Decide who can view and join %(roomName)s.": "Decide who can view and join %(roomName)s.",
     "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.",
     "Anyone": "Anyone",
     "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
     "Members only (since they were invited)": "Members only (since they were invited)",
     "Members only (since they joined)": "Members only (since they joined)",
+    "People with supported clients will be able to join the room without having a registered account.": "People with supported clients will be able to join the room without having a registered account.",
     "Who can read history?": "Who can read history?",
     "Security & Privacy": "Security & Privacy",
     "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.",
     "Encrypted": "Encrypted",
-    "Who can access this room?": "Who can access this room?",
+    "Access": "Access",
     "Unable to revoke sharing for email address": "Unable to revoke sharing for email address",
     "Unable to share email address": "Unable to share email address",
     "Your email address hasn't been verified yet": "Your email address hasn't been verified yet",
@@ -2331,6 +2340,16 @@
     "Manually export keys": "Manually export keys",
     "You'll lose access to your encrypted messages": "You'll lose access to your encrypted messages",
     "Are you sure you want to sign out?": "Are you sure you want to sign out?",
+    "%(count)s members|other": "%(count)s members",
+    "%(count)s members|one": "%(count)s member",
+    "%(count)s rooms|other": "%(count)s rooms",
+    "%(count)s rooms|one": "%(count)s room",
+    "Select spaces": "Select spaces",
+    "Decide which spaces can access this room. If a space is selected its members will be able to find and join <RoomName/>.": "Decide which spaces can access this room. If a space is selected its members will be able to find and join <RoomName/>.",
+    "Search spaces": "Search spaces",
+    "Spaces you know that contain this room": "Spaces you know that contain this room",
+    "Other spaces or rooms you might not know": "Other spaces or rooms you might not know",
+    "These are likely ones other room admins are a part of.": "These are likely ones other room admins are a part of.",
     "Confirm by comparing the following with the User Settings in your other session:": "Confirm by comparing the following with the User Settings in your other session:",
     "Confirm this user's session by comparing the following with their User Settings:": "Confirm this user's session by comparing the following with their User Settings:",
     "Session name": "Session name",
@@ -2374,12 +2393,13 @@
     "Update any local room aliases to point to the new room": "Update any local room aliases to point to the new room",
     "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room",
     "Put a link back to the old room at the start of the new room so people can see old messages": "Put a link back to the old room at the start of the new room so people can see old messages",
-    "Automatically invite users": "Automatically invite users",
+    "Automatically invite members from this room to the new one": "Automatically invite members from this room to the new one",
     "Upgrade private room": "Upgrade private room",
     "Upgrade public room": "Upgrade public room",
     "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.",
     "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please <a>report a bug</a>.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please <a>report a bug</a>.",
     "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.",
+    "<b>Please note upgrading will make a new version of the room</b>. All current messages will stay in this archived room.": "<b>Please note upgrading will make a new version of the room</b>. All current messages will stay in this archived room.",
     "You'll upgrade this room from <oldVersion /> to <newVersion />.": "You'll upgrade this room from <oldVersion /> to <newVersion />.",
     "Resend": "Resend",
     "You're all caught up.": "You're all caught up.",
@@ -2636,6 +2656,7 @@
     "You are an administrator of this community": "You are an administrator of this community",
     "You are a member of this community": "You are a member of this community",
     "Who can join this community?": "Who can join this community?",
+    "Only people who have been invited": "Only people who have been invited",
     "Everyone": "Everyone",
     "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!",
     "Long Description (HTML)": "Long Description (HTML)",
@@ -2733,10 +2754,6 @@
     "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.",
     "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.",
     "You don't have permission": "You don't have permission",
-    "%(count)s members|other": "%(count)s members",
-    "%(count)s members|one": "%(count)s member",
-    "%(count)s rooms|other": "%(count)s rooms",
-    "%(count)s rooms|one": "%(count)s room",
     "This room is suggested as a good one to join": "This room is suggested as a good one to join",
     "Suggested": "Suggested",
     "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",
diff --git a/src/utils/RoomUpgrade.ts b/src/utils/RoomUpgrade.ts
new file mode 100644
index 0000000000..7330b23863
--- /dev/null
+++ b/src/utils/RoomUpgrade.ts
@@ -0,0 +1,74 @@
+/*
+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 { Room } from "matrix-js-sdk/src/models/room";
+
+import { inviteUsersToRoom } from "../RoomInvite";
+import Modal from "../Modal";
+import { _t } from "../languageHandler";
+import ErrorDialog from "../components/views/dialogs/ErrorDialog";
+
+export async function upgradeRoom(
+    room: Room,
+    targetVersion: string,
+    inviteUsers = false,
+    // eslint-disable-next-line camelcase
+): Promise<{ replacement_room: string }> {
+    const cli = room.client;
+
+    let checkForUpgradeFn: (room: Room) => Promise<void>;
+    try {
+        const upgradePromise = cli.upgradeRoom(room.roomId, targetVersion);
+
+        // 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) {
+            checkForUpgradeFn = async (newRoom: Room) => {
+                // The upgradePromise should be done by the time we await it here.
+                const { replacement_room: newRoomId } = await upgradePromise;
+                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);
+                }
+
+                cli.removeListener('Room', checkForUpgradeFn);
+            };
+            cli.on('Room', checkForUpgradeFn);
+        }
+
+        // We have to await after so that the checkForUpgradesFn has a proper reference
+        // to the new room's ID.
+        return upgradePromise;
+    } catch (e) {
+        console.error(e);
+
+        if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn);
+
+        Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, {
+            title: _t('Error upgrading room'),
+            description: _t('Double check that your server supports the room version chosen and try again.'),
+        });
+        throw e;
+    }
+}

From 912e192dc64f780b091cca9cccacf9bb4fcf7240 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 2 Jul 2021 15:18:27 +0100
Subject: [PATCH 07/48] Tweak behaviour of setting restricted join rule

---
 .../views/elements/MiniAvatarUploader.tsx     |  2 +-
 .../tabs/room/SecurityRoomSettingsTab.tsx     | 96 ++++++++++++-------
 .../spaces/SpaceSettingsVisibilityTab.tsx     |  2 +-
 src/settings/handlers/RoomSettingsHandler.ts  |  4 +-
 4 files changed, 63 insertions(+), 41 deletions(-)

diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx
index 83fc1ebefd..b38e21977c 100644
--- a/src/components/views/elements/MiniAvatarUploader.tsx
+++ b/src/components/views/elements/MiniAvatarUploader.tsx
@@ -32,7 +32,7 @@ interface IProps {
     hasAvatar: boolean;
     noAvatarLabel?: string;
     hasAvatarLabel?: string;
-    setAvatarUrl(url: string): Promise<void>;
+    setAvatarUrl(url: string): Promise<any>;
 }
 
 const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => {
diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index 34f5b8c94c..ceb7dd21bd 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -36,6 +36,7 @@ import RoomAvatar from "../../../avatars/RoomAvatar";
 import ManageRestrictedJoinRuleDialog from '../../../dialogs/ManageRestrictedJoinRuleDialog';
 import RoomUpgradeWarningDialog from '../../../dialogs/RoomUpgradeWarningDialog';
 import { upgradeRoom } from "../../../../../utils/RoomUpgrade";
+import { arrayHasDiff } from "../../../../../utils/arrays";
 
 interface IProps {
     roomId: string;
@@ -166,56 +167,73 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         });
     };
 
-    private onJoinRuleChange = (joinRule: JoinRule) => {
-        if (joinRule === JoinRule.Restricted &&
-            !this.state.roomSupportsRestricted &&
-            this.state.preferredRestrictionVersion
-        ) {
-            const cli = MatrixClientPeg.get();
+    private onJoinRuleChange = async (joinRule: JoinRule) => {
+        const beforeJoinRule = this.state.joinRule;
+        if (beforeJoinRule === joinRule) return;
+
+        if (joinRule === JoinRule.Restricted) {
+            const matrixClient = MatrixClientPeg.get();
             const roomId = this.props.roomId;
-            const room = cli.getRoom(roomId);
-            const targetVersion = this.state.preferredRestrictionVersion;
-            const activeSpace = SpaceStore.instance.activeSpace;
-            Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
-                roomId,
-                targetVersion,
-                description: _t("This upgrade will allow members of selected spaces " +
-                    "access to this room without an invite."),
-                onFinished: async (resp) => {
-                    if (!resp?.continue) return;
-                    const { replacement_room: newRoomId } = await upgradeRoom(room, targetVersion, resp.invite);
+            const room = matrixClient.getRoom(roomId);
 
-                    const content: IContent = {
-                        join_rule: JoinRule.Restricted,
-                    };
+            if (this.state.roomSupportsRestricted) {
+                // Have the user pick which spaces to allow joins from
+                const { finished } = Modal.createTrackedDialog('Set restricted', '', ManageRestrictedJoinRuleDialog, {
+                    matrixClient,
+                    room,
+                    // if they have are viewing this room from the context of a space then default to that
+                    selected: SpaceStore.instance.activeSpace ? [SpaceStore.instance.activeSpace.roomId] : [],
+                }, "mx_ManageRestrictedJoinRuleDialog_wrapper");
 
-                    if (activeSpace) {
-                        content.allow = [{
-                            "type": RestrictedAllowType.RoomMembership,
-                            "room_id": activeSpace.roomId,
-                        }];
-                    }
-
-                    cli.sendStateEvent(newRoomId, EventType.RoomJoinRules, content);
-                },
-            });
-            return;
+                const [restrictedAllowRoomIds] = await finished;
+                if (!Array.isArray(restrictedAllowRoomIds)) return;
+            } else if (this.state.preferredRestrictionVersion) {
+                // Block this action on a room upgrade otherwise it'd make their room unjoinable
+                const targetVersion = this.state.preferredRestrictionVersion;
+                Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
+                    roomId,
+                    targetVersion,
+                    description: _t("This upgrade will allow members of selected spaces " +
+                        "access to this room without an invite."),
+                    onFinished: (resp) => {
+                        if (!resp?.continue) return;
+                        upgradeRoom(room, targetVersion, resp.invite);
+                    },
+                });
+                return;
+            }
         }
 
-        const beforeJoinRule = this.state.joinRule;
-        this.setState({ joinRule });
+        const content: IContent = {
+            join_rule: joinRule,
+        };
+
+        let restrictedAllowRoomIds: string[];
+        // pre-set the accepted spaces with the currently viewed one as per the microcopy
+        if (joinRule === JoinRule.Restricted && SpaceStore.instance.activeSpace) {
+            const spaceRoomId = SpaceStore.instance.activeSpace.roomId;
+            restrictedAllowRoomIds = [spaceRoomId];
+            content.allow = [{
+                "type": RestrictedAllowType.RoomMembership,
+                "room_id": spaceRoomId,
+            }];
+        }
+
+        this.setState({ joinRule, restrictedAllowRoomIds });
 
         const client = MatrixClientPeg.get();
-        client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, {
-            join_rule: joinRule,
-        }, "").catch((e) => {
+        client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, content, "").catch((e) => {
             console.error(e);
-            this.setState({ joinRule: beforeJoinRule });
+            this.setState({
+                joinRule: beforeJoinRule,
+                restrictedAllowRoomIds: undefined,
+            });
         });
     };
 
     private onRestrictedRoomIdsChange = (restrictedAllowRoomIds: string[]) => {
         const beforeRestrictedAllowRoomIds = this.state.restrictedAllowRoomIds;
+        if (!arrayHasDiff(beforeRestrictedAllowRoomIds || [], restrictedAllowRoomIds)) return;
         this.setState({ restrictedAllowRoomIds });
 
         const client = MatrixClientPeg.get();
@@ -234,6 +252,8 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
     private onGuestAccessChange = (allowed: boolean) => {
         const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden;
         const beforeGuestAccess = this.state.guestAccess;
+        if (beforeGuestAccess === guestAccess) return;
+
         this.setState({ guestAccess });
 
         const client = MatrixClientPeg.get();
@@ -247,6 +267,8 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
 
     private onHistoryRadioToggle = (history: HistoryVisibility) => {
         const beforeHistory = this.state.history;
+        if (beforeHistory === history) return;
+
         this.setState({ history: history });
         MatrixClientPeg.get().sendStateEvent(this.props.roomId, EventType.RoomHistoryVisibility, {
             history_visibility: history,
diff --git a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx
index 5d76f7d2c2..3257ce8fb0 100644
--- a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx
+++ b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx
@@ -39,7 +39,7 @@ enum SpaceVisibility {
 
 const useLocalEcho = <T extends any>(
     currentFactory: () => T,
-    setterFn: (value: T) => Promise<void>,
+    setterFn: (value: T) => Promise<any>,
     errorFn: (error: Error) => void,
 ): [value: T, handler: (value: T) => void] => {
     const [value, setValue] = useState(currentFactory);
diff --git a/src/settings/handlers/RoomSettingsHandler.ts b/src/settings/handlers/RoomSettingsHandler.ts
index 3315e40a65..b8db07f6bb 100644
--- a/src/settings/handlers/RoomSettingsHandler.ts
+++ b/src/settings/handlers/RoomSettingsHandler.ts
@@ -92,12 +92,12 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
         if (settingName === "urlPreviewsEnabled") {
             const content = this.getSettings(roomId, "org.matrix.room.preview_urls") || {};
             content['disable'] = !newValue;
-            return MatrixClientPeg.get().sendStateEvent(roomId, "org.matrix.room.preview_urls", content);
+            return MatrixClientPeg.get().sendStateEvent(roomId, "org.matrix.room.preview_urls", content).then();
         }
 
         const content = this.getSettings(roomId) || {};
         content[settingName] = newValue;
-        return MatrixClientPeg.get().sendStateEvent(roomId, "im.vector.web.settings", content, "");
+        return MatrixClientPeg.get().sendStateEvent(roomId, "im.vector.web.settings", content, "").then();
     }
 
     public canSetValue(settingName: string, roomId: string): boolean {

From 89949bd884ee39d131d51d38a1b8861da2e82549 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 2 Jul 2021 16:07:17 +0100
Subject: [PATCH 08/48] Add new in the spaces beta toast & explanatory modal

---
 src/components/structures/SpaceRoomView.tsx   |  9 ++-
 .../dialogs/AddExistingToSpaceDialog.tsx      | 10 ++-
 .../dialogs/{InfoDialog.js => InfoDialog.tsx} | 35 +++++-----
 src/components/views/rooms/RoomList.tsx       |  4 +-
 .../views/spaces/SpaceTreeLevel.tsx           |  8 +--
 src/i18n/strings/en_EN.json                   | 15 ++--
 src/stores/SpaceStore.tsx                     | 70 +++++++++++++++++++
 src/utils/space.tsx                           | 17 +++--
 8 files changed, 119 insertions(+), 49 deletions(-)
 rename src/components/views/dialogs/{InfoDialog.js => InfoDialog.tsx} (77%)

diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx
index 3880e014aa..cb440ca576 100644
--- a/src/components/structures/SpaceRoomView.tsx
+++ b/src/components/structures/SpaceRoomView.tsx
@@ -307,7 +307,6 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
 };
 
 const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
-    const cli = useContext(MatrixClientContext);
     const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
 
     let contextMenu;
@@ -330,7 +329,7 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
                         e.stopPropagation();
                         closeMenu();
 
-                        if (await showCreateNewRoom(cli, space)) {
+                        if (await showCreateNewRoom(space)) {
                             onNewRoomAdded();
                         }
                     }}
@@ -343,7 +342,7 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
                         e.stopPropagation();
                         closeMenu();
 
-                        const [added] = await showAddExistingRooms(cli, space);
+                        const [added] = await showAddExistingRooms(space);
                         if (added) {
                             onNewRoomAdded();
                         }
@@ -397,11 +396,11 @@ const SpaceLanding = ({ space }) => {
     }
 
     let settingsButton;
-    if (shouldShowSpaceSettings(cli, space)) {
+    if (shouldShowSpaceSettings(space)) {
         settingsButton = <AccessibleTooltipButton
             className="mx_SpaceRoomView_landing_settingsButton"
             onClick={() => {
-                showSpaceSettings(cli, space);
+                showSpaceSettings(space);
             }}
             title={_t("Settings")}
         />;
diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx
index c09097c4b4..adb36485a7 100644
--- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx
+++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx
@@ -17,7 +17,6 @@ limitations under the License.
 import React, { ReactNode, useContext, useMemo, useState } from "react";
 import classNames from "classnames";
 import { Room } from "matrix-js-sdk/src/models/room";
-import { MatrixClient } from "matrix-js-sdk/src/client";
 
 import { _t } from '../../../languageHandler';
 import { IDialogProps } from "./IDialogProps";
@@ -44,9 +43,8 @@ import EntityTile from "../rooms/EntityTile";
 import BaseAvatar from "../avatars/BaseAvatar";
 
 interface IProps extends IDialogProps {
-    matrixClient: MatrixClient;
     space: Room;
-    onCreateRoomClick(cli: MatrixClient, space: Room): void;
+    onCreateRoomClick(space: Room): void;
 }
 
 const Entry = ({ room, checked, onChange }) => {
@@ -295,7 +293,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
     </div>;
 };
 
-const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
+const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onFinished }) => {
     const [selectedSpace, setSelectedSpace] = useState(space);
     const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
 
@@ -344,13 +342,13 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
         onFinished={onFinished}
         fixedWidth={false}
     >
-        <MatrixClientContext.Provider value={cli}>
+        <MatrixClientContext.Provider value={space.client}>
             <AddExistingToSpace
                 space={space}
                 onFinished={onFinished}
                 footerPrompt={<>
                     <div>{ _t("Want to add a new room instead?") }</div>
-                    <AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
+                    <AccessibleButton onClick={() => onCreateRoomClick(space)} kind="link">
                         { _t("Create a new room") }
                     </AccessibleButton>
                 </>}
diff --git a/src/components/views/dialogs/InfoDialog.js b/src/components/views/dialogs/InfoDialog.tsx
similarity index 77%
rename from src/components/views/dialogs/InfoDialog.js
rename to src/components/views/dialogs/InfoDialog.tsx
index 8207d334d3..8570f46d27 100644
--- a/src/components/views/dialogs/InfoDialog.js
+++ b/src/components/views/dialogs/InfoDialog.tsx
@@ -1,7 +1,6 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2017 New Vector Ltd.
 Copyright 2019 Bastian Masanek, Noxware IT <matrix@noxware.de>
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -16,31 +15,31 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
-import PropTypes from 'prop-types';
-import * as sdk from '../../../index';
-import { _t } from '../../../languageHandler';
+import React, { ReactNode, KeyboardEvent } from 'react';
 import classNames from "classnames";
 
-export default class InfoDialog extends React.Component {
-    static propTypes = {
-        className: PropTypes.string,
-        title: PropTypes.string,
-        description: PropTypes.node,
-        button: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
-        onFinished: PropTypes.func,
-        hasCloseButton: PropTypes.bool,
-        onKeyDown: PropTypes.func,
-        fixedWidth: PropTypes.bool,
-    };
+import { _t } from '../../../languageHandler';
+import * as sdk from '../../../index';
+import { IDialogProps } from "./IDialogProps";
 
+interface IProps extends IDialogProps {
+    title?: string;
+    description?: ReactNode;
+    className?: string;
+    button?: boolean | string;
+    hasCloseButton?: boolean;
+    fixedWidth?: boolean;
+    onKeyDown?(event: KeyboardEvent): void;
+}
+
+export default class InfoDialog extends React.Component<IProps> {
     static defaultProps = {
         title: '',
         description: '',
         hasCloseButton: false,
     };
 
-    onFinished = () => {
+    private onFinished = () => {
         this.props.onFinished();
     };
 
diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx
index c94256800d..5c683711fc 100644
--- a/src/components/views/rooms/RoomList.tsx
+++ b/src/components/views/rooms/RoomList.tsx
@@ -140,7 +140,7 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
                             e.preventDefault();
                             e.stopPropagation();
                             onFinished();
-                            showCreateNewRoom(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
+                            showCreateNewRoom(SpaceStore.instance.activeSpace);
                         }}
                         disabled={!canAddRooms}
                         tooltip={canAddRooms ? undefined
@@ -153,7 +153,7 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
                             e.preventDefault();
                             e.stopPropagation();
                             onFinished();
-                            showAddExistingRooms(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
+                            showAddExistingRooms(SpaceStore.instance.activeSpace);
                         }}
                         disabled={!canAddRooms}
                         tooltip={canAddRooms ? undefined
diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx
index 486a988b93..908506aa3a 100644
--- a/src/components/views/spaces/SpaceTreeLevel.tsx
+++ b/src/components/views/spaces/SpaceTreeLevel.tsx
@@ -203,7 +203,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
         ev.preventDefault();
         ev.stopPropagation();
 
-        showSpaceSettings(this.context, this.props.space);
+        showSpaceSettings(this.props.space);
         this.setState({ contextMenuPosition: null }); // also close the menu
     };
 
@@ -222,7 +222,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
         ev.preventDefault();
         ev.stopPropagation();
 
-        showCreateNewRoom(this.context, this.props.space);
+        showCreateNewRoom(this.props.space);
         this.setState({ contextMenuPosition: null }); // also close the menu
     };
 
@@ -230,7 +230,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
         ev.preventDefault();
         ev.stopPropagation();
 
-        showAddExistingRooms(this.context, this.props.space);
+        showAddExistingRooms(this.props.space);
         this.setState({ contextMenuPosition: null }); // also close the menu
     };
 
@@ -285,7 +285,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
 
             let settingsOption;
             let leaveSection;
-            if (shouldShowSpaceSettings(this.context, this.props.space)) {
+            if (shouldShowSpaceSettings(this.props.space)) {
                 settingsOption = (
                     <IconizedContextMenuOption
                         iconClassName="mx_SpacePanel_iconSettings"
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 860fea32d8..a554345ec8 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -774,6 +774,16 @@
     "The person who invited you already left the room.": "The person who invited you already left the room.",
     "The person who invited you already left the room, or their server is offline.": "The person who invited you already left the room, or their server is offline.",
     "Failed to join room": "Failed to join room",
+    "New in the Spaces beta": "New in the Spaces beta",
+    "Help people in spaces to find and join private rooms": "Help people in spaces to find and join private rooms",
+    "Learn more": "Learn more",
+    "Help space members find private rooms": "Help space members find private rooms",
+    "To help space members find and join a private room, go to that room's Security & Privacy settings.": "To help space members find and join a private room, go to that room's Security & Privacy settings.",
+    "General": "General",
+    "Security & Privacy": "Security & Privacy",
+    "Roles & Permissions": "Roles & Permissions",
+    "This make it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.": "This make it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.",
+    "Skip": "Skip",
     "You joined the call": "You joined the call",
     "%(senderName)s joined the call": "%(senderName)s joined the call",
     "Call in progress": "Call in progress",
@@ -1040,7 +1050,6 @@
     "Invite people": "Invite people",
     "Invite with email or username": "Invite with email or username",
     "Failed to save space settings.": "Failed to save space settings.",
-    "General": "General",
     "Edit settings relating to your space.": "Edit settings relating to your space.",
     "Saving...": "Saving...",
     "Save Changes": "Save Changes",
@@ -1430,7 +1439,6 @@
     "Muted Users": "Muted Users",
     "Banned users": "Banned users",
     "Send %(eventType)s events": "Send %(eventType)s events",
-    "Roles & Permissions": "Roles & Permissions",
     "Permissions": "Permissions",
     "Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room",
     "Enable encryption?": "Enable encryption?",
@@ -1456,7 +1464,6 @@
     "Members only (since they joined)": "Members only (since they joined)",
     "People with supported clients will be able to join the room without having a registered account.": "People with supported clients will be able to join the room without having a registered account.",
     "Who can read history?": "Who can read history?",
-    "Security & Privacy": "Security & Privacy",
     "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.",
     "Encrypted": "Encrypted",
     "Access": "Access",
@@ -2147,7 +2154,6 @@
     "People you know on %(brand)s": "People you know on %(brand)s",
     "Hide": "Hide",
     "Show": "Show",
-    "Skip": "Skip",
     "Send %(count)s invites|other": "Send %(count)s invites",
     "Send %(count)s invites|one": "Send %(count)s invite",
     "Invite people to join %(communityName)s": "Invite people to join %(communityName)s",
@@ -2422,7 +2428,6 @@
     "We call the places where you can host your account ‘homeservers’.": "We call the places where you can host your account ‘homeservers’.",
     "Other homeserver": "Other homeserver",
     "Use your preferred Matrix homeserver if you have one, or host your own.": "Use your preferred Matrix homeserver if you have one, or host your own.",
-    "Learn more": "Learn more",
     "About homeservers": "About homeservers",
     "Reset event store?": "Reset event store?",
     "You most likely do not want to reset your event index store": "You most likely do not want to reset your event index store",
diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
index 6300c1a936..e6fc793c4f 100644
--- a/src/stores/SpaceStore.tsx
+++ b/src/stores/SpaceStore.tsx
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import React from "react";
 import { ListIteratee, Many, sortBy, throttle } from "lodash";
 import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
 import { Room } from "matrix-js-sdk/src/models/room";
@@ -38,6 +39,13 @@ import { arrayHasDiff } from "../utils/arrays";
 import { objectDiff } from "../utils/objects";
 import { arrayHasOrderChange } from "../utils/arrays";
 import { reorderLexicographically } from "../utils/stringOrderField";
+import { shouldShowSpaceSettings } from "../utils/space";
+import ToastStore from "./ToastStore";
+import { _t } from "../languageHandler";
+import GenericToast from "../components/views/toasts/GenericToast";
+import Modal from "../Modal";
+import InfoDialog from "../components/views/dialogs/InfoDialog";
+import { JoinRule } from "../../../matrix-js-sdk/src/@types/partials";
 
 type SpaceKey = string | symbol;
 
@@ -173,6 +181,68 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
             window.localStorage.removeItem(ACTIVE_SPACE_LS_KEY);
         }
 
+        // New in Spaces beta toast for Restricted Join Rule
+        (async () => {
+            const lsKey = "mx_SpaceBeta_restrictedJoinRuleToastSeen";
+            if (contextSwitch && space?.getJoinRule() === JoinRule.Invite && shouldShowSpaceSettings(space) &&
+                space.getJoinedMemberCount() > 1 && !localStorage.getItem(lsKey) /*&&
+                (await this.matrixClient.getCapabilities())
+                    ?.["m.room_versions"]?.["org.matrix.msc3244.room_capabilities"]?.["restricted"]?.preferred*/
+            ) {
+                const toastKey = "restrictedjoinrule";
+                ToastStore.sharedInstance().addOrReplaceToast({
+                    key: toastKey,
+                    title: _t("New in the Spaces beta"),
+                    props: {
+                        description: _t("Help people in spaces to find and join private rooms"),
+                        acceptLabel: _t("Learn more"),
+                        onAccept: () => {
+                            localStorage.setItem(lsKey, "true");
+                            ToastStore.sharedInstance().dismissToast(toastKey);
+
+                            Modal.createTrackedDialog("New in the Spaces beta", "restricted join rule", InfoDialog, {
+                                title: _t("Help space members find private rooms"),
+                                description: <>
+                                    <p>{ _t("To help space members find and join a private room, " +
+                                        "go to that room's Security & Privacy settings.") }</p>
+
+                                    { /* Reuses classes from TabbedView for simplicity, non-interactive */ }
+                                    <div style={{ width: "190px" }}>
+                                        <div className="mx_TabbedView_tabLabel">
+                                            <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_settingsIcon" />
+                                            <span className="mx_TabbedView_tabLabel_text">{ _t("General") }</span>
+                                        </div>
+                                        <div className="mx_TabbedView_tabLabel mx_TabbedView_tabLabel_active">
+                                            <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_securityIcon" />
+                                            <span className="mx_TabbedView_tabLabel_text">{ _t("Security & Privacy") }</span>
+                                        </div>
+                                        <div className="mx_TabbedView_tabLabel">
+                                            <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_rolesIcon" />
+                                            <span className="mx_TabbedView_tabLabel_text">{ _t("Roles & Permissions") }</span>
+                                        </div>
+                                    </div>
+
+                                    <p>{ _t("This make it easy for rooms to stay private to a space, " +
+                                        "while letting people in the space find and join them. " +
+                                        "All new rooms in a space will have this option available.")}</p>
+                                </>,
+                                button: _t("OK"),
+                                hasCloseButton: false,
+                                fixedWidth: true,
+                            });
+                        },
+                        rejectLabel: _t("Skip"),
+                        onReject: () => {
+                            localStorage.setItem(lsKey, "true");
+                            ToastStore.sharedInstance().dismissToast(toastKey);
+                        },
+                    },
+                    component: GenericToast,
+                    priority: 35,
+                });
+            }
+        })().then();
+
         if (space) {
             const suggestedRooms = await this.fetchSuggestedRooms(space);
             if (this._activeSpace === space) {
diff --git a/src/utils/space.tsx b/src/utils/space.tsx
index 38f6e348d7..c238a83bc2 100644
--- a/src/utils/space.tsx
+++ b/src/utils/space.tsx
@@ -16,10 +16,9 @@ limitations under the License.
 
 import React from "react";
 import { Room } from "matrix-js-sdk/src/models/room";
-import { MatrixClient } from "matrix-js-sdk/src/client";
 import { EventType } from "matrix-js-sdk/src/@types/event";
 
-import { calculateRoomVia } from "../utils/permalinks/Permalinks";
+import { calculateRoomVia } from "./permalinks/Permalinks";
 import Modal from "../Modal";
 import SpaceSettingsDialog from "../components/views/dialogs/SpaceSettingsDialog";
 import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToSpaceDialog";
@@ -30,8 +29,8 @@ import SpacePublicShare from "../components/views/spaces/SpacePublicShare";
 import InfoDialog from "../components/views/dialogs/InfoDialog";
 import { showRoomInviteDialog } from "../RoomInvite";
 
-export const shouldShowSpaceSettings = (cli: MatrixClient, space: Room) => {
-    const userId = cli.getUserId();
+export const shouldShowSpaceSettings = (space: Room) => {
+    const userId = space.client.getUserId();
     return space.getMyMembership() === "join"
         && (space.currentState.maySendStateEvent(EventType.RoomAvatar, userId)
             || space.currentState.maySendStateEvent(EventType.RoomName, userId)
@@ -48,20 +47,20 @@ export const makeSpaceParentEvent = (room: Room, canonical = false) => ({
     state_key: room.roomId,
 });
 
-export const showSpaceSettings = (cli: MatrixClient, space: Room) => {
+export const showSpaceSettings = (space: Room) => {
     Modal.createTrackedDialog("Space Settings", "", SpaceSettingsDialog, {
-        matrixClient: cli,
+        matrixClient: space.client,
         space,
     }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
 };
 
-export const showAddExistingRooms = async (cli: MatrixClient, space: Room) => {
+export const showAddExistingRooms = async (space: Room) => {
     return Modal.createTrackedDialog(
         "Space Landing",
         "Add Existing",
         AddExistingToSpaceDialog,
         {
-            matrixClient: cli,
+            matrixClient: space.client,
             onCreateRoomClick: showCreateNewRoom,
             space,
         },
@@ -69,7 +68,7 @@ export const showAddExistingRooms = async (cli: MatrixClient, space: Room) => {
     ).finished;
 };
 
-export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => {
+export const showCreateNewRoom = async (space: Room) => {
     const modal = Modal.createTrackedDialog<[boolean, IOpts]>(
         "Space Landing",
         "Create Room",

From 44bbf609732e4169e2c6e979f8d17d750c40ff38 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 09:56:02 +0100
Subject: [PATCH 09/48] Convert Dropdown to Typescript

---
 .../elements/{Dropdown.js => Dropdown.tsx}    | 239 ++++++++----------
 1 file changed, 108 insertions(+), 131 deletions(-)
 rename src/components/views/elements/{Dropdown.js => Dropdown.tsx} (68%)

diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.tsx
similarity index 68%
rename from src/components/views/elements/Dropdown.js
rename to src/components/views/elements/Dropdown.tsx
index f95247e9ae..c2ff59a2d3 100644
--- a/src/components/views/elements/Dropdown.js
+++ b/src/components/views/elements/Dropdown.tsx
@@ -1,7 +1,6 @@
 /*
-Copyright 2017 Vector Creations Ltd
 Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -16,34 +15,38 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, { createRef } from 'react';
-import PropTypes from 'prop-types';
+import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
 import classnames from 'classnames';
+
 import AccessibleButton from './AccessibleButton';
 import { _t } from '../../../languageHandler';
 import { Key } from "../../../Keyboard";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 
-class MenuOption extends React.Component {
-    constructor(props) {
-        super(props);
-        this._onMouseEnter = this._onMouseEnter.bind(this);
-        this._onClick = this._onClick.bind(this);
-    }
+interface IMenuOptionProps {
+    children: ReactElement;
+    highlighted?: boolean;
+    dropdownKey: string;
+    id?: string;
+    inputRef?: Ref<HTMLDivElement>;
+    onClick(dropdownKey: string): void;
+    onMouseEnter(dropdownKey: string): void;
+}
 
+class MenuOption extends React.Component<IMenuOptionProps> {
     static defaultProps = {
         disabled: false,
     };
 
-    _onMouseEnter() {
+    private onMouseEnter = () => {
         this.props.onMouseEnter(this.props.dropdownKey);
-    }
+    };
 
-    _onClick(e) {
+    private onClick = (e: React.MouseEvent) => {
         e.preventDefault();
         e.stopPropagation();
         this.props.onClick(this.props.dropdownKey);
-    }
+    };
 
     render() {
         const optClasses = classnames({
@@ -54,8 +57,8 @@ class MenuOption extends React.Component {
         return <div
             id={this.props.id}
             className={optClasses}
-            onClick={this._onClick}
-            onMouseEnter={this._onMouseEnter}
+            onClick={this.onClick}
+            onMouseEnter={this.onMouseEnter}
             role="option"
             aria-selected={this.props.highlighted}
             ref={this.props.inputRef}
@@ -65,69 +68,75 @@ class MenuOption extends React.Component {
     }
 }
 
-MenuOption.propTypes = {
-    children: PropTypes.oneOfType([
-      PropTypes.arrayOf(PropTypes.node),
-      PropTypes.node,
-    ]),
-    highlighted: PropTypes.bool,
-    dropdownKey: PropTypes.string,
-    onClick: PropTypes.func.isRequired,
-    onMouseEnter: PropTypes.func.isRequired,
-    inputRef: PropTypes.any,
-};
+interface IProps {
+    id: string;
+    // ARIA label
+    label: string;
+    value?: string;
+    className?: string;
+    children: ReactElement[];
+    // negative for consistency with HTML
+    disabled?: boolean;
+    // The width that the dropdown should be. If specified,
+    // the dropped-down part of the menu will be set to this
+    // width.
+    menuWidth?: number;
+    searchEnabled?: boolean;
+    // Called when the selected option changes
+    onOptionChange(dropdownKey: string): void;
+    // Called when the value of the search field changes
+    onSearchChange?(query: string): void;
+    // Function that, given the key of an option, returns
+    // a node representing that option to be displayed in the
+    // box itself as the currently-selected option (ie. as
+    // opposed to in the actual dropped-down part). If
+    // unspecified, the appropriate child element is used as
+    // in the dropped-down menu.
+    getShortOption?(value: string): ReactNode;
+}
+
+interface IState {
+    expanded: boolean;
+    highlightedOption: string | null;
+    searchQuery: string;
+}
 
 /*
  * Reusable dropdown select control, akin to react-select,
  * but somewhat simpler as react-select is 79KB of minified
  * javascript.
- *
- * TODO: Port NetworkDropdown to use this.
  */
 @replaceableComponent("views.elements.Dropdown")
-export default class Dropdown extends React.Component {
-    constructor(props) {
+export default class Dropdown extends React.Component<IProps, IState> {
+    private readonly buttonRef = createRef<HTMLDivElement>();
+    private dropdownRootElement: HTMLDivElement = null;
+    private ignoreEvent: MouseEvent = null;
+    private childrenByKey: Record<string, ReactNode> = {};
+
+    constructor(props: IProps) {
         super(props);
 
-        this.dropdownRootElement = null;
-        this.ignoreEvent = null;
+        this.reindexChildren(this.props.children);
 
-        this._onInputClick = this._onInputClick.bind(this);
-        this._onRootClick = this._onRootClick.bind(this);
-        this._onDocumentClick = this._onDocumentClick.bind(this);
-        this._onMenuOptionClick = this._onMenuOptionClick.bind(this);
-        this._onInputChange = this._onInputChange.bind(this);
-        this._collectRoot = this._collectRoot.bind(this);
-        this._collectInputTextBox = this._collectInputTextBox.bind(this);
-        this._setHighlightedOption = this._setHighlightedOption.bind(this);
-
-        this.inputTextBox = null;
-
-        this._reindexChildren(this.props.children);
-
-        const firstChild = React.Children.toArray(props.children)[0];
+        const firstChild = React.Children.toArray(props.children)[0] as ReactElement;
 
         this.state = {
             // True if the menu is dropped-down
             expanded: false,
             // The key of the highlighted option
             // (the option that would become selected if you pressed enter)
-            highlightedOption: firstChild ? firstChild.key : null,
+            highlightedOption: firstChild ? firstChild.key as string : null,
             // the current search query
             searchQuery: '',
         };
-    }
 
-    // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
-    UNSAFE_componentWillMount() { // eslint-disable-line camelcase
-        this._button = createRef();
         // Listen for all clicks on the document so we can close the
         // menu when the user clicks somewhere else
-        document.addEventListener('click', this._onDocumentClick, false);
+        document.addEventListener('click', this.onDocumentClick, false);
     }
 
     componentWillUnmount() {
-        document.removeEventListener('click', this._onDocumentClick, false);
+        document.removeEventListener('click', this.onDocumentClick, false);
     }
 
     // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@@ -135,21 +144,21 @@ export default class Dropdown extends React.Component {
         if (!nextProps.children || nextProps.children.length === 0) {
             return;
         }
-        this._reindexChildren(nextProps.children);
+        this.reindexChildren(nextProps.children);
         const firstChild = nextProps.children[0];
         this.setState({
             highlightedOption: firstChild ? firstChild.key : null,
         });
     }
 
-    _reindexChildren(children) {
+    private reindexChildren(children: ReactElement[]): void {
         this.childrenByKey = {};
         React.Children.forEach(children, (child) => {
             this.childrenByKey[child.key] = child;
         });
     }
 
-    _onDocumentClick(ev) {
+    private onDocumentClick = (ev: MouseEvent) => {
         // Close the dropdown if the user clicks anywhere that isn't
         // within our root element
         if (ev !== this.ignoreEvent) {
@@ -157,9 +166,9 @@ export default class Dropdown extends React.Component {
                 expanded: false,
             });
         }
-    }
+    };
 
-    _onRootClick(ev) {
+    private onRootClick = (ev: MouseEvent) => {
         // This captures any clicks that happen within our elements,
         // such that we can then ignore them when they're seen by the
         // click listener on the document handler, ie. not close the
@@ -167,9 +176,9 @@ export default class Dropdown extends React.Component {
         // NB. We can't just stopPropagation() because then the event
         // doesn't reach the React onClick().
         this.ignoreEvent = ev;
-    }
+    };
 
-    _onInputClick(ev) {
+    private onInputClick = (ev: React.MouseEvent) => {
         if (this.props.disabled) return;
 
         if (!this.state.expanded) {
@@ -178,24 +187,24 @@ export default class Dropdown extends React.Component {
             });
             ev.preventDefault();
         }
-    }
+    };
 
-    _close() {
+    private close() {
         this.setState({
             expanded: false,
         });
         // their focus was on the input, its getting unmounted, move it to the button
-        if (this._button.current) {
-            this._button.current.focus();
+        if (this.buttonRef.current) {
+            this.buttonRef.current.focus();
         }
     }
 
-    _onMenuOptionClick(dropdownKey) {
-        this._close();
+    private onMenuOptionClick = (dropdownKey: string) => {
+        this.close();
         this.props.onOptionChange(dropdownKey);
-    }
+    };
 
-    _onInputKeyDown = (e) => {
+    private onInputKeyDown = (e: React.KeyboardEvent) => {
         let handled = true;
 
         // These keys don't generate keypress events and so needs to be on keyup
@@ -204,16 +213,16 @@ export default class Dropdown extends React.Component {
                 this.props.onOptionChange(this.state.highlightedOption);
                 // fallthrough
             case Key.ESCAPE:
-                this._close();
+                this.close();
                 break;
             case Key.ARROW_DOWN:
                 this.setState({
-                    highlightedOption: this._nextOption(this.state.highlightedOption),
+                    highlightedOption: this.nextOption(this.state.highlightedOption),
                 });
                 break;
             case Key.ARROW_UP:
                 this.setState({
-                    highlightedOption: this._prevOption(this.state.highlightedOption),
+                    highlightedOption: this.prevOption(this.state.highlightedOption),
                 });
                 break;
             default:
@@ -224,53 +233,46 @@ export default class Dropdown extends React.Component {
             e.preventDefault();
             e.stopPropagation();
         }
-    }
+    };
 
-    _onInputChange(e) {
+    private onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
         this.setState({
-            searchQuery: e.target.value,
+            searchQuery: e.currentTarget.value,
         });
         if (this.props.onSearchChange) {
-            this.props.onSearchChange(e.target.value);
+            this.props.onSearchChange(e.currentTarget.value);
         }
-    }
+    };
 
-    _collectRoot(e) {
+    private collectRoot = (e: HTMLDivElement) => {
         if (this.dropdownRootElement) {
-            this.dropdownRootElement.removeEventListener(
-                'click', this._onRootClick, false,
-            );
+            this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
         }
         if (e) {
-            e.addEventListener('click', this._onRootClick, false);
+            e.addEventListener('click', this.onRootClick, false);
         }
         this.dropdownRootElement = e;
-    }
+    };
 
-    _collectInputTextBox(e) {
-        this.inputTextBox = e;
-        if (e) e.focus();
-    }
-
-    _setHighlightedOption(optionKey) {
+    private setHighlightedOption = (optionKey: string) => {
         this.setState({
             highlightedOption: optionKey,
         });
-    }
+    };
 
-    _nextOption(optionKey) {
+    private nextOption(optionKey: string): string {
         const keys = Object.keys(this.childrenByKey);
         const index = keys.indexOf(optionKey);
         return keys[(index + 1) % keys.length];
     }
 
-    _prevOption(optionKey) {
+    private prevOption(optionKey: string): string {
         const keys = Object.keys(this.childrenByKey);
         const index = keys.indexOf(optionKey);
         return keys[(index - 1) % keys.length];
     }
 
-    _scrollIntoView(node) {
+    private scrollIntoView(node: Element) {
         if (node) {
             node.scrollIntoView({
                 block: "nearest",
@@ -279,18 +281,18 @@ export default class Dropdown extends React.Component {
         }
     }
 
-    _getMenuOptions() {
+    private getMenuOptions() {
         const options = React.Children.map(this.props.children, (child) => {
             const highlighted = this.state.highlightedOption === child.key;
             return (
                 <MenuOption
                     id={`${this.props.id}__${child.key}`}
                     key={child.key}
-                    dropdownKey={child.key}
+                    dropdownKey={child.key as string}
                     highlighted={highlighted}
-                    onMouseEnter={this._setHighlightedOption}
-                    onClick={this._onMenuOptionClick}
-                    inputRef={highlighted ? this._scrollIntoView : undefined}
+                    onMouseEnter={this.setHighlightedOption}
+                    onClick={this.onMenuOptionClick}
+                    inputRef={highlighted ? this.scrollIntoView : undefined}
                 >
                     { child }
                 </MenuOption>
@@ -307,7 +309,7 @@ export default class Dropdown extends React.Component {
     render() {
         let currentValue;
 
-        const menuStyle = {};
+        const menuStyle: CSSProperties = {};
         if (this.props.menuWidth) menuStyle.width = this.props.menuWidth;
 
         let menu;
@@ -316,10 +318,10 @@ export default class Dropdown extends React.Component {
                 currentValue = (
                     <input
                         type="text"
+                        autoFocus={true}
                         className="mx_Dropdown_option"
-                        ref={this._collectInputTextBox}
-                        onKeyDown={this._onInputKeyDown}
-                        onChange={this._onInputChange}
+                        onKeyDown={this.onInputKeyDown}
+                        onChange={this.onInputChange}
                         value={this.state.searchQuery}
                         role="combobox"
                         aria-autocomplete="list"
@@ -332,7 +334,7 @@ export default class Dropdown extends React.Component {
             }
             menu = (
                 <div className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}>
-                    { this._getMenuOptions() }
+                    { this.getMenuOptions() }
                 </div>
             );
         }
@@ -356,14 +358,14 @@ export default class Dropdown extends React.Component {
 
         // Note the menu sits inside the AccessibleButton div so it's anchored
         // to the input, but overflows below it. The root contains both.
-        return <div className={classnames(dropdownClasses)} ref={this._collectRoot}>
+        return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
             <AccessibleButton
                 className="mx_Dropdown_input mx_no_textinput"
-                onClick={this._onInputClick}
+                onClick={this.onInputClick}
                 aria-haspopup="listbox"
                 aria-expanded={this.state.expanded}
                 disabled={this.props.disabled}
-                inputRef={this._button}
+                inputRef={this.buttonRef}
                 aria-label={this.props.label}
                 aria-describedby={`${this.props.id}_value`}
             >
@@ -374,28 +376,3 @@ export default class Dropdown extends React.Component {
         </div>;
     }
 }
-
-Dropdown.propTypes = {
-    id: PropTypes.string.isRequired,
-    // The width that the dropdown should be. If specified,
-    // the dropped-down part of the menu will be set to this
-    // width.
-    menuWidth: PropTypes.number,
-    // Called when the selected option changes
-    onOptionChange: PropTypes.func.isRequired,
-    // Called when the value of the search field changes
-    onSearchChange: PropTypes.func,
-    searchEnabled: PropTypes.bool,
-    // Function that, given the key of an option, returns
-    // a node representing that option to be displayed in the
-    // box itself as the currently-selected option (ie. as
-    // opposed to in the actual dropped-down part). If
-    // unspecified, the appropriate child element is used as
-    // in the dropped-down menu.
-    getShortOption: PropTypes.func,
-    value: PropTypes.string,
-    // negative for consistency with HTML
-    disabled: PropTypes.bool,
-    // ARIA label
-    label: PropTypes.string.isRequired,
-};

From 82100df9eaf22d07e3de4d8e605bb277c287c4ef Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 10:08:57 +0100
Subject: [PATCH 10/48] Bring dropdown styling into closer line with rest of
 our styling

---
 res/css/views/elements/_Dropdown.scss | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/res/css/views/elements/_Dropdown.scss b/res/css/views/elements/_Dropdown.scss
index 2a2508c17c..3b67e0191e 100644
--- a/res/css/views/elements/_Dropdown.scss
+++ b/res/css/views/elements/_Dropdown.scss
@@ -27,7 +27,7 @@ limitations under the License.
     display: flex;
     align-items: center;
     position: relative;
-    border-radius: 3px;
+    border-radius: 4px;
     border: 1px solid $strong-input-border-color;
     font-size: $font-12px;
     user-select: none;
@@ -109,7 +109,7 @@ input.mx_Dropdown_option:focus {
     z-index: 2;
     margin: 0;
     padding: 0px;
-    border-radius: 3px;
+    border-radius: 4px;
     border: 1px solid $input-focused-border-color;
     background-color: $primary-bg-color;
     max-height: 200px;

From 692347843d485ab1d990d29658be1f61741d4adc Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 10:09:35 +0100
Subject: [PATCH 11/48] Track restricted join rule support in the SpaceStore
 for sync access

---
 src/stores/SpaceStore.tsx | 120 ++++++++++++++++++++------------------
 1 file changed, 64 insertions(+), 56 deletions(-)

diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
index e6fc793c4f..9a2dc027c2 100644
--- a/src/stores/SpaceStore.tsx
+++ b/src/stores/SpaceStore.tsx
@@ -19,6 +19,8 @@ import { ListIteratee, Many, sortBy, throttle } from "lodash";
 import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
 import { Room } from "matrix-js-sdk/src/models/room";
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { JoinRule } from "matrix-js-sdk/src/@types/partials";
+import { IRoomCapability } from "matrix-js-sdk/src/client";
 
 import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
 import defaultDispatcher from "../dispatcher/dispatcher";
@@ -45,7 +47,6 @@ import { _t } from "../languageHandler";
 import GenericToast from "../components/views/toasts/GenericToast";
 import Modal from "../Modal";
 import InfoDialog from "../components/views/dialogs/InfoDialog";
-import { JoinRule } from "../../../matrix-js-sdk/src/@types/partials";
 
 type SpaceKey = string | symbol;
 
@@ -115,6 +116,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
     private _suggestedRooms: ISuggestedRoom[] = [];
     private _invitedSpaces = new Set<Room>();
     private spaceOrderLocalEchoMap = new Map<string, string>();
+    private _restrictedJoinRuleSupport?: IRoomCapability;
 
     public get invitedSpaces(): Room[] {
         return Array.from(this._invitedSpaces);
@@ -132,6 +134,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
         return this._suggestedRooms;
     }
 
+    public get restrictedJoinRuleSupport(): IRoomCapability {
+        return this._restrictedJoinRuleSupport;
+    }
+
     /**
      * Sets the active space, updates room list filters,
      * optionally switches the user's room back to where they were when they last viewed that space.
@@ -182,66 +188,63 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
         }
 
         // New in Spaces beta toast for Restricted Join Rule
-        (async () => {
-            const lsKey = "mx_SpaceBeta_restrictedJoinRuleToastSeen";
-            if (contextSwitch && space?.getJoinRule() === JoinRule.Invite && shouldShowSpaceSettings(space) &&
-                space.getJoinedMemberCount() > 1 && !localStorage.getItem(lsKey) /*&&
-                (await this.matrixClient.getCapabilities())
-                    ?.["m.room_versions"]?.["org.matrix.msc3244.room_capabilities"]?.["restricted"]?.preferred*/
-            ) {
-                const toastKey = "restrictedjoinrule";
-                ToastStore.sharedInstance().addOrReplaceToast({
-                    key: toastKey,
-                    title: _t("New in the Spaces beta"),
-                    props: {
-                        description: _t("Help people in spaces to find and join private rooms"),
-                        acceptLabel: _t("Learn more"),
-                        onAccept: () => {
-                            localStorage.setItem(lsKey, "true");
-                            ToastStore.sharedInstance().dismissToast(toastKey);
+        const lsKey = "mx_SpaceBeta_restrictedJoinRuleToastSeen";
+        if (contextSwitch && space?.getJoinRule() === JoinRule.Invite && shouldShowSpaceSettings(space) &&
+            space.getJoinedMemberCount() > 1 && !localStorage.getItem(lsKey)
+            && this.restrictedJoinRuleSupport?.preferred
+        ) {
+            const toastKey = "restrictedjoinrule";
+            ToastStore.sharedInstance().addOrReplaceToast({
+                key: toastKey,
+                title: _t("New in the Spaces beta"),
+                props: {
+                    description: _t("Help people in spaces to find and join private rooms"),
+                    acceptLabel: _t("Learn more"),
+                    onAccept: () => {
+                        localStorage.setItem(lsKey, "true");
+                        ToastStore.sharedInstance().dismissToast(toastKey);
 
-                            Modal.createTrackedDialog("New in the Spaces beta", "restricted join rule", InfoDialog, {
-                                title: _t("Help space members find private rooms"),
-                                description: <>
-                                    <p>{ _t("To help space members find and join a private room, " +
-                                        "go to that room's Security & Privacy settings.") }</p>
+                        Modal.createTrackedDialog("New in the Spaces beta", "restricted join rule", InfoDialog, {
+                            title: _t("Help space members find private rooms"),
+                            description: <>
+                                <p>{ _t("To help space members find and join a private room, " +
+                                    "go to that room's Security & Privacy settings.") }</p>
 
-                                    { /* Reuses classes from TabbedView for simplicity, non-interactive */ }
-                                    <div style={{ width: "190px" }}>
-                                        <div className="mx_TabbedView_tabLabel">
-                                            <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_settingsIcon" />
-                                            <span className="mx_TabbedView_tabLabel_text">{ _t("General") }</span>
-                                        </div>
-                                        <div className="mx_TabbedView_tabLabel mx_TabbedView_tabLabel_active">
-                                            <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_securityIcon" />
-                                            <span className="mx_TabbedView_tabLabel_text">{ _t("Security & Privacy") }</span>
-                                        </div>
-                                        <div className="mx_TabbedView_tabLabel">
-                                            <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_rolesIcon" />
-                                            <span className="mx_TabbedView_tabLabel_text">{ _t("Roles & Permissions") }</span>
-                                        </div>
+                                { /* Reuses classes from TabbedView for simplicity, non-interactive */ }
+                                <div style={{ width: "190px" }}>
+                                    <div className="mx_TabbedView_tabLabel">
+                                        <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_settingsIcon" />
+                                        <span className="mx_TabbedView_tabLabel_text">{ _t("General") }</span>
                                     </div>
+                                    <div className="mx_TabbedView_tabLabel mx_TabbedView_tabLabel_active">
+                                        <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_securityIcon" />
+                                        <span className="mx_TabbedView_tabLabel_text">{ _t("Security & Privacy") }</span>
+                                    </div>
+                                    <div className="mx_TabbedView_tabLabel">
+                                        <span className="mx_TabbedView_maskedIcon mx_RoomSettingsDialog_rolesIcon" />
+                                        <span className="mx_TabbedView_tabLabel_text">{ _t("Roles & Permissions") }</span>
+                                    </div>
+                                </div>
 
-                                    <p>{ _t("This make it easy for rooms to stay private to a space, " +
-                                        "while letting people in the space find and join them. " +
-                                        "All new rooms in a space will have this option available.")}</p>
-                                </>,
-                                button: _t("OK"),
-                                hasCloseButton: false,
-                                fixedWidth: true,
-                            });
-                        },
-                        rejectLabel: _t("Skip"),
-                        onReject: () => {
-                            localStorage.setItem(lsKey, "true");
-                            ToastStore.sharedInstance().dismissToast(toastKey);
-                        },
+                                <p>{ _t("This make it easy for rooms to stay private to a space, " +
+                                    "while letting people in the space find and join them. " +
+                                    "All new rooms in a space will have this option available.")}</p>
+                            </>,
+                            button: _t("OK"),
+                            hasCloseButton: false,
+                            fixedWidth: true,
+                        });
                     },
-                    component: GenericToast,
-                    priority: 35,
-                });
-            }
-        })().then();
+                    rejectLabel: _t("Skip"),
+                    onReject: () => {
+                        localStorage.setItem(lsKey, "true");
+                        ToastStore.sharedInstance().dismissToast(toastKey);
+                    },
+                },
+                component: GenericToast,
+                priority: 35,
+            });
+        }
 
         if (space) {
             const suggestedRooms = await this.fetchSuggestedRooms(space);
@@ -709,6 +712,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
             this.matrixClient.on("accountData", this.onAccountData);
         }
 
+        this.matrixClient.getCapabilities().then(capabilities => {
+            this._restrictedJoinRuleSupport = capabilities
+                ?.["m.room_versions"]?.["org.matrix.msc3244.room_capabilities"]?.["restricted"];
+        });
+
         await this.onSpaceUpdate(); // trigger an initial update
 
         // restore selected state from last session if any and still valid

From c5ca98a3ad090d38e9fcd840ffbea585afa973b7 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 10:10:25 +0100
Subject: [PATCH 12/48] Iterate SecurityRoomSettingsTab and
 ManageRestrictedJoinRuleDialog

---
 .../_ManageRestrictedJoinRuleDialog.scss      |  19 +--
 .../ManageRestrictedJoinRuleDialog.tsx        |  24 +++-
 .../tabs/room/SecurityRoomSettingsTab.tsx     | 128 ++++++++++--------
 src/i18n/strings/en_EN.json                   |  11 +-
 4 files changed, 106 insertions(+), 76 deletions(-)

diff --git a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss
index 6606f78a8a..91df76675a 100644
--- a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss
+++ b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.scss
@@ -104,7 +104,7 @@ limitations under the License.
         }
     }
 
-    .mx_ManageRestrictedJoinRuleDialog_section_experimental {
+    .mx_ManageRestrictedJoinRuleDialog_section_info {
         position: relative;
         border-radius: 8px;
         margin: 12px 0;
@@ -131,16 +131,19 @@ limitations under the License.
     }
 
     .mx_ManageRestrictedJoinRuleDialog_footer {
-        display: flex;
         margin-top: 20px;
-        align-self: end;
 
-        .mx_AccessibleButton {
-            display: inline-block;
-            align-self: center;
+        .mx_ManageRestrictedJoinRuleDialog_footer_buttons {
+            display: flex;
+            width: max-content;
+            margin-left: auto;
 
-            & + .mx_AccessibleButton {
-                margin-left: 24px;
+            .mx_AccessibleButton {
+                display: inline-block;
+
+                & + .mx_AccessibleButton {
+                    margin-left: 24px;
+                }
             }
         }
     }
diff --git a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
index 79a6fb7f24..ff08ae5d28 100644
--- a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
+++ b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
@@ -102,6 +102,13 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
         setNewSelected(new Set(newSelected));
     };
 
+    let inviteOnlyWarning;
+    if (newSelected.size < 1) {
+        inviteOnlyWarning = <div className="mx_ManageRestrictedJoinRuleDialog_section_info">
+            { _t("You're removing all spaces. Access will default to invite only") }
+        </div>;
+    }
+
     return <BaseDialog
         title={_t("Select spaces")}
         className="mx_ManageRestrictedJoinRuleDialog"
@@ -142,7 +149,7 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
                 { filteredOtherEntries.length > 0 ? (
                     <div className="mx_ManageRestrictedJoinRuleDialog_section">
                         <h3>{ _t("Other spaces or rooms you might not know") }</h3>
-                        <div className="mx_ManageRestrictedJoinRuleDialog_section_experimental">
+                        <div className="mx_ManageRestrictedJoinRuleDialog_section_info">
                             <div>{ _t("These are likely ones other room admins are a part of.") }</div>
                         </div>
                         { filteredOtherEntries.map(space => {
@@ -167,12 +174,15 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
             </AutoHideScrollbar>
 
             <div className="mx_ManageRestrictedJoinRuleDialog_footer">
-                <AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
-                    { _t("Cancel") }
-                </AccessibleButton>
-                <AccessibleButton kind="primary" onClick={() => onFinished(Array.from(newSelected))}>
-                    { _t("Confirm") }
-                </AccessibleButton>
+                { inviteOnlyWarning }
+                <div className="mx_ManageRestrictedJoinRuleDialog_footer_buttons">
+                    <AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
+                        { _t("Cancel") }
+                    </AccessibleButton>
+                    <AccessibleButton kind="primary" onClick={() => onFinished(Array.from(newSelected))}>
+                        { _t("Confirm") }
+                    </AccessibleButton>
+                </div>
             </div>
         </MatrixClientContext.Provider>
     </BaseDialog>;
diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index ceb7dd21bd..a05bae30c2 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -101,19 +101,14 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         );
 
         const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
-        this.setState({ joinRule, restrictedAllowRoomIds, guestAccess, history, encrypted });
+        const restrictedRoomCapabilities = SpaceStore.instance.restrictedJoinRuleSupport;
+        const roomSupportsRestricted = Array.isArray(restrictedRoomCapabilities?.support)
+            && restrictedRoomCapabilities.support.includes(room.getVersion());
+        const preferredRestrictionVersion = roomSupportsRestricted ? null : restrictedRoomCapabilities.preferred;
+        this.setState({ joinRule, restrictedAllowRoomIds, guestAccess, history, encrypted,
+            roomSupportsRestricted, preferredRestrictionVersion });
 
         this.hasAliases().then(hasAliases => this.setState({ hasAliases }));
-        cli.getCapabilities().then(capabilities => {
-            const roomCapabilities = capabilities["org.matrix.msc3244.room_capabilities"];
-            const roomSupportsRestricted = roomCapabilities && Array.isArray(roomCapabilities["restricted"]?.support) &&
-                roomCapabilities["restricted"].support.includes(room.getVersion());
-            const preferredRestrictionVersion = roomSupportsRestricted
-                ? roomCapabilities?.["restricted"].preferred
-                : undefined;
-
-            this.setState({ roomSupportsRestricted, preferredRestrictionVersion });
-        });
     }
 
     private pullContentPropertyFromEvent<T>(event: MatrixEvent, key: string, defaultValue: T): T {
@@ -169,23 +164,16 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
 
     private onJoinRuleChange = async (joinRule: JoinRule) => {
         const beforeJoinRule = this.state.joinRule;
-        if (beforeJoinRule === joinRule) return;
 
+        let restrictedAllowRoomIds: string[];
         if (joinRule === JoinRule.Restricted) {
             const matrixClient = MatrixClientPeg.get();
             const roomId = this.props.roomId;
             const room = matrixClient.getRoom(roomId);
 
-            if (this.state.roomSupportsRestricted) {
+            if (beforeJoinRule === JoinRule.Restricted || this.state.roomSupportsRestricted) {
                 // Have the user pick which spaces to allow joins from
-                const { finished } = Modal.createTrackedDialog('Set restricted', '', ManageRestrictedJoinRuleDialog, {
-                    matrixClient,
-                    room,
-                    // if they have are viewing this room from the context of a space then default to that
-                    selected: SpaceStore.instance.activeSpace ? [SpaceStore.instance.activeSpace.roomId] : [],
-                }, "mx_ManageRestrictedJoinRuleDialog_wrapper");
-
-                const [restrictedAllowRoomIds] = await finished;
+                restrictedAllowRoomIds = await this.editRestrictedRoomIds();
                 if (!Array.isArray(restrictedAllowRoomIds)) return;
             } else if (this.state.preferredRestrictionVersion) {
                 // Block this action on a room upgrade otherwise it'd make their room unjoinable
@@ -204,19 +192,18 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
             }
         }
 
+        if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return;
+
         const content: IContent = {
             join_rule: joinRule,
         };
 
-        let restrictedAllowRoomIds: string[];
         // pre-set the accepted spaces with the currently viewed one as per the microcopy
-        if (joinRule === JoinRule.Restricted && SpaceStore.instance.activeSpace) {
-            const spaceRoomId = SpaceStore.instance.activeSpace.roomId;
-            restrictedAllowRoomIds = [spaceRoomId];
-            content.allow = [{
+        if (joinRule === JoinRule.Restricted) {
+            content.allow = restrictedAllowRoomIds.map(roomId => ({
                 "type": RestrictedAllowType.RoomMembership,
-                "room_id": spaceRoomId,
-            }];
+                "room_id": roomId,
+            }));
         }
 
         this.setState({ joinRule, restrictedAllowRoomIds });
@@ -296,17 +283,31 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         }
     }
 
-    private onEditRestrictedClick = () => {
+    private editRestrictedRoomIds = async (): Promise<string[] | undefined> => {
+        let selected = this.state.restrictedAllowRoomIds;
+        if (!selected?.length && SpaceStore.instance.activeSpace) {
+            selected = [SpaceStore.instance.activeSpace.roomId];
+        }
+
         const matrixClient = MatrixClientPeg.get();
-        Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, {
+        const { finished } = Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, {
             matrixClient,
             room: matrixClient.getRoom(this.props.roomId),
-            selected: this.state.restrictedAllowRoomIds,
-            onFinished: (restrictedAllowRoomIds?: string[]) => {
-                if (!Array.isArray(restrictedAllowRoomIds)) return;
-                this.onRestrictedRoomIdsChange(restrictedAllowRoomIds);
-            },
+            selected,
         }, "mx_ManageRestrictedJoinRuleDialog_wrapper");
+
+        const [restrictedAllowRoomIds] = await finished;
+        return restrictedAllowRoomIds;
+    };
+
+    private onEditRestrictedClick = async () => {
+        const restrictedAllowRoomIds = await this.editRestrictedRoomIds();
+        if (!Array.isArray(restrictedAllowRoomIds)) return;
+        if (restrictedAllowRoomIds.length > 0) {
+            this.onRestrictedRoomIdsChange(restrictedAllowRoomIds);
+        } else {
+            this.onJoinRuleChange(JoinRule.Invite);
+        }
     };
 
     private renderJoinRule() {
@@ -332,6 +333,8 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
             value: JoinRule.Invite,
             label: _t("Private (invite only)"),
             description: _t("Only invited people can join."),
+            checked: this.state.joinRule === JoinRule.Invite
+                || (this.state.joinRule === JoinRule.Restricted && !this.state.restrictedAllowRoomIds?.length),
         }, {
             value: JoinRule.Public,
             label: _t("Public (anyone)"),
@@ -350,28 +353,23 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
             }
 
             let description;
-            if (joinRule === JoinRule.Restricted) {
-                let spacesWhichCanAccess;
-                if (this.state.restrictedAllowRoomIds?.length) {
-                    const shownSpaces = this.state.restrictedAllowRoomIds
-                        .map(roomId => client.getRoom(roomId))
-                        .filter(Boolean)
-                        .slice(0, 4);
+            if (joinRule === JoinRule.Restricted && this.state.restrictedAllowRoomIds?.length) {
+                const shownSpaces = this.state.restrictedAllowRoomIds
+                    .map(roomId => client.getRoom(roomId))
+                    .filter(room => room?.isSpaceRoom())
+                    .slice(0, 4);
 
-                    spacesWhichCanAccess = <div className="mx_SecurityRoomSettingsTab_spacesWithAccess">
-                        <h4>{ _t("Spaces with access") }</h4>
-                        { shownSpaces.map(room => {
-                            return <span key={room.roomId}>
-                                <RoomAvatar room={room} height={32} width={32} />
-                                { room.name }
-                            </span>;
-                        })}
-                        { shownSpaces.length < this.state.restrictedAllowRoomIds.length && <span>
-                            { _t("& %(count)s more", {
-                                count: this.state.restrictedAllowRoomIds.length - shownSpaces.length,
-                            }) }
-                        </span> }
-                    </div>;
+                let moreText;
+                if (shownSpaces.length < this.state.restrictedAllowRoomIds.length) {
+                    if (shownSpaces.length > 0) {
+                        moreText = _t("& %(count)s more", {
+                            count: this.state.restrictedAllowRoomIds.length - shownSpaces.length,
+                        });
+                    } else {
+                        moreText = _t("Currently, %(count)s spaces have access", {
+                            count: this.state.restrictedAllowRoomIds.length,
+                        });
+                    }
                 }
 
                 description = <div>
@@ -386,7 +384,17 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
                             </AccessibleButton>,
                         }) }
                     </span>
-                    { spacesWhichCanAccess }
+
+                    <div className="mx_SecurityRoomSettingsTab_spacesWithAccess">
+                        <h4>{ _t("Spaces with access") }</h4>
+                        { shownSpaces.map(room => {
+                            return <span key={room.roomId}>
+                                <RoomAvatar room={room} height={32} width={32} />
+                                { room.name }
+                            </span>;
+                        })}
+                        { moreText && <span>{ moreText }</span> }
+                    </div>
                 </div>;
             } else if (SpaceStore.instance.activeSpace) {
                 description = _t("Anyone in %(spaceName)s can find and join. You can select other spaces too.", {
@@ -403,6 +411,8 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
                     { upgradeRequiredPill }
                 </>,
                 description,
+                // if there are 0 allowed spaces then render it as invite only instead
+                checked: this.state.joinRule === JoinRule.Restricted && !!this.state.restrictedAllowRoomIds?.length,
             });
         }
 
@@ -478,7 +488,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         const state = client.getRoom(this.props.roomId).currentState;
         const canSetGuestAccess = state.mayClientSendStateEvent(EventType.RoomGuestAccess, client);
 
-        return <>
+        return <div className="mx_SettingsTab_section">
             <LabelledToggleSwitch
                 value={guestAccess === GuestAccess.CanJoin}
                 onChange={this.onGuestAccessChange}
@@ -489,7 +499,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
                 { _t("People with supported clients will be able to join " +
                     "the room without having a registered account.") }
             </p>
-        </>;
+        </div>;
     }
 
     render() {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 7d0f863b7f..12b0c40d75 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1455,9 +1455,12 @@
     "Public (anyone)": "Public (anyone)",
     "Anyone can find and join.": "Anyone can find and join.",
     "Upgrade required": "Upgrade required",
-    "Spaces with access": "Spaces with access",
     "& %(count)s more|other": "& %(count)s more",
+    "& %(count)s more|one": "& %(count)s more",
+    "Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access",
+    "Currently, %(count)s spaces have access|one": "Currently, %(count)s space has access",
     "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>",
+    "Spaces with access": "Spaces with access",
     "Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Anyone in %(spaceName)s can find and join. You can select other spaces too.",
     "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.",
     "Space members": "Space members",
@@ -2187,8 +2190,11 @@
     "Community ID": "Community ID",
     "example": "example",
     "Please enter a name for the room": "Please enter a name for the room",
-    "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.": "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.",
     "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.": "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.",
+    "Everyone in <SpaceName/> will be able to find and join this room.": "Everyone in <SpaceName/> will be able to find and join this room.",
+    "You can change this at any time from room settings.": "You can change this at any time from room settings.",
+    "Anyone will be able to find and join this room, not just members of <SpaceName/>.": "Anyone will be able to find and join this room, not just members of <SpaceName/>.",
+    "Only people invited will be able to find and join this room.": "Only people invited will be able to find and join this room.",
     "You can’t disable this later. Bridges & most bots won’t work yet.": "You can’t disable this later. Bridges & most bots won’t work yet.",
     "Your server requires encryption to be enabled in private rooms.": "Your server requires encryption to be enabled in private rooms.",
     "Enable end-to-end encryption": "Enable end-to-end encryption",
@@ -2355,6 +2361,7 @@
     "%(count)s members|one": "%(count)s member",
     "%(count)s rooms|other": "%(count)s rooms",
     "%(count)s rooms|one": "%(count)s room",
+    "You're removing all spaces. Access will default to invite only": "You're removing all spaces. Access will default to invite only",
     "Select spaces": "Select spaces",
     "Decide which spaces can access this room. If a space is selected its members will be able to find and join <RoomName/>.": "Decide which spaces can access this room. If a space is selected its members will be able to find and join <RoomName/>.",
     "Search spaces": "Search spaces",

From eb9f4c609a22c8493c9021d20958ccdf4286529c Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 10:10:47 +0100
Subject: [PATCH 13/48] Make CreateRoomDialog capable of creating restricted
 rooms in spaces

---
 res/css/views/dialogs/_CreateRoomDialog.scss  |  18 ++-
 .../views/dialogs/CreateRoomDialog.tsx        | 117 ++++++++++++++----
 src/createRoom.ts                             |   6 +-
 src/i18n/strings/en_EN.json                   |   8 +-
 4 files changed, 111 insertions(+), 38 deletions(-)

diff --git a/res/css/views/dialogs/_CreateRoomDialog.scss b/res/css/views/dialogs/_CreateRoomDialog.scss
index 2678f7b4ad..adba58cbb9 100644
--- a/res/css/views/dialogs/_CreateRoomDialog.scss
+++ b/res/css/views/dialogs/_CreateRoomDialog.scss
@@ -65,7 +65,7 @@ limitations under the License.
 .mx_CreateRoomDialog_aliasContainer {
     display: flex;
     // put margin on container so it can collapse with siblings
-    margin: 10px 0;
+    margin: 24px 0 10px;
 
     .mx_RoomAliasField {
         margin: 0;
@@ -101,10 +101,6 @@ limitations under the License.
         margin-left: 30px;
     }
 
-    .mx_CreateRoomDialog_topic {
-        margin-bottom: 36px;
-    }
-
     .mx_Dialog_content > .mx_SettingsFlag {
         margin-top: 24px;
     }
@@ -113,5 +109,17 @@ limitations under the License.
         margin: 0 85px 0 0;
         font-size: $font-12px;
     }
+
+    .mx_Dropdown {
+        margin-bottom: 8px;
+        font-weight: normal;
+        font-family: $font-family;
+        font-size: $font-14px;
+        color: $primary-fg-color;
+
+        .mx_Dropdown_input {
+            border: 1px solid $input-border-color;
+        }
+    }
 }
 
diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx
index b5c0096771..eecddf7f31 100644
--- a/src/components/views/dialogs/CreateRoomDialog.tsx
+++ b/src/components/views/dialogs/CreateRoomDialog.tsx
@@ -17,6 +17,7 @@ limitations under the License.
 
 import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react";
 import { Room } from "matrix-js-sdk/src/models/room";
+import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
 
 import SdkConfig from '../../../SdkConfig';
 import withValidation, { IFieldState } from '../elements/Validation';
@@ -31,7 +32,8 @@ import RoomAliasField from "../elements/RoomAliasField";
 import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
 import DialogButtons from "../elements/DialogButtons";
 import BaseDialog from "../dialogs/BaseDialog";
-import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
+import Dropdown from "../elements/Dropdown";
+import SpaceStore from "../../../stores/SpaceStore";
 
 interface IProps {
     defaultPublic?: boolean;
@@ -41,7 +43,7 @@ interface IProps {
 }
 
 interface IState {
-    isPublic: boolean;
+    joinRule: JoinRule;
     isEncrypted: boolean;
     name: string;
     topic: string;
@@ -54,15 +56,25 @@ interface IState {
 
 @replaceableComponent("views.dialogs.CreateRoomDialog")
 export default class CreateRoomDialog extends React.Component<IProps, IState> {
+    private readonly supportsRestricted: boolean;
     private nameField = createRef<Field>();
     private aliasField = createRef<RoomAliasField>();
 
     constructor(props) {
         super(props);
 
+        this.supportsRestricted = this.props.parentSpace && !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
+
+        let joinRule = JoinRule.Invite;
+        if (this.props.defaultPublic) {
+            joinRule = JoinRule.Public;
+        } else if (this.supportsRestricted) {
+            joinRule = JoinRule.Restricted;
+        }
+
         const config = SdkConfig.get();
         this.state = {
-            isPublic: this.props.defaultPublic || false,
+            joinRule,
             isEncrypted: privateShouldBeEncrypted(),
             name: this.props.defaultName || "",
             topic: "",
@@ -81,7 +93,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
         const opts: IOpts = {};
         const createOpts: IOpts["createOpts"] = opts.createOpts = {};
         createOpts.name = this.state.name;
-        if (this.state.isPublic) {
+        if (this.state.joinRule === JoinRule.Public) {
             createOpts.visibility = Visibility.Public;
             createOpts.preset = Preset.PublicChat;
             opts.guestAccess = false;
@@ -95,7 +107,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
             createOpts.creation_content = { 'm.federate': false };
         }
 
-        if (!this.state.isPublic) {
+        if (this.state.joinRule !== JoinRule.Public) {
             if (this.state.canChangeEncryption) {
                 opts.encryption = this.state.isEncrypted;
             } else {
@@ -109,8 +121,9 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
             opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
         }
 
-        if (this.props.parentSpace) {
+        if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
             opts.parentSpace = this.props.parentSpace;
+            opts.joinRule = JoinRule.Restricted;
         }
 
         return opts;
@@ -172,8 +185,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
         this.setState({ topic: ev.target.value });
     };
 
-    private onPublicChange = (isPublic: boolean) => {
-        this.setState({ isPublic });
+    private onJoinRuleChange = (joinRule: JoinRule) => {
+        this.setState({ joinRule });
     };
 
     private onEncryptedChange = (isEncrypted: boolean) => {
@@ -210,7 +223,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
 
     render() {
         let aliasField;
-        if (this.state.isPublic) {
+        if (this.state.joinRule === JoinRule.Public) {
             const domain = MatrixClientPeg.get().getDomain();
             aliasField = (
                 <div className="mx_CreateRoomDialog_aliasContainer">
@@ -224,19 +237,46 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
             );
         }
 
-        let publicPrivateLabel = <p>{_t(
-            "Private rooms can be found and joined by invitation only. Public rooms can be " +
-            "found and joined by anyone.",
-        )}</p>;
+        let publicPrivateLabel: JSX.Element;
         if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
-            publicPrivateLabel = <p>{_t(
-                "Private rooms can be found and joined by invitation only. Public rooms can be " +
-                "found and joined by anyone in this community.",
-            )}</p>;
+            publicPrivateLabel = <p>
+                { _t(
+                    "Private rooms can be found and joined by invitation only. Public rooms can be " +
+                    "found and joined by anyone in this community.",
+                ) }
+            </p>;
+        } else if (this.state.joinRule === JoinRule.Restricted) {
+            publicPrivateLabel = <p>
+                { _t(
+                    "Everyone in <SpaceName/> will be able to find and join this room.", {}, {
+                        SpaceName: () => this.props.parentSpace.name,
+                    },
+                ) }
+                &nbsp;
+                { _t("You can change this at any time from room settings.") }
+            </p>;
+        } else if (this.state.joinRule === JoinRule.Public) {
+            publicPrivateLabel = <p>
+                { _t(
+                    "Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, {
+                        SpaceName: () => this.props.parentSpace.name,
+                    },
+                ) }
+                &nbsp;
+                { _t("You can change this at any time from room settings.") }
+            </p>;
+        } else if (this.state.joinRule === JoinRule.Invite) {
+            publicPrivateLabel = <p>
+                { _t(
+                    "Only people invited will be able to find and join this room.",
+                ) }
+                &nbsp;
+                { _t("You can change this at any time from room settings.") }
+            </p>;
         }
 
         let e2eeSection;
-        if (!this.state.isPublic) {
+        if (this.state.joinRule !== JoinRule.Public) {
             let microcopy;
             if (privateShouldBeEncrypted()) {
                 if (this.state.canChangeEncryption) {
@@ -273,15 +313,31 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
             );
         }
 
-        let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room');
+        let title = _t("Create a room");
         if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
             const name = CommunityPrototypeStore.instance.getSelectedCommunityName();
             title = _t("Create a room in %(communityName)s", { communityName: name });
+        } else if (!this.props.parentSpace) {
+            title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room');
         }
+
+        const options = [
+            <div key={JoinRule.Invite} className="">
+                { _t("Private room (invite only)") }
+            </div>,
+            <div key={JoinRule.Public} className="">
+                { _t("Public room") }
+            </div>,
+        ];
+
+        if (this.supportsRestricted) {
+            options.unshift(<div key={JoinRule.Restricted} className="">
+                { _t("Visible to space members") }
+            </div>);
+        }
+
         return (
-            <BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
-                title={title}
-            >
+            <BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}>
                 <form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
                     <div className="mx_Dialog_content">
                         <Field
@@ -298,11 +354,18 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
                             value={this.state.topic}
                             className="mx_CreateRoomDialog_topic"
                         />
-                        <LabelledToggleSwitch
-                            label={_t("Make this room public")}
-                            onChange={this.onPublicChange}
-                            value={this.state.isPublic}
-                        />
+
+                        <Dropdown
+                            id="mx_CreateRoomDialog_typeDropdown"
+                            className="mx_CreateRoomDialog_typeDropdown"
+                            onOptionChange={this.onJoinRuleChange}
+                            menuWidth={448}
+                            value={this.state.joinRule}
+                            label={_t("Room visibility")}
+                        >
+                            { options }
+                        </Dropdown>
+
                         { publicPrivateLabel }
                         { e2eeSection }
                         { aliasField }
diff --git a/src/createRoom.ts b/src/createRoom.ts
index 0a88e2cef7..e809f5ff0a 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -153,10 +153,8 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
         });
 
         if (opts.joinRule === JoinRule.Restricted) {
-            const serverCapabilities = await client.getCapabilities();
-            const roomCapabilities = serverCapabilities?.["m.room_versions"]?.["org.matrix.msc3244.room_capabilities"];
-            if (roomCapabilities?.["restricted"]?.preferred) {
-                opts.createOpts.room_version = roomCapabilities?.["restricted"].preferred;
+            if (SpaceStore.instance.restrictedJoinRuleSupport?.preferred) {
+                opts.createOpts.room_version = SpaceStore.instance.restrictedJoinRuleSupport.preferred;
 
                 opts.createOpts.initial_state.push({
                     type: EventType.RoomJoinRules,
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 12b0c40d75..c7992d93c3 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2200,11 +2200,15 @@
     "Enable end-to-end encryption": "Enable end-to-end encryption",
     "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.",
     "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.",
+    "Create a room": "Create a room",
+    "Create a room in %(communityName)s": "Create a room in %(communityName)s",
     "Create a public room": "Create a public room",
     "Create a private room": "Create a private room",
-    "Create a room in %(communityName)s": "Create a room in %(communityName)s",
+    "Private room (invite only)": "Private room (invite only)",
+    "Public room": "Public room",
+    "Visible to space members": "Visible to space members",
     "Topic (optional)": "Topic (optional)",
-    "Make this room public": "Make this room public",
+    "Room visibility": "Room visibility",
     "Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.",
     "Create Room": "Create Room",
     "Sign out": "Sign out",

From 3301763f12954c95156000dd59e9f83c0a197203 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 10:19:33 +0100
Subject: [PATCH 14/48] stub getCapabilities in tests

---
 test/test-utils.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test/test-utils.js b/test/test-utils.js
index ad56522965..900c870f68 100644
--- a/test/test-utils.js
+++ b/test/test-utils.js
@@ -96,6 +96,7 @@ export function createTestClient() {
             },
         },
         decryptEventIfNeeded: () => Promise.resolve(),
+        getCapabilities: jest.fn().mockReturnValue({}),
     };
 }
 

From 0ca4a958f7002c4036e8cb835b3867b362886c66 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 10:34:50 +0100
Subject: [PATCH 15/48] fix getCapabilities stub

---
 test/test-utils.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/test-utils.js b/test/test-utils.js
index 900c870f68..35c4441077 100644
--- a/test/test-utils.js
+++ b/test/test-utils.js
@@ -96,7 +96,7 @@ export function createTestClient() {
             },
         },
         decryptEventIfNeeded: () => Promise.resolve(),
-        getCapabilities: jest.fn().mockReturnValue({}),
+        getCapabilities: jest.fn().mockResolvedValue({}),
     };
 }
 

From 9d8acd1af0178b999e14845c42e8ad507032a371 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 10:44:09 +0100
Subject: [PATCH 16/48] stub getJoinRule

---
 test/test-utils.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test/test-utils.js b/test/test-utils.js
index 35c4441077..a07994af20 100644
--- a/test/test-utils.js
+++ b/test/test-utils.js
@@ -269,6 +269,7 @@ export function mkStubRoom(roomId = null, name) {
         getCanonicalAlias: jest.fn(),
         getAltAliases: jest.fn().mockReturnValue([]),
         timeline: [],
+        getJoinRule: jest.fn().mockReturnValue("invite"),
     };
 }
 

From 04c923bd75e9758187314e5a64e41938bb0c81b1 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 11:35:56 +0100
Subject: [PATCH 17/48] fix tests by including client field on the Room stub
 and stubbing getJoinedMemberCount

---
 test/stores/SpaceStore-test.ts | 52 +++++++++++++++++-----------------
 test/test-utils.js             |  4 ++-
 2 files changed, 29 insertions(+), 27 deletions(-)

diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts
index 4cbd9f43c8..e6d8dc144e 100644
--- a/test/stores/SpaceStore-test.ts
+++ b/test/stores/SpaceStore-test.ts
@@ -53,32 +53,6 @@ const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.
 
 const testUserId = "@test:user";
 
-let rooms = [];
-
-const mkRoom = (roomId: string) => {
-    const room = mkStubRoom(roomId);
-    room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([]));
-    rooms.push(room);
-    return room;
-};
-
-const mkSpace = (spaceId: string, children: string[] = []) => {
-    const space = mkRoom(spaceId);
-    space.isSpaceRoom.mockReturnValue(true);
-    space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId =>
-        mkEvent({
-            event: true,
-            type: EventType.SpaceChild,
-            room: spaceId,
-            user: testUserId,
-            skey: roomId,
-            content: { via: [] },
-            ts: Date.now(),
-        }),
-    )));
-    return space;
-};
-
 const getValue = jest.fn();
 SettingsStore.getValue = getValue;
 
@@ -111,6 +85,32 @@ describe("SpaceStore", () => {
     const store = SpaceStore.instance;
     const client = MatrixClientPeg.get();
 
+    let rooms = [];
+
+    const mkRoom = (roomId: string) => {
+        const room = mkStubRoom(roomId, roomId, client);
+        room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([]));
+        rooms.push(room);
+        return room;
+    };
+
+    const mkSpace = (spaceId: string, children: string[] = []) => {
+        const space = mkRoom(spaceId);
+        space.isSpaceRoom.mockReturnValue(true);
+        space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId =>
+            mkEvent({
+                event: true,
+                type: EventType.SpaceChild,
+                room: spaceId,
+                user: testUserId,
+                skey: roomId,
+                content: { via: [] },
+                ts: Date.now(),
+            }),
+        )));
+        return space;
+    };
+
     const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true);
 
     const run = async () => {
diff --git a/test/test-utils.js b/test/test-utils.js
index a07994af20..33001e39d1 100644
--- a/test/test-utils.js
+++ b/test/test-utils.js
@@ -220,7 +220,7 @@ export function mkMessage(opts) {
     return mkEvent(opts);
 }
 
-export function mkStubRoom(roomId = null, name) {
+export function mkStubRoom(roomId = null, name, client) {
     const stubTimeline = { getEvents: () => [] };
     return {
         roomId,
@@ -235,6 +235,7 @@ export function mkStubRoom(roomId = null, name) {
         }),
         getMembersWithMembership: jest.fn().mockReturnValue([]),
         getJoinedMembers: jest.fn().mockReturnValue([]),
+        getJoinedMemberCount: jest.fn().mockReturnValue(1),
         getMembers: jest.fn().mockReturnValue([]),
         getPendingEvents: () => [],
         getLiveTimeline: () => stubTimeline,
@@ -270,6 +271,7 @@ export function mkStubRoom(roomId = null, name) {
         getAltAliases: jest.fn().mockReturnValue([]),
         timeline: [],
         getJoinRule: jest.fn().mockReturnValue("invite"),
+        client,
     };
 }
 

From 06284fe73d65fb5b9014b517a2ab57aebadeb7cf Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 12:05:06 +0100
Subject: [PATCH 18/48] Update e2e tests

---
 .../src/scenarios/directory.js                |  2 +-
 .../src/scenarios/lazy-loading.js             |  2 +-
 .../src/usecases/room-settings.js             | 28 +++++++------------
 3 files changed, 12 insertions(+), 20 deletions(-)

diff --git a/test/end-to-end-tests/src/scenarios/directory.js b/test/end-to-end-tests/src/scenarios/directory.js
index 53b790c174..fffca2b05c 100644
--- a/test/end-to-end-tests/src/scenarios/directory.js
+++ b/test/end-to-end-tests/src/scenarios/directory.js
@@ -25,7 +25,7 @@ module.exports = async function roomDirectoryScenarios(alice, bob) {
     console.log(" creating a public room and join through directory:");
     const room = 'test';
     await createRoom(alice, room);
-    await changeRoomSettings(alice, { directory: true, visibility: "public_no_guests", alias: "#test" });
+    await changeRoomSettings(alice, { directory: true, visibility: "public", alias: "#test" });
     await join(bob, room); //looks up room in directory
     const bobMessage = "hi Alice!";
     await sendMessage(bob, bobMessage);
diff --git a/test/end-to-end-tests/src/scenarios/lazy-loading.js b/test/end-to-end-tests/src/scenarios/lazy-loading.js
index 1b5d449af9..406f7b24a3 100644
--- a/test/end-to-end-tests/src/scenarios/lazy-loading.js
+++ b/test/end-to-end-tests/src/scenarios/lazy-loading.js
@@ -51,7 +51,7 @@ const charlyMsg2 = "how's it going??";
 
 async function setupRoomWithBobAliceAndCharlies(alice, bob, charlies) {
     await createRoom(bob, room);
-    await changeRoomSettings(bob, { directory: true, visibility: "public_no_guests", alias });
+    await changeRoomSettings(bob, { directory: true, visibility: "public", alias });
     // wait for alias to be set by server after clicking "save"
     // so the charlies can join it.
     await bob.delay(500);
diff --git a/test/end-to-end-tests/src/usecases/room-settings.js b/test/end-to-end-tests/src/usecases/room-settings.js
index b40afe76bf..01431197a7 100644
--- a/test/end-to-end-tests/src/usecases/room-settings.js
+++ b/test/end-to-end-tests/src/usecases/room-settings.js
@@ -98,18 +98,14 @@ async function checkRoomSettings(session, expectedSettings) {
     if (expectedSettings.visibility) {
         session.log.step(`checks visibility is ${expectedSettings.visibility}`);
         const radios = await session.queryAll(".mx_RoomSettingsDialog input[type=radio]");
-        assert.equal(radios.length, 7);
-        const inviteOnly = radios[0];
-        const publicNoGuests = radios[1];
-        const publicWithGuests = radios[2];
+        assert.equal(radios.length, 6);
+        const [inviteOnlyRoom, publicRoom] = radios;
 
         let expectedRadio = null;
         if (expectedSettings.visibility === "invite_only") {
-            expectedRadio = inviteOnly;
-        } else if (expectedSettings.visibility === "public_no_guests") {
-            expectedRadio = publicNoGuests;
-        } else if (expectedSettings.visibility === "public_with_guests") {
-            expectedRadio = publicWithGuests;
+            expectedRadio = inviteOnlyRoom;
+        } else if (expectedSettings.visibility === "public") {
+            expectedRadio = publicRoom;
         } else {
             throw new Error(`unrecognized room visibility setting: ${expectedSettings.visibility}`);
         }
@@ -165,17 +161,13 @@ async function changeRoomSettings(session, settings) {
     if (settings.visibility) {
         session.log.step(`sets visibility to ${settings.visibility}`);
         const radios = await session.queryAll(".mx_RoomSettingsDialog label");
-        assert.equal(radios.length, 7);
-        const inviteOnly = radios[0];
-        const publicNoGuests = radios[1];
-        const publicWithGuests = radios[2];
+        assert.equal(radios.length, 6);
+        const [inviteOnlyRoom, publicRoom] = radios;
 
         if (settings.visibility === "invite_only") {
-            await inviteOnly.click();
-        } else if (settings.visibility === "public_no_guests") {
-            await publicNoGuests.click();
-        } else if (settings.visibility === "public_with_guests") {
-            await publicWithGuests.click();
+            await inviteOnlyRoom.click();
+        } else if (settings.visibility === "public") {
+            await publicRoom.click();
         } else {
             throw new Error(`unrecognized room visibility setting: ${settings.visibility}`);
         }

From d004163177a5da6303a9a5f270dbdf5377a217a6 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 6 Jul 2021 12:05:30 +0100
Subject: [PATCH 19/48] Fix 2 new NPEs

---
 .../settings/tabs/room/SecurityRoomSettingsTab.tsx   |  2 +-
 src/createRoom.ts                                    | 12 ++++++------
 2 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index a05bae30c2..82eeef111d 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -104,7 +104,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         const restrictedRoomCapabilities = SpaceStore.instance.restrictedJoinRuleSupport;
         const roomSupportsRestricted = Array.isArray(restrictedRoomCapabilities?.support)
             && restrictedRoomCapabilities.support.includes(room.getVersion());
-        const preferredRestrictionVersion = roomSupportsRestricted ? null : restrictedRoomCapabilities.preferred;
+        const preferredRestrictionVersion = roomSupportsRestricted ? undefined : restrictedRoomCapabilities?.preferred;
         this.setState({ joinRule, restrictedAllowRoomIds, guestAccess, history, encrypted,
             roomSupportsRestricted, preferredRestrictionVersion });
 
diff --git a/src/createRoom.ts b/src/createRoom.ts
index e809f5ff0a..24c5dc513a 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -144,19 +144,19 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
     }
 
     if (opts.parentSpace) {
-        opts.createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true));
-        opts.createOpts.initial_state.push({
+        createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true));
+        createOpts.initial_state.push({
             type: EventType.RoomHistoryVisibility,
             content: {
-                "history_visibility": opts.createOpts.preset === Preset.PublicChat ? "world_readable" : "invited",
+                "history_visibility": createOpts.preset === Preset.PublicChat ? "world_readable" : "invited",
             },
         });
 
         if (opts.joinRule === JoinRule.Restricted) {
             if (SpaceStore.instance.restrictedJoinRuleSupport?.preferred) {
-                opts.createOpts.room_version = SpaceStore.instance.restrictedJoinRuleSupport.preferred;
+                createOpts.room_version = SpaceStore.instance.restrictedJoinRuleSupport.preferred;
 
-                opts.createOpts.initial_state.push({
+                createOpts.initial_state.push({
                     type: EventType.RoomJoinRules,
                     content: {
                         "join_rule": JoinRule.Restricted,
@@ -171,7 +171,7 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
     }
 
     if (opts.joinRule !== JoinRule.Restricted) {
-        opts.createOpts.initial_state.push({
+        createOpts.initial_state.push({
             type: EventType.RoomJoinRules,
             content: { join_rule: opts.joinRule },
         });

From 894bce7813c4dee78e951ed5397c9d61e9f8538e Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Tue, 6 Jul 2021 14:54:06 +0200
Subject: [PATCH 20/48] Update lockfile

---
 yarn.lock | 27 +++++++++++++++++++++++----
 1 file changed, 23 insertions(+), 4 deletions(-)

diff --git a/yarn.lock b/yarn.lock
index c8c3315855..ea4adfb09f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1677,6 +1677,11 @@
     "@types/scheduler" "*"
     csstype "^3.0.2"
 
+"@types/retry@^0.12.0":
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
+  integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
+
 "@types/sanitize-html@^2.3.1":
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.3.1.tgz#094d696b83b7394b016e96342bbffa6a028795ce"
@@ -5445,10 +5450,10 @@ 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.0.0:
-  version "12.0.0"
-  resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.0.0.tgz#8ee7cc37661476341d0c792a1a12bc78b19f9fdd"
-  integrity sha512-DHeq87Sx9Dv37FYyvZkmA1VYsQUNaVgc3QzMUkFwoHt1T4EZzgyYpdsp3uYruJzUW0ACvVJcwFdrU4e1VS97dQ==
+matrix-js-sdk@12.0.1:
+  version "12.0.1"
+  resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.0.1.tgz#3a63881f743420a4d39474daa39bd0fb90930d43"
+  integrity sha512-HkOWv8QHojceo3kPbC+vAIFUjsRAig6MBvEY35UygS3g2dL0UcJ5Qx09/2wcXtu6dowlDnWsz2HHk62tS2cklA==
   dependencies:
     "@babel/runtime" "^7.12.5"
     another-json "^0.2.0"
@@ -5456,6 +5461,7 @@ matrix-js-sdk@12.0.0:
     bs58 "^4.0.1"
     content-type "^1.0.4"
     loglevel "^1.7.1"
+    p-retry "^4.5.0"
     qs "^6.9.6"
     request "^2.88.2"
     unhomoglyph "^1.0.6"
@@ -6007,6 +6013,14 @@ p-locate@^4.1.0:
   dependencies:
     p-limit "^2.2.0"
 
+p-retry@^4.5.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.0.tgz#9de15ae696278cffe86fce2d8f73b7f894f8bc9e"
+  integrity sha512-SAHbQEwg3X5DRNaLmWjT+DlGc93ba5i+aP3QLfVNDncQEQO4xjbYW4N/lcVTSuP0aJietGfx2t94dJLzfBMpXw==
+  dependencies:
+    "@types/retry" "^0.12.0"
+    retry "^0.13.1"
+
 p-try@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
@@ -6816,6 +6830,11 @@ ret@~0.1.10:
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
   integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
 
+retry@^0.13.1:
+  version "0.13.1"
+  resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658"
+  integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==
+
 reusify@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"

From 437d53d1ccff764978613da396165d27257436f6 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 9 Jul 2021 08:43:41 +0100
Subject: [PATCH 21/48] Update space children (best effort) when upgrading a
 room

---
 .../views/dialogs/RoomUpgradeDialog.js        |  4 +-
 src/stores/SpaceStore.tsx                     |  4 +
 src/utils/RoomUpgrade.ts                      | 89 +++++++++++--------
 3 files changed, 60 insertions(+), 37 deletions(-)

diff --git a/src/components/views/dialogs/RoomUpgradeDialog.js b/src/components/views/dialogs/RoomUpgradeDialog.js
index 90092df7a5..acbb99099f 100644
--- a/src/components/views/dialogs/RoomUpgradeDialog.js
+++ b/src/components/views/dialogs/RoomUpgradeDialog.js
@@ -17,10 +17,10 @@ limitations under the License.
 import React from 'react';
 import PropTypes from 'prop-types';
 import * as sdk from '../../../index';
-import { MatrixClientPeg } from '../../../MatrixClientPeg';
 import Modal from '../../../Modal';
 import { _t } from '../../../languageHandler';
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { upgradeRoom } from "../../../utils/RoomUpgrade";
 
 @replaceableComponent("views.dialogs.RoomUpgradeDialog")
 export default class RoomUpgradeDialog extends React.Component {
@@ -45,7 +45,7 @@ export default class RoomUpgradeDialog extends React.Component {
 
     _onUpgradeClick = () => {
         this.setState({ busy: true });
-        MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => {
+        upgradeRoom(this.props.room, this._targetVersion, false, false).then(() => {
             this.props.onFinished(true);
         }).catch((err) => {
             const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
index 9a2dc027c2..91bc0a027c 100644
--- a/src/stores/SpaceStore.tsx
+++ b/src/stores/SpaceStore.tsx
@@ -335,6 +335,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
         return sortBy(parents, r => r.roomId)?.[0] || null;
     }
 
+    public getKnownParents(roomId: string): Set<string> {
+        return this.parentMap.get(roomId) || new Set();
+    }
+
     public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
         if (!space && SettingsStore.getValue("feature_spaces.all_rooms")) {
             return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
diff --git a/src/utils/RoomUpgrade.ts b/src/utils/RoomUpgrade.ts
index 7330b23863..e632ec6345 100644
--- a/src/utils/RoomUpgrade.ts
+++ b/src/utils/RoomUpgrade.ts
@@ -15,60 +15,79 @@ limitations under the License.
 */
 
 import { Room } from "matrix-js-sdk/src/models/room";
+import { EventType } from "matrix-js-sdk/src/@types/event";
 
 import { inviteUsersToRoom } from "../RoomInvite";
 import Modal from "../Modal";
 import { _t } from "../languageHandler";
 import ErrorDialog from "../components/views/dialogs/ErrorDialog";
+import SpaceStore from "../stores/SpaceStore";
 
 export async function upgradeRoom(
     room: Room,
     targetVersion: string,
     inviteUsers = false,
-    // eslint-disable-next-line camelcase
-): Promise<{ replacement_room: string }> {
+    handleError = true,
+    updateSpaces = true,
+): Promise<string> {
     const cli = room.client;
 
-    let checkForUpgradeFn: (room: Room) => Promise<void>;
+    let newRoomId: string;
     try {
-        const upgradePromise = cli.upgradeRoom(room.roomId, targetVersion);
-
-        // 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) {
-            checkForUpgradeFn = async (newRoom: Room) => {
-                // The upgradePromise should be done by the time we await it here.
-                const { replacement_room: newRoomId } = await upgradePromise;
-                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);
-                }
-
-                cli.removeListener('Room', checkForUpgradeFn);
-            };
-            cli.on('Room', checkForUpgradeFn);
-        }
-
-        // We have to await after so that the checkForUpgradesFn has a proper reference
-        // to the new room's ID.
-        return upgradePromise;
+        ({ replacement_room: newRoomId } = await cli.upgradeRoom(room.roomId, targetVersion));
     } catch (e) {
+        if (!handleError) throw e;
         console.error(e);
 
-        if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn);
-
-        Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, {
+        Modal.createTrackedDialog("Room Upgrade Error", "", ErrorDialog, {
             title: _t('Error upgrading room'),
             description: _t('Double check that your server supports the room version chosen and try again.'),
         });
         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<void> => {
+            // 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);
+            }
+
+            cli.removeListener('Room', checkForUpgradeFn);
+        };
+        cli.on('Room', checkForUpgradeFn);
+    }
+
+    if (updateSpaces) {
+        const parents = SpaceStore.instance.getKnownParents(room.roomId);
+        try {
+            for (const parentId of parents) {
+                const parent = cli.getRoom(parentId);
+                if (!parent?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId())) continue;
+
+                const currentEv = parent.currentState.getStateEvents(EventType.SpaceChild, room.roomId);
+                await cli.sendStateEvent(parentId, EventType.SpaceChild, {
+                    ...(currentEv?.getContent() || {}), // copy existing attributes like suggested
+                    via: [cli.getDomain()],
+                }, newRoomId);
+                await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, room.roomId);
+            }
+        } catch (e) {
+            // These errors are not critical to the room upgrade itself
+            console.warn("Failed to update parent spaces during room upgrade", e);
+        }
+    }
+
+    return newRoomId;
 }

From 6fe00d12ea444d82942263c04054f2f0cc080a53 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 9 Jul 2021 08:47:18 +0100
Subject: [PATCH 22/48] Convert RoomUpgradeDialog to TS

---
 ...UpgradeDialog.js => RoomUpgradeDialog.tsx} | 68 ++++++++++---------
 1 file changed, 36 insertions(+), 32 deletions(-)
 rename src/components/views/dialogs/{RoomUpgradeDialog.js => RoomUpgradeDialog.tsx} (58%)

diff --git a/src/components/views/dialogs/RoomUpgradeDialog.js b/src/components/views/dialogs/RoomUpgradeDialog.tsx
similarity index 58%
rename from src/components/views/dialogs/RoomUpgradeDialog.js
rename to src/components/views/dialogs/RoomUpgradeDialog.tsx
index acbb99099f..bcca0e3829 100644
--- a/src/components/views/dialogs/RoomUpgradeDialog.js
+++ b/src/components/views/dialogs/RoomUpgradeDialog.tsx
@@ -1,5 +1,5 @@
 /*
-Copyright 2018 New Vector Ltd
+Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -15,19 +15,29 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import * as sdk from '../../../index';
+import { Room } from "matrix-js-sdk/src/models/room";
+
 import Modal from '../../../Modal';
 import { _t } from '../../../languageHandler';
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { upgradeRoom } from "../../../utils/RoomUpgrade";
+import { IDialogProps } from "./IDialogProps";
+import BaseDialog from "./BaseDialog";
+import ErrorDialog from './ErrorDialog';
+import DialogButtons from '../elements/DialogButtons';
+import Spinner from "../elements/Spinner";
+
+interface IProps extends IDialogProps {
+    room: Room;
+}
+
+interface IState {
+    busy: boolean;
+}
 
 @replaceableComponent("views.dialogs.RoomUpgradeDialog")
-export default class RoomUpgradeDialog extends React.Component {
-    static propTypes = {
-        room: PropTypes.object.isRequired,
-        onFinished: PropTypes.func.isRequired,
-    };
+export default class RoomUpgradeDialog extends React.Component<IProps, IState> {
+    private targetVersion: string;
 
     state = {
         busy: true,
@@ -35,20 +45,19 @@ export default class RoomUpgradeDialog extends React.Component {
 
     async componentDidMount() {
         const recommended = await this.props.room.getRecommendedVersion();
-        this._targetVersion = recommended.version;
+        this.targetVersion = recommended.version;
         this.setState({ busy: false });
     }
 
-    _onCancelClick = () => {
+    private onCancelClick = (): void => {
         this.props.onFinished(false);
     };
 
-    _onUpgradeClick = () => {
+    private onUpgradeClick = (): void => {
         this.setState({ busy: true });
-        upgradeRoom(this.props.room, this._targetVersion, false, false).then(() => {
+        upgradeRoom(this.props.room, this.targetVersion, false, false).then(() => {
             this.props.onFinished(true);
         }).catch((err) => {
-            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
             Modal.createTrackedDialog('Failed to upgrade room', '', ErrorDialog, {
                 title: _t("Failed to upgrade room"),
                 description: ((err && err.message) ? err.message : _t("The room upgrade could not be completed")),
@@ -59,48 +68,43 @@ export default class RoomUpgradeDialog extends React.Component {
     };
 
     render() {
-        const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
-        const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
-        const Spinner = sdk.getComponent('views.elements.Spinner');
-
         let buttons;
         if (this.state.busy) {
             buttons = <Spinner />;
         } else {
             buttons = <DialogButtons
-                primaryButton={_t(
-                    'Upgrade this room to version %(version)s',
-                    { version: this._targetVersion },
-                )}
+                primaryButton={_t('Upgrade this room to version %(version)s', { version: this.targetVersion })}
                 primaryButtonClass="danger"
                 hasCancel={true}
-                onPrimaryButtonClick={this._onUpgradeClick}
-                focus={this.props.focus}
-                onCancel={this._onCancelClick}
+                onPrimaryButtonClick={this.onUpgradeClick}
+                onCancel={this.onCancelClick}
             />;
         }
 
         return (
-            <BaseDialog className="mx_RoomUpgradeDialog"
+            <BaseDialog
+                className="mx_RoomUpgradeDialog"
                 onFinished={this.props.onFinished}
                 title={_t("Upgrade Room Version")}
                 contentId='mx_Dialog_content'
                 hasCancel={true}
             >
                 <p>
-                    {_t(
+                    { _t(
                         "Upgrading this room requires closing down the current " +
                         "instance of the room and creating a new room in its place. " +
                         "To give room members the best possible experience, we will:",
-                    )}
+                    ) }
                 </p>
                 <ol>
-                    <li>{_t("Create a new room with the same name, description and avatar")}</li>
-                    <li>{_t("Update any local room aliases to point to the new room")}</li>
-                    <li>{_t("Stop users from speaking in the old version of the room, and post a message advising users to move to the new room")}</li>
-                    <li>{_t("Put a link back to the old room at the start of the new room so people can see old messages")}</li>
+                    <li>{ _t("Create a new room with the same name, description and avatar") }</li>
+                    <li>{ _t("Update any local room aliases to point to the new room") }</li>
+                    <li>{ _t("Stop users from speaking in the old version of the room, " +
+                        "and post a message advising users to move to the new room") }</li>
+                    <li>{ _t("Put a link back to the old room at the start of the new room " +
+                        "so people can see old messages") }</li>
                 </ol>
-                {buttons}
+                { buttons }
             </BaseDialog>
         );
     }

From 5986609731daae10b35dbb6847208e251876b659 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Thu, 15 Jul 2021 10:05:37 +0100
Subject: [PATCH 23/48] i18n

---
 src/i18n/strings/en_EN.json | 73 ++++++++++++++++++++++++++-----------
 1 file changed, 52 insertions(+), 21 deletions(-)

diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 5cc900a21b..50c5abbcf4 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -437,8 +437,6 @@
     "To use it, just wait for autocomplete results to load and tab through them.": "To use it, just wait for autocomplete results to load and tab through them.",
     "Upgrades a room to a new version": "Upgrades a room to a new version",
     "You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.",
-    "Error upgrading room": "Error upgrading room",
-    "Double check that your server supports the room version chosen and try again.": "Double check that your server supports the room version chosen and try again.",
     "Changes your display nickname": "Changes your display nickname",
     "Changes your display nickname in the current room only": "Changes your display nickname in the current room only",
     "Changes the avatar of the current room": "Changes the avatar of the current room",
@@ -732,6 +730,8 @@
     "Common names and surnames are easy to guess": "Common names and surnames are easy to guess",
     "Straight rows of keys are easy to guess": "Straight rows of keys are easy to guess",
     "Short keyboard patterns are easy to guess": "Short keyboard patterns are easy to guess",
+    "Error upgrading room": "Error upgrading room",
+    "Double check that your server supports the room version chosen and try again.": "Double check that your server supports the room version chosen and try again.",
     "Invite to %(spaceName)s": "Invite to %(spaceName)s",
     "Share your public space": "Share your public space",
     "Unknown App": "Unknown App",
@@ -778,6 +778,16 @@
     "The person who invited you already left the room.": "The person who invited you already left the room.",
     "The person who invited you already left the room, or their server is offline.": "The person who invited you already left the room, or their server is offline.",
     "Failed to join room": "Failed to join room",
+    "New in the Spaces beta": "New in the Spaces beta",
+    "Help people in spaces to find and join private rooms": "Help people in spaces to find and join private rooms",
+    "Learn more": "Learn more",
+    "Help space members find private rooms": "Help space members find private rooms",
+    "To help space members find and join a private room, go to that room's Security & Privacy settings.": "To help space members find and join a private room, go to that room's Security & Privacy settings.",
+    "General": "General",
+    "Security & Privacy": "Security & Privacy",
+    "Roles & Permissions": "Roles & Permissions",
+    "This make it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.": "This make it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.",
+    "Skip": "Skip",
     "You joined the call": "You joined the call",
     "%(senderName)s joined the call": "%(senderName)s joined the call",
     "Call in progress": "Call in progress",
@@ -1037,7 +1047,6 @@
     "Invite people": "Invite people",
     "Invite with email or username": "Invite with email or username",
     "Failed to save space settings.": "Failed to save space settings.",
-    "General": "General",
     "Edit settings relating to your space.": "Edit settings relating to your space.",
     "Saving...": "Saving...",
     "Save Changes": "Save Changes",
@@ -1432,27 +1441,35 @@
     "Muted Users": "Muted Users",
     "Banned users": "Banned users",
     "Send %(eventType)s events": "Send %(eventType)s events",
-    "Roles & Permissions": "Roles & Permissions",
     "Permissions": "Permissions",
     "Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room",
     "Enable encryption?": "Enable encryption?",
     "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
-    "Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.",
-    "Click here to fix": "Click here to fix",
+    "This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.",
     "To link to this room, please add an address.": "To link to this room, please add an address.",
-    "Only people who have been invited": "Only people who have been invited",
-    "Anyone who knows the room's link, apart from guests": "Anyone who knows the room's link, apart from guests",
-    "Anyone who knows the room's link, including guests": "Anyone who knows the room's link, including guests",
+    "Private (invite only)": "Private (invite only)",
+    "Only invited people can join.": "Only invited people can join.",
+    "Public (anyone)": "Public (anyone)",
+    "Anyone can find and join.": "Anyone can find and join.",
+    "Upgrade required": "Upgrade required",
+    "& %(count)s more|other": "& %(count)s more",
+    "Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access",
+    "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>",
+    "Spaces with access": "Spaces with access",
+    "Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Anyone in %(spaceName)s can find and join. You can select other spaces too.",
+    "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.",
+    "Space members": "Space members",
+    "Decide who can view and join %(roomName)s.": "Decide who can view and join %(roomName)s.",
     "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
     "Members only (since they were invited)": "Members only (since they were invited)",
     "Members only (since they joined)": "Members only (since they joined)",
     "Anyone": "Anyone",
     "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.",
+    "People with supported clients will be able to join the room without having a registered account.": "People with supported clients will be able to join the room without having a registered account.",
     "Who can read history?": "Who can read history?",
-    "Security & Privacy": "Security & Privacy",
     "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.",
     "Encrypted": "Encrypted",
-    "Who can access this room?": "Who can access this room?",
+    "Access": "Access",
     "Unable to revoke sharing for email address": "Unable to revoke sharing for email address",
     "Unable to share email address": "Unable to share email address",
     "Your email address hasn't been verified yet": "Your email address hasn't been verified yet",
@@ -2148,7 +2165,6 @@
     "People you know on %(brand)s": "People you know on %(brand)s",
     "Hide": "Hide",
     "Show": "Show",
-    "Skip": "Skip",
     "Send %(count)s invites|other": "Send %(count)s invites",
     "Send %(count)s invites|one": "Send %(count)s invite",
     "Invite people to join %(communityName)s": "Invite people to join %(communityName)s",
@@ -2177,18 +2193,25 @@
     "Community ID": "Community ID",
     "example": "example",
     "Please enter a name for the room": "Please enter a name for the room",
-    "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.": "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.",
     "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.": "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.",
+    "Everyone in <SpaceName/> will be able to find and join this room.": "Everyone in <SpaceName/> will be able to find and join this room.",
+    "You can change this at any time from room settings.": "You can change this at any time from room settings.",
+    "Anyone will be able to find and join this room, not just members of <SpaceName/>.": "Anyone will be able to find and join this room, not just members of <SpaceName/>.",
+    "Only people invited will be able to find and join this room.": "Only people invited will be able to find and join this room.",
     "You can’t disable this later. Bridges & most bots won’t work yet.": "You can’t disable this later. Bridges & most bots won’t work yet.",
     "Your server requires encryption to be enabled in private rooms.": "Your server requires encryption to be enabled in private rooms.",
     "Enable end-to-end encryption": "Enable end-to-end encryption",
     "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.",
     "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.",
+    "Create a room": "Create a room",
+    "Create a room in %(communityName)s": "Create a room in %(communityName)s",
     "Create a public room": "Create a public room",
     "Create a private room": "Create a private room",
-    "Create a room in %(communityName)s": "Create a room in %(communityName)s",
+    "Private room (invite only)": "Private room (invite only)",
+    "Public room": "Public room",
+    "Visible to space members": "Visible to space members",
     "Topic (optional)": "Topic (optional)",
-    "Make this room public": "Make this room public",
+    "Room visibility": "Room visibility",
     "Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.",
     "Create Room": "Create Room",
     "Sign out": "Sign out",
@@ -2342,6 +2365,17 @@
     "Manually export keys": "Manually export keys",
     "You'll lose access to your encrypted messages": "You'll lose access to your encrypted messages",
     "Are you sure you want to sign out?": "Are you sure you want to sign out?",
+    "%(count)s members|other": "%(count)s members",
+    "%(count)s members|one": "%(count)s member",
+    "%(count)s rooms|other": "%(count)s rooms",
+    "%(count)s rooms|one": "%(count)s room",
+    "You're removing all spaces. Access will default to invite only": "You're removing all spaces. Access will default to invite only",
+    "Select spaces": "Select spaces",
+    "Decide which spaces can access this room. If a space is selected its members will be able to find and join <RoomName/>.": "Decide which spaces can access this room. If a space is selected its members will be able to find and join <RoomName/>.",
+    "Search spaces": "Search spaces",
+    "Spaces you know that contain this room": "Spaces you know that contain this room",
+    "Other spaces or rooms you might not know": "Other spaces or rooms you might not know",
+    "These are likely ones other room admins are a part of.": "These are likely ones other room admins are a part of.",
     "Confirm by comparing the following with the User Settings in your other session:": "Confirm by comparing the following with the User Settings in your other session:",
     "Confirm this user's session by comparing the following with their User Settings:": "Confirm this user's session by comparing the following with their User Settings:",
     "Session name": "Session name",
@@ -2385,12 +2419,13 @@
     "Update any local room aliases to point to the new room": "Update any local room aliases to point to the new room",
     "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room",
     "Put a link back to the old room at the start of the new room so people can see old messages": "Put a link back to the old room at the start of the new room so people can see old messages",
-    "Automatically invite users": "Automatically invite users",
+    "Automatically invite members from this room to the new one": "Automatically invite members from this room to the new one",
     "Upgrade private room": "Upgrade private room",
     "Upgrade public room": "Upgrade public room",
     "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.",
     "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please <a>report a bug</a>.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please <a>report a bug</a>.",
     "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.",
+    "<b>Please note upgrading will make a new version of the room</b>. All current messages will stay in this archived room.": "<b>Please note upgrading will make a new version of the room</b>. All current messages will stay in this archived room.",
     "You'll upgrade this room from <oldVersion /> to <newVersion />.": "You'll upgrade this room from <oldVersion /> to <newVersion />.",
     "Resend": "Resend",
     "You're all caught up.": "You're all caught up.",
@@ -2413,7 +2448,6 @@
     "We call the places where you can host your account ‘homeservers’.": "We call the places where you can host your account ‘homeservers’.",
     "Other homeserver": "Other homeserver",
     "Use your preferred Matrix homeserver if you have one, or host your own.": "Use your preferred Matrix homeserver if you have one, or host your own.",
-    "Learn more": "Learn more",
     "About homeservers": "About homeservers",
     "Reset event store?": "Reset event store?",
     "You most likely do not want to reset your event index store": "You most likely do not want to reset your event index store",
@@ -2647,6 +2681,7 @@
     "You are an administrator of this community": "You are an administrator of this community",
     "You are a member of this community": "You are a member of this community",
     "Who can join this community?": "Who can join this community?",
+    "Only people who have been invited": "Only people who have been invited",
     "Everyone": "Everyone",
     "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!",
     "Long Description (HTML)": "Long Description (HTML)",
@@ -2744,10 +2779,6 @@
     "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.",
     "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.",
     "You don't have permission": "You don't have permission",
-    "%(count)s members|other": "%(count)s members",
-    "%(count)s members|one": "%(count)s member",
-    "%(count)s rooms|other": "%(count)s rooms",
-    "%(count)s rooms|one": "%(count)s room",
     "This room is suggested as a good one to join": "This room is suggested as a good one to join",
     "Suggested": "Suggested",
     "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",

From f2a88599a4c8fdcd8cbca736b427139805eceb9e Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Mon, 19 Jul 2021 16:51:43 +0100
Subject: [PATCH 24/48] delint

---
 src/components/views/avatars/RoomAvatar.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx
index 36f6db87a6..f285222f7b 100644
--- a/src/components/views/avatars/RoomAvatar.tsx
+++ b/src/components/views/avatars/RoomAvatar.tsx
@@ -147,7 +147,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
                 className={classNames(className, {
                     mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
                 })}
-                name={room ? room.name : oobData.name}
+                name={roomName}
                 idName={idName}
                 urls={this.state.urls}
                 onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}

From a58edcb241b8ff40158b32b18891b538337b68dd Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 21 Jul 2021 10:43:12 +0100
Subject: [PATCH 25/48] tweak copy

---
 src/components/views/dialogs/CreateRoomDialog.tsx             | 4 ++--
 .../views/settings/tabs/room/SecurityRoomSettingsTab.tsx      | 2 +-
 src/i18n/strings/en_EN.json                                   | 1 -
 3 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx
index eecddf7f31..60df13f983 100644
--- a/src/components/views/dialogs/CreateRoomDialog.tsx
+++ b/src/components/views/dialogs/CreateRoomDialog.tsx
@@ -249,7 +249,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
             publicPrivateLabel = <p>
                 { _t(
                     "Everyone in <SpaceName/> will be able to find and join this room.", {}, {
-                        SpaceName: () => this.props.parentSpace.name,
+                        SpaceName: () => <b>{ this.props.parentSpace.name }</b>,
                     },
                 ) }
                 &nbsp;
@@ -259,7 +259,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
             publicPrivateLabel = <p>
                 { _t(
                     "Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, {
-                        SpaceName: () => this.props.parentSpace.name,
+                        SpaceName: () => <b>{ this.props.parentSpace.name }</b>,
                     },
                 ) }
                 &nbsp;
diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index 1ab883a698..f70477166f 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -337,7 +337,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
                 || (this.state.joinRule === JoinRule.Restricted && !this.state.restrictedAllowRoomIds?.length),
         }, {
             value: JoinRule.Public,
-            label: _t("Public (anyone)"),
+            label: _t("Public"),
             description: _t("Anyone can find and join."),
         }];
 
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 5211eeb222..d2cbb1df74 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1440,7 +1440,6 @@
     "To link to this room, please add an address.": "To link to this room, please add an address.",
     "Private (invite only)": "Private (invite only)",
     "Only invited people can join.": "Only invited people can join.",
-    "Public (anyone)": "Public (anyone)",
     "Anyone can find and join.": "Anyone can find and join.",
     "Upgrade required": "Upgrade required",
     "& %(count)s more|other": "& %(count)s more",

From fd64d37305c35022586fc1a8a677a3f1aea74eaa Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 21 Jul 2021 11:20:41 +0100
Subject: [PATCH 26/48] Add iconography to Create Room Dialog dropdown

Remove unused old stale feather-customised/globe.svg
---
 res/css/views/dialogs/_CreateRoomDialog.scss  | 38 +++++++++++++++++++
 res/img/feather-customised/globe.svg          |  7 ----
 .../views/dialogs/CreateRoomDialog.tsx        |  6 +--
 .../ManageRestrictedJoinRuleDialog.tsx        |  2 +-
 .../tabs/room/SecurityRoomSettingsTab.tsx     |  2 +-
 src/i18n/strings/en_EN.json                   |  6 +--
 src/stores/SpaceStore.tsx                     |  2 +-
 7 files changed, 47 insertions(+), 16 deletions(-)
 delete mode 100644 res/img/feather-customised/globe.svg

diff --git a/res/css/views/dialogs/_CreateRoomDialog.scss b/res/css/views/dialogs/_CreateRoomDialog.scss
index adba58cbb9..173256f8af 100644
--- a/res/css/views/dialogs/_CreateRoomDialog.scss
+++ b/res/css/views/dialogs/_CreateRoomDialog.scss
@@ -120,6 +120,44 @@ limitations under the License.
         .mx_Dropdown_input {
             border: 1px solid $input-border-color;
         }
+
+        .mx_Dropdown_option {
+            font-size: $font-14px;
+            line-height: $font-32px;
+            height: 32px;
+
+            > div {
+                padding-left: 30px;
+                position: relative;
+
+                &::before {
+                    content: "";
+                    position: absolute;
+                    height: 16px;
+                    width: 16px;
+                    left: 6px;
+                    top: 8px;
+                    mask-repeat: no-repeat;
+                    mask-position: center;
+                    background-color: $secondary-fg-color;
+                }
+            }
+        }
+
+        .mx_CreateRoomDialog_dropdown_invite::before {
+            mask-image: url('$(res)/img/element-icons/lock.svg');
+            mask-size: contain;
+        }
+
+        .mx_CreateRoomDialog_dropdown_public::before {
+            mask-image: url('$(res)/img/globe.svg');
+            mask-size: 12px;
+        }
+
+        .mx_CreateRoomDialog_dropdown_restricted::before {
+            mask-image: url('$(res)/img/element-icons/community-members.svg');
+            mask-size: contain;
+        }
     }
 }
 
diff --git a/res/img/feather-customised/globe.svg b/res/img/feather-customised/globe.svg
deleted file mode 100644
index 8af7dc41dc..0000000000
--- a/res/img/feather-customised/globe.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<svg height="12" viewBox="0 0 12 12" width="12" xmlns="http://www.w3.org/2000/svg">
-    <g style="stroke:#454545;stroke-width:.8;fill:none;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round" transform="translate(1 1)">
-        <circle cx="5" cy="5" r="5"/>
-        <path d="m0 5h10"/>
-        <path d="m5 0c1.25064019 1.36917645 1.96137638 3.14601693 2 5-.03862362 1.85398307-.74935981 3.63082355-2 5-1.25064019-1.36917645-1.96137638-3.14601693-2-5 .03862362-1.85398307.74935981-3.63082355 2-5z"/>
-    </g>
-</svg>
diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx
index db0b48f71c..b5d8421ae0 100644
--- a/src/components/views/dialogs/CreateRoomDialog.tsx
+++ b/src/components/views/dialogs/CreateRoomDialog.tsx
@@ -322,16 +322,16 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
         }
 
         const options = [
-            <div key={JoinRule.Invite} className="">
+            <div key={JoinRule.Invite} className="mx_CreateRoomDialog_dropdown_invite">
                 { _t("Private room (invite only)") }
             </div>,
-            <div key={JoinRule.Public} className="">
+            <div key={JoinRule.Public} className="mx_CreateRoomDialog_dropdown_public">
                 { _t("Public room") }
             </div>,
         ];
 
         if (this.supportsRestricted) {
-            options.unshift(<div key={JoinRule.Restricted} className="">
+            options.unshift(<div key={JoinRule.Restricted} className="mx_CreateRoomDialog_dropdown_restricted">
                 { _t("Visible to space members") }
             </div>);
         }
diff --git a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
index ff08ae5d28..9d571a8ed6 100644
--- a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
+++ b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
@@ -117,7 +117,7 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
     >
         <p>
             { _t("Decide which spaces can access this room. " +
-                "If a space is selected its members will be able to find and join <RoomName/>.", {}, {
+                "If a space is selected, its members can find and join <RoomName/>.", {}, {
                 RoomName: () => <b>{ room.name }</b>,
             })}
         </p>
diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index 312b8d84f5..0b29e18103 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -419,7 +419,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
         return (
             <div className="mx_SecurityRoomSettingsTab_joinRule">
                 <div className="mx_SettingsTab_subsectionText">
-                    <span>{ _t("Decide who can view and join %(roomName)s.", {
+                    <span>{ _t("Decide who can join %(roomName)s.", {
                         roomName: client.getRoom(this.props.roomId)?.name,
                     }) }</span>
                 </div>
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 2dce7055f6..554e8a7d9b 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -772,7 +772,7 @@
     "General": "General",
     "Security & Privacy": "Security & Privacy",
     "Roles & Permissions": "Roles & Permissions",
-    "This make it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.": "This make it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.",
+    "This makes it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.": "This makes it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.",
     "Skip": "Skip",
     "You joined the call": "You joined the call",
     "%(senderName)s joined the call": "%(senderName)s joined the call",
@@ -1440,7 +1440,7 @@
     "Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Anyone in %(spaceName)s can find and join. You can select other spaces too.",
     "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.",
     "Space members": "Space members",
-    "Decide who can view and join %(roomName)s.": "Decide who can view and join %(roomName)s.",
+    "Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.",
     "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
     "Members only (since they were invited)": "Members only (since they were invited)",
     "Members only (since they joined)": "Members only (since they joined)",
@@ -2365,7 +2365,7 @@
     "%(count)s rooms|one": "%(count)s room",
     "You're removing all spaces. Access will default to invite only": "You're removing all spaces. Access will default to invite only",
     "Select spaces": "Select spaces",
-    "Decide which spaces can access this room. If a space is selected its members will be able to find and join <RoomName/>.": "Decide which spaces can access this room. If a space is selected its members will be able to find and join <RoomName/>.",
+    "Decide which spaces can access this room. If a space is selected, its members can find and join <RoomName/>.": "Decide which spaces can access this room. If a space is selected, its members can find and join <RoomName/>.",
     "Search spaces": "Search spaces",
     "Spaces you know that contain this room": "Spaces you know that contain this room",
     "Other spaces or rooms you might not know": "Other spaces or rooms you might not know",
diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
index 6f03c6002f..a1ee9df4e5 100644
--- a/src/stores/SpaceStore.tsx
+++ b/src/stores/SpaceStore.tsx
@@ -232,7 +232,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
                                     </div>
                                 </div>
 
-                                <p>{ _t("This make it easy for rooms to stay private to a space, " +
+                                <p>{ _t("This makes it easy for rooms to stay private to a space, " +
                                     "while letting people in the space find and join them. " +
                                     "All new rooms in a space will have this option available.")}</p>
                             </>,

From d147aaa98467c5207a8456d0c01d803890f90581 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 21 Jul 2021 11:27:36 +0100
Subject: [PATCH 27/48] delint and fix min-height issue

---
 res/css/views/dialogs/_CreateRoomDialog.scss                  | 1 +
 .../views/dialogs/ManageRestrictedJoinRuleDialog.tsx          | 4 ++--
 src/components/views/elements/Dropdown.tsx                    | 2 +-
 .../views/settings/tabs/room/SecurityRoomSettingsTab.tsx      | 2 +-
 src/stores/SpaceStore.tsx                                     | 2 +-
 5 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/res/css/views/dialogs/_CreateRoomDialog.scss b/res/css/views/dialogs/_CreateRoomDialog.scss
index 173256f8af..5321d8ff69 100644
--- a/res/css/views/dialogs/_CreateRoomDialog.scss
+++ b/res/css/views/dialogs/_CreateRoomDialog.scss
@@ -125,6 +125,7 @@ limitations under the License.
             font-size: $font-14px;
             line-height: $font-32px;
             height: 32px;
+            min-height: 32px;
 
             > div {
                 padding-left: 30px;
diff --git a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
index 9d571a8ed6..c63fdc4c84 100644
--- a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
+++ b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
@@ -119,12 +119,12 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
             { _t("Decide which spaces can access this room. " +
                 "If a space is selected, its members can find and join <RoomName/>.", {}, {
                 RoomName: () => <b>{ room.name }</b>,
-            })}
+            }) }
         </p>
         <MatrixClientContext.Provider value={cli}>
             <SearchBox
                 className="mx_textinput_icon mx_textinput_search"
-                placeholder={ _t("Search spaces") }
+                placeholder={_t("Search spaces")}
                 onSearch={setQuery}
                 autoComplete={true}
                 autoFocus={true}
diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx
index c2ff59a2d3..dddcceb97c 100644
--- a/src/components/views/elements/Dropdown.tsx
+++ b/src/components/views/elements/Dropdown.tsx
@@ -140,7 +140,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
     }
 
     // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
-    UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
+    UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line
         if (!nextProps.children || nextProps.children.length === 0) {
             return;
         }
diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index 0b29e18103..320bc52ccd 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -392,7 +392,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
                                 <RoomAvatar room={room} height={32} width={32} />
                                 { room.name }
                             </span>;
-                        })}
+                        }) }
                         { moreText && <span>{ moreText }</span> }
                     </div>
                 </div>;
diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
index a1ee9df4e5..d907a7a21d 100644
--- a/src/stores/SpaceStore.tsx
+++ b/src/stores/SpaceStore.tsx
@@ -234,7 +234,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
 
                                 <p>{ _t("This makes it easy for rooms to stay private to a space, " +
                                     "while letting people in the space find and join them. " +
-                                    "All new rooms in a space will have this option available.")}</p>
+                                    "All new rooms in a space will have this option available.") }</p>
                             </>,
                             button: _t("OK"),
                             hasCloseButton: false,

From 8da2d0fe72103b0b3301c9f5e09c9f2c819e237d Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Thu, 22 Jul 2021 11:31:46 +0200
Subject: [PATCH 28/48] Fix avatar obstructing membership and state changes

---
 res/css/views/rooms/_EventTile.scss      | 3 ++-
 res/css/views/rooms/_GroupLayout.scss    | 1 +
 src/components/views/rooms/EventTile.tsx | 2 +-
 3 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index ab4fb28791..af3f480999 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -132,7 +132,8 @@ $hover-select-border: 4px;
         }
     }
 
-    &.mx_EventTile_info .mx_EventTile_line {
+    &.mx_EventTile_info .mx_EventTile_line,
+    & ~ .mx_EventListSummary .mx_EventTile_avatar ~ .mx_EventTile_line {
         padding-left: calc($left-gutter + 18px);
     }
 
diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss
index ddee81a914..ebb7f99e45 100644
--- a/res/css/views/rooms/_GroupLayout.scss
+++ b/res/css/views/rooms/_GroupLayout.scss
@@ -26,6 +26,7 @@ $left-gutter: 64px;
 
         > .mx_EventTile_avatar {
             position: absolute;
+            z-index: 9;
         }
 
         .mx_MessageTimestamp {
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index 6861ea7af5..c6c605a8c2 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -1142,6 +1142,7 @@ export default class EventTile extends React.Component<IProps, IState> {
                         { ircTimestamp }
                         { sender }
                         { ircPadlock }
+                        { avatar }
                         <div className="mx_EventTile_line" key="mx_EventTile_line">
                             { groupTimestamp }
                             { groupPadlock }
@@ -1162,7 +1163,6 @@ export default class EventTile extends React.Component<IProps, IState> {
                         </div>
                         { reactionsRow }
                         { msgOption }
-                        { avatar }
                     </>)
                 );
             }

From 472ead41fbf31cc4248a8643f8bff6214a3534a5 Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Thu, 22 Jul 2021 16:05:09 +0200
Subject: [PATCH 29/48] Make images fit inside message bubble

---
 src/components/views/messages/MImageBody.tsx | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx
index 44c15d50e7..c24f014d84 100644
--- a/src/components/views/messages/MImageBody.tsx
+++ b/src/components/views/messages/MImageBody.tsx
@@ -341,7 +341,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
             // mx_MImageBody_thumbnail resizes img to exactly container size
             img = (
                 <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this.image}
-                    style={{ maxWidth: maxWidth + "px" }}
+                    style={{ maxWidth: `min(100%, ${maxWidth}px)` }}
                     alt={content.body}
                     onError={this.onImageError}
                     onLoad={this.onImageLoad}
@@ -364,7 +364,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
                 { showPlaceholder &&
                     <div className="mx_MImageBody_thumbnail" style={{
                         // Constrain width here so that spinner appears central to the loaded thumbnail
-                        maxWidth: infoWidth + "px",
+                        maxWidth: `min(100%, ${infoWidth}px)`,
                     }}>
                         { placeholder }
                     </div>
@@ -452,7 +452,7 @@ export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProp
         let className = 'mx_HiddenImagePlaceholder';
         if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover';
         return (
-            <div className={className} style={{ maxWidth: maxWidth }}>
+            <div className={className} style={{ maxWidth: `min(100%, ${maxWidth}px)` }}>
                 <div className='mx_HiddenImagePlaceholder_button'>
                     <span className='mx_HiddenImagePlaceholder_eye' />
                     <span>{ _t("Show image") }</span>

From 224a9db3ec39e2445cc0a4273638f44b5176a12f Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Thu, 22 Jul 2021 16:48:55 +0200
Subject: [PATCH 30/48] Add event selected state for message bubbles

---
 res/css/views/rooms/_EventBubbleTile.scss | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss
index cac463d4db..a65afdf0d5 100644
--- a/res/css/views/rooms/_EventBubbleTile.scss
+++ b/res/css/views/rooms/_EventBubbleTile.scss
@@ -38,7 +38,8 @@ limitations under the License.
         padding-top: 0;
     }
 
-    &:hover {
+    &:hover,
+    &.mx_EventTile_selected {
         &::before {
             content: '';
             position: absolute;

From eec63574e6223058f470668207b399735f236a27 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 22 Jul 2021 09:26:26 -0600
Subject: [PATCH 31/48] Move src/voice to src/audio for better naming

Many of these files are used by Audio and Voice messages.

Fixes https://github.com/vector-im/element-web/issues/18131
---
 src/{voice => audio}/ManagedPlayback.ts                       | 0
 src/{voice => audio}/Playback.ts                              | 0
 src/{voice => audio}/PlaybackClock.ts                         | 0
 src/{voice => audio}/PlaybackManager.ts                       | 0
 src/{voice => audio}/RecorderWorklet.ts                       | 0
 src/{voice => audio}/VoiceRecording.ts                        | 0
 src/{voice => audio}/compat.ts                                | 0
 src/{voice => audio}/consts.ts                                | 0
 src/components/views/audio_messages/AudioPlayer.tsx           | 2 +-
 src/components/views/audio_messages/DurationClock.tsx         | 2 +-
 src/components/views/audio_messages/LiveRecordingClock.tsx    | 2 +-
 src/components/views/audio_messages/LiveRecordingWaveform.tsx | 2 +-
 src/components/views/audio_messages/PlayPauseButton.tsx       | 2 +-
 src/components/views/audio_messages/PlaybackClock.tsx         | 2 +-
 src/components/views/audio_messages/PlaybackWaveform.tsx      | 2 +-
 src/components/views/audio_messages/RecordingPlayback.tsx     | 2 +-
 src/components/views/audio_messages/SeekBar.tsx               | 2 +-
 src/components/views/messages/MAudioBody.tsx                  | 4 ++--
 src/components/views/rooms/MessageComposer.tsx                | 2 +-
 src/components/views/rooms/VoiceRecordComposerTile.tsx        | 2 +-
 src/stores/VoiceRecordingStore.ts                             | 2 +-
 21 files changed, 14 insertions(+), 14 deletions(-)
 rename src/{voice => audio}/ManagedPlayback.ts (100%)
 rename src/{voice => audio}/Playback.ts (100%)
 rename src/{voice => audio}/PlaybackClock.ts (100%)
 rename src/{voice => audio}/PlaybackManager.ts (100%)
 rename src/{voice => audio}/RecorderWorklet.ts (100%)
 rename src/{voice => audio}/VoiceRecording.ts (100%)
 rename src/{voice => audio}/compat.ts (100%)
 rename src/{voice => audio}/consts.ts (100%)

diff --git a/src/voice/ManagedPlayback.ts b/src/audio/ManagedPlayback.ts
similarity index 100%
rename from src/voice/ManagedPlayback.ts
rename to src/audio/ManagedPlayback.ts
diff --git a/src/voice/Playback.ts b/src/audio/Playback.ts
similarity index 100%
rename from src/voice/Playback.ts
rename to src/audio/Playback.ts
diff --git a/src/voice/PlaybackClock.ts b/src/audio/PlaybackClock.ts
similarity index 100%
rename from src/voice/PlaybackClock.ts
rename to src/audio/PlaybackClock.ts
diff --git a/src/voice/PlaybackManager.ts b/src/audio/PlaybackManager.ts
similarity index 100%
rename from src/voice/PlaybackManager.ts
rename to src/audio/PlaybackManager.ts
diff --git a/src/voice/RecorderWorklet.ts b/src/audio/RecorderWorklet.ts
similarity index 100%
rename from src/voice/RecorderWorklet.ts
rename to src/audio/RecorderWorklet.ts
diff --git a/src/voice/VoiceRecording.ts b/src/audio/VoiceRecording.ts
similarity index 100%
rename from src/voice/VoiceRecording.ts
rename to src/audio/VoiceRecording.ts
diff --git a/src/voice/compat.ts b/src/audio/compat.ts
similarity index 100%
rename from src/voice/compat.ts
rename to src/audio/compat.ts
diff --git a/src/voice/consts.ts b/src/audio/consts.ts
similarity index 100%
rename from src/voice/consts.ts
rename to src/audio/consts.ts
diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx
index fb9270765e..3c0e5c1143 100644
--- a/src/components/views/audio_messages/AudioPlayer.tsx
+++ b/src/components/views/audio_messages/AudioPlayer.tsx
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { Playback, PlaybackState } from "../../../voice/Playback";
+import { Playback, PlaybackState } from "../../../audio/Playback";
 import React, { createRef, ReactNode, RefObject } from "react";
 import { UPDATE_EVENT } from "../../../stores/AsyncStore";
 import PlayPauseButton from "./PlayPauseButton";
diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx
index 81852b5944..15bc6c98a4 100644
--- a/src/components/views/audio_messages/DurationClock.tsx
+++ b/src/components/views/audio_messages/DurationClock.tsx
@@ -17,7 +17,7 @@ limitations under the License.
 import React from "react";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import Clock from "./Clock";
-import { Playback } from "../../../voice/Playback";
+import { Playback } from "../../../audio/Playback";
 
 interface IProps {
     playback: Playback;
diff --git a/src/components/views/audio_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx
index a9dbd3c52f..e7330efc1d 100644
--- a/src/components/views/audio_messages/LiveRecordingClock.tsx
+++ b/src/components/views/audio_messages/LiveRecordingClock.tsx
@@ -15,7 +15,7 @@ limitations under the License.
 */
 
 import React from "react";
-import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
+import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import Clock from "./Clock";
 import { MarkedExecution } from "../../../utils/MarkedExecution";
diff --git a/src/components/views/audio_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx
index b9c5f80f05..9c33889884 100644
--- a/src/components/views/audio_messages/LiveRecordingWaveform.tsx
+++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx
@@ -15,7 +15,7 @@ limitations under the License.
 */
 
 import React from "react";
-import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
+import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { arrayFastResample } from "../../../utils/arrays";
 import { percentageOf } from "../../../utils/numbers";
diff --git a/src/components/views/audio_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx
index a4f1e770f2..de2822cc39 100644
--- a/src/components/views/audio_messages/PlayPauseButton.tsx
+++ b/src/components/views/audio_messages/PlayPauseButton.tsx
@@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import { _t } from "../../../languageHandler";
-import { Playback, PlaybackState } from "../../../voice/Playback";
+import { Playback, PlaybackState } from "../../../audio/Playback";
 import classNames from "classnames";
 
 // omitted props are handled by render function
diff --git a/src/components/views/audio_messages/PlaybackClock.tsx b/src/components/views/audio_messages/PlaybackClock.tsx
index 374d47c31d..affb025d86 100644
--- a/src/components/views/audio_messages/PlaybackClock.tsx
+++ b/src/components/views/audio_messages/PlaybackClock.tsx
@@ -17,7 +17,7 @@ limitations under the License.
 import React from "react";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import Clock from "./Clock";
-import { Playback, PlaybackState } from "../../../voice/Playback";
+import { Playback, PlaybackState } from "../../../audio/Playback";
 import { UPDATE_EVENT } from "../../../stores/AsyncStore";
 
 interface IProps {
diff --git a/src/components/views/audio_messages/PlaybackWaveform.tsx b/src/components/views/audio_messages/PlaybackWaveform.tsx
index ea1b846c01..96fd3f5ae2 100644
--- a/src/components/views/audio_messages/PlaybackWaveform.tsx
+++ b/src/components/views/audio_messages/PlaybackWaveform.tsx
@@ -18,7 +18,7 @@ import React from "react";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
 import Waveform from "./Waveform";
-import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback";
+import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
 import { percentageOf } from "../../../utils/numbers";
 
 interface IProps {
diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx
index ca0ed83d84..9a45101efc 100644
--- a/src/components/views/audio_messages/RecordingPlayback.tsx
+++ b/src/components/views/audio_messages/RecordingPlayback.tsx
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { Playback, PlaybackState } from "../../../voice/Playback";
+import { Playback, PlaybackState } from "../../../audio/Playback";
 import React, { ReactNode } from "react";
 import { UPDATE_EVENT } from "../../../stores/AsyncStore";
 import PlayPauseButton from "./PlayPauseButton";
diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/SeekBar.tsx
index 5231a2fb79..f0c03bb032 100644
--- a/src/components/views/audio_messages/SeekBar.tsx
+++ b/src/components/views/audio_messages/SeekBar.tsx
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { Playback, PlaybackState } from "../../../voice/Playback";
+import { Playback, PlaybackState } from "../../../audio/Playback";
 import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { MarkedExecution } from "../../../utils/MarkedExecution";
diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx
index 1f0b0f25f4..823198f44d 100644
--- a/src/components/views/messages/MAudioBody.tsx
+++ b/src/components/views/messages/MAudioBody.tsx
@@ -16,14 +16,14 @@ limitations under the License.
 
 import React from "react";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
-import { Playback } from "../../../voice/Playback";
+import { Playback } from "../../../audio/Playback";
 import InlineSpinner from '../elements/InlineSpinner';
 import { _t } from "../../../languageHandler";
 import AudioPlayer from "../audio_messages/AudioPlayer";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 import MFileBody from "./MFileBody";
 import { IBodyProps } from "./IBodyProps";
-import { PlaybackManager } from "../../../voice/PlaybackManager";
+import { PlaybackManager } from "../../../audio/PlaybackManager";
 
 interface IState {
     error?: Error;
diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx
index b16d22b416..ee1ff7d17d 100644
--- a/src/components/views/rooms/MessageComposer.tsx
+++ b/src/components/views/rooms/MessageComposer.tsx
@@ -35,7 +35,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
 import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
-import { RecordingState } from "../../../voice/VoiceRecording";
+import { RecordingState } from "../../../audio/VoiceRecording";
 import Tooltip, { Alignment } from "../elements/Tooltip";
 import ResizeNotifier from "../../../utils/ResizeNotifier";
 import { E2EStatus } from '../../../utils/ShieldUtils';
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index f0df64fcb4..4c40d218b0 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -20,7 +20,7 @@ import React, { ReactNode } from "react";
 import {
     RecordingState,
     VoiceRecording,
-} from "../../../voice/VoiceRecording";
+} from "../../../audio/VoiceRecording";
 import { Room } from "matrix-js-sdk/src/models/room";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
 import classNames from "classnames";
diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts
index 81c19e7e82..df837fec88 100644
--- a/src/stores/VoiceRecordingStore.ts
+++ b/src/stores/VoiceRecordingStore.ts
@@ -17,7 +17,7 @@ limitations under the License.
 import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
 import defaultDispatcher from "../dispatcher/dispatcher";
 import { ActionPayload } from "../dispatcher/payloads";
-import { VoiceRecording } from "../voice/VoiceRecording";
+import { VoiceRecording } from "../audio/VoiceRecording";
 
 interface IState {
     recording?: VoiceRecording;

From e1bb04f45a071318872bed0623bba3c5b59ce8db Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 22 Jul 2021 09:27:38 -0600
Subject: [PATCH 32/48] Remove answered TODOs

---
 src/components/views/messages/MAudioBody.tsx           | 2 --
 src/components/views/messages/MVoiceMessageBody.tsx    | 2 --
 src/components/views/rooms/VoiceRecordComposerTile.tsx | 1 -
 3 files changed, 5 deletions(-)

diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx
index 823198f44d..288ad16d88 100644
--- a/src/components/views/messages/MAudioBody.tsx
+++ b/src/components/views/messages/MAudioBody.tsx
@@ -76,7 +76,6 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
 
     public render() {
         if (this.state.error) {
-            // TODO: @@TR: Verify error state
             return (
                 <span className="mx_MAudioBody">
                     <img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
@@ -86,7 +85,6 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
         }
 
         if (!this.state.playback) {
-            // TODO: @@TR: Verify loading/decrypting state
             return (
                 <span className="mx_MAudioBody">
                     <InlineSpinner />
diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx
index f184caf448..55b608cf2d 100644
--- a/src/components/views/messages/MVoiceMessageBody.tsx
+++ b/src/components/views/messages/MVoiceMessageBody.tsx
@@ -27,7 +27,6 @@ export default class MVoiceMessageBody extends MAudioBody {
     // A voice message is an audio file but rendered in a special way.
     public render() {
         if (this.state.error) {
-            // TODO: @@TR: Verify error state
             return (
                 <span className="mx_MVoiceMessageBody">
                     <img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
@@ -37,7 +36,6 @@ export default class MVoiceMessageBody extends MAudioBody {
         }
 
         if (!this.state.playback) {
-            // TODO: @@TR: Verify loading/decrypting state
             return (
                 <span className="mx_MVoiceMessageBody">
                     <InlineSpinner />
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index 4c40d218b0..8323320520 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -189,7 +189,6 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
         if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
 
         if (this.state.recordingPhase !== RecordingState.Started) {
-            // TODO: @@ TR: Should we disable this during upload? What does a failed upload look like?
             return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
         }
 

From bd46275ec6a77806fdfd92868d74f25b099cc430 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Thu, 22 Jul 2021 17:48:17 +0200
Subject: [PATCH 33/48] Don't show scrollbar for url previews
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
---
 res/css/views/rooms/_LinkPreviewWidget.scss | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss
index 0832337ecd..8505905108 100644
--- a/res/css/views/rooms/_LinkPreviewWidget.scss
+++ b/res/css/views/rooms/_LinkPreviewWidget.scss
@@ -33,7 +33,7 @@ limitations under the License.
 .mx_LinkPreviewWidget_caption {
     margin-left: 15px;
     flex: 1 1 auto;
-    overflow-x: hidden; // cause it to wrap rather than clip
+    overflow: hidden; // cause it to wrap rather than clip
 }
 
 .mx_LinkPreviewWidget_title {

From c427612c241a5f5e34439c0b672c0dac047a9fe3 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 22 Jul 2021 12:14:38 -0600
Subject: [PATCH 34/48] de-dupe audio player

Fixes https://github.com/vector-im/element-web/issues/18161
---
 .../views/audio_messages/AudioPlayer.tsx      | 47 ++-----------
 .../views/audio_messages/AudioPlayerBase.tsx  | 70 +++++++++++++++++++
 .../audio_messages/RecordingPlayback.tsx      | 48 ++-----------
 3 files changed, 80 insertions(+), 85 deletions(-)
 create mode 100644 src/components/views/audio_messages/AudioPlayerBase.tsx

diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx
index 3c0e5c1143..b83f89fe5b 100644
--- a/src/components/views/audio_messages/AudioPlayer.tsx
+++ b/src/components/views/audio_messages/AudioPlayer.tsx
@@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { Playback, PlaybackState } from "../../../audio/Playback";
 import React, { createRef, ReactNode, RefObject } from "react";
-import { UPDATE_EVENT } from "../../../stores/AsyncStore";
 import PlayPauseButton from "./PlayPauseButton";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { formatBytes } from "../../../utils/FormattingUtils";
@@ -25,47 +23,13 @@ import { Key } from "../../../Keyboard";
 import { _t } from "../../../languageHandler";
 import SeekBar from "./SeekBar";
 import PlaybackClock from "./PlaybackClock";
-
-interface IProps {
-    // Playback instance to render. Cannot change during component lifecycle: create
-    // an all-new component instead.
-    playback: Playback;
-
-    mediaName: string;
-}
-
-interface IState {
-    playbackPhase: PlaybackState;
-    error?: boolean;
-}
+import AudioPlayerBase from "./AudioPlayerBase";
 
 @replaceableComponent("views.audio_messages.AudioPlayer")
-export default class AudioPlayer extends React.PureComponent<IProps, IState> {
+export default class AudioPlayer extends AudioPlayerBase {
     private playPauseRef: RefObject<PlayPauseButton> = createRef();
     private seekRef: RefObject<SeekBar> = createRef();
 
-    constructor(props: IProps) {
-        super(props);
-
-        this.state = {
-            playbackPhase: PlaybackState.Decoding, // default assumption
-        };
-
-        // We don't need to de-register: the class handles this for us internally
-        this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
-
-        // Don't wait for the promise to complete - it will emit a progress update when it
-        // is done, and it's not meant to take long anyhow.
-        this.props.playback.prepare().catch(e => {
-            console.error("Error processing audio file:", e);
-            this.setState({ error: true });
-        });
-    }
-
-    private onPlaybackUpdate = (ev: PlaybackState) => {
-        this.setState({ playbackPhase: ev });
-    };
-
     private onKeyDown = (ev: React.KeyboardEvent) => {
         // stopPropagation() prevents the FocusComposer catch-all from triggering,
         // but we need to do it on key down instead of press (even though the user
@@ -91,10 +55,10 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
         return `(${formatBytes(bytes)})`;
     }
 
-    public render(): ReactNode {
+    protected renderComponent(): ReactNode {
         // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
         // events for accessibility
-        return <>
+        return (
             <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
                 <div className='mx_AudioPlayer_primaryContainer'>
                     <PlayPauseButton
@@ -124,7 +88,6 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
                     <PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
                 </div>
             </div>
-            { this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
-        </>;
+        );
     }
 }
diff --git a/src/components/views/audio_messages/AudioPlayerBase.tsx b/src/components/views/audio_messages/AudioPlayerBase.tsx
new file mode 100644
index 0000000000..d8fc9d507f
--- /dev/null
+++ b/src/components/views/audio_messages/AudioPlayerBase.tsx
@@ -0,0 +1,70 @@
+/*
+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 { Playback, PlaybackState } from "../../../audio/Playback";
+import { TileShape } from "../rooms/EventTile";
+import React, { ReactNode } from "react";
+import { UPDATE_EVENT } from "../../../stores/AsyncStore";
+import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { _t } from "../../../languageHandler";
+
+interface IProps {
+    // Playback instance to render. Cannot change during component lifecycle: create
+    // an all-new component instead.
+    playback: Playback;
+
+    mediaName?: string;
+    tileShape?: TileShape;
+}
+
+interface IState {
+    playbackPhase: PlaybackState;
+    error?: boolean;
+}
+
+@replaceableComponent("views.audio_messages.AudioPlayerBase")
+export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
+    constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            playbackPhase: PlaybackState.Decoding, // default assumption
+        };
+
+        // We don't need to de-register: the class handles this for us internally
+        this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
+
+        // Don't wait for the promise to complete - it will emit a progress update when it
+        // is done, and it's not meant to take long anyhow.
+        this.props.playback.prepare().catch(e => {
+            console.error("Error processing audio file:", e);
+            this.setState({ error: true });
+        });
+    }
+
+    private onPlaybackUpdate = (ev: PlaybackState) => {
+        this.setState({ playbackPhase: ev });
+    };
+
+    protected abstract renderComponent(): ReactNode;
+
+    public render(): ReactNode {
+        return <>
+            { this.renderComponent() }
+            { this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
+        </>;
+    }
+}
diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx
index 9a45101efc..e3f612c9b6 100644
--- a/src/components/views/audio_messages/RecordingPlayback.tsx
+++ b/src/components/views/audio_messages/RecordingPlayback.tsx
@@ -14,68 +14,30 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { Playback, PlaybackState } from "../../../audio/Playback";
 import React, { ReactNode } from "react";
-import { UPDATE_EVENT } from "../../../stores/AsyncStore";
 import PlayPauseButton from "./PlayPauseButton";
 import PlaybackClock from "./PlaybackClock";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { TileShape } from "../rooms/EventTile";
 import PlaybackWaveform from "./PlaybackWaveform";
-import { _t } from "../../../languageHandler";
-
-interface IProps {
-    // Playback instance to render. Cannot change during component lifecycle: create
-    // an all-new component instead.
-    playback: Playback;
-
-    tileShape?: TileShape;
-}
-
-interface IState {
-    playbackPhase: PlaybackState;
-    error?: boolean;
-}
+import AudioPlayerBase from "./AudioPlayerBase";
 
 @replaceableComponent("views.audio_messages.RecordingPlayback")
-export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
-    constructor(props: IProps) {
-        super(props);
-
-        this.state = {
-            playbackPhase: PlaybackState.Decoding, // default assumption
-        };
-
-        // We don't need to de-register: the class handles this for us internally
-        this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
-
-        // Don't wait for the promise to complete - it will emit a progress update when it
-        // is done, and it's not meant to take long anyhow.
-        this.props.playback.prepare().catch(e => {
-            console.error("Error processing audio file:", e);
-            this.setState({ error: true });
-        });
-    }
-
+export default class RecordingPlayback extends AudioPlayerBase {
     private get isWaveformable(): boolean {
         return this.props.tileShape !== TileShape.Notif
             && this.props.tileShape !== TileShape.FileGrid
             && this.props.tileShape !== TileShape.Pinned;
     }
 
-    private onPlaybackUpdate = (ev: PlaybackState) => {
-        this.setState({ playbackPhase: ev });
-    };
-
-    public render(): ReactNode {
+    protected renderComponent(): ReactNode {
         const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
-        return <>
+        return (
             <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
                 <PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
                 <PlaybackClock playback={this.props.playback} />
                 { this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
             </div>
-            { this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
-        </>;
+        );
     }
 }

From d38f2cf5b59467adf0597efddf51f80cdce9ed21 Mon Sep 17 00:00:00 2001
From: David Baker <dave@matrix.org>
Date: Thu, 22 Jul 2021 22:49:30 +0100
Subject: [PATCH 35/48] Fix display of image messages that lack thumbnails

Fixes https://github.com/vector-im/element-web/issues/18175
---
 src/utils/MediaEventHelper.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts
index cf34d5dea4..8b8edcc62a 100644
--- a/src/utils/MediaEventHelper.ts
+++ b/src/utils/MediaEventHelper.ts
@@ -67,6 +67,7 @@ export class MediaEventHelper implements IDestroyable {
     private prepareThumbnailUrl = async () => {
         if (this.media.isEncrypted) {
             const blob = await this.thumbnailBlob.value;
+            if (blob === null) return null;
             return URL.createObjectURL(blob);
         } else {
             return this.media.thumbnailHttp;

From cd77b9f1af11e59f51e0a23c6258c7cb95db7034 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 23 Jul 2021 08:55:16 +0100
Subject: [PATCH 36/48] merge two opposing if statements

---
 src/components/views/dialogs/CreateRoomDialog.tsx | 15 +++++----------
 1 file changed, 5 insertions(+), 10 deletions(-)

diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx
index b5d8421ae0..a06f508908 100644
--- a/src/components/views/dialogs/CreateRoomDialog.tsx
+++ b/src/components/views/dialogs/CreateRoomDialog.tsx
@@ -93,13 +93,18 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
         const opts: IOpts = {};
         const createOpts: IOpts["createOpts"] = opts.createOpts = {};
         createOpts.name = this.state.name;
+
         if (this.state.joinRule === JoinRule.Public) {
             createOpts.visibility = Visibility.Public;
             createOpts.preset = Preset.PublicChat;
             opts.guestAccess = false;
             const { alias } = this.state;
             createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
+        } else {
+            // If we cannot change encryption we pass `true` for safety, the server should automatically do this for us.
+            opts.encryption = this.state.canChangeEncryption ? this.state.isEncrypted : true;
         }
+
         if (this.state.topic) {
             createOpts.topic = this.state.topic;
         }
@@ -107,16 +112,6 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
             createOpts.creation_content = { 'm.federate': false };
         }
 
-        if (this.state.joinRule !== JoinRule.Public) {
-            if (this.state.canChangeEncryption) {
-                opts.encryption = this.state.isEncrypted;
-            } else {
-                // the server should automatically do this for us, but for safety
-                // we'll demand it too.
-                opts.encryption = true;
-            }
-        }
-
         if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
             opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
         }

From f5630acea77b24af819172b61d250201d9a8009d Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 23 Jul 2021 10:23:45 +0100
Subject: [PATCH 37/48] Adhere to better eslint rules

---
 src/Registration.js                           | 13 ++-
 .../dialogs/security/CreateKeyBackupDialog.js |  2 +-
 .../security/CreateSecretStorageDialog.js     |  7 +-
 .../dialogs/security/ExportE2eKeysDialog.js   | 14 ++-
 .../dialogs/security/ImportE2eKeysDialog.js   |  5 +-
 src/components/structures/EmbeddedPage.js     |  3 +-
 src/components/structures/GroupView.js        | 41 +++++---
 src/components/structures/MyGroups.js         |  3 +-
 src/components/structures/RoomStatusBar.js    |  8 +-
 src/components/structures/RoomView.tsx        |  3 +-
 src/components/structures/SearchBox.js        |  4 +-
 src/components/structures/SpaceRoomView.tsx   | 16 ++--
 .../structures/auth/ForgotPassword.tsx        | 10 +-
 src/components/structures/auth/Login.tsx      |  4 +-
 .../structures/auth/Registration.tsx          | 16 ++--
 .../views/audio_messages/Waveform.tsx         | 10 +-
 .../auth/InteractiveAuthEntryComponents.tsx   | 10 +-
 src/components/views/avatars/BaseAvatar.tsx   |  6 +-
 src/components/views/avatars/MemberAvatar.tsx |  8 +-
 .../context_menus/DialpadContextMenu.tsx      |  6 +-
 .../context_menus/StatusMessageContextMenu.js | 23 +++--
 .../views/context_menus/WidgetContextMenu.tsx |  3 +-
 .../dialogs/AddExistingToSpaceDialog.tsx      | 14 ++-
 .../views/dialogs/AddressPickerDialog.tsx     | 12 ++-
 src/components/views/dialogs/BaseDialog.js    |  4 +-
 .../views/dialogs/BetaFeedbackDialog.tsx      | 13 ++-
 .../views/dialogs/BugReportDialog.tsx         |  4 +-
 .../CommunityPrototypeInviteDialog.tsx        | 14 ++-
 .../views/dialogs/ConfirmRedactDialog.tsx     |  4 +-
 .../views/dialogs/ConfirmUserActionDialog.tsx |  4 +-
 .../CreateCommunityPrototypeDialog.tsx        |  6 +-
 .../views/dialogs/CreateGroupDialog.tsx       | 11 ++-
 .../views/dialogs/CreateRoomDialog.tsx        |  4 +-
 .../views/dialogs/CryptoStoreTooNewDialog.tsx |  2 +-
 .../views/dialogs/DevtoolsDialog.tsx          | 94 ++++++++++++++-----
 .../dialogs/EditCommunityPrototypeDialog.tsx  |  6 +-
 .../views/dialogs/ForwardDialog.tsx           | 18 ++--
 .../views/dialogs/IncomingSasDialog.js        | 13 ++-
 src/components/views/dialogs/InfoDialog.js    |  3 +-
 src/components/views/dialogs/InviteDialog.tsx | 39 +++++---
 .../dialogs/SessionRestoreErrorDialog.js      |  4 +-
 .../views/dialogs/StorageEvictedDialog.js     |  4 +-
 .../security/AccessSecretStorageDialog.tsx    |  3 +-
 .../security/RestoreKeyBackupDialog.js        |  2 +-
 src/components/views/elements/AddressTile.tsx |  2 +-
 .../elements/DesktopCapturerSourcePicker.tsx  |  3 +-
 src/components/views/elements/ImageView.tsx   | 29 +++---
 .../views/elements/MiniAvatarUploader.tsx     |  2 +-
 src/components/views/elements/Pill.js         |  5 +-
 .../views/elements/PowerSelector.js           | 15 ++-
 src/components/views/elements/Tooltip.tsx     |  3 +-
 src/components/views/emojipicker/Category.tsx | 10 +-
 .../views/groups/GroupMemberList.js           | 18 +++-
 .../views/groups/GroupMemberTile.js           | 11 ++-
 src/components/views/groups/GroupRoomList.js  | 24 +++--
 src/components/views/groups/GroupRoomTile.js  |  6 +-
 src/components/views/messages/CallEvent.tsx   |  2 +-
 src/components/views/messages/MImageBody.tsx  | 23 +++--
 src/components/views/messages/MVideoBody.tsx  |  3 +-
 .../views/messages/RoomAvatarEvent.js         |  7 +-
 src/components/views/right_panel/UserInfo.tsx | 30 +++---
 src/components/views/rooms/EventTile.tsx      | 13 ++-
 .../views/rooms/JumpToBottomButton.js         |  7 +-
 src/components/views/rooms/MemberList.tsx     | 22 +++--
 .../views/rooms/MessageComposer.tsx           |  7 +-
 src/components/views/rooms/NewRoomIntro.tsx   | 13 ++-
 .../views/rooms/ReadReceiptMarker.js          |  4 +-
 .../views/rooms/RoomBreadcrumbs.tsx           |  4 +-
 src/components/views/rooms/RoomDetailRow.js   |  8 +-
 src/components/views/rooms/RoomList.tsx       |  4 +-
 src/components/views/rooms/RoomPreviewBar.js  |  6 +-
 .../views/rooms/SimpleRoomHeader.js           |  8 +-
 src/components/views/rooms/Stickerpicker.js   |  6 +-
 .../views/rooms/TopUnreadMessagesBar.js       | 14 +--
 src/components/views/settings/BridgeTile.tsx  |  2 +-
 src/components/views/settings/ChangeAvatar.js | 15 ++-
 .../views/settings/EventIndexPanel.tsx        | 19 ++--
 .../views/settings/ProfileSettings.js         |  6 +-
 src/components/views/settings/SetIdServer.tsx |  4 +-
 .../tabs/room/GeneralRoomSettingsTab.js       |  9 +-
 .../tabs/room/RolesRoomSettingsTab.tsx        |  7 +-
 .../tabs/room/SecurityRoomSettingsTab.tsx     | 13 ++-
 .../tabs/user/AppearanceUserSettingsTab.tsx   |  7 +-
 .../tabs/user/GeneralUserSettingsTab.js       |  8 +-
 .../tabs/user/HelpUserSettingsTab.tsx         | 50 ++++++----
 .../settings/tabs/user/LabsUserSettingsTab.js |  7 +-
 .../views/spaces/SpaceBasicSettings.tsx       | 39 +++++---
 src/components/views/voip/CallView.tsx        | 14 ++-
 src/components/views/voip/DialPad.tsx         | 12 ++-
 src/components/views/voip/DialPadModal.tsx    |  8 +-
 src/components/views/voip/VideoFeed.tsx       |  2 +-
 .../structures/MessagePanel-test.js           | 34 +++++--
 92 files changed, 710 insertions(+), 348 deletions(-)

diff --git a/src/Registration.js b/src/Registration.js
index 70dcd38454..c59d244149 100644
--- a/src/Registration.js
+++ b/src/Registration.js
@@ -51,10 +51,15 @@ export async function startAnyRegistrationFlow(options) {
         description: _t("Use your account or create a new one to continue."),
         button: _t("Create Account"),
         extraButtons: [
-            <button key="start_login" onClick={() => {
-                modal.close();
-                dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after });
-            }}>{ _t('Sign In') }</button>,
+            <button
+                key="start_login"
+                onClick={() => {
+                    modal.close();
+                    dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after });
+                }}
+            >
+                { _t('Sign In') }
+            </button>,
         ],
         onFinished: (proceed) => {
             if (proceed) {
diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js
index 412194ab43..2cef1c0e41 100644
--- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js
+++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js
@@ -269,7 +269,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
 
             <details>
                 <summary>{ _t("Advanced") }</summary>
-                <AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
+                <AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick}>
                     { _t("Set up with a Security Key") }
                 </AccessibleButton>
             </details>
diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js
index aa78d68830..641df4f897 100644
--- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js
+++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js
@@ -474,7 +474,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
                 outlined
             >
                 <div className="mx_CreateSecretStorageDialog_optionTitle">
-                    <span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span>
+                    <span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup" />
                     { _t("Generate a Security Key") }
                 </div>
                 <div>{ _t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }</div>
@@ -493,7 +493,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
                 outlined
             >
                 <div className="mx_CreateSecretStorageDialog_optionTitle">
-                    <span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span>
+                    <span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase" />
                     { _t("Enter a Security Phrase") }
                 </div>
                 <div>{ _t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.") }</div>
@@ -701,7 +701,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
                         <code ref={this._collectRecoveryKeyNode}>{ this._recoveryKey.encodedPrivateKey }</code>
                     </div>
                     <div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
-                        <AccessibleButton kind='primary' className="mx_Dialog_primary"
+                        <AccessibleButton kind='primary'
+                            className="mx_Dialog_primary"
                             onClick={this._onDownloadClick}
                             disabled={this.state.phase === PHASE_STORING}
                         >
diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js
index 0435d81968..dbed9f3968 100644
--- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js
+++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js
@@ -148,8 +148,12 @@ export default class ExportE2eKeysDialog extends React.Component {
                                     </label>
                                 </div>
                                 <div className='mx_E2eKeysDialog_inputCell'>
-                                    <input ref={this._passphrase1} id='passphrase1'
-                                        autoFocus={true} size='64' type='password'
+                                    <input
+                                        ref={this._passphrase1}
+                                        id='passphrase1'
+                                        autoFocus={true}
+                                        size='64'
+                                        type='password'
                                         disabled={disableForm}
                                     />
                                 </div>
@@ -161,8 +165,10 @@ export default class ExportE2eKeysDialog extends React.Component {
                                     </label>
                                 </div>
                                 <div className='mx_E2eKeysDialog_inputCell'>
-                                    <input ref={this._passphrase2} id='passphrase2'
-                                        size='64' type='password'
+                                    <input ref={this._passphrase2}
+                                        id='passphrase2'
+                                        size='64'
+                                        type='password'
                                         disabled={disableForm}
                                     />
                                 </div>
diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js
index 6017d07047..0936ad696d 100644
--- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js
+++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js
@@ -174,7 +174,10 @@ export default class ImportE2eKeysDialog extends React.Component {
                         </div>
                     </div>
                     <div className='mx_Dialog_buttons'>
-                        <input className='mx_Dialog_primary' type='submit' value={_t('Import')}
+                        <input
+                            className='mx_Dialog_primary'
+                            type='submit'
+                            value={_t('Import')}
                             disabled={!this.state.enableSubmit || disableForm}
                         />
                         <button onClick={this._onCancelClick} disabled={disableForm}>
diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js
index 472a43e142..037a0eba2a 100644
--- a/src/components/structures/EmbeddedPage.js
+++ b/src/components/structures/EmbeddedPage.js
@@ -120,8 +120,7 @@ export default class EmbeddedPage extends React.PureComponent {
 
         const content = <div className={`${className}_body`}
             dangerouslySetInnerHTML={{ __html: this.state.page }}
-        >
-        </div>;
+        />;
 
         if (this.props.scrollbar) {
             return <AutoHideScrollbar className={classes}>
diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js
index 9d69fce801..99fa94e62b 100644
--- a/src/components/structures/GroupView.js
+++ b/src/components/structures/GroupView.js
@@ -222,7 +222,7 @@ class FeaturedRoom extends React.Component {
 
         let roomNameNode = null;
         if (permalink) {
-            roomNameNode = <a href={permalink} onClick={this.onClick} >{ roomName }</a>;
+            roomNameNode = <a href={permalink} onClick={this.onClick}>{ roomName }</a>;
         } else {
             roomNameNode = <span>{ roomName }</span>;
         }
@@ -1185,10 +1185,13 @@ export default class GroupView extends React.Component {
                     avatarImage = <Spinner />;
                 } else {
                     const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
-                    avatarImage = <GroupAvatar groupId={this.props.groupId}
+                    avatarImage = <GroupAvatar
+                        groupId={this.props.groupId}
                         groupName={this.state.profileForm.name}
                         groupAvatarUrl={this.state.profileForm.avatar_url}
-                        width={28} height={28} resizeMethod='crop'
+                        width={28}
+                        height={28}
+                        resizeMethod='crop'
                     />;
                 }
 
@@ -1199,9 +1202,12 @@ export default class GroupView extends React.Component {
                         </label>
                         <div className="mx_GroupView_avatarPicker_edit">
                             <label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
-                                <img src={require("../../../res/img/camera.svg")}
-                                    alt={_t("Upload avatar")} title={_t("Upload avatar")}
-                                    width="17" height="15" />
+                                <img
+                                    src={require("../../../res/img/camera.svg")}
+                                    alt={_t("Upload avatar")}
+                                    title={_t("Upload avatar")}
+                                    width="17"
+                                    height="15" />
                             </label>
                             <input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected} />
                         </div>
@@ -1238,7 +1244,8 @@ export default class GroupView extends React.Component {
                     groupAvatarUrl={groupAvatarUrl}
                     groupName={groupName}
                     onClick={onGroupHeaderItemClick}
-                    width={28} height={28}
+                    width={28}
+                    height={28}
                 />;
                 if (summary.profile && summary.profile.name) {
                     nameNode = <div onClick={onGroupHeaderItemClick}>
@@ -1269,28 +1276,32 @@ export default class GroupView extends React.Component {
                         key="_cancelButton"
                         onClick={this._onCancelClick}
                     >
-                        <img src={require("../../../res/img/cancel.svg")} className="mx_filterFlipColor"
-                            width="18" height="18" alt={_t("Cancel")} />
+                        <img
+                            src={require("../../../res/img/cancel.svg")}
+                            className="mx_filterFlipColor"
+                            width="18"
+                            height="18"
+                            alt={_t("Cancel")} />
                     </AccessibleButton>,
                 );
             } else {
                 if (summary.user && summary.user.membership === 'join') {
                     rightButtons.push(
-                        <AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_editButton"
+                        <AccessibleButton
+                            className="mx_GroupHeader_button mx_GroupHeader_editButton"
                             key="_editButton"
                             onClick={this._onEditClick}
                             title={_t("Community Settings")}
-                        >
-                        </AccessibleButton>,
+                        />,
                     );
                 }
                 rightButtons.push(
-                    <AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_shareButton"
+                    <AccessibleButton
+                        className="mx_GroupHeader_button mx_GroupHeader_shareButton"
                         key="_shareButton"
                         onClick={this._onShareClick}
                         title={_t('Share Community')}
-                    >
-                    </AccessibleButton>,
+                    />,
                 );
             }
 
diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js
index fca5613ede..dab18c4161 100644
--- a/src/components/structures/MyGroups.js
+++ b/src/components/structures/MyGroups.js
@@ -109,8 +109,7 @@ export default class MyGroups extends React.Component {
             <SimpleRoomHeader title={_t("Communities")} icon={require("../../../res/img/icons-groups.svg")} />
             <div className='mx_MyGroups_header'>
                 <div className="mx_MyGroups_headerCard">
-                    <AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
-                    </AccessibleButton>
+                    <AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick} />
                     <div className="mx_MyGroups_headerCard_content">
                         <div className="mx_MyGroups_headerCard_header">
                             { _t('Create a new community') }
diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js
index ac4d197346..8b10c54cba 100644
--- a/src/components/structures/RoomStatusBar.js
+++ b/src/components/structures/RoomStatusBar.js
@@ -266,8 +266,12 @@ export default class RoomStatusBar extends React.PureComponent {
                 <div className="mx_RoomStatusBar">
                     <div role="alert">
                         <div className="mx_RoomStatusBar_connectionLostBar">
-                            <img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
-                                height="24" title="/!\ " alt="/!\ " />
+                            <img
+                                src={require("../../../res/img/feather-customised/warning-triangle.svg")}
+                                width="24"
+                                height="24"
+                                title="/!\ "
+                                alt="/!\ " />
                             <div>
                                 <div className="mx_RoomStatusBar_connectionLostBar_title">
                                     { _t('Connectivity to the server has been lost.') }
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 1eb958fa6c..474b99262d 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -1740,7 +1740,8 @@ export default class RoomView extends React.Component<IProps, IState> {
                                 onJoinClick={this.onJoinButtonClicked}
                                 onForgetClick={this.onForgetClick}
                                 onRejectClick={this.onRejectThreepidInviteButtonClicked}
-                                canPreview={false} error={this.state.roomLoadError}
+                                canPreview={false}
+                                error={this.state.roomLoadError}
                                 roomAlias={roomAlias}
                                 joining={this.state.joining}
                                 inviterName={inviterName}
diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js
index 3cf4b9b593..6d310662e3 100644
--- a/src/components/structures/SearchBox.js
+++ b/src/components/structures/SearchBox.js
@@ -136,8 +136,8 @@ export default class SearchBox extends React.Component {
                 key="button"
                 tabIndex={-1}
                 className="mx_SearchBox_closeButton"
-                onClick={() => {this._clearSearch("button"); }}>
-            </AccessibleButton>) : undefined;
+                onClick={() => {this._clearSearch("button"); }}
+            />) : undefined;
 
         // show a shorter placeholder when blurred, if requested
         // this is used for the room filter field that has
diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx
index 06b2f4a629..fefed7cada 100644
--- a/src/components/structures/SpaceRoomView.tsx
+++ b/src/components/structures/SpaceRoomView.tsx
@@ -101,12 +101,14 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
         <hr />
         <div>
             <span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
-            <AccessibleButton kind="link" onClick={() => {
-                if (onClick) onClick();
-                Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
+            <AccessibleButton
+                kind="link"
+                onClick={() => {
+                    if (onClick) onClick();
+                    Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
                     featureId: "feature_spaces",
-                });
-            }}>
+                    });
+                }}>
                 { _t("Feedback") }
             </AccessibleButton>
         </div>
@@ -553,9 +555,7 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
             onFinished={onFinished}
         />
 
-        <div className="mx_SpaceRoomView_buttons">
-
-        </div>
+        <div className="mx_SpaceRoomView_buttons" />
         <SpaceFeedbackPrompt />
     </div>;
 };
diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx
index 3755505f3d..f978a6cded 100644
--- a/src/components/structures/auth/ForgotPassword.tsx
+++ b/src/components/structures/auth/ForgotPassword.tsx
@@ -315,7 +315,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
             { _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
                 "link it contains, click below.", { emailAddress: this.state.email }) }
             <br />
-            <input className="mx_Login_submit" type="button" onClick={this.onVerify}
+            <input
+                className="mx_Login_submit"
+                type="button"
+                onClick={this.onVerify}
                 value={_t('I have verified my email address')} />
         </div>;
     }
@@ -328,7 +331,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
                 "push notifications. To re-enable notifications, sign in again on each " +
                 "device.",
             ) }</p>
-            <input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
+            <input
+                className="mx_Login_submit"
+                type="button"
+                onClick={this.props.onComplete}
                 value={_t('Return to login screen')} />
         </div>;
     }
diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx
index 6a3d339681..7a05d8c6c6 100644
--- a/src/components/structures/auth/Login.tsx
+++ b/src/components/structures/auth/Login.tsx
@@ -463,7 +463,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
                         "Either use HTTPS or <a>enable unsafe scripts</a>.", {},
                     {
                         'a': (sub) => {
-                            return <a target="_blank" rel="noreferrer noopener"
+                            return <a
+                                target="_blank"
+                                rel="noreferrer noopener"
                                 href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
                             >
                                 { sub }
diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx
index 549e47260f..2b97650d4b 100644
--- a/src/components/structures/auth/Registration.tsx
+++ b/src/components/structures/auth/Registration.tsx
@@ -557,12 +557,16 @@ export default class Registration extends React.Component<IProps, IState> {
                             loggedInUserId: this.state.differentLoggedInUserId,
                         },
                     ) }</p>
-                    <p><AccessibleButton element="span" className="mx_linkButton" onClick={async event => {
-                        const sessionLoaded = await this.onLoginClickWithCheck(event);
-                        if (sessionLoaded) {
-                            dis.dispatch({ action: "view_welcome_page" });
-                        }
-                    }}>
+                    <p><AccessibleButton
+                        element="span"
+                        className="mx_linkButton"
+                        onClick={async event => {
+                            const sessionLoaded = await this.onLoginClickWithCheck(event);
+                            if (sessionLoaded) {
+                                dis.dispatch({ action: "view_welcome_page" });
+                            }
+                        }}
+                    >
                         { _t("Continue with previous account") }
                     </AccessibleButton></p>
                 </div>;
diff --git a/src/components/views/audio_messages/Waveform.tsx b/src/components/views/audio_messages/Waveform.tsx
index 8a4427fd01..4e44abdf46 100644
--- a/src/components/views/audio_messages/Waveform.tsx
+++ b/src/components/views/audio_messages/Waveform.tsx
@@ -54,9 +54,13 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
                     'mx_Waveform_bar': true,
                     'mx_Waveform_bar_100pct': isCompleteBar,
                 });
-                return <span key={i} style={{
-                    "--barHeight": h,
-                } as WaveformCSSProperties} className={classes} />;
+                return <span
+                    key={i}
+                    style={{
+                        "--barHeight": h,
+                    } as WaveformCSSProperties}
+                    className={classes}
+                />;
             }) }
         </div>;
     }
diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx
index 763ce10cd9..d9db140645 100644
--- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx
+++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx
@@ -416,8 +416,10 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
         let submitButton;
         if (this.props.showContinue !== false) {
             // XXX: button classes
-            submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
-                onClick={this.trySubmit} disabled={!allChecked}>{ _t("Accept") }</button>;
+            submitButton = <button
+                className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
+                onClick={this.trySubmit}
+                disabled={!allChecked}>{ _t("Accept") }</button>;
         }
 
         return (
@@ -616,7 +618,9 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
                                 aria-label={_t("Code")}
                             />
                             <br />
-                            <input type="submit" value={_t("Submit")}
+                            <input
+                                type="submit"
+                                value={_t("Submit")}
                                 className={submitClasses}
                                 disabled={!enableSubmit}
                             />
diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx
index 87cdbe7512..6aaef29854 100644
--- a/src/components/views/avatars/BaseAvatar.tsx
+++ b/src/components/views/avatars/BaseAvatar.tsx
@@ -187,7 +187,8 @@ const BaseAvatar = (props: IProps) => {
                     width: toPx(width),
                     height: toPx(height),
                 }}
-                title={title} alt={_t("Avatar")}
+                title={title}
+                alt={_t("Avatar")}
                 inputRef={inputRef}
                 {...otherProps} />
         );
@@ -201,7 +202,8 @@ const BaseAvatar = (props: IProps) => {
                     width: toPx(width),
                     height: toPx(height),
                 }}
-                title={title} alt=""
+                title={title}
+                alt=""
                 ref={inputRef}
                 {...otherProps} />
         );
diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx
index 61155e3880..11c24a5981 100644
--- a/src/components/views/avatars/MemberAvatar.tsx
+++ b/src/components/views/avatars/MemberAvatar.tsx
@@ -102,8 +102,12 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
         }
 
         return (
-            <BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
-                idName={userId} url={this.state.imageUrl} onClick={onClick} />
+            <BaseAvatar {...otherProps}
+                name={this.state.name}
+                title={this.state.title}
+                idName={userId}
+                url={this.state.imageUrl}
+                onClick={onClick} />
         );
     }
 }
diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx
index 39dfd50795..aead3a266e 100644
--- a/src/components/views/context_menus/DialpadContextMenu.tsx
+++ b/src/components/views/context_menus/DialpadContextMenu.tsx
@@ -60,8 +60,10 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
                     <AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} />
                 </div>
                 <div className="mx_DialPadContextMenu_header">
-                    <Field className="mx_DialPadContextMenu_dialled"
-                        value={this.state.value} autoFocus={true}
+                    <Field
+                        className="mx_DialPadContextMenu_dialled"
+                        value={this.state.value}
+                        autoFocus={true}
                         onChange={this.onChange}
                     />
                 </div>
diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js
index f90d9cc005..e05b05116c 100644
--- a/src/components/views/context_menus/StatusMessageContextMenu.js
+++ b/src/components/views/context_menus/StatusMessageContextMenu.js
@@ -109,8 +109,10 @@ export default class StatusMessageContextMenu extends React.Component {
                 </AccessibleButton>;
             }
         } else {
-            actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
-                disabled={!this.state.message} onClick={this._onSubmit}
+            actionButton = <AccessibleButton
+                className="mx_StatusMessageContextMenu_submit"
+                disabled={!this.state.message}
+                onClick={this._onSubmit}
             >
                 <span>{ _t("Set status") }</span>
             </AccessibleButton>;
@@ -121,12 +123,19 @@ export default class StatusMessageContextMenu extends React.Component {
             spinner = <Spinner w="24" h="24" />;
         }
 
-        const form = <form className="mx_StatusMessageContextMenu_form"
-            autoComplete="off" onSubmit={this._onSubmit}
+        const form = <form
+            className="mx_StatusMessageContextMenu_form"
+            autoComplete="off"
+            onSubmit={this._onSubmit}
         >
-            <input type="text" className="mx_StatusMessageContextMenu_message"
-                key="message" placeholder={_t("Set a new status...")}
-                autoFocus={true} maxLength="60" value={this.state.message}
+            <input
+                type="text"
+                className="mx_StatusMessageContextMenu_message"
+                key="message"
+                placeholder={_t("Set a new status...")}
+                autoFocus={true}
+                maxLength="60"
+                value={this.state.message}
                 onChange={this._onStatusChange}
             />
             <div className="mx_StatusMessageContextMenu_actionContainer">
diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx
index b21efdceb9..26d7b640a4 100644
--- a/src/components/views/context_menus/WidgetContextMenu.tsx
+++ b/src/components/views/context_menus/WidgetContextMenu.tsx
@@ -76,7 +76,8 @@ const WidgetContextMenu: React.FC<IProps> = ({
             onFinished();
         };
         streamAudioStreamButton = <IconizedContextMenuOption
-            onClick={onStreamAudioClick} label={_t("Start audio stream")}
+            onClick={onStreamAudioClick}
+            label={_t("Start audio stream")}
         />;
     }
 
diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx
index 0f78b971eb..89f7c8596f 100644
--- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx
+++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx
@@ -211,10 +211,16 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
     function overflowTile(overflowCount, totalCount) {
         const text = _t("and %(count)s others...", { count: overflowCount });
         return (
-            <EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
-                <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
-            } name={text} presenceState="online" suppressOnHover={true}
-            onClick={() => setTruncateAt(totalCount)} />
+            <EntityTile
+                className="mx_EntityTile_ellipsis"
+                avatarJsx={
+                    <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
+                }
+                name={text}
+                presenceState="online"
+                suppressOnHover={true}
+                onClick={() => setTruncateAt(totalCount)}
+            />
         );
     }
 
diff --git a/src/components/views/dialogs/AddressPickerDialog.tsx b/src/components/views/dialogs/AddressPickerDialog.tsx
index 1a976918bd..6b239ee570 100644
--- a/src/components/views/dialogs/AddressPickerDialog.tsx
+++ b/src/components/views/dialogs/AddressPickerDialog.tsx
@@ -665,8 +665,8 @@ export default class AddressPickerDialog extends React.Component<IProps, IState>
                 onChange={this.onQueryChanged}
                 placeholder={this.getPlaceholder()}
                 defaultValue={this.props.value}
-                autoFocus={this.props.focus}>
-            </textarea>,
+                autoFocus={this.props.focus}
+            />,
         );
 
         const filteredSuggestedList = this.getFilteredSuggestions();
@@ -727,8 +727,12 @@ export default class AddressPickerDialog extends React.Component<IProps, IState>
         }
 
         return (
-            <BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
-                onFinished={this.props.onFinished} title={this.props.title}>
+            <BaseDialog
+                className="mx_AddressPickerDialog"
+                onKeyDown={this.onKeyDown}
+                onFinished={this.props.onFinished}
+                title={this.props.title}
+            >
                 { inputLabel }
                 <div className="mx_Dialog_content">
                     <div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js
index 8ccc485d7c..42b21ec743 100644
--- a/src/components/views/dialogs/BaseDialog.js
+++ b/src/components/views/dialogs/BaseDialog.js
@@ -118,9 +118,7 @@ export default class BaseDialog extends React.Component {
 
         let headerImage;
         if (this.props.headerImage) {
-            headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage}
-                alt=""
-            />;
+            headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage} alt="" />;
         }
 
         return (
diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx
index 917004dbc7..34218a3399 100644
--- a/src/components/views/dialogs/BetaFeedbackDialog.tsx
+++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx
@@ -71,13 +71,16 @@ const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
                 &nbsp;
                 { _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
 
-                <AccessibleButton kind="link" onClick={() => {
-                    onFinished(false);
-                    defaultDispatcher.dispatch({
+                <AccessibleButton
+                    kind="link"
+                    onClick={() => {
+                        onFinished(false);
+                        defaultDispatcher.dispatch({
                         action: Action.ViewUserSettings,
                         initialTabId: UserTab.Labs,
-                    });
-                }}>
+                        });
+                    }}
+                >
                     { _t("To leave the beta, visit your settings.") }
                 </AccessibleButton>
             </div>
diff --git a/src/components/views/dialogs/BugReportDialog.tsx b/src/components/views/dialogs/BugReportDialog.tsx
index 64e984fe20..3df05dac6e 100644
--- a/src/components/views/dialogs/BugReportDialog.tsx
+++ b/src/components/views/dialogs/BugReportDialog.tsx
@@ -188,7 +188,9 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
         }
 
         return (
-            <BaseDialog className="mx_BugReportDialog" onFinished={this.onCancel}
+            <BaseDialog
+                className="mx_BugReportDialog"
+                onFinished={this.onCancel}
                 title={_t('Submit debug logs')}
                 contentId='mx_Dialog_content'
             >
diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx
index 73fd4def25..6a8773ce45 100644
--- a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx
+++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx
@@ -205,9 +205,12 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
                 people.push((
                     <AccessibleButton
                         onClick={this.onShowMorePeople}
-                        kind="link" key="more"
+                        kind="link"
+                        key="more"
                         className="mx_CommunityPrototypeInviteDialog_morePeople"
-                    >{ _t("Show more") }</AccessibleButton>
+                    >
+                        { _t("Show more") }
+                    </AccessibleButton>
                 ));
             }
         }
@@ -240,10 +243,13 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
                         { peopleIntro }
                         { people }
                         <AccessibleButton
-                            kind="primary" onClick={this.onSubmit}
+                            kind="primary"
+                            onClick={this.onSubmit}
                             disabled={this.state.busy}
                             className="mx_CommunityPrototypeInviteDialog_primaryButton"
-                        >{ buttonText }</AccessibleButton>
+                        >
+                            { buttonText }
+                        </AccessibleButton>
                     </div>
                 </form>
             </BaseDialog>
diff --git a/src/components/views/dialogs/ConfirmRedactDialog.tsx b/src/components/views/dialogs/ConfirmRedactDialog.tsx
index a2f2b10144..b346d2d44c 100644
--- a/src/components/views/dialogs/ConfirmRedactDialog.tsx
+++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx
@@ -37,8 +37,8 @@ export default class ConfirmRedactDialog extends React.Component<IProps> {
                        "Note that if you delete a room name or topic change, it could undo the change.")}
                 placeholder={_t("Reason (optional)")}
                 focus
-                button={_t("Remove")}>
-            </TextInputDialog>
+                button={_t("Remove")}
+            />
         );
     }
 }
diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.tsx b/src/components/views/dialogs/ConfirmUserActionDialog.tsx
index cbef474c69..7099556ac6 100644
--- a/src/components/views/dialogs/ConfirmUserActionDialog.tsx
+++ b/src/components/views/dialogs/ConfirmUserActionDialog.tsx
@@ -104,7 +104,9 @@ export default class ConfirmUserActionDialog extends React.Component<IProps> {
         }
 
         return (
-            <BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
+            <BaseDialog
+                className="mx_ConfirmUserActionDialog"
+                onFinished={this.props.onFinished}
                 title={this.props.title}
                 contentId='mx_Dialog_content'
             >
diff --git a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx
index 392ab9edad..ccac45fbcc 100644
--- a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx
+++ b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx
@@ -204,8 +204,10 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent<
                         </div>
                         <div className="mx_CreateCommunityPrototypeDialog_colAvatar">
                             <input
-                                type="file" style={{ display: "none" }}
-                                ref={this.avatarUploadRef} accept="image/*"
+                                type="file"
+                                style={{ display: "none" }}
+                                ref={this.avatarUploadRef}
+                                accept="image/*"
                                 onChange={this.onAvatarChanged}
                             />
                             <AccessibleButton
diff --git a/src/components/views/dialogs/CreateGroupDialog.tsx b/src/components/views/dialogs/CreateGroupDialog.tsx
index 88ae801441..b1ea75d367 100644
--- a/src/components/views/dialogs/CreateGroupDialog.tsx
+++ b/src/components/views/dialogs/CreateGroupDialog.tsx
@@ -123,7 +123,9 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
         }
 
         return (
-            <BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
+            <BaseDialog
+                className="mx_CreateGroupDialog"
+                onFinished={this.props.onFinished}
                 title={_t('Create Community')}
             >
                 <form onSubmit={this.onFormSubmit}>
@@ -133,8 +135,11 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
                                 <label htmlFor="groupname">{ _t('Community Name') }</label>
                             </div>
                             <div>
-                                <input id="groupname" className="mx_CreateGroupDialog_input"
-                                    autoFocus={true} size={64}
+                                <input
+                                    id="groupname"
+                                    className="mx_CreateGroupDialog_input"
+                                    autoFocus={true}
+                                    size={64}
                                     placeholder={_t('Example')}
                                     onChange={this.onGroupNameChange}
                                     value={this.state.groupName}
diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx
index 6d75b94c70..d52319768c 100644
--- a/src/components/views/dialogs/CreateRoomDialog.tsx
+++ b/src/components/views/dialogs/CreateRoomDialog.tsx
@@ -279,7 +279,9 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
             title = _t("Create a room in %(communityName)s", { communityName: name });
         }
         return (
-            <BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
+            <BaseDialog
+                className="mx_CreateRoomDialog"
+                onFinished={this.props.onFinished}
                 title={title}
             >
                 <form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
diff --git a/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx b/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx
index 134c4ab79e..d03b668cd9 100644
--- a/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx
+++ b/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx
@@ -72,7 +72,7 @@ const CryptoStoreTooNewDialog: React.FC<IProps> = (props: IProps) => {
             hasCancel={false}
             onPrimaryButtonClick={props.onFinished}
         >
-            <button onClick={_onLogoutClicked} >
+            <button onClick={_onLogoutClicked}>
                 { _t('Sign out') }
             </button>
         </DialogButtons>
diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx
index 61cda796ee..8ae9d0654f 100644
--- a/src/components/views/dialogs/DevtoolsDialog.tsx
+++ b/src/components/views/dialogs/DevtoolsDialog.tsx
@@ -182,14 +182,23 @@ export class SendCustomEvent extends GenericEditor<ISendCustomEventProps, ISendC
 
                 <br />
 
-                <Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
-                    autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
+                <Field
+                    id="evContent"
+                    label={_t("Event Content")}
+                    type="text"
+                    className="mx_DevTools_textarea"
+                    autoComplete="off"
+                    value={this.state.evContent}
+                    onChange={this.onChange}
+                    element="textarea" />
             </div>
             <div className="mx_Dialog_buttons">
                 <button onClick={this.onBack}>{ _t('Back') }</button>
                 { !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
                 { showTglFlip && <div style={{ float: "right" }}>
-                    <input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
+                    <input
+                        id="isStateEvent"
+                        className="mx_DevTools_tgl mx_DevTools_tgl-flip"
                         type="checkbox"
                         checked={this.state.isStateEvent}
                         onChange={this.onChange}
@@ -282,14 +291,24 @@ class SendAccountData extends GenericEditor<ISendAccountDataProps, ISendAccountD
                 { this.textInput('eventType', _t('Event Type')) }
                 <br />
 
-                <Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
-                    autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
+                <Field
+                    id="evContent"
+                    label={_t("Event Content")}
+                    type="text"
+                    className="mx_DevTools_textarea"
+                    autoComplete="off"
+                    value={this.state.evContent}
+                    onChange={this.onChange}
+                    element="textarea"
+                />
             </div>
             <div className="mx_Dialog_buttons">
                 <button onClick={this.onBack}>{ _t('Back') }</button>
                 { !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
                 { !this.state.message && <div style={{ float: "right" }}>
-                    <input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
+                    <input
+                        id="isRoomAccountData"
+                        className="mx_DevTools_tgl mx_DevTools_tgl-flip"
                         type="checkbox"
                         checked={this.state.isRoomAccountData}
                         disabled={this.props.forceMode}
@@ -371,11 +390,18 @@ class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredList
 
     render() {
         return <div>
-            <Field label={_t('Filter results')} autoFocus={true} size={64}
-                type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
+            <Field
+                label={_t('Filter results')}
+                autoFocus={true}
+                size={64}
+                type="text"
+                autoComplete="off"
+                value={this.props.query}
+                onChange={this.onQuery}
                 className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
                 // force re-render so that autoFocus is applied when this component is re-used
-                key={this.props.children[0] ? this.props.children[0].key : ''} />
+                key={this.props.children[0] ? this.props.children[0].key : ''}
+            />
 
             <TruncatedList getChildren={this.getChildren}
                 getChildCount={this.getChildCount}
@@ -459,11 +485,16 @@ class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateEx
     render() {
         if (this.state.event) {
             if (this.state.editing) {
-                return <SendCustomEvent room={this.props.room} forceStateEvent={true} onBack={this.onBack} inputs={{
+                return <SendCustomEvent
+                    room={this.props.room}
+                    forceStateEvent={true}
+                    onBack={this.onBack}
+                    inputs={{
                     eventType: this.state.event.getType(),
                     evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
                     stateKey: this.state.event.getStateKey(),
-                }} />;
+                    }}
+                />;
             }
 
             return <div className="mx_ViewSource">
@@ -594,7 +625,9 @@ class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDa
                     inputs={{
                         eventType: this.state.event.getType(),
                         evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
-                    }} forceMode={true} />;
+                    }}
+                    forceMode={true}
+                />;
             }
 
             return <div className="mx_ViewSource">
@@ -631,7 +664,9 @@ class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDa
             <div className="mx_Dialog_buttons">
                 <button onClick={this.onBack}>{ _t('Back') }</button>
                 <div style={{ float: "right" }}>
-                    <input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
+                    <input
+                        id="isRoomAccountData"
+                        className="mx_DevTools_tgl mx_DevTools_tgl-flip"
                         type="checkbox"
                         checked={this.state.isRoomAccountData}
                         onChange={this.onChange}
@@ -1021,8 +1056,13 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
                 <div>
                     <div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
                         <Field
-                            label={_t('Filter results')} autoFocus={true} size={64}
-                            type="text" autoComplete="off" value={this.state.query} onChange={this.onQueryChange}
+                            label={_t('Filter results')}
+                            autoFocus={true}
+                            size={64}
+                            type="text"
+                            autoComplete="off"
+                            value={this.state.query}
+                            onChange={this.onQueryChange}
                             className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
                         />
                         <table>
@@ -1040,7 +1080,9 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
                                             <a href="" onClick={(e) => this.onViewClick(e, i)}>
                                                 <code>{ i }</code>
                                             </a>
-                                            <a href="" onClick={(e) => this.onEditClick(e, i)}
+                                            <a
+                                                href=""
+                                                onClick={(e) => this.onEditClick(e, i)}
                                                 className='mx_DevTools_SettingsExplorer_edit'
                                             >
                                             ✏
@@ -1104,18 +1146,26 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
 
                         <div>
                             <Field
-                                id="valExpl" label={_t("Values at explicit levels")} type="text"
-                                className="mx_DevTools_textarea" element="textarea"
-                                autoComplete="off" value={this.state.explicitValues}
+                                id="valExpl"
+                                label={_t("Values at explicit levels")}
+                                type="text"
+                                className="mx_DevTools_textarea"
+                                element="textarea"
+                                autoComplete="off"
+                                value={this.state.explicitValues}
                                 onChange={this.onExplValuesEdit}
                             />
                         </div>
 
                         <div>
                             <Field
-                                id="valExpl" label={_t("Values at explicit levels in this room")} type="text"
-                                className="mx_DevTools_textarea" element="textarea"
-                                autoComplete="off" value={this.state.explicitRoomValues}
+                                id="valExpl"
+                                label={_t("Values at explicit levels in this room")}
+                                type="text"
+                                className="mx_DevTools_textarea"
+                                element="textarea"
+                                autoComplete="off"
+                                value={this.state.explicitRoomValues}
                                 onChange={this.onExplRoomValuesEdit}
                             />
                         </div>
diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx
index 1eabb68081..a0e6046d71 100644
--- a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx
+++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx
@@ -144,8 +144,10 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent<IP
                         </div>
                         <div className="mx_EditCommunityPrototypeDialog_rowAvatar">
                             <input
-                                type="file" style={{ display: "none" }}
-                                ref={this.avatarUploadRef} accept="image/*"
+                                type="file"
+                                style={{ display: "none" }}
+                                ref={this.avatarUploadRef}
+                                accept="image/*"
                                 onChange={this.onAvatarChanged}
                             />
                             <AccessibleButton
diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx
index 839ca6da2f..77e2b6ae0c 100644
--- a/src/components/views/dialogs/ForwardDialog.tsx
+++ b/src/components/views/dialogs/ForwardDialog.tsx
@@ -106,12 +106,12 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
         className = "mx_ForwardList_sending";
         disabled = true;
         title = _t("Sending");
-        icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
+        icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
     } else if (sendState === SendState.Sent) {
         className = "mx_ForwardList_sent";
         disabled = true;
         title = _t("Sent");
-        icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
+        icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
     } else {
         className = "mx_ForwardList_sendFailed";
         disabled = true;
@@ -204,10 +204,16 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
     function overflowTile(overflowCount, totalCount) {
         const text = _t("and %(count)s others...", { count: overflowCount });
         return (
-            <EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
-                <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
-            } name={text} presenceState="online" suppressOnHover={true}
-            onClick={() => setTruncateAt(totalCount)} />
+            <EntityTile
+                className="mx_EntityTile_ellipsis"
+                avatarJsx={
+                    <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
+                }
+                name={text}
+                presenceState="online"
+                suppressOnHover={true}
+                onClick={() => setTruncateAt(totalCount)}
+            />
         );
     }
 
diff --git a/src/components/views/dialogs/IncomingSasDialog.js b/src/components/views/dialogs/IncomingSasDialog.js
index 963d98ef3f..a5c9f2107f 100644
--- a/src/components/views/dialogs/IncomingSasDialog.js
+++ b/src/components/views/dialogs/IncomingSasDialog.js
@@ -133,18 +133,23 @@ export default class IncomingSasDialog extends React.Component {
                 ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48)
                 : null;
             profile = <div className="mx_IncomingSasDialog_opponentProfile">
-                <BaseAvatar name={oppProfile.displayname}
+                <BaseAvatar
+                    name={oppProfile.displayname}
                     idName={this.props.verifier.userId}
                     url={url}
-                    width={48} height={48} resizeMethod='crop'
+                    width={48}
+                    height={48}
+                    resizeMethod='crop'
                 />
                 <h2>{ oppProfile.displayname }</h2>
             </div>;
         } else if (this.state.opponentProfileError) {
             profile = <div>
-                <BaseAvatar name={this.props.verifier.userId.slice(1)}
+                <BaseAvatar
+                    name={this.props.verifier.userId.slice(1)}
                     idName={this.props.verifier.userId}
-                    width={48} height={48}
+                    width={48}
+                    height={48}
                 />
                 <h2>{ this.props.verifier.userId }</h2>
             </div>;
diff --git a/src/components/views/dialogs/InfoDialog.js b/src/components/views/dialogs/InfoDialog.js
index 8207d334d3..ff8c3645ca 100644
--- a/src/components/views/dialogs/InfoDialog.js
+++ b/src/components/views/dialogs/InfoDialog.js
@@ -63,8 +63,7 @@ export default class InfoDialog extends React.Component {
                 { this.props.button !== false && <DialogButtons primaryButton={this.props.button || _t('OK')}
                     onPrimaryButtonClick={this.onFinished}
                     hasCancel={false}
-                >
-                </DialogButtons> }
+                /> }
             </BaseDialog>
         );
     }
diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx
index 46f140fd39..609829b833 100644
--- a/src/components/views/dialogs/InviteDialog.tsx
+++ b/src/components/views/dialogs/InviteDialog.tsx
@@ -196,7 +196,9 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
             ? <img
                 className='mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar'
                 src={require("../../../../res/img/icon-email-pill-avatar.svg")}
-                width={avatarSize} height={avatarSize} />
+                width={avatarSize}
+                height={avatarSize}
+            />
             : <BaseAvatar
                 className='mx_InviteDialog_userTile_avatar'
                 url={this.props.member.getMxcAvatarUrl()
@@ -214,8 +216,11 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
                     className='mx_InviteDialog_userTile_remove'
                     onClick={this.onRemove}
                 >
-                    <img src={require("../../../../res/img/icon-pill-remove.svg")}
-                        alt={_t('Remove')} width={8} height={8}
+                    <img
+                        src={require("../../../../res/img/icon-pill-remove.svg")}
+                        alt={_t('Remove')}
+                        width={8}
+                        height={8}
                     />
                 </AccessibleButton>
             );
@@ -297,7 +302,9 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
         const avatar = (this.props.member as ThreepidMember).isEmail
             ? <img
                 src={require("../../../../res/img/icon-email-pill-avatar.svg")}
-                width={avatarSize} height={avatarSize} />
+                width={avatarSize}
+                height={avatarSize}
+            />
             : <BaseAvatar
                 url={this.props.member.getMxcAvatarUrl()
                     ? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize)
@@ -1458,7 +1465,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
                         <p className='mx_InviteDialog_helpText'>
                             <img
                                 src={require("../../../../res/img/element-icons/info.svg")}
-                                width={14} height={14} />
+                                width={14}
+                                height={14} />
                             { " " + _t("Invited people will be able to read old messages.") }
                         </p>;
                 }
@@ -1534,14 +1542,18 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
             // Only show the backspace button if the field has content
             let dialPadField;
             if (this.state.dialPadValue.length !== 0) {
-                dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
+                dialPadField = <Field
+                    className="mx_InviteDialog_dialPadField"
+                    id="dialpad_number"
                     value={this.state.dialPadValue}
                     autoFocus={true}
                     onChange={this.onDialChange}
                     postfixComponent={backspaceButton}
                 />;
             } else {
-                dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
+                dialPadField = <Field
+                    className="mx_InviteDialog_dialPadField"
+                    id="dialpad_number"
                     value={this.state.dialPadValue}
                     autoFocus={true}
                     onChange={this.onDialChange}
@@ -1552,14 +1564,19 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
                 <form onSubmit={this.onDialFormSubmit}>
                     { dialPadField }
                 </form>
-                <Dialpad hasDial={false}
-                    onDigitPress={this.onDigitPress} onDeletePress={this.onDeletePress}
+                <Dialpad
+                    hasDial={false}
+                    onDigitPress={this.onDigitPress}
+                    onDeletePress={this.onDeletePress}
                 />
             </div>;
             tabs.push(new Tab(TabId.DialPad, _td("Dial pad"), 'mx_InviteDialog_dialPadIcon', dialPadSection));
             dialogContent = <React.Fragment>
-                <TabbedView tabs={tabs} initialTabId={this.state.currentTabId}
-                    tabLocation={TabLocation.TOP} onChange={this.onTabChange}
+                <TabbedView
+                    tabs={tabs}
+                    initialTabId={this.state.currentTabId}
+                    tabLocation={TabLocation.TOP}
+                    onChange={this.onTabChange}
                 />
                 { consultConnectSection }
             </React.Fragment>;
diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js
index b7037d1f4f..eeeadbbfe5 100644
--- a/src/components/views/dialogs/SessionRestoreErrorDialog.js
+++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js
@@ -85,7 +85,9 @@ export default class SessionRestoreErrorDialog extends React.Component {
         }
 
         return (
-            <BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
+            <BaseDialog
+                className="mx_ErrorDialog"
+                onFinished={this.props.onFinished}
                 title={_t('Unable to restore session')}
                 contentId='mx_Dialog_content'
                 hasCancel={false}
diff --git a/src/components/views/dialogs/StorageEvictedDialog.js b/src/components/views/dialogs/StorageEvictedDialog.js
index 124e1763c9..507ee09e75 100644
--- a/src/components/views/dialogs/StorageEvictedDialog.js
+++ b/src/components/views/dialogs/StorageEvictedDialog.js
@@ -54,7 +54,9 @@ export default class StorageEvictedDialog extends React.Component {
         }
 
         return (
-            <BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
+            <BaseDialog
+                className="mx_ErrorDialog"
+                onFinished={this.props.onFinished}
                 title={_t('Missing session data')}
                 contentId='mx_Dialog_content'
                 hasCancel={false}
diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx
index b425f37dce..de0a0e1f18 100644
--- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx
+++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx
@@ -287,7 +287,8 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
             <div className="mx_AccessSecretStorageDialog_reset">
                 { _t("Forgotten or lost all recovery methods? <a>Reset all</a>", null, {
                     a: (sub) => <a
-                        href="" onClick={this.onResetAllClick}
+                        href=""
+                        onClick={this.onResetAllClick}
                         className="mx_AccessSecretStorageDialog_reset_link">{ sub }</a>,
                 }) }
             </div>
diff --git a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js
index e8bd8af01c..2b272a3b88 100644
--- a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js
+++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js
@@ -399,7 +399,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
 
             let keyStatus;
             if (this.state.recoveryKey.length === 0) {
-                keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus"></div>;
+                keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus" />;
             } else if (this.state.recoveryKeyValid) {
                 keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus">
                     { "\uD83D\uDC4D " }{ _t("This looks like a valid Security Key!") }
diff --git a/src/components/views/elements/AddressTile.tsx b/src/components/views/elements/AddressTile.tsx
index cdeb7cacd6..52c0d84ac2 100644
--- a/src/components/views/elements/AddressTile.tsx
+++ b/src/components/views/elements/AddressTile.tsx
@@ -122,7 +122,7 @@ export default class AddressTile extends React.Component<IProps> {
         let dismiss;
         if (this.props.canDismiss) {
             dismiss = (
-                <div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed} >
+                <div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed}>
                     <img src={require("../../../../res/img/icon-address-delete.svg")} width="9" height="9" />
                 </div>
             );
diff --git a/src/components/views/elements/DesktopCapturerSourcePicker.tsx b/src/components/views/elements/DesktopCapturerSourcePicker.tsx
index 82d0aa4976..20ecf08a5f 100644
--- a/src/components/views/elements/DesktopCapturerSourcePicker.tsx
+++ b/src/components/views/elements/DesktopCapturerSourcePicker.tsx
@@ -51,7 +51,8 @@ export class ExistingSource extends React.Component<DesktopCapturerSourceIProps>
             <AccessibleButton
                 className="mx_desktopCapturerSourcePicker_stream_button"
                 title={this.props.source.name}
-                onClick={this.onClick} >
+                onClick={this.onClick}
+            >
                 <img
                     className="mx_desktopCapturerSourcePicker_stream_thumbnail"
                     src={this.props.source.thumbnailURL}
diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx
index 954c1ab783..96b6d7553e 100644
--- a/src/components/views/elements/ImageView.tsx
+++ b/src/components/views/elements/ImageView.tsx
@@ -384,7 +384,8 @@ export default class ImageView extends React.Component<IProps, IState> {
             const avatar = (
                 <MemberAvatar
                     member={mxEvent.sender}
-                    width={32} height={32}
+                    width={32}
+                    height={32}
                     viewUserOnClick={true}
                 />
             );
@@ -403,7 +404,7 @@ export default class ImageView extends React.Component<IProps, IState> {
             // an empty div here, since the panel uses space-between
             // and we want the same placement of elements
             info = (
-                <div></div>
+                <div />
             );
         }
 
@@ -427,15 +428,15 @@ export default class ImageView extends React.Component<IProps, IState> {
                 <AccessibleTooltipButton
                     className="mx_ImageView_button mx_ImageView_button_zoomOut"
                     title={_t("Zoom out")}
-                    onClick={this.onZoomOutClick}>
-                </AccessibleTooltipButton>
+                    onClick={this.onZoomOutClick}
+                />
             );
             zoomInButton = (
                 <AccessibleTooltipButton
                     className="mx_ImageView_button mx_ImageView_button_zoomIn"
                     title={_t("Zoom in")}
-                    onClick={this.onZoomInClick}>
-                </AccessibleTooltipButton>
+                    onClick={this.onZoomInClick}
+                />
             );
         }
 
@@ -457,24 +458,24 @@ export default class ImageView extends React.Component<IProps, IState> {
                         <AccessibleTooltipButton
                             className="mx_ImageView_button mx_ImageView_button_rotateCCW"
                             title={_t("Rotate Left")}
-                            onClick={this.onRotateCounterClockwiseClick}>
-                        </AccessibleTooltipButton>
+                            onClick={this.onRotateCounterClockwiseClick}
+                        />
                         <AccessibleTooltipButton
                             className="mx_ImageView_button mx_ImageView_button_rotateCW"
                             title={_t("Rotate Right")}
-                            onClick={this.onRotateClockwiseClick}>
-                        </AccessibleTooltipButton>
+                            onClick={this.onRotateClockwiseClick}
+                        />
                         <AccessibleTooltipButton
                             className="mx_ImageView_button mx_ImageView_button_download"
                             title={_t("Download")}
-                            onClick={this.onDownloadClick}>
-                        </AccessibleTooltipButton>
+                            onClick={this.onDownloadClick}
+                        />
                         { contextMenuButton }
                         <AccessibleTooltipButton
                             className="mx_ImageView_button mx_ImageView_button_close"
                             title={_t("Close")}
-                            onClick={this.props.onFinished}>
-                        </AccessibleTooltipButton>
+                            onClick={this.props.onFinished}
+                        />
                         { this.renderContextMenu() }
                     </div>
                 </div>
diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx
index 47bcd845ba..22ff4bf4b3 100644
--- a/src/components/views/elements/MiniAvatarUploader.tsx
+++ b/src/components/views/elements/MiniAvatarUploader.tsx
@@ -92,7 +92,7 @@ const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAva
             <div className="mx_MiniAvatarUploader_indicator">
                 { busy ?
                     <Spinner w={20} h={20} /> :
-                    <div className="mx_MiniAvatarUploader_cameraIcon"></div> }
+                    <div className="mx_MiniAvatarUploader_cameraIcon" /> }
             </div>
 
             <div className={classNames("mx_Tooltip", {
diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js
index ba166ccfc6..aba1d443a9 100644
--- a/src/components/views/elements/Pill.js
+++ b/src/components/views/elements/Pill.js
@@ -258,7 +258,10 @@ class Pill extends React.Component {
                     linkText = groupId;
                     if (this.props.shouldShowPillAvatar) {
                         avatar = <BaseAvatar
-                            name={name || groupId} width={16} height={16} aria-hidden="true"
+                            name={name || groupId}
+                            width={16}
+                            height={16}
+                            aria-hidden="true"
                             url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(16) : null} />;
                     }
                     pillClass = 'mx_GroupPill';
diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js
index 016e7ddea5..42386ca5c1 100644
--- a/src/components/views/elements/PowerSelector.js
+++ b/src/components/views/elements/PowerSelector.js
@@ -134,8 +134,10 @@ export default class PowerSelector extends React.Component {
         const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label;
         if (this.state.custom) {
             picker = (
-                <Field type="number"
-                    label={label} max={this.props.maxValue}
+                <Field
+                    type="number"
+                    label={label}
+                    max={this.props.maxValue}
                     onBlur={this.onCustomBlur}
                     onKeyDown={this.onCustomKeyDown}
                     onChange={this.onCustomChange}
@@ -157,9 +159,12 @@ export default class PowerSelector extends React.Component {
             });
 
             picker = (
-                <Field element="select"
-                    label={label} onChange={this.onSelectChange}
-                    value={String(this.state.selectValue)} disabled={this.props.disabled}
+                <Field
+                    element="select"
+                    label={label}
+                    onChange={this.onSelectChange}
+                    value={String(this.state.selectValue)}
+                    disabled={this.props.disabled}
                 >
                     { options }
                 </Field>
diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx
index e64819f441..c335684c05 100644
--- a/src/components/views/elements/Tooltip.tsx
+++ b/src/components/views/elements/Tooltip.tsx
@@ -166,8 +166,7 @@ export default class Tooltip extends React.Component<IProps> {
     public render() {
         // Render a placeholder
         return (
-            <div className={this.props.className}>
-            </div>
+            <div className={this.props.className} />
         );
     }
 }
diff --git a/src/components/views/emojipicker/Category.tsx b/src/components/views/emojipicker/Category.tsx
index 244de96243..395ff1cbc8 100644
--- a/src/components/views/emojipicker/Category.tsx
+++ b/src/components/views/emojipicker/Category.tsx
@@ -101,14 +101,16 @@ class Category extends React.PureComponent<IProps> {
                     { name }
                 </h2>
                 <LazyRenderList
-                    element="ul" className="mx_EmojiPicker_list"
-                    itemHeight={EMOJI_HEIGHT} items={rows}
+                    element="ul"
+                    className="mx_EmojiPicker_list"
+                    itemHeight={EMOJI_HEIGHT}
+                    items={rows}
                     scrollTop={localScrollTop}
                     height={localHeight}
                     overflowItems={OVERFLOW_ROWS}
                     overflowMargin={0}
-                    renderItem={this.renderEmojiRow}>
-                </LazyRenderList>
+                    renderItem={this.renderEmojiRow}
+                />
             </section>
         );
     }
diff --git a/src/components/views/groups/GroupMemberList.js b/src/components/views/groups/GroupMemberList.js
index f6b01d7d64..3204f82e82 100644
--- a/src/components/views/groups/GroupMemberList.js
+++ b/src/components/views/groups/GroupMemberList.js
@@ -86,10 +86,16 @@ export default class GroupMemberList extends React.Component {
         const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
         const text = _t("and %(count)s others...", { count: overflowCount });
         return (
-            <EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
-                <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
-            } name={text} presenceState="online" suppressOnHover={true}
-            onClick={this._showFullMemberList} />
+            <EntityTile
+                className="mx_EntityTile_ellipsis"
+                avatarJsx={
+                    <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
+                }
+                name={text}
+                presenceState="online"
+                suppressOnHover={true}
+                onClick={this._showFullMemberList}
+            />
         );
     };
 
@@ -152,7 +158,9 @@ export default class GroupMemberList extends React.Component {
             );
         });
 
-        return <TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAt}
+        return <TruncatedList
+            className="mx_MemberList_wrapper"
+            truncateAt={this.state.truncateAt}
             createOverflowElement={this._createOverflowTile}
         >
             { memberTiles }
diff --git a/src/components/views/groups/GroupMemberTile.js b/src/components/views/groups/GroupMemberTile.js
index 70a63bdba5..301f57be23 100644
--- a/src/components/views/groups/GroupMemberTile.js
+++ b/src/components/views/groups/GroupMemberTile.js
@@ -56,14 +56,19 @@ export default class GroupMemberTile extends React.Component {
                 aria-hidden="true"
                 name={this.props.member.displayname || this.props.member.userId}
                 idName={this.props.member.userId}
-                width={36} height={36}
+                width={36}
+                height={36}
                 url={avatarUrl}
             />
         );
 
         return (
-            <EntityTile name={name} avatarJsx={av} onClick={this.onClick}
-                suppressOnHover={true} presenceState="online"
+            <EntityTile
+                name={name}
+                avatarJsx={av}
+                onClick={this.onClick}
+                suppressOnHover={true}
+                presenceState="online"
                 powerStatus={this.props.member.isPrivileged ? EntityTile.POWER_STATUS_ADMIN : null}
             />
         );
diff --git a/src/components/views/groups/GroupRoomList.js b/src/components/views/groups/GroupRoomList.js
index 00220441e7..e3dbdddb4f 100644
--- a/src/components/views/groups/GroupRoomList.js
+++ b/src/components/views/groups/GroupRoomList.js
@@ -76,10 +76,16 @@ export default class GroupRoomList extends React.Component {
         const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
         const text = _t("and %(count)s others...", { count: overflowCount });
         return (
-            <EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
-                <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
-            } name={text} presenceState="online" suppressOnHover={true}
-            onClick={this._showFullRoomList} />
+            <EntityTile
+                className="mx_EntityTile_ellipsis"
+                avatarJsx={
+                    <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
+                }
+                name={text}
+                presenceState="online"
+                suppressOnHover={true}
+                onClick={this._showFullRoomList}
+            />
         );
     };
 
@@ -142,7 +148,8 @@ export default class GroupRoomList extends React.Component {
         }
         const inputBox = (
             <input
-                className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query"
+                className="mx_GroupRoomList_query mx_textinput"
+                id="mx_GroupRoomList_query"
                 type="text"
                 onChange={this.onSearchQueryChanged}
                 value={this.state.searchQuery}
@@ -156,8 +163,11 @@ export default class GroupRoomList extends React.Component {
             <div className="mx_GroupRoomList" role="tabpanel">
                 { inviteButton }
                 <AutoHideScrollbar className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
-                    <TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
-                        createOverflowElement={this._createOverflowTile}>
+                    <TruncatedList
+                        className="mx_GroupRoomList_wrapper"
+                        truncateAt={this.state.truncateAt}
+                        createOverflowElement={this._createOverflowTile}
+                    >
                         { this.makeGroupRoomTiles(this.state.searchQuery) }
                     </TruncatedList>
                 </AutoHideScrollbar>
diff --git a/src/components/views/groups/GroupRoomTile.js b/src/components/views/groups/GroupRoomTile.js
index 662359669d..7bbfaa93a8 100644
--- a/src/components/views/groups/GroupRoomTile.js
+++ b/src/components/views/groups/GroupRoomTile.js
@@ -48,8 +48,10 @@ class GroupRoomTile extends React.Component {
             : null;
 
         const av = (
-            <BaseAvatar name={this.props.groupRoom.displayname}
-                width={36} height={36}
+            <BaseAvatar
+                name={this.props.groupRoom.displayname}
+                width={36}
+                height={36}
                 url={avatarUrl}
             />
         );
diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx
index c0be3b46bb..c81055bfb7 100644
--- a/src/components/views/messages/CallEvent.tsx
+++ b/src/components/views/messages/CallEvent.tsx
@@ -206,7 +206,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
                             { sender }
                         </div>
                         <div className="mx_CallEvent_type">
-                            <div className="mx_CallEvent_type_icon"></div>
+                            <div className="mx_CallEvent_type_icon" />
                             { callType }
                         </div>
                     </div>
diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx
index 44c15d50e7..a447e3fec7 100644
--- a/src/components/views/messages/MImageBody.tsx
+++ b/src/components/views/messages/MImageBody.tsx
@@ -306,7 +306,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
                     imageElement = <HiddenImagePlaceholder />;
                 } else {
                     imageElement = (
-                        <img style={{ display: 'none' }} src={thumbUrl} ref={this.image}
+                        <img
+                            style={{ display: 'none' }}
+                            src={thumbUrl}
+                            ref={this.image}
                             alt={content.body}
                             onError={this.onImageError}
                             onLoad={this.onImageLoad}
@@ -340,7 +343,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
             // which has the same width as the timeline
             // mx_MImageBody_thumbnail resizes img to exactly container size
             img = (
-                <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this.image}
+                <img
+                    className="mx_MImageBody_thumbnail"
+                    src={thumbUrl}
+                    ref={this.image}
                     style={{ maxWidth: maxWidth + "px" }}
                     alt={content.body}
                     onError={this.onImageError}
@@ -360,12 +366,15 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
         }
 
         const thumbnail = (
-            <div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }} >
+            <div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }}>
                 { showPlaceholder &&
-                    <div className="mx_MImageBody_thumbnail" style={{
-                        // Constrain width here so that spinner appears central to the loaded thumbnail
-                        maxWidth: infoWidth + "px",
-                    }}>
+                    <div
+                        className="mx_MImageBody_thumbnail"
+                        style={{
+                            // Constrain width here so that spinner appears central to the loaded thumbnail
+                            maxWidth: infoWidth + "px",
+                        }}
+                    >
                         { placeholder }
                     </div>
                 }
diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx
index 6121a23752..77c7ebacda 100644
--- a/src/components/views/messages/MVideoBody.tsx
+++ b/src/components/views/messages/MVideoBody.tsx
@@ -267,8 +267,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
                     width={width}
                     poster={poster}
                     onPlay={this.videoOnPlay}
-                >
-                </video>
+                />
                 { this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
             </span>
         );
diff --git a/src/components/views/messages/RoomAvatarEvent.js b/src/components/views/messages/RoomAvatarEvent.js
index d68d794ee6..9832332311 100644
--- a/src/components/views/messages/RoomAvatarEvent.js
+++ b/src/components/views/messages/RoomAvatarEvent.js
@@ -78,8 +78,11 @@ export default class RoomAvatarEvent extends React.Component {
                     { senderDisplayName: senderDisplayName },
                     {
                         'img': () =>
-                            <AccessibleButton key="avatar" className="mx_RoomAvatarEvent_avatar"
-                                onClick={this.onAvatarClick}>
+                            <AccessibleButton
+                                key="avatar"
+                                className="mx_RoomAvatarEvent_avatar"
+                                onClick={this.onAvatarClick}
+                            >
                                 <RoomAvatar width={14} height={14} oobData={oobData} />
                             </AccessibleButton>,
                     })
diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx
index 5e2b327a5f..c837e814c8 100644
--- a/src/components/views/right_panel/UserInfo.tsx
+++ b/src/components/views/right_panel/UserInfo.tsx
@@ -205,7 +205,7 @@ function DeviceItem({ userId, device }: {userId: string, device: IDevice}) {
 
     if (isVerified) {
         return (
-            <div className={classes} title={device.deviceId} >
+            <div className={classes} title={device.deviceId}>
                 <div className={iconClasses} />
                 <div className="mx_UserInfo_device_name">{ deviceName }</div>
                 <div className="mx_UserInfo_device_trusted">{ trustedLabel }</div>
@@ -1353,13 +1353,16 @@ const BasicUserInfo: React.FC<{
         if (hasCrossSigningKeys !== undefined) {
             // Note: mx_UserInfo_verifyButton is for the end-to-end tests
             verifyButton = (
-                <AccessibleButton className="mx_UserInfo_field mx_UserInfo_verifyButton" onClick={() => {
-                    if (hasCrossSigningKeys) {
-                        verifyUser(member as User);
-                    } else {
-                        legacyVerifyUser(member as User);
-                    }
-                }}>
+                <AccessibleButton
+                    className="mx_UserInfo_field mx_UserInfo_verifyButton"
+                    onClick={() => {
+                        if (hasCrossSigningKeys) {
+                            verifyUser(member as User);
+                        } else {
+                            legacyVerifyUser(member as User);
+                        }
+                    }}
+                >
                     { _t("Verify") }
                 </AccessibleButton>
             );
@@ -1374,12 +1377,15 @@ const BasicUserInfo: React.FC<{
     let editDevices;
     if (member.userId == cli.getUserId()) {
         editDevices = (<p>
-            <AccessibleButton className="mx_UserInfo_field" onClick={() => {
-                dis.dispatch({
+            <AccessibleButton
+                className="mx_UserInfo_field"
+                onClick={() => {
+                    dis.dispatch({
                     action: Action.ViewUserSettings,
                     initialTabId: UserTab.Security,
-                });
-            }}>
+                    });
+                }}
+            >
                 { _t("Edit devices") }
             </AccessibleButton>
         </p>);
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index 6861ea7af5..f69308dc86 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -711,9 +711,12 @@ export default class EventTile extends React.Component<IProps, IState> {
 
             // add to the start so the most recent is on the end (ie. ends up rightmost)
             avatars.unshift(
-                <ReadReceiptMarker key={userId} member={receipt.roomMember}
+                <ReadReceiptMarker
+                    key={userId}
+                    member={receipt.roomMember}
                     fallbackUserId={userId}
-                    leftOffset={left} hidden={hidden}
+                    leftOffset={left}
+                    hidden={hidden}
                     readReceiptInfo={readReceiptInfo}
                     checkUnmounting={this.props.checkUnmounting}
                     suppressAnimation={this.suppressReadReceiptAnimation}
@@ -949,8 +952,10 @@ export default class EventTile extends React.Component<IProps, IState> {
             }
             avatar = (
                 <div className="mx_EventTile_avatar">
-                    <MemberAvatar member={member}
-                        width={avatarSize} height={avatarSize}
+                    <MemberAvatar
+                        member={member}
+                        width={avatarSize}
+                        height={avatarSize}
                         viewUserOnClick={true}
                     />
                 </div>
diff --git a/src/components/views/rooms/JumpToBottomButton.js b/src/components/views/rooms/JumpToBottomButton.js
index 15872bdeb0..d2e2a391a6 100644
--- a/src/components/views/rooms/JumpToBottomButton.js
+++ b/src/components/views/rooms/JumpToBottomButton.js
@@ -28,10 +28,11 @@ export default (props) => {
         badge = (<div className="mx_JumpToBottomButton_badge">{ props.numUnreadMessages }</div>);
     }
     return (<div className={className}>
-        <AccessibleButton className="mx_JumpToBottomButton_scrollDown"
+        <AccessibleButton
+            className="mx_JumpToBottomButton_scrollDown"
             title={_t("Scroll to most recent messages")}
-            onClick={props.onScrollToBottomClick}>
-        </AccessibleButton>
+            onClick={props.onScrollToBottomClick}
+        />
         { badge }
     </div>);
 };
diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx
index 90a2a03c12..0c90d2ee09 100644
--- a/src/components/views/rooms/MemberList.tsx
+++ b/src/components/views/rooms/MemberList.tsx
@@ -306,10 +306,16 @@ export default class MemberList extends React.Component<IProps, IState> {
         // For now we'll pretend this is any entity. It should probably be a separate tile.
         const text = _t("and %(count)s others...", { count: overflowCount });
         return (
-            <EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
-                <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
-            } name={text} presenceState="online" suppressOnHover={true}
-            onClick={onClick} />
+            <EntityTile
+                className="mx_EntityTile_ellipsis"
+                avatarJsx={
+                    <BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
+                }
+                name={text}
+                presenceState="online"
+                suppressOnHover={true}
+                onClick={onClick}
+            />
         );
     };
 
@@ -465,8 +471,12 @@ export default class MemberList extends React.Component<IProps, IState> {
                 return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this.showPresence} />;
             } else {
                 // Is a 3pid invite
-                return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true}
-                    onClick={() => this.onPending3pidInviteClick(m)} />;
+                return <EntityTile
+                    key={m.getStateKey()}
+                    name={m.getContent().display_name}
+                    suppressOnHover={true}
+                    onClick={() => this.onPending3pidInviteClick(m)}
+                />;
             }
         });
     }
diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx
index b16d22b416..69ebb91a4b 100644
--- a/src/components/views/rooms/MessageComposer.tsx
+++ b/src/components/views/rooms/MessageComposer.tsx
@@ -98,9 +98,7 @@ const EmojiButton = ({ addEmoji }) => {
             isExpanded={menuDisplayed}
             title={_t('Emoji picker')}
             inputRef={button}
-        >
-
-        </ContextMenuTooltipButton>
+        />
 
         { contextMenu }
     </React.Fragment>;
@@ -439,7 +437,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
         if (secondsLeft) {
             recordingTooltip = <Tooltip
                 label={_t("%(seconds)ss left", { seconds: secondsLeft })}
-                alignment={Alignment.Top} yOffset={-50}
+                alignment={Alignment.Top}
+                yOffset={-50}
             />;
         }
 
diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx
index a41ad19b41..cdca9ab75d 100644
--- a/src/components/views/rooms/NewRoomIntro.tsx
+++ b/src/components/views/rooms/NewRoomIntro.tsx
@@ -58,13 +58,18 @@ const NewRoomIntro = () => {
         const member = room?.getMember(dmPartner);
         const displayName = member?.rawDisplayName || dmPartner;
         body = <React.Fragment>
-            <RoomAvatar room={room} width={AVATAR_SIZE} height={AVATAR_SIZE} onClick={() => {
-                defaultDispatcher.dispatch<ViewUserPayload>({
+            <RoomAvatar
+                room={room}
+                width={AVATAR_SIZE}
+                height={AVATAR_SIZE}
+                onClick={() => {
+                    defaultDispatcher.dispatch<ViewUserPayload>({
                     action: Action.ViewUser,
                     // XXX: We should be using a real member object and not assuming what the receiver wants.
                     member: member || { userId: dmPartner } as User,
-                });
-            }} />
+                    });
+                }}
+            />
 
             <h2>{ room.name }</h2>
 
diff --git a/src/components/views/rooms/ReadReceiptMarker.js b/src/components/views/rooms/ReadReceiptMarker.js
index 2ea7ac6428..4ea16f69a3 100644
--- a/src/components/views/rooms/ReadReceiptMarker.js
+++ b/src/components/views/rooms/ReadReceiptMarker.js
@@ -192,7 +192,9 @@ export default class ReadReceiptMarker extends React.PureComponent {
                     member={this.props.member}
                     fallbackUserId={this.props.fallbackUserId}
                     aria-hidden="true"
-                    width={14} height={14} resizeMethod="crop"
+                    width={14}
+                    height={14}
+                    resizeMethod="crop"
                     style={style}
                     title={title}
                     onClick={this.props.onClick}
diff --git a/src/components/views/rooms/RoomBreadcrumbs.tsx b/src/components/views/rooms/RoomBreadcrumbs.tsx
index 2e133b0487..a20c409a53 100644
--- a/src/components/views/rooms/RoomBreadcrumbs.tsx
+++ b/src/components/views/rooms/RoomBreadcrumbs.tsx
@@ -105,7 +105,9 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
             // NOTE: The CSSTransition timeout MUST match the timeout in our CSS!
             return (
                 <CSSTransition
-                    appear={true} in={this.state.doAnimation} timeout={640}
+                    appear={true}
+                    in={this.state.doAnimation}
+                    timeout={640}
                     classNames='mx_RoomBreadcrumbs'
                 >
                     <Toolbar className='mx_RoomBreadcrumbs' aria-label={_t("Recently visited rooms")}>
diff --git a/src/components/views/rooms/RoomDetailRow.js b/src/components/views/rooms/RoomDetailRow.js
index 25fff09c10..f4be44b1af 100644
--- a/src/components/views/rooms/RoomDetailRow.js
+++ b/src/components/views/rooms/RoomDetailRow.js
@@ -105,8 +105,12 @@ export default class RoomDetailRow extends React.Component {
 
         return <tr key={room.roomId} onClick={this.onClick} onMouseDown={this.props.onMouseDown}>
             <td className="mx_RoomDirectory_roomAvatar">
-                <BaseAvatar width={24} height={24} resizeMethod='crop'
-                    name={name} idName={name}
+                <BaseAvatar
+                    width={24}
+                    height={24}
+                    resizeMethod='crop'
+                    name={name}
+                    idName={name}
                     url={avatarUrl} />
             </td>
             <td className="mx_RoomDirectory_roomDescription">
diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx
index 9e7d9ca205..080816df14 100644
--- a/src/components/views/rooms/RoomList.tsx
+++ b/src/components/views/rooms/RoomList.tsx
@@ -428,7 +428,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
                     groupId={g.groupId}
                     groupName={g.name}
                     groupAvatarUrl={g.avatarUrl}
-                    width={32} height={32} resizeMethod='crop'
+                    width={32}
+                    height={32}
+                    resizeMethod='crop'
                 />
             );
             const openGroup = () => {
diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js
index 3cd34b1966..b8a4315e2d 100644
--- a/src/components/views/rooms/RoomPreviewBar.js
+++ b/src/components/views/rooms/RoomPreviewBar.js
@@ -536,8 +536,10 @@ export default class RoomPreviewBar extends React.Component {
                         "If you think you're seeing this message in error, please " +
                         "<issueLink>submit a bug report</issueLink>.",
                         { errcode: this.props.error.errcode },
-                        { issueLink: label => <a href="https://github.com/vector-im/element-web/issues/new/choose"
-                            target="_blank" rel="noreferrer noopener">{ label }</a> },
+                        { issueLink: label => <a
+                            href="https://github.com/vector-im/element-web/issues/new/choose"
+                            target="_blank"
+                            rel="noreferrer noopener">{ label }</a> },
                     ),
                 ];
                 break;
diff --git a/src/components/views/rooms/SimpleRoomHeader.js b/src/components/views/rooms/SimpleRoomHeader.js
index 768a456b35..a2b5566e39 100644
--- a/src/components/views/rooms/SimpleRoomHeader.js
+++ b/src/components/views/rooms/SimpleRoomHeader.js
@@ -35,13 +35,15 @@ export default class SimpleRoomHeader extends React.Component {
         let icon;
         if (this.props.icon) {
             icon = <img
-                className="mx_RoomHeader_icon" src={this.props.icon}
-                width="25" height="25"
+                className="mx_RoomHeader_icon"
+                src={this.props.icon}
+                width="25"
+                height="25"
             />;
         }
 
         return (
-            <div className="mx_RoomHeader mx_RoomHeader_wrapper" >
+            <div className="mx_RoomHeader mx_RoomHeader_wrapper">
                 <div className="mx_RoomHeader_simpleHeader">
                     { icon }
                     { this.props.title }
diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js
index c0e6826ba5..6649948331 100644
--- a/src/components/views/rooms/Stickerpicker.js
+++ b/src/components/views/rooms/Stickerpicker.js
@@ -403,8 +403,7 @@ export default class Stickerpicker extends React.PureComponent {
                     onClick={this._onHideStickersClick}
                     active={this.state.showStickers.toString()}
                     title={_t("Hide Stickers")}
-                >
-                </AccessibleButton>;
+                />;
 
             const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
             stickerPicker = <ContextMenu
@@ -431,8 +430,7 @@ export default class Stickerpicker extends React.PureComponent {
                     className="mx_MessageComposer_button mx_MessageComposer_stickers"
                     onClick={this._onShowStickersClick}
                     title={_t("Show Stickers")}
-                >
-                </AccessibleTooltipButton>;
+                />;
         }
         return <React.Fragment>
             { stickersButton }
diff --git a/src/components/views/rooms/TopUnreadMessagesBar.js b/src/components/views/rooms/TopUnreadMessagesBar.js
index 0f632e7128..d2a3e3a303 100644
--- a/src/components/views/rooms/TopUnreadMessagesBar.js
+++ b/src/components/views/rooms/TopUnreadMessagesBar.js
@@ -32,14 +32,16 @@ export default class TopUnreadMessagesBar extends React.Component {
     render() {
         return (
             <div className="mx_TopUnreadMessagesBar">
-                <AccessibleButton className="mx_TopUnreadMessagesBar_scrollUp"
+                <AccessibleButton
+                    className="mx_TopUnreadMessagesBar_scrollUp"
                     title={_t('Jump to first unread message.')}
-                    onClick={this.props.onScrollUpClick}>
-                </AccessibleButton>
-                <AccessibleButton className="mx_TopUnreadMessagesBar_markAsRead"
+                    onClick={this.props.onScrollUpClick}
+                />
+                <AccessibleButton
+                    className="mx_TopUnreadMessagesBar_markAsRead"
                     title={_t('Mark all as read')}
-                    onClick={this.props.onCloseClick}>
-                </AccessibleButton>
+                    onClick={this.props.onCloseClick}
+                />
             </div>
         );
     }
diff --git a/src/components/views/settings/BridgeTile.tsx b/src/components/views/settings/BridgeTile.tsx
index 7228e4b939..5dd5ed9ba1 100644
--- a/src/components/views/settings/BridgeTile.tsx
+++ b/src/components/views/settings/BridgeTile.tsx
@@ -124,7 +124,7 @@ export default class BridgeTile extends React.PureComponent<IProps> {
                 url={avatarUrl}
             />;
         } else {
-            networkIcon = <div className="noProtocolIcon"></div>;
+            networkIcon = <div className="noProtocolIcon" />;
         }
         let networkItem = null;
         if (network) {
diff --git a/src/components/views/settings/ChangeAvatar.js b/src/components/views/settings/ChangeAvatar.js
index 36d5d4aa0c..c3a1544cdc 100644
--- a/src/components/views/settings/ChangeAvatar.js
+++ b/src/components/views/settings/ChangeAvatar.js
@@ -148,13 +148,22 @@ export default class ChangeAvatar extends React.Component {
         if (this.props.room && !this.avatarSet) {
             const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
             avatarImg = <RoomAvatar
-                room={this.props.room} width={this.props.width} height={this.props.height} resizeMethod='crop'
+                room={this.props.room}
+                width={this.props.width}
+                height={this.props.height}
+                resizeMethod='crop'
             />;
         } else {
             const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
             // XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ?
-            avatarImg = <BaseAvatar width={this.props.width} height={this.props.height} resizeMethod='crop'
-                name='?' idName={MatrixClientPeg.get().getUserIdLocalpart()} url={this.state.avatarUrl} />;
+            avatarImg = <BaseAvatar
+                width={this.props.width}
+                height={this.props.height}
+                resizeMethod='crop'
+                name='?'
+                idName={MatrixClientPeg.get().getUserIdLocalpart()}
+                url={this.state.avatarUrl}
+            />;
         }
 
         let uploadSection;
diff --git a/src/components/views/settings/EventIndexPanel.tsx b/src/components/views/settings/EventIndexPanel.tsx
index de49c2a980..9966e38de8 100644
--- a/src/components/views/settings/EventIndexPanel.tsx
+++ b/src/components/views/settings/EventIndexPanel.tsx
@@ -178,8 +178,11 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
                         "appear in search results.",
                     ) }</div>
                     <div>
-                        <AccessibleButton kind="primary" disabled={this.state.enabling}
-                            onClick={this.onEnable}>
+                        <AccessibleButton
+                            kind="primary"
+                            disabled={this.state.enabling}
+                            onClick={this.onEnable}
+                        >
                             { _t("Enable") }
                         </AccessibleButton>
                         { this.state.enabling ? <InlineSpinner /> : <div /> }
@@ -203,8 +206,10 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
                         brand,
                     },
                     {
-                        nativeLink: sub => <a href={nativeLink}
-                            target="_blank" rel="noreferrer noopener"
+                        nativeLink: sub => <a
+                            href={nativeLink}
+                            target="_blank"
+                            rel="noreferrer noopener"
                         >{ sub }</a>,
                     },
                 ) }</div>
@@ -219,8 +224,10 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
                         brand,
                     },
                     {
-                        desktopLink: sub => <a href="https://element.io/get-started"
-                            target="_blank" rel="noreferrer noopener"
+                        desktopLink: sub => <a
+                            href="https://element.io/get-started"
+                            target="_blank"
+                            rel="noreferrer noopener"
                         >{ sub }</a>,
                     },
                 ) }</div>
diff --git a/src/components/views/settings/ProfileSettings.js b/src/components/views/settings/ProfileSettings.js
index 02eaaaeea8..d05fca983c 100644
--- a/src/components/views/settings/ProfileSettings.js
+++ b/src/components/views/settings/ProfileSettings.js
@@ -172,7 +172,8 @@ export default class ProfileSettings extends React.Component {
             >
                 <input
                     type="file"
-                    ref={this._avatarUpload} className="mx_ProfileSettings_avatarUpload"
+                    ref={this._avatarUpload}
+                    className="mx_ProfileSettings_avatarUpload"
                     onChange={this._onAvatarChanged}
                     accept="image/*"
                 />
@@ -181,7 +182,8 @@ export default class ProfileSettings extends React.Component {
                         <span className="mx_SettingsTab_subheading">{ _t("Profile") }</span>
                         <Field
                             label={_t("Display Name")}
-                            type="text" value={this.state.displayName}
+                            type="text"
+                            value={this.state.displayName}
                             autoComplete="off"
                             onChange={this._onDisplayNameChanged}
                         />
diff --git a/src/components/views/settings/SetIdServer.tsx b/src/components/views/settings/SetIdServer.tsx
index fd8abc0dbe..1f488f1e67 100644
--- a/src/components/views/settings/SetIdServer.tsx
+++ b/src/components/views/settings/SetIdServer.tsx
@@ -426,7 +426,9 @@ export default class SetIdServer extends React.Component<IProps, IState> {
                     disabled={this.state.busy}
                     forceValidity={this.state.error ? false : null}
                 />
-                <AccessibleButton type="submit" kind="primary_sm"
+                <AccessibleButton
+                    type="submit"
+                    kind="primary_sm"
                     onClick={this.checkIdServer}
                     disabled={!this.idServerChangeEnabled()}
                 >{ _t("Change") }</AccessibleButton>
diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.js b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.js
index e2f30192b9..b90fb310e0 100644
--- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.js
+++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.js
@@ -97,9 +97,12 @@ export default class GeneralRoomSettingsTab extends React.Component {
 
                 <div className="mx_SettingsTab_heading">{ _t("Room Addresses") }</div>
                 <div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
-                    <AliasSettings roomId={this.props.roomId}
-                        canSetCanonicalAlias={canSetCanonical} canSetAliases={canSetAliases}
-                        canonicalAliasEvent={canonicalAliasEv} />
+                    <AliasSettings
+                        roomId={this.props.roomId}
+                        canSetCanonicalAlias={canSetCanonical}
+                        canSetAliases={canSetAliases}
+                        canonicalAliasEvent={canonicalAliasEv}
+                    />
                 </div>
                 <div className="mx_SettingsTab_heading">{ _t("Other") }</div>
                 { flairSection }
diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx
index edc0220921..9225bc6b94 100644
--- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx
@@ -346,8 +346,11 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
                             let bannedBy = member.events.member.getSender(); // start by falling back to mxid
                             if (sender) bannedBy = sender.name;
                             return (
-                                <BannedUser key={member.userId} canUnban={canBanUsers}
-                                    member={member} reason={banEvent.reason}
+                                <BannedUser
+                                    key={member.userId}
+                                    canUnban={canBanUsers}
+                                    member={member}
+                                    reason={banEvent.reason}
                                     by={bannedBy}
                                 />
                             );
diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
index 88bc2046ce..99ae2e52ab 100644
--- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx
@@ -133,8 +133,10 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
                 "may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
                 {},
                 {
-                    a: sub => <a href="https://element.io/help#encryption"
-                        rel="noreferrer noopener" target="_blank"
+                    a: sub => <a
+                        href="https://element.io/help#encryption"
+                        rel="noreferrer noopener"
+                        target="_blank"
                     >{ sub }</a>,
                 },
             ),
@@ -424,8 +426,11 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
                         <div className='mx_SettingsTab_subsectionText'>
                             <span>{ _t("Once enabled, encryption cannot be disabled.") }</span>
                         </div>
-                        <LabelledToggleSwitch value={isEncrypted} onChange={this.onEncryptionChange}
-                            label={_t("Encrypted")} disabled={!canEnableEncryption}
+                        <LabelledToggleSwitch
+                            value={isEncrypted}
+                            onChange={this.onEncryptionChange}
+                            label={_t("Encrypted")}
+                            disabled={!canEnableEncryption}
                         />
                     </div>
                     { encryptionSettings }
diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
index d1c497b351..44873816dc 100644
--- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
@@ -303,9 +303,12 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
                         />
                         <AccessibleButton
                             onClick={this.onAddCustomTheme}
-                            type="submit" kind="primary_sm"
+                            type="submit"
+                            kind="primary_sm"
                             disabled={!this.state.customThemeUrl.trim()}
-                        >{ _t("Add theme") }</AccessibleButton>
+                        >
+                            { _t("Add theme") }
+                        </AccessibleButton>
                         { messageElement }
                     </form>
                 </div>
diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js
index 2a6e8937a3..238d6cca21 100644
--- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js
@@ -426,9 +426,13 @@ export default class GeneralUserSettingsTab extends React.Component {
         const supportsMultiLanguageSpellCheck = plaf.supportsMultiLanguageSpellCheck();
 
         const discoWarning = this.state.requiredPolicyInfo.hasTerms
-            ? <img className='mx_GeneralUserSettingsTab_warningIcon'
+            ? <img
+                className='mx_GeneralUserSettingsTab_warningIcon'
                 src={require("../../../../../../res/img/feather-customised/warning-triangle.svg")}
-                width="18" height="18" alt={_t("Warning")} />
+                width="18"
+                height="18"
+                alt={_t("Warning")}
+            />
             : null;
 
         let accountManagementSection;
diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
index 33de634611..eaf52e6062 100644
--- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
@@ -134,28 +134,39 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
                 <span className='mx_SettingsTab_subheading'>{ _t("Credits") }</span>
                 <ul>
                     <li>
-                        The <a href="themes/element/img/backgrounds/lake.jpg" rel="noreferrer noopener"
-                            target="_blank">default cover photo</a> is ©&nbsp;
-                        <a href="https://www.flickr.com/golan" rel="noreferrer noopener"
-                            target="_blank">Jesús Roncero</a> used under the terms of&nbsp;
-                        <a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noreferrer noopener"
-                            target="_blank">CC-BY-SA 4.0</a>.
+                        The <a href="themes/element/img/backgrounds/lake.jpg" rel="noreferrer noopener" target="_blank">
+                            default cover photo
+                        </a> is ©&nbsp;
+                        <a href="https://www.flickr.com/golan" rel="noreferrer noopener" target="_blank">
+                            Jesús Roncero
+                        </a> used under the terms of&nbsp;
+                        <a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noreferrer noopener" target="_blank">
+                            CC-BY-SA 4.0
+                        </a>.
                     </li>
                     <li>
-                        The <a href="https://github.com/matrix-org/twemoji-colr" rel="noreferrer noopener"
-                            target="_blank">twemoji-colr</a> font is ©&nbsp;
-                        <a href="https://mozilla.org" rel="noreferrer noopener"
-                            target="_blank">Mozilla Foundation</a> used under the terms of&nbsp;
-                        <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener"
-                            target="_blank">Apache 2.0</a>.
+                        The <a
+                            href="https://github.com/matrix-org/twemoji-colr"
+                            rel="noreferrer noopener"
+                            target="_blank"
+                        >
+                            twemoji-colr
+                        </a> font is ©&nbsp;
+                        <a href="https://mozilla.org" rel="noreferrer noopener" target="_blank">
+                            Mozilla Foundation
+                        </a> used under the terms of&nbsp;
+                        <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank">Apache 2.0</a>.
                     </li>
                     <li>
-                        The <a href="https://twemoji.twitter.com/" rel="noreferrer noopener"
-                            target="_blank">Twemoji</a> emoji art is ©&nbsp;
-                        <a href="https://twemoji.twitter.com/" rel="noreferrer noopener"
-                            target="_blank">Twitter, Inc and other contributors</a> used under the terms of&nbsp;
-                        <a href="https://creativecommons.org/licenses/by/4.0/" rel="noreferrer noopener"
-                            target="_blank">CC-BY 4.0</a>.
+                        The <a href="https://twemoji.twitter.com/" rel="noreferrer noopener" target="_blank">
+                            Twemoji
+                        </a> emoji art is ©&nbsp;
+                        <a href="https://twemoji.twitter.com/" rel="noreferrer noopener" target="_blank">
+                            Twitter, Inc and other contributors
+                        </a> used under the terms of&nbsp;
+                        <a href="https://creativecommons.org/licenses/by/4.0/" rel="noreferrer noopener" target="_blank">
+                            CC-BY 4.0
+                        </a>.
                     </li>
                 </ul>
             </div>
@@ -254,7 +265,8 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
                             "<a>Security Disclosure Policy</a>.", {},
                             {
                                 a: sub => <a href="https://matrix.org/security-disclosure-policy/"
-                                    rel="noreferrer noopener" target="_blank"
+                                    rel="noreferrer noopener"
+                                    target="_blank"
                                 >{ sub }</a>,
                             },
                         ) }
diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
index aace4ca557..fa854fc4d8 100644
--- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
@@ -86,8 +86,11 @@ export default class LabsUserSettingsTab extends React.Component {
                             'test out new features and help shape them before they actually launch. ' +
                             '<a>Learn more</a>.', {}, {
                             'a': (sub) => {
-                                return <a href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md"
-                                    rel='noreferrer noopener' target='_blank'>{ sub }</a>;
+                                return <a
+                                    href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md"
+                                    rel='noreferrer noopener'
+                                    target='_blank'
+                                >{ sub }</a>;
                             },
                         })
                     }
diff --git a/src/components/views/spaces/SpaceBasicSettings.tsx b/src/components/views/spaces/SpaceBasicSettings.tsx
index 6d2cc1f5db..9d3696c5a9 100644
--- a/src/components/views/spaces/SpaceBasicSettings.tsx
+++ b/src/components/views/spaces/SpaceBasicSettings.tsx
@@ -57,11 +57,15 @@ export const SpaceAvatar = ({
                     src={avatar}
                     alt=""
                 />
-                <AccessibleButton onClick={() => {
-                    avatarUploadRef.current.value = "";
-                    setAvatarDataUrl(undefined);
-                    setAvatar(undefined);
-                }} kind="link" className="mx_SpaceBasicSettings_avatar_remove">
+                <AccessibleButton
+                    onClick={() => {
+                        avatarUploadRef.current.value = "";
+                        setAvatarDataUrl(undefined);
+                        setAvatar(undefined);
+                    }}
+                    kind="link"
+                    className="mx_SpaceBasicSettings_avatar_remove"
+                >
                     { _t("Delete") }
                 </AccessibleButton>
             </React.Fragment>;
@@ -77,16 +81,21 @@ export const SpaceAvatar = ({
 
     return <div className="mx_SpaceBasicSettings_avatarContainer">
         { avatarSection }
-        <input type="file" ref={avatarUploadRef} onChange={(e) => {
-            if (!e.target.files?.length) return;
-            const file = e.target.files[0];
-            setAvatar(file);
-            const reader = new FileReader();
-            reader.onload = (ev) => {
-                setAvatarDataUrl(ev.target.result as string);
-            };
-            reader.readAsDataURL(file);
-        }} accept="image/*" />
+        <input
+            type="file"
+            ref={avatarUploadRef}
+            onChange={(e) => {
+                if (!e.target.files?.length) return;
+                const file = e.target.files[0];
+                setAvatar(file);
+                const reader = new FileReader();
+                reader.onload = (ev) => {
+                    setAvatarDataUrl(ev.target.result as string);
+                };
+                reader.readAsDataURL(file);
+            }}
+            accept="image/*"
+        />
     </div>;
 };
 
diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx
index 8bdd6e0f55..e53c2f4823 100644
--- a/src/components/views/voip/CallView.tsx
+++ b/src/components/views/voip/CallView.tsx
@@ -665,15 +665,19 @@ export default class CallView extends React.Component<IProps, IState> {
 
         let fullScreenButton;
         if (this.props.call.type === CallType.Video && !this.props.pipMode) {
-            fullScreenButton = <div className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
-                onClick={this.onFullscreenClick} title={_t("Fill Screen")}
+            fullScreenButton = <div
+                className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
+                onClick={this.onFullscreenClick}
+                title={_t("Fill Screen")}
             />;
         }
 
         let expandButton;
         if (this.props.pipMode) {
-            expandButton = <div className="mx_CallView_header_button mx_CallView_header_button_expand"
-                onClick={this.onExpandClick} title={_t("Return to call")}
+            expandButton = <div
+                className="mx_CallView_header_button mx_CallView_header_button_expand"
+                onClick={this.onExpandClick}
+                title={_t("Return to call")}
             />;
         }
 
@@ -685,7 +689,7 @@ export default class CallView extends React.Component<IProps, IState> {
         let header: React.ReactNode;
         if (!this.props.pipMode) {
             header = <div className="mx_CallView_header">
-                <div className="mx_CallView_header_phoneIcon"></div>
+                <div className="mx_CallView_header_phoneIcon" />
                 <span className="mx_CallView_header_callType">{ callTypeText }</span>
                 { headerControls }
             </div>;
diff --git a/src/components/views/voip/DialPad.tsx b/src/components/views/voip/DialPad.tsx
index 2af8bd6989..3b4a29b3f9 100644
--- a/src/components/views/voip/DialPad.tsx
+++ b/src/components/views/voip/DialPad.tsx
@@ -68,13 +68,19 @@ export default class Dialpad extends React.PureComponent<IProps> {
         for (let i = 0; i < BUTTONS.length; i++) {
             const button = BUTTONS[i];
             const digitSubtext = BUTTON_LETTERS[i];
-            buttonNodes.push(<DialPadButton key={button} kind={DialPadButtonKind.Digit}
-                digit={button} digitSubtext={digitSubtext} onButtonPress={this.props.onDigitPress}
+            buttonNodes.push(<DialPadButton
+                key={button}
+                kind={DialPadButtonKind.Digit}
+                digit={button}
+                digitSubtext={digitSubtext}
+                onButtonPress={this.props.onDigitPress}
             />);
         }
 
         if (this.props.hasDial) {
-            buttonNodes.push(<DialPadButton key="dial" kind={DialPadButtonKind.Dial}
+            buttonNodes.push(<DialPadButton
+                key="dial"
+                kind={DialPadButtonKind.Dial}
                 onButtonPress={this.props.onDialPress}
             />);
         }
diff --git a/src/components/views/voip/DialPadModal.tsx b/src/components/views/voip/DialPadModal.tsx
index 0bba65e44f..a36fc37dff 100644
--- a/src/components/views/voip/DialPadModal.tsx
+++ b/src/components/views/voip/DialPadModal.tsx
@@ -81,14 +81,18 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
         // Only show the backspace button if the field has content
         let dialPadField;
         if (this.state.value.length !== 0) {
-            dialPadField = <Field className="mx_DialPadModal_field" id="dialpad_number"
+            dialPadField = <Field
+                className="mx_DialPadModal_field"
+                id="dialpad_number"
                 value={this.state.value}
                 autoFocus={true}
                 onChange={this.onChange}
                 postfixComponent={backspaceButton}
             />;
         } else {
-            dialPadField = <Field className="mx_DialPadModal_field" id="dialpad_number"
+            dialPadField = <Field
+                className="mx_DialPadModal_field"
+                id="dialpad_number"
                 value={this.state.value}
                 autoFocus={true}
                 onChange={this.onChange}
diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx
index e5461eb1b4..7d9ae190c8 100644
--- a/src/components/views/voip/VideoFeed.tsx
+++ b/src/components/views/voip/VideoFeed.tsx
@@ -136,7 +136,7 @@ export default class VideoFeed extends React.Component<IProps, IState> {
             const avatarSize = this.props.pipMode ? 76 : 160;
 
             return (
-                <div className={classnames(videoClasses)} >
+                <div className={classnames(videoClasses)}>
                     <MemberAvatar
                         member={member}
                         height={avatarSize}
diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js
index f415b85105..dea5bcefb7 100644
--- a/test/components/structures/MessagePanel-test.js
+++ b/test/components/structures/MessagePanel-test.js
@@ -312,8 +312,12 @@ describe('MessagePanel', function() {
 
     it('should insert the read-marker in the right place', function() {
         const res = TestUtils.renderIntoDocument(
-            <WrappedMessagePanel className="cls" events={events} readMarkerEventId={events[4].getId()}
-                readMarkerVisible={true} />,
+            <WrappedMessagePanel
+                className="cls"
+                events={events}
+                readMarkerEventId={events[4].getId()}
+                readMarkerVisible={true}
+            />,
         );
 
         const tiles = TestUtils.scryRenderedComponentsWithType(
@@ -330,8 +334,12 @@ describe('MessagePanel', function() {
     it('should show the read-marker that fall in summarised events after the summary', function() {
         const melsEvents = mkMelsEvents();
         const res = TestUtils.renderIntoDocument(
-            <WrappedMessagePanel className="cls" events={melsEvents} readMarkerEventId={melsEvents[4].getId()}
-                readMarkerVisible={true} />,
+            <WrappedMessagePanel
+                className="cls"
+                events={melsEvents}
+                readMarkerEventId={melsEvents[4].getId()}
+                readMarkerVisible={true}
+            />,
         );
 
         const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_EventListSummary');
@@ -348,8 +356,12 @@ describe('MessagePanel', function() {
     it('should hide the read-marker at the end of summarised events', function() {
         const melsEvents = mkMelsEventsOnly();
         const res = TestUtils.renderIntoDocument(
-            <WrappedMessagePanel className="cls" events={melsEvents} readMarkerEventId={melsEvents[9].getId()}
-                readMarkerVisible={true} />,
+            <WrappedMessagePanel
+                className="cls"
+                events={melsEvents}
+                readMarkerEventId={melsEvents[9].getId()}
+                readMarkerVisible={true}
+            />,
         );
 
         const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_EventListSummary');
@@ -371,7 +383,10 @@ describe('MessagePanel', function() {
 
         // first render with the RM in one place
         let mp = ReactDOM.render(
-            <WrappedMessagePanel className="cls" events={events} readMarkerEventId={events[4].getId()}
+            <WrappedMessagePanel
+                className="cls"
+                events={events}
+                readMarkerEventId={events[4].getId()}
                 readMarkerVisible={true}
             />, parentDiv);
 
@@ -387,7 +402,10 @@ describe('MessagePanel', function() {
 
         // now move the RM
         mp = ReactDOM.render(
-            <WrappedMessagePanel className="cls" events={events} readMarkerEventId={events[6].getId()}
+            <WrappedMessagePanel
+                className="cls"
+                events={events}
+                readMarkerEventId={events[6].getId()}
                 readMarkerVisible={true}
             />, parentDiv);
 

From f99c0fad3ec428e34523e7b3f2daf151d43093f3 Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Fri, 23 Jul 2021 11:40:34 +0200
Subject: [PATCH 38/48] Make inline events feel less claustrophobic in bubble
 layout

---
 res/css/views/rooms/_EventBubbleTile.scss | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss
index a65afdf0d5..487bb38c49 100644
--- a/res/css/views/rooms/_EventBubbleTile.scss
+++ b/res/css/views/rooms/_EventBubbleTile.scss
@@ -221,6 +221,7 @@ limitations under the License.
         display: flex;
         align-items: center;
         justify-content: center;
+        padding: 5px 0;
 
         .mx_EventTile_avatar {
             position: static;
@@ -287,7 +288,7 @@ limitations under the License.
     & + .mx_EventListSummary {
         .mx_EventTile {
             margin-top: 0;
-            padding: 0;
+            padding: 2px 0;
         }
     }
 

From 1ba5f19f2eee5914f0c2cb28749cfc8045a64aa4 Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Fri, 23 Jul 2021 12:09:39 +0200
Subject: [PATCH 39/48] Put avatar is right place when sender isnt displayed in
 message bubbles

---
 res/css/views/rooms/_EventBubbleTile.scss | 6 ++++++
 src/components/views/rooms/EventTile.tsx  | 1 +
 2 files changed, 7 insertions(+)

diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss
index a65afdf0d5..44afd23ac6 100644
--- a/res/css/views/rooms/_EventBubbleTile.scss
+++ b/res/css/views/rooms/_EventBubbleTile.scss
@@ -162,6 +162,12 @@ limitations under the License.
         }
     }
 
+    &.mx_EventTile_noSender {
+        .mx_EventTile_avatar {
+            top: -19px;
+        }
+    }
+
     &[data-has-reply=true] {
         > .mx_EventTile_line {
             flex-direction: column;
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index c6c605a8c2..96520143b9 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -893,6 +893,7 @@ export default class EventTile extends React.Component<IProps, IState> {
             mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
             mx_EventTile_bad: isEncryptionFailure,
             mx_EventTile_emote: msgtype === 'm.emote',
+            mx_EventTile_noSender: this.props.hideSender,
         });
 
         // If the tile is in the Sending state, don't speak the message.

From dcfd5d47933b9f6cbc99cfef95006d0edea27df3 Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Fri, 23 Jul 2021 12:12:52 +0200
Subject: [PATCH 40/48] Overlay avatar on top of bubbles

---
 res/css/views/rooms/_EventBubbleTile.scss | 1 +
 1 file changed, 1 insertion(+)

diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss
index 44afd23ac6..f325d68551 100644
--- a/res/css/views/rooms/_EventBubbleTile.scss
+++ b/res/css/views/rooms/_EventBubbleTile.scss
@@ -156,6 +156,7 @@ limitations under the License.
         position: absolute;
         top: 0;
         line-height: 1;
+        z-index: 9;
         img {
             box-shadow: 0 0 0 3px $eventbubble-avatar-outline;
             border-radius: 50%;

From 4a4ec596bd90b117df5da5e495a06dc628fd1f0c Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 23 Jul 2021 11:27:00 +0100
Subject: [PATCH 41/48] Fix position of the space hierarchy spinner

---
 res/css/structures/_SpaceRoomView.scss | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss
index 48b565be7f..3119d2fe6e 100644
--- a/res/css/structures/_SpaceRoomView.scss
+++ b/res/css/structures/_SpaceRoomView.scss
@@ -234,6 +234,9 @@ $SpaceRoomViewInnerWidth: 428px;
     }
 
     .mx_SpaceRoomView_landing {
+        display: flex;
+        flex-direction: column;
+
         > .mx_BaseAvatar_image,
         > .mx_BaseAvatar > .mx_BaseAvatar_image {
             border-radius: 12px;
@@ -340,6 +343,7 @@ $SpaceRoomViewInnerWidth: 428px;
 
         .mx_SearchBox {
             margin: 0 0 20px;
+            flex: 0;
         }
 
         .mx_SpaceFeedbackPrompt {

From 2b133deb63db689a373bddabe48125e16233e09a Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 23 Jul 2021 12:19:54 +0100
Subject: [PATCH 42/48] fix scroll behaviour to match that of prior to the
 spinner fix

---
 res/css/structures/_SpaceRoomView.scss | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss
index 3119d2fe6e..e4832d9430 100644
--- a/res/css/structures/_SpaceRoomView.scss
+++ b/res/css/structures/_SpaceRoomView.scss
@@ -354,6 +354,11 @@ $SpaceRoomViewInnerWidth: 428px;
                 display: none;
             }
         }
+
+        .mx_SpaceRoomDirectory_list {
+            // we don't want this container to get forced into the flexbox layout
+            display: contents;
+        }
     }
 
     .mx_SpaceRoomView_privateScope {

From 42b213ba8c873041680dd7a1792a59f3fe37b934 Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Fri, 23 Jul 2021 14:17:26 +0200
Subject: [PATCH 43/48] Fix clipped avatar in room list

---
 res/css/views/avatars/_BaseAvatar.scss    | 1 -
 res/css/views/rooms/_EventBubbleTile.scss | 6 +++++-
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss
index 65e4493f19..cbddd97e18 100644
--- a/res/css/views/avatars/_BaseAvatar.scss
+++ b/res/css/views/avatars/_BaseAvatar.scss
@@ -27,7 +27,6 @@ limitations under the License.
     // https://bugzilla.mozilla.org/show_bug.cgi?id=255139
     display: inline-block;
     user-select: none;
-    line-height: 1;
 }
 
 .mx_BaseAvatar_initial {
diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss
index 487bb38c49..8629682693 100644
--- a/res/css/views/rooms/_EventBubbleTile.scss
+++ b/res/css/views/rooms/_EventBubbleTile.scss
@@ -155,13 +155,17 @@ limitations under the License.
     .mx_EventTile_avatar {
         position: absolute;
         top: 0;
-        line-height: 1;
         img {
             box-shadow: 0 0 0 3px $eventbubble-avatar-outline;
             border-radius: 50%;
         }
     }
 
+    .mx_BaseAvatar,
+    .mx_EventTile_avatar {
+        line-height: 1;
+    }
+
     &[data-has-reply=true] {
         > .mx_EventTile_line {
             flex-direction: column;

From 3ce6fcc64b7259d5cd1e1f694e4420c36f178a4f Mon Sep 17 00:00:00 2001
From: Germain Souquet <germain@souquet.com>
Date: Fri, 23 Jul 2021 14:52:51 +0200
Subject: [PATCH 44/48] Fix reactions row pushing content on IRC layout

---
 res/css/views/rooms/_IRCLayout.scss      | 5 +++++
 src/components/views/rooms/EventTile.tsx | 3 ++-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss
index 97190807ca..578c0325d2 100644
--- a/res/css/views/rooms/_IRCLayout.scss
+++ b/res/css/views/rooms/_IRCLayout.scss
@@ -116,6 +116,11 @@ $irc-line-height: $font-18px;
         .mx_EditMessageComposer_buttons {
             position: relative;
         }
+
+        .mx_ReactionsRow {
+            padding-left: 0;
+            padding-right: 0;
+        }
     }
 
     .mx_EventTile_emote {
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index c6c605a8c2..556ce1e577 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -1160,8 +1160,9 @@ export default class EventTile extends React.Component<IProps, IState> {
                             />
                             { keyRequestInfo }
                             { actionBar }
+                            { this.props.layout === Layout.IRC && (reactionsRow) }
                         </div>
-                        { reactionsRow }
+                        { this.props.layout !== Layout.IRC && (reactionsRow) }
                         { msgOption }
                     </>)
                 );

From 3c1902c26a69c463bcaff95f48a23b6b3d608f34 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 23 Jul 2021 16:09:41 +0100
Subject: [PATCH 45/48] Update matrix-org-eslint-plugin

---
 yarn.lock | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/yarn.lock b/yarn.lock
index 5283f5778a..c576148e19 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3234,8 +3234,8 @@ eslint-config-google@^0.14.0:
   integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==
 
 "eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main":
-  version "0.3.2"
-  resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/8529f1d77863db6327cf1a1a4fa65d06cc26f91b"
+  version "0.3.3"
+  resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/50d6bdf6704dd95016d5f1f824f00cac6eaa64e1"
 
 eslint-plugin-react-hooks@^4.2.0:
   version "4.2.0"

From 5f2582395ffcf07d8edf6a384ba2d9db084a666b Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 23 Jul 2021 16:21:59 +0100
Subject: [PATCH 46/48] Fix blurhash rounded corners missing regression

---
 res/css/views/messages/_MImageBody.scss | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss
index f5d8131e6e..42565a76d0 100644
--- a/res/css/views/messages/_MImageBody.scss
+++ b/res/css/views/messages/_MImageBody.scss
@@ -28,7 +28,7 @@ $timelineImageBorderRadius: 4px;
     justify-content: center;
     align-items: center;
 
-    > canvas {
+    > div > canvas {
         border-radius: $timelineImageBorderRadius;
     }
 }

From 4fe0e216d6417ad15559d57ed9ef1720b8c705be Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 23 Jul 2021 16:22:35 +0100
Subject: [PATCH 47/48] Use div instead of span for mx_MImageBody to not
 violate spec

---
 res/css/views/messages/_MImageBody.scss      | 4 ----
 src/components/views/messages/MImageBody.tsx | 8 ++++----
 2 files changed, 4 insertions(+), 8 deletions(-)

diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss
index 42565a76d0..a748435cd8 100644
--- a/res/css/views/messages/_MImageBody.scss
+++ b/res/css/views/messages/_MImageBody.scss
@@ -16,10 +16,6 @@ limitations under the License.
 
 $timelineImageBorderRadius: 4px;
 
-.mx_MImageBody {
-    display: block;
-}
-
 .mx_MImageBody_thumbnail {
     object-fit: contain;
     border-radius: $timelineImageBorderRadius;
diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx
index c24f014d84..945994d964 100644
--- a/src/components/views/messages/MImageBody.tsx
+++ b/src/components/views/messages/MImageBody.tsx
@@ -416,10 +416,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
 
         if (this.state.error !== null) {
             return (
-                <span className="mx_MImageBody">
+                <div className="mx_MImageBody">
                     <img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
                     { _t("Error decrypting image") }
-                </span>
+                </div>
             );
         }
 
@@ -434,10 +434,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
         const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
         const fileBody = this.getFileBody();
 
-        return <span className="mx_MImageBody">
+        return <div className="mx_MImageBody">
             { thumbnail }
             { fileBody }
-        </span>;
+        </div>;
     }
 }
 

From fa550a65af11f5b0e9422b2ca8b0f51cab6b0736 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 23 Jul 2021 19:01:12 +0100
Subject: [PATCH 48/48] Fix editing of <sub> & <sup> & <u>

---
 src/editor/deserialize.ts | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts
index eb8adfda9d..9033f99b6c 100644
--- a/src/editor/deserialize.ts
+++ b/src/editor/deserialize.ts
@@ -121,6 +121,12 @@ function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLEl
             return partCreator.plain(`\`${n.textContent}\``);
         case "DEL":
             return partCreator.plain(`<del>${n.textContent}</del>`);
+        case "SUB":
+            return partCreator.plain(`<sub>${n.textContent}</sub>`);
+        case "SUP":
+            return partCreator.plain(`<sup>${n.textContent}</sup>`);
+        case "U":
+            return partCreator.plain(`<u>${n.textContent}</u>`);
         case "LI": {
             const indent = "  ".repeat(state.listDepth - 1);
             if (n.parentElement.nodeName === "OL") {