feat(tg): P0 — dev mock bridge + Telegram-native foundations

Mini App V4 phase 0. The (tg) shell gains the groundwork every later
phase builds on:

- Dev mock bridge: outside Telegram, a development build falls back to a
  no-op WebApp object and skips the webapp-auth POST (the regular panel
  session cookie authorizes API calls), so the cockpit is workable in a
  plain browser. Production keeps the "Open from Telegram" wall.
- Telegram theme adoption: themeParams map onto the shadcn CSS variables
  scoped to #tg-shell (desktop dashboard untouched), colorScheme drives
  the dark class, themeChanged re-applies live. Non-hex values are
  dropped at the trust boundary.
- Viewport/swipe correctness: shell height rides Telegram's own
  --tg-viewport-stable-height (100dvh fallback), vertical swipe-to-close
  disabled so list scrolling can't dismiss the app.
- Native chrome bindings: TgWebAppProvider context plus useMainButton /
  useBackButton declarative hooks and a null-safe haptics helper —
  consumers never touch window.Telegram directly.
This commit is contained in:
Renn F
2026-07-19 00:22:12 +02:00
parent 945ce006fd
commit ae21874817
8 changed files with 682 additions and 18 deletions
+11 -1
View File
@@ -11,6 +11,12 @@ import Script from "next/script";
* `afterInteractive` strategy instead — `waitForTelegramWebApp` (in
* lib/telegram/webapp.ts) briefly polls for `window.Telegram.WebApp` to
* absorb the resulting load race rather than assuming it's present on mount.
*
* Height reads `--tg-viewport-stable-height`, a :root variable the Telegram
* script itself maintains (steady during keyboard/panel animations, unlike
* dvh inside the webview); outside Telegram it's unset and 100dvh applies.
* `#tg-shell` is the hook the page uses to scope Telegram theme variables
* to this surface only.
*/
export default function TelegramLayout({
children,
@@ -18,7 +24,11 @@ export default function TelegramLayout({
children: React.ReactNode;
}) {
return (
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
<div
id="tg-shell"
className="flex flex-col overflow-hidden bg-background text-foreground"
style={{ height: "var(--tg-viewport-stable-height, 100dvh)" }}
>
<Script
src="https://telegram.org/js/telegram-web-app.js"
strategy="afterInteractive"
+58 -3
View File
@@ -1,10 +1,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const { waitForTelegramWebApp } = vi.hoisted(() => ({
waitForTelegramWebApp: vi.fn(),
}));
vi.mock("@/lib/telegram/webapp", () => ({ waitForTelegramWebApp }));
// Keep the real dev-mock helpers (createDevMockWebApp / isDevMockWebApp) —
// only the bridge resolver is faked.
vi.mock("@/lib/telegram/webapp", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
waitForTelegramWebApp,
}));
const { startTelegramThemeSync } = vi.hoisted(() => ({
startTelegramThemeSync: vi.fn(() => () => undefined),
}));
vi.mock("@/lib/telegram/theme", () => ({ startTelegramThemeSync }));
const { post } = vi.hoisted(() => ({ post: vi.fn() }));
vi.mock("@/lib/api/client", () => ({
@@ -35,15 +45,25 @@ vi.mock("@/components/tg/tg-chat-tab", () => ({
import TelegramMiniAppPage from "../page";
function mockWebApp(initData = "abc123") {
return { ready: vi.fn(), expand: vi.fn(), initData };
return {
ready: vi.fn(),
expand: vi.fn(),
disableVerticalSwipes: vi.fn(),
initData,
};
}
describe("TelegramMiniAppPage — auth bootstrap", () => {
beforeEach(() => {
waitForTelegramWebApp.mockReset();
startTelegramThemeSync.mockClear();
post.mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("shows a spinner while validating", () => {
waitForTelegramWebApp.mockReturnValue(new Promise(() => {}));
render(<TelegramMiniAppPage />);
@@ -72,6 +92,7 @@ describe("TelegramMiniAppPage — auth bootstrap", () => {
);
expect(webApp.ready).toHaveBeenCalledTimes(1);
expect(webApp.expand).toHaveBeenCalledTimes(1);
expect(webApp.disableVerticalSwipes).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", {
init_data: "real-init-data",
});
@@ -91,4 +112,38 @@ describe("TelegramMiniAppPage — auth bootstrap", () => {
expect(screen.getByText("Mini App disabled")).toBeInTheDocument();
expect(screen.queryByTestId("tg-tab-bar")).not.toBeInTheDocument();
});
it("falls back to the dev mock outside Telegram in development — no auth POST", async () => {
vi.stubEnv("NODE_ENV", "development");
waitForTelegramWebApp.mockResolvedValue(null);
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
);
expect(post).not.toHaveBeenCalled();
expect(
screen.queryByText(/open from telegram/i),
).not.toBeInTheDocument();
});
it("starts Telegram theme sync against the #tg-shell element once ready", async () => {
const shell = document.createElement("div");
shell.id = "tg-shell";
document.body.appendChild(shell);
try {
const webApp = mockWebApp();
waitForTelegramWebApp.mockResolvedValue(webApp);
post.mockResolvedValue({ data: { ok: true } });
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(startTelegramThemeSync).toHaveBeenCalledWith(webApp, shell),
);
} finally {
shell.remove();
}
});
});
+44 -11
View File
@@ -2,7 +2,14 @@
import { useEffect, useState } from "react";
import api, { getErrorMessage } from "@/lib/api/client";
import { waitForTelegramWebApp } from "@/lib/telegram/webapp";
import {
createDevMockWebApp,
isDevMockWebApp,
waitForTelegramWebApp,
type TelegramWebApp,
} from "@/lib/telegram/webapp";
import { startTelegramThemeSync } from "@/lib/telegram/theme";
import { TgWebAppProvider } from "@/lib/telegram/hooks";
import { TgTabBar, type TgTab } from "@/components/tg/tg-tab-bar";
import { TgApprovalsTab } from "@/components/tg/tg-approvals-tab";
import { TgInboxTab } from "@/components/tg/tg-inbox-tab";
@@ -12,7 +19,7 @@ import { Loader2, AlertTriangle, ExternalLink } from "lucide-react";
type BootstrapState =
| { kind: "validating" }
| { kind: "ready" }
| { kind: "ready"; webApp: TelegramWebApp }
| { kind: "not_in_telegram" }
| { kind: "error"; message: string };
@@ -31,6 +38,11 @@ function CenteredMessage({ children }: { children: React.ReactNode }) {
* before rendering the tabbed cockpit. There's no way to read the resulting
* httponly session cookie client-side to skip this on a warm reload, so it
* always runs; it's cheap and the backend contract says so explicitly.
*
* Outside Telegram, a development build falls back to the dev mock bridge
* (skipping the auth POST — the regular panel session cookie authorizes the
* API calls) so the shell is workable in a plain browser; production keeps
* the "Open from Telegram" wall.
*/
export default function TelegramMiniAppPage() {
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
@@ -39,19 +51,27 @@ export default function TelegramMiniAppPage() {
useEffect(() => {
let cancelled = false;
void (async () => {
const webApp = await waitForTelegramWebApp();
let webApp = await waitForTelegramWebApp();
if (cancelled) return;
if (!webApp && process.env.NODE_ENV === "development") {
webApp = createDevMockWebApp();
}
if (!webApp) {
setState({ kind: "not_in_telegram" });
return;
}
webApp.ready();
webApp.expand();
webApp.disableVerticalSwipes?.();
if (isDevMockWebApp(webApp)) {
setState({ kind: "ready", webApp });
return;
}
try {
await api.post("/telegram/webapp-auth", {
init_data: webApp.initData ?? "",
});
if (!cancelled) setState({ kind: "ready" });
if (!cancelled) setState({ kind: "ready", webApp });
} catch (err) {
if (!cancelled) {
setState({ kind: "error", message: getErrorMessage(err) });
@@ -63,6 +83,17 @@ export default function TelegramMiniAppPage() {
};
}, []);
// Adopt the user's Telegram palette for the whole shell (and track live
// theme switches). Scoped to #tg-shell so the desktop dashboard is
// untouched; the dev mock carries empty themeParams, so a dev browser
// keeps the panel's own theme.
useEffect(() => {
if (state.kind !== "ready") return;
const shell = document.getElementById("tg-shell");
if (!shell) return;
return startTelegramThemeSync(state.webApp, shell);
}, [state]);
if (state.kind === "validating") {
return (
<CenteredMessage>
@@ -96,12 +127,14 @@ export default function TelegramMiniAppPage() {
}
return (
<div className="p-3 pb-20">
{tab === "approvals" && <TgApprovalsTab />}
{tab === "inbox" && <TgInboxTab />}
{tab === "board" && <TgBoardTab />}
{tab === "chat" && <TgChatTab />}
<TgTabBar active={tab} onChange={setTab} />
</div>
<TgWebAppProvider webApp={state.webApp}>
<div className="p-3 pb-20">
{tab === "approvals" && <TgApprovalsTab />}
{tab === "inbox" && <TgInboxTab />}
{tab === "board" && <TgBoardTab />}
{tab === "chat" && <TgChatTab />}
<TgTabBar active={tab} onChange={setTab} />
</div>
</TgWebAppProvider>
);
}
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import {
TgWebAppProvider,
useMainButton,
useBackButton,
type MainButtonOptions,
} from "../hooks";
import type { TelegramWebApp } from "../webapp";
function fakeMainButton() {
return {
setText: vi.fn(),
show: vi.fn(),
hide: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
showProgress: vi.fn(),
hideProgress: vi.fn(),
onClick: vi.fn(),
offClick: vi.fn(),
};
}
function fakeBackButton() {
return {
show: vi.fn(),
hide: vi.fn(),
onClick: vi.fn(),
offClick: vi.fn(),
};
}
function webAppWith(overrides: Partial<TelegramWebApp>): TelegramWebApp {
return {
ready: () => undefined,
expand: () => undefined,
initData: "",
...overrides,
};
}
function MainButtonHarness(props: MainButtonOptions) {
useMainButton(props);
return null;
}
function BackButtonHarness({ onBack }: { onBack: (() => void) | null }) {
useBackButton(onBack);
return null;
}
describe("useMainButton", () => {
let mainButton: ReturnType<typeof fakeMainButton>;
let webApp: TelegramWebApp;
beforeEach(() => {
mainButton = fakeMainButton();
webApp = webAppWith({ MainButton: mainButton });
});
it("configures and shows the button declaratively", () => {
render(
<TgWebAppProvider webApp={webApp}>
<MainButtonHarness text="Approve" visible onClick={() => undefined} />
</TgWebAppProvider>,
);
expect(mainButton.setText).toHaveBeenCalledWith("Approve");
expect(mainButton.enable).toHaveBeenCalled();
expect(mainButton.hideProgress).toHaveBeenCalled();
expect(mainButton.show).toHaveBeenCalled();
expect(mainButton.onClick).toHaveBeenCalledTimes(1);
});
it("reflects loading/disabled and invokes the latest onClick closure", () => {
const first = vi.fn();
const second = vi.fn();
const { rerender } = render(
<TgWebAppProvider webApp={webApp}>
<MainButtonHarness text="Approve" visible onClick={first} />
</TgWebAppProvider>,
);
rerender(
<TgWebAppProvider webApp={webApp}>
<MainButtonHarness text="Approve" visible loading disabled onClick={second} />
</TgWebAppProvider>,
);
expect(mainButton.showProgress).toHaveBeenCalled();
expect(mainButton.disable).toHaveBeenCalled();
// Same subscribed handler survives rerenders but calls the fresh closure.
expect(mainButton.onClick).toHaveBeenCalledTimes(1);
const handler = mainButton.onClick.mock.calls[0][0] as () => void;
handler();
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
it("unhooks and hides on unmount", () => {
const { unmount } = render(
<TgWebAppProvider webApp={webApp}>
<MainButtonHarness text="Approve" visible onClick={() => undefined} />
</TgWebAppProvider>,
);
unmount();
expect(mainButton.offClick).toHaveBeenCalledTimes(1);
expect(mainButton.hide).toHaveBeenCalled();
});
it("no-ops without a provider (outside Telegram)", () => {
expect(() =>
render(
<MainButtonHarness text="Approve" visible onClick={() => undefined} />,
),
).not.toThrow();
});
});
describe("useBackButton", () => {
it("shows while a handler is set, hides when null, unhooks on unmount", () => {
const backButton = fakeBackButton();
const webApp = webAppWith({ BackButton: backButton });
const onBack = vi.fn();
const { rerender, unmount } = render(
<TgWebAppProvider webApp={webApp}>
<BackButtonHarness onBack={onBack} />
</TgWebAppProvider>,
);
expect(backButton.show).toHaveBeenCalled();
const handler = backButton.onClick.mock.calls[0][0] as () => void;
handler();
expect(onBack).toHaveBeenCalledTimes(1);
rerender(
<TgWebAppProvider webApp={webApp}>
<BackButtonHarness onBack={null} />
</TgWebAppProvider>,
);
expect(backButton.hide).toHaveBeenCalled();
unmount();
expect(backButton.offClick).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,115 @@
import { describe, it, expect, vi } from "vitest";
import { applyTelegramTheme, startTelegramThemeSync } from "../theme";
import type { TelegramWebApp } from "../webapp";
function webAppWith(overrides: Partial<TelegramWebApp>): TelegramWebApp {
return {
ready: () => undefined,
expand: () => undefined,
initData: "",
...overrides,
};
}
describe("applyTelegramTheme", () => {
it("toggles the dark class from colorScheme", () => {
const el = document.createElement("div");
applyTelegramTheme(webAppWith({ colorScheme: "dark" }), el);
expect(el.classList.contains("dark")).toBe(true);
applyTelegramTheme(webAppWith({ colorScheme: "light" }), el);
expect(el.classList.contains("dark")).toBe(false);
});
it("maps themeParams onto the panel CSS variables", () => {
const el = document.createElement("div");
applyTelegramTheme(
webAppWith({
themeParams: {
bg_color: "#17212b",
text_color: "#f5f5f5",
hint_color: "#708499",
button_color: "#5288c1",
button_text_color: "#ffffff",
},
}),
el,
);
expect(el.style.getPropertyValue("--background")).toBe("#17212b");
expect(el.style.getPropertyValue("--card")).toBe("#17212b");
expect(el.style.getPropertyValue("--foreground")).toBe("#f5f5f5");
expect(el.style.getPropertyValue("--muted-foreground")).toBe("#708499");
expect(el.style.getPropertyValue("--primary")).toBe("#5288c1");
expect(el.style.getPropertyValue("--primary-foreground")).toBe("#ffffff");
});
it("prefers the more specific section/secondary keys when present", () => {
const el = document.createElement("div");
applyTelegramTheme(
webAppWith({
themeParams: {
bg_color: "#111111",
secondary_bg_color: "#222222",
section_bg_color: "#333333",
},
}),
el,
);
expect(el.style.getPropertyValue("--background")).toBe("#222222");
expect(el.style.getPropertyValue("--card")).toBe("#333333");
});
it("drops non-hex values instead of injecting them into style", () => {
const el = document.createElement("div");
applyTelegramTheme(
webAppWith({
themeParams: {
bg_color: "url(javascript:alert(1))",
text_color: "#abc",
},
}),
el,
);
expect(el.style.getPropertyValue("--background")).toBe("");
expect(el.style.getPropertyValue("--foreground")).toBe("");
});
});
describe("startTelegramThemeSync", () => {
it("applies immediately, re-applies on themeChanged, and unsubscribes on cleanup", () => {
const el = document.createElement("div");
const listeners = new Map<string, () => void>();
const webApp = webAppWith({
colorScheme: "light",
themeParams: { bg_color: "#ffffff" },
onEvent: vi.fn((event: string, cb: () => void) => {
listeners.set(event, cb);
}),
offEvent: vi.fn((event: string) => {
listeners.delete(event);
}),
});
const stop = startTelegramThemeSync(webApp, el);
expect(el.style.getPropertyValue("--background")).toBe("#ffffff");
webApp.colorScheme = "dark";
webApp.themeParams = { bg_color: "#17212b" };
listeners.get("themeChanged")?.();
expect(el.style.getPropertyValue("--background")).toBe("#17212b");
expect(el.classList.contains("dark")).toBe(true);
stop();
expect(webApp.offEvent).toHaveBeenCalledTimes(1);
expect(listeners.has("themeChanged")).toBe(false);
});
it("is a one-shot apply with no-op cleanup when the bridge lacks events", () => {
const el = document.createElement("div");
const stop = startTelegramThemeSync(
webAppWith({ themeParams: { bg_color: "#123456" } }),
el,
);
expect(el.style.getPropertyValue("--background")).toBe("#123456");
expect(stop).not.toThrow();
});
});
+128
View File
@@ -0,0 +1,128 @@
"use client";
/**
* React bindings for the Telegram WebApp bridge. The page bootstraps the
* bridge once (real object or dev mock) and provides it here; components
* reach native chrome (MainButton, BackButton) through these hooks and
* never touch `window.Telegram` directly — that keeps every consumer
* null-safe outside Telegram by construction.
*/
import { createContext, useContext, useEffect, useRef } from "react";
import type { TelegramWebApp } from "./webapp";
const TgWebAppContext = createContext<TelegramWebApp | null>(null);
export function TgWebAppProvider({
webApp,
children,
}: {
webApp: TelegramWebApp | null;
children: React.ReactNode;
}) {
return (
<TgWebAppContext.Provider value={webApp}>
{children}
</TgWebAppContext.Provider>
);
}
/** The bootstrapped bridge, or null when rendered outside the provider
* (tests) or before bootstrap resolves. */
export function useTgWebApp(): TelegramWebApp | null {
return useContext(TgWebAppContext);
}
export interface MainButtonOptions {
text: string;
visible: boolean;
disabled?: boolean;
/** Shows Telegram's spinner on the button while a mutation is in flight. */
loading?: boolean;
onClick: () => void;
}
/**
* Drives Telegram's native bottom action button declaratively. The button is
* global singleton chrome, so exactly one mounted component should own it at
* a time (the focused card, not every card). Hidden + unhooked on unmount.
* No-ops when the bridge (or its MainButton) is absent — callers that need a
* fallback can render their own button when `useTgWebApp()?.MainButton` is
* missing.
*/
export function useMainButton({
text,
visible,
disabled = false,
loading = false,
onClick,
}: MainButtonOptions): void {
const webApp = useTgWebApp();
const mainButton = webApp?.MainButton;
const onClickRef = useRef(onClick);
useEffect(() => {
onClickRef.current = onClick;
}, [onClick]);
useEffect(() => {
if (!mainButton) return;
const handler = () => onClickRef.current();
mainButton.onClick(handler);
return () => {
mainButton.offClick(handler);
mainButton.hide();
};
}, [mainButton]);
useEffect(() => {
if (!mainButton) return;
mainButton.setText(text);
if (disabled) {
mainButton.disable();
} else {
mainButton.enable();
}
if (loading) {
mainButton.showProgress();
} else {
mainButton.hideProgress();
}
if (visible) {
mainButton.show();
} else {
mainButton.hide();
}
}, [mainButton, text, visible, disabled, loading]);
}
/**
* Shows Telegram's native header back button while `onBack` is non-null and
* invokes it on tap. Pass null to hide (e.g. at the root of a card stack).
*/
export function useBackButton(onBack: (() => void) | null): void {
const webApp = useTgWebApp();
const backButton = webApp?.BackButton;
const onBackRef = useRef(onBack);
useEffect(() => {
onBackRef.current = onBack;
}, [onBack]);
useEffect(() => {
if (!backButton) return;
const handler = () => onBackRef.current?.();
backButton.onClick(handler);
return () => {
backButton.offClick(handler);
backButton.hide();
};
}, [backButton]);
useEffect(() => {
if (!backButton) return;
if (onBack) {
backButton.show();
} else {
backButton.hide();
}
}, [backButton, onBack]);
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Telegram theme → panel token bridge.
*
* Maps the launcher's `themeParams` colors onto the shadcn CSS variables the
* whole component library reads, scoped to the `(tg)` shell element only —
* the desktop dashboard keeps its own theme. Later map entries win by
* re-setting the same variable, so a newer, more specific Telegram key
* (e.g. `section_bg_color`) overrides the broader fallback before it.
*/
import type { TelegramThemeParams, TelegramWebApp } from "./webapp";
/** [Telegram key, panel CSS variable] — applied in order. */
const THEME_VAR_MAP: ReadonlyArray<[keyof TelegramThemeParams, string]> = [
["bg_color", "--background"],
["secondary_bg_color", "--background"],
["bg_color", "--card"],
["section_bg_color", "--card"],
["bg_color", "--popover"],
["section_bg_color", "--popover"],
["text_color", "--foreground"],
["text_color", "--card-foreground"],
["text_color", "--popover-foreground"],
["hint_color", "--muted-foreground"],
["subtitle_text_color", "--muted-foreground"],
["button_color", "--primary"],
["button_color", "--ring"],
["button_text_color", "--primary-foreground"],
["destructive_text_color", "--destructive"],
["section_separator_color", "--border"],
];
/** Telegram promises `#rrggbb`; anything else is dropped rather than
* injected into a style attribute (the bridge object is still a trust
* boundary — a malformed value must not become CSS). */
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
/**
* Applies the WebApp's current colorScheme + themeParams to `root`: toggles
* the `.dark` class (so unmapped tokens and `dark:` variants stay coherent)
* and sets every validly-colored mapped variable inline (inline wins over
* both `:root` and `.dark` definitions). Missing/invalid params are simply
* skipped — the panel's own theme shows through, which is the right
* degraded look.
*/
export function applyTelegramTheme(
webApp: TelegramWebApp,
root: HTMLElement,
): void {
root.classList.toggle("dark", webApp.colorScheme === "dark");
const params = webApp.themeParams ?? {};
for (const [key, cssVar] of THEME_VAR_MAP) {
const value = params[key];
if (value && HEX_COLOR.test(value)) {
root.style.setProperty(cssVar, value);
}
}
}
/**
* Applies the theme now and re-applies on every `themeChanged` bridge event
* (the user switching Telegram themes mid-session). Returns a cleanup that
* unsubscribes; safe when the bridge lacks onEvent/offEvent (older clients,
* the dev mock) — then it's a one-shot apply with a no-op cleanup.
*/
export function startTelegramThemeSync(
webApp: TelegramWebApp,
root: HTMLElement,
): () => void {
applyTelegramTheme(webApp, root);
if (!webApp.onEvent || !webApp.offEvent) return () => undefined;
const handler = () => applyTelegramTheme(webApp, root);
webApp.onEvent("themeChanged", handler);
return () => webApp.offEvent?.("themeChanged", handler);
}
+106 -3
View File
@@ -3,11 +3,54 @@
*
* Thin wrapper over the global `window.Telegram.WebApp` object injected by
* https://telegram.org/js/telegram-web-app.js (loaded by the `(tg)` layout).
* Only the handful of fields/methods the cockpit actually needs are typed —
* the real object carries far more (haptics, theme params, main button,
* etc.) that nothing here uses yet.
* Only the fields/methods the cockpit actually uses are typed — the real
* object carries far more.
*/
/** Colors Telegram derives from the user's active Telegram theme. All hex
* (`#rrggbb`), all optional — older clients omit the newer keys. */
export interface TelegramThemeParams {
bg_color?: string;
secondary_bg_color?: string;
section_bg_color?: string;
section_separator_color?: string;
text_color?: string;
hint_color?: string;
subtitle_text_color?: string;
link_color?: string;
accent_text_color?: string;
button_color?: string;
button_text_color?: string;
destructive_text_color?: string;
}
export interface TelegramMainButton {
setText: (text: string) => void;
show: () => void;
hide: () => void;
enable: () => void;
disable: () => void;
showProgress: (leaveActive?: boolean) => void;
hideProgress: () => void;
onClick: (cb: () => void) => void;
offClick: (cb: () => void) => void;
}
export interface TelegramBackButton {
show: () => void;
hide: () => void;
onClick: (cb: () => void) => void;
offClick: (cb: () => void) => void;
}
export interface TelegramHapticFeedback {
impactOccurred: (
style: "light" | "medium" | "heavy" | "rigid" | "soft",
) => void;
notificationOccurred: (type: "error" | "success" | "warning") => void;
selectionChanged: () => void;
}
export interface TelegramWebApp {
/** Signals the Mini App is ready to be displayed — hides Telegram's own
* loading placeholder. Safe to call more than once. */
@@ -19,6 +62,18 @@ export interface TelegramWebApp {
* when the WebApp object exists but wasn't launched with real init data
* (e.g. a bare browser tab pointed at the URL). */
initData: string;
/** "light" | "dark" — tracks the user's Telegram theme. */
colorScheme?: "light" | "dark";
themeParams?: TelegramThemeParams;
/** Subscribe/unsubscribe to bridge events ("themeChanged", …). */
onEvent?: (event: string, cb: () => void) => void;
offEvent?: (event: string, cb: () => void) => void;
/** Bot API 7.7+ — stops vertical swipes from minimizing the app so
* scrolling a list never accidentally dismisses the cockpit. */
disableVerticalSwipes?: () => void;
HapticFeedback?: TelegramHapticFeedback;
MainButton?: TelegramMainButton;
BackButton?: TelegramBackButton;
}
declare global {
@@ -41,6 +96,54 @@ export function getInitData(): string {
return getTelegramWebApp()?.initData ?? "";
}
const DEV_MOCK_MARKER = "__robocoDevMock";
/**
* Dev-only stand-in for the real WebApp object so the cockpit shell renders
* in a plain desktop browser (`pnpm dev` + a normal panel session). Every
* bridge method is a no-op; `initData` is empty and the page skips the
* webapp-auth POST for a mock (see `isDevMockWebApp`), riding the regular
* session cookie instead. The caller gates on NODE_ENV === "development",
* so production builds eliminate the branch entirely.
*/
export function createDevMockWebApp(): TelegramWebApp {
const noop = () => undefined;
const mock: TelegramWebApp & Record<string, unknown> = {
ready: noop,
expand: noop,
initData: "",
colorScheme: window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light",
themeParams: {},
[DEV_MOCK_MARKER]: true,
};
return mock;
}
/** True for objects minted by `createDevMockWebApp` — never for the real
* bridge, whose surface Telegram controls. */
export function isDevMockWebApp(webApp: TelegramWebApp): boolean {
return DEV_MOCK_MARKER in webApp;
}
/** Null-safe haptic feedback — silently no-ops outside Telegram (and in the
* dev mock, which has no HapticFeedback object). */
export const haptics = {
/** Light tap for selections/navigation. */
tap(): void {
getTelegramWebApp()?.HapticFeedback?.impactOccurred("light");
},
/** Success notification pulse after a mutation lands. */
success(): void {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred("success");
},
/** Error notification pulse after a mutation fails. */
error(): void {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred("error");
},
};
const POLL_INTERVAL_MS = 100;
/**