feat(mobx): start implementing theme store

This commit is contained in:
Paul Makles 2021-12-13 17:27:06 +00:00
parent 26a34032f9
commit bd4369cf29
9 changed files with 207 additions and 112 deletions

View file

@ -1,11 +1,11 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { Download, CloudDownload } from "@styled-icons/boxicons-regular"; import { Download, CloudDownload } from "@styled-icons/boxicons-regular";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { internalSubscribe } from "../../lib/eventEmitter"; import { internalSubscribe } from "../../lib/eventEmitter";
import { ThemeContext } from "../../context/Theme"; import { useApplicationState } from "../../mobx/State";
import IconButton from "../ui/IconButton"; import IconButton from "../ui/IconButton";
@ -27,7 +27,7 @@ export default function UpdateIndicator({ style }: Props) {
}); });
if (!pending) return null; if (!pending) return null;
const theme = useContext(ThemeContext); const theme = useApplicationState().settings.theme;
if (style === "titlebar") { if (style === "titlebar") {
return ( return (
@ -36,7 +36,10 @@ export default function UpdateIndicator({ style }: Props) {
content="A new update is available!" content="A new update is available!"
placement="bottom"> placement="bottom">
<div onClick={() => updateSW(true)}> <div onClick={() => updateSW(true)}>
<CloudDownload size={22} color={theme.success} /> <CloudDownload
size={22}
color={theme.getVariable("success")}
/>
</div> </div>
</Tooltip> </Tooltip>
</div> </div>
@ -47,7 +50,7 @@ export default function UpdateIndicator({ style }: Props) {
return ( return (
<IconButton onClick={() => updateSW(true)}> <IconButton onClick={() => updateSW(true)}>
<Download size={22} color={theme.success} /> <Download size={22} color={theme.getVariable("success")} />
</IconButton> </IconButton>
); );
} }

View file

@ -5,12 +5,10 @@ import { useParams } from "react-router-dom";
import { Masquerade } from "revolt-api/types/Channels"; import { Masquerade } from "revolt-api/types/Channels";
import { Presence } from "revolt-api/types/Users"; import { Presence } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users"; import { User } from "revolt.js/dist/maps/Users";
import { Nullable } from "revolt.js/dist/util/null";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { useContext } from "preact/hooks"; import { useApplicationState } from "../../../mobx/State";
import { ThemeContext } from "../../../context/Theme";
import { useClient } from "../../../context/revoltjs/RevoltClient"; import { useClient } from "../../../context/revoltjs/RevoltClient";
import fallback from "../assets/user.png"; import fallback from "../assets/user.png";
@ -26,15 +24,15 @@ interface Props extends IconBaseProps<User> {
} }
export function useStatusColour(user?: User) { export function useStatusColour(user?: User) {
const theme = useContext(ThemeContext); const theme = useApplicationState().settings.theme;
return user?.online && user?.status?.presence !== Presence.Invisible return user?.online && user?.status?.presence !== Presence.Invisible
? user?.status?.presence === Presence.Idle ? user?.status?.presence === Presence.Idle
? theme["status-away"] ? theme.getVariable("status-away")
: user?.status?.presence === Presence.Busy : user?.status?.presence === Presence.Busy
? theme["status-busy"] ? theme.getVariable("status-busy")
: theme["status-online"] : theme.getVariable("status-online")
: theme["status-invisible"]; : theme.getVariable("status-invisible");
} }
const VoiceIndicator = styled.div<{ status: VoiceStatus }>` const VoiceIndicator = styled.div<{ status: VoiceStatus }>`

View file

@ -6,9 +6,6 @@ import { useEffect } from "preact/hooks";
import { useApplicationState } from "../mobx/State"; import { useApplicationState } from "../mobx/State";
import { getState } from "../redux"; import { getState } from "../redux";
import { connectState } from "../redux/connector";
import { Children } from "../types/Preact";
export type Variables = export type Variables =
| "accent" | "accent"
@ -66,9 +63,11 @@ export type MonospaceFonts =
| "Ubuntu Mono" | "Ubuntu Mono"
| "JetBrains Mono"; | "JetBrains Mono";
export type Theme = { export type Overrides = {
[variable in Variables]: string; [variable in Variables]: string;
} & { };
export type Theme = Overrides & {
light?: boolean; light?: boolean;
font?: Fonts; font?: Fonts;
css?: string; css?: string;
@ -228,7 +227,6 @@ export const DEFAULT_MONO_FONT = "Fira Code";
// Generated from https://gitlab.insrt.uk/revolt/community/themes // Generated from https://gitlab.insrt.uk/revolt/community/themes
export const PRESETS: Record<string, Theme> = { export const PRESETS: Record<string, Theme> = {
light: { light: {
light: true,
accent: "#FD6671", accent: "#FD6671",
background: "#F6F6F6", background: "#F6F6F6",
foreground: "#000000", foreground: "#000000",
@ -255,7 +253,6 @@ export const PRESETS: Record<string, Theme> = {
"status-invisible": "#A5A5A5", "status-invisible": "#A5A5A5",
}, },
dark: { dark: {
light: false,
accent: "#FD6671", accent: "#FD6671",
background: "#191919", background: "#191919",
foreground: "#F6F6F6", foreground: "#F6F6F6",
@ -319,33 +316,22 @@ export const generateVariables = (theme: Theme) => {
}); });
}; };
// Load the default default them and apply extras later export default function Theme() {
export const ThemeContext = createContext<Theme>(PRESETS["dark"]);
interface Props {
children: Children;
}
export default function Theme({ children }: Props) {
const settings = useApplicationState().settings; const settings = useApplicationState().settings;
const theme = settings.theme;
const theme: Theme = {
...getBaseTheme(settings.get("appearance:theme:base") ?? "dark"),
...settings.get("appearance:theme:custom"),
};
const root = document.documentElement.style; const root = document.documentElement.style;
useEffect(() => { useEffect(() => {
const font = theme.font ?? DEFAULT_FONT; const font = theme.getFont() ?? DEFAULT_FONT;
root.setProperty("--font", `"${font}"`); root.setProperty("--font", `"${font}"`);
FONTS[font].load(); FONTS[font].load();
}, [root, theme.font]); }, [root, theme.getFont()]);
useEffect(() => { useEffect(() => {
const font = theme.monospaceFont ?? DEFAULT_MONO_FONT; const font = theme.getMonospaceFont() ?? DEFAULT_MONO_FONT;
root.setProperty("--monospace-font", `"${font}"`); root.setProperty("--monospace-font", `"${font}"`);
MONOSPACE_FONTS[font].load(); MONOSPACE_FONTS[font].load();
}, [root, theme.monospaceFont]); }, [root, theme.getMonospaceFont()]);
useEffect(() => { useEffect(() => {
root.setProperty( root.setProperty(
@ -363,16 +349,14 @@ export default function Theme({ children }: Props) {
return () => window.removeEventListener("resize", resize); return () => window.removeEventListener("resize", resize);
}, [root]); }, [root]);
const variables = theme.getVariables();
return ( return (
<ThemeContext.Provider value={theme}> <>
<Helmet> <Helmet>
<meta name="theme-color" content={theme["background"]} /> <meta name="theme-color" content={variables["background"]} />
</Helmet> </Helmet>
<GlobalTheme theme={theme} /> <GlobalTheme theme={variables} />
{theme.css && ( <style dangerouslySetInnerHTML={{ __html: theme.getCSS() ?? "" }} />
<style dangerouslySetInnerHTML={{ __html: theme.css }} /> </>
)}
{children}
</ThemeContext.Provider>
); );
} }

View file

@ -17,15 +17,14 @@ export default function Context({ children }: { children: Children }) {
return ( return (
<Router basename={import.meta.env.BASE_URL}> <Router basename={import.meta.env.BASE_URL}>
<State> <State>
<Theme> <Settings>
<Settings> <Locale>
<Locale> <Intermediate>
<Intermediate> <Client>{children}</Client>
<Client>{children}</Client> </Intermediate>
</Intermediate> </Locale>
</Locale> </Settings>
</Settings> <Theme />
</Theme>
</State> </State>
</Router> </Router>
); );

View file

@ -2,11 +2,12 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { mapToRecord } from "../../lib/conversion"; import { mapToRecord } from "../../lib/conversion";
import { Theme } from "../../context/Theme"; import { Fonts, MonospaceFonts, Overrides, Theme } from "../../context/Theme";
import { Sounds } from "../../assets/sounds/Audio"; import { Sounds } from "../../assets/sounds/Audio";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
import STheme from "./helpers/STheme";
export type SoundOptions = { export type SoundOptions = {
[key in Sounds]?: boolean; [key in Sounds]?: boolean;
@ -20,43 +21,35 @@ interface ISettings {
"appearance:emoji": EmojiPack; "appearance:emoji": EmojiPack;
"appearance:ligatures": boolean; "appearance:ligatures": boolean;
"appearance:theme:base": string;
"appearance:theme:custom": Partial<Theme>; "appearance:theme:base": "dark" | "light";
"appearance:theme:overrides": Partial<Overrides>;
"appearance:theme:light": boolean;
"appearance:theme:font": Fonts;
"appearance:theme:monoFont": MonospaceFonts;
"appearance:theme:css": string;
} }
/*const Schema: {
[key in keyof ISettings]:
| "string"
| "number"
| "boolean"
| "object"
| "function";
} = {
"notifications:desktop": "boolean",
"notifications:sounds": "object",
"appearance:emoji": "string",
"appearance:ligatures": "boolean",
"appearance:theme:base": "string",
"appearance:theme:custom": "object",
};*/
/** /**
* Manages user settings. * Manages user settings.
*/ */
export default class Settings implements Store, Persistent<ISettings> { export default class Settings implements Store, Persistent<ISettings> {
private data: ObservableMap<string, unknown>; private data: ObservableMap<string, unknown>;
theme: STheme;
/** /**
* Construct new Layout store. * Construct new Settings store.
*/ */
constructor() { constructor() {
this.data = new ObservableMap(); this.data = new ObservableMap();
makeAutoObservable(this); makeAutoObservable(this);
this.theme = new STheme(this);
} }
get id() { get id() {
return "layout"; return "settings";
} }
toJSON() { toJSON() {
@ -69,18 +62,38 @@ export default class Settings implements Store, Persistent<ISettings> {
); );
} }
/**
* Set a settings key.
* @param key Colon-divided key
* @param value Value
*/
@action set<T extends keyof ISettings>(key: T, value: ISettings[T]) { @action set<T extends keyof ISettings>(key: T, value: ISettings[T]) {
return this.data.set(key, value); this.data.set(key, value);
} }
/**
* Get a settings key.
* @param key Colon-divided key
* @returns Value at key
*/
@computed get<T extends keyof ISettings>(key: T) { @computed get<T extends keyof ISettings>(key: T) {
return this.data.get(key) as ISettings[T] | undefined; return this.data.get(key) as ISettings[T] | undefined;
} }
/**
* Set a value in settings without type-checking.
* @param key Colon-divided key
* @param value Value
*/
@action setUnchecked(key: string, value: unknown) { @action setUnchecked(key: string, value: unknown) {
return this.data.set(key, value); this.data.set(key, value);
} }
/**
* Get a settings key with unknown type.
* @param key Colon-divided key
* @returns Value at key
*/
@computed getUnchecked(key: string) { @computed getUnchecked(key: string) {
return this.data.get(key); return this.data.get(key);
} }

View file

@ -0,0 +1,94 @@
import { makeAutoObservable, computed } from "mobx";
import {
Theme,
PRESETS,
Variables,
DEFAULT_FONT,
DEFAULT_MONO_FONT,
} from "../../../context/Theme";
import Settings from "../Settings";
/**
* Helper class for reading and writing themes.
*/
export default class STheme {
private settings: Settings;
/**
* Construct a new theme helper.
* @param settings Settings parent class
*/
constructor(settings: Settings) {
this.settings = settings;
makeAutoObservable(this);
}
/**
* Get the base theme used for this theme.
* @returns Id of base theme
*/
@computed getBase() {
return this.settings.get("appearance:theme:base") ?? "dark";
}
/**
* Get whether the theme is light.
* @returns True if the theme is light
*/
@computed isLight() {
return (
this.settings.get("appearance:theme:light") ??
this.getBase() === "light"
);
}
/**
* Get the current theme's CSS variables.
* @returns Record of CSS variables
*/
@computed getVariables(): Theme {
return {
...PRESETS[this.getBase()],
...this.settings.get("appearance:theme:overrides"),
light: this.isLight(),
};
}
/**
* Get a specific value of a variable by its key.
* @param key Variable
* @returns Value of variable
*/
@computed getVariable(key: Variables) {
return (this.settings.get("appearance:theme:overrides") ??
PRESETS[this.getBase()])[key]!;
}
/**
* Get the current applied font.
* @returns Current font
*/
@computed getFont() {
return this.settings.get("appearance:theme:font") ?? DEFAULT_FONT;
}
/**
* Get the current applied monospace font.
* @returns Current monospace font
*/
@computed getMonospaceFont() {
return (
this.settings.get("appearance:theme:monoFont") ?? DEFAULT_MONO_FONT
);
}
/**
* Get the currently applied CSS snippet.
* @returns CSS string
*/
@computed getCSS() {
return this.settings.get("appearance:theme:css");
}
}

View file

@ -5,13 +5,9 @@ import { LIBRARY_VERSION } from "revolt.js";
import styles from "./Login.module.scss"; import styles from "./Login.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import { ThemeContext } from "../../context/Theme";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import LocaleSelector from "../../components/common/LocaleSelector"; import LocaleSelector from "../../components/common/LocaleSelector";
import background from "./background.jpg"; import background from "./background.jpg";
@ -23,8 +19,9 @@ import { FormReset, FormSendReset } from "./forms/FormReset";
import { FormResend, FormVerify } from "./forms/FormVerify"; import { FormResend, FormVerify } from "./forms/FormVerify";
export default observer(() => { export default observer(() => {
const theme = useContext(ThemeContext); const state = useApplicationState();
const configuration = useApplicationState().config.get(); const theme = state.settings.theme;
const configuration = state.config.get();
return ( return (
<> <>
@ -33,7 +30,10 @@ export default observer(() => {
)} )}
<div className={styles.login}> <div className={styles.login}>
<Helmet> <Helmet>
<meta name="theme-color" content={theme.background} /> <meta
name="theme-color"
content={theme.getVariable("background")}
/>
</Helmet> </Helmet>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.attribution}> <div className={styles.attribution}>

View file

@ -15,7 +15,7 @@ import {
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { ThemeContext } from "../../context/Theme"; import { useApplicationState } from "../../mobx/State";
import Category from "../../components/ui/Category"; import Category from "../../components/ui/Category";
import Header from "../../components/ui/Header"; import Header from "../../components/ui/Header";
@ -53,7 +53,7 @@ export function GenericSettings({
showExitButton, showExitButton,
}: Props) { }: Props) {
const history = useHistory(); const history = useHistory();
const theme = useContext(ThemeContext); const theme = useApplicationState().settings.theme;
const { page } = useParams<{ page: string }>(); const { page } = useParams<{ page: string }>();
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
@ -94,8 +94,8 @@ export function GenericSettings({
name="theme-color" name="theme-color"
content={ content={
isTouchscreenDevice isTouchscreenDevice
? theme["background"] ? theme.getVariable("background")
: theme["secondary-background"] : theme.getVariable("secondary-background")
} }
/> />
</Helmet> </Helmet>

View file

@ -1,5 +1,6 @@
import { Reset, Import } from "@styled-icons/boxicons-regular"; import { Reset, Import } from "@styled-icons/boxicons-regular";
import { Pencil, Store } from "@styled-icons/boxicons-solid"; import { Pencil, Store } from "@styled-icons/boxicons-solid";
import { Link } from "react-router-dom";
// @ts-expect-error shade-blend-color does not have typings. // @ts-expect-error shade-blend-color does not have typings.
import pSBC from "shade-blend-color"; import pSBC from "shade-blend-color";
@ -8,16 +9,14 @@ import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks"; import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import CategoryButton from "../../../components/ui/fluent/CategoryButton";
import { debounce } from "../../../lib/debounce"; import { debounce } from "../../../lib/debounce";
import { useApplicationState } from "../../../mobx/State";
import { dispatch } from "../../../redux"; import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { isExperimentEnabled } from "../../../redux/reducers/experiments";
import { EmojiPacks, Settings } from "../../../redux/reducers/settings"; import { EmojiPacks, Settings } from "../../../redux/reducers/settings";
import { import {
DEFAULT_FONT, DEFAULT_FONT,
DEFAULT_MONO_FONT, DEFAULT_MONO_FONT,
@ -28,7 +27,6 @@ import {
MONOSPACE_FONTS, MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS, MONOSPACE_FONT_KEYS,
Theme, Theme,
ThemeContext,
ThemeOptions, ThemeOptions,
} from "../../../context/Theme"; } from "../../../context/Theme";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
@ -40,14 +38,13 @@ import Checkbox from "../../../components/ui/Checkbox";
import ColourSwatches from "../../../components/ui/ColourSwatches"; import ColourSwatches from "../../../components/ui/ColourSwatches";
import ComboBox from "../../../components/ui/ComboBox"; import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox"; import InputBox from "../../../components/ui/InputBox";
import CategoryButton from "../../../components/ui/fluent/CategoryButton";
import darkSVG from "../assets/dark.svg"; import darkSVG from "../assets/dark.svg";
import lightSVG from "../assets/light.svg"; import lightSVG from "../assets/light.svg";
import mutantSVG from "../assets/mutant_emoji.svg"; import mutantSVG from "../assets/mutant_emoji.svg";
import notoSVG from "../assets/noto_emoji.svg"; import notoSVG from "../assets/noto_emoji.svg";
import openmojiSVG from "../assets/openmoji_emoji.svg"; import openmojiSVG from "../assets/openmoji_emoji.svg";
import twemojiSVG from "../assets/twemoji_emoji.svg"; import twemojiSVG from "../assets/twemoji_emoji.svg";
import { Link } from "react-router-dom";
import { isExperimentEnabled } from "../../../redux/reducers/experiments";
interface Props { interface Props {
settings: Settings; settings: Settings;
@ -55,7 +52,7 @@ interface Props {
// ! FIXME: code needs to be rewritten to fix jittering // ! FIXME: code needs to be rewritten to fix jittering
export function Component(props: Props) { export function Component(props: Props) {
const theme = useContext(ThemeContext); const theme = useApplicationState().settings.theme;
const { writeClipboard, openScreen } = useIntermediate(); const { writeClipboard, openScreen } = useIntermediate();
function setTheme(theme: ThemeOptions) { function setTheme(theme: ThemeOptions) {
@ -112,8 +109,7 @@ export function Component(props: Props) {
draggable={false} draggable={false}
data-active={selected === "light"} data-active={selected === "light"}
onClick={() => onClick={() =>
selected !== "light" && selected !== "light" && setTheme({ base: "light" })
setTheme({ base: "light" })
} }
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
/> />
@ -138,16 +134,24 @@ export function Component(props: Props) {
</div> </div>
</div> </div>
{isExperimentEnabled('theme_shop') && <Link to="/settings/theme_shop" replace> {isExperimentEnabled("theme_shop") && (
<CategoryButton icon={<Store size={24} />} action="chevron" hover> <Link to="/settings/theme_shop" replace>
<Text id="app.settings.pages.theme_shop.title" /> <CategoryButton
</CategoryButton> icon={<Store size={24} />}
</Link>} action="chevron"
hover>
<Text id="app.settings.pages.theme_shop.title" />
</CategoryButton>
</Link>
)}
<h3> <h3>
<Text id="app.settings.pages.appearance.accent_selector" /> <Text id="app.settings.pages.appearance.accent_selector" />
</h3> </h3>
<ColourSwatches value={theme.accent} onChange={setAccent} /> <ColourSwatches
value={theme.getVariable("accent")}
onChange={setAccent}
/>
{/*<h3> {/*<h3>
<Text id="app.settings.pages.appearance.message_display" /> <Text id="app.settings.pages.appearance.message_display" />
@ -175,7 +179,7 @@ export function Component(props: Props) {
<Text id="app.settings.pages.appearance.font" /> <Text id="app.settings.pages.appearance.font" />
</h3> </h3>
<ComboBox <ComboBox
value={theme.font ?? DEFAULT_FONT} value={theme.getFont()}
onChange={(e) => onChange={(e) =>
pushOverride({ font: e.currentTarget.value as Fonts }) pushOverride({ font: e.currentTarget.value as Fonts })
}> }>
@ -363,11 +367,11 @@ export function Component(props: Props) {
<div <div
className={styles.entry} className={styles.entry}
key={x} key={x}
style={{ backgroundColor: theme[x] }}> style={{ backgroundColor: theme.getVariable(x) }}>
<div className={styles.input}> <div className={styles.input}>
<input <input
type="color" type="color"
value={theme[x]} value={theme.getVariable(x)}
onChange={(v) => onChange={(v) =>
setOverride({ setOverride({
[x]: v.currentTarget.value, [x]: v.currentTarget.value,
@ -377,8 +381,8 @@ export function Component(props: Props) {
</div> </div>
<span <span
style={`color: ${getContrastingColour( style={`color: ${getContrastingColour(
theme[x], theme.getVariable(x),
theme["primary-background"], theme.getVariable("primary-background"),
)}`}> )}`}>
{x} {x}
</span> </span>
@ -395,7 +399,7 @@ export function Component(props: Props) {
<InputBox <InputBox
type="text" type="text"
className={styles.text} className={styles.text}
value={theme[x]} value={theme.getVariable(x)}
onChange={(y) => onChange={(y) =>
setOverride({ setOverride({
[x]: y.currentTarget.value, [x]: y.currentTarget.value,
@ -416,7 +420,7 @@ export function Component(props: Props) {
<Text id="app.settings.pages.appearance.mono_font" /> <Text id="app.settings.pages.appearance.mono_font" />
</h3> </h3>
<ComboBox <ComboBox
value={theme.monospaceFont ?? DEFAULT_MONO_FONT} value={theme.getMonospaceFont()}
onChange={(e) => onChange={(e) =>
pushOverride({ pushOverride({
monospaceFont: e.currentTarget monospaceFont: e.currentTarget