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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user