Skill: zenith2828/tg-miniapp (version 1.0.1)

--- name: tg-miniapp description: Build Telegram Mini Apps without the pain. Includes solutions for safe areas, fullscreen mode, BackButton handlers, sharing with inline mode, position:fixed issues, and React gotchas. Use when building or debugging Telegram Mini Apps, or when encountering issues with WebApp API, safe areas, or sharing. --- # Telegram Mini App Development Battle-tested solutions for common Telegram Mini App issues. ## Quick Reference ### Safe Area (Fullscreen Mode) ```typescript // Use reactive hook - values are async and context-dependent const safeArea = useSafeAreaInset(); // from references/hooks.ts <div style={{ paddingTop: safeArea.top }}>Header</div> ``` ### position:fixed Broken Telegram applies `transform` to container. Use `createPortal`: ```tsx import { createPortal } from 'react-dom'; createPortal(<Modal />, document.body); ``` ### BackButton Not Firing Use `@telegram-apps/sdk`, not raw WebApp: ```typescript import { onBackButtonClick, showBackButton } from '@telegram-apps/sdk'; onBackButtonClick(handleBack); ``` ### Sharing with Inline Mode 1. Enable inline mode: @BotFather → `/setinline` → select bot 2. Backend calls `savePreparedInlineMessage` → returns `prepared_message_id` 3. Frontend calls `WebApp.shareMessage(prepared_message_id)` **Note:** `prepared_message_id` is **single-use** — prepare fresh for each share tap. For static content, consider caching images on R2/CDN and preparing per-tap. ### React "0" Rendering ```tsx // WRONG: renders literal "0" {count && <span>{count}</span>} // CORRECT {count > 0 && <span>{count}</span>} ``` ## Detailed Guides - **[references/KNOWLEDGE.md](references/KNOWLEDGE.md)** — Full knowledge base with all gotchas and solutions - **[references/hooks.ts](references/hooks.ts)** — Copy-paste React hooks (useSafeAreaInset, useFullscreen, etc.) - **[references/components.tsx](references/components.tsx)** — Ready-to-use components (SafeAreaHeader, DebugOverlay) ## Testing Checklist Before shipping, test: - [ ] Open from folder (chat list) - [ ] Open from direct bot chat - [ ] iOS device - [ ] Android device - [ ] Share flow (tap → dismiss → tap again) - [ ] Share to different chat types (user, group, channel) - [ ] Back button - [ ] Scroll with sticky header

path: references/KNOWLEDGE.md
content: # Telegram Mini App Knowledge Base

Everything we've learned building Telegram Mini Apps. Read this before starting a new project or debugging issues.

---

## 🔴 Critical Issues (Will Bite You)

### 1. Safe Area / Fullscreen Mode
**The Problem:** In fullscreen mode, Telegram overlays its controls (X Close, chevron) on top of your app. Content gets hidden behind them.

**Why It's Tricky:**
- `safeAreaInset` and `contentSafeAreaInset` can return 0 initially
- Values differ based on how app is opened (folder vs direct chat)
- Values are async — can change after initial render
- iOS and Android have different safe areas

**The Solution:**
```typescript
// Use reactive hook, not one-time check
function useSafeAreaInset() {
  const [insets, setInsets] = useState({ top: 0, bottom: 0 });

  useEffect(() => {
    const webApp = window.Telegram?.WebApp;
    if (!webApp) return;

    const update = () => {
      const safeArea = webApp.safeAreaInset || { top: 0 };
      const contentSafeArea = webApp.contentSafeAreaInset || { top: 0 };
      const isFullscreen = webApp.isFullscreen;
      
      let top = Math.max(safeArea.top || 0, contentSafeArea.top || 0);
      
      // Minimum fallbacks when in fullscreen but values are low
      if (isFullscreen && top < 80) {
        top = webApp.platform === 'ios' ? 100 : 80;
      }
      
      setInsets({ top, bottom: safeArea.bottom || 0 });
    };

    update();
    
    // Listen for changes
    webApp.onEvent?.('safeAreaChanged', update);
    webApp.onEvent?.('fullscreenChanged', update);
    
    // Fallback: poll every 500ms
    const interval = setInterval(update, 500);
    return () => clearInterval(interval);
  }, []);

  return insets;
}
```

