Do most of the theme handling from the Theme component (#2390)

* Set data-bs-theme attribute from Theme component

* Handle temporary theme changes in Theme component

* Fetch theme list on AdminSettings component mount

* Include CodeTheme in Theme component

* Improve handling of browser-compact theme

---------

Co-authored-by: SleeplessOne1917 <28871516+SleeplessOne1917@users.noreply.github.com>
This commit is contained in:
matc-pub 2024-03-14 13:33:49 +01:00 committed by GitHub
parent 201e5fcd53
commit 9a5f9dd18a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 205 additions and 145 deletions

View file

@ -79,6 +79,12 @@ export async function createSsrHtml(
<!DOCTYPE html> <!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}> <html ${helmet.htmlAttributes.toString()}>
<head> <head>
<script nonce="${cspNonce}">
if (!document.documentElement.hasAttribute("data-bs-theme")) {
const light = window.matchMedia("(prefers-color-scheme: light)").matches;
document.documentElement.setAttribute("data-bs-theme", light ? "light" : "dark");
}
</script>
${lazyScripts} ${lazyScripts}
<script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script> <script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>

View file

@ -1,5 +1,4 @@
import { isAnonymousPath, isAuthPath, setIsoData } from "@utils/app"; import { isAnonymousPath, isAuthPath, setIsoData } from "@utils/app";
import { dataBsTheme } from "@utils/browser";
import { Component, RefObject, createRef, linkEvent } from "inferno"; import { Component, RefObject, createRef, linkEvent } from "inferno";
import { Provider } from "inferno-i18next-dess"; import { Provider } from "inferno-i18next-dess";
import { Route, Switch } from "inferno-router"; import { Route, Switch } from "inferno-router";
@ -14,7 +13,6 @@ import { Navbar } from "./navbar";
import "./styles.scss"; import "./styles.scss";
import { Theme } from "./theme"; import { Theme } from "./theme";
import AnonymousGuard from "../common/anonymous-guard"; import AnonymousGuard from "../common/anonymous-guard";
import { CodeTheme } from "./code-theme";
export class App extends Component<any, any> { export class App extends Component<any, any> {
private isoData: IsoDataOptionalSite = setIsoData(this.context); private isoData: IsoDataOptionalSite = setIsoData(this.context);
@ -36,11 +34,7 @@ export class App extends Component<any, any> {
return ( return (
<> <>
<Provider i18next={I18NextService.i18n}> <Provider i18next={I18NextService.i18n}>
<div <div id="app" className="lemmy-site">
id="app"
className="lemmy-site"
data-bs-theme={dataBsTheme(siteRes)}
>
<button <button
type="button" type="button"
className="btn skip-link bg-light position-absolute start-0 z-3" className="btn skip-link bg-light position-absolute start-0 z-3"
@ -49,10 +43,7 @@ export class App extends Component<any, any> {
{I18NextService.i18n.t("jump_to_content", "Jump to content")} {I18NextService.i18n.t("jump_to_content", "Jump to content")}
</button> </button>
{siteView && ( {siteView && (
<> <Theme defaultTheme={siteView.local_site.default_theme} />
<Theme defaultTheme={siteView.local_site.default_theme} />
<CodeTheme defaultTheme={siteView.local_site.default_theme} />
</>
)} )}
<Navbar siteRes={siteRes} /> <Navbar siteRes={siteRes} />
<div className="mt-4 p-0 fl-1"> <div className="mt-4 p-0 fl-1">

View file

@ -1,18 +1,34 @@
import { dataBsTheme } from "@utils/browser"; import { dataBsTheme } from "@utils/browser";
import { Component } from "inferno"; import { Component } from "inferno";
import { Helmet } from "inferno-helmet"; import { Helmet } from "inferno-helmet";
import { UserService } from "../../services";
interface CodeThemeProps { interface CodeThemeProps {
defaultTheme: string; theme: string;
} }
export class CodeTheme extends Component<CodeThemeProps, any> { export class CodeTheme extends Component<CodeThemeProps, any> {
render() { render() {
const user = UserService.Instance.myUserInfo; const { theme } = this.props;
const userTheme = user?.local_user_view.local_user.theme; const hasTheme = theme !== "browser" && theme !== "browser-compact";
const theme =
user && userTheme !== "browser" ? userTheme : this.props.defaultTheme; if (!hasTheme) {
return (
<Helmet>
<link
rel="stylesheet"
type="text/css"
href={`/css/code-themes/atom-one-light.css`}
media="(prefers-color-scheme: light)"
/>
<link
rel="stylesheet"
type="text/css"
href={`/css/code-themes/atom-one-dark.css`}
media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)"
/>
</Helmet>
);
}
return ( return (
<Helmet> <Helmet>

View file

@ -1,76 +1,168 @@
import { Component } from "inferno"; import { Component } from "inferno";
import { Helmet } from "inferno-helmet"; import { Helmet } from "inferno-helmet";
import { UserService } from "../../services"; import { UserService } from "../../services";
import { dataBsTheme, isBrowser } from "@utils/browser";
import { CodeTheme } from "./code-theme";
interface Props { interface Props {
defaultTheme: string; defaultTheme: string;
} }
export class Theme extends Component<Props> { interface State {
render() { themeOverride?: string;
const user = UserService.Instance.myUserInfo; graceTheme?: string;
const hasTheme = user?.local_user_view.local_user.theme !== "browser"; }
if (user && hasTheme) { export class Theme extends Component<Props, State> {
private lightQuery?: MediaQueryList;
constructor(props, context) {
super(props, context);
if (isBrowser()) {
window.addEventListener("refresh-theme", this.eventListener);
window.addEventListener("set-theme-override", this.eventListener);
this.lightQuery = window.matchMedia("(prefers-color-scheme: light)");
this.lightQuery.addEventListener("change", this.eventListener);
}
}
private graceTimer;
private eventListener = e => {
if (e.type === "refresh-theme" || e.type === "change") {
this.forceUpdate();
} else if (e.type === "set-theme-override") {
if (e.detail?.theme) {
this.setState({
themeOverride: e.detail.theme,
graceTheme: this.state?.themeOverride ?? this.currentTheme(),
});
// Keep both themes enabled for one second. Avoids unstyled flashes.
clearTimeout(this.graceTimer);
this.graceTimer = setTimeout(() => {
this.setState({ graceTheme: undefined });
}, 1000);
} else {
this.setState({ themeOverride: undefined, graceTheme: undefined });
}
}
};
componentWillUnmount(): void {
if (isBrowser()) {
window.removeEventListener("refresh-theme", this.eventListener);
this.lightQuery?.removeEventListener("change", this.eventListener);
}
}
currentTheme(): string {
const user = UserService.Instance.myUserInfo;
const userTheme = user?.local_user_view.local_user.theme;
return userTheme ?? "browser";
}
render() {
if (this.state?.themeOverride) {
if (!this.state.graceTheme) {
return this.renderTheme(this.state.themeOverride);
}
// Render both themes to prevent rendering without theme.
return [
this.renderTheme(this.state.graceTheme ?? this.currentTheme()),
this.renderTheme(this.state.themeOverride),
];
}
return this.renderTheme(this.currentTheme());
}
renderTheme(theme: string) {
const hasTheme = theme !== "browser" && theme !== "browser-compact";
const detectedBsTheme = {};
if (this.lightQuery) {
detectedBsTheme["data-bs-theme"] = this.lightQuery.matches
? "light"
: "dark";
}
if (theme && hasTheme) {
return ( return (
<Helmet> <>
<link <Helmet htmlAttributes={{ "data-bs-theme": dataBsTheme(theme) }}>
rel="stylesheet" <link
type="text/css" rel="stylesheet"
href={`/css/themes/${user.local_user_view.local_user.theme}.css`} type="text/css"
/> href={`/css/themes/${theme}.css`}
</Helmet> />
</Helmet>
<CodeTheme theme={theme} />
</>
); );
} else if ( } else if (
this.props.defaultTheme !== "browser" && this.props.defaultTheme !== "browser" &&
this.props.defaultTheme !== "browser-compact" this.props.defaultTheme !== "browser-compact"
) { ) {
return ( return (
<Helmet> <>
<link <Helmet
rel="stylesheet" htmlAttributes={{
type="text/css" "data-bs-theme": dataBsTheme(this.props.defaultTheme),
href={`/css/themes/${this.props.defaultTheme}.css`} }}
/> >
</Helmet> <link
rel="stylesheet"
type="text/css"
href={`/css/themes/${this.props.defaultTheme}.css`}
/>
</Helmet>
<CodeTheme theme={this.props.defaultTheme} />
</>
); );
} else if (this.props.defaultTheme === "browser-compact") { } else if (
this.props.defaultTheme === "browser-compact" ||
theme === "browser-compact"
) {
return ( return (
<Helmet> <>
<link <Helmet htmlAttributes={detectedBsTheme}>
rel="stylesheet" <link
type="text/css" rel="stylesheet"
href="/css/themes/litely-compact.css" type="text/css"
id="default-light" href="/css/themes/litely-compact.css"
media="(prefers-color-scheme: light)" id="default-light"
/> media="(prefers-color-scheme: light)"
<link />
rel="stylesheet" <link
type="text/css" rel="stylesheet"
href="/css/themes/darkly-compact.css" type="text/css"
id="default-dark" href="/css/themes/darkly-compact.css"
media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)" id="default-dark"
/> media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)"
</Helmet> />
</Helmet>
<CodeTheme theme="browser-compact" />
</>
); );
} else { } else {
return ( return (
<Helmet> <>
<link <Helmet htmlAttributes={detectedBsTheme}>
rel="stylesheet" <link
type="text/css" rel="stylesheet"
href="/css/themes/litely.css" type="text/css"
id="default-light" href="/css/themes/litely.css"
media="(prefers-color-scheme: light)" id="default-light"
/> media="(prefers-color-scheme: light)"
<link />
rel="stylesheet" <link
type="text/css" rel="stylesheet"
href="/css/themes/darkly.css" type="text/css"
id="default-dark" href="/css/themes/darkly.css"
media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)" id="default-dark"
/> media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)"
</Helmet> />
</Helmet>
<CodeTheme theme="browser" />
</>
); );
} }
} }

View file

@ -102,6 +102,9 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
async componentDidMount() { async componentDidMount() {
if (!this.state.isIsomorphic) { if (!this.state.isIsomorphic) {
await this.fetchData(); await this.fetchData();
} else {
const themeList = await fetchThemeList();
this.setState({ themeList });
} }
} }

View file

@ -1,5 +1,5 @@
import { setIsoData } from "@utils/app"; import { setIsoData } from "@utils/app";
import { isBrowser, updateDataBsTheme } from "@utils/browser"; import { isBrowser, refreshTheme } from "@utils/browser";
import { getQueryParams } from "@utils/helpers"; import { getQueryParams } from "@utils/helpers";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { RouteComponentProps } from "inferno-router/dist/Route"; import { RouteComponentProps } from "inferno-router/dist/Route";
@ -47,7 +47,7 @@ async function handleLoginSuccess(i: Login, loginRes: LoginResponse) {
if (site.state === "success") { if (site.state === "success") {
UserService.Instance.myUserInfo = site.data.my_user; UserService.Instance.myUserInfo = site.data.my_user;
updateDataBsTheme(site.data); refreshTheme();
} }
const { prev } = getLoginQueryParams(); const { prev } = getLoginQueryParams();

View file

@ -7,7 +7,6 @@ import {
myAuth, myAuth,
personToChoice, personToChoice,
setIsoData, setIsoData,
setTheme,
showLocal, showLocal,
updateCommunityBlock, updateCommunityBlock,
updateInstanceBlock, updateInstanceBlock,
@ -67,7 +66,7 @@ import { PersonListing } from "./person-listing";
import { InitialFetchRequest } from "../../interfaces"; import { InitialFetchRequest } from "../../interfaces";
import TotpModal from "../common/totp-modal"; import TotpModal from "../common/totp-modal";
import { LoadingEllipses } from "../common/loading-ellipses"; import { LoadingEllipses } from "../common/loading-ellipses";
import { updateDataBsTheme } from "../../utils/browser"; import { refreshTheme, setThemeOverride } from "../../utils/browser";
import { getHttpBaseInternal } from "../../utils/env"; import { getHttpBaseInternal } from "../../utils/env";
type SettingsData = RouteDataResponse<{ type SettingsData = RouteDataResponse<{
@ -342,6 +341,7 @@ export class Settings extends Component<any, SettingsState> {
componentWillUnmount(): void { componentWillUnmount(): void {
// In case `interface_language` change wasn't saved. // In case `interface_language` change wasn't saved.
loadUserLanguage(); loadUserLanguage();
setThemeOverride(undefined);
} }
static async fetchInitialData({ static async fetchInitialData({
@ -1453,7 +1453,7 @@ export class Settings extends Component<any, SettingsState> {
handleThemeChange(i: Settings, event: any) { handleThemeChange(i: Settings, event: any) {
i.setState(s => ((s.saveUserSettingsForm.theme = event.target.value), s)); i.setState(s => ((s.saveUserSettingsForm.theme = event.target.value), s));
setTheme(event.target.value, true); setThemeOverride(event.target.value);
} }
handleInterfaceLangChange(i: Settings, event: any) { handleInterfaceLangChange(i: Settings, event: any) {
@ -1571,6 +1571,7 @@ export class Settings extends Component<any, SettingsState> {
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
setThemeOverride(undefined);
i.setState({ saveRes }); i.setState({ saveRes });
} }
@ -1665,7 +1666,7 @@ export class Settings extends Component<any, SettingsState> {
} = siteRes.data.my_user!.local_user_view; } = siteRes.data.my_user!.local_user_view;
UserService.Instance.myUserInfo = siteRes.data.my_user; UserService.Instance.myUserInfo = siteRes.data.my_user;
updateDataBsTheme(siteRes.data); refreshTheme();
i.setState(prev => ({ i.setState(prev => ({
...prev, ...prev,

View file

@ -44,7 +44,6 @@ import postToCommentSortType from "./post-to-comment-sort-type";
import searchCommentTree from "./search-comment-tree"; import searchCommentTree from "./search-comment-tree";
import selectableLanguages from "./selectable-languages"; import selectableLanguages from "./selectable-languages";
import setIsoData from "./set-iso-data"; import setIsoData from "./set-iso-data";
import setTheme from "./set-theme";
import setupDateFns from "./setup-date-fns"; import setupDateFns from "./setup-date-fns";
import showAvatars from "./show-avatars"; import showAvatars from "./show-avatars";
import showLocal from "./show-local"; import showLocal from "./show-local";
@ -103,7 +102,6 @@ export {
searchCommentTree, searchCommentTree,
selectableLanguages, selectableLanguages,
setIsoData, setIsoData,
setTheme,
setupDateFns, setupDateFns,
showAvatars, showAvatars,
showLocal, showLocal,

View file

@ -1,11 +1,9 @@
import { GetSiteResponse } from "lemmy-js-client"; import { GetSiteResponse } from "lemmy-js-client";
import { setupEmojiDataModel, setupMarkdown } from "../../markdown"; import { setupEmojiDataModel, setupMarkdown } from "../../markdown";
import { UserService } from "../../services"; import { UserService } from "../../services";
import { updateDataBsTheme } from "@utils/browser";
export default function initializeSite(site?: GetSiteResponse) { export default function initializeSite(site?: GetSiteResponse) {
UserService.Instance.myUserInfo = site?.my_user; UserService.Instance.myUserInfo = site?.my_user;
updateDataBsTheme(site);
if (site) { if (site) {
setupEmojiDataModel(site.custom_emojis ?? []); setupEmojiDataModel(site.custom_emojis ?? []);
} }

View file

@ -1,39 +0,0 @@
import { fetchThemeList } from "@utils/app";
import { dataBsTheme, isBrowser, loadCss } from "@utils/browser";
export default async function setTheme(theme: string, forceReload = false) {
if (!isBrowser()) {
return;
}
if (theme === "browser" && !forceReload) {
return;
}
// This is only run on a force reload
if (theme === "browser") {
theme = "darkly";
}
const themeList = await fetchThemeList();
// Unload all the other themes
for (var i = 0; i < themeList.length; i++) {
const styleSheet = document.getElementById(themeList[i]);
if (styleSheet) {
styleSheet.setAttribute("disabled", "disabled");
}
}
document
.getElementById("default-light")
?.setAttribute("disabled", "disabled");
document.getElementById("default-dark")?.setAttribute("disabled", "disabled");
// Load the theme dynamically
const cssLoc = `/css/themes/${theme}.css`;
loadCss(theme, cssLoc);
document
.getElementById("app")
?.setAttribute("data-bs-theme", dataBsTheme(theme));
document.getElementById(theme)?.removeAttribute("disabled");
}

View file

@ -3,13 +3,13 @@ import clearAuthCookie from "./clear-auth-cookie";
import dataBsTheme from "./data-bs-theme"; import dataBsTheme from "./data-bs-theme";
import isBrowser from "./is-browser"; import isBrowser from "./is-browser";
import isDark from "./is-dark"; import isDark from "./is-dark";
import loadCss from "./load-css";
import platform from "./platform"; import platform from "./platform";
import refreshTheme from "./refresh-theme";
import restoreScrollPosition from "./restore-scroll-position"; import restoreScrollPosition from "./restore-scroll-position";
import saveScrollPosition from "./save-scroll-position"; import saveScrollPosition from "./save-scroll-position";
import setAuthCookie from "./set-auth-cookie"; import setAuthCookie from "./set-auth-cookie";
import setThemeOverride from "./set-theme-override";
import share from "./share"; import share from "./share";
import updateDataBsTheme from "./update-data-bs-theme";
export { export {
canShare, canShare,
@ -17,11 +17,11 @@ export {
dataBsTheme, dataBsTheme,
isBrowser, isBrowser,
isDark, isDark,
loadCss,
platform, platform,
refreshTheme,
restoreScrollPosition, restoreScrollPosition,
saveScrollPosition, saveScrollPosition,
setAuthCookie, setAuthCookie,
setThemeOverride,
share, share,
updateDataBsTheme,
}; };

View file

@ -1,12 +0,0 @@
export default function loadCss(id: string, loc: string) {
if (!document.getElementById(id)) {
var head = document.getElementsByTagName("head")[0];
var link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.type = "text/css";
link.href = loc;
link.media = "all";
head.appendChild(link);
}
}

View file

@ -0,0 +1,7 @@
import { isBrowser } from ".";
export default function refreshTheme() {
if (isBrowser()) {
window.dispatchEvent(new CustomEvent("refresh-theme"));
}
}

View file

@ -0,0 +1,10 @@
import { isBrowser } from "@utils/browser";
export default async function setThemeOverride(theme?: string) {
if (!isBrowser()) {
return;
}
window.dispatchEvent(
new CustomEvent("set-theme-override", { detail: { theme } }),
);
}

View file

@ -1,11 +0,0 @@
import { GetSiteResponse } from "lemmy-js-client";
import isBrowser from "./is-browser";
import dataBsTheme from "./data-bs-theme";
export default function updateDataBsTheme(siteRes?: GetSiteResponse) {
if (isBrowser()) {
document
.getElementById("app")
?.setAttribute("data-bs-theme", dataBsTheme(siteRes));
}
}