124 lines
3.9 KiB
TypeScript
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
|
|
}
|