**Sticky Headers:**
```tsx
// WRONG - content shows through gap
<div className="sticky top-0">Header</div>

// CORRECT - background covers full safe area
<div 
  className="sticky top-0 bg-[var(--tg-theme-bg-color)]"
  style={{ paddingTop: safeAreaInset.top }}
>
  Header
</div>
```

---

### 2. position: fixed Doesn't Work
**The Problem:** Telegram Mini Apps apply CSS `transform` to the container, which breaks `position: fixed` elements.

**Symptoms:** Bottom sheets, modals, tooltips render in wrong position.

**The Solution:** Use React `createPortal` to render to `document.body`:
```tsx
import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(
    <div className="fixed inset-0 z-[9999]">{children}</div>,
    document.body
  );
}
```

---

### 3. React Renders "0" as Text
**The Problem:** `{value && value > 0 && <Component />}` renders literal "0" when value is 0.

**The Solution:**
```tsx
// WRONG
{count && count > 0 && <span>{count}</span>}

// CORRECT
{count != null && count > 0 && <span>{count}</span>}
// OR
{count > 0 && <span>{count}</span>}
// OR
{!!count && <span>{count}</span>}
```

---

### 4. BackButton Click Handler Doesn't Fire
**The Problem:** Telegram BackButton appears but clicking does nothing.

**Why:** Using raw `window.Telegram.WebApp.BackButton` with stale closures.

**The Solution:** Use `@telegram-apps/sdk`:
```typescript
import { 
  mountBackButton, 
  showBackButton, 
  hideBackButton, 
  onBackButtonClick, 
  offBackButtonClick 
} from '@telegram-apps/sdk';

// Mount once at app init
mountBackButton();

// In component
useEffect(() => {
  if (shouldShowBack) {
    showBackButton();
    onBackButtonClick(handleBack);
  } else {
    hideBackButton();
  }
  
  return () => offBackButtonClick(handleBack);
}, [shouldShowBack, handleBack]);
```

---

### 5. Sharing with Inline Mode
**The Problem:** `WebApp.shareMessage()` fails with cryptic errors.

**Requirements:**
1. Bot must have **inline mode enabled** via @BotFather (`/setinline`)
2. Must call `savePreparedInlineMessage` on backend first
3. Pass the `prepared_message_id` to `shareMessage()`

**Basic Flow:**
```
Frontend → POST /api/prepare-share → Backend calls savePreparedInlineMessage
                                   ← Returns prepared_message_id
Frontend → WebApp.shareMessage(id) → Native share picker
```

**Key Insight: prepared_message_id is Single-Use**
Once a `prepared_message_id` is consumed by `shareMessage()` (whether sent or dismissed), it cannot be reused. This affects UX:
- User taps share → prepares message → opens picker → dismisses → taps share again → **fails**
- Need to prepare a fresh message for each share attempt

**Two Approaches:**

**A) Dynamic Preparation (per-tap)**
Prepare a fresh message every time user taps share:
```typescript
const handleShare = async () => {
  const { prepared_message_id } = await fetch('/api/prepare-share').then(r => r.json());
  await WebApp.shareMessage(prepared_message_id);
};
```
- ✅ Always works, each tap gets fresh ID
- ⚠️ More API calls
- ⚠️ Slight delay before picker opens

**B) Static Content Caching (recommended for frequent shares)**
Use static inline results with `allow_user_chats: true`. The content stays the same, so you can cache results:
```typescript
// Backend: savePreparedInlineMessage with static result
const result = {
  type: 'photo',
  photo_url: 'https://cdn.example.com/static-card.jpg', // Same URL always
  thumbnail_url: ...,
};
const { prepared_message_id } = await bot.savePreparedInlineMessage(userId, result, {
  allow_user_chats: true,
  allow_bot_chats: true,
  allow_group_chats: true,
  allow_channel_chats: true,
});

// Frontend: prepare once, reuse pattern
const handleShare = async () => {
  // For static content, prepare fresh each time anyway (IDs are single-use)
  const { prepared_message_id } = await fetch('/api/prepare-share-static').then(r => r.json());
  await WebApp.shareMessage(prepared_message_id);
};
```
- ✅ Works reliably for multi-share scenarios
- ✅ Image can be cached on CDN
- ⚠️ Content is same for all shares of that item

