mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user