From 1220ad37ce102bff3db97498b8c7327266ba6d5a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Wed, 7 Jul 2021 11:54:53 +0200
Subject: [PATCH 01/24] Remove the usage of symbol
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>
---
 src/components/structures/RoomDirectory.tsx        |  2 +-
 src/components/views/directory/NetworkDropdown.tsx | 12 ++++++------
 2 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx
index 3acd9f1a2e..fe07920d95 100644
--- a/src/components/structures/RoomDirectory.tsx
+++ b/src/components/structures/RoomDirectory.tsx
@@ -61,7 +61,7 @@ interface IState {
     loading: boolean;
     protocolsLoading: boolean;
     error?: string;
-    instanceId: string | symbol;
+    instanceId: string;
     roomServer: string;
     filterString: string;
     selectedCommunityId?: string;
diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx
index c57aa7bccc..155349e39d 100644
--- a/src/components/views/directory/NetworkDropdown.tsx
+++ b/src/components/views/directory/NetworkDropdown.tsx
@@ -41,7 +41,8 @@ import QuestionDialog from "../dialogs/QuestionDialog";
 import UIStore from "../../../stores/UIStore";
 import { compare } from "../../../utils/strings";
 
-export const ALL_ROOMS = Symbol("ALL_ROOMS");
+// XXX: We would ideally use a symbol here but we can't since we save this value to localStorage
+export const ALL_ROOMS = "ALL_ROOMS";
 
 const SETTING_NAME = "room_directory_servers";
 
@@ -94,8 +95,7 @@ export interface IInstance {
     fields: object;
     network_id: string;
     // XXX: this is undocumented but we rely on it.
-    // we inject a fake entry with a symbolic instance_id.
-    instance_id: string | symbol;
+    instance_id: string;
 }
 
 export interface IProtocol {
@@ -112,8 +112,8 @@ export type Protocols = Record<string, IProtocol>;
 interface IProps {
     protocols: Protocols;
     selectedServerName: string;
-    selectedInstanceId: string | symbol;
-    onOptionChange(server: string, instanceId?: string | symbol): void;
+    selectedInstanceId: string;
+    onOptionChange(server: string, instanceId?: string): void;
 }
 
 // This dropdown sources homeservers from three places:
@@ -171,7 +171,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
 
             const protocolsList = server === hsName ? Object.values(protocols) : [];
             if (protocolsList.length > 0) {
-                // add a fake protocol with the ALL_ROOMS symbol
+                // add a fake protocol with the ALL_ROOMS
                 protocolsList.push({
                     instances: [{
                         fields: [],

From b94dc2d0e5b3eea9d62077f99c206b8c42e06707 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Wed, 7 Jul 2021 12:01:08 +0200
Subject: [PATCH 02/24] Remember the last used server for room directory
 searches
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>
---
 src/components/structures/RoomDirectory.tsx | 17 ++++++++++++++---
 1 file changed, 14 insertions(+), 3 deletions(-)

diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx
index fe07920d95..c0fdcebc75 100644
--- a/src/components/structures/RoomDirectory.tsx
+++ b/src/components/structures/RoomDirectory.tsx
@@ -48,6 +48,9 @@ import { ActionPayload } from "../../dispatcher/payloads";
 const MAX_NAME_LENGTH = 80;
 const MAX_TOPIC_LENGTH = 800;
 
+const LAST_SERVER_KEY = "mx_last_room_directory_server";
+const LAST_INSTANCE_KEY = "mx_last_room_directory_instance";
+
 function track(action: string) {
     Analytics.trackEvent('RoomDirectory', action);
 }
@@ -150,8 +153,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
             publicRooms: [],
             loading: true,
             error: null,
-            instanceId: undefined,
-            roomServer: MatrixClientPeg.getHomeserverName(),
+            instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
+            roomServer: localStorage.getItem(LAST_SERVER_KEY) || MatrixClientPeg.getHomeserverName(),
             filterString: this.props.initialText || "",
             selectedCommunityId,
             communityName: null,
@@ -342,7 +345,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
         }
     };
 
-    private onOptionChange = (server: string, instanceId?: string | symbol) => {
+    private onOptionChange = (server: string, instanceId?: string) => {
         // clear next batch so we don't try to load more rooms
         this.nextBatch = null;
         this.setState({
@@ -360,6 +363,14 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
         // find the five gitter ones, at which point we do not want
         // to render all those rooms when switching back to 'all networks'.
         // Easiest to just blow away the state & re-fetch.
+
+        // We have to be careful here so that we don't set instanceId = "undefined"
+        localStorage.setItem(LAST_SERVER_KEY, server);
+        if (instanceId) {
+            localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
+        } else {
+            localStorage.removeItem(LAST_INSTANCE_KEY);
+        }
     };
 
     private onFillRequest = (backwards: boolean) => {

From 255ab49ccbbc65f7235460c83c9bebe59669d45f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Wed, 7 Jul 2021 13:48:48 +0200
Subject: [PATCH 03/24] Handle edge cases
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>
---
 src/components/structures/RoomDirectory.tsx | 28 ++++++++++++++++++---
 1 file changed, 25 insertions(+), 3 deletions(-)

diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx
index c0fdcebc75..3892c9da5c 100644
--- a/src/components/structures/RoomDirectory.tsx
+++ b/src/components/structures/RoomDirectory.tsx
@@ -119,7 +119,29 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
         } else if (!selectedCommunityId) {
             MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
                 this.protocols = response;
-                this.setState({ protocolsLoading: false });
+                const myHomeserver = MatrixClientPeg.getHomeserverName();
+                const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
+                const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
+                const configSevers = SdkConfig.get().roomDirectory?.servers || [];
+                const roomServer = configSevers.includes(lsRoomServer)
+                    ? lsRoomServer
+                    : myHomeserver;
+                const instanceIds = [];
+                if (roomServer === myHomeserver) {
+                    Object.values(this.protocols).forEach((protocol) => {
+                        protocol.instances.forEach((instance) => instanceIds.push(instance.instance_id));
+                    });
+                }
+                const instanceId = (instanceIds.includes(lsInstanceId) || lsInstanceId === ALL_ROOMS)
+                    ? lsInstanceId
+                    : null;
+
+                this.setState({
+                    protocolsLoading: false,
+                    instanceId: instanceId,
+                    roomServer: roomServer,
+                });
+                this.refreshRoomList();
             }, (err) => {
                 console.warn(`error loading third party protocols: ${err}`);
                 this.setState({ protocolsLoading: false });
@@ -153,8 +175,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
             publicRooms: [],
             loading: true,
             error: null,
-            instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
-            roomServer: localStorage.getItem(LAST_SERVER_KEY) || MatrixClientPeg.getHomeserverName(),
+            instanceId: null,
+            roomServer: null,
             filterString: this.props.initialText || "",
             selectedCommunityId,
             communityName: null,

From 5b5ef5e04c5f7ccfd77ba4c717c2a5f0a6f560cc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Wed, 7 Jul 2021 13:59:02 +0200
Subject: [PATCH 04/24] Handle servers from settings
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>
---
 src/components/structures/RoomDirectory.tsx | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx
index 3892c9da5c..4a0e7615c4 100644
--- a/src/components/structures/RoomDirectory.tsx
+++ b/src/components/structures/RoomDirectory.tsx
@@ -123,7 +123,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
                 const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
                 const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
                 const configSevers = SdkConfig.get().roomDirectory?.servers || [];
-                const roomServer = configSevers.includes(lsRoomServer)
+                const settingsServers = SettingsStore.getValue("room_directory_servers") || [];
+                const roomServer = [...configSevers, ...settingsServers].includes(lsRoomServer)
                     ? lsRoomServer
                     : myHomeserver;
                 const instanceIds = [];

From 64f32a9fc154cb274b2e8bb75ea64f77f87f7491 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Thu, 8 Jul 2021 13:12:57 +0200
Subject: [PATCH 05/24] Focus composer after reacting
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>
---
 src/components/views/emojipicker/ReactionPicker.tsx | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx
index d8f8b7f2ff..d5a2277ab2 100644
--- a/src/components/views/emojipicker/ReactionPicker.tsx
+++ b/src/components/views/emojipicker/ReactionPicker.tsx
@@ -22,6 +22,7 @@ import EmojiPicker from "./EmojiPicker";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
 import dis from "../../../dispatcher/dispatcher";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { Action } from '../../../dispatcher/actions';
 
 interface IProps {
     mxEvent: MatrixEvent;
@@ -93,6 +94,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
                 this.props.mxEvent.getRoomId(),
                 myReactions[reaction],
             );
+            dis.dispatch({ action: Action.FocusComposer });
             // Tell the emoji picker not to bump this in the more frequently used list.
             return false;
         } else {
@@ -104,6 +106,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
                 },
             });
             dis.dispatch({ action: "message_sent" });
+            dis.dispatch({ action: Action.FocusComposer });
             return true;
         }
     };

From 8e7e4c9e8dcc3f62f5a1def5b64a439e76f4f483 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Thu, 8 Jul 2021 14:47:36 +0100
Subject: [PATCH 06/24] Convert MessageContextMenu to Typescript

---
 ...eContextMenu.js => MessageContextMenu.tsx} | 175 ++++++++++--------
 .../views/elements/AccessibleButton.tsx       |   6 +-
 2 files changed, 96 insertions(+), 85 deletions(-)
 rename src/components/views/context_menus/{MessageContextMenu.js => MessageContextMenu.tsx} (73%)

diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.tsx
similarity index 73%
rename from src/components/views/context_menus/MessageContextMenu.js
rename to src/components/views/context_menus/MessageContextMenu.tsx
index a2086451cd..7619b116d6 100644
--- a/src/components/views/context_menus/MessageContextMenu.js
+++ b/src/components/views/context_menus/MessageContextMenu.tsx
@@ -1,6 +1,6 @@
 /*
 Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
-Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C.
+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,12 +16,11 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import { EventStatus } from 'matrix-js-sdk/src/models/event';
+import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
+import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
 
 import { MatrixClientPeg } from '../../../MatrixClientPeg';
 import dis from '../../../dispatcher/dispatcher';
-import * as sdk from '../../../index';
 import { _t } from '../../../languageHandler';
 import Modal from '../../../Modal';
 import Resend from '../../../Resend';
@@ -29,53 +28,65 @@ import SettingsStore from '../../../settings/SettingsStore';
 import { isUrlPermitted } from '../../../HtmlUtils';
 import { isContentActionable } from '../../../utils/EventUtils';
 import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu';
-import { EventType } from "matrix-js-sdk/src/@types/event";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
 import ForwardDialog from "../dialogs/ForwardDialog";
 import { Action } from "../../../dispatcher/actions";
+import ReportEventDialog from '../dialogs/ReportEventDialog';
+import ViewSource from '../../structures/ViewSource';
+import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog';
+import ErrorDialog from '../dialogs/ErrorDialog';
+import ShareDialog from '../dialogs/ShareDialog';
+import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
 
-export function canCancel(eventStatus) {
+export function canCancel(eventStatus: EventStatus): boolean {
     return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
 }
 
+interface IEventTileOps {
+    isWidgetHidden(): boolean;
+    unhideWidget(): void;
+}
+
+interface IProps {
+    /* the MatrixEvent associated with the context menu */
+    mxEvent: MatrixEvent;
+    /* an optional EventTileOps implementation that can be used to unhide preview widgets */
+    eventTileOps?: IEventTileOps;
+    permalinkCreator?: RoomPermalinkCreator;
+    /* an optional function to be called when the user clicks collapse thread, if not provided hide button */
+    collapseReplyThread?(): void;
+    /* callback called when the menu is dismissed */
+    onFinished(): void;
+    /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
+    onCloseDialog?(): void;
+}
+
+interface IState {
+    canRedact: boolean;
+    canPin: boolean;
+}
+
 @replaceableComponent("views.context_menus.MessageContextMenu")
-export default class MessageContextMenu extends React.Component {
-    static propTypes = {
-        /* the MatrixEvent associated with the context menu */
-        mxEvent: PropTypes.object.isRequired,
-
-        /* an optional EventTileOps implementation that can be used to unhide preview widgets */
-        eventTileOps: PropTypes.object,
-
-        /* an optional function to be called when the user clicks collapse thread, if not provided hide button */
-        collapseReplyThread: PropTypes.func,
-
-        /* callback called when the menu is dismissed */
-        onFinished: PropTypes.func,
-
-        /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
-        onCloseDialog: PropTypes.func,
-    };
-
+export default class MessageContextMenu extends React.Component<IProps, IState> {
     state = {
         canRedact: false,
         canPin: false,
     };
 
     componentDidMount() {
-        MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
-        this._checkPermissions();
+        MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions);
+        this.checkPermissions();
     }
 
     componentWillUnmount() {
         const cli = MatrixClientPeg.get();
         if (cli) {
-            cli.removeListener('RoomMember.powerLevel', this._checkPermissions);
+            cli.removeListener('RoomMember.powerLevel', this.checkPermissions);
         }
     }
 
-    _checkPermissions = () => {
+    private checkPermissions = (): void => {
         const cli = MatrixClientPeg.get();
         const room = cli.getRoom(this.props.mxEvent.getRoomId());
 
@@ -93,7 +104,7 @@ export default class MessageContextMenu extends React.Component {
         this.setState({ canRedact, canPin });
     };
 
-    _isPinned() {
+    private isPinned(): boolean {
         const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
         const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
         if (!pinnedEvent) return false;
@@ -101,38 +112,35 @@ export default class MessageContextMenu extends React.Component {
         return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
     }
 
-    onResendReactionsClick = () => {
-        for (const reaction of this._getUnsentReactions()) {
+    private onResendReactionsClick = (): void => {
+        for (const reaction of this.getUnsentReactions()) {
             Resend.resend(reaction);
         }
         this.closeMenu();
     };
 
-    onReportEventClick = () => {
-        const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog");
+    private onReportEventClick = (): void => {
         Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
             mxEvent: this.props.mxEvent,
         }, 'mx_Dialog_reportEvent');
         this.closeMenu();
     };
 
-    onViewSourceClick = () => {
-        const ViewSource = sdk.getComponent('structures.ViewSource');
+    private onViewSourceClick = (): void => {
         Modal.createTrackedDialog('View Event Source', '', ViewSource, {
             mxEvent: this.props.mxEvent,
         }, 'mx_Dialog_viewsource');
         this.closeMenu();
     };
 
-    onRedactClick = () => {
-        const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
+    private onRedactClick = (): void => {
         Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
-            onFinished: async (proceed, reason) => {
+            onFinished: async (proceed: boolean, reason?: string) => {
                 if (!proceed) return;
 
                 const cli = MatrixClientPeg.get();
                 try {
-                    if (this.props.onCloseDialog) this.props.onCloseDialog();
+                    this.props.onCloseDialog?.();
                     await cli.redactEvent(
                         this.props.mxEvent.getRoomId(),
                         this.props.mxEvent.getId(),
@@ -145,7 +153,6 @@ export default class MessageContextMenu extends React.Component {
                     // (e.g. no errcode or statusCode) as in that case the redactions end up in the
                     // detached queue and we show the room status bar to allow retry
                     if (typeof code !== "undefined") {
-                        const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
                         // display error message stating you couldn't delete this.
                         Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
                             title: _t('Error'),
@@ -158,7 +165,7 @@ export default class MessageContextMenu extends React.Component {
         this.closeMenu();
     };
 
-    onForwardClick = () => {
+    private onForwardClick = (): void => {
         Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
             matrixClient: MatrixClientPeg.get(),
             event: this.props.mxEvent,
@@ -167,7 +174,7 @@ export default class MessageContextMenu extends React.Component {
         this.closeMenu();
     };
 
-    onPinClick = () => {
+    private onPinClick = (): void => {
         const cli = MatrixClientPeg.get();
         const room = cli.getRoom(this.props.mxEvent.getRoomId());
         const eventId = this.props.mxEvent.getId();
@@ -188,18 +195,16 @@ export default class MessageContextMenu extends React.Component {
         this.closeMenu();
     };
 
-    closeMenu = () => {
-        if (this.props.onFinished) this.props.onFinished();
+    private closeMenu = (): void => {
+        this.props.onFinished();
     };
 
-    onUnhidePreviewClick = () => {
-        if (this.props.eventTileOps) {
-            this.props.eventTileOps.unhideWidget();
-        }
+    private onUnhidePreviewClick = (): void => {
+        this.props.eventTileOps?.unhideWidget();
         this.closeMenu();
     };
 
-    onQuoteClick = () => {
+    private onQuoteClick = (): void => {
         dis.dispatch({
             action: Action.ComposerInsert,
             event: this.props.mxEvent,
@@ -207,9 +212,8 @@ export default class MessageContextMenu extends React.Component {
         this.closeMenu();
     };
 
-    onPermalinkClick = (e) => {
+    private onPermalinkClick = (e: React.MouseEvent): void => {
         e.preventDefault();
-        const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
         Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
             target: this.props.mxEvent,
             permalinkCreator: this.props.permalinkCreator,
@@ -217,30 +221,27 @@ export default class MessageContextMenu extends React.Component {
         this.closeMenu();
     };
 
-    onCollapseReplyThreadClick = () => {
+    private onCollapseReplyThreadClick = (): void => {
         this.props.collapseReplyThread();
         this.closeMenu();
     };
 
-    _getReactions(filter) {
+    private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
         const cli = MatrixClientPeg.get();
         const room = cli.getRoom(this.props.mxEvent.getRoomId());
         const eventId = this.props.mxEvent.getId();
         return room.getPendingEvents().filter(e => {
             const relation = e.getRelation();
-            return relation &&
-                relation.rel_type === "m.annotation" &&
-                relation.event_id === eventId &&
-                filter(e);
+            return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e);
         });
     }
 
-    _getPendingReactions() {
-        return this._getReactions(e => canCancel(e.status));
+    private getPendingReactions(): MatrixEvent[] {
+        return this.getReactions(e => canCancel(e.status));
     }
 
-    _getUnsentReactions() {
-        return this._getReactions(e => e.status === EventStatus.NOT_SENT);
+    private getUnsentReactions(): MatrixEvent[] {
+        return this.getReactions(e => e.status === EventStatus.NOT_SENT);
     }
 
     render() {
@@ -248,16 +249,17 @@ export default class MessageContextMenu extends React.Component {
         const me = cli.getUserId();
         const mxEvent = this.props.mxEvent;
         const eventStatus = mxEvent.status;
-        const unsentReactionsCount = this._getUnsentReactions().length;
-        let resendReactionsButton;
-        let redactButton;
-        let forwardButton;
-        let pinButton;
-        let unhidePreviewButton;
-        let externalURLButton;
-        let quoteButton;
-        let collapseReplyThread;
-        let redactItemList;
+        const unsentReactionsCount = this.getUnsentReactions().length;
+
+        let resendReactionsButton: JSX.Element;
+        let redactButton: JSX.Element;
+        let forwardButton: JSX.Element;
+        let pinButton: JSX.Element;
+        let unhidePreviewButton: JSX.Element;
+        let externalURLButton: JSX.Element;
+        let quoteButton: JSX.Element;
+        let collapseReplyThread: JSX.Element;
+        let redactItemList: JSX.Element;
 
         // status is SENT before remote-echo, null after
         const isSent = !eventStatus || eventStatus === EventStatus.SENT;
@@ -296,7 +298,7 @@ export default class MessageContextMenu extends React.Component {
                 pinButton = (
                     <IconizedContextMenuOption
                         iconClassName="mx_MessageContextMenu_iconPin"
-                        label={ this._isPinned() ? _t('Unpin') : _t('Pin') }
+                        label={ this.isPinned() ? _t('Unpin') : _t('Pin') }
                         onClick={this.onPinClick}
                     />
                 );
@@ -327,16 +329,20 @@ export default class MessageContextMenu extends React.Component {
         if (this.props.permalinkCreator) {
             permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
         }
-        // XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
         const permalinkButton = (
             <IconizedContextMenuOption
                 iconClassName="mx_MessageContextMenu_iconPermalink"
                 onClick={this.onPermalinkClick}
                 label= {_t('Share')}
                 element="a"
-                href={permalink}
-                target="_blank"
-                rel="noreferrer noopener"
+                {
+                    // XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
+                    ...{
+                        href: permalink,
+                        target: "_blank",
+                        rel: "noreferrer noopener",
+                    }
+                }
             />
         );
 
@@ -351,8 +357,8 @@ export default class MessageContextMenu extends React.Component {
         }
 
         // Bridges can provide a 'external_url' to link back to the source.
-        if (typeof (mxEvent.event.content.external_url) === "string" &&
-            isUrlPermitted(mxEvent.event.content.external_url)
+        if (typeof (mxEvent.getContent().external_url) === "string" &&
+            isUrlPermitted(mxEvent.getContent().external_url)
         ) {
             externalURLButton = (
                 <IconizedContextMenuOption
@@ -360,9 +366,14 @@ export default class MessageContextMenu extends React.Component {
                     onClick={this.closeMenu}
                     label={ _t('Source URL') }
                     element="a"
-                    target="_blank"
-                    rel="noreferrer noopener"
-                    href={mxEvent.event.content.external_url}
+                    {
+                        // XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
+                        ...{
+                            target: "_blank",
+                            rel: "noreferrer noopener",
+                            href: mxEvent.getContent().external_url,
+                        }
+                    }
                 />
             );
         }
@@ -377,7 +388,7 @@ export default class MessageContextMenu extends React.Component {
             );
         }
 
-        let reportEventButton;
+        let reportEventButton: JSX.Element;
         if (mxEvent.getSender() !== me) {
             reportEventButton = (
                 <IconizedContextMenuOption
diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx
index 997bbcb9c2..8bb6341c3d 100644
--- a/src/components/views/elements/AccessibleButton.tsx
+++ b/src/components/views/elements/AccessibleButton.tsx
@@ -14,7 +14,7 @@
  limitations under the License.
  */
 
-import React from 'react';
+import React, { ReactHTML } from 'react';
 
 import { Key } from '../../../Keyboard';
 import classnames from 'classnames';
@@ -29,7 +29,7 @@ export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Elemen
  */
 interface IProps extends React.InputHTMLAttributes<Element> {
     inputRef?: React.Ref<Element>;
-    element?: string;
+    element?: keyof ReactHTML;
     // The kind of button, similar to how Bootstrap works.
     // See available classes for AccessibleButton for options.
     kind?: string;
@@ -122,7 +122,7 @@ export default function AccessibleButton({
 }
 
 AccessibleButton.defaultProps = {
-    element: 'div',
+    element: 'div' as keyof ReactHTML,
     role: 'button',
     tabIndex: 0,
 };

From f8907a6ea4fc8a4fb5ed9b6a6e5ac076a28eb3cc Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Thu, 8 Jul 2021 14:48:06 +0100
Subject: [PATCH 07/24] Fix bug which prevented more than one event getting
 pinned

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

diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx
index 7619b116d6..999e98f4ad 100644
--- a/src/components/views/context_menus/MessageContextMenu.tsx
+++ b/src/components/views/context_menus/MessageContextMenu.tsx
@@ -179,7 +179,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
         const room = cli.getRoom(this.props.mxEvent.getRoomId());
         const eventId = this.props.mxEvent.getId();
 
-        const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || [];
+        const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || [];
         if (pinnedIds.includes(eventId)) {
             pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
         } else {

From 7734f8aeefcbbc2d9186f6efa88a537d6040c253 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Thu, 8 Jul 2021 17:33:49 +0200
Subject: [PATCH 08/24] Handle focusing multiple composers
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>
---
 src/components/structures/RoomView.tsx           | 16 ++++++++++++++++
 .../views/emojipicker/ReactionPicker.tsx         |  4 ++--
 .../views/rooms/EditMessageComposer.tsx          |  2 ++
 src/dispatcher/actions.ts                        | 14 +++++++++++++-
 4 files changed, 33 insertions(+), 3 deletions(-)

diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 0e77c301fd..0b345de49f 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -832,6 +832,22 @@ export default class RoomView extends React.Component<IProps, IState> {
                 break;
             }
 
+            case Action.FocusAComposer: {
+                // re-dispatch to the correct composer
+                if (this.state.editState) {
+                    dis.dispatch({
+                        ...payload,
+                        action: Action.FocusEditMessageComposer,
+                    });
+                } else {
+                    dis.dispatch({
+                        ...payload,
+                        action: Action.FocusComposer,
+                    });
+                }
+                break;
+            }
+
             case "scroll_to_bottom":
                 this.messagePanel?.jumpToLiveTimeline();
                 break;
diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx
index d5a2277ab2..e129b45c9a 100644
--- a/src/components/views/emojipicker/ReactionPicker.tsx
+++ b/src/components/views/emojipicker/ReactionPicker.tsx
@@ -94,7 +94,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
                 this.props.mxEvent.getRoomId(),
                 myReactions[reaction],
             );
-            dis.dispatch({ action: Action.FocusComposer });
+            dis.dispatch({ action: Action.FocusAComposer });
             // Tell the emoji picker not to bump this in the more frequently used list.
             return false;
         } else {
@@ -106,7 +106,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
                 },
             });
             dis.dispatch({ action: "message_sent" });
-            dis.dispatch({ action: Action.FocusComposer });
+            dis.dispatch({ action: Action.FocusAComposer });
             return true;
         }
     };
diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx
index fea6499dd8..3bfa121799 100644
--- a/src/components/views/rooms/EditMessageComposer.tsx
+++ b/src/components/views/rooms/EditMessageComposer.tsx
@@ -452,6 +452,8 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
             } else if (payload.text) {
                 this.editorRef.current?.insertPlaintext(payload.text);
             }
+        } else if (payload.action === Action.FocusEditMessageComposer && this.editorRef.current) {
+            this.editorRef.current.focus();
         }
     };
 
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 6438ecc0f2..d376560ace 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -56,10 +56,22 @@ export enum Action {
     CheckUpdates = "check_updates",
 
     /**
-     * Focuses the user's cursor to the composer. No additional payload information required.
+     * Focuses the user's cursor to the send message composer. No additional payload information required.
      */
     FocusComposer = "focus_composer",
 
+    /**
+     * Focuses the user's cursor to the edit message composer. No additional payload information required.
+     */
+    FocusEditMessageComposer = "focus_edit_message_composer",
+
+    /**
+     * Focuses the user's cursor to the edit message composer or send message
+     * composer based on the current edit state. No additional payload
+     * information required.
+     */
+    FocusAComposer = "focus_a_composer",
+
     /**
      * Opens the user menu (previously known as the top left menu). No additional payload information required.
      */

From 68d194444a907b5dafcc88f7271a48cdb9310485 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Thu, 8 Jul 2021 17:36:31 +0200
Subject: [PATCH 09/24] FocusComposer -> FocusSendMessageComposer
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>
---
 src/ContentMessages.tsx                            | 2 +-
 src/components/structures/LoggedInView.tsx         | 4 ++--
 src/components/structures/MatrixChat.tsx           | 4 ++--
 src/components/structures/RoomSearch.tsx           | 2 +-
 src/components/structures/RoomStatusBar.js         | 4 ++--
 src/components/structures/RoomView.tsx             | 6 +++---
 src/components/views/elements/ReplyThread.js       | 2 +-
 src/components/views/rooms/EditMessageComposer.tsx | 6 +++---
 src/components/views/rooms/SendMessageComposer.tsx | 2 +-
 src/dispatcher/actions.ts                          | 2 +-
 10 files changed, 17 insertions(+), 17 deletions(-)

diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx
index 670c175a48..0ab193081b 100644
--- a/src/ContentMessages.tsx
+++ b/src/ContentMessages.tsx
@@ -569,7 +569,7 @@ export default class ContentMessages {
         dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
 
         // Focus the composer view
-        dis.fire(Action.FocusComposer);
+        dis.fire(Action.FocusSendMessageComposer);
 
         function onProgress(ev) {
             upload.total = ev.total;
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index 5a26967cb0..89fa8db376 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -398,7 +398,7 @@ class LoggedInView extends React.Component<IProps, IState> {
             // refocusing during a paste event will make the
             // paste end up in the newly focused element,
             // so dispatch synchronously before paste happens
-            dis.fire(Action.FocusComposer, true);
+            dis.fire(Action.FocusSendMessageComposer, true);
         }
     };
 
@@ -552,7 +552,7 @@ class LoggedInView extends React.Component<IProps, IState> {
 
             if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
                 // synchronous dispatch so we focus before key generates input
-                dis.fire(Action.FocusComposer, true);
+                dis.fire(Action.FocusSendMessageComposer, true);
                 ev.stopPropagation();
                 // we should *not* preventDefault() here as
                 // that would prevent typing in the now-focussed composer
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index c7a200239c..d692b0fa7f 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -443,7 +443,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
             CountlyAnalytics.instance.trackPageChange(durationMs);
         }
         if (this.focusComposer) {
-            dis.fire(Action.FocusComposer);
+            dis.fire(Action.FocusSendMessageComposer);
             this.focusComposer = false;
         }
     }
@@ -1427,7 +1427,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
                 showNotificationsToast(false);
             }
 
-            dis.fire(Action.FocusComposer);
+            dis.fire(Action.FocusSendMessageComposer);
             this.setState({
                 ready: true,
             });
diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx
index 9cdd1efe7e..e8080b4f7b 100644
--- a/src/components/structures/RoomSearch.tsx
+++ b/src/components/structures/RoomSearch.tsx
@@ -131,7 +131,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
         switch (action) {
             case RoomListAction.ClearSearch:
                 this.clearInput();
-                defaultDispatcher.fire(Action.FocusComposer);
+                defaultDispatcher.fire(Action.FocusSendMessageComposer);
                 break;
             case RoomListAction.NextRoom:
             case RoomListAction.PrevRoom:
diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js
index f6e42a4f9c..80ea26c3f2 100644
--- a/src/components/structures/RoomStatusBar.js
+++ b/src/components/structures/RoomStatusBar.js
@@ -118,12 +118,12 @@ export default class RoomStatusBar extends React.PureComponent {
             this.setState({ isResending: false });
         });
         this.setState({ isResending: true });
-        dis.fire(Action.FocusComposer);
+        dis.fire(Action.FocusSendMessageComposer);
     };
 
     _onCancelAllClick = () => {
         Resend.cancelUnsentEvents(this.props.room);
-        dis.fire(Action.FocusComposer);
+        dis.fire(Action.FocusSendMessageComposer);
     };
 
     _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 0b345de49f..0f8d7189b7 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -842,7 +842,7 @@ export default class RoomView extends React.Component<IProps, IState> {
                 } else {
                     dis.dispatch({
                         ...payload,
-                        action: Action.FocusComposer,
+                        action: Action.FocusSendMessageComposer,
                     });
                 }
                 break;
@@ -1262,7 +1262,7 @@ export default class RoomView extends React.Component<IProps, IState> {
         ContentMessages.sharedInstance().sendContentListToRoom(
             ev.dataTransfer.files, this.state.room.roomId, this.context,
         );
-        dis.fire(Action.FocusComposer);
+        dis.fire(Action.FocusSendMessageComposer);
 
         this.setState({
             draggingFile: false,
@@ -1564,7 +1564,7 @@ export default class RoomView extends React.Component<IProps, IState> {
         } else {
             // Otherwise we have to jump manually
             this.messagePanel.jumpToLiveTimeline();
-            dis.fire(Action.FocusComposer);
+            dis.fire(Action.FocusSendMessageComposer);
         }
     };
 
diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js
index aea447c9b1..2047de6c58 100644
--- a/src/components/views/elements/ReplyThread.js
+++ b/src/components/views/elements/ReplyThread.js
@@ -334,7 +334,7 @@ export default class ReplyThread extends React.Component {
             events,
         });
 
-        dis.fire(Action.FocusComposer);
+        dis.fire(Action.FocusSendMessageComposer);
     }
 
     render() {
diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx
index 3bfa121799..e4b13e2155 100644
--- a/src/components/views/rooms/EditMessageComposer.tsx
+++ b/src/components/views/rooms/EditMessageComposer.tsx
@@ -181,7 +181,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
                 } else {
                     this.clearStoredEditorState();
                     dis.dispatch({ action: 'edit_event', event: null });
-                    dis.fire(Action.FocusComposer);
+                    dis.fire(Action.FocusSendMessageComposer);
                 }
                 event.preventDefault();
                 break;
@@ -200,7 +200,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
     private cancelEdit = (): void => {
         this.clearStoredEditorState();
         dis.dispatch({ action: "edit_event", event: null });
-        dis.fire(Action.FocusComposer);
+        dis.fire(Action.FocusSendMessageComposer);
     };
 
     private get shouldSaveStoredEditorState(): boolean {
@@ -375,7 +375,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
 
         // close the event editing and focus composer
         dis.dispatch({ action: "edit_event", event: null });
-        dis.fire(Action.FocusComposer);
+        dis.fire(Action.FocusSendMessageComposer);
     };
 
     private cancelPreviousPendingEdit(): void {
diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx
index 2c45c1bbf8..0639c20fef 100644
--- a/src/components/views/rooms/SendMessageComposer.tsx
+++ b/src/components/views/rooms/SendMessageComposer.tsx
@@ -497,7 +497,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
 
         switch (payload.action) {
             case 'reply_to_event':
-            case Action.FocusComposer:
+            case Action.FocusSendMessageComposer:
                 this.editorRef.current?.focus();
                 break;
             case "send_composer_insert":
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index d376560ace..26011063b7 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -58,7 +58,7 @@ export enum Action {
     /**
      * Focuses the user's cursor to the send message composer. No additional payload information required.
      */
-    FocusComposer = "focus_composer",
+    FocusSendMessageComposer = "focus_composer",
 
     /**
      * Focuses the user's cursor to the edit message composer. No additional payload information required.

From 6401577fe49ea2e81a26299cc96dc56a0af7a72f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Thu, 8 Jul 2021 17:37:39 +0200
Subject: [PATCH 10/24] focus_composer -> focus_send_message_composer
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>
---
 src/dispatcher/actions.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 26011063b7..a4bfa171cd 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -58,7 +58,7 @@ export enum Action {
     /**
      * Focuses the user's cursor to the send message composer. No additional payload information required.
      */
-    FocusSendMessageComposer = "focus_composer",
+    FocusSendMessageComposer = "focus_send_message_composer",
 
     /**
      * Focuses the user's cursor to the edit message composer. No additional payload information required.

From 27db0da43738dfed89028044f0032d895c7dc28d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Thu, 8 Jul 2021 17:40:41 +0200
Subject: [PATCH 11/24] Simpler code
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>
---
 src/components/structures/RoomView.tsx | 12 +-----------
 1 file changed, 1 insertion(+), 11 deletions(-)

diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 0f8d7189b7..3ba14b32fb 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -834,17 +834,7 @@ export default class RoomView extends React.Component<IProps, IState> {
 
             case Action.FocusAComposer: {
                 // re-dispatch to the correct composer
-                if (this.state.editState) {
-                    dis.dispatch({
-                        ...payload,
-                        action: Action.FocusEditMessageComposer,
-                    });
-                } else {
-                    dis.dispatch({
-                        ...payload,
-                        action: Action.FocusSendMessageComposer,
-                    });
-                }
+                dis.fire(this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer);
                 break;
             }
 

From 7a1381135aab49837215ead81f963682f5fdf61e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Thu, 8 Jul 2021 18:31:47 +0200
Subject: [PATCH 12/24] Simplifie some code
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>
---
 src/components/structures/RoomView.tsx | 15 ++++-----------
 1 file changed, 4 insertions(+), 11 deletions(-)

diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 3ba14b32fb..8e0b8a5f4a 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -818,17 +818,10 @@ export default class RoomView extends React.Component<IProps, IState> {
 
             case Action.ComposerInsert: {
                 // re-dispatch to the correct composer
-                if (this.state.editState) {
-                    dis.dispatch({
-                        ...payload,
-                        action: "edit_composer_insert",
-                    });
-                } else {
-                    dis.dispatch({
-                        ...payload,
-                        action: "send_composer_insert",
-                    });
-                }
+                dis.dispatch({
+                    ...payload,
+                    action: this.state.editState ? "edit_composer_insert" : "send_composer_insert",
+                });
                 break;
             }
 

From 88a5969f2d0a790e52ce8be839889b055db4f509 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 10:32:43 +0200
Subject: [PATCH 13/24] Remove a word

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/views/directory/NetworkDropdown.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx
index 155349e39d..0492168f36 100644
--- a/src/components/views/directory/NetworkDropdown.tsx
+++ b/src/components/views/directory/NetworkDropdown.tsx
@@ -171,7 +171,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
 
             const protocolsList = server === hsName ? Object.values(protocols) : [];
             if (protocolsList.length > 0) {
-                // add a fake protocol with the ALL_ROOMS
+                // add a fake protocol with ALL_ROOMS
                 protocolsList.push({
                     instances: [{
                         fields: [],

From 84b00b5c388709aab1f7c0e3b45291fd7e0ec5a7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 10:39:02 +0200
Subject: [PATCH 14/24] Make the code more readable
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>
---
 src/components/structures/RoomDirectory.tsx | 31 +++++++++++----------
 1 file changed, 16 insertions(+), 15 deletions(-)

diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx
index 4a0e7615c4..4d335bf63c 100644
--- a/src/components/structures/RoomDirectory.tsx
+++ b/src/components/structures/RoomDirectory.tsx
@@ -122,25 +122,26 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
                 const myHomeserver = MatrixClientPeg.getHomeserverName();
                 const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
                 const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
-                const configSevers = SdkConfig.get().roomDirectory?.servers || [];
-                const settingsServers = SettingsStore.getValue("room_directory_servers") || [];
-                const roomServer = [...configSevers, ...settingsServers].includes(lsRoomServer)
-                    ? lsRoomServer
-                    : myHomeserver;
-                const instanceIds = [];
-                if (roomServer === myHomeserver) {
-                    Object.values(this.protocols).forEach((protocol) => {
-                        protocol.instances.forEach((instance) => instanceIds.push(instance.instance_id));
-                    });
+                const roomServer = (
+                    SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) ||
+                    SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
+                ) ? lsRoomServer : myHomeserver;
+
+                let instanceId: string = null;
+                if (
+                    (lsInstanceId === ALL_ROOMS) ||
+                    (
+                        roomServer === myHomeserver &&
+                        Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
+                    )
+                ) {
+                    instanceId = lsInstanceId;
                 }
-                const instanceId = (instanceIds.includes(lsInstanceId) || lsInstanceId === ALL_ROOMS)
-                    ? lsInstanceId
-                    : null;
 
                 this.setState({
                     protocolsLoading: false,
-                    instanceId: instanceId,
-                    roomServer: roomServer,
+                    instanceId,
+                    roomServer,
                 });
                 this.refreshRoomList();
             }, (err) => {

From 6dcf860181dc04bb93417a7d3687c505a1d0bedb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 10:44:20 +0200
Subject: [PATCH 15/24] Refresh the room list only if validation failed
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>
---
 src/components/structures/RoomDirectory.tsx | 21 +++++++++++++--------
 1 file changed, 13 insertions(+), 8 deletions(-)

diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx
index 4d335bf63c..b36531236a 100644
--- a/src/components/structures/RoomDirectory.tsx
+++ b/src/components/structures/RoomDirectory.tsx
@@ -138,12 +138,17 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
                     instanceId = lsInstanceId;
                 }
 
-                this.setState({
-                    protocolsLoading: false,
-                    instanceId,
-                    roomServer,
-                });
-                this.refreshRoomList();
+                // Refresh the room list only if validation failed and we had to change these
+                if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) {
+                    this.setState({
+                        protocolsLoading: false,
+                        instanceId,
+                        roomServer,
+                    });
+                    this.refreshRoomList();
+                    return;
+                }
+                this.setState({ protocolsLoading: false });
             }, (err) => {
                 console.warn(`error loading third party protocols: ${err}`);
                 this.setState({ protocolsLoading: false });
@@ -177,8 +182,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
             publicRooms: [],
             loading: true,
             error: null,
-            instanceId: null,
-            roomServer: null,
+            instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
+            roomServer: localStorage.getItem(LAST_SERVER_KEY),
             filterString: this.props.initialText || "",
             selectedCommunityId,
             communityName: null,

From 046b3f325c79eca455c0780331e8b44a90d3bc28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 12:31:44 +0200
Subject: [PATCH 16/24] Iterate PR
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>
---
 src/components/structures/RoomDirectory.tsx | 19 ++++++++++---------
 1 file changed, 10 insertions(+), 9 deletions(-)

diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx
index b36531236a..bd25a764a0 100644
--- a/src/components/structures/RoomDirectory.tsx
+++ b/src/components/structures/RoomDirectory.tsx
@@ -122,19 +122,20 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
                 const myHomeserver = MatrixClientPeg.getHomeserverName();
                 const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
                 const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
-                const roomServer = (
+
+                let roomServer = myHomeserver;
+                if (
                     SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) ||
                     SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
-                ) ? lsRoomServer : myHomeserver;
+                ) {
+                    roomServer = lsRoomServer;
+                }
 
                 let instanceId: string = null;
-                if (
-                    (lsInstanceId === ALL_ROOMS) ||
-                    (
-                        roomServer === myHomeserver &&
-                        Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
-                    )
-                ) {
+                if (roomServer === myHomeserver && (
+                    lsInstanceId === ALL_ROOMS ||
+                    Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
+                )) {
                     instanceId = lsInstanceId;
                 }
 

From 318a68e761623f7e75f9df2f246591c5e4c3f5a6 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 9 Jul 2021 11:49:05 +0100
Subject: [PATCH 17/24] Update Modernizr and stop it from polluting classes on
 the html tag

---
 src/@types/global.d.ts | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index d257ee4c5c..759cc306f5 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -15,7 +15,7 @@ limitations under the License.
 */
 
 import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
-import * as ModernizrStatic from "modernizr";
+import "@types/modernizr";
 
 import ContentMessages from "../ContentMessages";
 import { IMatrixClientPeg } from "../MatrixClientPeg";
@@ -50,7 +50,6 @@ import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
 
 declare global {
     interface Window {
-        Modernizr: ModernizrStatic;
         matrixChat: ReturnType<Renderer>;
         mxMatrixClientPeg: IMatrixClientPeg;
         Olm: {

From 866c1b76bd842e31ec1f040bd88ad5c8629a1772 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 12:57:42 +0200
Subject: [PATCH 18/24] Basic TS conversion
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>
---
 ...ettingsTab.js => VoiceUserSettingsTab.tsx} | 55 +++++++++++--------
 1 file changed, 31 insertions(+), 24 deletions(-)
 rename src/components/views/settings/tabs/user/{VoiceUserSettingsTab.js => VoiceUserSettingsTab.tsx} (84%)

diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
similarity index 84%
rename from src/components/views/settings/tabs/user/VoiceUserSettingsTab.js
rename to src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
index fe6261cb21..bce047665d 100644
--- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
@@ -18,7 +18,7 @@ limitations under the License.
 import React from 'react';
 import { _t } from "../../../../../languageHandler";
 import SdkConfig from "../../../../../SdkConfig";
-import MediaDeviceHandler from "../../../../../MediaDeviceHandler";
+import MediaDeviceHandler, { IMediaDevices } from "../../../../../MediaDeviceHandler";
 import Field from "../../../elements/Field";
 import AccessibleButton from "../../../elements/AccessibleButton";
 import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
@@ -27,13 +27,20 @@ import Modal from "../../../../../Modal";
 import { SettingLevel } from "../../../../../settings/SettingLevel";
 import { replaceableComponent } from "../../../../../utils/replaceableComponent";
 
+interface IState {
+    mediaDevices: IMediaDevices;
+    activeAudioOutput: string;
+    activeAudioInput: string;
+    activeVideoInput: string;
+}
+
 @replaceableComponent("views.settings.tabs.user.VoiceUserSettingsTab")
-export default class VoiceUserSettingsTab extends React.Component {
+export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
     constructor() {
-        super();
+        super({});
 
         this.state = {
-            mediaDevices: false,
+            mediaDevices: null,
             activeAudioOutput: null,
             activeAudioInput: null,
             activeVideoInput: null,
@@ -43,11 +50,11 @@ export default class VoiceUserSettingsTab extends React.Component {
     async componentDidMount() {
         const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices();
         if (canSeeDeviceLabels) {
-            this._refreshMediaDevices();
+            this.refreshMediaDevices();
         }
     }
 
-    _refreshMediaDevices = async (stream) => {
+    private refreshMediaDevices = async (stream?: MediaStream) => {
         this.setState({
             mediaDevices: await MediaDeviceHandler.getDevices(),
             activeAudioOutput: MediaDeviceHandler.getAudioOutput(),
@@ -62,7 +69,7 @@ export default class VoiceUserSettingsTab extends React.Component {
         }
     };
 
-    _requestMediaPermissions = async () => {
+    private requestMediaPermissions = async () => {
         let constraints;
         let stream;
         let error;
@@ -95,40 +102,40 @@ export default class VoiceUserSettingsTab extends React.Component {
                 ),
             });
         } else {
-            this._refreshMediaDevices(stream);
+            this.refreshMediaDevices(stream);
         }
     };
 
-    _setAudioOutput = (e) => {
+    private setAudioOutput = (e) => {
         MediaDeviceHandler.instance.setAudioOutput(e.target.value);
         this.setState({
             activeAudioOutput: e.target.value,
         });
     };
 
-    _setAudioInput = (e) => {
+    private setAudioInput = (e) => {
         MediaDeviceHandler.instance.setAudioInput(e.target.value);
         this.setState({
             activeAudioInput: e.target.value,
         });
     };
 
-    _setVideoInput = (e) => {
+    private setVideoInput = (e) => {
         MediaDeviceHandler.instance.setVideoInput(e.target.value);
         this.setState({
             activeVideoInput: e.target.value,
         });
     };
 
-    _changeWebRtcMethod = (p2p) => {
+    private changeWebRtcMethod = (p2p) => {
         MatrixClientPeg.get().setForceTURN(!p2p);
     };
 
-    _changeFallbackICEServerAllowed = (allow) => {
+    private changeFallbackICEServerAllowed = (allow) => {
         MatrixClientPeg.get().setFallbackICEServerAllowed(allow);
     };
 
-    _renderDeviceOptions(devices, category) {
+    private renderDeviceOptions(devices, category) {
         return devices.map((d) => {
             return (<option key={`${category}-${d.deviceId}`} value={d.deviceId}>{d.label}</option>);
         });
@@ -141,11 +148,11 @@ export default class VoiceUserSettingsTab extends React.Component {
         let speakerDropdown = null;
         let microphoneDropdown = null;
         let webcamDropdown = null;
-        if (this.state.mediaDevices === false) {
+        if (!this.state.mediaDevices) {
             requestButton = (
                 <div className='mx_VoiceUserSettingsTab_missingMediaPermissions'>
                     <p>{_t("Missing media permissions, click the button below to request.")}</p>
-                    <AccessibleButton onClick={this._requestMediaPermissions} kind="primary">
+                    <AccessibleButton onClick={this.requestMediaPermissions} kind="primary">
                         {_t("Request media permissions")}
                     </AccessibleButton>
                 </div>
@@ -177,8 +184,8 @@ export default class VoiceUserSettingsTab extends React.Component {
                 speakerDropdown = (
                     <Field element="select" label={_t("Audio Output")}
                         value={this.state.activeAudioOutput || defaultDevice}
-                        onChange={this._setAudioOutput}>
-                        {this._renderDeviceOptions(audioOutputs, 'audioOutput')}
+                        onChange={this.setAudioOutput}>
+                        {this.renderDeviceOptions(audioOutputs, 'audioOutput')}
                     </Field>
                 );
             }
@@ -189,8 +196,8 @@ export default class VoiceUserSettingsTab extends React.Component {
                 microphoneDropdown = (
                     <Field element="select" label={_t("Microphone")}
                         value={this.state.activeAudioInput || defaultDevice}
-                        onChange={this._setAudioInput}>
-                        {this._renderDeviceOptions(audioInputs, 'audioInput')}
+                        onChange={this.setAudioInput}>
+                        {this.renderDeviceOptions(audioInputs, 'audioInput')}
                     </Field>
                 );
             }
@@ -201,8 +208,8 @@ export default class VoiceUserSettingsTab extends React.Component {
                 webcamDropdown = (
                     <Field element="select" label={_t("Camera")}
                         value={this.state.activeVideoInput || defaultDevice}
-                        onChange={this._setVideoInput}>
-                        {this._renderDeviceOptions(videoInputs, 'videoInput')}
+                        onChange={this.setVideoInput}>
+                        {this.renderDeviceOptions(videoInputs, 'videoInput')}
                     </Field>
                 );
             }
@@ -220,12 +227,12 @@ export default class VoiceUserSettingsTab extends React.Component {
                     <SettingsFlag
                         name='webRtcAllowPeerToPeer'
                         level={SettingLevel.DEVICE}
-                        onChange={this._changeWebRtcMethod}
+                        onChange={this.changeWebRtcMethod}
                     />
                     <SettingsFlag
                         name='fallbackICEServerAllowed'
                         level={SettingLevel.DEVICE}
-                        onChange={this._changeFallbackICEServerAllowed}
+                        onChange={this.changeFallbackICEServerAllowed}
                     />
                 </div>
             </div>

From dadfba90753607508872619679025ee63a76b53f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 13:08:39 +0200
Subject: [PATCH 19/24] Add MediaDeviceKindEnum
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>
---
 src/MediaDeviceHandler.ts                     | 31 +++++++++----------
 .../views/rooms/VoiceRecordComposerTile.tsx   |  4 +--
 .../tabs/user/VoiceUserSettingsTab.tsx        |  8 ++---
 3 files changed, 20 insertions(+), 23 deletions(-)

diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts
index 49ef123def..bc0291a623 100644
--- a/src/MediaDeviceHandler.ts
+++ b/src/MediaDeviceHandler.ts
@@ -20,12 +20,15 @@ import { SettingLevel } from "./settings/SettingLevel";
 import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
 import EventEmitter from 'events';
 
-interface IMediaDevices {
-    audioOutput: Array<MediaDeviceInfo>;
-    audioInput: Array<MediaDeviceInfo>;
-    videoInput: Array<MediaDeviceInfo>;
+// XXX: MediaDeviceKind is a union type, so we make our own enum
+export enum MediaDeviceKindEnum {
+    AudioOutput = "audiooutput",
+    AudioInput = "audioinput",
+    VideoInput = "videoinput",
 }
 
+export type IMediaDevices = Record<MediaDeviceKindEnum, Array<MediaDeviceInfo>>;
+
 export enum MediaDeviceHandlerEvent {
     AudioOutputChanged = "audio_output_changed",
 }
@@ -51,20 +54,14 @@ export default class MediaDeviceHandler extends EventEmitter {
 
         try {
             const devices = await navigator.mediaDevices.enumerateDevices();
+            const output = {
+                [MediaDeviceKindEnum.AudioOutput]: [],
+                [MediaDeviceKindEnum.AudioInput]: [],
+                [MediaDeviceKindEnum.VideoInput]: [],
+            };
 
-            const audioOutput = [];
-            const audioInput = [];
-            const videoInput = [];
-
-            devices.forEach((device) => {
-                switch (device.kind) {
-                    case 'audiooutput': audioOutput.push(device); break;
-                    case 'audioinput': audioInput.push(device); break;
-                    case 'videoinput': videoInput.push(device); break;
-                }
-            });
-
-            return { audioOutput, audioInput, videoInput };
+            devices.forEach((device) => output[device.kind].push(device));
+            return output;
         } catch (error) {
             console.warn('Unable to refresh WebRTC Devices: ', error);
         }
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index f08c8fe6df..5d984eacfa 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -33,7 +33,7 @@ import RecordingPlayback from "../audio_messages/RecordingPlayback";
 import { MsgType } from "matrix-js-sdk/src/@types/event";
 import Modal from "../../../Modal";
 import ErrorDialog from "../dialogs/ErrorDialog";
-import MediaDeviceHandler from "../../../MediaDeviceHandler";
+import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
 
 interface IProps {
     room: Room;
@@ -135,7 +135,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
         // change between this and recording, but at least we will have tried.
         try {
             const devices = await MediaDeviceHandler.getDevices();
-            if (!devices?.['audioInput']?.length) {
+            if (!devices?.[MediaDeviceKindEnum.AudioInput]?.length) {
                 Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
                     title: _t("No microphone found"),
                     description: <>
diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
index bce047665d..f5adc05d6b 100644
--- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
@@ -18,7 +18,7 @@ limitations under the License.
 import React from 'react';
 import { _t } from "../../../../../languageHandler";
 import SdkConfig from "../../../../../SdkConfig";
-import MediaDeviceHandler, { IMediaDevices } from "../../../../../MediaDeviceHandler";
+import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../MediaDeviceHandler";
 import Field from "../../../elements/Field";
 import AccessibleButton from "../../../elements/AccessibleButton";
 import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
@@ -178,7 +178,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
                 }
             };
 
-            const audioOutputs = this.state.mediaDevices.audioOutput.slice(0);
+            const audioOutputs = this.state.mediaDevices[MediaDeviceKindEnum.AudioOutput].slice(0);
             if (audioOutputs.length > 0) {
                 const defaultDevice = getDefaultDevice(audioOutputs);
                 speakerDropdown = (
@@ -190,7 +190,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
                 );
             }
 
-            const audioInputs = this.state.mediaDevices.audioInput.slice(0);
+            const audioInputs = this.state.mediaDevices[MediaDeviceKindEnum.AudioInput].slice(0);
             if (audioInputs.length > 0) {
                 const defaultDevice = getDefaultDevice(audioInputs);
                 microphoneDropdown = (
@@ -202,7 +202,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
                 );
             }
 
-            const videoInputs = this.state.mediaDevices.videoInput.slice(0);
+            const videoInputs = this.state.mediaDevices[MediaDeviceKindEnum.VideoInput].slice(0);
             if (videoInputs.length > 0) {
                 const defaultDevice = getDefaultDevice(videoInputs);
                 webcamDropdown = (

From cd95be147c87afd5136db324313052c1efe7ed15 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 13:45:39 +0200
Subject: [PATCH 20/24] Finish TS conversion
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>
---
 .../tabs/user/VoiceUserSettingsTab.tsx        | 57 +++++++++----------
 1 file changed, 26 insertions(+), 31 deletions(-)

diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
index f5adc05d6b..02ab96667c 100644
--- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
@@ -22,16 +22,14 @@ import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../..
 import Field from "../../../elements/Field";
 import AccessibleButton from "../../../elements/AccessibleButton";
 import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
-import * as sdk from "../../../../../index";
 import Modal from "../../../../../Modal";
 import { SettingLevel } from "../../../../../settings/SettingLevel";
 import { replaceableComponent } from "../../../../../utils/replaceableComponent";
+import SettingsFlag from '../../../elements/SettingsFlag';
+import ErrorDialog from '../../../dialogs/ErrorDialog';
 
-interface IState {
+interface IState extends Record<MediaDeviceKindEnum, string> {
     mediaDevices: IMediaDevices;
-    activeAudioOutput: string;
-    activeAudioInput: string;
-    activeVideoInput: string;
 }
 
 @replaceableComponent("views.settings.tabs.user.VoiceUserSettingsTab")
@@ -41,9 +39,9 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
 
         this.state = {
             mediaDevices: null,
-            activeAudioOutput: null,
-            activeAudioInput: null,
-            activeVideoInput: null,
+            [MediaDeviceKindEnum.AudioOutput]: null,
+            [MediaDeviceKindEnum.AudioInput]: null,
+            [MediaDeviceKindEnum.VideoInput]: null,
         };
     }
 
@@ -54,12 +52,12 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
         }
     }
 
-    private refreshMediaDevices = async (stream?: MediaStream) => {
+    private refreshMediaDevices = async (stream?: MediaStream): Promise<void> => {
         this.setState({
             mediaDevices: await MediaDeviceHandler.getDevices(),
-            activeAudioOutput: MediaDeviceHandler.getAudioOutput(),
-            activeAudioInput: MediaDeviceHandler.getAudioInput(),
-            activeVideoInput: MediaDeviceHandler.getVideoInput(),
+            [MediaDeviceKindEnum.AudioOutput]: MediaDeviceHandler.getAudioOutput(),
+            [MediaDeviceKindEnum.AudioInput]: MediaDeviceHandler.getAudioInput(),
+            [MediaDeviceKindEnum.VideoInput]: MediaDeviceHandler.getVideoInput(),
         });
         if (stream) {
             // kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
@@ -69,7 +67,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
         }
     };
 
-    private requestMediaPermissions = async () => {
+    private requestMediaPermissions = async (): Promise<void> => {
         let constraints;
         let stream;
         let error;
@@ -93,7 +91,6 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
         if (error) {
             console.log("Failed to list userMedia devices", error);
             const brand = SdkConfig.get().brand;
-            const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
             Modal.createTrackedDialog('No media permissions', '', ErrorDialog, {
                 title: _t('No media permissions'),
                 description: _t(
@@ -106,44 +103,42 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
         }
     };
 
-    private setAudioOutput = (e) => {
+    private setAudioOutput = (e): void => {
         MediaDeviceHandler.instance.setAudioOutput(e.target.value);
         this.setState({
-            activeAudioOutput: e.target.value,
+            [MediaDeviceKindEnum.AudioOutput]: e.target.value,
         });
     };
 
-    private setAudioInput = (e) => {
+    private setAudioInput = (e): void => {
         MediaDeviceHandler.instance.setAudioInput(e.target.value);
         this.setState({
-            activeAudioInput: e.target.value,
+            [MediaDeviceKindEnum.AudioInput]: e.target.value,
         });
     };
 
-    private setVideoInput = (e) => {
+    private setVideoInput = (e): void => {
         MediaDeviceHandler.instance.setVideoInput(e.target.value);
         this.setState({
-            activeVideoInput: e.target.value,
+            [MediaDeviceKindEnum.VideoInput]: e.target.value,
         });
     };
 
-    private changeWebRtcMethod = (p2p) => {
+    private changeWebRtcMethod = (p2p: boolean): void => {
         MatrixClientPeg.get().setForceTURN(!p2p);
     };
 
-    private changeFallbackICEServerAllowed = (allow) => {
+    private changeFallbackICEServerAllowed = (allow: boolean): void => {
         MatrixClientPeg.get().setFallbackICEServerAllowed(allow);
     };
 
-    private renderDeviceOptions(devices, category) {
+    private renderDeviceOptions(devices: Array<MediaDeviceInfo>, category: MediaDeviceKindEnum): Array<JSX.Element> {
         return devices.map((d) => {
             return (<option key={`${category}-${d.deviceId}`} value={d.deviceId}>{d.label}</option>);
         });
     }
 
     render() {
-        const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
-
         let requestButton = null;
         let speakerDropdown = null;
         let microphoneDropdown = null;
@@ -183,9 +178,9 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
                 const defaultDevice = getDefaultDevice(audioOutputs);
                 speakerDropdown = (
                     <Field element="select" label={_t("Audio Output")}
-                        value={this.state.activeAudioOutput || defaultDevice}
+                        value={this.state[MediaDeviceKindEnum.AudioOutput] || defaultDevice}
                         onChange={this.setAudioOutput}>
-                        {this.renderDeviceOptions(audioOutputs, 'audioOutput')}
+                        {this.renderDeviceOptions(audioOutputs, MediaDeviceKindEnum.AudioOutput)}
                     </Field>
                 );
             }
@@ -195,9 +190,9 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
                 const defaultDevice = getDefaultDevice(audioInputs);
                 microphoneDropdown = (
                     <Field element="select" label={_t("Microphone")}
-                        value={this.state.activeAudioInput || defaultDevice}
+                        value={this.state[MediaDeviceKindEnum.AudioInput] || defaultDevice}
                         onChange={this.setAudioInput}>
-                        {this.renderDeviceOptions(audioInputs, 'audioInput')}
+                        {this.renderDeviceOptions(audioInputs, MediaDeviceKindEnum.AudioInput)}
                     </Field>
                 );
             }
@@ -207,9 +202,9 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
                 const defaultDevice = getDefaultDevice(videoInputs);
                 webcamDropdown = (
                     <Field element="select" label={_t("Camera")}
-                        value={this.state.activeVideoInput || defaultDevice}
+                        value={this.state[MediaDeviceKindEnum.VideoInput] || defaultDevice}
                         onChange={this.setVideoInput}>
-                        {this.renderDeviceOptions(videoInputs, 'videoInput')}
+                        {this.renderDeviceOptions(videoInputs, MediaDeviceKindEnum.VideoInput)}
                     </Field>
                 );
             }

From 1b209a9cb32a2505696cc1fed782f71740736769 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 14:13:31 +0200
Subject: [PATCH 21/24] Add setDevice() method
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>
---
 src/MediaDeviceHandler.ts | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts
index bc0291a623..073f24523d 100644
--- a/src/MediaDeviceHandler.ts
+++ b/src/MediaDeviceHandler.ts
@@ -103,6 +103,14 @@ export default class MediaDeviceHandler extends EventEmitter {
         setMatrixCallVideoInput(deviceId);
     }
 
+    public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {
+        switch (kind) {
+            case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break;
+            case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break;
+            case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break;
+        }
+    }
+
     public static getAudioOutput(): string {
         return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
     }

From dbc37675a02bd23dd5e0e1f7d2b7bd72e2dce425 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 14:15:36 +0200
Subject: [PATCH 22/24] Simplifie the code
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>
---
 .../tabs/user/VoiceUserSettingsTab.tsx        | 133 +++++++-----------
 1 file changed, 53 insertions(+), 80 deletions(-)

diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
index 02ab96667c..3fd4455271 100644
--- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
@@ -28,14 +28,29 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
 import SettingsFlag from '../../../elements/SettingsFlag';
 import ErrorDialog from '../../../dialogs/ErrorDialog';
 
+const getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>) => {
+    // Note we're looking for a device with deviceId 'default' but adding a device
+    // with deviceId == the empty string: this is because Chrome gives us a device
+    // with deviceId 'default', so we're looking for this, not the one we are adding.
+    if (!devices.some((i) => i.deviceId === 'default')) {
+        devices.unshift({
+        deviceId: '',
+        label: _t('Default Device'),
+        });
+        return '';
+    } else {
+        return 'default';
+    }
+};
+
 interface IState extends Record<MediaDeviceKindEnum, string> {
     mediaDevices: IMediaDevices;
 }
 
 @replaceableComponent("views.settings.tabs.user.VoiceUserSettingsTab")
 export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
-    constructor() {
-        super({});
+    constructor(props: {}) {
+        super(props);
 
         this.state = {
             mediaDevices: null,
@@ -103,25 +118,9 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
         }
     };
 
-    private setAudioOutput = (e): void => {
-        MediaDeviceHandler.instance.setAudioOutput(e.target.value);
-        this.setState({
-            [MediaDeviceKindEnum.AudioOutput]: e.target.value,
-        });
-    };
-
-    private setAudioInput = (e): void => {
-        MediaDeviceHandler.instance.setAudioInput(e.target.value);
-        this.setState({
-            [MediaDeviceKindEnum.AudioInput]: e.target.value,
-        });
-    };
-
-    private setVideoInput = (e): void => {
-        MediaDeviceHandler.instance.setVideoInput(e.target.value);
-        this.setState({
-            [MediaDeviceKindEnum.VideoInput]: e.target.value,
-        });
+    private setDevice = (deviceId: string, kind: MediaDeviceKindEnum): void => {
+        MediaDeviceHandler.instance.setDevice(deviceId, kind);
+        this.setState<null>({ [kind]: deviceId });
     };
 
     private changeWebRtcMethod = (p2p: boolean): void => {
@@ -138,6 +137,23 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
         });
     }
 
+    private renderDropdown(kind: MediaDeviceKindEnum, label: string): JSX.Element {
+        const devices = this.state.mediaDevices[kind].slice(0);
+        if (devices.length === 0) return null;
+
+        const defaultDevice = getDefaultDevice(devices);
+        return (
+            <Field
+                element="select"
+                label={label}
+                value={this.state[kind] || defaultDevice}
+                onChange={(e) => this.setDevice(e.target.value, kind)}
+            >
+                { this.renderDeviceOptions(devices, kind) }
+            </Field>
+        );
+    }
+
     render() {
         let requestButton = null;
         let speakerDropdown = null;
@@ -153,71 +169,28 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
                 </div>
             );
         } else if (this.state.mediaDevices) {
-            speakerDropdown = <p>{ _t('No Audio Outputs detected') }</p>;
-            microphoneDropdown = <p>{ _t('No Microphones detected') }</p>;
-            webcamDropdown = <p>{ _t('No Webcams detected') }</p>;
-
-            const defaultOption = {
-                deviceId: '',
-                label: _t('Default Device'),
-            };
-            const getDefaultDevice = (devices) => {
-                // Note we're looking for a device with deviceId 'default' but adding a device
-                // with deviceId == the empty string: this is because Chrome gives us a device
-                // with deviceId 'default', so we're looking for this, not the one we are adding.
-                if (!devices.some((i) => i.deviceId === 'default')) {
-                    devices.unshift(defaultOption);
-                    return '';
-                } else {
-                    return 'default';
-                }
-            };
-
-            const audioOutputs = this.state.mediaDevices[MediaDeviceKindEnum.AudioOutput].slice(0);
-            if (audioOutputs.length > 0) {
-                const defaultDevice = getDefaultDevice(audioOutputs);
-                speakerDropdown = (
-                    <Field element="select" label={_t("Audio Output")}
-                        value={this.state[MediaDeviceKindEnum.AudioOutput] || defaultDevice}
-                        onChange={this.setAudioOutput}>
-                        {this.renderDeviceOptions(audioOutputs, MediaDeviceKindEnum.AudioOutput)}
-                    </Field>
-                );
-            }
-
-            const audioInputs = this.state.mediaDevices[MediaDeviceKindEnum.AudioInput].slice(0);
-            if (audioInputs.length > 0) {
-                const defaultDevice = getDefaultDevice(audioInputs);
-                microphoneDropdown = (
-                    <Field element="select" label={_t("Microphone")}
-                        value={this.state[MediaDeviceKindEnum.AudioInput] || defaultDevice}
-                        onChange={this.setAudioInput}>
-                        {this.renderDeviceOptions(audioInputs, MediaDeviceKindEnum.AudioInput)}
-                    </Field>
-                );
-            }
-
-            const videoInputs = this.state.mediaDevices[MediaDeviceKindEnum.VideoInput].slice(0);
-            if (videoInputs.length > 0) {
-                const defaultDevice = getDefaultDevice(videoInputs);
-                webcamDropdown = (
-                    <Field element="select" label={_t("Camera")}
-                        value={this.state[MediaDeviceKindEnum.VideoInput] || defaultDevice}
-                        onChange={this.setVideoInput}>
-                        {this.renderDeviceOptions(videoInputs, MediaDeviceKindEnum.VideoInput)}
-                    </Field>
-                );
-            }
+            speakerDropdown = (
+                this.renderDropdown(MediaDeviceKindEnum.AudioOutput, _t("Audio Output")) ||
+                <p>{ _t('No Audio Outputs detected') }</p>
+            );
+            microphoneDropdown = (
+                this.renderDropdown(MediaDeviceKindEnum.AudioInput, _t("Microphone")) ||
+                <p>{ _t('No Microphones detected') }</p>
+            );
+            webcamDropdown = (
+                this.renderDropdown(MediaDeviceKindEnum.VideoInput, _t("Camera")) ||
+                <p>{ _t('No Webcams detected') }</p>
+            );
         }
 
         return (
             <div className="mx_SettingsTab mx_VoiceUserSettingsTab">
                 <div className="mx_SettingsTab_heading">{_t("Voice & Video")}</div>
                 <div className="mx_SettingsTab_section">
-                    {requestButton}
-                    {speakerDropdown}
-                    {microphoneDropdown}
-                    {webcamDropdown}
+                    { requestButton }
+                    { speakerDropdown }
+                    { microphoneDropdown }
+                    { webcamDropdown }
                     <SettingsFlag name='VideoView.flipVideoHorizontally' level={SettingLevel.ACCOUNT} />
                     <SettingsFlag
                         name='webRtcAllowPeerToPeer'

From 96100ffaf386c1febb2bc972c31f28e618d3886d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 14:19:27 +0200
Subject: [PATCH 23/24] Fix styling
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>
---
 .../views/settings/tabs/user/VoiceUserSettingsTab.tsx        | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
index 3fd4455271..86c32cc6cd 100644
--- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
@@ -33,10 +33,7 @@ const getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>) => {
     // with deviceId == the empty string: this is because Chrome gives us a device
     // with deviceId 'default', so we're looking for this, not the one we are adding.
     if (!devices.some((i) => i.deviceId === 'default')) {
-        devices.unshift({
-        deviceId: '',
-        label: _t('Default Device'),
-        });
+        devices.unshift({ deviceId: '', label: _t('Default Device') });
         return '';
     } else {
         return 'default';

From cdeb0be84778ff790873f619a748274c8745bdb3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com>
Date: Fri, 9 Jul 2021 14:22:41 +0200
Subject: [PATCH 24/24] i18n
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>
---
 src/i18n/strings/en_EN.json | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 7d4252545b..7795bb2610 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1364,17 +1364,17 @@
     "Where you’re logged in": "Where you’re logged in",
     "Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.": "Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.",
     "A session's public name is visible to people you communicate with": "A session's public name is visible to people you communicate with",
+    "Default Device": "Default Device",
     "No media permissions": "No media permissions",
     "You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam",
     "Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
     "Request media permissions": "Request media permissions",
-    "No Audio Outputs detected": "No Audio Outputs detected",
-    "No Microphones detected": "No Microphones detected",
-    "No Webcams detected": "No Webcams detected",
-    "Default Device": "Default Device",
     "Audio Output": "Audio Output",
+    "No Audio Outputs detected": "No Audio Outputs detected",
     "Microphone": "Microphone",
+    "No Microphones detected": "No Microphones detected",
     "Camera": "Camera",
+    "No Webcams detected": "No Webcams detected",
     "Voice & Video": "Voice & Video",
     "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
     "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.",