mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+38
-44
@@ -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 bot’s “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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 } {
|
||||
|
||||
Reference in New Issue
Block a user