Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 130b213

Browse files
committed
Add custom theme support
1 parent ccaa56e commit 130b213

File tree

5 files changed

+239
-45
lines changed

5 files changed

+239
-45
lines changed

res/css/_common.pcss

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ legend {
603603
.mx_Dialog
604604
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
605605
.mx_UserProfileSettings button
606-
),
606+
):not(.mx_ThemeChoicePanel_CustomTheme_container button),
607607
.mx_Dialog input[type="submit"],
608608
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
609609
.mx_Dialog_buttons input[type="submit"] {
@@ -623,14 +623,14 @@ legend {
623623
.mx_Dialog
624624
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
625625
.mx_UserProfileSettings button
626-
):last-child {
626+
):not(.mx_ThemeChoicePanel_CustomTheme_container button):last-child {
627627
margin-right: 0px;
628628
}
629629

630630
.mx_Dialog
631631
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
632632
.mx_UserProfileSettings button
633-
):focus,
633+
):not(.mx_ThemeChoicePanel_CustomTheme_container button):focus,
634634
.mx_Dialog input[type="submit"]:focus,
635635
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
636636
.mx_Dialog_buttons input[type="submit"]:focus {
@@ -642,7 +642,7 @@ legend {
642642
.mx_Dialog_buttons
643643
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
644644
.mx_UserProfileSettings button
645-
),
645+
):not(.mx_ThemeChoicePanel_CustomTheme_container button),
646646
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
647647
color: var(--cpd-color-text-on-solid-primary);
648648
background-color: var(--cpd-color-bg-action-primary-rest);
@@ -653,7 +653,9 @@ legend {
653653
.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]),
654654
.mx_Dialog input[type="submit"].danger,
655655
.mx_Dialog_buttons
656-
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button),
656+
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
657+
.mx_ThemeChoicePanel_CustomTheme_container button
658+
),
657659
.mx_Dialog_buttons input[type="submit"].danger {
658660
background-color: var(--cpd-color-bg-critical-primary);
659661
border: solid 1px var(--cpd-color-bg-critical-primary);
@@ -669,7 +671,7 @@ legend {
669671
.mx_Dialog
670672
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
671673
.mx_UserProfileSettings button
672-
):disabled,
674+
):not(.mx_ThemeChoicePanel_CustomTheme_container button):disabled,
673675
.mx_Dialog input[type="submit"]:disabled,
674676
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
675677
.mx_Dialog_buttons input[type="submit"]:disabled {

res/css/views/settings/_ThemeChoicePanel.pcss

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ limitations under the License.
6262

6363
.mx_ThemeChoicePanel_ThemeSelectors {
6464
display: flex;
65+
flex-wrap: wrap;
6566
/* Override form default style */
6667
flex-direction: row !important;
6768
gap: var(--cpd-space-4x) !important;
@@ -87,3 +88,41 @@ limitations under the License.
8788
}
8889
}
8990
}
91+
92+
.mx_ThemeChoicePanel_CustomTheme_header {
93+
margin-block: 0;
94+
margin-top: var(--cpd-space-2x);
95+
border-bottom: 1px solid var(--cpd-color-alpha-gray-400);
96+
padding-bottom: var(--cpd-space-2x);
97+
width: 100%;
98+
}
99+
100+
.mx_ThemeChoicePanel_CustomTheme_container {
101+
width: 100%;
102+
display: flex;
103+
flex-direction: column;
104+
gap: var(--cpd-space-4x);
105+
106+
.mx_ThemeChoicePanel_CustomTheme_EditInPlace input:focus {
107+
// When the input is focused, the border is growing
108+
// We need to move it a bit to avoid the left border to be under the left panel
109+
margin-left: 2px;
110+
}
111+
112+
.mx_ThemeChoicePanel_CustomThemeList {
113+
display: flex;
114+
justify-content: space-between;
115+
align-items: center;
116+
117+
.mx_ThemeChoicePanel_CustomThemeList_name {
118+
font: var(--cpd-font-body-sm-semibold);
119+
overflow: hidden;
120+
text-overflow: ellipsis;
121+
white-space: nowrap;
122+
}
123+
124+
.mx_ThemeChoicePanel_CustomThemeList_delete {
125+
color: var(--cpd-color-icon-critical-primary);
126+
}
127+
}
128+
}

src/components/views/settings/ThemeChoicePanel2.tsx

Lines changed: 186 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,18 @@
1414
* limitations under the License.
1515
*/
1616

17-
import React, { Dispatch, JSX, useRef, useState } from "react";
18-
import { InlineField, ToggleControl, Label, Root, RadioControl } from "@vector-im/compound-web";
17+
import React, { ChangeEvent, Dispatch, JSX, useMemo, useRef, useState } from "react";
18+
import {
19+
InlineField,
20+
ToggleControl,
21+
Label,
22+
Root,
23+
RadioControl,
24+
Text,
25+
EditInPlace,
26+
IconButton,
27+
} from "@vector-im/compound-web";
28+
import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg";
1929
import classNames from "classnames";
2030

