- 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
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
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,
|
||
},
|
||
});
|