learning_ai_notes/mobile/src/app/_layout.tsx
Saravana Achu Mac 6620e5dabb fix(mobile): auth-gated hydrates and telemetry flush on background
- Run notes/workspace/inbox hydrate, broadcast/survey polling, and offline
  queue flush timers only when hasBootstrapped && isAuthenticated
- Split effects: kill switch, bootstrap, initPlatform, global AppState flush
- Avoid pre-auth 401s from parallel API hydrates

Made-with: Cursor
2026-03-31 01:51:05 -07:00

543 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { AppState, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useAuthStore, type AuthState } from '../store/auth-store';
import { useInboxStore } from '../store/inbox-store';
import { useNotesStore } from '../store/notes-store';
import { useWorkspaceStore } from '../store/workspace-store';
import { checkKillSwitch, flushTelemetry, initPlatform } from '../lib/platform';
import { getBroadcastClient } from '../lib/broadcast-client';
import { getSurveyClient } from '../lib/survey-client';
import { flushNoteQueue, getNoteQueueSize } from '../lib/offline-queue';
import type { InAppMessage } from '@bytelyst/broadcast-client';
import type { ActiveSurvey, Question, QuestionAnswer } from '@bytelyst/survey-client';
import { colors } from '../theme';
export default function RootLayout() {
const [broadcastMessages, setBroadcastMessages] = useState<InAppMessage[]>([]);
const [activeSurvey, setActiveSurvey] = useState<ActiveSurvey | null>(null);
const [surveyStarted, setSurveyStarted] = useState(false);
const [surveyIndex, setSurveyIndex] = useState(0);
const [surveyAnswers, setSurveyAnswers] = useState<Record<string, QuestionAnswer>>({});
const [textAnswer, setTextAnswer] = useState('');
const [queueStatus, setQueueStatus] = useState<{ pending: number; lastFlushed: number }>({
pending: getNoteQueueSize(),
lastFlushed: 0,
});
const [killSwitchState, setKillSwitchState] = useState<{
checked: boolean;
disabled: boolean;
message: string | null;
}>({
checked: false,
disabled: false,
message: null,
});
const bootstrapAuth = useAuthStore((state: AuthState) => state.bootstrap);
const hasBootstrapped = useAuthStore((state: AuthState) => state.hasBootstrapped);
const isAuthenticated = useAuthStore((state: AuthState) => state.isAuthenticated);
function toSurveyAnswer(question: Question, value: string): QuestionAnswer {
if (question.type === 'single_choice' || question.type === 'dropdown') {
return { type: 'single_choice', optionId: value };
}
if (question.type === 'multiple_choice') {
return { type: 'multiple_choice', optionIds: [value] };
}
if (question.type === 'ranking') {
return { type: 'ranking', rankedOptionIds: [value] };
}
if (question.type === 'rating' || question.type === 'nps' || question.type === 'scale') {
return { type: 'rating', value: Number(value) };
}
return { type: 'text', value };
}
async function flushQueuedNoteMutations(): Promise<void> {
const pending = getNoteQueueSize();
if (pending === 0) {
setQueueStatus({ pending: 0, lastFlushed: 0 });
return;
}
const result = await flushNoteQueue().catch(() => ({ flushed: 0, failed: getNoteQueueSize() }));
setQueueStatus({
pending: result.failed,
lastFlushed: result.flushed,
});
}
async function loadBroadcasts(): Promise<void> {
try {
const { messages } = await getBroadcastClient().listMessages();
setBroadcastMessages(messages.filter((message) => message.status !== 'dismissed'));
} catch {
}
}
async function loadSurvey(): Promise<void> {
try {
const { survey } = await getSurveyClient().getActiveSurvey();
setActiveSurvey((current) => current ?? survey);
} catch {
}
}
useEffect(() => {
void checkKillSwitch()
.then((state) => {
setKillSwitchState({
checked: true,
disabled: state.disabled,
message: state.message,
});
})
.catch(() => {
setKillSwitchState({ checked: true, disabled: false, message: null });
});
}, []);
useEffect(() => {
void bootstrapAuth();
}, [bootstrapAuth]);
useEffect(() => {
void initPlatform();
}, []);
useEffect(() => {
const sub = AppState.addEventListener('change', (nextState) => {
if (nextState === 'background' || nextState === 'inactive') {
flushTelemetry();
}
});
return () => sub.remove();
}, []);
useEffect(() => {
if (!hasBootstrapped || !isAuthenticated) {
return undefined;
}
let cancelled = false;
void (async () => {
await Promise.all([
useNotesStore.getState().hydrate(),
useWorkspaceStore.getState().hydrate(),
useInboxStore.getState().hydrate(),
]);
if (cancelled) {
return;
}
void flushQueuedNoteMutations();
void loadBroadcasts();
void loadSurvey();
})();
const broadcastTimer = setInterval(() => {
void loadBroadcasts();
}, 5 * 60_000);
const surveyTimer = setInterval(() => {
void loadSurvey();
}, 10 * 60_000);
const appStateSubscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
void flushQueuedNoteMutations();
}
});
return () => {
cancelled = true;
clearInterval(broadcastTimer);
clearInterval(surveyTimer);
appStateSubscription.remove();
};
}, [hasBootstrapped, isAuthenticated]);
if (killSwitchState.checked && killSwitchState.disabled) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
backgroundColor: colors.bgCanvas,
gap: 12,
}}
>
<StatusBar style="light" />
<Text style={{ color: colors.textPrimary, fontSize: 24, fontWeight: '700', textAlign: 'center' }}>
NoteLett is temporarily unavailable
</Text>
<Text style={{ color: colors.textSecondary, fontSize: 15, textAlign: 'center' }}>
{killSwitchState.message ?? 'Service access is currently paused by the platform team. Please try again later.'}
</Text>
</View>
);
}
const currentQuestion = activeSurvey?.questions[surveyIndex];
async function dismissBroadcast(messageId: string): Promise<void> {
try {
await getBroadcastClient().markDismissed(messageId);
} catch {
}
setBroadcastMessages((current) => current.filter((message) => message.id !== messageId));
}
async function startSurvey(): Promise<void> {
if (!activeSurvey) {
return;
}
try {
await getSurveyClient().startSurvey(activeSurvey.id);
} catch {
}
setSurveyStarted(true);
}
async function dismissSurvey(): Promise<void> {
if (activeSurvey) {
try {
await getSurveyClient().dismissSurvey(activeSurvey.id);
} catch {
}
}
setActiveSurvey(null);
setSurveyStarted(false);
setSurveyIndex(0);
setSurveyAnswers({});
setTextAnswer('');
}
async function submitAnswer(value: string): Promise<void> {
if (!activeSurvey || !currentQuestion) {
return;
}
const answer = toSurveyAnswer(currentQuestion, value);
setSurveyAnswers((current) => ({
...current,
[currentQuestion.id]: answer,
}));
try {
await getSurveyClient().submitAnswer(activeSurvey.id, currentQuestion.id, answer);
} catch {
return;
}
const isLastQuestion = surveyIndex >= activeSurvey.questions.length - 1;
if (isLastQuestion) {
try {
await getSurveyClient().completeSurvey(activeSurvey.id);
} catch {
}
await dismissSurvey();
return;
}
setSurveyIndex((current) => current + 1);
setTextAnswer('');
}
return (
<View style={styles.root}>
<StatusBar style="light" />
{broadcastMessages.length > 0 ? (
<View style={styles.bannerSection}>
{broadcastMessages.map((message) => (
<View key={message.id} style={styles.bannerCard}>
<View style={styles.bannerContent}>
<Text style={styles.bannerTitle}>{message.title}</Text>
{message.body ? <Text style={styles.bannerBody}>{message.body}</Text> : null}
</View>
{message.dismissible !== false ? (
<Pressable onPress={() => void dismissBroadcast(message.id)}>
<Text style={styles.bannerDismiss}>×</Text>
</Pressable>
) : null}
</View>
))}
</View>
) : null}
{queueStatus.pending > 0 || queueStatus.lastFlushed > 0 ? (
<View style={styles.queueStatusCard}>
<Text style={styles.queueStatusTitle}>Offline sync</Text>
<Text style={styles.queueStatusBody}>Pending actions: {queueStatus.pending}</Text>
<Text style={styles.queueStatusBody}>Flushed on last attempt: {queueStatus.lastFlushed}</Text>
</View>
) : null}
{activeSurvey && !surveyStarted ? (
<View style={styles.surveyPrompt}>
<Text style={styles.surveyPromptText}>{activeSurvey.title}</Text>
<View style={styles.surveyActions}>
<Pressable style={styles.primaryButton} onPress={() => void startSurvey()}>
<Text style={styles.primaryButtonText}>Start</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={() => void dismissSurvey()}>
<Text style={styles.secondaryButtonText}>Dismiss</Text>
</Pressable>
</View>
</View>
) : null}
<View style={styles.stackContainer}>
<Stack
screenOptions={{
headerShown: false,
animation: 'fade',
}}
/>
</View>
<Modal animationType="slide" transparent visible={Boolean(activeSurvey && surveyStarted && currentQuestion)}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>{activeSurvey?.title}</Text>
<Text style={styles.modalQuestion}>{currentQuestion?.text}</Text>
<Text style={styles.modalMeta}>
{surveyIndex + 1}/{activeSurvey?.questions.length ?? 1}
</Text>
{currentQuestion?.options?.length ? (
<ScrollView contentContainerStyle={styles.optionsList}>
{currentQuestion.options.map((option) => (
<Pressable
key={option.id}
style={styles.optionButton}
onPress={() => void submitAnswer(option.id)}
>
<Text style={styles.optionText}>{option.emoji ? `${option.emoji} ` : ''}{option.text}</Text>
</Pressable>
))}
</ScrollView>
) : currentQuestion && (currentQuestion.type === 'rating' || currentQuestion.type === 'nps' || currentQuestion.type === 'scale') ? (
<View style={styles.ratingRow}>
{Array.from(
{ length: (currentQuestion.maxValue ?? 5) - (currentQuestion.minValue ?? 1) + 1 },
(_, index) => (currentQuestion.minValue ?? 1) + index,
).map((value) => (
<Pressable key={value} style={styles.ratingButton} onPress={() => void submitAnswer(String(value))}>
<Text style={styles.ratingText}>{value}</Text>
</Pressable>
))}
</View>
) : (
<>
<TextInput
value={textAnswer}
onChangeText={setTextAnswer}
placeholder="Your answer"
placeholderTextColor={colors.textTertiary}
style={styles.answerInput}
multiline={currentQuestion?.type === 'text_long'}
/>
<Pressable
style={[styles.primaryButton, textAnswer.trim().length === 0 ? styles.disabledButton : null]}
disabled={textAnswer.trim().length === 0}
onPress={() => void submitAnswer(textAnswer.trim())}
>
<Text style={styles.primaryButtonText}>Next</Text>
</Pressable>
</>
)}
<Pressable style={styles.secondaryButton} onPress={() => void dismissSurvey()}>
<Text style={styles.secondaryButtonText}>Close</Text>
</Pressable>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.bgCanvas,
},
queueStatusCard: {
marginHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderDefault,
backgroundColor: colors.surfaceCard,
padding: 10,
gap: 4,
},
queueStatusTitle: {
color: colors.textPrimary,
fontWeight: '700',
fontSize: 13,
},
queueStatusBody: {
color: colors.textSecondary,
fontSize: 12,
},
bannerSection: {
paddingHorizontal: 12,
paddingTop: 10,
gap: 8,
},
bannerCard: {
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderDefault,
backgroundColor: colors.bgElevated,
padding: 10,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
},
bannerContent: {
flex: 1,
gap: 4,
paddingRight: 10,
},
bannerTitle: {
color: colors.textPrimary,
fontWeight: '700',
fontSize: 13,
},
bannerBody: {
color: colors.textSecondary,
fontSize: 12,
},
bannerDismiss: {
color: colors.textSecondary,
fontSize: 18,
lineHeight: 18,
},
surveyPrompt: {
margin: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderDefault,
backgroundColor: colors.surfaceCard,
padding: 12,
gap: 10,
},
surveyPromptText: {
color: colors.textPrimary,
fontWeight: '700',
},
surveyActions: {
flexDirection: 'row',
gap: 8,
},
primaryButton: {
borderRadius: 10,
backgroundColor: colors.accentPrimary,
paddingHorizontal: 14,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: colors.textPrimary,
fontWeight: '700',
},
secondaryButton: {
borderRadius: 10,
borderWidth: 1,
borderColor: colors.borderDefault,
paddingHorizontal: 14,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
},
secondaryButtonText: {
color: colors.textSecondary,
fontWeight: '600',
},
stackContainer: {
flex: 1,
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.45)',
justifyContent: 'center',
paddingHorizontal: 16,
},
modalCard: {
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderDefault,
backgroundColor: colors.bgCanvas,
padding: 16,
gap: 12,
maxHeight: '85%',
},
modalTitle: {
color: colors.textPrimary,
fontWeight: '700',
fontSize: 18,
},
modalQuestion: {
color: colors.textPrimary,
fontSize: 15,
lineHeight: 21,
},
modalMeta: {
color: colors.textSecondary,
fontSize: 12,
},
optionsList: {
gap: 8,
},
optionButton: {
borderRadius: 10,
borderWidth: 1,
borderColor: colors.borderDefault,
paddingHorizontal: 12,
paddingVertical: 10,
},
optionText: {
color: colors.textPrimary,
fontSize: 14,
},
ratingRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
ratingButton: {
width: 38,
height: 38,
borderRadius: 19,
borderWidth: 1,
borderColor: colors.borderDefault,
alignItems: 'center',
justifyContent: 'center',
},
ratingText: {
color: colors.textPrimary,
fontWeight: '600',
},
answerInput: {
borderRadius: 10,
borderWidth: 1,
borderColor: colors.borderDefault,
backgroundColor: colors.bgElevated,
color: colors.textPrimary,
padding: 10,
minHeight: 42,
textAlignVertical: 'top' as const,
},
disabledButton: {
opacity: 0.5,
},
});