2131
import { _t } from "../../../languageHandler";
@@ -27,7 +37,9 @@ import dis from "../../../dispatcher/dispatcher";
2737
import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload";
2838
import { Action } from "../../../dispatcher/actions";
2939
import { useTheme } from "../../../hooks/useTheme";
30-
import { findHighContrastTheme, getOrderedThemes } from "../../../theme";
40+
import { findHighContrastTheme, getOrderedThemes, CustomTheme as CustomThemeType, ITheme } from "../../../theme";
41+
import { useSettingValue } from "../../../hooks/useSettings";
42+
import { logger } from "../../../../../matrix-js-sdk/src/logger";
3143

3244
/**
3345
* Interface for the theme state
@@ -49,9 +61,13 @@ function useThemeState(): [ThemeState, Dispatch<React.SetStateAction<ThemeState>
4961
return [themeState, setThemeState];
5062
}
5163

64+
/**
65+
* Panel to choose the theme
66+
*/
5267
export function ThemeChoicePanel(): JSX.Element {
5368
const [themeState, setThemeState] = useThemeState();
5469
const themeWatcher = useRef(new ThemeWatcher());
70+
const customThemeEnabled = useSettingValue<boolean>("feature_custom_themes");
5571

5672
return (
5773
<SettingsSubsection heading={_t("common|theme")} newUi={true}>
@@ -68,6 +84,7 @@ export function ThemeChoicePanel(): JSX.Element {
6884
disabled={themeState.systemThemeActivated}
6985
onChange={(theme) => setThemeState((_themeState) => ({ ..._themeState, theme }))}
7086
/>
87+
{customThemeEnabled && <CustomTheme />}
7188
</SettingsSubsection>
7289
);
7390
}
@@ -121,7 +138,7 @@ interface ThemeSelectorProps {
121138
* Component to select the theme
122139
*/
123140
function ThemeSelectors({ theme, disabled, onChange }: ThemeSelectorProps): JSX.Element {
124-
const orderedThemes = useRef(getThemes());
141+
const themes = useThemes();
125142

126143
return (
127144
<Root
@@ -151,51 +168,183 @@ function ThemeSelectors({ theme, disabled, onChange }: ThemeSelectorProps): JSX.
151168
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme, forceTheme: newTheme });
152169
}}
153170
>
154-
{orderedThemes.current.map((_theme) => (
155-
<InlineField
156-
className={classNames("mx_ThemeChoicePanel_themeSelector", {
157-
[`mx_ThemeChoicePanel_themeSelector_enabled`]: !disabled && theme === _theme.id,
158-
[`mx_ThemeChoicePanel_themeSelector_disabled`]: disabled,
159-
// We need to force the compound theme to be light or dark
160-
// The theme selection doesn't depend on the current theme
161-
// For example when the light theme is used, the dark theme selector should be dark
162-
"cpd-theme-light": _theme.id.includes("light"),
163-
"cpd-theme-dark": _theme.id.includes("dark"),
164-
})}
165-
name="themeSelector"
166-
key={`${_theme.id}_${disabled}`}
167-
control={
168-
<RadioControl
169-
name="themeSelector"
170-
defaultChecked={!disabled && theme === _theme.id}
171-
disabled={disabled}
172-
value={_theme.id}
173-
/>
174-
}
175-
>
176-
<Label className="mx_ThemeChoicePanel_themeSelector_Label">{_theme.name}</Label>
177-
</InlineField>
178-
))}
171+
{themes.map((_theme) => {
172+
return (
173+
<InlineField
174+
className={classNames("mx_ThemeChoicePanel_themeSelector", {
175+
[`mx_ThemeChoicePanel_themeSelector_enabled`]: !disabled && theme === _theme.id,
176+
[`mx_ThemeChoicePanel_themeSelector_disabled`]: disabled,
177+
// We need to force the compound theme to be light or dark
178+
// The theme selection doesn't depend on the current theme
179+
// For example when the light theme is used, the dark theme selector should be dark
180+
"cpd-theme-light": !_theme.isDark,
181+
"cpd-theme-dark": _theme.isDark,
182+
})}
183+
name="themeSelector"
184+
key={`${_theme.id}_${disabled}`}
185+
control={
186+
<RadioControl
187+
name="themeSelector"
188+
defaultChecked={!disabled && theme === _theme.id}
189+
disabled={disabled}
190+
value={_theme.id}
191+
/>
192+
}
193+
>
194+
<Label className="mx_ThemeChoicePanel_themeSelector_Label">{_theme.name}</Label>
195+
</InlineField>
196+
);
197+
})}
179198
</Root>
180199
);
181200
}
182201

