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
|
* `afterInteractive` strategy instead — `waitForTelegramWebApp` (in
|
||||||
* lib/telegram/webapp.ts) briefly polls for `window.Telegram.WebApp` to
|
* lib/telegram/webapp.ts) briefly polls for `window.Telegram.WebApp` to
|
||||||
* absorb the resulting load race rather than assuming it's present on mount.
|
* 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({
|
export default function TelegramLayout({
|
||||||
children,
|
children,
|
||||||
@@ -18,7 +24,11 @@ export default function TelegramLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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
|
<Script
|
||||||
src="https://telegram.org/js/telegram-web-app.js"
|
src="https://telegram.org/js/telegram-web-app.js"
|
||||||
strategy="afterInteractive"
|
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";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
const { waitForTelegramWebApp } = vi.hoisted(() => ({
|
const { waitForTelegramWebApp } = vi.hoisted(() => ({
|
||||||
waitForTelegramWebApp: vi.fn(),
|
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() }));
|
const { post } = vi.hoisted(() => ({ post: vi.fn() }));
|
||||||
vi.mock("@/lib/api/client", () => ({
|
vi.mock("@/lib/api/client", () => ({
|
||||||
@@ -35,15 +45,25 @@ vi.mock("@/components/tg/tg-chat-tab", () => ({
|
|||||||
import TelegramMiniAppPage from "../page";
|
import TelegramMiniAppPage from "../page";
|
||||||
|
|
||||||
function mockWebApp(initData = "abc123") {
|
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", () => {
|
describe("TelegramMiniAppPage — auth bootstrap", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
waitForTelegramWebApp.mockReset();
|
waitForTelegramWebApp.mockReset();
|
||||||
|
startTelegramThemeSync.mockClear();
|
||||||
post.mockReset();
|
post.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a spinner while validating", () => {
|
it("shows a spinner while validating", () => {
|
||||||
waitForTelegramWebApp.mockReturnValue(new Promise(() => {}));
|
waitForTelegramWebApp.mockReturnValue(new Promise(() => {}));
|
||||||
render(<TelegramMiniAppPage />);
|
render(<TelegramMiniAppPage />);
|
||||||
@@ -72,6 +92,7 @@ describe("TelegramMiniAppPage — auth bootstrap", () => {
|
|||||||
);
|
);
|
||||||
expect(webApp.ready).toHaveBeenCalledTimes(1);
|
expect(webApp.ready).toHaveBeenCalledTimes(1);
|
||||||
expect(webApp.expand).toHaveBeenCalledTimes(1);
|
expect(webApp.expand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(webApp.disableVerticalSwipes).toHaveBeenCalledTimes(1);
|
||||||
expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", {
|
expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", {
|
||||||
init_data: "real-init-data",
|
init_data: "real-init-data",
|
||||||
});
|
});
|
||||||
@@ -91,4 +112,38 @@ describe("TelegramMiniAppPage — auth bootstrap", () => {
|
|||||||
expect(screen.getByText("Mini App disabled")).toBeInTheDocument();
|
expect(screen.getByText("Mini App disabled")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("tg-tab-bar")).not.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 { useEffect, useState } from "react";
|
||||||
import api, { getErrorMessage } from "@/lib/api/client";
|
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 { TgTabBar, type TgTab } from "@/components/tg/tg-tab-bar";
|
||||||
import { TgApprovalsTab } from "@/components/tg/tg-approvals-tab";
|
import { TgApprovalsTab } from "@/components/tg/tg-approvals-tab";
|
||||||
import { TgInboxTab } from "@/components/tg/tg-inbox-tab";
|
import { TgInboxTab } from "@/components/tg/tg-inbox-tab";
|
||||||
@@ -12,7 +19,7 @@ import { Loader2, AlertTriangle, ExternalLink } from "lucide-react";
|
|||||||
|
|
||||||
type BootstrapState =
|
type BootstrapState =
|
||||||
| { kind: "validating" }
|
| { kind: "validating" }
|
||||||
| { kind: "ready" }
|
| { kind: "ready"; webApp: TelegramWebApp }
|
||||||
| { kind: "not_in_telegram" }
|
| { kind: "not_in_telegram" }
|
||||||
| { kind: "error"; message: string };
|
| { 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
|
* 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
|
* 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.
|
* 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() {
|
export default function TelegramMiniAppPage() {
|
||||||
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
|
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
|
||||||
@@ -39,19 +51,27 @@ export default function TelegramMiniAppPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const webApp = await waitForTelegramWebApp();
|
let webApp = await waitForTelegramWebApp();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (!webApp && process.env.NODE_ENV === "development") {
|
||||||
|
webApp = createDevMockWebApp();
|
||||||
|
}
|
||||||
if (!webApp) {
|
if (!webApp) {
|
||||||
setState({ kind: "not_in_telegram" });
|
setState({ kind: "not_in_telegram" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
webApp.ready();
|
webApp.ready();
|
||||||
webApp.expand();
|
webApp.expand();
|
||||||
|
webApp.disableVerticalSwipes?.();
|
||||||
|
if (isDevMockWebApp(webApp)) {
|
||||||
|
setState({ kind: "ready", webApp });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.post("/telegram/webapp-auth", {
|
await api.post("/telegram/webapp-auth", {
|
||||||
init_data: webApp.initData ?? "",
|
init_data: webApp.initData ?? "",
|
||||||
});
|
});
|
||||||
if (!cancelled) setState({ kind: "ready" });
|
if (!cancelled) setState({ kind: "ready", webApp });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setState({ kind: "error", message: getErrorMessage(err) });
|
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") {
|
if (state.kind === "validating") {
|
||||||
return (
|
return (
|
||||||
<CenteredMessage>
|
<CenteredMessage>
|
||||||
@@ -96,12 +127,14 @@ export default function TelegramMiniAppPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 pb-20">
|
<TgWebAppProvider webApp={state.webApp}>
|
||||||
{tab === "approvals" && <TgApprovalsTab />}
|
<div className="p-3 pb-20">
|
||||||
{tab === "inbox" && <TgInboxTab />}
|
{tab === "approvals" && <TgApprovalsTab />}
|
||||||
{tab === "board" && <TgBoardTab />}
|
{tab === "inbox" && <TgInboxTab />}
|
||||||
{tab === "chat" && <TgChatTab />}
|
{tab === "board" && <TgBoardTab />}
|
||||||
<TgTabBar active={tab} onChange={setTab} />
|
{tab === "chat" && <TgChatTab />}
|
||||||
</div>
|
<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
|
* Thin wrapper over the global `window.Telegram.WebApp` object injected by
|
||||||
* https://telegram.org/js/telegram-web-app.js (loaded by the `(tg)` layout).
|
* 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 —
|
* Only the fields/methods the cockpit actually uses are typed — the real
|
||||||
* the real object carries far more (haptics, theme params, main button,
|
* object carries far more.
|
||||||
* etc.) that nothing here uses yet.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 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 {
|
export interface TelegramWebApp {
|
||||||
/** Signals the Mini App is ready to be displayed — hides Telegram's own
|
/** Signals the Mini App is ready to be displayed — hides Telegram's own
|
||||||
* loading placeholder. Safe to call more than once. */
|
* 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
|
* when the WebApp object exists but wasn't launched with real init data
|
||||||
* (e.g. a bare browser tab pointed at the URL). */
|
* (e.g. a bare browser tab pointed at the URL). */
|
||||||
initData: string;
|
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 {
|
declare global {
|
||||||
@@ -41,6 +96,54 @@ export function getInitData(): string {
|
|||||||
return getTelegramWebApp()?.initData ?? "";
|
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;
|
const POLL_INTERVAL_MS = 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user