* 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
336 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|