Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions app/(tabs)/ai-chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import { useUser } from "@clerk/expo";
import { Stack } from "expo-router";

import { AiChatScreen } from "@/components/ai-chat/ai-chat-screen";
import { FeatureErrorBoundary } from "@/components/errors/FeatureErrorBoundary";

export default function AiChatTabScreen() {
const { user } = useUser();

return (
<>
<Stack.Screen options={{ headerShown: false }} />
<AiChatScreen
avatarUrl={user?.imageUrl}
firstName={user?.firstName}
userId={user?.id}
/>
<FeatureErrorBoundary
fallbackMessage="Your journal entries are still available from the other tabs."
featureName="AI Chat"
>
<AiChatScreen
avatarUrl={user?.imageUrl}
firstName={user?.firstName}
userId={user?.id}
/>
</FeatureErrorBoundary>
</>
);
}
16 changes: 12 additions & 4 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import "../global.css";

import { ClerkProvider, useAuth } from "@clerk/expo";
import { tokenCache } from "@clerk/expo/token-cache";
import { Stack } from "expo-router";
import { Stack, type ErrorBoundaryProps } from "expo-router";
import { useEffect } from "react";

import { AchievementWatcher } from "@/components/achievements/AchievementWatcher";
import { AppLockGate } from "@/components/app-lock/AppLockGate";
import { RootErrorFallback } from "@/components/errors/RootErrorFallback";
import { setSupabaseAccessTokenProvider } from "@/lib/supabase";
import { AppLockProvider } from "@/providers/AppLockProvider";
import { AppDialogProvider } from "@/providers/AppDialogProvider";
import { ConnectivityProvider } from "@/providers/ConnectivityProvider";

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? "";

Expand All @@ -20,13 +22,19 @@ if (!publishableKey) {
export default function RootLayout() {
return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<AppDialogProvider>
<AppStack />
</AppDialogProvider>
<ConnectivityProvider>
<AppDialogProvider>
<AppStack />
</AppDialogProvider>
</ConnectivityProvider>
</ClerkProvider>
);
}

export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
return <RootErrorFallback error={error} retry={retry} />;
}

