496 lines
17 KiB
TypeScript
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' },
|
|
})
|