Options for selecting dark theme, fix some white flashes when in dark mode (#2722)

* add dark theme selection to settings/schema

* use `useThemePrefs` where needed

* adjust theme providers to support various themes

* update storybook

* handle web themes

* better themeing for web

* dont show dark theme prefs when color mode is light

* drop the inverted text change on oled theme

* get the color mode inside of `useColorModeTheme`

* use `ThemeName` type everywhere

* typo

* use dim/dark instead of dark/oled

* prevent any fickers on web

* fix styles

* use `dim` for dark default

* more cleanup

* 🤔

* set system background color

* ts
zio/stable
Hailey 2024-02-06 11:43:51 -08:00 committed by GitHub
parent 856f80fc6d
commit ec86282403
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 251 additions and 172 deletions

View File

@ -1,13 +1,13 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
<meta name="referrer" content="origin-when-cross-origin">
<title>{%- block head_title -%}Bluesky{%- endblock -%}</title>
<!-- Hello Humans! API docs at https://atproto.com -->
<style>
/**
* Extend the react-native-web reset:
@ -40,31 +40,39 @@
}
/* Color theming */
/* Default will always be white */
:root {
--text: black;
--background: white;
--backgroundLight: hsl(211, 20%, 95%);
}
html.colorMode--dark {
--text: white;
--background: hsl(211, 20%, 4%);
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
html.colorMode--system {
--text: black;
--background: white;
--backgroundLight: hsl(211, 20%, 95%);
}
}
/* This gives us a black background when system is dark and we have not loaded the theme/color scheme values in JS */
@media (prefers-color-scheme: dark) {
html.colorMode--system {
:root {
--text: white;
--background: black;
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
}
/* Overwrite those preferences with the selected theme */
html.theme--light {
--text: black;
--background: white;
--backgroundLight: hsl(211, 20%, 95%);
}
html.theme--dark {
--text: white;
--background: hsl(211, 20%, 4%);
--background: black;
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
}
html.theme--dim {
--text: white;
--background: hsl(211, 20%, 4%);
--backgroundLight: hsl(211, 20%, 10%);
color-scheme: dark;
}
/* Remove autofill styles on Webkit */

View File

@ -17,7 +17,6 @@ import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {init as initPersistedState} from '#/state/persisted'
import {listenSessionDropped} from './state/events'
import {useColorMode} from 'state/shell'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import {Shell} from 'view/shell'
@ -49,10 +48,9 @@ import {useLingui} from '@lingui/react'
SplashScreen.preventAutoHideAsync()
function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme(colorMode)
const theme = useColorModeTheme()
const {_} = useLingui()
// init
@ -75,7 +73,7 @@ function InnerApp() {
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>

View File

@ -10,7 +10,6 @@ import 'view/icons'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {Shell} from 'view/shell/index'
import {ToastContainer} from 'view/com/util/Toast.web'
import {ThemeProvider} from 'lib/ThemeContext'
@ -36,8 +35,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
const theme = useColorModeTheme(colorMode)
const theme = useColorModeTheme()
// init
useEffect(() => {
@ -55,7 +53,7 @@ function InnerApp() {
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>

View File

@ -21,7 +21,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Svg, {Path, SvgProps} from 'react-native-svg'
import {isAndroid} from '#/platform/detection'
import {useColorMode} from '#/state/shell'
import {useThemePrefs} from 'state/shell'
import {Logotype} from '#/view/icons/Logotype'
// @ts-ignore
@ -75,7 +75,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
isLayoutReady &&
reduceMotion !== undefined
const colorMode = useColorMode()
const {colorMode} = useThemePrefs()
const colorScheme = useColorScheme()
const themeName = colorMode === 'system' ? colorScheme : colorMode
const isDarkMode = themeName === 'dark'

View File

@ -71,7 +71,7 @@ export const lightPalette = {
export const darkPalette: Palette = {
white: tokens.color.gray_0,
black: tokens.color.gray_1000,
black: tokens.color.trueBlack,
contrast_25: tokens.color.gray_975,
contrast_50: tokens.color.gray_950,
@ -130,6 +130,11 @@ export const darkPalette: Palette = {
negative_975: tokens.color.red_975,
} as const
export const dimPalette: Palette = {
...darkPalette,
black: tokens.color.gray_1000,
} as const
export const light = {
name: 'light',
palette: lightPalette,
@ -191,70 +196,6 @@ export const light = {
},
}
export const dim: Theme = {
name: 'dim',
palette: darkPalette,
atoms: {
text: {
color: darkPalette.white,
},
text_contrast_700: {
color: darkPalette.contrast_800,
},
text_contrast_600: {
color: darkPalette.contrast_700,
},
text_contrast_500: {
color: darkPalette.contrast_600,
},
text_contrast_400: {
color: darkPalette.contrast_500,
},
text_inverted: {
color: darkPalette.black,
},
bg: {
backgroundColor: darkPalette.contrast_50,
},
bg_contrast_25: {
backgroundColor: darkPalette.contrast_100,
},
bg_contrast_50: {
backgroundColor: darkPalette.contrast_200,
},
bg_contrast_100: {
backgroundColor: darkPalette.contrast_300,
},
bg_contrast_200: {
backgroundColor: darkPalette.contrast_400,
},
bg_contrast_300: {
backgroundColor: darkPalette.contrast_500,
},
border: {
borderColor: darkPalette.contrast_200,
},
border_contrast: {
borderColor: darkPalette.contrast_400,
},
shadow_sm: {
...atoms.shadow_sm,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_md: {
...atoms.shadow_md,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_lg: {
...atoms.shadow_lg,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
},
}
export const dark: Theme = {
name: 'dark',
palette: darkPalette,
@ -318,3 +259,17 @@ export const dark: Theme = {
},
},
}
export const dim: Theme = {
...dark,
name: 'dim',
atoms: {
...dark.atoms,
text_inverted: {
color: dimPalette.black,
},
bg: {
backgroundColor: dimPalette.black,
},
},
}

View File

@ -1,10 +1,54 @@
import React from 'react'
import {useColorScheme} from 'react-native'
import * as persisted from '#/state/persisted'
import {useThemePrefs} from 'state/shell'
import {isWeb} from 'platform/detection'
import {ThemeName, light, dark, dim} from '#/alf/themes'
import * as SystemUI from 'expo-system-ui'
export function useColorModeTheme(
theme: persisted.Schema['colorMode'],
): 'light' | 'dark' {
export function useColorModeTheme(): ThemeName {
const colorScheme = useColorScheme()
return (theme === 'system' ? colorScheme : theme) || 'light'
const {colorMode, darkTheme} = useThemePrefs()
return React.useMemo(() => {
if (
(colorMode === 'system' && colorScheme === 'light') ||
colorMode === 'light'
) {
updateDocument('light')
updateSystemBackground('light')
return 'light'
} else {
const themeName = darkTheme ?? 'dim'
updateDocument(themeName)
updateSystemBackground(themeName)
return themeName
}
}, [colorMode, darkTheme, colorScheme])
}
function updateDocument(theme: ThemeName) {
// @ts-ignore web only
if (isWeb && typeof window !== 'undefined') {
// @ts-ignore web only
const html = window.document.documentElement
// remove any other color mode classes
html.className = html.className.replace(/(theme)--\w+/g, '')
html.classList.add(`theme--${theme}`)
}
}
function updateSystemBackground(theme: ThemeName) {
switch (theme) {
case 'light':
SystemUI.setBackgroundColorAsync(light.atoms.bg.backgroundColor)
break
case 'dark':
SystemUI.setBackgroundColorAsync(dark.atoms.bg.backgroundColor)
break
case 'dim':
SystemUI.setBackgroundColorAsync(dim.atoms.bg.backgroundColor)
break
}
}

View File

@ -1,11 +1,7 @@
import React, {ReactNode, createContext, useContext} from 'react'
import {
TextStyle,
useColorScheme,
ViewStyle,
ColorSchemeName,
} from 'react-native'
import {darkTheme, defaultTheme} from './themes'
import {TextStyle, ViewStyle} from 'react-native'
import {darkTheme, defaultTheme, dimTheme} from './themes'
import {ThemeName} from '#/alf/themes'
export type ColorScheme = 'light' | 'dark'
@ -84,23 +80,31 @@ export interface Theme {
export interface ThemeProviderProps {
children?: ReactNode
theme?: 'light' | 'dark' | 'system'
theme: ThemeName
}
export const ThemeContext = createContext<Theme>(defaultTheme)
export const useTheme = () => useContext(ThemeContext)
function getTheme(theme: ColorSchemeName) {
return theme === 'dark' ? darkTheme : defaultTheme
function getTheme(theme: ThemeName) {
switch (theme) {
case 'light':
return defaultTheme
case 'dim':
return dimTheme
case 'dark':
return darkTheme
default:
return defaultTheme
}
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
theme,
children,
}) => {
const colorScheme = useColorScheme()
const themeValue = getTheme(theme === 'system' ? colorScheme : theme)
const themeValue = getTheme(theme)
return (
<ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>

View File

@ -2,7 +2,7 @@ import {Platform} from 'react-native'
import type {Theme} from './ThemeContext'
import {colors} from './styles'
import {darkPalette, lightPalette} from '#/alf/themes'
import {darkPalette, lightPalette, dimPalette} from '#/alf/themes'
export const defaultTheme: Theme = {
colorScheme: 'light',
@ -336,3 +336,14 @@ export const darkTheme: Theme = {
},
},
}
export const dimTheme: Theme = {
...darkTheme,
palette: {
...darkTheme.palette,
default: {
...darkTheme.palette.default,
background: dimPalette.black,
},
},
}

View File

@ -69,6 +69,7 @@ const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
export function transform(legacy: Partial<LegacySchema>): Schema {
return {
colorMode: legacy.shell?.colorMode || defaults.colorMode,
darkTheme: defaults.darkTheme,
session: {
accounts: legacy.session?.accounts || defaults.session.accounts,
currentAccount:

View File

@ -18,6 +18,7 @@ export type PersistedAccount = z.infer<typeof accountSchema>
export const schema = z.object({
colorMode: z.enum(['system', 'light', 'dark']),
darkTheme: z.enum(['dim', 'dark']).optional(),
session: z.object({
accounts: z.array(accountSchema),
currentAccount: accountSchema.optional(),
@ -60,6 +61,7 @@ export type Schema = z.infer<typeof schema>
export const defaults: Schema = {
colorMode: 'system',
darkTheme: 'dim',
session: {
accounts: [],
currentAccount: undefined,

View File

@ -1,57 +1,65 @@
import React from 'react'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['colorMode']
type SetContext = (v: persisted.Schema['colorMode']) => void
type StateContext = {
colorMode: persisted.Schema['colorMode']
darkTheme: persisted.Schema['darkTheme']
}
type SetContext = {
setColorMode: (v: persisted.Schema['colorMode']) => void
setDarkTheme: (v: persisted.Schema['darkTheme']) => void
}
const stateContext = React.createContext<StateContext>('system')
const setContext = React.createContext<SetContext>(
(_: persisted.Schema['colorMode']) => {},
)
const stateContext = React.createContext<StateContext>({
colorMode: 'system',
darkTheme: 'dark',
})
const setContext = React.createContext<SetContext>({} as SetContext)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('colorMode'))
const [colorMode, setColorMode] = React.useState(persisted.get('colorMode'))
const [darkTheme, setDarkTheme] = React.useState(persisted.get('darkTheme'))
const setStateWrapped = React.useCallback(
(colorMode: persisted.Schema['colorMode']) => {
setState(colorMode)
persisted.write('colorMode', colorMode)
updateDocument(colorMode)
const setColorModeWrapped = React.useCallback(
(_colorMode: persisted.Schema['colorMode']) => {
setColorMode(_colorMode)
persisted.write('colorMode', _colorMode)
},
[setState],
[setColorMode],
)
const setDarkThemeWrapped = React.useCallback(
(_darkTheme: persisted.Schema['darkTheme']) => {
setDarkTheme(_darkTheme)
persisted.write('darkTheme', _darkTheme)
},
[setDarkTheme],
)
React.useEffect(() => {
updateDocument(persisted.get('colorMode')) // set on load
return persisted.onUpdate(() => {
setState(persisted.get('colorMode'))
updateDocument(persisted.get('colorMode'))
setColorModeWrapped(persisted.get('colorMode'))
setDarkThemeWrapped(persisted.get('darkTheme'))
})
}, [setState])
}, [setColorModeWrapped, setDarkThemeWrapped])
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
<stateContext.Provider value={{colorMode, darkTheme}}>
<setContext.Provider
value={{
setDarkTheme: setDarkThemeWrapped,
setColorMode: setColorModeWrapped,
}}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export function useColorMode() {
export function useThemePrefs() {
return React.useContext(stateContext)
}
export function useSetColorMode() {
export function useSetThemePrefs() {
return React.useContext(setContext)
}
function updateDocument(colorMode: string) {
if (isWeb && typeof window !== 'undefined') {
const html = window.document.documentElement
// remove any other color mode classes
html.className = html.className.replace(/colorMode--\w+/g, '')
html.classList.add(`colorMode--${colorMode}`)
}
}

View File

@ -14,7 +14,7 @@ export {
useSetDrawerSwipeDisabled,
} from './drawer-swipe-disabled'
export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode'
export {useColorMode, useSetColorMode} from './color-mode'
export {useThemePrefs, useSetThemePrefs} from './color-mode'
export {useOnboardingState, useOnboardingDispatch} from './onboarding'
export {useComposerState, useComposerControls} from './composer'
export {useTickEveryMinute} from './tick-every-minute'

View File

@ -40,8 +40,8 @@ import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useModalControls} from '#/state/modals'
import {
useSetMinimalShellMode,
useColorMode,
useSetColorMode,
useThemePrefs,
useSetThemePrefs,
useOnboardingDispatch,
} from '#/state/shell'
import {
@ -144,8 +144,8 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export function SettingsScreen({}: Props) {
const queryClient = useQueryClient()
const colorMode = useColorMode()
const setColorMode = useSetColorMode()
const {colorMode, darkTheme} = useThemePrefs()
const {setColorMode, setDarkTheme} = useSetThemePrefs()
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
@ -483,8 +483,36 @@ export function SettingsScreen({}: Props) {
/>
</View>
</View>
<View style={styles.spacer20} />
{colorMode !== 'light' && (
<>
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Dark Theme</Trans>
</Text>
<View>
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
<SelectableBtn
selected={!darkTheme || darkTheme === 'dim'}
label={_(msg`Dim`)}
left
onSelect={() => setDarkTheme('dim')}
accessibilityHint={_(msg`Set dark theme to the dim theme`)}
/>
<SelectableBtn
selected={darkTheme === 'dark'}
label={_(msg`Dark`)}
right
onSelect={() => setDarkTheme('dark')}
accessibilityHint={_(msg`Set dark theme to the dark theme`)}
/>
</View>
</View>
<View style={styles.spacer20} />
</>
)}
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Basics</Trans>
</Text>

View File

@ -3,7 +3,7 @@ import {View} from 'react-native'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {atoms as a, useTheme, ThemeProvider} from '#/alf'
import {useSetColorMode} from '#/state/shell'
import {useSetThemePrefs} from '#/state/shell'
import {Button} from '#/components/Button'
import {Theming} from './Theming'
@ -19,7 +19,7 @@ import {Icons} from './Icons'
export function Storybook() {
const t = useTheme()
const setColorMode = useSetColorMode()
const {setColorMode, setDarkTheme} = useSetThemePrefs()
return (
<ScrollView>
@ -38,7 +38,7 @@ export function Storybook() {
variant="solid"
color="secondary"
size="small"
label='Set theme to "system"'
label='Set theme to "light"'
onPress={() => setColorMode('light')}>
Light
</Button>
@ -46,8 +46,22 @@ export function Storybook() {
variant="solid"
color="secondary"
size="small"
label='Set theme to "system"'
onPress={() => setColorMode('dark')}>
label='Set theme to "dim"'
onPress={() => {
setColorMode('dark')
setDarkTheme('dim')
}}>
Dim
</Button>
<Button
variant="solid"
color="secondary"
size="small"
label='Set theme to "dark"'
onPress={() => {
setColorMode('dark')
setDarkTheme('dark')
}}>
Dark
</Button>
</View>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<!--
<!--
This viewport works for phones with notches.
It's optimized for gestures by disabling global zoom.
-->
@ -44,31 +44,39 @@
}
/* Color theming */
/* Default will always be white */
:root {
--text: black;
--background: white;
--backgroundLight: hsl(211, 20%, 95%);
}
html.colorMode--dark {
--text: white;
--background: hsl(211, 20%, 4%);
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
html.colorMode--system {
--text: black;
--background: white;
--backgroundLight: hsl(211, 20%, 95%);
}
}
/* This gives us a black background when system is dark and we have not loaded the theme/color scheme values in JS */
@media (prefers-color-scheme: dark) {
html.colorMode--system {
:root {
--text: white;
--background: black;
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
}
/* Overwrite those preferences with the selected theme */
html.theme--light {
--text: black;
--background: white;
--backgroundLight: hsl(211, 20%, 95%);
}
html.theme--dark {
--text: white;
--background: hsl(211, 20%, 4%);
--background: black;
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
}
html.theme--dim {
--text: white;
--background: hsl(211, 20%, 4%);
--backgroundLight: hsl(211, 20%, 10%);
color-scheme: dark;
}
/* Remove autofill styles on Webkit */
@ -142,7 +150,7 @@
.ProseMirror .mention {
color: #0085ff;
}
.ProseMirror a,
.ProseMirror a,
.ProseMirror .autolink {
color: #0085ff;
}
@ -200,7 +208,7 @@
</head>
<body>
<!--
<!--
A generic no script element with a reload button and a message.
Feel free to customize this however you'd like.
-->