diff --git a/res/css/_components.scss b/res/css/_components.scss index 81b5e3be99e..731e20217b7 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -22,6 +22,7 @@ @import "./structures/_MyGroups.scss"; @import "./structures/_NonUrgentToastContainer.scss"; @import "./structures/_NotificationPanel.scss"; +@import "./structures/_QuickSettingsButton.scss"; @import "./structures/_RightPanel.scss"; @import "./structures/_RoomDirectory.scss"; @import "./structures/_RoomSearch.scss"; diff --git a/res/css/structures/_QuickSettingsButton.scss b/res/css/structures/_QuickSettingsButton.scss new file mode 100644 index 00000000000..24883478bdc --- /dev/null +++ b/res/css/structures/_QuickSettingsButton.scss @@ -0,0 +1,176 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_QuickSettingsButton { + flex: 0 0 auto; + width: 32px; + height: 32px; + border-radius: 8px; + position: relative; + margin: 12px auto; + + &::before { + content: ""; + position: absolute; + width: inherit; + height: inherit; + mask-image: url('$(res)/img/element-icons/settings.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 16px; + background: $secondary-content; + } + + &:hover { + background-color: $quaternary-content; + + &::before { + background-color: $primary-content; + } + } +} + +.mx_QuickSettingsButton_ContextMenuWrapper .mx_ContextualMenu { + padding: 16px; + width: max-content; + min-width: 200px; + contain: unset; // let the dropdown paint beyond the context menu + + > div > h2 { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-content; + margin: 0 0 16px; + } + + .mx_AccessibleButton_kind_primary_outline { + display: block; + } + + > div > h4 { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + text-transform: uppercase; + color: $tertiary-content; + margin: 20px 0 12px; + } + + .mx_QuickSettingsButton_pinToSidebarHeading { + padding-left: 24px; + position: relative; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + width: 16px; + height: 16px; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + } + } + + .mx_Checkbox { + margin-bottom: 8px; + } + + .mx_QuickSettingsButton_favouritesCheckbox, + .mx_QuickSettingsButton_peopleCheckbox { + .mx_Checkbox_background + div { + padding-left: 22px; + position: relative; + margin-left: 6px; + font-size: $font-15px; + line-height: $font-24px; + color: $secondary-content; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 16px; + height: 16px; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + } + } + } + + .mx_QuickSettingsButton_favouritesCheckbox .mx_Checkbox_background + div::before { + mask-image: url('$(res)/img/element-icons/roomlist/favorite.svg'); + } + + .mx_QuickSettingsButton_peopleCheckbox .mx_Checkbox_background + div::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_QuickSettingsButton_moreOptionsButton { + padding-left: 22px; + margin-left: 22px; + font-size: $font-15px; + line-height: $font-24px; + color: $secondary-content; + position: relative; + margin-bottom: 16px; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 16px; + height: 16px; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } + } + + .mx_QuickSettingsButton_themePicker { + display: flex; + align-items: center; + + > h4 { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + text-transform: uppercase; + display: inline-block; + margin: 0; + } + + .mx_Dropdown { + min-width: 100px; + margin-left: auto; + height: min-content; + } + } +} diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 0785d4955f6..e343af88b5c 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -48,7 +48,6 @@ $activeBorderColor: $secondary-content; mask-size: 32px; mask-repeat: no-repeat; margin-left: $gutterSize; - margin-bottom: 12px; background-color: $tertiary-content; mask-image: url('$(res)/img/element-icons/expand-space-panel.svg'); diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index 86e0822b110..54154c7f7b0 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -178,26 +178,20 @@ export default class Dropdown extends React.Component { this.ignoreEvent = ev; }; - private onChevronClick = (ev: React.MouseEvent) => { - if (this.state.expanded) { - this.setState({ expanded: false }); - ev.stopPropagation(); - ev.preventDefault(); - } - }; - private onAccessibleButtonClick = (ev: ButtonEvent) => { if (this.props.disabled) return; if (!this.state.expanded) { - this.setState({ - expanded: true, - }); + this.setState({ expanded: true }); ev.preventDefault(); } else if ((ev as React.KeyboardEvent).key === Key.ENTER) { // the accessible button consumes enter onKeyDown for firing onClick, so handle it here this.props.onOptionChange(this.state.highlightedOption); this.close(); + } else if (!(ev as React.KeyboardEvent).key) { + // collapse on other non-keyboard event activations + this.setState({ expanded: false }); + ev.preventDefault(); } }; @@ -383,7 +377,7 @@ export default class Dropdown extends React.Component { onKeyDown={this.onKeyDown} > { currentValue } - + { menu } ; diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx index feb9552230a..2655fc78c52 100644 --- a/src/components/views/settings/ThemeChoicePanel.tsx +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; -import { enumerateThemes, findHighContrastTheme, findNonHighContrastTheme, isHighContrastTheme } from "../../../theme"; +import { findHighContrastTheme, findNonHighContrastTheme, getOrderedThemes, isHighContrastTheme } from "../../../theme"; import ThemeWatcher from "../../../settings/watchers/ThemeWatcher"; import AccessibleButton from "../elements/AccessibleButton"; import dis from "../../../dispatcher/dispatcher"; @@ -28,7 +28,6 @@ import Field from '../elements/Field'; import StyledRadioGroup from "../elements/StyledRadioGroup"; import { SettingLevel } from "../../../settings/SettingLevel"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { compare } from "../../../utils/strings"; import { logger } from "matrix-js-sdk/src/logger"; @@ -58,13 +57,13 @@ export default class ThemeChoicePanel extends React.Component { super(props); this.state = { - ...this.calculateThemeState(), + ...ThemeChoicePanel.calculateThemeState(), customThemeUrl: "", customThemeMessage: { isError: false, text: "" }, }; } - private calculateThemeState(): IThemeState { + public static calculateThemeState(): IThemeState { // We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we // show the right values for things. @@ -238,14 +237,7 @@ export default class ThemeChoicePanel extends React.Component { ); } - // XXX: replace any type here - const themes = Object.entries(enumerateThemes()) - .map(p => ({ id: p[0], name: p[1] })) // convert pairs to objects for code readability - .filter(p => !isHighContrastTheme(p.id)); - const builtInThemes = themes.filter(p => !p.id.startsWith("custom-")); - const customThemes = themes.filter(p => !builtInThemes.includes(p)) - .sort((a, b) => compare(a.name, b.name)); - const orderedThemes = [...builtInThemes, ...customThemes]; + const orderedThemes = getOrderedThemes(); return (
{ _t("Theme") } diff --git a/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx index 8b1d0d8f852..92a5bacb64f 100644 --- a/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx @@ -23,7 +23,7 @@ import StyledCheckbox from "../../../elements/StyledCheckbox"; import { useSettingValue } from "../../../../../hooks/useSettings"; import { MetaSpace } from "../../../../../stores/spaces"; -const onMetaSpaceChangeFactory = (metaSpace: MetaSpace) => (e: ChangeEvent) => { +export const onMetaSpaceChangeFactory = (metaSpace: MetaSpace) => (e: ChangeEvent) => { const currentValue = SettingsStore.getValue("Spaces.enabledMetaSpaces"); SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.ACCOUNT, { ...currentValue, diff --git a/src/components/views/spaces/QuickSettingsButton.tsx b/src/components/views/spaces/QuickSettingsButton.tsx new file mode 100644 index 00000000000..59cfcb967cc --- /dev/null +++ b/src/components/views/spaces/QuickSettingsButton.tsx @@ -0,0 +1,150 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useMemo } from "react"; + +import { _t } from "../../../languageHandler"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { alwaysAboveRightOf, ChevronFace, ContextMenu, useContextMenu } from "../../structures/ContextMenu"; +import AccessibleButton from "../elements/AccessibleButton"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import { MetaSpace } from "../../../stores/spaces"; +import { useSettingValue } from "../../../hooks/useSettings"; +import { onMetaSpaceChangeFactory } from "../settings/tabs/user/SidebarUserSettingsTab"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "../dialogs/UserSettingsDialog"; +import { findNonHighContrastTheme, getOrderedThemes } from "../../../theme"; +import Dropdown from "../elements/Dropdown"; +import ThemeChoicePanel from "../settings/ThemeChoicePanel"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import dis from "../../../dispatcher/dispatcher"; +import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload"; + +const QuickSettingsButton = () => { + const orderedThemes = useMemo(getOrderedThemes, []); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + const { + [MetaSpace.Favourites]: favouritesEnabled, + [MetaSpace.People]: peopleEnabled, + } = useSettingValue>("Spaces.enabledMetaSpaces"); + + let contextMenu: JSX.Element; + if (menuDisplayed) { + const themeState = ThemeChoicePanel.calculateThemeState(); + const nonHighContrast = findNonHighContrastTheme(themeState.theme); + const theme = nonHighContrast ? nonHighContrast : themeState.theme; + + contextMenu = +

{ _t("Quick settings") }

+ + { + closeMenu(); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Sidebar, + }); + }} + kind="primary_outline" + > + { _t("All settings") } + + +

{ _t("Pin to sidebar") }

+ + + { _t("Favourites") } + + + { _t("People") } + + { + closeMenu(); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Sidebar, + }); + }} + > + { _t("More options") } + + +
+

