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" ;
1929import classNames from "classnames" ;
2030
2131import { _t } from "../../../languageHandler" ;
@@ -27,7 +37,9 @@ import dis from "../../../dispatcher/dispatcher";
2737import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload" ;
2838import { Action } from "../../../dispatcher/actions" ;
2939import { 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+ */
5267export 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 */
123140function 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