Files
roboco/panel/src/lib/telegram/hooks.tsx
T
Renn F ae21874817 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.
2026-07-19 00:22:12 +02:00

129 lines
3.3 KiB
TypeScript

"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]);
}