d3ro-voice/apps/mobile-rn/src/lib/device-context.tsx
2026-08-29 18:33:45 +09:00

124 lines
3.9 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { AppState, type AppStateStatus } from 'react-native'
import { useAuth } from './auth-context'
import { getMobileRuntimeConfig } from './native-config'
import { supabase } from './supabase'
import {
registerCurrentDevice,
type RegisteredDevice,
} from '../features/devices/device-service'
interface DeviceContextValue {
currentDevice: RegisteredDevice | null
loading: boolean
error: string | null
refreshCurrentDevice: () => Promise<void>
}
const DeviceContext = createContext<DeviceContextValue | null>(null)
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
export function DeviceProvider({ children }: { children: ReactNode }): React.ReactElement {
const { user, purgeLocalSession } = useAuth()
const [currentDevice, setCurrentDevice] = useState<RegisteredDevice | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const generationRef = useRef(0)
const lastHeartbeatRef = useRef(0)
const revocationHandledRef = useRef(false)
const handleRevocation = useCallback(async (): Promise<void> => {
if (revocationHandledRef.current) return
revocationHandledRef.current = true
await purgeLocalSession()
}, [purgeLocalSession])
const refreshCurrentDevice = useCallback(async (): Promise<void> => {
const userId = user?.id
const generation = ++generationRef.current
if (!userId) {
setCurrentDevice(null)
setLoading(false)
setError(null)
return
}
setLoading(true)
try {
const device = await registerCurrentDevice(userId, getMobileRuntimeConfig())
if (generation !== generationRef.current) return
setCurrentDevice(device)
setError(null)
lastHeartbeatRef.current = Date.now()
if (device.revokedAt !== null) await handleRevocation()
} catch {
if (generation !== generationRef.current) return
setError('device_registration_failed')
} finally {
if (generation === generationRef.current) setLoading(false)
}
}, [handleRevocation, user?.id])
useEffect(() => {
revocationHandledRef.current = false
void refreshCurrentDevice()
return () => { generationRef.current += 1 }
}, [refreshCurrentDevice, user?.id])
useEffect(() => {
if (!user?.id || !currentDevice?.id) return undefined
const channel = supabase
.channel(`mobile-current-device:${currentDevice.id}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'devices',
filter: `id=eq.${currentDevice.id}`,
},
(payload) => {
const revokedAt = (payload.new as { revoked_at?: unknown }).revoked_at
if (typeof revokedAt === 'string' && Number.isFinite(Date.parse(revokedAt))) {
void handleRevocation().catch(() => undefined)
}
},
)
.subscribe()
return () => { void supabase.removeChannel(channel) }
}, [currentDevice?.id, handleRevocation, user?.id])
useEffect(() => {
const subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
if (
state === 'active'
&& user?.id
&& Date.now() - lastHeartbeatRef.current >= HEARTBEAT_INTERVAL_MS
) void refreshCurrentDevice()
})
return () => subscription.remove()
}, [refreshCurrentDevice, user?.id])
const value = useMemo<DeviceContextValue>(() => ({
currentDevice,
loading,
error,
refreshCurrentDevice,
}), [currentDevice, error, loading, refreshCurrentDevice])
return <DeviceContext.Provider value={value}>{children}</DeviceContext.Provider>
}
export function useDevice(): DeviceContextValue {
const value = useContext(DeviceContext)
if (!value) throw new Error('useDevice must be used inside DeviceProvider')
return value
}