diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index 0504e3a76a..19edf505e0 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -452,9 +452,7 @@ class LoggedInView extends React.PureComponent
{
// composer, so CTRL+` it is
if (ctrlCmdOnly) {
- dis.dispatch({
- action: 'toggle_top_left_menu',
- });
+ dis.fire(Action.ToggleUserMenu);
handled = true;
}
break;
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index 69f91047b7..e08381d8fa 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -72,6 +72,7 @@ import {
hideToast as hideAnalyticsToast
} from "../../toasts/AnalyticsToast";
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
+import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
/** constants for MatrixChat.state.view */
export enum Views {
@@ -604,9 +605,12 @@ export default class MatrixChat extends React.PureComponent {
this.viewIndexedRoom(payload.roomIndex);
break;
case Action.ViewUserSettings: {
+ const tabPayload = payload as OpenToTabPayload;
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
- Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
- /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
+ Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
+ {initialTabId: tabPayload.initialTabId},
+ /*className=*/null, /*isPriority=*/false, /*isStatic=*/true
+ );
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx
index c0e0e58db8..704dbf8832 100644
--- a/src/components/structures/TabbedView.tsx
+++ b/src/components/structures/TabbedView.tsx
@@ -27,25 +27,20 @@ import { ReactNode } from "react";
* Represents a tab for the TabbedView.
*/
export class Tab {
- public label: string;
- public icon: string;
- public body: React.ReactNode;
-
/**
* Creates a new tab.
- * @param {string} tabLabel The untranslated tab label.
- * @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
- * @param {React.ReactNode} tabJsx The JSX for the tab container.
+ * @param {string} id The tab's ID.
+ * @param {string} label The untranslated tab label.
+ * @param {string} icon The class for the tab icon. This should be a simple mask.
+ * @param {React.ReactNode} body The JSX for the tab container.
*/
- constructor(tabLabel: string, tabIconClass: string, tabJsx: React.ReactNode) {
- this.label = tabLabel;
- this.icon = tabIconClass;
- this.body = tabJsx;
+ constructor(public id: string, public label: string, public icon: string, public body: React.ReactNode) {
}
}
interface IProps {
tabs: Tab[];
+ initialTabId?: string;
}
interface IState {
@@ -53,16 +48,17 @@ interface IState {
}
export default class TabbedView extends React.Component {
- static propTypes = {
- // The tabs to show
- tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
- };
-
constructor(props: IProps) {
super(props);
+ let activeTabIndex = 0;
+ if (props.initialTabId) {
+ const tabIndex = props.tabs.findIndex(t => t.id === props.initialTabId);
+ if (tabIndex >= 0) activeTabIndex = tabIndex;
+ }
+
this.state = {
- activeTabIndex: 0,
+ activeTabIndex,
};
}
diff --git a/src/components/structures/TopLeftMenuButton.js b/src/components/structures/TopLeftMenuButton.js
index 234dc661f9..71e7e61406 100644
--- a/src/components/structures/TopLeftMenuButton.js
+++ b/src/components/structures/TopLeftMenuButton.js
@@ -24,6 +24,7 @@ import * as Avatar from '../../Avatar';
import { _t } from '../../languageHandler';
import dis from "../../dispatcher/dispatcher";
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
+import {Action} from "../../dispatcher/actions";
const AVATAR_SIZE = 28;
@@ -75,7 +76,7 @@ export default class TopLeftMenuButton extends React.Component {
onAction = (payload) => {
// For accessibility
- if (payload.action === "toggle_top_left_menu") {
+ if (payload.action === Action.ToggleUserMenu) {
if (this._buttonRef) this._buttonRef.click();
}
};
diff --git a/src/components/structures/UserMenuButton.tsx b/src/components/structures/UserMenuButton.tsx
new file mode 100644
index 0000000000..d8f96d4a91
--- /dev/null
+++ b/src/components/structures/UserMenuButton.tsx
@@ -0,0 +1,270 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import * as React from "react";
+import {User} from "matrix-js-sdk/src/models/user";
+import { MatrixClientPeg } from "../../MatrixClientPeg";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { ActionPayload } from "../../dispatcher/payloads";
+import { Action } from "../../dispatcher/actions";
+import { createRef } from "react";
+import { _t } from "../../languageHandler";
+import {ContextMenu, ContextMenuButton} from "./ContextMenu";
+import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
+import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
+import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
+import Modal from "../../Modal";
+import LogoutDialog from "../views/dialogs/LogoutDialog";
+import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
+import {getCustomTheme} from "../../theme";
+import {getHostingLink} from "../../utils/HostingLink";
+import AccessibleButton from "../views/elements/AccessibleButton";
+
+interface IProps {
+}
+
+interface IState {
+ user: User;
+ menuDisplayed: boolean;
+ isDarkTheme: boolean;
+}
+
+export default class UserMenuButton extends React.Component {
+ private dispatcherRef: string;
+ private themeWatcherRef: string;
+ private buttonRef: React.RefObject = createRef();
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ menuDisplayed: false,
+ user: MatrixClientPeg.get().getUser(MatrixClientPeg.get().getUserId()),
+ isDarkTheme: this.isUserOnDarkTheme(),
+ };
+ }
+
+ private get displayName(): string {
+ if (MatrixClientPeg.get().isGuest()) {
+ return _t("Guest");
+ } else if (this.state.user) {
+ return this.state.user.displayName;
+ } else {
+ return MatrixClientPeg.get().getUserId();
+ }
+ }
+
+ public componentDidMount() {
+ this.dispatcherRef = defaultDispatcher.register(this.onAction);
+ this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
+ }
+
+ public componentWillUnmount() {
+ if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
+ if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
+ }
+
+ private isUserOnDarkTheme(): boolean {
+ const theme = SettingsStore.getValue("theme");
+ if (theme.startsWith("custom-")) {
+ return getCustomTheme(theme.substring(0, 7)).is_dark;
+ }
+ return theme === "dark";
+ }
+
+ private onThemeChanged = () => {
+ this.setState({isDarkTheme: this.isUserOnDarkTheme()});
+ };
+
+ private onAction = (ev: ActionPayload) => {
+ if (ev.action !== Action.ToggleUserMenu) return; // not interested
+
+ // For accessibility
+ if (this.buttonRef.current) this.buttonRef.current.click();
+ };
+
+ private onOpenMenuClick = (ev: InputEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.setState({menuDisplayed: true});
+ };
+
+ private onCloseMenu = () => {
+ this.setState({menuDisplayed: false});
+ };
+
+ private onSwitchThemeClick = () => {
+ // Disable system theme matching if the user hits this button
+ SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
+
+ const newTheme = this.state.isDarkTheme ? "light" : "dark";
+ SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme);
+ };
+
+ private onSettingsOpen = (ev: React.MouseEvent, tabId: string) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
+ defaultDispatcher.dispatch(payload);
+ this.setState({menuDisplayed: false}); // also close the menu
+ };
+
+ private onShowArchived = (ev: React.MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ // TODO: Archived room view (deferred)
+ console.log("TODO: Show archived rooms");
+ };
+
+ private onProvideFeedback = (ev: React.MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
+ this.setState({menuDisplayed: false}); // also close the menu
+ };
+
+ private onSignOutClick = (ev: React.MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
+ this.setState({menuDisplayed: false}); // also close the menu
+ };
+
+ public render() {
+ let contextMenu;
+ if (this.state.menuDisplayed) {
+ let hostingLink;
+ const signupLink = getHostingLink("user-context-menu");
+ if (signupLink) {
+ hostingLink = (
+
+ {_t(
+ "
Upgrade to your own domain", {},
+ {
+ a: sub => (
+
{sub}
+ ),
+ },
+ )}
+
+ );
+ }
+
+ const elementRect = this.buttonRef.current.getBoundingClientRect();
+ contextMenu = (
+
+
+
+
+
+ {this.displayName}
+
+
+ {MatrixClientPeg.get().getUserId()}
+
+
+
+
})
+
+
+ {hostingLink}
+
+
+
+ -
+
+
+ {_t("Sign out")}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {contextMenu}
+
+ )
+ }
+}
diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js
index c2b98cd9f3..7ad1001f75 100644
--- a/src/components/views/dialogs/RoomSettingsDialog.js
+++ b/src/components/views/dialogs/RoomSettingsDialog.js
@@ -30,6 +30,13 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore";
+export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB";
+export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB";
+export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB";
+export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
+export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB";
+export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB";
+
export default class RoomSettingsDialog extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
@@ -56,21 +63,25 @@ export default class RoomSettingsDialog extends React.Component {
const tabs = [];
tabs.push(new Tab(
+ ROOM_GENERAL_TAB,
_td("General"),
"mx_RoomSettingsDialog_settingsIcon",
,
));
tabs.push(new Tab(
+ ROOM_SECURITY_TAB,
_td("Security & Privacy"),
"mx_RoomSettingsDialog_securityIcon",
,
));
tabs.push(new Tab(
+ ROOM_ROLES_TAB,
_td("Roles & Permissions"),
"mx_RoomSettingsDialog_rolesIcon",
,
));
tabs.push(new Tab(
+ ROOM_NOTIFICATIONS_TAB,
_td("Notifications"),
"mx_RoomSettingsDialog_notificationsIcon",
,
@@ -78,6 +89,7 @@ export default class RoomSettingsDialog extends React.Component {
if (SettingsStore.isFeatureEnabled("feature_bridge_state")) {
tabs.push(new Tab(
+ ROOM_BRIDGES_TAB,
_td("Bridges"),
"mx_RoomSettingsDialog_bridgesIcon",
,
@@ -85,6 +97,7 @@ export default class RoomSettingsDialog extends React.Component {
}
tabs.push(new Tab(
+ ROOM_ADVANCED_TAB,
_td("Advanced"),
"mx_RoomSettingsDialog_warningIcon",
,
diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js
index 4592d921a9..1f1a8d1523 100644
--- a/src/components/views/dialogs/UserSettingsDialog.js
+++ b/src/components/views/dialogs/UserSettingsDialog.js
@@ -33,9 +33,21 @@ import * as sdk from "../../../index";
import SdkConfig from "../../../SdkConfig";
import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
+export const USER_GENERAL_TAB = "USER_GENERAL_TAB";
+export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB";
+export const USER_FLAIR_TAB = "USER_FLAIR_TAB";
+export const USER_NOTIFICATIONS_TAB = "USER_NOTIFICATIONS_TAB";
+export const USER_PREFERENCES_TAB = "USER_PREFERENCES_TAB";
+export const USER_VOICE_TAB = "USER_VOICE_TAB";
+export const USER_SECURITY_TAB = "USER_SECURITY_TAB";
+export const USER_LABS_TAB = "USER_LABS_TAB";
+export const USER_MJOLNIR_TAB = "USER_MJOLNIR_TAB";
+export const USER_HELP_TAB = "USER_HELP_TAB";
+
export default class UserSettingsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
+ initialTabId: PropTypes.string,
};
constructor() {
@@ -63,42 +75,50 @@ export default class UserSettingsDialog extends React.Component {
const tabs = [];
tabs.push(new Tab(
+ USER_GENERAL_TAB,
_td("General"),
"mx_UserSettingsDialog_settingsIcon",
,
));
tabs.push(new Tab(
+ USER_APPEARANCE_TAB,
_td("Appearance"),
"mx_UserSettingsDialog_appearanceIcon",
,
));
tabs.push(new Tab(
+ USER_FLAIR_TAB,
_td("Flair"),
"mx_UserSettingsDialog_flairIcon",
,
));
tabs.push(new Tab(
+ USER_NOTIFICATIONS_TAB,
_td("Notifications"),
"mx_UserSettingsDialog_bellIcon",
,
));
tabs.push(new Tab(
+ USER_PREFERENCES_TAB,
_td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon",
,
));
tabs.push(new Tab(
+ USER_VOICE_TAB,
_td("Voice & Video"),
"mx_UserSettingsDialog_voiceIcon",
,
));
tabs.push(new Tab(
+ USER_SECURITY_TAB,
_td("Security & Privacy"),
"mx_UserSettingsDialog_securityIcon",
,
));
if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) {
tabs.push(new Tab(
+ USER_LABS_TAB,
_td("Labs"),
"mx_UserSettingsDialog_labsIcon",
,
@@ -106,12 +126,14 @@ export default class UserSettingsDialog extends React.Component {
}
if (this.state.mjolnirEnabled) {
tabs.push(new Tab(
+ USER_MJOLNIR_TAB,
_td("Ignored users"),
"mx_UserSettingsDialog_mjolnirIcon",
,
));
}
tabs.push(new Tab(
+ USER_HELP_TAB,
_td("Help & About"),
"mx_UserSettingsDialog_helpIcon",
,
@@ -127,7 +149,7 @@ export default class UserSettingsDialog extends React.Component {
-
+
);
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 71493d6e44..c9b5d9e3ad 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -36,6 +36,7 @@ export enum Action {
/**
* Open the user settings. No additional payload information required.
+ * Optionally can include an OpenToTabPayload.
*/
ViewUserSettings = "view_user_settings",
@@ -58,4 +59,9 @@ export enum Action {
* Focuses the user's cursor to the composer. No additional payload information required.
*/
FocusComposer = "focus_composer",
+
+ /**
+ * Opens the user menu (previously known as the top left menu). No additional payload information required.
+ */
+ ToggleUserMenu = "toggle_user_menu",
}
diff --git a/src/dispatcher/payloads/OpenToTabPayload.ts b/src/dispatcher/payloads/OpenToTabPayload.ts
new file mode 100644
index 0000000000..2877ee053e
--- /dev/null
+++ b/src/dispatcher/payloads/OpenToTabPayload.ts
@@ -0,0 +1,27 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ActionPayload } from "../payloads";
+import { Action } from "../actions";
+
+export interface OpenToTabPayload extends ActionPayload {
+ action: Action.ViewUserSettings | string, // TODO: Add room settings action
+
+ /**
+ * The tab ID to open in the settings view to start, if possible.
+ */
+ initialTabId?: string;
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index cf6dc2431a..3520446c2b 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2042,6 +2042,14 @@
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
+ "Switch to light mode": "Switch to light mode",
+ "Switch to dark mode": "Switch to dark mode",
+ "Switch theme": "Switch theme",
+ "Security & privacy": "Security & privacy",
+ "All settings": "All settings",
+ "Archived rooms": "Archived rooms",
+ "Feedback": "Feedback",
+ "Account settings": "Account settings",
"Could not load user profile": "Could not load user profile",
"Verify this login": "Verify this login",
"Session verified": "Session verified",
diff --git a/src/theme.js b/src/theme.js
index ccb753d601..6ed0657bbc 100644
--- a/src/theme.js
+++ b/src/theme.js
@@ -62,7 +62,7 @@ function setCustomThemeVars(customTheme) {
}
}
-function getCustomTheme(themeName) {
+export function getCustomTheme(themeName) {
// set css variables
const customThemes = SettingsStore.getValue("custom_themes");
if (!customThemes) {