feat: migrate to Next.js for Mini App and enhance project structure

- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability.
- Updated the `.gitignore` to exclude Next.js build artifacts and node modules.
- Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack.
- Enhanced Dockerfile to support the new build process for the Next.js application.
- Updated CI workflow to build and test the Next.js application.
- Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking.
- Refactored frontend testing setup to accommodate the new structure and testing framework.
- Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
2026-03-03 16:04:08 +03:00
parent 2de5c1cb81
commit 16bf1a1043
148 changed files with 20240 additions and 7270 deletions

View File

@@ -0,0 +1,63 @@
/**
* Application initialization: language sync, access-denied logic, deep link routing.
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
*/
"use client";
import { useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { getLang } from "@/i18n/messages";
import { useTranslation } from "@/i18n/use-translation";
import { RETRY_DELAY_MS } from "@/lib/constants";
export interface UseAppInitParams {
/** Whether the user is allowed (localhost or has valid initData). */
isAllowed: boolean;
/** Telegram Mini App start_param (e.g. "duty" for current duty deep link). */
startParam: string | undefined;
}
/**
* Syncs language from backend config, applies document lang/title, handles access denied
* when not allowed, and routes to current duty view when opened via startParam=duty.
*/
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
const setLang = useAppStore((s) => s.setLang);
const lang = useAppStore((s) => s.lang);
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
const setLoading = useAppStore((s) => s.setLoading);
const setCurrentView = useAppStore((s) => s.setCurrentView);
const { t } = useTranslation();
// Sync lang from backend config (window.__DT_LANG).
useEffect(() => {
if (typeof window === "undefined") return;
setLang(getLang());
}, [setLang]);
// Apply lang to document (title and html lang) for accessibility and i18n.
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = lang;
document.title = t("app.title");
}, [lang, t]);
// When not allowed (no initData and not localhost), show access denied after delay.
useEffect(() => {
if (isAllowed) {
setAccessDenied(false);
return;
}
const id = setTimeout(() => {
setAccessDenied(true);
setLoading(false);
}, RETRY_DELAY_MS);
return () => clearTimeout(id);
}, [isAllowed, setAccessDenied, setLoading]);
// When opened via deep link startParam=duty, show current duty view first.
useEffect(() => {
if (startParam === "duty") setCurrentView("currentDuty");
}, [startParam, setCurrentView]);
}

View File

