feat: add browser environment support with access token authentication

Enable the web client to run in plain browser environments alongside Telegram Mini App support:

- Server: Add CLI_API_TOKEN authentication path as alternative to Telegram initData
- Client: Add useAuthSource hook to detect and manage Telegram vs browser auth sources
- Client: Add usePlatform hook for platform abstraction with graceful haptic feedback degradation
- Client: Add LoginPrompt component for browser access token login
- Client: Extend useTheme to fall back to system prefers-color-scheme in browser
- Client: Migrate all direct HapticFeedback calls to use usePlatform hook
This commit is contained in:
weishu
2025-12-18 12:45:49 +08:00
parent 5fe1256e11
commit cf2b96b566
14 changed files with 552 additions and 96 deletions
+38 -44
View File
@@ -2,28 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { initializeTheme } from '@/hooks/useTheme'
import { useAuth } from '@/hooks/useAuth'
import { useAuthSource } from '@/hooks/useAuthSource'
import { usePlatform } from '@/hooks/usePlatform'
import { useSocket } from '@/hooks/useSocket'
import type { DecryptedMessage, Machine, Session, SessionSummary, SyncEvent } from '@/types/api'
import { SessionList } from '@/components/SessionList'
import { SessionChat } from '@/components/SessionChat'
import { MachineList } from '@/components/MachineList'
import { SpawnSession } from '@/components/SpawnSession'
function getInitData(): string | null {
const tg = getTelegramWebApp()
if (tg?.initData) {
return tg.initData
}
const query = new URLSearchParams(window.location.search)
const tgWebAppData = query.get('tgWebAppData')
if (tgWebAppData) {
return tgWebAppData
}
const fromQuery = new URLSearchParams(window.location.search).get('initData')
return fromQuery || null
}
import { LoginPrompt } from '@/components/LoginPrompt'
type Screen =
| { type: 'sessions' }
@@ -134,8 +121,9 @@ function mergeMessages(existing: DecryptedMessage[], incoming: DecryptedMessage[
}
export function App() {
const [initData, setInitData] = useState<string | null>(() => getInitData())
const { token, api, isLoading: isAuthLoading, error: authError, user } = useAuth(initData)
const { authSource, isLoading: isAuthSourceLoading, isTelegram, setAccessToken, clearAuth } = useAuthSource()
const { token, api, isLoading: isAuthLoading, error: authError, user } = useAuth(authSource)
const { haptic } = usePlatform()
const [screen, setScreen] = useState<Screen>(() => {
const deepLinkedSessionId = getDeepLinkedSessionId()
@@ -237,27 +225,6 @@ export function App() {
}
}, [goBack, screen.type])
useEffect(() => {
if (initData) {
return
}
let attempts = 0
const interval = setInterval(() => {
attempts += 1
const next = getInitData()
if (next) {
setInitData(next)
clearInterval(interval)
} else if (attempts >= 20) {
clearInterval(interval)
}
}, 250)
return () => {
clearInterval(interval)
}
}, [initData])
const loadSessions = useCallback(async () => {
if (!api) return
@@ -351,7 +318,7 @@ export function App() {
api.sendMessage(selectedSessionId, text, localId)
.then(() => {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
setMessages((prev) =>
prev.map(m => m.localId === localId
? { ...m, status: 'sent' as const }
@@ -360,7 +327,7 @@ export function App() {
)
})
.catch(() => {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
setMessages((prev) =>
prev.map(m => m.localId === localId
? { ...m, status: 'failed' as const }
@@ -450,6 +417,21 @@ export function App() {
}
})
// Loading auth source
if (isAuthSourceLoading) {
return (
<div className="p-4">
<div className="text-sm text-[var(--app-hint)]">Loading</div>
</div>
)
}
// No auth source (browser environment, not logged in)
if (!authSource) {
return <LoginPrompt onLogin={setAccessToken} />
}
// Authenticating
if (isAuthLoading) {
return (
<div className="p-4">
@@ -458,7 +440,19 @@ export function App() {
)
}
// Auth error
if (authError || !token || !api) {
// If using access token and auth failed, show login again
if (authSource.type === 'accessToken') {
return (
<LoginPrompt
onLogin={setAccessToken}
error={authError ?? 'Authentication failed'}
/>
)
}
// Telegram auth failed
return (
<div className="p-4 space-y-3">
<div className="text-base font-semibold">Happy Mini App</div>
@@ -466,7 +460,7 @@ export function App() {
{authError ?? 'Not authorized'}
</div>
<div className="text-xs text-[var(--app-hint)]">
Open this page from Telegram using the bots Open App button (not Open in browser).
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
</div>
</div>
)
@@ -530,7 +524,7 @@ export function App() {
api.sendMessage(screen.sessionId, text, localId)
.then(() => {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
// Update status to sent
setMessages((prev) =>
prev.map(m => m.localId === localId
@@ -540,7 +534,7 @@ export function App() {
)
})
.catch(() => {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
// Update status to failed
setMessages((prev) =>
prev.map(m => m.localId === localId
+2 -2
View File
@@ -34,11 +34,11 @@ export class ApiClient {
return await res.json() as T
}
async authenticate(initData: string): Promise<AuthResponse> {
async authenticate(auth: { initData: string } | { accessToken: string }): Promise<AuthResponse> {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ initData })
body: JSON.stringify(auth)
})
if (!res.ok) {
@@ -14,7 +14,7 @@ import type { Suggestion } from '@/hooks/useActiveSuggestions'
import { useActiveWord } from '@/hooks/useActiveWord'
import { useActiveSuggestions } from '@/hooks/useActiveSuggestions'
import { applySuggestion } from '@/utils/applySuggestion'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { usePlatform } from '@/hooks/usePlatform'
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
import { StatusBar } from '@/components/AssistantChat/StatusBar'
@@ -95,6 +95,7 @@ export function HappyComposer(props: {
})
}, [composerText])
const { haptic: platformHaptic } = usePlatform()
const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes)
const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions(
activeWord,
@@ -103,15 +104,14 @@ export function HappyComposer(props: {
)
const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => {
const tg = getTelegramWebApp()
if (type === 'light') {
tg?.HapticFeedback?.impactOccurred('light')
platformHaptic.impact('light')
} else if (type === 'success') {
tg?.HapticFeedback?.notificationOccurred('success')
platformHaptic.notification('success')
} else {
tg?.HapticFeedback?.notificationOccurred('error')
platformHaptic.notification('error')
}
}, [])
}, [platformHaptic])
const handleSuggestionSelect = useCallback((index: number) => {
const suggestion = suggestions[index]
+113
View File
@@ -0,0 +1,113 @@
import type { Themes } from 'react-shiki/web'
import { useMemo, useState } from 'react'
import { useShikiHighlighter } from 'react-shiki/web'
import { usePlatform } from '@/hooks/usePlatform'
const SHIKI_THEMES: Themes = {
light: 'github-light',
dark: 'github-dark',
}
function normalizeLanguage(language?: string): string {
const raw = language?.trim()
if (!raw) return 'text'
const cleaned = raw.startsWith('language-') ? raw.slice('language-'.length) : raw
const canonical = cleaned.toLowerCase()
if (canonical === 'text' || canonical === 'plaintext' || canonical === 'txt') return 'text'
return cleaned
}
function safeCopyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text)
}
return Promise.reject(new Error('Clipboard API not available'))
}
function CopyIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
}
function CheckIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
export function CodeBlock(props: {
code: string
language?: string
showCopyButton?: boolean
}) {
const { haptic } = usePlatform()
const showCopyButton = props.showCopyButton ?? true
const normalizedLanguage = useMemo(() => normalizeLanguage(props.language), [props.language])
const [copied, setCopied] = useState(false)
const highlighted = useShikiHighlighter(props.code, normalizedLanguage, SHIKI_THEMES, {
delay: 75,
structure: 'inline',
})
const handleCopy = async () => {
try {
await safeCopyToClipboard(props.code)
haptic.notification('success')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
haptic.notification('error')
}
}
return (
<div className="relative overflow-hidden rounded-md bg-[var(--app-code-bg)]">
{showCopyButton ? (
<button
type="button"
onClick={handleCopy}
className="absolute right-1.5 top-1.5 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy"
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
) : null}
<pre className="overflow-auto p-2 pr-8 text-xs">
<code className="shiki font-mono">
{highlighted ?? props.code}
</code>
</pre>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useCallback, useState } from 'react'
import { ApiClient } from '@/api/client'
type LoginPromptProps = {
onLogin: (token: string) => void
error?: string | null
}
export function LoginPrompt(props: LoginPromptProps) {
const [accessToken, setAccessToken] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
const trimmedToken = accessToken.trim()
if (!trimmedToken) {
setError('Please enter an access token')
return
}
setIsLoading(true)
setError(null)
try {
// Validate the token by attempting to authenticate
const client = new ApiClient('')
await client.authenticate({ accessToken: trimmedToken })
// If successful, pass the token to parent
props.onLogin(trimmedToken)
} catch (e) {
setError(e instanceof Error ? e.message : 'Authentication failed')
} finally {
setIsLoading(false)
}
}, [accessToken, props])
const displayError = error || props.error
return (
<div className="h-full flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<div className="text-2xl font-semibold">Happy</div>
<div className="text-sm text-[var(--app-hint)]">
Enter your access token to continue
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<input
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="Access Token"
autoComplete="current-password"
disabled={isLoading}
className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent disabled:opacity-50"
/>
</div>
{displayError && (
<div className="text-sm text-red-500 text-center">
{displayError}
</div>
)}
<button
type="submit"
disabled={isLoading || !accessToken.trim()}
className="w-full py-2.5 rounded-lg bg-[var(--app-button)] text-[var(--app-button-text)] font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
{/* Help text */}
<div className="text-xs text-[var(--app-hint)] text-center">
Use the CLI_API_TOKEN from your server configuration
</div>
</div>
</div>
)
}
+8 -7
View File
@@ -10,7 +10,7 @@ import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
import { HappyThread } from '@/components/AssistantChat/HappyThread'
import { useHappyRuntime } from '@/lib/assistant-runtime'
import { SessionHeader } from '@/components/SessionHeader'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { usePlatform } from '@/hooks/usePlatform'
export function SessionChat(props: {
api: ApiClient
@@ -27,6 +27,7 @@ export function SessionChat(props: {
onSend: (text: string) => void
onRetryMessage?: (localId: string) => void
}) {
const { haptic } = usePlatform()
const controlsDisabled = !props.session.active
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
@@ -63,25 +64,25 @@ export function SessionChat(props: {
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
try {
await props.api.setPermissionMode(props.session.id, mode as 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan')
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
props.onRefresh()
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
console.error('Failed to set permission mode:', e)
}
}, [props.api, props.session.id, props.onRefresh])
}, [props.api, props.session.id, props.onRefresh, haptic])
// Model mode change handler
const handleModelModeChange = useCallback(async (mode: ModelMode) => {
try {
await props.api.setModelMode(props.session.id, mode as 'default' | 'sonnet' | 'opus')
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
props.onRefresh()
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
console.error('Failed to set model mode:', e)
}
}, [props.api, props.session.id, props.onRefresh])
}, [props.api, props.session.id, props.onRefresh, haptic])
// Abort handler
const handleAbort = useCallback(async () => {
+5 -4
View File
@@ -3,7 +3,7 @@ import type { ApiClient } from '@/api/client'
import type { Machine } from '@/types/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { usePlatform } from '@/hooks/usePlatform'
function getMachineTitle(machine: Machine | null): string {
if (!machine) return 'Machine'
@@ -19,6 +19,7 @@ export function SpawnSession(props: {
onSuccess: (sessionId: string) => void
onCancel: () => void
}) {
const { haptic } = usePlatform()
const [directory, setDirectory] = useState('')
const [isWorking, setIsWorking] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -34,14 +35,14 @@ export function SpawnSession(props: {
try {
const result = await props.api.spawnSession(props.machineId, trimmed)
if (result.type === 'success') {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
props.onSuccess(result.sessionId)
return
}
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
setError(result.message)
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
setError(e instanceof Error ? e.message : 'Failed to spawn session')
} finally {
setIsWorking(false)
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import type { ChatToolCall, ToolPermission } from '@/chat/types'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { usePlatform } from '@/hooks/usePlatform'
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
@@ -108,6 +108,7 @@ export function PermissionFooter(props: {
disabled: boolean
onDone: () => void
}) {
const { haptic } = usePlatform()
const permission = props.tool.permission
const [loading, setLoading] = useState<'allow' | 'deny' | 'abort' | null>(null)
const [loadingAllEdits, setLoadingAllEdits] = useState(false)
@@ -121,15 +122,15 @@ export function PermissionFooter(props: {
const summary = formatPermissionSummary(permission, props.tool.name, props.tool.input, codex)
const isPending = permission.status === 'pending'
const run = async (action: () => Promise<void>, haptic: 'success' | 'error') => {
const run = async (action: () => Promise<void>, hapticType: 'success' | 'error') => {
if (props.disabled) return
setError(null)
try {
await action()
getTelegramWebApp()?.HapticFeedback?.notificationOccurred(haptic)
haptic.notification(hapticType)
props.onDone()
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
setError(e instanceof Error ? e.message : 'Request failed')
}
}
@@ -8,7 +8,7 @@ import {
type CodeHeaderProps,
} from '@assistant-ui/react-markdown'
import remarkGfm from 'remark-gfm'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { getPlatform } from '@/hooks/usePlatform'
import { cn } from '@/lib/utils'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
@@ -66,13 +66,14 @@ function CodeHeader(props: CodeHeaderProps) {
const language = props.language && props.language !== 'unknown' ? props.language : ''
const handleCopy = async () => {
const { haptic } = getPlatform()
try {
await safeCopyToClipboard(props.code)
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
haptic.notification('success')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
haptic.notification('error')
}
}
+33 -9
View File
@@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { ApiClient } from '@/api/client'
import type { AuthResponse } from '@/types/api'
export type AuthSource =
| { type: 'telegram'; initData: string }
| { type: 'accessToken'; token: string }
function decodeJwtExpMs(token: string): number | null {
const parts = token.split('.')
if (parts.length < 2) return null
@@ -22,7 +26,14 @@ function decodeJwtExpMs(token: string): number | null {
}
}
export function useAuth(initData: string | null): {
function getAuthPayload(source: AuthSource): { initData: string } | { accessToken: string } {
if (source.type === 'telegram') {
return { initData: source.initData }
}
return { accessToken: source.token }
}
export function useAuth(authSource: AuthSource | null): {
token: string | null
user: AuthResponse['user'] | null
api: ApiClient | null
@@ -37,12 +48,16 @@ export function useAuth(initData: string | null): {
const api = useMemo(() => (token ? new ApiClient(token) : null), [token])
// Stable reference for auth source to use in effects
const authSourceRef = useRef(authSource)
authSourceRef.current = authSource
useEffect(() => {
let isCancelled = false
async function run() {
if (!initData) {
setError('Missing Telegram initData (open inside Telegram)')
if (!authSource) {
// No auth source - waiting for login
return
}
@@ -50,7 +65,7 @@ export function useAuth(initData: string | null): {
setError(null)
try {
const client = new ApiClient('') // temporary for auth call
const auth = await client.authenticate(initData)
const auth = await client.authenticate(getAuthPayload(authSource))
if (isCancelled) return
setToken(auth.token)
setUser(auth.user)
@@ -69,10 +84,10 @@ export function useAuth(initData: string | null): {
return () => {
isCancelled = true
}
}, [initData])
}, [authSource])
useEffect(() => {
if (!token || !initData) {
if (!token || !authSource) {
return
}
@@ -96,9 +111,15 @@ export function useAuth(initData: string | null): {
if (refreshInFlightRef.current) return
refreshInFlightRef.current = true
const currentSource = authSourceRef.current
if (!currentSource) {
refreshInFlightRef.current = false
return
}
try {
const client = new ApiClient('')
const auth = await client.authenticate(initData)
const auth = await client.authenticate(getAuthPayload(currentSource))
if (isCancelled) return
setToken(auth.token)
setUser(auth.user)
@@ -107,7 +128,10 @@ export function useAuth(initData: string | null): {
if (Date.now() >= expMs) {
setToken(null)
setUser(null)
setError('Session expired. Reopen the Mini App from Telegram.')
const msg = currentSource.type === 'telegram'
? 'Session expired. Reopen the Mini App from Telegram.'
: 'Session expired. Please login again.'
setError(msg)
return
}
schedule(15_000)
@@ -124,7 +148,7 @@ export function useAuth(initData: string | null): {
clearTimeout(timeout)
}
}
}, [initData, token])
}, [authSource, token])
return { token, user, api, isLoading, error }
}
+139
View File
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { getTelegramWebApp } from './useTelegram'
import type { AuthSource } from './useAuth'
const ACCESS_TOKEN_KEY = 'hapi_access_token'
function getTelegramInitData(): string | null {
const tg = getTelegramWebApp()
if (tg?.initData) {
return tg.initData
}
// Fallback: check URL parameters (for testing or alternative flows)
const query = new URLSearchParams(window.location.search)
const tgWebAppData = query.get('tgWebAppData')
if (tgWebAppData) {
return tgWebAppData
}
const initData = query.get('initData')
return initData || null
}
function getStoredAccessToken(): string | null {
try {
return localStorage.getItem(ACCESS_TOKEN_KEY)
} catch {
return null
}
}
function storeAccessToken(token: string): void {
try {
localStorage.setItem(ACCESS_TOKEN_KEY, token)
} catch {
// Ignore storage errors
}
}
function clearStoredAccessToken(): void {
try {
localStorage.removeItem(ACCESS_TOKEN_KEY)
} catch {
// Ignore storage errors
}
}
export function useAuthSource(): {
authSource: AuthSource | null
isLoading: boolean
isTelegram: boolean
setAccessToken: (token: string) => void
clearAuth: () => void
} {
const [authSource, setAuthSource] = useState<AuthSource | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isTelegram, setIsTelegram] = useState(false)
const retryCountRef = useRef(0)
// Initialize auth source on mount, with retry for delayed Telegram initData
useEffect(() => {
const telegramInitData = getTelegramInitData()
if (telegramInitData) {
// Telegram Mini App environment
setAuthSource({ type: 'telegram', initData: telegramInitData })
setIsTelegram(true)
setIsLoading(false)
return
}
// Check for stored access token as fallback
const storedToken = getStoredAccessToken()
if (storedToken) {
setAuthSource({ type: 'accessToken', token: storedToken })
setIsLoading(false)
return
}
// Check if we're likely in a Telegram environment before polling
// Hints: Telegram object exists (SDK loading), iframe, or Telegram URL params (in query or hash)
const hasTelegramHint =
typeof window !== 'undefined' && (
window.Telegram !== undefined ||
window.self !== window.top ||
window.location.search.includes('tgWebApp') ||
window.location.hash.includes('tgWebApp')
)
if (!hasTelegramHint) {
// Plain browser - show login prompt immediately
setIsLoading(false)
return
}
// Telegram environment detected - poll for delayed initData
// Telegram WebApp SDK may initialize slightly after page mount
const maxRetries = 20
const retryInterval = 250 // ms
const interval = setInterval(() => {
retryCountRef.current += 1
const initData = getTelegramInitData()
if (initData) {
setAuthSource({ type: 'telegram', initData })
setIsTelegram(true)
setIsLoading(false)
clearInterval(interval)
} else if (retryCountRef.current >= maxRetries) {
// Give up - show login prompt for browser access
setIsLoading(false)
clearInterval(interval)
}
}, retryInterval)
return () => {
clearInterval(interval)
}
}, [])
const setAccessToken = useCallback((token: string) => {
storeAccessToken(token)
setAuthSource({ type: 'accessToken', token })
}, [])
const clearAuth = useCallback(() => {
clearStoredAccessToken()
setAuthSource(null)
}, [])
return {
authSource,
isLoading,
isTelegram,
setAccessToken,
clearAuth
}
}
+55
View File
@@ -0,0 +1,55 @@
import { useMemo } from 'react'
import { getTelegramWebApp } from './useTelegram'
export type HapticStyle = 'light' | 'medium' | 'heavy' | 'rigid' | 'soft'
export type HapticNotification = 'error' | 'success' | 'warning'
export type PlatformHaptic = {
/** Trigger impact feedback */
impact: (style: HapticStyle) => void
/** Trigger notification feedback */
notification: (type: HapticNotification) => void
/** Trigger selection changed feedback */
selection: () => void
}
export type Platform = {
/** Whether running in Telegram Mini App */
isTelegram: boolean
/** Haptic feedback (no-op on browser) */
haptic: PlatformHaptic
}
function createHaptic(): PlatformHaptic {
return {
impact: (style: HapticStyle) => {
getTelegramWebApp()?.HapticFeedback?.impactOccurred(style)
},
notification: (type: HapticNotification) => {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred(type)
},
selection: () => {
getTelegramWebApp()?.HapticFeedback?.selectionChanged()
}
}
}
// Singleton haptic instance (functions are stable)
const haptic = createHaptic()
export function usePlatform(): Platform {
const isTelegram = useMemo(() => getTelegramWebApp() !== null, [])
return {
isTelegram,
haptic
}
}
// Non-hook version for use outside React components
export function getPlatform(): Platform {
return {
isTelegram: getTelegramWebApp() !== null,
haptic
}
}
+16 -2
View File
@@ -5,7 +5,16 @@ type ColorScheme = 'light' | 'dark'
function getColorScheme(): ColorScheme {
const tg = getTelegramWebApp()
return tg?.colorScheme === 'dark' ? 'dark' : 'light'
if (tg?.colorScheme) {
return tg.colorScheme === 'dark' ? 'dark' : 'light'
}
// Fallback to system preference for browser environment
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return 'light'
}
function isIOS(): boolean {
@@ -47,10 +56,15 @@ function updateScheme(): void {
// Initialize theme on module load
applyTheme(currentScheme)
// Listen for Telegram theme changes
// 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 } {