function AppStack() {
const { getToken, isLoaded, userId } = useAuth();

Expand Down
29 changes: 18 additions & 11 deletions app/insights/report/[periodType].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
ReportShell,
UpdatingBanner,
} from "@/components/insights/report/ReportScreenStates";
import { FeatureErrorBoundary } from "@/components/errors/FeatureErrorBoundary";
import { ReportStatGrid } from "@/components/insights/report/ReportStatGrid";
import { reportColors } from "@/constants/report-theme";
import { useAppDialog } from "@/hooks/useAppDialog";
Expand Down Expand Up @@ -118,17 +119,22 @@ export default function AIInsightReportScreen() {
return (
<ReportShell bottomNavHeight={bottomNavHeight} insetsTop={insets.top}>
<Stack.Screen options={{ headerShown: false }} />
<ScrollView
className="flex-1"
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
contentContainerStyle={{
gap: 18,
paddingBottom: bottomNavHeight + 180,
paddingHorizontal: 24,
paddingTop: Math.max(58, insets.top + 18),
}}
<FeatureErrorBoundary
fallbackMessage="Previously generated reports remain available after this view recovers."
featureName="Reflection Report"
onRetry={reportState.refresh}
>
<ScrollView
className="flex-1"
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
contentContainerStyle={{
gap: 18,
paddingBottom: bottomNavHeight + 180,
paddingHorizontal: 24,
paddingTop: Math.max(58, insets.top + 18),
}}
>
<View className="flex-row items-center justify-between">
<AnimatedIconButton
accessibilityLabel="Back to Insights"
Expand Down Expand Up @@ -291,7 +297,8 @@ export default function AIInsightReportScreen() {
periodType={period.type}
/>
)}
</ScrollView>
</ScrollView>
</FeatureErrorBoundary>
<BottomTabBar activeTab="Insights" />
</ReportShell>
);
Expand Down
2 changes: 1 addition & 1 deletion app/settings/privacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export default function PrivacySettingsScreen() {
<SettingsRow
icon="external-link"
label="External Deletion Page"
onPress={() => router.push(privacyPolicyHref)}
onPress={() => router.push(accountDeletionUrl as Href)}
value="View"
/>
</>
Expand Down
41 changes: 36 additions & 5 deletions components/ai-chat/ai-chat-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AnimatedIconButton } from "@/components/ui/animated-icon-button";
import { useAppDialog } from "@/hooks/useAppDialog";
import { useConnectivity } from "@/hooks/useConnectivity";
import { generateLocalJournalResponse } from "@/lib/ai/localJournalAssistant";
import { generateRemoteJournalResponse } from "@/lib/ai/remoteJournalAssistant";
import { useJournalStore } from "@/store/journal-store";
Expand Down Expand Up @@ -66,6 +67,7 @@ export function AiChatScreen({
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
const { showDialog } = useAppDialog();
const connectivity = useConnectivity();
const requestIdRef = useRef(0);
const scrollViewRef = useRef<ScrollView>(null);
const [message, setMessage] = useState("");
Expand Down Expand Up @@ -117,7 +119,9 @@ export function AiChatScreen({
userId: userId ?? "guest",
} satisfies ChatMessage,
];
const canSendMessage = message.trim().length > 0 && !!userId && !isThinking;
const isOffline = connectivity.status === "offline";
const canSendMessage =
message.trim().length > 0 && !!userId && !isThinking && !isOffline;
const shouldUseKeyboardOffset = process.env.EXPO_OS === "android";
const footerKeyboardOffset = shouldUseKeyboardOffset ? keyboardOffset : 0;

Expand Down Expand Up @@ -211,6 +215,16 @@ export function AiChatScreen({
return;
}

if (isOffline) {
showDialog({
confirmText: "OK",
message:
"Internet is required for AI Chat. Your journal entries are still available offline.",
title: "Internet required",
});
return;
}

const userMessage: ChatMessage = {
content: trimmedMessage,
createdAt: new Date().toISOString(),
Expand Down Expand Up @@ -334,10 +348,19 @@ export function AiChatScreen({
</View>

<View className="flex-row items-center gap-2">
<View className="flex-row items-center gap-2 rounded-full bg-[#CFF8E6] px-2 py-1">
<View className="size-2 rounded-full bg-[#10B981]" />
<Text className="text-[11px] font-bold leading-4 text-[#047857]">
Online
<View
className="flex-row items-center gap-2 rounded-full px-2 py-1"
style={{ backgroundColor: isOffline ? "#FFF7ED" : "#CFF8E6" }}
>
<View
className="size-2 rounded-full"
style={{ backgroundColor: isOffline ? "#F97316" : "#10B981" }}
/>
<Text
className="text-[11px] font-bold leading-4"
style={{ color: isOffline ? "#C2410C" : "#047857" }}
>
{isOffline ? "Offline" : "Online"}
Comment thread
aryansoni-dev marked this conversation as resolved.
</Text>
</View>

Expand Down Expand Up @@ -374,6 +397,14 @@ export function AiChatScreen({
</View>

<View className="mt-6 gap-6">
{isOffline ? (
<View className="rounded-[20px] bg-[#FFF7ED] px-4 py-3">
<Text className="text-[14px] font-semibold leading-6 text-[#9A3412]">
Internet is required for AI Chat. Your journal entries are still
available offline.
</Text>
</View>
) : null}
{visibleMessages.map((chatMessage, index) => (
<ChatBubble
assistantBubbleMaxWidth={assistantBubbleMaxWidth}
Expand Down
8 changes: 7 additions & 1 deletion components/auth/auth-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,9 @@ export function AuthScreen({
return;
}

showError("Google signup needs one more step before continuing.");
showError(
`${getSocialProviderLabel(strategy)} ${mode} needs one more step before continuing.`,
);
} catch (error) {
showError(getClerkErrorMessage(error));
} finally {
Expand Down Expand Up @@ -549,6 +551,10 @@ export function AuthScreen({
);
}

function getSocialProviderLabel(strategy: "oauth_google" | "oauth_apple") {
return strategy === "oauth_apple" ? "Apple" : "Google";
}

type SocialButtonsProps = {
disabled: boolean;
onApplePress: () => void;
Expand Down
16 changes: 16 additions & 0 deletions components/connectivity/OfflineNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { WifiOff } from "lucide-react-native";
import { Text, View } from "react-native";

export function OfflineNotice({ message }: { message?: string }) {
return (
<View className="flex-row items-start gap-3 rounded-[20px] bg-[#FFF7ED] px-4 py-4">
<View className="mt-0.5 size-8 items-center justify-center rounded-full bg-white">
<WifiOff color="#C2410C" size={17} strokeWidth={2.4} />
</View>
<Text className="flex-1 text-[14px] font-semibold leading-6 text-[#9A3412]">
Comment thread
aryansoni-dev marked this conversation as resolved.
{message ??
"You are offline. Changes will be saved on this device and synced later."}
</Text>
</View>
);
}
67 changes: 67 additions & 0 deletions components/errors/FeatureErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";

import { reportAppError } from "@/lib/errors/reportAppError";

type FeatureErrorBoundaryProps = {
children: ReactNode;
fallbackMessage?: string;
featureName: string;
onRetry?: () => void;
};

type FeatureErrorBoundaryState = {
error: Error | null;
};

export class FeatureErrorBoundary extends React.Component<
FeatureErrorBoundaryProps,
FeatureErrorBoundaryState
> {
state: FeatureErrorBoundaryState = {
error: null,
};

static getDerivedStateFromError(error: Error): FeatureErrorBoundaryState {
return { error };
}

componentDidCatch(error: Error) {
reportAppError(error, {
feature: this.props.featureName,
operation: "render",
});
}

render() {
if (!this.state.error) {
return this.props.children;
}

return (
<View className="rounded-[22px] bg-[#FFF1F5] px-4 py-4">
<Text className="text-[16px] font-bold leading-6 text-[#9F1239]">
Comment thread
aryansoni-dev marked this conversation as resolved.
{this.props.featureName} is unavailable right now.
</Text>
<Text className="mt-2 text-[14px] leading-6 text-[#71717B]">
{this.props.fallbackMessage ??
"This part of DearDiary ran into a problem. The rest of the screen is still available."}
</Text>
{this.props.onRetry ? (
<Pressable
accessibilityRole="button"
className="mt-4 min-h-11 items-center justify-center rounded-full bg-white px-4"
onPress={() => {
this.setState({ error: null });
this.props.onRetry?.();
}}
>
<Text className="text-[14px] font-bold leading-5 text-[#FF2056]">
Retry
</Text>
</Pressable>
) : null}
Comment thread
aryansoni-dev marked this conversation as resolved.
</View>
);
}
}
32 changes: 32 additions & 0 deletions components/errors/InlineErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Pressable, Text, View } from "react-native";

import type { AppError } from "@/types/appError";

type InlineErrorMessageProps = {
error: AppError;
onRetry?: () => void;
};

export function InlineErrorMessage({ error, onRetry }: InlineErrorMessageProps) {
return (
<View
accessibilityRole="alert"
className="rounded-[20px] bg-[#FFF1F5] px-4 py-4"
>
<Text className="text-[14px] font-semibold leading-6 text-[#9F1239]">
{error.userMessage}
</Text>
{error.retryable && onRetry ? (
<Pressable
accessibilityRole="button"
className="mt-3 min-h-10 items-center justify-center rounded-full bg-white px-4"
onPress={onRetry}
>
<Text className="text-[14px] font-bold leading-5 text-[#FF2056]">
Retry
</Text>
</Pressable>
) : null}
</View>
);
}
Loading