@@ -0,0 +1,29 @@
/**
* 60-second interval to refresh duty list when viewing the current month.
* Replaces state.todayRefreshInterval from webapp/js/main.js.
*/
"use client";
import { useEffect, useRef } from "react";
const AUTO_REFRESH_INTERVAL_MS = 60000;
/**
* When isCurrentMonth is true, calls refresh() immediately, then every 60 seconds.
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
*/
export function useAutoRefresh(
refresh: () => void,
isCurrentMonth: boolean
): void {
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
useEffect(() => {
if (!isCurrentMonth) return;
refreshRef.current();
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [isCurrentMonth]);
}

View File

@@ -0,0 +1,34 @@
/**
* Returns true when the given media query matches (e.g. min-width: 640px for desktop).
* Used to switch DayDetail between Popover (desktop) and Sheet (mobile).
*/
"use client";
import { useState, useEffect } from "react";
/**
* Match a media query string (e.g. "(min-width: 640px)").
* Returns undefined during SSR to avoid hydration mismatch; client gets the real value.
*/
export function useMediaQuery(query: string): boolean | undefined {
const [matches, setMatches] = useState<boolean | undefined>(() =>
typeof window === "undefined" ? undefined : window.matchMedia(query).matches
);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mq.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
/** True when viewport is at least 640px (desktop). Undefined during SSR. */
export function useIsDesktop(): boolean | undefined {
return useMediaQuery("(min-width: 640px)");
}

View File

@@ -0,0 +1,182 @@
/**
* Fetches duties and calendar events for the current month. Handles loading, error,
* access denied, and retry after ACCESS_DENIED. Replaces loadMonth() from webapp/js/main.js.
*/
"use client";
import { useEffect, useRef, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "@/lib/api";
import {
firstDayOfMonth,
lastDayOfMonth,
getMonday,
localDateString,
dutyOverlapsLocalRange,
} from "@/lib/date-utils";
import { RETRY_AFTER_ACCESS_DENIED_MS, RETRY_AFTER_ERROR_MS, MAX_GENERAL_RETRIES } from "@/lib/constants";
import { logger } from "@/lib/logger";
import { translate } from "@/i18n/messages";
export interface UseMonthDataOptions {
/** Telegram init data string for API auth. When undefined, no fetch (unless isLocalhost). */
initDataRaw: string | undefined;
/** When true, fetch runs for the current month. When false, no fetch (e.g. access not allowed). */
enabled: boolean;
}
/**
* Fetches duties and calendar events for store.currentMonth when enabled.
* Cancels in-flight request when month changes or component unmounts.
* On ACCESS_DENIED, shows access denied and retries once after RETRY_AFTER_ACCESS_DENIED_MS.
* Returns retry() to manually trigger a reload.
*
* The load callback is stabilized (empty dependency array) and reads latest
* options from a ref and currentMonth/lang from Zustand getState(), so the
* effect that calls load only re-runs when enabled, currentMonth, lang, or
* initDataRaw actually change.
*/
export function useMonthData(options: UseMonthDataOptions): { retry: () => void } {
const { initDataRaw, enabled } = options;
const currentMonth = useAppStore((s) => s.currentMonth);
const lang = useAppStore((s) => s.lang);
const abortRef = useRef<AbortController | null>(null);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initDataRetriedRef = useRef(false);
const generalRetryCountRef = useRef(0);
const mountedRef = useRef(true);
const optionsRef = useRef({ initDataRaw, enabled, lang });
optionsRef.current = { initDataRaw, enabled, lang };
const loadRef = useRef<() => void>(() => {});
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const load = useCallback(() => {
const { initDataRaw: initDataRawOpt, enabled: enabledOpt, lang: langOpt } = optionsRef.current;
if (!enabledOpt) return;
const initData = initDataRawOpt ?? "";
if (!initData && typeof window !== "undefined") {
const h = window.location.hostname;
if (h !== "localhost" && h !== "127.0.0.1" && h !== "") return;
}
const store = useAppStore.getState();
const currentMonthNow = store.currentMonth;
if (abortRef.current) abortRef.current.abort();
abortRef.current = new AbortController();
const signal = abortRef.current.signal;
store.batchUpdate({
accessDenied: false,
accessDeniedDetail: null,
loading: true,
error: null,
});
const first = firstDayOfMonth(currentMonthNow);
const start = getMonday(first);
const gridEnd = new Date(start);
gridEnd.setDate(gridEnd.getDate() + 41);
const from = localDateString(start);
const to = localDateString(gridEnd);
const run = async () => {
try {
logger.debug("Loading month", from, to);
const [duties, events] = await Promise.all([
fetchDuties(from, to, initData, langOpt, signal),
fetchCalendarEvents(from, to, initData, langOpt, signal),
]);
const last = lastDayOfMonth(currentMonthNow);
const firstKey = localDateString(first);
const lastKey = localDateString(last);
const dutiesInMonth = duties.filter((d) =>
dutyOverlapsLocalRange(d, firstKey, lastKey)
);
useAppStore.getState().batchUpdate({
duties: dutiesInMonth,
calendarEvents: events,
loading: false,
error: null,
});
} catch (e) {
if ((e as Error).name === "AbortError") return;
if (e instanceof AccessDeniedError) {
logger.warn("Access denied in loadMonth", e.serverDetail);
useAppStore.getState().batchUpdate({
accessDenied: true,
accessDeniedDetail: e.serverDetail ?? null,
loading: false,
});
if (!initDataRetriedRef.current) {
initDataRetriedRef.current = true;
const timeoutId = setTimeout(() => {
if (mountedRef.current) loadRef.current();
}, RETRY_AFTER_ACCESS_DENIED_MS);
retryTimeoutRef.current = timeoutId;
}
return;
}
logger.error("Load month failed", e);
if (generalRetryCountRef.current < MAX_GENERAL_RETRIES && mountedRef.current) {
generalRetryCountRef.current++;
const timeoutId = setTimeout(() => {
if (mountedRef.current) loadRef.current();
}, RETRY_AFTER_ERROR_MS);
retryTimeoutRef.current = timeoutId;
return;
}
useAppStore.getState().batchUpdate({
error: translate(langOpt, "error_generic"),
loading: false,
});
}
};
run();
}, []);
loadRef.current = load;
useEffect(() => {
if (!enabled) return;
initDataRetriedRef.current = false;
generalRetryCountRef.current = 0;
load();
return () => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
if (abortRef.current) abortRef.current.abort();
abortRef.current = null;
};
}, [enabled, load, currentMonth, lang, initDataRaw]);
useEffect(() => {
if (!enabled) return;
const handleVisibility = () => {
if (document.visibilityState !== "visible") return;
const { duties, loading: isLoading, error: hasError } = useAppStore.getState();
if (duties.length === 0 && !isLoading && !hasError) {
generalRetryCountRef.current = 0;
loadRef.current();
}
};
document.addEventListener("visibilitychange", handleVisibility);
return () => document.removeEventListener("visibilitychange", handleVisibility);
}, [enabled]);
return { retry: load };
}

View File

@@ -0,0 +1,44 @@
/**
* Toggles an "is-scrolled" class on the sticky element when the user has scrolled.
* Replaces bindStickyScrollShadow from webapp/js/main.js.
*/
"use client";
import { useEffect, useRef } from "react";
const IS_SCROLLED_CLASS = "is-scrolled";
/**
* Listens to window scroll and toggles the class "is-scrolled" on the given element
* when window.scrollY > 0. Uses passive scroll listener.
*/
export function useStickyScroll(
elementRef: React.RefObject<HTMLElement | null>
): void {
const rafRef = useRef<number | null>(null);
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const update = () => {
rafRef.current = null;
const scrolled = typeof window !== "undefined" && window.scrollY > 0;
el.classList.toggle(IS_SCROLLED_CLASS, scrolled);
};
const handleScroll = () => {
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(update);
}
};
update();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [elementRef]);
}