{ _t("Theme") }

+ { + // XXX: mostly copied from ThemeChoicePanel + // doing getValue in the .catch will still return the value we failed to set, + // so remember what the value was before we tried to set it so we can revert + // const oldTheme: string = SettingsStore.getValue("theme"); + SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme).catch(() => { + dis.dispatch({ action: Action.RecheckTheme }); + }); + // The settings watcher doesn't fire until the echo comes back from the + // server, so to make the theme change immediately we need to manually + // do the dispatch now + // XXX: The local echoed value appears to be unreliable, in particular + // when settings custom themes(!) so adding forceTheme to override + // the value from settings. + dis.dispatch({ action: Action.RecheckTheme, forceTheme: newTheme }); + closeMenu(); + }} + value={theme} + label={_t("Space selection")} + > + { orderedThemes.map((theme) => ( +
+ { theme.name } +
+ )) } +
+
+
; + } + + return <> + + + { contextMenu } + ; +}; + +export default QuickSettingsButton; diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 9c572c9fe50..0aa2d44dff9 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -54,6 +54,8 @@ import IconizedContextMenu, { import SettingsStore from "../../../settings/SettingsStore"; import { SettingLevel } from "../../../settings/SettingLevel"; import UIStore from "../../../stores/UIStore"; +import QuickSettingsButton from "./QuickSettingsButton"; +import { useSettingValue } from "../../../hooks/useSettings"; const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -277,6 +279,7 @@ const InnerSpacePanel = React.memo(({ children, isPanelCo }); const SpacePanel = () => { + const metaSpacesEnabled = useSettingValue("feature_spaces_metaspaces"); const [isPanelCollapsed, setPanelCollapsed] = useState(true); const ref = useRef(); useLayoutEffect(() => { @@ -322,6 +325,7 @@ const SpacePanel = () => { onClick={() => setPanelCollapsed(!isPanelCollapsed)} title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")} /> + { metaSpacesEnabled && } ) } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0f1f5b05477..898cce419e1 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1034,6 +1034,14 @@ "Your server isn't responding to some requests.": "Your server isn't responding to some requests.", "Decline (%(counter)s)": "Decline (%(counter)s)", "Accept to continue:": "Accept to continue:", + "Quick settings": "Quick settings", + "All settings": "All settings", + "Pin to sidebar": "Pin to sidebar", + "Favourites": "Favourites", + "People": "People", + "More options": "More options", + "Theme": "Theme", + "Space selection": "Space selection", "Delete avatar": "Delete avatar", "Delete": "Delete", "Upload avatar": "Upload avatar", @@ -1070,8 +1078,6 @@ "Show all rooms": "Show all rooms", "All rooms": "All rooms", "Options": "Options", - "Favourites": "Favourites", - "People": "People", "Other rooms": "Other rooms", "Spaces": "Spaces", "Expand space panel": "Expand space panel", @@ -1317,7 +1323,6 @@ "Use high contrast": "Use high contrast", "Custom theme URL": "Custom theme URL", "Add theme": "Add theme", - "Theme": "Theme", "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).", "Checking for an update...": "Checking for an update...", "No update available.": "No update available.", @@ -1639,7 +1644,6 @@ "Show Stickers": "Show Stickers", "Send a sticker": "Send a sticker", "Send voice message": "Send voice message", - "More options": "More options", "The conversation continues here.": "The conversation continues here.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", "You do not have permission to post to this room": "You do not have permission to post to this room", @@ -2249,7 +2253,6 @@ "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", "Direct Messages": "Direct Messages", - "Space selection": "Space selection", "Add existing rooms": "Add existing rooms", "Want to add a new room instead?": "Want to add a new room instead?", "Create a new room": "Create a new room", @@ -3056,7 +3059,6 @@ "New here? Create an account": "New here? Create an account", "Notification settings": "Notification settings", "Security & privacy": "Security & privacy", - "All settings": "All settings", "Community settings": "Community settings", "User settings": "User settings", "Switch to light mode": "Switch to light mode", diff --git a/src/theme.ts b/src/theme.ts index b1eec5acedd..e344d58fed4 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -19,6 +19,7 @@ import { _t } from "./languageHandler"; import SettingsStore from "./settings/SettingsStore"; import ThemeWatcher from "./settings/watchers/ThemeWatcher"; +import { compare } from "./utils/strings"; export const DEFAULT_THEME = "light"; const HIGH_CONTRAST_THEMES = { @@ -86,6 +87,21 @@ export function enumerateThemes(): {[key: string]: string} { return Object.assign({}, customThemeNames, BUILTIN_THEMES); } +interface ITheme { + id: string; + name: string; +} + +export function getOrderedThemes(): ITheme[] { + const themes = Object.entries(enumerateThemes()) + .map(p => ({ id: p[0], name: p[1] })) // convert pairs to objects for code readability + .filter(p => !isHighContrastTheme(p.id)); + const builtInThemes = themes.filter(p => !p.id.startsWith("custom-")); + const customThemes = themes.filter(p => !builtInThemes.includes(p)) + .sort((a, b) => compare(a.name, b.name)); + return [...builtInThemes, ...customThemes]; +} + function clearCustomTheme(): void { // remove all css variables, we assume these are there because of the custom theme const inlineStyleProps = Object.values(document.body.style);