summaryrefslogtreecommitdiff
path: root/makima/frontend/src/hooks/useUserSettings.ts
blob: b39244d0862d56e10b931c34a8c21b04a6ea5713 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import { useCallback, useEffect, useState } from "react";
import {
  getUserSettings,
  updateUserSettings,
  type UserSettings,
} from "../lib/api";

const DEFAULT_SETTINGS: UserSettings = { documentModeEnabled: false };

// Module-level cache + pub-sub so multiple components mounting the hook stay
// in sync without a full provider/context. Toggling the flag in <SettingsPage>
// will reactively update <DirectivesPage> if it's mounted, and vice versa.
let cachedSettings: UserSettings | null = null;
let inflight: Promise<void> | null = null;
const subscribers = new Set<(s: UserSettings | null) => void>();

function notify() {
  for (const sub of subscribers) sub(cachedSettings);
}

function loadOnce(): Promise<void> {
  if (inflight) return inflight;
  inflight = getUserSettings()
    .then((s) => {
      cachedSettings = s;
      notify();
    })
    .catch((err) => {
      // Swallow but log — fall back to safe defaults so the existing UI keeps
      // rendering even if /settings endpoint is unavailable.
      console.error("Failed to load user settings:", err);
      cachedSettings = DEFAULT_SETTINGS;
      notify();
    })
    .finally(() => {
      inflight = null;
    });
  return inflight;
}

export interface UseUserSettingsResult {
  /** Loaded settings, or null while loading for the first time. */
  settings: UserSettings | null;
  /** True while the initial GET is in flight. */
  loading: boolean;
  /** Update one or more settings; persists via PUT and updates the cache. */
  update: (patch: Partial<UserSettings>) => Promise<UserSettings>;
  /** Force a refresh from the server (e.g. after sign-in). */
  refresh: () => Promise<void>;
}

/**
 * React hook for the per-user settings record (feature flags).
 *
 * Calls GET /api/v1/users/me/settings on first mount and caches the result.
 * Subsequent mounts read from the cache. `update()` PUTs to the server and
 * notifies all live subscribers so UI gates reactively flip without a reload.
 */
export function useUserSettings(): UseUserSettingsResult {
  const [settings, setSettings] = useState<UserSettings | null>(cachedSettings);
  const [loading, setLoading] = useState<boolean>(cachedSettings === null);

  useEffect(() => {
    let mounted = true;
    const sub = (s: UserSettings | null) => {
      if (!mounted) return;
      setSettings(s);
      setLoading(false);
    };
    subscribers.add(sub);

    if (cachedSettings === null) {
      loadOnce();
    } else {
      // Already cached — make sure local state matches.
      setSettings(cachedSettings);
      setLoading(false);
    }

    return () => {
      mounted = false;
      subscribers.delete(sub);
    };
  }, []);

  const update = useCallback(
    async (patch: Partial<UserSettings>): Promise<UserSettings> => {
      const base = cachedSettings ?? DEFAULT_SETTINGS;
      const merged: UserSettings = { ...base, ...patch };
      // Optimistic update so the toggle flips immediately.
      cachedSettings = merged;
      notify();
      try {
        const result = await updateUserSettings(merged);
        cachedSettings = result;
        notify();
        return result;
      } catch (err) {
        // Roll back to last-known-good on failure.
        cachedSettings = base;
        notify();
        throw err;
      }
    },
    [],
  );

  const refresh = useCallback(async () => {
    cachedSettings = null;
    notify();
    await loadOnce();
  }, []);

  return { settings, loading, update, refresh };
}