View File

@@ -0,0 +1,67 @@
/**
* Touch swipe detection for horizontal month navigation.
* Replaces swipe logic from webapp/js/main.js (threshold 50px).
*/
"use client";
import { useEffect, useRef } from "react";
export interface UseSwipeOptions {
/** Minimum horizontal distance (px) to count as swipe. Default 50. */
threshold?: number;
/** When true, swipe handlers are not attached. */
disabled?: boolean;
}
/**
* Attaches touchstart/touchend to the element ref and invokes onSwipeLeft or onSwipeRight
* when a horizontal swipe exceeds the threshold. Vertical swipes are ignored.
*/
export function useSwipe(
elementRef: React.RefObject<HTMLElement | null>,
onSwipeLeft: () => void,
onSwipeRight: () => void,
options: UseSwipeOptions = {}
): void {
const { threshold = 50, disabled = false } = options;
const startX = useRef(0);
const startY = useRef(0);
const onSwipeLeftRef = useRef(onSwipeLeft);
const onSwipeRightRef = useRef(onSwipeRight);
onSwipeLeftRef.current = onSwipeLeft;
onSwipeRightRef.current = onSwipeRight;
useEffect(() => {
const el = elementRef.current;
if (!el || disabled) return;
const handleStart = (e: TouchEvent) => {
if (e.changedTouches.length === 0) return;
const t = e.changedTouches[0];
startX.current = t.clientX;
startY.current = t.clientY;
};
const handleEnd = (e: TouchEvent) => {
if (e.changedTouches.length === 0) return;
const t = e.changedTouches[0];
const deltaX = t.clientX - startX.current;
const deltaY = t.clientY - startY.current;
if (Math.abs(deltaX) <= threshold) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
if (deltaX > threshold) {
onSwipeRightRef.current();
} else if (deltaX < -threshold) {
onSwipeLeftRef.current();
}
};
el.addEventListener("touchstart", handleStart, { passive: true });
el.addEventListener("touchend", handleEnd, { passive: true });
return () => {
el.removeEventListener("touchstart", handleStart);
el.removeEventListener("touchend", handleEnd);
};
}, [elementRef, disabled, threshold]);
}

View File

