learning_ai_notes/mobile/src/app/_layout.tsx

550 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 { useIntakeStore } from '../store/intake-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();
void useIntakeStore.getState().pollActiveJobs();
})();
const broadcastTimer = setInterval(() => {
void loadBroadcasts();
}, 5 * 60_000);
const surveyTimer = setInterval(() => {
void loadSurvey();
}, 10 * 60_000);
const intakeTimer = setInterval(() => {
void useIntakeStore.getState().pollActiveJobs();
}, 30_000);
const appStateSubscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
void flushQueuedNoteMutations();
}
});
return () => {
cancelled = true;
clearInterval(broadcastTimer);
clearInterval(surveyTimer);
clearInterval(intakeTimer);
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: colors.overlayBackdrop,
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,
},
});