**Fallback chain:**
1. Try native `shareMessage` (requires inline mode)
2. Try sending image to bot chat (user forwards manually)
3. Fall back to text share via `web_app_open_tg_link`

**Known Behaviors (2026-02):**
- `shareMessage` requires **WebApp 8.0+** — check version before calling
- **PNG actually works** — Despite docs suggesting JPEG/GIF, PNG renders fine in inline results
- **Callback returns falsy even on success** — use truthy check `if (sent)` not `=== true`
- **JPEG recommended for share cards** — Smaller file size, faster loading in chat
- **photo_url must be publicly accessible** — Use R2 public bucket or similar CDN

**Two-Button Pattern (ClawdFessions approach):**
For apps where you want both quick sharing AND rich custom cards:
```tsx
<div className="flex gap-1">
  {/* Quick Share - uses static prepared message, opens native picker */}
  <button onClick={handleQuickShare}>⚡</button>
  
  {/* Share Card - sends rich image to bot chat, user forwards manually */}
  <button onClick={handleShareCard}>🎨</button>
</div>
```
- Quick Share: Native picker, faster, less friction
- Share Card: Custom generated image, richer but requires manual forward

---

### 6. Generating Share Cards (Server-Side Images)
**The Problem:** You want custom share card images generated dynamically per item.

**Solution: resvg-wasm in Cloudflare Workers**
Generate SVG → convert to PNG/JPEG server-side:
```typescript
import { Resvg, initWasm } from '@resvg/resvg-wasm';
import resvgWasm from './resvg.wasm';

let initialized = false;
async function ensureWasm() {
  if (!initialized) {
    await initWasm(resvgWasm);
    initialized = true;
  }
}

async function svgToJpeg(svg: string): Promise<Uint8Array> {
  await ensureWasm();
  const resvg = new Resvg(svg, {
    fitTo: { mode: 'width', value: 800 },
    font: {
      fontBuffers: [fontData], // Bundle your fonts
      defaultFontFamily: 'Inter',
    },
  });
  const pixels = resvg.render();
  return encodeJpeg(pixels); // Use jpeg-js or similar
}
```

**Tips:**
- Bundle fonts as base64 or binary — Google Fonts won't load at runtime
- Cache generated images in R2 with content hash keys
- 800px width is good for Telegram inline results
- JPEG for photos, PNG if you need transparency

**R2 Caching Pattern:**
```typescript
// Check cache first
const cacheKey = `cards/${confessionId}.jpg`;
const cached = await env.CARDS.get(cacheKey);
if (cached) return new Response(cached.body, { headers: { 'Content-Type': 'image/jpeg' } });

// Generate and cache
const jpeg = await generateCard(confession);
await env.CARDS.put(cacheKey, jpeg, { httpMetadata: { contentType: 'image/jpeg' } });
return new Response(jpeg, { headers: { 'Content-Type': 'image/jpeg' } });
```

---

## 🟡 Common Gotchas

### API Image Paths
Different APIs return images in different paths. Always use fallback chains:
```typescript
const imageUrl = item.preview?.image256 
  || item.collection?.preview?.image256 
  || item.image 
  || '/placeholder.png';
```

### GetGems API
- Only supports `limit`, **no pagination/offset**
- Rate limit: 400 requests / 5 minutes per IP
- Volume data is **all-time**, not 24h
- USDT listings exist — detect by token address

### Fragment API
- Number images: `https://nft.fragment.com/number/{11-digit-number}.webp`
- Username images: via separate API call

