| ... | @@ -1,11 +1,17 @@ | ... | @@ -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 | import { useCallback, useEffect, useSyncExternalStore } from "react"; | 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 | type ThemeSnapshot = { | 8 | type ThemeSnapshot = { |
| 5 | theme: Theme; | 9 | theme: Theme; |
| 6 | systemDark: boolean; | 10 | systemDark: boolean; |
| 7 | }; | 11 | }; |
| 8 | | 12 | |
| | 13 | type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">; |
| | 14 | |
| 9 | const STORAGE_KEY = "t3code:theme"; | 15 | const STORAGE_KEY = "t3code:theme"; |
| 10 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; | 16 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 11 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { | 17 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| ... | @@ -15,19 +21,46 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { | ... | @@ -15,19 +21,46 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| 15 | const THEME_COLOR_META_NAME = "theme-color"; | 21 | const THEME_COLOR_META_NAME = "theme-color"; |
| 16 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; | 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 | let listeners: Array<() => void> = []; | 54 | let listeners: Array<() => void> = []; |
| 19 | let lastSnapshot: ThemeSnapshot | null = null; | 55 | let lastSnapshot: ThemeSnapshot | null = null; |
| 20 | let lastDesktopTheme: Theme | null = null; | 56 | let lastDesktopTheme: Theme | null = null; |
| 21 | let lastAppliedTheme: ThemeSnapshot | null = null; | 57 | let lastAppliedTheme: ThemeSnapshot | null = null; |
| | 58 | let themeStorageReadFailure: ThemeStorageError | null = null; |
| 22 | | 59 | |
| 23 | function emitChange() { | 60 | function emitChange() { |
| 24 | for (const listener of listeners) listener(); | 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 | function getSystemDark() { | 64 | function getSystemDark() { |
| 32 | return ( | 65 | return ( |
| 33 | typeof window !== "undefined" && | 66 | typeof window !== "undefined" && |
| ... | @@ -36,13 +69,61 @@ function getSystemDark() { | ... | @@ -36,13 +69,61 @@ function getSystemDark() { |
| 36 | ); | 69 | ); |
| 37 | } | 70 | } |
| 38 | | 71 | |
| 39 | function getStored(): Theme { | 72 | export function readThemePreference(): Theme { |
| 40 | if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; | 73 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; |
| 41 | const raw = localStorage.getItem(STORAGE_KEY); | 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 | if (raw === "light" || raw === "dark" || raw === "system") return raw; | 84 | if (raw === "light" || raw === "dark" || raw === "system") return raw; |
| 43 | return DEFAULT_THEME_SNAPSHOT.theme; | 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 | function ensureThemeColorMetaTag(): HTMLMetaElement { | 127 | function ensureThemeColorMetaTag(): HTMLMetaElement { |
| 47 | let element = document.querySelector<HTMLMetaElement>(DYNAMIC_THEME_COLOR_SELECTOR); | 128 | let element = document.querySelector<HTMLMetaElement>(DYNAMIC_THEME_COLOR_SELECTOR); |
| 48 | if (element) { | 129 | if (element) { |
| ... | @@ -118,7 +199,18 @@ function applyTheme(theme: Theme, suppressTransitions = false) { | ... | @@ -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 | if (typeof window === "undefined") return; | 214 | if (typeof window === "undefined") return; |
| 123 | const bridge = window.desktopBridge; | 215 | const bridge = window.desktopBridge; |
| 124 | if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { | 216 | if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { |
| ... | @@ -126,7 +218,14 @@ function syncDesktopTheme(theme: Theme) { | ... | @@ -126,7 +218,14 @@ function syncDesktopTheme(theme: Theme) { |
| 126 | } | 218 | } |
| 127 | | 219 | |
| 128 | lastDesktopTheme = theme; | 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 | if (lastDesktopTheme === theme) { | 229 | if (lastDesktopTheme === theme) { |
| 131 | lastDesktopTheme = null; | 230 | lastDesktopTheme = null; |
| 132 | } | 231 | } |
| ... | @@ -134,12 +233,12 @@ function syncDesktopTheme(theme: Theme) { | ... | @@ -134,12 +233,12 @@ function syncDesktopTheme(theme: Theme) { |
| 134 | } | 233 | } |
| 135 | | 234 | |
| 136 | // Apply immediately on module load to prevent flash | 235 | // Apply immediately on module load to prevent flash |
| 137 | if (typeof document !== "undefined" && hasThemeStorage()) { | 236 | if (typeof document !== "undefined" && typeof window !== "undefined") { |
| 138 | applyTheme(getStored()); | 237 | applyTheme(getStored()); |
| 139 | } | 238 | } |
| 140 | | 239 | |
| 141 | function getSnapshot(): ThemeSnapshot { | 240 | function getSnapshot(): ThemeSnapshot { |
| 142 | if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; | 241 | if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; |
| 143 | const theme = getStored(); | 242 | const theme = getStored(); |
| 144 | const systemDark = theme === "system" ? getSystemDark() : false; | 243 | const systemDark = theme === "system" ? getSystemDark() : false; |
| 145 | | 244 | |
| ... | @@ -170,6 +269,7 @@ function subscribe(listener: () => void): () => void { | ... | @@ -170,6 +269,7 @@ function subscribe(listener: () => void): () => void { |
| 170 | // Listen for storage changes from other tabs | 269 | // Listen for storage changes from other tabs |
| 171 | const handleStorage = (e: StorageEvent) => { | 270 | const handleStorage = (e: StorageEvent) => { |
| 172 | if (e.key === STORAGE_KEY) { | 271 | if (e.key === STORAGE_KEY) { |
| | 272 | themeStorageReadFailure = null; |
| 173 | applyTheme(getStored(), true); | 273 | applyTheme(getStored(), true); |
| 174 | emitChange(); | 274 | emitChange(); |
| 175 | } | 275 | } |
| ... | @@ -191,8 +291,26 @@ export function useTheme() { | ... | @@ -191,8 +291,26 @@ export function useTheme() { |
| 191 | theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; | 291 | theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; |
| 192 | | 292 | |
| 193 | const setTheme = useCallback((next: Theme) => { | 293 | const setTheme = useCallback((next: Theme) => { |
| 194 | if (!hasThemeStorage()) return; | 294 | if (typeof window === "undefined") return; |
| 195 | localStorage.setItem(STORAGE_KEY, next); | 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 | applyTheme(next, true); | 314 | applyTheme(next, true); |
| 197 | emitChange(); | 315 | emitChange(); |
| 198 | }, []); | 316 | }, []); |