feat(web): implement conditional Telegram SDK loading

Add lazy loading of Telegram SDK only when running inside Telegram Mini App
environment instead of unconditionally loading in all browsers. Includes:

- New environment detection functions: isTelegramEnvironment(), isTelegramApp()
- Dynamic SDK loading with 3s timeout via loadTelegramSdk()
- Removed static script tag from index.html
- Made haptic feedback lazy (SDK check on each call)
- Made theme listeners lazy (attached in initializeTheme())
- Updated all components to use unified detection functions

This prevents SDK load time overhead in regular browser environments while
ensuring the app works correctly both in Telegram and browser contexts.
This commit is contained in:
weishu
2025-12-18 22:54:40 +08:00
parent 2a097d0cdf
commit 4606ba23d9
8 changed files with 167 additions and 105 deletions
-1
View File
@@ -33,7 +33,6 @@
} }
} }
</script> </script>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4 -4
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getTelegramWebApp } from '@/hooks/useTelegram' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram'
import { initializeTheme } from '@/hooks/useTheme' import { initializeTheme } from '@/hooks/useTheme'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { useAuthSource } from '@/hooks/useAuthSource' import { useAuthSource } from '@/hooks/useAuthSource'
@@ -138,7 +138,7 @@ export function App() {
// Navigate and sync browser history (browser only, not Telegram) // Navigate and sync browser history (browser only, not Telegram)
const navigateTo = useCallback((newScreen: Screen) => { const navigateTo = useCallback((newScreen: Screen) => {
setScreen(newScreen) setScreen(newScreen)
if (!getTelegramWebApp()?.initData) { if (!isTelegramApp()) {
history.pushState({ screen: newScreen }, '') history.pushState({ screen: newScreen }, '')
} }
}, []) }, [])
@@ -209,7 +209,7 @@ export function App() {
// Handle browser back button (browser only, not Telegram) // Handle browser back button (browser only, not Telegram)
useEffect(() => { useEffect(() => {
if (getTelegramWebApp()?.initData) return if (isTelegramApp()) return
const handlePopState = (event: PopStateEvent) => { const handlePopState = (event: PopStateEvent) => {
if (event.state?.screen) { if (event.state?.screen) {
@@ -227,7 +227,7 @@ export function App() {
const goBack = useCallback(() => { const goBack = useCallback(() => {
// Browser: use native back (triggers popstate) // Browser: use native back (triggers popstate)
if (!getTelegramWebApp()?.initData) { if (!isTelegramApp()) {
history.back() history.back()
return return
} }
+2 -3
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import type { Session } from '@/types/api' import type { Session } from '@/types/api'
import { getTelegramWebApp } from '@/hooks/useTelegram' import { isTelegramApp } from '@/hooks/useTelegram'
function getSessionTitle(session: Session): string { function getSessionTitle(session: Session): string {
if (session.metadata?.name) { if (session.metadata?.name) {
@@ -20,11 +20,10 @@ export function SessionHeader(props: {
session: Session session: Session
onBack: () => void onBack: () => void
}) { }) {
const isTelegram = getTelegramWebApp() !== null
const title = useMemo(() => getSessionTitle(props.session), [props.session]) const title = useMemo(() => getSessionTitle(props.session), [props.session])
// In Telegram, don't render header (Telegram provides its own) // In Telegram, don't render header (Telegram provides its own)
if (isTelegram) { if (isTelegramApp()) {
return null return null
} }
+3 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { getTelegramWebApp } from './useTelegram' import { getTelegramWebApp, isTelegramEnvironment } from './useTelegram'
import type { AuthSource } from './useAuth' import type { AuthSource } from './useAuth'
const ACCESS_TOKEN_KEY = 'hapi_access_token' const ACCESS_TOKEN_KEY = 'hapi_access_token'
@@ -77,15 +77,8 @@ export function useAuthSource(): {
return return
} }
// Check if we're likely in a Telegram environment before polling // Check if we're in a Telegram environment before polling
// Only use URL params (tgWebApp) as reliable indicator if (!isTelegramEnvironment()) {
const hasTelegramHint =
typeof window !== 'undefined' && (
window.location.search.includes('tgWebApp') ||
window.location.hash.includes('tgWebApp')
)
if (!hasTelegramHint) {
// Plain browser - show login prompt immediately // Plain browser - show login prompt immediately
setIsLoading(false) setIsLoading(false)
return return
+42 -51
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { getTelegramWebApp } from './useTelegram' import { getTelegramWebApp, isTelegramApp } from './useTelegram'
export type HapticStyle = 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' export type HapticStyle = 'light' | 'medium' | 'heavy' | 'rigid' | 'soft'
export type HapticNotification = 'error' | 'success' | 'warning' export type HapticNotification = 'error' | 'success' | 'warning'
@@ -20,62 +20,53 @@ export type Platform = {
haptic: PlatformHaptic haptic: PlatformHaptic
} }
function createHaptic(): PlatformHaptic { // Vibration patterns for web fallback (in ms)
const tg = getTelegramWebApp() const vibrationPatterns = {
light: 10,
medium: 20,
heavy: 30,
rigid: 15,
soft: 10,
success: 20,
warning: [20, 50, 20] as number | number[],
error: [30, 50, 30] as number | number[],
selection: 5,
}
// Vibration patterns for web fallback (in ms) function vibrate(pattern: number | number[]) {
const vibrationPatterns = { navigator.vibrate?.(pattern)
light: 10, }
medium: 20,
heavy: 30,
rigid: 15,
soft: 10,
success: 20,
warning: [20, 50, 20] as number | number[],
error: [30, 50, 30] as number | number[],
selection: 5,
}
const vibrate = (pattern: number | number[]) => { // Lazy haptic - checks for Telegram SDK on each call
navigator.vibrate?.(pattern) const haptic: PlatformHaptic = {
} impact: (style: HapticStyle) => {
const tg = getTelegramWebApp()
return { if (tg?.HapticFeedback) {
impact: (style: HapticStyle) => { tg.HapticFeedback.impactOccurred(style)
if (tg?.HapticFeedback) { } else {
tg.HapticFeedback.impactOccurred(style) vibrate(vibrationPatterns[style])
} else { }
vibrate(vibrationPatterns[style]) },
} notification: (type: HapticNotification) => {
}, const tg = getTelegramWebApp()
notification: (type: HapticNotification) => { if (tg?.HapticFeedback) {
if (tg?.HapticFeedback) { tg.HapticFeedback.notificationOccurred(type)
tg.HapticFeedback.notificationOccurred(type) } else {
} else { vibrate(vibrationPatterns[type])
vibrate(vibrationPatterns[type]) }
} },
}, selection: () => {
selection: () => { const tg = getTelegramWebApp()
if (tg?.HapticFeedback) { if (tg?.HapticFeedback) {
tg.HapticFeedback.selectionChanged() tg.HapticFeedback.selectionChanged()
} else { } else {
vibrate(vibrationPatterns.selection) vibrate(vibrationPatterns.selection)
}
} }
} }
} }
// Singleton haptic instance (functions are stable)
const haptic = createHaptic()
function checkIsTelegram(): boolean {
const tg = getTelegramWebApp()
// SDK is always loaded, but initData is only present in actual Telegram environment
return tg !== null && Boolean(tg.initData)
}
export function usePlatform(): Platform { export function usePlatform(): Platform {
const isTelegram = useMemo(() => checkIsTelegram(), []) const isTelegram = useMemo(() => isTelegramApp(), [])
return { return {
isTelegram, isTelegram,
@@ -86,7 +77,7 @@ export function usePlatform(): Platform {
// Non-hook version for use outside React components // Non-hook version for use outside React components
export function getPlatform(): Platform { export function getPlatform(): Platform {
return { return {
isTelegram: checkIsTelegram(), isTelegram: isTelegramApp(),
haptic haptic
} }
} }
+67
View File
@@ -1,3 +1,30 @@
/**
* Detects if the current environment is Telegram Mini App
* by checking URL hash/query parameters that Telegram passes.
* This works BEFORE the SDK is loaded.
*/
export function isTelegramEnvironment(): boolean {
if (typeof window === 'undefined') return false
// Telegram passes launch params via window.location.hash
// Format: #tgWebAppVersion=...&tgWebAppData=...&tgWebAppPlatform=...
const hash = window.location.hash.slice(1)
const hashParams = new URLSearchParams(hash)
// Primary detection: check hash parameters
if (hashParams.has('tgWebAppVersion') || hashParams.has('tgWebAppData')) {
return true
}
// Fallback: check query parameters (alternative flow)
const search = window.location.search
if (search.includes('tgWebApp') || search.includes('initData')) {
return true
}
return false
}
export type TelegramWebAppThemeParams = { export type TelegramWebAppThemeParams = {
bg_color?: string bg_color?: string
text_color?: string text_color?: string
@@ -75,3 +102,43 @@ declare global {
export function getTelegramWebApp(): TelegramWebApp | null { export function getTelegramWebApp(): TelegramWebApp | null {
return window.Telegram?.WebApp ?? null return window.Telegram?.WebApp ?? null
} }
/**
* Checks if running inside a real Telegram Mini App.
* Requires SDK to be loaded. Returns true only if initData is present.
*/
export function isTelegramApp(): boolean {
const tg = getTelegramWebApp()
return tg !== null && Boolean(tg.initData)
}
/**
* Dynamically loads the Telegram Web App SDK with timeout.
* Only call this if isTelegramEnvironment() returns true.
*/
export function loadTelegramSdk(timeoutMs = 3000): Promise<void> {
return new Promise((resolve) => {
if (window.Telegram?.WebApp) {
resolve()
return
}
let settled = false
const settle = () => {
if (!settled) {
settled = true
resolve()
}
}
// Timeout - don't block app indefinitely
setTimeout(settle, timeoutMs)
const script = document.createElement('script')
script.src = 'https://telegram.org/js/telegram-web-app.js'
script.async = true
script.onload = settle
script.onerror = settle
document.head.appendChild(script)
})
}
+17 -14
View File
@@ -53,19 +53,8 @@ function updateScheme(): void {
} }
} }
// Initialize theme on module load // Track if theme listeners have been set up
applyTheme(currentScheme) let listenersInitialized = false
// Listen for theme changes
const tg = getTelegramWebApp()
if (tg?.onEvent) {
// Telegram theme changes
tg.onEvent('themeChanged', updateScheme)
} else if (typeof window !== 'undefined' && window.matchMedia) {
// Browser system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateScheme)
}
export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } {
const colorScheme = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) const colorScheme = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
@@ -76,8 +65,22 @@ export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } {
} }
} }
// Call this once at app startup to ensure theme is applied // Call this once at app startup to ensure theme is applied and listeners attached
export function initializeTheme(): void { export function initializeTheme(): void {
currentScheme = getColorScheme() currentScheme = getColorScheme()
applyTheme(currentScheme) applyTheme(currentScheme)
// Set up listeners only once (after SDK may have loaded)
if (!listenersInitialized) {
listenersInitialized = true
const tg = getTelegramWebApp()
if (tg?.onEvent) {
// Telegram theme changes
tg.onEvent('themeChanged', updateScheme)
} else if (typeof window !== 'undefined' && window.matchMedia) {
// Browser system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateScheme)
}
}
} }
+32 -22
View File
@@ -3,28 +3,38 @@ import ReactDOM from 'react-dom/client'
import { App } from './App' import { App } from './App'
import './index.css' import './index.css'
import { registerSW } from 'virtual:pwa-register' import { registerSW } from 'virtual:pwa-register'
import { isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram'
registerSW({ async function bootstrap() {
immediate: true, // Only load Telegram SDK in Telegram environment (with 3s timeout)
onOfflineReady() { if (isTelegramEnvironment()) {
console.log('App ready for offline use') await loadTelegramSdk()
},
onRegistered(registration) {
if (registration) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
registration.update()
}
})
}
},
onRegisterError(error) {
console.error('SW registration error:', error)
} }
})
ReactDOM.createRoot(document.getElementById('root')!).render( registerSW({
<React.StrictMode> immediate: true,
<App /> onOfflineReady() {
</React.StrictMode> console.log('App ready for offline use')
) },
onRegistered(registration) {
if (registration) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
registration.update()
}
})
}
},
onRegisterError(error) {
console.error('SW registration error:', error)
}
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
}
bootstrap()