### Theme Colors
Use CSS variables for Telegram theme integration:
```css
background: var(--tg-theme-bg-color, #0f0f1a);
color: var(--tg-theme-text-color, #fff);
```

---

## 🟢 Best Practices

### Debug Overlay
Add a dev-only panel showing platform info:
```tsx
function DebugOverlay() {
  if (!isDev) return null;
  
  const webApp = window.Telegram?.WebApp;
  return (
    <div className="fixed bottom-4 right-4 bg-black/80 p-2 text-xs">
      <div>Platform: {webApp?.platform}</div>
      <div>Fullscreen: {webApp?.isFullscreen ? 'Y' : 'N'}</div>
      <div>Safe top: {webApp?.safeAreaInset?.top}</div>
      <div>Content safe top: {webApp?.contentSafeAreaInset?.top}</div>
    </div>
  );
}
```

### Testing Checklist
- [ ] Open from folder (chat list)
- [ ] Open from direct chat
- [ ] Open from inline button
- [ ] Test on iOS
- [ ] Test on Android
- [ ] Test share flow
- [ ] Test back button
- [ ] Scroll with sticky header

### Deployment
```bash
# Always staging first
npx wrangler pages deploy dist --project-name myapp-staging

# Production only after testing
npx wrangler pages deploy dist --project-name myapp
```

---

## 📁 Component Library

Reusable components at `~/clawd/projects/tg-miniapp-components/`:
- `useSafeAreaInset()` — Reactive safe area hook
- `useFullscreen()` — Fullscreen state + control
- `SafeAreaHeader` — Sticky header with safe area handling
- `DebugOverlay` — Dev panel for debugging

---

## 📚 Resources

