element-portable/src/components/views/settings/ThemeChoicePanel.tsx
David Langley 491f0cd08a
Change license (#13)
* Copyright headers 1

* Licence headers 2

* Copyright Headers 3

* Copyright Headers 4

* Copyright Headers 5

* Copyright Headers 6

* Copyright headers 7

* Add copyright headers for html and config file

* Replace license files and update package.json

* Update with CLA

* lint
2024-09-09 13:57:16 +00:00

336 lines
13 KiB
TypeScript

/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, JSX, useCallback, useMemo, useRef, useState } from "react";
import {
InlineField,
ToggleControl,
Label,
Root,
RadioControl,
EditInPlace,
IconButton,
ErrorMessage,
HelpMessage,
} from "@vector-im/compound-web";
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import SettingsSubsection from "./shared/SettingsSubsection";
import ThemeWatcher from "../../../settings/watchers/ThemeWatcher";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import dis from "../../../dispatcher/dispatcher";
import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload";
import { Action } from "../../../dispatcher/actions";
import { useTheme } from "../../../hooks/useTheme";
import { findHighContrastTheme, getOrderedThemes, CustomTheme as CustomThemeType, ITheme } from "../../../theme";
import { useSettingValue } from "../../../hooks/useSettings";
/**
* Panel to choose the theme
*/
export function ThemeChoicePanel(): JSX.Element {
const themeState = useTheme();
const themeWatcher = useRef(new ThemeWatcher());
const customThemeEnabled = useSettingValue<boolean>("feature_custom_themes");
return (
<SettingsSubsection heading={_t("common|theme")} legacy={false} data-testid="themePanel">
{themeWatcher.current.isSystemThemeSupported() && (
<SystemTheme systemThemeActivated={themeState.systemThemeActivated} />
)}
<ThemeSelectors theme={themeState.theme} disabled={themeState.systemThemeActivated} />
{customThemeEnabled && <CustomTheme theme={themeState.theme} />}
</SettingsSubsection>
);
}
/**
* Component to toggle the system theme
*/
interface SystemThemeProps {
/* Whether the system theme is activated */
systemThemeActivated: boolean;
}
/**
* Component to toggle the system theme
*/
function SystemTheme({ systemThemeActivated }: SystemThemeProps): JSX.Element {
return (
<Root
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("systemTheme") === "on";
await SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
}}
>
<InlineField
name="systemTheme"
control={<ToggleControl name="systemTheme" defaultChecked={systemThemeActivated} />}
>
<Label>{SettingsStore.getDisplayName("use_system_theme")}</Label>
</InlineField>
</Root>
);
}
/**
* Component to select the theme
*/
interface ThemeSelectorProps {
/* The current theme */
theme: string;
/* The theme can't be selected */
disabled: boolean;
}
/**
* Component to select the theme
*/
function ThemeSelectors({ theme, disabled }: ThemeSelectorProps): JSX.Element {
const themes = useThemes();
return (
<Root
className="mx_ThemeChoicePanel_ThemeSelectors"
onChange={async (evt) => {
// We don't have any file in the form, we can cast it as string safely
const newTheme = new FormData(evt.currentTarget).get("themeSelector") as string | null;
// Do nothing if the same theme is selected
if (!newTheme || theme === newTheme) return;
// doing getValue in the .catch will still return the value we failed to set,
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme).catch(() => {
dis.dispatch<RecheckThemePayload>({ 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<RecheckThemePayload>({ action: Action.RecheckTheme, forceTheme: newTheme });
}}
>
{themes.map((_theme) => {
const isChecked = theme === _theme.id;
return (
<InlineField
className={classNames("mx_ThemeChoicePanel_themeSelector", {
[`mx_ThemeChoicePanel_themeSelector_enabled`]: !disabled && theme === _theme.id,
[`mx_ThemeChoicePanel_themeSelector_disabled`]: disabled,
// We need to force the compound theme to be light or dark
// The theme selection doesn't depend on the current theme
// For example when the light theme is used, the dark theme selector should be dark
"cpd-theme-light": !_theme.isDark,
"cpd-theme-dark": _theme.isDark,
})}
name="themeSelector"
key={_theme.id}
control={
<RadioControl
name="themeSelector"
checked={!disabled && isChecked}
disabled={disabled}
value={_theme.id}
/>
}
>
<Label className="mx_ThemeChoicePanel_themeSelector_Label">{_theme.name}</Label>
</InlineField>
);
})}
</Root>
);
}
/**
* Return all the available themes
*/
function useThemes(): Array<ITheme & { isDark: boolean }> {
const customThemes = useSettingValue<CustomThemeType[] | undefined>("custom_themes");
return useMemo(() => {
// Put the custom theme into a map
// To easily find the theme by name when going through the themes list
const checkedCustomThemes = customThemes || [];
const customThemeMap = checkedCustomThemes.reduce(
(map, theme) => map.set(theme.name, theme),
new Map<string, CustomThemeType>(),
);
const themes = getOrderedThemes();
// Separate the built-in themes from the custom themes
// To insert the high contrast theme between them
const builtInThemes = themes.filter((theme) => !customThemeMap.has(theme.name));
const otherThemes = themes.filter((theme) => customThemeMap.has(theme.name));
const highContrastTheme = makeHighContrastTheme();
if (highContrastTheme) builtInThemes.push(highContrastTheme);
const allThemes = builtInThemes.concat(otherThemes);
// Check if the themes are dark
return allThemes.map((theme) => {
const customTheme = customThemeMap.get(theme.name);
const isDark = (customTheme ? customTheme.is_dark : theme.id.includes("dark")) || false;
return { ...theme, isDark };
});
}, [customThemes]);
}
/**
* Create the light high contrast theme
*/
function makeHighContrastTheme(): ITheme | undefined {
const lightHighContrastId = findHighContrastTheme("light");
if (lightHighContrastId) {
return {
name: _t("settings|appearance|high_contrast"),
id: lightHighContrastId,
};
}
}
interface CustomThemeProps {
/**
* The current theme
*/
theme: string;
}
/**
* Add and manager custom themes
*/
function CustomTheme({ theme }: CustomThemeProps): JSX.Element {
const [customTheme, setCustomTheme] = useState<string>("");
const [error, setError] = useState<string>();
const clear = useCallback(() => {
setError(undefined);
setCustomTheme("");
}, [setError, setCustomTheme]);
return (
<div className="mx_ThemeChoicePanel_CustomTheme">
<EditInPlace
className="mx_ThemeChoicePanel_CustomTheme_EditInPlace"
label={_t("settings|appearance|custom_theme_add")}
cancelButtonLabel={_t("action|cancel")}
saveButtonLabel={_t("settings|appearance|custom_theme_add")}
savingLabel={_t("settings|appearance|custom_theme_downloading")}
value={customTheme}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setError(undefined);
setCustomTheme(e.target.value);
}}
onSave={async () => {
// The field empty is empty
if (!customTheme) return;
// Get the custom themes and do a cheap clone
// To avoid to mutate the original array in the settings
const currentThemes =
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
try {
const r = await fetch(customTheme);
// XXX: need some schema for this
const themeInfo = await r.json();
if (
!themeInfo ||
typeof themeInfo["name"] !== "string" ||
typeof themeInfo["colors"] !== "object"
) {
setError(_t("settings|appearance|custom_theme_invalid"));
return;
}
// Check if the theme is already existing
const isAlreadyExisting = Boolean(currentThemes.find((t) => t.name === themeInfo.name));
if (isAlreadyExisting) {
clear();
return;
}
currentThemes.push(themeInfo);
} catch (e) {
logger.error(e);
setError(_t("settings|appearance|custom_theme_error_downloading"));
return;
}
// Reset the error
clear();
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
}}
onCancel={clear}
>
<HelpMessage>{_t("settings|appearance|custom_theme_help")}</HelpMessage>
{error && <ErrorMessage>{error}</ErrorMessage>}
</EditInPlace>
<CustomThemeList theme={theme} />
</div>
);
}
interface CustomThemeListProps {
/*
* The current theme
*/
theme: string;
}
/**
* List of the custom themes
*/
function CustomThemeList({ theme: currentTheme }: CustomThemeListProps): JSX.Element {
const customThemes = useSettingValue<CustomThemeType[]>("custom_themes") || [];
return (
<ul className="mx_ThemeChoicePanel_CustomThemeList">
{customThemes.map((theme) => {
return (
<li key={theme.name} className="mx_ThemeChoicePanel_CustomThemeList_theme" aria-label={theme.name}>
<span className="mx_ThemeChoicePanel_CustomThemeList_name">{theme.name}</span>
<IconButton
destructive={true}
aria-label={_t("action|delete")}
tooltip={_t("action|delete")}
onClick={async () => {
// Get the custom themes and do a cheap clone
// To avoid to mutate the original array in the settings
const currentThemes =
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
// Remove the theme from the list
const newThemes = currentThemes.filter((t) => t.name !== theme.name);
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, newThemes);
// If the delete custom theme is the current theme, reset the theme to the default theme
// By settings the theme at null at the device level, we are getting the default theme
if (currentTheme === `custom-${theme.name}`) {
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, null);
dis.dispatch<RecheckThemePayload>({
action: Action.RecheckTheme,
});
}
}}
>
<DeleteIcon />
</IconButton>
</li>
);
})}
</ul>
);
}