Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s
527 lines
19 KiB
TypeScript
527 lines
19 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 HistoryDetailScreen from './src/screens/HistoryDetailScreen'
|
|
import DevicesScreen from './src/screens/DevicesScreen'
|
|
import DictionaryScreen from './src/screens/DictionaryScreen'
|
|
import CommandsScreen from './src/screens/CommandsScreen'
|
|
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 TalkScreen from './src/screens/TalkScreen'
|
|
import SettingsScreen from './src/screens/SettingsScreen'
|
|
import UnifiedSearchScreen from './src/screens/UnifiedSearchScreen'
|
|
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 {
|
|
notificationNavigationQueue,
|
|
type NotificationNavigationReadiness,
|
|
} from './src/features/notifications/notification-navigation-queue'
|
|
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 { createHistoryCacheScope } from './src/features/history/history-cache-writer'
|
|
import { synchronizeAfterMissedNotifications } from './src/features/notifications/missed-notification-sync'
|
|
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
|
|
HistoryDetail: { historyId: string }
|
|
ProPaywall: undefined
|
|
Devices: undefined
|
|
Dictionary: undefined
|
|
Commands: 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 }
|
|
Talk: undefined
|
|
Settings: undefined
|
|
UnifiedSearch: { initialQuery?: string } | undefined
|
|
}
|
|
|
|
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 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',
|
|
)
|
|
|
|
// Readiness is read through a ref so the notification callbacks keep a
|
|
// stable identity; the flush effect below re-runs when it changes.
|
|
const authenticatedAppReady = !authLoading
|
|
&& !preferencesLoading
|
|
&& user !== null
|
|
&& !needsOnboarding
|
|
&& !recoveryMode
|
|
const authenticatedAppReadyRef = useRef(authenticatedAppReady)
|
|
authenticatedAppReadyRef.current = authenticatedAppReady
|
|
|
|
const flushNotificationNavigation = useCallback((): void => {
|
|
const readiness: NotificationNavigationReadiness = {
|
|
navigationReady: navigationRef.isReady(),
|
|
authenticatedAppReady: authenticatedAppReadyRef.current,
|
|
}
|
|
const target = notificationNavigationQueue.take(readiness)
|
|
if (target === null) return
|
|
const navigator = navigationRef as unknown as {
|
|
navigate: (name: string, params: Record<string, string>) => void
|
|
}
|
|
navigator.navigate(target.name, target.params ?? {})
|
|
}, [navigationRef])
|
|
|
|
// Opened notifications can arrive before auth bootstrap finished (cold
|
|
// start: the native payload is one-shot) or while signed out. Queue the
|
|
// target in the app-lifetime queue and replay it once the authenticated
|
|
// stack is ready instead of dropping it.
|
|
const navigateFromNotification = useCallback((target: NotificationNavigationTarget): void => {
|
|
notificationNavigationQueue.offer(target)
|
|
flushNotificationNavigation()
|
|
}, [flushNotificationNavigation])
|
|
|
|
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])
|
|
|
|
// Invalidated whenever the signed-in user changes (including the null that
|
|
// the logout privacy purge publishes) so an in-flight full sync cannot write
|
|
// the previous account's history cache back after the purge.
|
|
const [historyCacheScope] = useState(createHistoryCacheScope)
|
|
useEffect(() => () => historyCacheScope.invalidate(), [historyCacheScope, user?.id])
|
|
|
|
const synchronizeMissedNotifications = useCallback(async (): Promise<boolean> => {
|
|
const userId = user?.id
|
|
if (!userId) return false
|
|
return synchronizeAfterMissedNotifications(userId, historyCacheScope.capture(), {
|
|
listRecentHistory: (id) => listHistoryPage({ userId: id, filter: 'all', pageSize: 50 }),
|
|
listTeams,
|
|
fetchEntitlement: fetchEntitlementSnapshot,
|
|
refreshEntitlement: () => entitlement.refresh(),
|
|
})
|
|
}, [entitlement, historyCacheScope, 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])
|
|
|
|
// Declared after the onboarding reset so a replayed notification target is
|
|
// pushed on top of the fresh Main root rather than wiped by resetRoot.
|
|
useEffect(() => {
|
|
if (authenticatedAppReady) flushNotificationNavigation()
|
|
}, [authenticatedAppReady, flushNotificationNavigation])
|
|
|
|
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={() => {
|
|
flushNotificationNavigation()
|
|
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="HistoryDetail" component={HistoryDetailScreen} />
|
|
<Stack.Screen name="Devices" component={DevicesScreen} />
|
|
<Stack.Screen name="Dictionary" component={DictionaryScreen} />
|
|
<Stack.Screen name="Commands" component={CommandsScreen} />
|
|
<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="Talk" component={TalkScreen} />
|
|
<Stack.Screen name="Settings" component={SettingsScreen} />
|
|
<Stack.Screen name="UnifiedSearch" component={UnifiedSearchScreen} />
|
|
<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' },
|
|
})
|