- [Telegram Mini Apps Docs](https://core.telegram.org/bots/webapps)
- [@telegram-apps/sdk](https://github.com/Telegram-Mini-Apps/telegram-apps)
- Project PLAYBOOK files contain project-specific learnings

---

*Last updated: 2026-02-02*
*Compiled from sessions: 2026-01-27 through 2026-02-02*


path: references/components.tsx
content: /**
 * Telegram Mini App React Components
 * Copy-paste into your project
 * 
 * Requires: hooks.ts from this same skill
 */

import React, { useState, useCallback, useEffect, ReactNode, CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { useSafeAreaInset, useFullscreen, useTelegramTheme, getWebApp, isDevMode } from './hooks';

// ============================================================================
// SafeAreaHeader
// ============================================================================

interface SafeAreaHeaderProps {
  children: ReactNode;
  className?: string;
  style?: CSSProperties;
  transparent?: boolean;
  backgroundColor?: string;
  position?: 'fixed' | 'sticky';
  blur?: boolean;
  height?: number;
  border?: boolean;
  zIndex?: number;
}

/**
 * Header with proper safe area handling
 * 
 * @example
 * <SafeAreaHeader blur border>
 *   <h1>My App</h1>
 * </SafeAreaHeader>
 */
export function SafeAreaHeader({
  children,
  className = '',
  style = {},
  transparent = false,
  backgroundColor,
  position = 'sticky',
  blur = false,
  height = 56,
  border = false,
  zIndex = 1000,
}: SafeAreaHeaderProps) {
  const { totalTop } = useSafeAreaInset();
  const { params, isDark } = useTelegramTheme();

  const bgColor = backgroundColor 
    ?? (transparent ? 'transparent' : (params.bg_color ?? '#0f0f1a'));

  const headerStyle: CSSProperties = {
    position,
    top: 0,
    left: 0,
    right: 0,
    zIndex,
    paddingTop: totalTop,
    backgroundColor: blur ? `${bgColor}cc` : bgColor,
    backdropFilter: blur ? 'blur(20px)' : undefined,
    WebkitBackdropFilter: blur ? 'blur(20px)' : undefined,
    borderBottom: border 
      ? `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`
      : undefined,
    ...style,
  };

  const contentStyle: CSSProperties = {
    height,
    display: 'flex',
    alignItems: 'center',
    paddingLeft: 16,
    paddingRight: 16,
  };

  return (
    <header style={headerStyle} className={className}>
      <div style={contentStyle}>{children}</div>
    </header>
  );
}

/**
 * Spacer for fixed SafeAreaHeader
 */
export function SafeAreaHeaderSpacer({ height = 56 }: { height?: number }) {
  const { totalTop } = useSafeAreaInset();
  return <div style={{ height: totalTop + height }} />;
}

// ============================================================================
// DebugOverlay
// ============================================================================

interface DebugOverlayProps {
  forceShow?: boolean;
  defaultOpen?: boolean;
  position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
}

/**
 * Debug panel showing safe areas, viewport, platform info
 * Only visible on localhost or with ?debug=1
 * 
 * @example
 * <DebugOverlay /> // Add to app root
 */
export function DebugOverlay({
  forceShow = false,
  defaultOpen = false,
  position = 'bottom-right',
}: DebugOverlayProps) {
  const [isOpen, setIsOpen] = useState(defaultOpen);
  const [copiedKey, setCopiedKey] = useState<string | null>(null);
  
  const insets = useSafeAreaInset();
  const { isFullscreen } = useFullscreen();

  if (!forceShow && !isDevMode()) return null;

  const webApp = getWebApp();
  
  const values = [
    { label: 'Platform', value: webApp?.platform ?? 'unknown' },
    { label: 'Version', value: webApp?.version ?? 'N/A' },
    { label: 'Fullscreen', value: isFullscreen ? '✓' : '✗' },
    { label: 'Viewport', value: `${window.innerWidth}×${webApp?.viewportHeight ?? window.innerHeight}` },
    { label: 'Safe Top', value: insets.device.top },
    { label: 'Safe Bottom', value: insets.device.bottom },
    { label: 'Content Top', value: insets.content.top },
    { label: 'Content Bottom', value: insets.content.bottom },
    { label: '⚡ Total Top', value: insets.totalTop },
    { label: '⚡ Total Bottom', value: insets.totalBottom },
  ];

  const handleCopy = async (label: string, value: any) => {
    await navigator.clipboard.writeText(String(value));
    setCopiedKey(label);
    setTimeout(() => setCopiedKey(null), 1000);
  };

  const positionStyle: CSSProperties = {
    'top-left': { top: 8, left: 8 },
    'top-right': { top: 8, right: 8 },
    'bottom-left': { bottom: 8, left: 8 },
    'bottom-right': { bottom: 8, right: 8 },
  }[position];

  return createPortal(
    <>
      <button
        onClick={() => setIsOpen(!isOpen)}
        style={{
          position: 'fixed',
          ...positionStyle,
          zIndex: 100000,
          width: 44,
          height: 44,
          borderRadius: '50%',
          border: 'none',
          backgroundColor: isOpen ? '#ef4444' : 'rgba(0,0,0,0.7)',
          color: 'white',
          fontSize: 20,
          cursor: 'pointer',
        }}
      >
        {isOpen ? '✕' : '🐛'}
      </button>

      {isOpen && (
        <div
          style={{
            position: 'fixed',
            ...positionStyle,
            [position.includes('right') ? 'right' : 'left']: 60,
            zIndex: 99999,
            backgroundColor: 'rgba(0,0,0,0.9)',
            color: '#fff',
            borderRadius: 12,
            padding: 12,
            fontSize: 12,
            fontFamily: 'monospace',
            minWidth: 180,
          }}
        >
          <div style={{ fontWeight: 600, marginBottom: 8, opacity: 0.7 }}>TG DEBUG</div>
          {values.map(({ label, value }) => (
            <div
              key={label}
              onClick={() => handleCopy(label, value)}
              style={{
                display: 'flex',
                justifyContent: 'space-between',
                padding: '4px 6px',
                cursor: 'pointer',
                backgroundColor: copiedKey === label ? 'rgba(34,197,94,0.3)' : 'transparent',
                borderRadius: 4,
              }}
            >
              <span style={{ opacity: 0.7 }}>{label}</span>
              <span>{value}</span>
            </div>
          ))}
          <div style={{ marginTop: 8, opacity: 0.5, fontSize: 10, textAlign: 'center' }}>
            Tap to copy
          </div>
        </div>
      )}
    </>,
    document.body
  );
}

// ============================================================================
// Modal (uses createPortal to fix position:fixed issue)
// ============================================================================

interface ModalProps {
  children: ReactNode;
  isOpen: boolean;
  onClose: () => void;
  zIndex?: number;
}

/**
 * Modal that works in Telegram Mini Apps
 * Uses createPortal to avoid position:fixed issues
 * 
 * @example
 * <Modal isOpen={show} onClose={() => setShow(false)}>
 *   <div>Modal content</div>
 * </Modal>
 */
export function Modal({ children, isOpen, onClose, zIndex = 9999 }: ModalProps) {
  const { totalBottom } = useSafeAreaInset();

  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden';
      return () => { document.body.style.overflow = ''; };
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return createPortal(
    <div
      style={{
        position: 'fixed',
        inset: 0,
        zIndex,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: 'rgba(0,0,0,0.5)',
        paddingBottom: totalBottom,
      }}
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
    >
      {children}
    </div>,
    document.body
  );
}


path: references/hooks.ts
content: /**
 * Telegram Mini App React Hooks
 * Copy-paste into your project
 */

import { useState, useEffect, useCallback } from 'react';

// ============================================================================
// Types
// ============================================================================

interface SafeAreaInset {
  top: number;
  bottom: number;
  left: number;
  right: number;
}

interface TelegramWebApp {
  version: string;
  platform: string;
  isExpanded: boolean;
  viewportHeight: number;
  viewportStableHeight: number;
  isFullscreen: boolean;
  safeAreaInset: SafeAreaInset;
  contentSafeAreaInset: SafeAreaInset;
  themeParams: Record<string, string>;
  colorScheme: 'light' | 'dark';
  requestFullscreen: () => void;
  exitFullscreen: () => void;
  onEvent: (eventType: string, callback: () => void) => void;
  offEvent: (eventType: string, callback: () => void) => void;
}

declare global {
  interface Window {
    Telegram?: { WebApp?: TelegramWebApp };
  }
}

// ============================================================================
// Utilities
// ============================================================================

export function getWebApp(): TelegramWebApp | null {
  if (typeof window === 'undefined') return null;
  return window.Telegram?.WebApp ?? null;
}

export function isDevMode(): boolean {
  if (typeof window === 'undefined') return false;
  const url = new URL(window.location.href);
  return ['localhost', '127.0.0.1'].includes(url.hostname) || url.searchParams.get('debug') === '1';
}

// ============================================================================
// useSafeAreaInset
// ============================================================================

export interface SafeAreaInsets {
  device: SafeAreaInset;
  content: SafeAreaInset;
  totalTop: number;
  totalBottom: number;
}

/**
 * Reactive safe area insets - updates when Telegram sends events
 * 
 * @example
 * const { totalTop, totalBottom } = useSafeAreaInset();
 * <div style={{ paddingTop: totalTop }}>Header</div>
 */
export function useSafeAreaInset(): SafeAreaInsets {
  const [insets, setInsets] = useState<SafeAreaInsets>(() => {
    const webApp = getWebApp();
    const device = webApp?.safeAreaInset ?? { top: 0, bottom: 0, left: 0, right: 0 };
    const content = webApp?.contentSafeAreaInset ?? { top: 0, bottom: 0, left: 0, right: 0 };
    return {
      device,
      content,
      totalTop: device.top + content.top,
      totalBottom: device.bottom + content.bottom,
    };
  });

  const updateInsets = useCallback(() => {
    const webApp = getWebApp();
    const device = webApp?.safeAreaInset ?? { top: 0, bottom: 0, left: 0, right: 0 };
    const content = webApp?.contentSafeAreaInset ?? { top: 0, bottom: 0, left: 0, right: 0 };
    
    // Add minimum fallbacks for fullscreen mode
    let totalTop = device.top + content.top;
    if (webApp?.isFullscreen && totalTop < 80) {
      totalTop = webApp.platform === 'ios' ? 100 : 80;
    }
    
    setInsets({
      device,
      content,
      totalTop,
      totalBottom: device.bottom + content.bottom,
    });
  }, []);

  useEffect(() => {
    const webApp = getWebApp();
    if (!webApp) return;

    updateInsets();

    webApp.onEvent('safeAreaChanged', updateInsets);
    webApp.onEvent('contentSafeAreaChanged', updateInsets);
    webApp.onEvent('fullscreenChanged', updateInsets);

    // Fallback: poll every 500ms (some events may not fire)
    const interval = setInterval(updateInsets, 500);

    return () => {
      webApp.offEvent('safeAreaChanged', updateInsets);
      webApp.offEvent('contentSafeAreaChanged', updateInsets);
      webApp.offEvent('fullscreenChanged', updateInsets);
      clearInterval(interval);
    };
  }, [updateInsets]);

  return insets;
}

// ============================================================================
// useFullscreen
// ============================================================================

export interface FullscreenState {
  isFullscreen: boolean;
  isSupported: boolean;
  requestFullscreen: () => void;
  exitFullscreen: () => void;
  toggleFullscreen: () => void;
}

/**
 * Manage Telegram Mini App fullscreen mode (requires version 8.0+)
 * 
 * @example
 * const { isFullscreen, toggleFullscreen } = useFullscreen();
 * <button onClick={toggleFullscreen}>{isFullscreen ? 'Exit' : 'Fullscreen'}</button>
 */
export function useFullscreen(): FullscreenState {
  const [isFullscreen, setIsFullscreen] = useState(() => {
    return getWebApp()?.isFullscreen ?? false;
  });

  const isSupported = (() => {
    const webApp = getWebApp();
    if (!webApp) return false;
    return parseFloat(webApp.version) >= 8.0;
  })();

  const requestFullscreen = useCallback(() => {
    getWebApp()?.requestFullscreen?.();
  }, []);

  const exitFullscreen = useCallback(() => {
    getWebApp()?.exitFullscreen?.();
  }, []);

  const toggleFullscreen = useCallback(() => {
    if (isFullscreen) exitFullscreen();
    else requestFullscreen();
  }, [isFullscreen, requestFullscreen, exitFullscreen]);

  useEffect(() => {
    const webApp = getWebApp();
    if (!webApp) return;

    const handleChange = () => setIsFullscreen(webApp.isFullscreen);

    webApp.onEvent('fullscreenChanged', handleChange);
    webApp.onEvent('fullscreenFailed', handleChange);

    return () => {
      webApp.offEvent('fullscreenChanged', handleChange);
      webApp.offEvent('fullscreenFailed', handleChange);
    };
  }, []);

  return { isFullscreen, isSupported, requestFullscreen, exitFullscreen, toggleFullscreen };
}

// ============================================================================
// useTelegramTheme
// ============================================================================

export interface TelegramTheme {
  params: Record<string, string>;
  colorScheme: 'light' | 'dark';
  isDark: boolean;
}

/**
 * Reactive Telegram theme params
 * 
 * @example
 * const { params, isDark } = useTelegramTheme();
 * <div style={{ background: params.bg_color }}>...</div>
 */
export function useTelegramTheme(): TelegramTheme {
  const [theme, setTheme] = useState<TelegramTheme>(() => {
    const webApp = getWebApp();
    return {
      params: webApp?.themeParams ?? {},
      colorScheme: webApp?.colorScheme ?? 'light',
      isDark: webApp?.colorScheme === 'dark',
    };
  });

  useEffect(() => {
    const webApp = getWebApp();
    if (!webApp) return;

    const handleChange = () => {
      setTheme({
        params: webApp.themeParams,
        colorScheme: webApp.colorScheme,
        isDark: webApp.colorScheme === 'dark',
      });
    };

    webApp.onEvent('themeChanged', handleChange);
    return () => webApp.offEvent('themeChanged', handleChange);
  }, []);

  return theme;
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)