@@ -0,0 +1,50 @@
/**
* Unit tests for use-telegram-auth: isLocalhost.
* Ported from webapp/js/auth.test.js. getInitData is handled by SDK in the hook.
*/
import { describe, it, expect, afterEach } from "vitest";
import { isLocalhost } from "./use-telegram-auth";
describe("isLocalhost", () => {
const origLocation = window.location;
afterEach(() => {
Object.defineProperty(window, "location", {
value: origLocation,
writable: true,
});
});
it("returns true for localhost", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, hostname: "localhost" },
writable: true,
});
expect(isLocalhost()).toBe(true);
});
it("returns true for 127.0.0.1", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, hostname: "127.0.0.1" },
writable: true,
});
expect(isLocalhost()).toBe(true);
});
it("returns true for empty hostname", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, hostname: "" },
writable: true,
});
expect(isLocalhost()).toBe(true);
});
it("returns false for other hostnames", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, hostname: "example.com" },
writable: true,
});
expect(isLocalhost()).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import {
retrieveRawInitData,
retrieveLaunchParams,
} from "@telegram-apps/sdk-react";
import { getStartParamFromUrl } from "@/lib/launch-params";
/**
* Whether the app is running on localhost (dev without Telegram).
*/
export function isLocalhost(): boolean {
if (typeof window === "undefined") return false;
const h = window.location.hostname;
return h === "localhost" || h === "127.0.0.1" || h === "";
}
/**
* Telegram auth and launch context for API and deep links.
* Replaces webapp/js/auth.js getInitData, isLocalhost, and startParam detection.
*
* Uses imperative retrieveRawInitData/retrieveLaunchParams in useEffect so that
* non-Telegram environments (e.g. browser) do not throw during render.
* start_param is also read from URL (search/hash) as fallback when SDK is delayed.
*
* - initDataRaw: string for X-Telegram-Init-Data header (undefined when not in TWA)
* - startParam: deep link param (e.g. "duty" for current duty view)
* - isLocalhost: true when hostname is localhost/127.0.0.1 for dev without Telegram
*/
export function useTelegramAuth(): {
initDataRaw: string | undefined;
startParam: string | undefined;
isLocalhost: boolean;
} {
const urlStartParam = useMemo(() => getStartParamFromUrl(), []);
const [initDataRaw, setInitDataRaw] = useState<string | undefined>(undefined);
const [startParam, setStartParam] = useState<string | undefined>(urlStartParam);
const localhost = useMemo(() => isLocalhost(), []);
useEffect(() => {
try {
const raw = retrieveRawInitData();
setInitDataRaw(raw ?? undefined);
const lp = retrieveLaunchParams();
const param =
typeof lp.start_param === "string" ? lp.start_param : urlStartParam;
setStartParam(param ?? urlStartParam ?? undefined);
} catch {
setInitDataRaw(undefined);
setStartParam(urlStartParam ?? undefined);
}
}, [urlStartParam]);
return {
initDataRaw,
startParam,
isLocalhost: localhost,
};
}

View File

