1import { personalUiSettingsSyncEnabled, useUiSettingsSync } from "../state/uiSettingsSync";
2import { useAtomValue } from "@effect/atom-react";
3import { useEffect } from "react";
4
5import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment";
6import { primaryServerSettingsAtom } from "../state/server";
7import { getThemeDefinition, singleAppearanceOf } from "../themePalette";
8import { useEnvironmentThemeDefinitions } from "./useEnvironmentTheme";
9import { useTheme } from "./useTheme";
10
11/**
12 * Scoped per environment: each machine's `t3 theme set` is its own act, so
13 * hopping between primary environments neither replays one environment's
14 * theme over the user's pick nor swallows another's.
15 */
16const APPLIED_DEFAULT_THEME_STORAGE_PREFIX = "t3code:default-theme-applied:v2:";
17
18/**
19 * One generation per set: keyed on when the theme was set, not just its
20 * value, so re-asserting the same theme still acts. Environments provisioned
21 * by builds without the timestamp degrade to applying once per value.
22 */
23export function defaultThemeGeneration(theme: string, setAt: string): string {
24 return setAt.length > 0 ? `${theme}@${setAt}` : theme;
25}
26
27/**
28 * The generation to apply, or null to leave this client alone. Pure so the
29 * rule -- apply once per set, never replay one already applied, wait for a
30 * theme that has not arrived yet -- can be tested without a renderer.
31 */
32export function defaultThemeToApply(input: {
33 readonly environmentId: string | null;
34 readonly defaultTheme: string;
35 readonly defaultThemeSetAt: string;
36 readonly appliedGeneration: string | null;
37 readonly resolves: boolean;
38 readonly profileEnabled: boolean;
39}): string | null {
40 if (input.profileEnabled) return null;
41 if (input.environmentId === null || input.defaultTheme.length === 0) return null;
42 const generation = defaultThemeGeneration(input.defaultTheme, input.defaultThemeSetAt);
43 if (input.appliedGeneration === generation) return null;
44 // The setting and the palette it names arrive independently, so an id that
45 // does not resolve yet is not a failure -- the effect runs again when the
46 // published set changes.
47 if (!input.resolves) return null;
48 return generation;
49}
50
51function readAppliedGeneration(storageKey: string): string | null {
52 if (typeof window === "undefined") return null;
53 try {
54 return window.localStorage.getItem(storageKey);
55 } catch {
56 return null;
57 }
58}
59
60function writeAppliedGeneration(storageKey: string, generation: string): void {
61 try {
62 window.localStorage.setItem(storageKey, generation);
63 } catch {
64 // Unrecordable means the next config event applies again; harmless.
65 }
66}
67
68/**
69 * Applies the environment's theme (`t3 theme set <id>`). Each set switches
70 * this client once — live when connected, on the next connect otherwise —
71 * and then steps aside: a theme the user picks in Settings afterwards wins
72 * until the environment's theme is set again. The environment's own published
73 * themes are valid targets, which is why this waits for an id that does not
74 * resolve yet — the setting and the palette it names arrive independently.
75 */
76export function useDefaultThemeAdoption(): void {
77 const sync = useUiSettingsSync();
78 const profileEnabled = sync.controller !== null || personalUiSettingsSyncEnabled();
79 const environmentId = useAtomValue(primaryEnvironmentIdAtom);
80 const settings = useAtomValue(primaryServerSettingsAtom);
81 const { defaultTheme, defaultThemeSetAt } = settings;
82 const { setTheme, setAppearanceMode } = useTheme();
83 // Re-runs adoption when a late-arriving published theme makes the
84 // requested id resolvable.
85 const environmentThemes = useEnvironmentThemeDefinitions();
86
87 useEffect(() => {
88 if (typeof window === "undefined" || environmentId === null) return;
89 const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`;
90 if (profileEnabled) {
91 if (defaultTheme.length > 0)
92 writeAppliedGeneration(storageKey, defaultThemeGeneration(defaultTheme, defaultThemeSetAt));
93 }
94 const definition = getThemeDefinition(defaultTheme);
95 const generation = defaultThemeToApply({
96 environmentId,
97 defaultTheme,
98 defaultThemeSetAt,
99 appliedGeneration: readAppliedGeneration(storageKey),
100 resolves: definition !== null,
101 profileEnabled,
102 });
103 if (generation === null || definition === null) return;
104
105 // Deliberately not the card's rule. Clicking a card is a user choosing one
106 // half of their own mix; a set theme is the environment saying what this
107 // client opens on, so it takes the base preference and, for a
108 // single-appearance theme, the matching mode -- otherwise a dark-only
109 // theme on a light client is recorded as applied while nothing changes.
110 if (!setTheme(defaultTheme)) return;
111 const half = singleAppearanceOf(definition);
112 // Recorded only once both land: marking the generation applied while the
113 // appearance switch failed would leave a dark-only theme rendering its
114 // light half with no retry.
115 if (half !== null && !setAppearanceMode(half)) return;
116 writeAppliedGeneration(storageKey, generation);
117 }, [
118 profileEnabled,
119 environmentId,
120 defaultTheme,
121 defaultThemeSetAt,
122 environmentThemes,
123 setTheme,
124 setAppearanceMode,
125 ]);
126}