d3ro-voice/apps/mobile-rn/App.tsx
2026-08-29 18:33:45 +09:00

496 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Pressable,
StatusBar,
StyleSheet,
Text,
View,
} from 'react-native'
import {
DarkTheme,
DefaultTheme,
NavigationContainer,
useNavigationContainerRef,
type LinkingOptions,
type NavigatorScreenParams,
type Theme as NavigationTheme,
} from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { I18nProvider, useI18n } from '@d3ro/i18n'
import { AuthProvider, useAuth } from './src/lib/auth-context'
import {
MobilePreferencesProvider,
useMobilePreferences,
} from './src/lib/preferences-context'
import TabNavigator, { type MainTabParamList } from './src/navigation/TabNavigator'
import LoginScreen from './src/screens/LoginScreen'
import ProPaywallScreen from './src/screens/ProPaywallScreen'
import SignUpScreen from './src/screens/SignUpScreen'
import ForgotPasswordScreen from './src/screens/ForgotPasswordScreen'
import UpdatePasswordScreen from './src/screens/UpdatePasswordScreen'
import AccountScreen from './src/screens/AccountScreen'
import OnboardingScreen from './src/screens/OnboardingScreen'
import HistoryScreen from './src/screens/HistoryScreen'
import HistoryDetailScreen from './src/screens/HistoryDetailScreen'
import DevicesScreen from './src/screens/DevicesScreen'
import DictionaryScreen from './src/screens/DictionaryScreen'
import CommandsScreen from './src/screens/CommandsScreen'
import MeetingsScreen from './src/screens/MeetingsScreen'
import MeetingDetailScreen from './src/screens/MeetingDetailScreen'
import KnowledgeScreen from './src/screens/KnowledgeScreen'
import ActionsScreen from './src/screens/ActionsScreen'
import TeamsScreen from './src/screens/TeamsScreen'
import TeamDetailScreen from './src/screens/TeamDetailScreen'
import InviteAcceptScreen from './src/screens/InviteAcceptScreen'
import NotificationsScreen from './src/screens/NotificationsScreen'
import DataPortabilityScreen from './src/screens/DataPortabilityScreen'
import TemplatesScreen from './src/screens/TemplatesScreen'
import MemosScreen from './src/screens/MemosScreen'
import AdminScreen from './src/screens/AdminScreen'
import AdminUserDetailScreen from './src/screens/AdminUserDetailScreen'
import { EntitlementProvider } from './src/lib/entitlement-context'
import { fetchEntitlementSnapshot, useEntitlement } from './src/lib/entitlement-context'
import { BillingProvider } from './src/lib/billing-context'
import { MobileAdsProvider } from './src/lib/mobile-ads-context'
import { DeviceProvider } from './src/lib/device-context'
import { useNotificationRuntime } from './src/features/notifications/use-notification-runtime'
import type { NotificationNavigationTarget } from './src/features/notifications/notification-contract'
import { loadPendingInviteToken } from './src/features/teams/pending-invite'
import { listTeams } from './src/features/teams/team-service'
import { listHistoryPage } from './src/features/history/history-service'
import { saveHistoryCache } from './src/features/history/history-cache'
import { useRecordingProcessingQueue } from './src/features/recording/use-recording-processing-queue'
import {
getPendingIncomingMediaId,
onIncomingMediaAvailable,
} from './src/features/import/incoming-media-intent'
export type RootStackParamList = {
Login: undefined
SignUp: undefined
ForgotPassword: undefined
UpdatePassword: { mode?: 'authenticated' } | undefined
Onboarding: { replay?: boolean } | undefined
Main: NavigatorScreenParams<MainTabParamList> | undefined
Account: undefined
History: undefined
HistoryDetail: { historyId: string }
ProPaywall: undefined
Devices: undefined
Dictionary: undefined
Commands: undefined
Meetings: undefined
MeetingDetail: { meetingId: string }
Knowledge: undefined
Actions: undefined
Teams: undefined
TeamDetail: { teamId: string }
InviteAccept: { token?: string; url?: string } | undefined
Notifications: undefined
DataPortability: undefined
Templates: { initialKind?: 'dictation' | 'meeting_document' } | undefined
Memos: undefined
Admin: undefined
AdminUserDetail: { userId: string }
}
type AuthEntryRoute = 'Login' | 'SignUp'
const Stack = createNativeStackNavigator<RootStackParamList>()
const linking: LinkingOptions<RootStackParamList> = {
prefixes: ['d3ro-voice://', 'https://d3ro.chanpaca.net'],
config: {
screens: {
InviteAccept: {
path: 'accept-invite',
},
},
},
}
function createNavigationTheme(
effectiveTheme: 'light' | 'dark',
palette: ReturnType<typeof useMobilePreferences>['palette'],
): NavigationTheme {
const base = effectiveTheme === 'dark' ? DarkTheme : DefaultTheme
return {
...base,
dark: effectiveTheme === 'dark',
colors: {
...base.colors,
primary: palette.accent.main,
background: palette.bg.app,
card: palette.bg.card,
text: palette.text.primary,
border: palette.border.default,
notification: palette.tag.red,
},
}
}
function BootScreen({ testID }: { testID: string }): React.ReactElement {
const { palette } = useMobilePreferences()
return (
<View
style={[styles.bootScreen, { backgroundColor: palette.bg.app }]}
testID={testID}
accessibilityRole="progressbar"
>
<ActivityIndicator size="large" color={palette.accent.main} />
</View>
)
}
export function PrivacyCleanupBlockedScreen({
onRetry,
}: {
onRetry: () => Promise<void>
}): React.ReactElement {
const { t } = useI18n()
const { palette } = useMobilePreferences()
return (
<View
style={[styles.privacyBlockedScreen, { backgroundColor: palette.bg.app }]}
accessibilityRole="alert"
testID="auth-privacy-cleanup-blocked"
>
<Text style={[styles.privacyBlockedTitle, { color: palette.text.primary }]}>
{t('mobile.auth.localDataCleanupTitle')}
</Text>
<Text style={[styles.privacyBlockedBody, { color: palette.text.muted }]}>
{t('mobile.auth.localDataCleanupBody')}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.auth.localDataCleanupRetry')}
onPress={() => void onRetry()}
style={({ pressed }) => [
styles.privacyRetryButton,
{ backgroundColor: palette.accent.main },
pressed && styles.privacyRetryButtonPressed,
]}
testID="auth-privacy-cleanup-retry"
>
<Text style={[styles.privacyRetryLabel, { color: palette.text.onAccent }]}>
{t('mobile.auth.localDataCleanupRetry')}
</Text>
</Pressable>
</View>
)
}
function RootNavigator(): React.ReactElement {
const {
user,
loading: authLoading,
recoveryMode,
privacyCleanupState,
retryPrivacyCleanup,
} = useAuth()
const entitlement = useEntitlement()
const {
loading: preferencesLoading,
needsOnboarding,
effectiveTheme,
palette,
} = useMobilePreferences()
const [authEntryRoute, setAuthEntryRoute] = useState<AuthEntryRoute>('Login')
const navigationRef = useNavigationContainerRef<RootStackParamList>()
const pendingNotificationTarget = useRef<NotificationNavigationTarget | null>(null)
const lastRoutedIncomingMediaId = useRef<string | null>(null)
const previousNeedsOnboarding = useRef<boolean | null>(null)
const navigationTheme = useMemo(
() => createNavigationTheme(effectiveTheme, palette),
[effectiveTheme, palette],
)
useRecordingProcessingQueue(
user?.id ?? null,
!authLoading && privacyCleanupState === 'ready',
)
const navigateFromNotification = useCallback((target: NotificationNavigationTarget): void => {
if (user === null && target.name !== 'InviteAccept') return
if (!navigationRef.isReady()) {
pendingNotificationTarget.current = target
return
}
const navigator = navigationRef as unknown as {
navigate: (name: string, params: Record<string, string>) => void
}
navigator.navigate(target.name, target.params ?? {})
}, [navigationRef, user])
const routeIncomingMedia = useCallback(async (): Promise<void> => {
if (user === null || needsOnboarding || recoveryMode || !navigationRef.isReady()) return
const incomingMediaId = await getPendingIncomingMediaId().catch(() => null)
if (incomingMediaId === null || incomingMediaId === lastRoutedIncomingMediaId.current) return
lastRoutedIncomingMediaId.current = incomingMediaId
navigationRef.navigate('Main', {
screen: 'Record',
params: { incomingMediaId },
})
}, [needsOnboarding, navigationRef, recoveryMode, user])
useEffect(() => {
const subscription = onIncomingMediaAvailable(() => {
void routeIncomingMedia()
})
return () => subscription.remove()
}, [routeIncomingMedia])
useEffect(() => {
void routeIncomingMedia()
}, [routeIncomingMedia])
const synchronizeMissedNotifications = useCallback(async (): Promise<boolean> => {
const userId = user?.id
if (!userId) return false
try {
const [history] = await Promise.all([
listHistoryPage({ userId, filter: 'all', pageSize: 50 }),
listTeams(userId),
fetchEntitlementSnapshot(userId),
])
await saveHistoryCache(userId, history.entries)
await entitlement.refresh()
return true
} catch {
return false
}
}, [entitlement, user?.id])
useNotificationRuntime({
navigate: navigateFromNotification,
onFullSyncRequired: synchronizeMissedNotifications,
})
useEffect(() => {
if (authLoading || preferencesLoading) return
const previous = previousNeedsOnboarding.current
previousNeedsOnboarding.current = needsOnboarding
// Completing onboarding changes the navigator branch in the same render.
// React Navigation can preserve the old Onboarding route across that
// remount, making the screen-level replace target stale. Reset from the
// container only for the authenticated true -> false transition; replay
// onboarding and unauthenticated audience selection must keep their own
// exit behavior.
if (previous !== true || needsOnboarding || user === null) return
if (!navigationRef.isReady()) return
navigationRef.resetRoot({ index: 0, routes: [{ name: 'Main' }] })
}, [authLoading, navigationRef, needsOnboarding, preferencesLoading, user])
useEffect(() => {
if (!user?.id) return
let active = true
void loadPendingInviteToken().then((token) => {
if (active && token !== null) {
navigateFromNotification({ name: 'InviteAccept', params: { token } })
}
}).catch(() => undefined)
return () => { active = false }
}, [navigateFromNotification, user?.id])
if (privacyCleanupState === 'failed') {
return <PrivacyCleanupBlockedScreen onRetry={retryPrivacyCleanup} />
}
if (authLoading || preferencesLoading) {
return <BootScreen testID="app-bootstrap" />
}
const isRecoveringPassword = user !== null && recoveryMode
const branch = isRecoveringPassword
? 'recovery'
: needsOnboarding
? 'onboarding'
: user === null
? `auth-${authEntryRoute}`
: 'app'
return (
<NavigationContainer
ref={navigationRef}
theme={navigationTheme}
linking={linking}
onReady={() => {
const pending = pendingNotificationTarget.current
pendingNotificationTarget.current = null
if (pending !== null) navigateFromNotification(pending)
void routeIncomingMedia()
}}
>
<Stack.Navigator
key={branch}
initialRouteName={user === null && !needsOnboarding ? authEntryRoute : undefined}
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: palette.bg.app },
animation: 'fade',
}}
>
{isRecoveringPassword ? (
<Stack.Screen name="UpdatePassword" component={UpdatePasswordScreen} />
) : needsOnboarding ? (
<>
<Stack.Screen name="Onboarding">
{(props) => (
<OnboardingScreen
{...props}
onFreshFinish={(audience) => {
setAuthEntryRoute(audience === 'new' ? 'SignUp' : 'Login')
}}
/>
)}
</Stack.Screen>
<Stack.Screen name="InviteAccept" component={InviteAcceptScreen} />
<Stack.Screen name="Login" component={LoginScreen} />
</>
) : user === null ? (
<>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="SignUp" component={SignUpScreen} />
<Stack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
<Stack.Screen name="InviteAccept" component={InviteAcceptScreen} />
</>
) : (
<>
<Stack.Screen name="Main" component={TabNavigator} />
<Stack.Screen name="Account" component={AccountScreen} />
<Stack.Screen name="History" component={HistoryScreen} />
<Stack.Screen name="HistoryDetail" component={HistoryDetailScreen} />
<Stack.Screen name="Devices" component={DevicesScreen} />
<Stack.Screen name="Dictionary" component={DictionaryScreen} />
<Stack.Screen name="Commands" component={CommandsScreen} />
<Stack.Screen name="Meetings" component={MeetingsScreen} />
<Stack.Screen name="MeetingDetail" component={MeetingDetailScreen} />
<Stack.Screen name="Knowledge" component={KnowledgeScreen} />
<Stack.Screen name="Actions" component={ActionsScreen} />
<Stack.Screen name="Teams" component={TeamsScreen} />
<Stack.Screen name="TeamDetail" component={TeamDetailScreen} />
<Stack.Screen name="InviteAccept" component={InviteAcceptScreen} />
<Stack.Screen name="Notifications" component={NotificationsScreen} />
<Stack.Screen name="DataPortability" component={DataPortabilityScreen} />
<Stack.Screen name="Templates">
{({ route }) => (
<TemplatesScreen initialKind={route.params?.initialKind} />
)}
</Stack.Screen>
<Stack.Screen name="Memos" component={MemosScreen} />
<Stack.Screen name="Admin" component={AdminScreen} />
<Stack.Screen name="AdminUserDetail" component={AdminUserDetailScreen} />
<Stack.Screen name="UpdatePassword" component={UpdatePasswordScreen} />
<Stack.Screen
name="Onboarding"
component={OnboardingScreen}
options={{ presentation: 'fullScreenModal' }}
/>
<Stack.Screen
name="ProPaywall"
component={ProPaywallScreen}
options={{ presentation: 'modal' }}
/>
</>
)}
</Stack.Navigator>
</NavigationContainer>
)
}
function LocaleSynchronizer({ children }: { children: React.ReactNode }): React.ReactElement {
const { preferences } = useMobilePreferences()
const { locale, setLocale } = useI18n()
useEffect(() => {
if (locale !== preferences.locale) setLocale(preferences.locale)
}, [locale, preferences.locale, setLocale])
return <>{children}</>
}
function LocalizedApplication(): React.ReactElement {
const {
loading,
preferences,
effectiveTheme,
palette,
} = useMobilePreferences()
if (loading) return <BootScreen testID="preferences-bootstrap" />
return (
<I18nProvider initialLocale={preferences.locale}>
<LocaleSynchronizer>
<StatusBar
barStyle={effectiveTheme === 'dark' ? 'light-content' : 'dark-content'}
backgroundColor={palette.bg.app}
/>
<RootNavigator />
</LocaleSynchronizer>
</I18nProvider>
)
}
export default function App(): React.ReactElement {
return (
<GestureHandlerRootView style={styles.root}>
<SafeAreaProvider>
<AuthProvider>
<EntitlementProvider>
<BillingProvider>
<MobileAdsProvider>
<MobilePreferencesProvider>
<DeviceProvider>
<LocalizedApplication />
</DeviceProvider>
</MobilePreferencesProvider>
</MobileAdsProvider>
</BillingProvider>
</EntitlementProvider>
</AuthProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
)
}
const styles = StyleSheet.create({
root: { flex: 1 },
bootScreen: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
privacyBlockedScreen: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 28,
gap: 16,
},
privacyBlockedTitle: {
fontSize: 21,
fontWeight: '500',
textAlign: 'center',
},
privacyBlockedBody: {
maxWidth: 460,
fontSize: 15,
lineHeight: 22,
textAlign: 'center',
},
privacyRetryButton: {
minHeight: 48,
minWidth: 220,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
paddingHorizontal: 20,
paddingVertical: 12,
},
privacyRetryButtonPressed: { opacity: 0.82 },
privacyRetryLabel: { fontSize: 15, fontWeight: '500' },
})