183202
/**
184-
* Get the themes
185-
* @returns The themes
203+
* Return all the available themes
186204
*/
187-
function getThemes(): ReturnType<typeof getOrderedThemes> {
188-
const themes = getOrderedThemes();
205+
function useThemes(): Array<ITheme & { isDark: boolean }> {
206+
const customThemes = useSettingValue<CustomThemeType[] | undefined>("custom_themes");
207+
return useMemo(() => {
208+
const themes = getOrderedThemes();
209+
// Put the custom theme into a map
210+
// To easily find the theme by name when going through the themes list
211+
const customThemeMap = customThemes?.reduce(
212+
(map, theme) => map.set(theme.name, theme),
213+
new Map<string, CustomThemeType>(),
214+
);
215+
216+
// Separate the built-in themes from the custom themes
217+
// To insert the high contrast theme between them
218+
const builtInThemes = themes.filter((theme) => !customThemeMap?.has(theme.name));
219+
const otherThemes = themes.filter((theme) => customThemeMap?.has(theme.name));
220+
221+
const highContrastTheme = makeHighContrastTheme();
222+
if (highContrastTheme) builtInThemes.push(highContrastTheme);
223+
224+
const allThemes = builtInThemes.concat(otherThemes);
225+
226+
// Check if the themes are dark
227+
return allThemes.map((theme) => {
228+
const customTheme = customThemeMap?.get(theme.name);
229+
const isDark = (customTheme ? customTheme.is_dark : theme.id.includes("dark")) || false;
230+
return { ...theme, isDark };
231+
});
232+
}, [customThemes]);
233+
}
189234

190-
// Currently only light theme has a high contrast theme
235+
/**
236+
* Create the light high contrast theme
237+
*/
238+
function makeHighContrastTheme(): ITheme | undefined {
191239
const lightHighContrastId = findHighContrastTheme("light");
192240
if (lightHighContrastId) {
193-
const lightHighContrast = {
241+
return {
194242
name: _t("settings|appearance|high_contrast"),
195243
id: lightHighContrastId,
196244
};
197-
themes.push(lightHighContrast);
198245
}
246+
}
247+
248+
/**
249+
* Add and manager custom themes
250+
*/
251+
function CustomTheme(): JSX.Element {
252+
const [customTheme, setCustomTheme] = useState<string>("");
253+
const [error, setError] = useState<string>();
199254

200-
return themes;
255+
return (
256+
<>
257+
<Text className="mx_ThemeChoicePanel_CustomTheme_header" as="h4" size="lg" weight="semibold">
258+
{_t("settings|appearance|custom_themes")}
259+
</Text>
260+
<div className="mx_ThemeChoicePanel_CustomTheme_container">
261+
<EditInPlace
262+
className="mx_ThemeChoicePanel_CustomTheme_EditInPlace"
263+
label={_t("settings|appearance|custom_theme_add")}
264+
saveButtonLabel={_t("settings|appearance|custom_theme_add")}
265+
// TODO
266+
savingLabel={_t("settings|appearance|custom_theme_downloading")}
267+
helpLabel={_t("settings|appearance|custom_theme_help")}
268+
error={error}
269+
value={customTheme}
270+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
271+
setError(undefined);
272+
setCustomTheme(e.target.value);
273+
}}
274+
onSave={async () => {
275+
// The field empty is empty
276+
if (!customTheme) return;
277+
278+
// Get the custom themes and do a cheap clone
279+
// To avoid to mutate the original array in the settings
280+
const currentThemes =
281+
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
282+
283+
try {
284+
const r = await fetch(customTheme);
285+
// XXX: need some schema for this
286+
const themeInfo = await r.json();
287+
if (
288+
!themeInfo ||
289+
typeof themeInfo["name"] !== "string" ||
290+
typeof themeInfo["colors"] !== "object"
291+
) {
292+
setError(_t("settings|appearance|custom_theme_invalid"));
293+
return;
294+
}
295+
currentThemes.push(themeInfo);
296+
} catch (e) {
297+
logger.error(e);
298+
setError(_t("settings|appearance|custom_theme_error_downloading"));
299+
return;
300+
}
301+
302+
// Reset the error
303+
setError(undefined);
304+
setCustomTheme("");
305+
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
306+
}}
307+
onCancel={() => {
308+
setError(undefined);
309+
setCustomTheme("");
310+
}}
311+
/>
312+
<CustomThemeList />
313+
</div>
314+
</>
315+
);
316+
}
317+
318+
/**
319+
* List of the custom themes
320+
* @constructor
321+
*/
322+
function CustomThemeList(): JSX.Element {
323+
const customThemes = useSettingValue<CustomThemeType[]>("custom_themes") || [];
324+
325+
return (
326+
<>
327+
{customThemes.map((theme) => {
328+
return (
329+
<div key={theme.name} className="mx_ThemeChoicePanel_CustomThemeList">
330+
<span className="mx_ThemeChoicePanel_CustomThemeList_name">{theme.name}</span>
331+
<IconButton
332+
onClick={async () => {
333+
// Get the custom themes and do a cheap clone
334+
// To avoid to mutate the original array in the settings
335+
const currentThemes =
336+
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
337+
338+
// Remove the theme from the list
339+
const newThemes = currentThemes.filter((t) => t.name !== theme.name);
340+
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, newThemes);
341+
}}
342+
>
343+
<DeleteIcon className="mx_ThemeChoicePanel_CustomThemeList_delete" />
344+
</IconButton>
345+
</div>
346+
);
347+
})}
348+
</>
349+
);
201350
}

0 commit comments

Comments
 (0)