| ... | ... | @@ -1,11 +1,17 @@ |
| 1 | import type { DesktopBridge } from "@t3tools/contracts"; |
| 2 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 3 | import * as Schema from "effect/Schema"; |
| 1 | 4 | import { useCallback, useEffect, useSyncExternalStore } from "react"; |
| 2 | 5 | |
| 3 | | type Theme = "light" | "dark" | "system"; |
| 6 | const ThemePreference = Schema.Literals(["light", "dark", "system"]); |
| 7 | type Theme = typeof ThemePreference.Type; |
| 4 | 8 | type ThemeSnapshot = { |
| 5 | 9 | theme: Theme; |
| 6 | 10 | systemDark: boolean; |
| 7 | 11 | }; |
| 8 | 12 | |
| 13 | type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">; |
| 14 | |
| 9 | 15 | const STORAGE_KEY = "t3code:theme"; |
| 10 | 16 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 11 | 17 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| ... | ... | @@ -15,19 +21,46 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| 15 | 21 | const THEME_COLOR_META_NAME = "theme-color"; |
| 16 | 22 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; |
| 17 | 23 | |
| 24 | export class ThemeStorageError extends Schema.TaggedErrorClass<ThemeStorageError>()( |
| 25 | "ThemeStorageError", |
| 26 | { |
| 27 | operation: Schema.Literals(["read", "write"]), |
| 28 | storageKey: Schema.String, |
| 29 | theme: Schema.optional(ThemePreference), |
| 30 | cause: Schema.Defect(), |
| 31 | }, |
| 32 | ) { |
| 33 | override get message(): string { |
| 34 | return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | export const isThemeStorageError = Schema.is(ThemeStorageError); |
| 39 | |
| 40 | export class DesktopThemeSyncError extends Schema.TaggedErrorClass<DesktopThemeSyncError>()( |
| 41 | "DesktopThemeSyncError", |
| 42 | { |
| 43 | theme: ThemePreference, |
| 44 | cause: Schema.Defect(), |
| 45 | }, |
| 46 | ) { |
| 47 | override get message(): string { |
| 48 | return `Failed to sync the ${this.theme} theme to the desktop shell.`; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); |
| 53 | |
| 18 | 54 | let listeners: Array<() => void> = []; |
| 19 | 55 | let lastSnapshot: ThemeSnapshot | null = null; |
| 20 | 56 | let lastDesktopTheme: Theme | null = null; |
| 21 | 57 | let lastAppliedTheme: ThemeSnapshot | null = null; |
| 58 | let themeStorageReadFailure: ThemeStorageError | null = null; |
| 22 | 59 | |
| 23 | 60 | function emitChange() { |
| 24 | 61 | for (const listener of listeners) listener(); |
| 25 | 62 | } |
| 26 | 63 | |
| 27 | | function hasThemeStorage() { |
| 28 | | return typeof window !== "undefined" && typeof localStorage !== "undefined"; |
| 29 | | } |
| 30 | | |
| 31 | 64 | function getSystemDark() { |
| 32 | 65 | return ( |
| 33 | 66 | typeof window !== "undefined" && |
| ... | ... | @@ -36,13 +69,61 @@ function getSystemDark() { |
| 36 | 69 | ); |
| 37 | 70 | } |
| 38 | 71 | |
| 39 | | function getStored(): Theme { |
| 40 | | if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; |
| 41 | | const raw = localStorage.getItem(STORAGE_KEY); |
| 72 | export function readThemePreference(): Theme { |
| 73 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; |
| 74 | let raw: string | null; |
| 75 | try { |
| 76 | raw = window.localStorage.getItem(STORAGE_KEY); |
| 77 | } catch (cause) { |
| 78 | throw new ThemeStorageError({ |
| 79 | operation: "read", |
| 80 | storageKey: STORAGE_KEY, |
| 81 | cause, |
| 82 | }); |
| 83 | } |
| 42 | 84 | if (raw === "light" || raw === "dark" || raw === "system") return raw; |
| 43 | 85 | return DEFAULT_THEME_SNAPSHOT.theme; |
| 44 | 86 | } |
| 45 | 87 | |
| 88 | export function writeThemePreference(theme: Theme): void { |
| 89 | if (typeof window === "undefined") return; |
| 90 | try { |
| 91 | window.localStorage.setItem(STORAGE_KEY, theme); |
| 92 | themeStorageReadFailure = null; |
| 93 | } catch (cause) { |
| 94 | throw new ThemeStorageError({ |
| 95 | operation: "write", |
| 96 | storageKey: STORAGE_KEY, |
| 97 | theme, |
| 98 | cause, |
| 99 | }); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | function getStored(): Theme { |
| 104 | if (themeStorageReadFailure !== null) { |
| 105 | return DEFAULT_THEME_SNAPSHOT.theme; |
| 106 | } |
| 107 | try { |
| 108 | return readThemePreference(); |
| 109 | } catch (cause) { |
| 110 | const error = isThemeStorageError(cause) |
| 111 | ? cause |
| 112 | : new ThemeStorageError({ |
| 113 | operation: "read", |
| 114 | storageKey: STORAGE_KEY, |
| 115 | cause, |
| 116 | }); |
| 117 | themeStorageReadFailure = error; |
| 118 | console.error(error.message, { |
| 119 | operation: error.operation, |
| 120 | storageKey: error.storageKey, |
| 121 | ...safeErrorLogAttributes(error), |
| 122 | }); |
| 123 | return DEFAULT_THEME_SNAPSHOT.theme; |
| 124 | } |
| 125 | } |
| 126 | |
| 46 | 127 | function ensureThemeColorMetaTag(): HTMLMetaElement { |
| 47 | 128 | let element = document.querySelector<HTMLMetaElement>(DYNAMIC_THEME_COLOR_SELECTOR); |
| 48 | 129 | if (element) { |
| ... | ... | @@ -118,7 +199,18 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 118 | 199 | } |
| 119 | 200 | } |
| 120 | 201 | |
| 121 | | function syncDesktopTheme(theme: Theme) { |
| 202 | export async function syncDesktopThemePreference( |
| 203 | bridge: DesktopThemeBridge, |
| 204 | theme: Theme, |
| 205 | ): Promise<void> { |
| 206 | try { |
| 207 | await bridge.setTheme(theme); |
| 208 | } catch (cause) { |
| 209 | throw new DesktopThemeSyncError({ theme, cause }); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | export function syncDesktopTheme(theme: Theme) { |
| 122 | 214 | if (typeof window === "undefined") return; |
| 123 | 215 | const bridge = window.desktopBridge; |
| 124 | 216 | if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { |
| ... | ... | @@ -126,7 +218,14 @@ function syncDesktopTheme(theme: Theme) { |
| 126 | 218 | } |
| 127 | 219 | |
| 128 | 220 | lastDesktopTheme = theme; |
| 129 | | void bridge.setTheme(theme).catch(() => { |
| 221 | void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { |
| 222 | const error = isDesktopThemeSyncError(cause) |
| 223 | ? cause |
| 224 | : new DesktopThemeSyncError({ theme, cause }); |
| 225 | console.error(error.message, { |
| 226 | theme: error.theme, |
| 227 | ...safeErrorLogAttributes(error), |
| 228 | }); |
| 130 | 229 | if (lastDesktopTheme === theme) { |
| 131 | 230 | lastDesktopTheme = null; |
| 132 | 231 | } |
| ... | ... | @@ -134,12 +233,12 @@ function syncDesktopTheme(theme: Theme) { |
| 134 | 233 | } |
| 135 | 234 | |
| 136 | 235 | // Apply immediately on module load to prevent flash |
| 137 | | if (typeof document !== "undefined" && hasThemeStorage()) { |
| 236 | if (typeof document !== "undefined" && typeof window !== "undefined") { |
| 138 | 237 | applyTheme(getStored()); |
| 139 | 238 | } |
| 140 | 239 | |
| 141 | 240 | function getSnapshot(): ThemeSnapshot { |
| 142 | | if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; |
| 241 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; |
| 143 | 242 | const theme = getStored(); |
| 144 | 243 | const systemDark = theme === "system" ? getSystemDark() : false; |
| 145 | 244 | |
| ... | ... | @@ -170,6 +269,7 @@ function subscribe(listener: () => void): () => void { |
| 170 | 269 | // Listen for storage changes from other tabs |
| 171 | 270 | const handleStorage = (e: StorageEvent) => { |
| 172 | 271 | if (e.key === STORAGE_KEY) { |
| 272 | themeStorageReadFailure = null; |
| 173 | 273 | applyTheme(getStored(), true); |
| 174 | 274 | emitChange(); |
| 175 | 275 | } |
| ... | ... | @@ -191,8 +291,26 @@ export function useTheme() { |
| 191 | 291 | theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; |
| 192 | 292 | |
| 193 | 293 | const setTheme = useCallback((next: Theme) => { |
| 194 | | if (!hasThemeStorage()) return; |
| 195 | | localStorage.setItem(STORAGE_KEY, next); |
| 294 | if (typeof window === "undefined") return; |
| 295 | try { |
| 296 | writeThemePreference(next); |
| 297 | } catch (cause) { |
| 298 | const error = isThemeStorageError(cause) |
| 299 | ? cause |
| 300 | : new ThemeStorageError({ |
| 301 | operation: "write", |
| 302 | storageKey: STORAGE_KEY, |
| 303 | theme: next, |
| 304 | cause, |
| 305 | }); |
| 306 | console.error(error.message, { |
| 307 | operation: error.operation, |
| 308 | storageKey: error.storageKey, |
| 309 | theme: next, |
| 310 | ...safeErrorLogAttributes(error), |
| 311 | }); |
| 312 | return; |
| 313 | } |
| 196 | 314 | applyTheme(next, true); |
| 197 | 315 | emitChange(); |
| 198 | 316 | }, []); |