@@ -0,0 +1,140 @@
/**
* Unit tests for useTelegramTheme, getFallbackScheme, and applyTheme.
* Ported from webapp/js/theme.test.js (getTheme, applyTheme).
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import {
useTelegramTheme,
getFallbackScheme,
applyTheme,
} from "./use-telegram-theme";
vi.mock("@telegram-apps/sdk-react", () => ({
useSignal: vi.fn(() => undefined),
isThemeParamsDark: vi.fn(),
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
}));
describe("getFallbackScheme", () => {
const originalMatchMedia = window.matchMedia;
const originalGetComputedStyle = window.getComputedStyle;
afterEach(() => {
window.matchMedia = originalMatchMedia;
window.getComputedStyle = originalGetComputedStyle;
document.documentElement.removeAttribute("data-theme");
vi.clearAllMocks();
});
it("returns dark when prefers-color-scheme is dark", () => {
window.matchMedia = vi.fn((query: string) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
})) as unknown as typeof window.matchMedia;
expect(getFallbackScheme()).toBe("dark");
});
it("returns light when prefers-color-scheme is light", () => {
window.matchMedia = vi.fn((query: string) => ({
matches: query === "(prefers-color-scheme: light)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
})) as unknown as typeof window.matchMedia;
expect(getFallbackScheme()).toBe("light");
});
it("uses --tg-color-scheme when set on document", () => {
window.getComputedStyle = vi.fn(() =>
Object.assign(
{},
{
getPropertyValue: (prop: string) =>
prop === "--tg-color-scheme" ? " light " : "",
}
)
) as unknown as typeof window.getComputedStyle;
expect(getFallbackScheme()).toBe("light");
});
it("uses --tg-color-scheme dark when set", () => {
window.getComputedStyle = vi.fn(() =>
Object.assign(
{},
{
getPropertyValue: (prop: string) =>
prop === "--tg-color-scheme" ? "dark" : "",
}
)
) as unknown as typeof window.getComputedStyle;
expect(getFallbackScheme()).toBe("dark");
});
});
describe("applyTheme", () => {
afterEach(() => {
document.documentElement.removeAttribute("data-theme");
vi.clearAllMocks();
});
it("sets data-theme to given scheme", () => {
applyTheme("light");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
applyTheme("dark");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("resolves scheme via getFallbackScheme when no argument", () => {
window.matchMedia = vi.fn((query: string) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
})) as unknown as typeof window.matchMedia;
applyTheme();
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
});
describe("useTelegramTheme", () => {
const originalMatchMedia = window.matchMedia;
const originalGetComputedStyle = window.getComputedStyle;
afterEach(() => {
window.matchMedia = originalMatchMedia;
window.getComputedStyle = originalGetComputedStyle;
document.documentElement.removeAttribute("data-theme");
vi.clearAllMocks();
});
it("sets data-theme to dark when useSignal returns true", async () => {
const { useSignal } = await import("@telegram-apps/sdk-react");
vi.mocked(useSignal).mockReturnValue(true);
renderHook(() => useTelegramTheme());
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("sets data-theme to light when useSignal returns false", async () => {
const { useSignal } = await import("@telegram-apps/sdk-react");
vi.mocked(useSignal).mockReturnValue(false);
renderHook(() => useTelegramTheme());
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
});

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect } from "react";
import {
useSignal,
isThemeParamsDark,
setMiniAppBackgroundColor,
setMiniAppHeaderColor,
} from "@telegram-apps/sdk-react";
/**
* Resolves color scheme when Telegram theme is not available (SSR or non-TWA).
* Uses --tg-color-scheme (if set by Telegram) then prefers-color-scheme.
*/
export function getFallbackScheme(): "dark" | "light" {
if (typeof window === "undefined") return "dark";
try {
const cssScheme = getComputedStyle(document.documentElement)
.getPropertyValue("--tg-color-scheme")
.trim();
if (cssScheme === "light" || cssScheme === "dark") return cssScheme;
} catch {
// ignore
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
return "light";
}
/**
* Ensure --surface differs from --bg so cards/cells are visible.
* iOS OLED sends secondary_bg === bg (#000000) while section_bg differs;
* PC desktop sends section_bg === bg (#17212b) while secondary_bg differs.
* When the CSS-resolved --surface equals --bg, override with whichever
* Telegram color provides contrast, or a synthesized lighter fallback.
*/
export function fixSurfaceContrast(): void {
const root = document.documentElement;
const cs = getComputedStyle(root);
const bg = cs.getPropertyValue("--bg").trim();
const surface = cs.getPropertyValue("--surface").trim();
if (!bg || !surface || bg !== surface) return;
const sectionBg = cs.getPropertyValue("--tg-theme-section-bg-color").trim();
if (sectionBg && sectionBg !== bg) {
root.style.setProperty("--surface", sectionBg);
return;
}
const secondaryBg = cs.getPropertyValue("--tg-theme-secondary-bg-color").trim();
if (secondaryBg && secondaryBg !== bg) {
root.style.setProperty("--surface", secondaryBg);
return;
}
root.style.setProperty("--surface", `color-mix(in srgb, ${bg}, white 8%)`);
}
/**
* Applies theme: sets data-theme, forces reflow, fixes surface contrast,
* then Mini App background/header.
* Shared by TelegramProvider (initial + delayed) and useTelegramTheme.
* @param scheme - If provided, use it; otherwise resolve via getFallbackScheme().
*/
export function applyTheme(scheme?: "dark" | "light"): void {
const resolved = scheme ?? getFallbackScheme();
document.documentElement.setAttribute("data-theme", resolved);
void document.documentElement.offsetHeight; // force reflow so WebView repaints
fixSurfaceContrast();
if (setMiniAppBackgroundColor.isAvailable()) {
setMiniAppBackgroundColor("bg_color");
}
if (setMiniAppHeaderColor.isAvailable()) {
setMiniAppHeaderColor("bg_color");
}
}
/**
* Maps Telegram theme params to data-theme and Mini App background/header.
* Subscribes to theme changes via SDK signals.
* Ported from webapp/js/theme.js applyTheme / initTheme.
*/
export function useTelegramTheme(): "dark" | "light" {
const signalDark = useSignal(isThemeParamsDark);
const isDark =
typeof signalDark === "boolean" ? signalDark : getFallbackScheme() === "dark";
useEffect(() => {
applyTheme(isDark ? "dark" : "light");
}, [isDark]);
return isDark ? "dark" : "light";
}