feat(mobx): continue implementing themes; performance work on settings

This commit is contained in:
Paul Makles 2021-12-13 17:27:30 +00:00
parent bd4369cf29
commit 65be047dc6
15 changed files with 497 additions and 287 deletions

View file

@ -0,0 +1,258 @@
import { Store } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Link } from "react-router-dom";
import { Text } from "preact-i18n";
import TextAreaAutoSize from "../../lib/TextAreaAutoSize";
import { useApplicationState } from "../../mobx/State";
import { EmojiPack } from "../../mobx/stores/Settings";
import {
Fonts,
FONTS,
FONT_KEYS,
MonospaceFonts,
MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS,
} from "../../context/Theme";
import Checkbox from "../ui/Checkbox";
import ColourSwatches from "../ui/ColourSwatches";
import ComboBox from "../ui/ComboBox";
import Radio from "../ui/Radio";
import CategoryButton from "../ui/fluent/CategoryButton";
import mutantSVG from "./mutant_emoji.svg";
import notoSVG from "./noto_emoji.svg";
import openmojiSVG from "./openmoji_emoji.svg";
import twemojiSVG from "./twemoji_emoji.svg";
import { ThemeBaseSelector } from "./appearance/ThemeBaseSelector";
export const ThemeBaseSelectorShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<ThemeBaseSelector value={theme.getBase()} setValue={theme.setBase} />
);
});
export const ThemeShopShim = () => {
if (!useApplicationState().experiments.isEnabled("theme_shop")) return null;
return (
<Link to="/settings/theme_shop" replace>
<CategoryButton icon={<Store size={24} />} action="chevron" hover>
<Text id="app.settings.pages.theme_shop.title" />
</CategoryButton>
</Link>
);
};
export const ThemeAccentShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.accent_selector" />
</h3>
<ColourSwatches
value={theme.getVariable("accent")}
onChange={(colour) => theme.setVariable("accent", colour)}
/>
</>
);
});
export const ThemeCustomCSSShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.custom_css" />
</h3>
<TextAreaAutoSize
maxRows={20}
minHeight={480}
code
value={theme.getCSS() ?? ""}
onChange={(ev) => theme.setCSS(ev.currentTarget.value)}
/>
</>
);
});
export const DisplayCompactShim = () => {
return (
<>
<h3>
<Text id="app.settings.pages.appearance.message_display" />
</h3>
<div /* className={styles.display} */>
<Radio
description={
<Text id="app.settings.pages.appearance.display.default_description" />
}
checked>
<Text id="app.settings.pages.appearance.display.default" />
</Radio>
<Radio
description={
<Text id="app.settings.pages.appearance.display.compact_description" />
}
disabled>
<Text id="app.settings.pages.appearance.display.compact" />
</Radio>
</div>
</>
);
};
export const DisplayFontShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ComboBox
value={theme.getFont()}
onChange={(e) => theme.setFont(e.currentTarget.value as Fonts)}>
{FONT_KEYS.map((key) => (
<option value={key} key={key}>
{FONTS[key as keyof typeof FONTS].name}
</option>
))}
</ComboBox>
</>
);
});
export const DisplayMonospaceFontShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ComboBox
value={theme.getMonospaceFont()}
onChange={(e) =>
theme.setMonospaceFont(
e.currentTarget.value as MonospaceFonts,
)
}>
{MONOSPACE_FONT_KEYS.map((key) => (
<option value={key} key={key}>
{
MONOSPACE_FONTS[key as keyof typeof MONOSPACE_FONTS]
.name
}
</option>
))}
</ComboBox>
</>
);
});
export const DisplayLigaturesShim = observer(() => {
const settings = useApplicationState().settings;
if (settings.theme.getFont() !== "Inter") return null;
return (
<p>
<Checkbox
checked={settings.get("appearance:ligatures") ?? false}
onChange={(v) => settings.set("appearance:ligatures", v)}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}>
<Text id="app.settings.pages.appearance.ligatures" />
</Checkbox>
</p>
);
});
export const DisplayEmojiShim = observer(() => {
const settings = useApplicationState().settings;
const emojiPack = settings.get("appearance:emoji");
const setPack = (v: EmojiPack) => () => settings.set("appearance:emoji", v);
return (
<>
<h3>
<Text id="app.settings.pages.appearance.emoji_pack" />
</h3>
<div /* className={styles.emojiPack} */>
<div /* className={styles.row} */>
<div>
<div
/* className={styles.button} */
onClick={setPack("mutant")}
data-active={emojiPack === "mutant"}>
<img
loading="eager"
src={mutantSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>
Mutant Remix{" "}
<a
href="https://mutant.revolt.chat"
target="_blank"
rel="noreferrer">
(by Revolt)
</a>
</h4>
</div>
<div>
<div
/* className={styles.button} */
onClick={setPack("twemoji")}
data-active={emojiPack === "twemoji"}>
<img
loading="eager"
src={twemojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Twemoji</h4>
</div>
</div>
<div /* className={styles.row} */>
<div>
<div
/* className={styles.button} */
onClick={setPack("openmoji")}
data-active={emojiPack === "openmoji"}>
<img
loading="eager"
src={openmojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Openmoji</h4>
</div>
<div>
<div
/* className={styles.button} */
onClick={setPack("noto")}
data-active={emojiPack === "noto"}>
<img
loading="eager"
src={notoSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Noto Emoji</h4>
</div>
</div>
</div>
</>
);
});

View file

@ -0,0 +1,84 @@
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useApplicationState } from "../../../mobx/State";
import darkSVG from "./dark.svg";
import lightSVG from "./light.svg";
const List = styled.div`
gap: 8px;
display: flex;
width: 100%;
> div {
min-width: 0;
display: flex;
flex-direction: column;
}
img {
cursor: pointer;
border-radius: var(--border-radius);
transition: border 0.3s;
border: 3px solid transparent;
width: 100%;
&[data-active="true"] {
cursor: default;
border: 3px solid var(--accent);
&:hover {
border: 3px solid var(--accent);
}
}
&:hover {
border: 3px solid var(--tertiary-background);
}
}
`;
interface Props {
value?: "light" | "dark";
setValue: (base: "light" | "dark") => void;
}
export function ThemeBaseSelector({ value, setValue }: Props) {
return (
<>
<h3>
<Text id="app.settings.pages.appearance.theme" />
</h3>
<List>
<div>
<img
loading="eager"
src={lightSVG}
draggable={false}
data-active={value === "light"}
onClick={() => value !== "light" && setValue("light")}
onContextMenu={(e) => e.preventDefault()}
/>
<h4>
<Text id="app.settings.pages.appearance.color.light" />
</h4>
</div>
<div>
<img
loading="eager"
src={darkSVG}
draggable={false}
data-active={value === "dark"}
onClick={() => value !== "dark" && setValue("dark")}
onContextMenu={(e) => e.preventDefault()}
/>
<h4>
<Text id="app.settings.pages.appearance.color.dark" />
</h4>
</div>
</List>
</>
);
}

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View file

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -7,9 +7,9 @@ interface Props {
children: Children;
description?: Children;
checked: boolean;
checked?: boolean;
disabled?: boolean;
onSelect: () => void;
onSelect?: () => void;
}
interface BaseProps {
@ -87,9 +87,10 @@ const RadioDescription = styled.span<BaseProps>`
`;
export default function Radio(props: Props) {
const selected = props.checked ?? false;
return (
<RadioBase
selected={props.checked}
selected={selected}
disabled={props.disabled}
onClick={() =>
!props.disabled && props.onSelect && props.onSelect()
@ -101,7 +102,7 @@ export default function Radio(props: Props) {
<span>
<span>{props.children}</span>
{props.description && (
<RadioDescription selected={props.checked}>
<RadioDescription selected={selected}>
{props.description}
</RadioDescription>
)}

View file

@ -1,7 +1,7 @@
import { observer } from "mobx-react-lite";
import { Helmet } from "react-helmet";
import { createGlobalStyle } from "styled-components";
import { createContext } from "preact";
import { useEffect } from "preact/hooks";
import { useApplicationState } from "../mobx/State";
@ -316,7 +316,7 @@ export const generateVariables = (theme: Theme) => {
});
};
export default function Theme() {
export default observer(() => {
const settings = useApplicationState().settings;
const theme = settings.theme;
@ -359,4 +359,4 @@ export default function Theme() {
<style dangerouslySetInnerHTML={{ __html: theme.getCSS() ?? "" }} />
</>
);
}
});

76
src/mobx/stores/Cache.ts Normal file
View file

@ -0,0 +1,76 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { mapToRecord } from "../../lib/conversion";
import { StoredTheme } from "../../redux/reducers/themes";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
interface Data {
themes: Record<string, StoredTheme>;
}
/**
* Cache data store for temporary, long-lived data.
*/
export default class Cache implements Store, Persistent<Data> {
private themes: ObservableMap<string, StoredTheme>;
/**
* Construct new Cache store.
*/
constructor() {
this.themes = new ObservableMap();
makeAutoObservable(this);
}
get id() {
return "draft";
}
toJSON() {
return {
themes: JSON.parse(JSON.stringify(mapToRecord(this.themes))),
};
}
@action hydrate(data: Data) {
Object.keys(data.themes).forEach((key) =>
this.themes.set(key, data.themes[key]),
);
}
/**
* Cache a given theme.
* @param theme Theme
*/
@action cacheTheme(theme: StoredTheme) {
this.themes.set(theme.slug, theme);
}
/**
* Remove a cached theme.
* @param slug String
*/
@action removeTheme(slug: string) {
this.themes.delete(slug);
}
/**
* Get a cached theme by its slug.
* @param slug Theme slug
* @returns Theme, if found
*/
@computed getTheme(slug: string) {
return this.themes.get(slug);
}
/**
* Get all cached themes.
* @returns Themes
*/
@computed getThemes() {
return [...this.themes.values()];
}
}

View file

@ -80,6 +80,10 @@ export default class Settings implements Store, Persistent<ISettings> {
return this.data.get(key) as ISettings[T] | undefined;
}
@action remove<T extends keyof ISettings>(key: T) {
this.data.delete(key);
}
/**
* Set a value in settings without type-checking.
* @param key Colon-divided key

View file

@ -1,4 +1,4 @@
import { makeAutoObservable, computed } from "mobx";
import { makeAutoObservable, computed, action } from "mobx";
import {
Theme,
@ -6,6 +6,8 @@ import {
Variables,
DEFAULT_FONT,
DEFAULT_MONO_FONT,
Fonts,
MonospaceFonts,
} from "../../../context/Theme";
import Settings from "../Settings";
@ -23,6 +25,7 @@ export default class STheme {
constructor(settings: Settings) {
this.settings = settings;
makeAutoObservable(this);
this.setBase = this.setBase.bind(this);
}
/**
@ -56,6 +59,13 @@ export default class STheme {
};
}
@action setVariable(key: Variables, value: string) {
this.settings.set("appearance:theme:overrides", {
...this.settings.get("appearance:theme:overrides"),
[key]: value,
});
}
/**
* Get a specific value of a variable by its key.
* @param key Variable
@ -66,6 +76,10 @@ export default class STheme {
PRESETS[this.getBase()])[key]!;
}
@action setFont(font: Fonts) {
this.settings.set("appearance:theme:font", font);
}
/**
* Get the current applied font.
* @returns Current font
@ -74,6 +88,10 @@ export default class STheme {
return this.settings.get("appearance:theme:font") ?? DEFAULT_FONT;
}
@action setMonospaceFont(font: MonospaceFonts) {
this.settings.set("appearance:theme:monoFont", font);
}
/**
* Get the current applied monospace font.
* @returns Current monospace font
@ -84,6 +102,14 @@ export default class STheme {
);
}
@action setCSS(value: string) {
if (value.length > 0) {
this.settings.set("appearance:theme:css", value);
} else {
this.settings.remove("appearance:theme:css");
}
}
/**
* Get the currently applied CSS snippet.
* @returns CSS string
@ -91,4 +117,12 @@ export default class STheme {
@computed getCSS() {
return this.settings.get("appearance:theme:css");
}
@action setBase(base?: "light" | "dark") {
if (base) {
this.settings.set("appearance:theme:base", base);
} else {
this.settings.remove("appearance:theme:base");
}
}
}

View file

@ -1,61 +1,50 @@
import { Reset, Import } from "@styled-icons/boxicons-regular";
import { Pencil, Store } from "@styled-icons/boxicons-solid";
import { Link } from "react-router-dom";
import { Pencil } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
// @ts-expect-error shade-blend-color does not have typings.
import pSBC from "shade-blend-color";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { useCallback, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { debounce } from "../../../lib/debounce";
import { useApplicationState } from "../../../mobx/State";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { isExperimentEnabled } from "../../../redux/reducers/experiments";
import { EmojiPacks, Settings } from "../../../redux/reducers/settings";
import {
DEFAULT_FONT,
DEFAULT_MONO_FONT,
Fonts,
FONTS,
FONT_KEYS,
MonospaceFonts,
MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS,
Theme,
ThemeOptions,
} from "../../../context/Theme";
import { Theme, ThemeOptions } from "../../../context/Theme";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import CollapsibleSection from "../../../components/common/CollapsibleSection";
import Tooltip from "../../../components/common/Tooltip";
import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox";
import ColourSwatches from "../../../components/ui/ColourSwatches";
import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox";
import CategoryButton from "../../../components/ui/fluent/CategoryButton";
import darkSVG from "../assets/dark.svg";
import lightSVG from "../assets/light.svg";
import mutantSVG from "../assets/mutant_emoji.svg";
import notoSVG from "../assets/noto_emoji.svg";
import openmojiSVG from "../assets/openmoji_emoji.svg";
import twemojiSVG from "../assets/twemoji_emoji.svg";
import {
ThemeBaseSelectorShim,
ThemeShopShim,
ThemeAccentShim,
DisplayCompactShim,
DisplayFontShim,
DisplayMonospaceFontShim,
DisplayLigaturesShim,
DisplayEmojiShim,
ThemeCustomCSSShim,
} from "../../../components/settings/AppearanceShims";
interface Props {
settings: Settings;
}
// ! FIXME: code needs to be rewritten to fix jittering
export function Component(props: Props) {
const theme = useApplicationState().settings.theme;
export const Component = observer((props: Props) => {
//const theme = useApplicationState().settings.theme;
const { writeClipboard, openScreen } = useIntermediate();
function setTheme(theme: ThemeOptions) {
/*function setTheme(theme: ThemeOptions) {
dispatch({
type: "SETTINGS_SET_THEME",
theme,
@ -95,191 +84,18 @@ export function Component(props: Props) {
useEffect(() => setOverride({ css }), [setOverride, css]);
const selected = props.settings.theme?.base ?? "dark";
const selected = theme.getBase();*/
return (
<div className={styles.appearance}>
<h3>
<Text id="app.settings.pages.appearance.theme" />
</h3>
<div className={styles.themes}>
<div className={styles.theme}>
<img
loading="eager"
src={lightSVG}
draggable={false}
data-active={selected === "light"}
onClick={() =>
selected !== "light" && setTheme({ base: "light" })
}
onContextMenu={(e) => e.preventDefault()}
/>
<h4>
<Text id="app.settings.pages.appearance.color.light" />
</h4>
</div>
<div className={styles.theme}>
<img
loading="eager"
src={darkSVG}
draggable={false}
data-active={selected === "dark"}
onClick={() =>
selected !== "dark" && setTheme({ base: "dark" })
}
onContextMenu={(e) => e.preventDefault()}
/>
<h4>
<Text id="app.settings.pages.appearance.color.dark" />
</h4>
</div>
</div>
<ThemeBaseSelectorShim />
<ThemeShopShim />
<ThemeAccentShim />
{/*<DisplayCompactShim />
<DisplayFontShim />
<DisplayLigaturesShim />
<DisplayEmojiShim />*/}
{isExperimentEnabled("theme_shop") && (
<Link to="/settings/theme_shop" replace>
<CategoryButton
icon={<Store size={24} />}
action="chevron"
hover>
<Text id="app.settings.pages.theme_shop.title" />
</CategoryButton>
</Link>
)}
<h3>
<Text id="app.settings.pages.appearance.accent_selector" />
</h3>
<ColourSwatches
value={theme.getVariable("accent")}
onChange={setAccent}
/>
{/*<h3>
<Text id="app.settings.pages.appearance.message_display" />
</h3>
<div className={styles.display}>
<Radio
description={
<Text id="app.settings.pages.appearance.display.default_description" />
}
checked
>
<Text id="app.settings.pages.appearance.display.default" />
</Radio>
<Radio
description={
<Text id="app.settings.pages.appearance.display.compact_description" />
}
disabled
>
<Text id="app.settings.pages.appearance.display.compact" />
</Radio>
</div>*/}
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ComboBox
value={theme.getFont()}
onChange={(e) =>
pushOverride({ font: e.currentTarget.value as Fonts })
}>
{FONT_KEYS.map((key) => (
<option value={key} key={key}>
{FONTS[key as keyof typeof FONTS].name}
</option>
))}
</ComboBox>
{/* TOFIX: Only show when a font with ligature support is selected, i.e.: Inter.*/}
<p>
<Checkbox
checked={props.settings.theme?.ligatures === true}
onChange={() =>
setTheme({
ligatures: !props.settings.theme?.ligatures,
})
}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}>
<Text id="app.settings.pages.appearance.ligatures" />
</Checkbox>
</p>
<h3>
<Text id="app.settings.pages.appearance.emoji_pack" />
</h3>
<div className={styles.emojiPack}>
<div className={styles.row}>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("mutant")}
data-active={emojiPack === "mutant"}>
<img
loading="eager"
src={mutantSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>
Mutant Remix{" "}
<a
href="https://mutant.revolt.chat"
target="_blank"
rel="noreferrer">
(by Revolt)
</a>
</h4>
</div>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("twemoji")}
data-active={emojiPack === "twemoji"}>
<img
loading="eager"
src={twemojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Twemoji</h4>
</div>
</div>
<div className={styles.row}>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("openmoji")}
data-active={emojiPack === "openmoji"}>
<img
loading="eager"
src={openmojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Openmoji</h4>
</div>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("noto")}
data-active={emojiPack === "noto"}>
<img
loading="eager"
src={notoSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Noto Emoji</h4>
</div>
</div>
</div>
<CollapsibleSection
{/*<CollapsibleSection
defaultValue={false}
id="settings_overrides"
summary={<Text id="app.settings.pages.appearance.overrides" />}>
@ -300,7 +116,6 @@ export function Component(props: Props) {
onClick={() => writeClipboard(JSON.stringify(theme))}>
<Tooltip content={<Text id="app.special.copy" />}>
{" "}
{/*TOFIX: Try to put the tooltip above the .code div without messing up the css challenge */}
{JSON.stringify(theme)}
</Tooltip>
</div>
@ -410,48 +225,18 @@ export function Component(props: Props) {
</div>
))}
</div>
</CollapsibleSection>
</CollapsibleSection>*/}
<CollapsibleSection
id="settings_advanced_appearance"
defaultValue={false}
summary={<Text id="app.settings.pages.appearance.advanced" />}>
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ComboBox
value={theme.getMonospaceFont()}
onChange={(e) =>
pushOverride({
monospaceFont: e.currentTarget
.value as MonospaceFonts,
})
}>
{MONOSPACE_FONT_KEYS.map((key) => (
<option value={key} key={key}>
{
MONOSPACE_FONTS[
key as keyof typeof MONOSPACE_FONTS
].name
}
</option>
))}
</ComboBox>
<h3>
<Text id="app.settings.pages.appearance.custom_css" />
</h3>
<TextAreaAutoSize
maxRows={20}
minHeight={480}
code
value={css}
onChange={(ev) => setCSS(ev.currentTarget.value)}
/>
<DisplayMonospaceFontShim />
<ThemeCustomCSSShim />
</CollapsibleSection>
</div>
);
}
});
export const Appearance = connectState(Component, (state) => {
return {

View file

@ -150,38 +150,6 @@
}
.appearance {
.theme {
min-width: 0;
display: flex;
flex-direction: column;
}
.themes {
gap: 8px;
display: flex;
width: 100%;
img {
cursor: pointer;
border-radius: var(--border-radius);
transition: border 0.3s;
border: 3px solid transparent;
width: 100%;
&[data-active="true"] {
cursor: default;
border: 3px solid var(--accent);
&:hover {
border: 3px solid var(--accent);
}
}
&:hover {
border: 3px solid var(--tertiary-background);
}
}
}
details {
summary {
font-size: 0.8125rem;