From ecef3e961a79ac9c99bf9a419fa7573433148da0 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 23 Aug 2024 16:59:36 +0800 Subject: [PATCH] fix: Respect app light/dark mode over OS preference #228 Signed-off-by: Innei --- src/main/init.ts | 8 +- src/main/tipc/setting.ts | 12 +- src/renderer/index.html | 14 -- .../ui/code-highlighter/shiki/hooks.ts | 4 +- .../src/components/ui/media/preview-media.tsx | 6 +- .../src/components/ui/modal/stacked/modal.tsx | 9 + .../src/components/ui/segement/ctx.tsx | 10 + .../src/components/ui/segement/index.tsx | 88 ++++++++ src/renderer/src/components/ui/sonner.tsx | 4 +- src/renderer/src/hooks/common/useDark.ts | 56 +++-- .../src/modules/entry-content/header.tsx | 4 +- .../src/modules/entry-content/index.tsx | 4 +- src/renderer/src/modules/settings/control.tsx | 51 ++++- .../src/modules/settings/tabs/apperance.tsx | 206 ++++++++++-------- .../src/providers/ui-setting-sync.tsx | 4 +- 15 files changed, 322 insertions(+), 158 deletions(-) create mode 100644 src/renderer/src/components/ui/segement/ctx.tsx create mode 100644 src/renderer/src/components/ui/segement/index.tsx diff --git a/src/main/init.ts b/src/main/init.ts index 11eb5cda55..f61050ded1 100644 --- a/src/main/init.ts +++ b/src/main/init.ts @@ -2,9 +2,10 @@ import path from "node:path" import { registerIpcMain } from "@egoist/tipc/main" import { APP_PROTOCOL } from "@shared/constants" -import { app } from "electron" +import { app, nativeTheme } from "electron" import { getIconPath } from "./helper" +import { store } from "./lib/store" import { registerAppMenu } from "./menu" import { initializeSentry } from "./sentry" import { router } from "./tipc" @@ -39,6 +40,11 @@ export const initializeApp = () => { app.dock.setIcon(getIconPath()) } + // store.set("appearance", input); + const appearance = store.get("appearance") + if (appearance && ["light", "dark", "system"].includes(appearance)) { + nativeTheme.themeSource = appearance + } // In this file you can include the rest of your app"s specific main process // code. You can also put them in separate files and require them here. registerAppMenu() diff --git a/src/main/tipc/setting.ts b/src/main/tipc/setting.ts index 7b312881da..f314ccda51 100644 --- a/src/main/tipc/setting.ts +++ b/src/main/tipc/setting.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module" import { app, nativeTheme } from "electron" import { setDockCount } from "../lib/dock" +import { store } from "../lib/store" import { createSettingWindow } from "../window" import { t } from "./_instance" @@ -31,20 +32,15 @@ export const settingRoute = { }) }), ), + getAppearance: t.procedure.action(async () => nativeTheme.themeSource), setAppearance: t.procedure .input<"light" | "dark" | "system">() .action(async ({ input }) => { - // NOTE: Temporarily changing to system to get the color mode that system is in at the moment may cause a bit of a problem. - // On macos, there is a bug, traffic lights flicker - nativeTheme.themeSource = "system" - const systemColorMode = nativeTheme.shouldUseDarkColors ? - "dark" : - "light" + nativeTheme.themeSource = input - nativeTheme.themeSource = systemColorMode === input ? "system" : input + store.set("appearance", input) }), setDockBadge: t.procedure.input().action(async ({ input }) => { setDockCount(input) }), - } diff --git a/src/renderer/index.html b/src/renderer/index.html index 02e7db929d..7c481f7eef 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -12,20 +12,6 @@ Follow - -
diff --git a/src/renderer/src/components/ui/code-highlighter/shiki/hooks.ts b/src/renderer/src/components/ui/code-highlighter/shiki/hooks.ts index ea6adca0e2..06db2154c4 100644 --- a/src/renderer/src/components/ui/code-highlighter/shiki/hooks.ts +++ b/src/renderer/src/components/ui/code-highlighter/shiki/hooks.ts @@ -1,7 +1,7 @@ -import { useDark } from "@renderer/hooks/common" +import { useIsDark } from "@renderer/hooks/common" export const useShikiDefaultTheme = () => { - const isDark = useDark() + const isDark = useIsDark() return isDark ? "github-dark" : "github-light" } diff --git a/src/renderer/src/components/ui/media/preview-media.tsx b/src/renderer/src/components/ui/media/preview-media.tsx index 1ced2876ea..22233fc6ef 100644 --- a/src/renderer/src/components/ui/media/preview-media.tsx +++ b/src/renderer/src/components/ui/media/preview-media.tsx @@ -1,5 +1,5 @@ import { m } from "@renderer/components/common/Motion" -import { COPY_MAP, isElectronBuild } from "@renderer/constants" +import { COPY_MAP } from "@renderer/constants" import { tipcClient } from "@renderer/lib/client" import { stopPropagation } from "@renderer/lib/dom" import { replaceImgUrlIfNeed } from "@renderer/lib/img-proxy" @@ -23,9 +23,7 @@ const Wrapper: Component<{ return (
- {isElectronBuild && ( -
- )} + +
+ {isElectronBuild && ( +
+ )}
+ {isElectronBuild && ( +
+ )} + void + componentId: string +} +export const SegmentGroupContext = createContext( + null!, +) diff --git a/src/renderer/src/components/ui/segement/index.tsx b/src/renderer/src/components/ui/segement/index.tsx new file mode 100644 index 0000000000..4d0943d2a7 --- /dev/null +++ b/src/renderer/src/components/ui/segement/index.tsx @@ -0,0 +1,88 @@ +import { cn } from "@renderer/lib/utils" +import { m } from "framer-motion" +import type { ReactNode } from "react" +import { useId, useMemo, useState } from "react" +import { useContextSelector } from "use-context-selector" + +import { SegmentGroupContext } from "./ctx" + +interface SegmentGroupProps { + value?: string + onValueChanged?: (value: string) => void +} +export const SegmentGroup = (props: ComponentType) => { + const { onValueChanged, value, className } = props + + const [currentValue, setCurrentValue] = useState(value || "") + const componentId = useId() + + return ( + ({ + value: currentValue, + setValue: (value) => { + setCurrentValue(value) + onValueChanged?.(value) + }, + componentId, + }), + [componentId, currentValue, onValueChanged], + )} + > +
+ {props.children} +
+
+ ) +} + +export const SegmentItem: Component<{ + value: string + label: ReactNode +}> = ({ label, value, className }) => { + const isActive = useContextSelector( + SegmentGroupContext, + (v) => v.value === value, + ) + const setValue = useContextSelector(SegmentGroupContext, (v) => v.setValue) + const layoutId = useContextSelector( + SegmentGroupContext, + (v) => v.componentId, + ) + return ( + + ) +} diff --git a/src/renderer/src/components/ui/sonner.tsx b/src/renderer/src/components/ui/sonner.tsx index 848ed31651..458220e21a 100644 --- a/src/renderer/src/components/ui/sonner.tsx +++ b/src/renderer/src/components/ui/sonner.tsx @@ -1,11 +1,11 @@ -import { useDark } from "@renderer/hooks/common" +import { useIsDark } from "@renderer/hooks/common" import { Toaster as Sonner } from "sonner" type ToasterProps = React.ComponentProps const Toaster = ({ ...props }: ToasterProps) => ( useMediaQuery("(prefers-color-scheme: dark)") type ColorMode = "light" | "dark" | "system" -const darkAtom = !window.electron ? +const themeAtom = !window.electron ? atomWithStorage( getStorageNS("color-mode"), "system" as ColorMode, @@ -19,28 +20,38 @@ const darkAtom = !window.electron ? ) : atom("system" as ColorMode) function useDarkElectron() { - return useAtomValue(darkAtom) === "dark" + return useAtomValue(themeAtom) === "dark" } function useDarkWebApp() { const systemIsDark = useDarkQuery() - const mode = useAtomValue(darkAtom) + const mode = useAtomValue(themeAtom) return mode === "dark" || (mode === "system" && systemIsDark) } -export const useDark = window.electron ? useDarkElectron : useDarkWebApp +export const useIsDark = window.electron ? useDarkElectron : useDarkWebApp -const useSyncDarkElectron = () => { +export const useThemeAtomValue = () => useAtomValue(themeAtom) + +const useSyncThemeElectron = () => { const appIsDark = useDarkQuery() useLayoutEffect(() => { - document.documentElement.dataset.theme = appIsDark ? "dark" : "light" - disableTransition(["[role=switch]>*"])() + let isMounted = true + tipcClient?.getAppearance().then((appearance) => { + if (!isMounted) return + jotaiStore.set(themeAtom, appearance) + disableTransition(["[role=switch]>*"])() - jotaiStore.set(darkAtom, appIsDark ? "dark" : "light") + document.documentElement.dataset.theme = + appearance === "system" ? (appIsDark ? "dark" : "light") : appearance + }) + return () => { + isMounted = false + } }, [appIsDark]) } -const useSyncDarkWebApp = () => { - const colorMode = useAtomValue(darkAtom) +const useSyncThemeWebApp = () => { + const colorMode = useAtomValue(themeAtom) const systemIsDark = useDarkQuery() useLayoutEffect(() => { const realColorMode: Exclude = @@ -50,21 +61,18 @@ const useSyncDarkWebApp = () => { }, [colorMode, systemIsDark]) } -export const useSyncDark = window.electron ? - useSyncDarkElectron : - useSyncDarkWebApp +export const useSyncThemeark = window.electron ? + useSyncThemeElectron : + useSyncThemeWebApp -export const useSetDarkInWebApp = () => { - const systemColorMode = useDarkQuery() ? "dark" : "light" - return useCallback( - (colorMode: Exclude) => - jotaiStore.set( - darkAtom, - colorMode === systemColorMode ? "system" : colorMode, - ), - [systemColorMode], - ) -} +export const useSetTheme = () => + useCallback((colorMode: ColorMode) => { + jotaiStore.set(themeAtom, colorMode) + + if (window.electron) { + tipcClient?.setAppearance(colorMode) + } + }, []) function disableTransition(disableTransitionExclude: string[] = []) { const css = document.createElement("style") diff --git a/src/renderer/src/modules/entry-content/header.tsx b/src/renderer/src/modules/entry-content/header.tsx index 6dc6c05087..f87e6b5311 100644 --- a/src/renderer/src/modules/entry-content/header.tsx +++ b/src/renderer/src/modules/entry-content/header.tsx @@ -28,7 +28,9 @@ export function EntryHeader({ const entryTitleMeta = useEntryTitleMeta() const isAtTop = useEntryContentScrollToTop() - const shouldShowMeta = !isAtTop && entryTitleMeta + + const shouldShowMeta = !isAtTop && !!entryTitleMeta?.title + if (!entry?.entries) return null return ( diff --git a/src/renderer/src/modules/entry-content/index.tsx b/src/renderer/src/modules/entry-content/index.tsx index 96fbf2f093..049aa311a0 100644 --- a/src/renderer/src/modules/entry-content/index.tsx +++ b/src/renderer/src/modules/entry-content/index.tsx @@ -147,7 +147,7 @@ export const EntryContentRender: Component<{ entryId: string }> = ({ "h-0 min-w-0 grow overflow-y-auto @container", className, )} - scrollbarClassName="mr-1" + scrollbarClassName="mr-[1.5px]" viewportClassName="p-5" ref={scrollerRef} > @@ -278,7 +278,7 @@ const TitleMetaHandler: Component<{ const atTop = useIsSoFWrappedElement() useEffect(() => { - setEntryContentScrollToTop(false) + setEntryContentScrollToTop(true) }, [entryId]) useLayoutEffect(() => { setEntryContentScrollToTop(atTop) diff --git a/src/renderer/src/modules/settings/control.tsx b/src/renderer/src/modules/settings/control.tsx index 905adeee28..510dd17e65 100644 --- a/src/renderer/src/modules/settings/control.tsx +++ b/src/renderer/src/modules/settings/control.tsx @@ -1,9 +1,11 @@ import { Button } from "@renderer/components/ui/button" import { Checkbox } from "@renderer/components/ui/checkbox" import { Label } from "@renderer/components/ui/label" +import { SegmentGroup, SegmentItem } from "@renderer/components/ui/segement" import { Switch } from "@renderer/components/ui/switch" import { cn } from "@renderer/lib/utils" -import { useId } from "react" +import type { ReactNode } from "react" +import { useId, useState } from "react" export const SettingCheckbox: Component<{ label: string @@ -45,6 +47,45 @@ export const SettingSwitch: Component<{ ) } +export const SettingTabbedSegment: Component<{ + label: string + value: string + onValueChanged?: (value: string) => void + values: { value: string, label: string, icon?: ReactNode }[] +}> = ({ label, className, value, values, onValueChanged }) => { + const [currentValue, setCurrentValue] = useState(value) + + return ( +
+ + + { + setCurrentValue(v) + onValueChanged?.(v) + }} + > + {values.map((v) => ( + + {v.icon} + {v.label} +
+ )} + /> + ))} + +
+ ) +} + export const SettingDescription: Component = ({ children, className }) => ( void buttonText: string }) => ( -
+
{label}
- +
) diff --git a/src/renderer/src/modules/settings/tabs/apperance.tsx b/src/renderer/src/modules/settings/tabs/apperance.tsx index 0c6799729e..37002f3d12 100644 --- a/src/renderer/src/modules/settings/tabs/apperance.tsx +++ b/src/renderer/src/modules/settings/tabs/apperance.tsx @@ -13,12 +13,11 @@ import { SelectValue, } from "@renderer/components/ui/select" import { isElectronBuild } from "@renderer/constants" -import { useDark, useSetDarkInWebApp } from "@renderer/hooks/common" -import { tipcClient } from "@renderer/lib/client" +import { useSetTheme, useThemeAtomValue } from "@renderer/hooks/common" import { getOS } from "@renderer/lib/utils" import { bundledThemes } from "shiki/themes" -import { SettingSwitch } from "../control" +import { SettingTabbedSegment } from "../control" import { ContentFontSelector, UIFontSelector } from "../modules/fonts" import { createSettingBuilder } from "../setting-builder" import { SettingsTitle } from "../title" @@ -26,98 +25,82 @@ import { SettingsTitle } from "../title" const SettingBuilder = createSettingBuilder(useUISettingValue) const defineItem = createDefineSettingItem(useUISettingValue, setUISetting) -export const SettingAppearance = () => { - const isDark = useDark() - - const setDarkInWebApp = useSetDarkInWebApp() - - return ( - <> - -
- { - if (window.electron) { - tipcClient?.setAppearance(e ? "dark" : "light") - } else { - setDarkInWebApp(e ? "dark" : "light") - } - }} - />, - - defineItem("opaqueSidebar", { - label: "Opaque sidebars", - hide: !window.electron || !["macOS", "Linux"].includes(getOS()), - }), - - { - type: "title", - value: "Unread count", - }, - - defineItem("showDockBadge", { - label: "Show as Dock badge", - hide: !window.electron || !["macOS", "Linux"].includes(getOS()), - }), - - defineItem("sidebarShowUnreadCount", { - label: "Show in sidebar", - }), - - { - type: "title", - value: "Fonts", - }, - TextSize, - UIFontSelector, - ContentFontSelector, - { - type: "title", - value: "Content", - }, - ShikiTheme, - - defineItem("guessCodeLanguage", { - label: "Guess code language", - hide: !isElectronBuild, - description: - "Major programming languages that use models to infer unlabeled code blocks", - }), - - defineItem("readerRenderInlineStyle", { - label: "Render inline style", - description: - "Allows rendering of the inline style of the original HTML.", - }), - { - type: "title", - value: "Misc", - }, - - defineItem("modalOverlay", { - label: "Show modal overlay", - description: "Show modal overlay", - }), - defineItem("reduceMotion", { - label: "Reduce motion", - description: - "Reducing the motion of elements to improve performance and reduce energy consumption", - }), - ]} - /> -
- - ) -} +export const SettingAppearance = () => ( + <> + +
+ +
+ +) const ShikiTheme = () => { const codeHighlightTheme = useUISettingKey("codeHighlightTheme") return ( @@ -186,3 +169,36 @@ const TextSize = () => {
) } + +const AppThemeSegment = () => { + const theme = useThemeAtomValue() + const setTheme = useSetTheme() + + return ( + , + }, + { + value: "light", + label: "Light", + icon: , + }, + { + value: "dark", + label: "Dark", + icon: , + }, + ]} + onValueChanged={(value) => { + setTheme(value as "light" | "dark" | "system") + }} + /> + ) +} diff --git a/src/renderer/src/providers/ui-setting-sync.tsx b/src/renderer/src/providers/ui-setting-sync.tsx index 3190bd86c7..c92043f8df 100644 --- a/src/renderer/src/providers/ui-setting-sync.tsx +++ b/src/renderer/src/providers/ui-setting-sync.tsx @@ -1,12 +1,12 @@ import { useUISettingValue } from "@renderer/atoms/settings/ui" -import { useSyncDark } from "@renderer/hooks/common" +import { useSyncThemeark } from "@renderer/hooks/common" import { tipcClient } from "@renderer/lib/client" import { feedUnreadActions } from "@renderer/store/unread" import { useEffect, useInsertionEffect } from "react" const useUISettingSync = () => { const setting = useUISettingValue() - useSyncDark() + useSyncThemeark() useInsertionEffect(() => { const root = document.documentElement root.style.fontSize = `${setting.uiTextSize}px`