import type { AvatarStyle } from "@rakazo/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useEffect, useState } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useAvatarStyle } from "../components/avatar-style"; import { BotAvatar } from "../components/bot-avatar"; import type { MobileBot } from "../lib/api"; import { changePassword as changeAccountPassword, currentApiBase, deleteAccount, loadSessionToken, type MobileMe, rpc, selectedSpaceId, signOut, } from "../lib/api"; import { confirmDeleteBot } from "../lib/bot-lifecycle"; import { canPostPromotedNotifications, DEFAULT_LIVE_NOTIFICATION_SETTINGS, getLiveNotificationSettings, type LiveNotificationSettings, openLiveNotificationSettings, openPromotedNotificationSettings, setLiveNotificationSettings, } from "../lib/live-notifications"; import { native } from "../lib/native"; import { registerPushToken } from "../lib/push"; export default function Account() { const router = useRouter(); const { focus } = useLocalSearchParams<{ focus?: string }>(); const [me, setMe] = useState(null); const [password, setPassword] = useState(""); const [pending, setPending] = useState(false); const [avatarPending, setAvatarPending] = useState(false); const [avatarError, setAvatarError] = useState(null); const [notifications, setNotifications] = useState( DEFAULT_LIVE_NOTIFICATION_SETTINGS, ); const [notificationsReady, setNotificationsReady] = useState(Platform.OS !== "android"); const [notificationPending, setNotificationPending] = useState(false); const [notificationError, setNotificationError] = useState(null); const [error, setError] = useState(null); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState(""); const [passwordPending, setPasswordPending] = useState(false); const [passwordMessage, setPasswordMessage] = useState(null); const [archivedBots, setArchivedBots] = useState([]); const [usage, setUsage] = useState<{ runs: number; inputTokens: number; outputTokens: number; } | null>(null); const { avatarStyle, updateAvatarStyle } = useAvatarStyle(); useEffect(() => { void rpc("me") .then(setMe) .catch(() => undefined); void rpc("bots/listArchived") .then(setArchivedBots) .catch(() => undefined); void rpc<{ runs: number; inputTokens: number; outputTokens: number }>("usage/summary") .then(setUsage) .catch(() => undefined); if (Platform.OS === "android") { void getLiveNotificationSettings() .then(setNotifications) .catch(() => undefined) .finally(() => setNotificationsReady(true)); } }, []); const usageBlock = ( Usage {usage ? ( {usage.runs} runs · {usage.inputTokens + usage.outputTokens} tokens ) : null} Model spend uses your provider keys. ); async function restoreBot(botId: string) { try { await rpc("bots/restore", { botId }); setArchivedBots((bots) => bots.filter((bot) => bot.id !== botId)); } catch (restoreError) { Alert.alert( "Could not restore bot", restoreError instanceof Error ? restoreError.message : "Try again.", ); } } async function selectAvatarStyle(next: AvatarStyle) { if (next === avatarStyle) return; setAvatarPending(true); setAvatarError(null); try { await updateAvatarStyle(next); } catch { setAvatarError("Couldn't update avatars"); } finally { setAvatarPending(false); } } async function handleSignOut() { setPending(true); setError(null); try { await signOut(); router.dismissAll(); router.replace("/sign-in"); } catch (err) { setError(err instanceof Error ? err.message : "Could not sign out"); setPending(false); } } async function handlePasswordChange() { if (newPassword !== passwordConfirmation) { setPasswordMessage("Passwords do not match"); return; } setPasswordPending(true); setPasswordMessage(null); try { await changeAccountPassword(currentPassword, newPassword); setCurrentPassword(""); setNewPassword(""); setPasswordConfirmation(""); setPasswordMessage("Password updated"); } catch (cause) { setPasswordMessage(cause instanceof Error ? cause.message : "Could not change password"); } finally { setPasswordPending(false); } } async function updateNotifications(next: LiveNotificationSettings) { const previous = notifications; setNotifications(next); setNotificationPending(true); setNotificationError(null); try { await setLiveNotificationSettings( next, currentApiBase(), await loadSessionToken(), selectedSpaceId() ?? "", ); if (next.liveConnection && !(await canPostPromotedNotifications())) { await openPromotedNotificationSettings(); } await registerPushToken(); } catch (cause) { setNotifications(previous); setNotificationError( cause instanceof Error ? cause.message : "Could not update notifications", ); } finally { setNotificationPending(false); } } function confirmDeletion() { setError(null); Alert.alert( "Delete your account?", "This permanently deletes your account, bots, conversations, memories, files, and saved connections. This cannot be undone.", [ { text: "Cancel", style: "cancel" }, { text: "Delete account", style: "destructive", onPress: () => void handleDeletion(), }, ], ); } async function handleDeletion() { setPending(true); setError(null); try { await deleteAccount(password); router.dismissAll(); router.replace("/sign-in"); } catch (err) { setError(err instanceof Error ? err.message : "Could not delete account"); } finally { setPending(false); } } return ( {focus === "usage" ? usageBlock : null} {me?.name || "Your account"} {me?.email ? {me.email} : null} {focus !== "usage" ? usageBlock : null} Password {passwordMessage ? {passwordMessage} : null} void handlePasswordChange()} style={({ pressed }) => [ styles.changePasswordButton, (passwordPending || !currentPassword || newPassword.length < 8) && styles.disabled, pressed && styles.pressed, ]} > {passwordPending ? ( ) : ( Change password )} Avatars {(["robot", "organic"] as const).map((style) => { const selected = avatarStyle === style; return ( void selectAvatarStyle(style)} style={({ pressed }) => [ styles.avatarOption, selected && styles.avatarOptionSelected, pressed && styles.pressed, ]} > {style === "robot" ? "Robot" : "Organic"} ); })} {avatarError ? {avatarError} : null} {Platform.OS === "android" ? ( Notifications void updateNotifications({ ...notifications, liveConnection }) } /> void updateNotifications({ ...notifications, messages })} /> void updateNotifications({ ...notifications, scheduledTasks }) } /> void updateNotifications({ ...notifications, needsAttention }) } /> void openPromotedNotificationSettings()} style={{ minHeight: 44, justifyContent: "center" }} > Live update settings void openLiveNotificationSettings()} style={{ minHeight: 44, justifyContent: "center" }} > Notification settings {notificationError ? {notificationError} : null} ) : null} router.push("/models")} style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]} > Models Choose your provider and active model router.push("/voice")} style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]} > Voice Speak replies aloud with ElevenLabs, OpenAI, or Cartesia router.push("/integrations")} style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]} > Integrations Connect apps. void handleSignOut()} style={({ pressed }) => [styles.button, pressed && styles.pressed]} > Sign out {archivedBots.length > 0 ? ( Archived bots {archivedBots.map((bot) => ( {bot.name} void restoreBot(bot.id)} hitSlop={8}> Restore confirmDeleteBot(bot, () => setArchivedBots((bots) => bots.filter((item) => item.id !== bot.id)), ) } hitSlop={8} > Delete ))} ) : null} Delete account Enter your current password, then confirm permanent deletion of your account and all associated data. { setPassword(value); setError(null); }} placeholder="Current password" placeholderTextColor={native.tertiaryLabel} secureTextEntry style={styles.password} textContentType="password" value={password} /> {error ? {error} : null} [ styles.deleteButton, (pending || !password) && styles.disabled, pressed && styles.pressed, ]} > {pending ? ( ) : ( Delete account )} ); } function NotificationSwitch({ label, detail, value, disabled, onChange, }: { label: string; detail: string; value: boolean; disabled: boolean; onChange: (value: boolean) => void; }) { return ( {label} {detail} ); } function AccountPasswordInput({ label, value, onChange, autoComplete, }: { label: string; value: string; onChange: (value: string) => void; autoComplete: "current-password" | "new-password"; }) { return ( ); } const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: native.page, }, content: { flexGrow: 1, padding: 20, gap: 20, }, profile: { borderRadius: 16, backgroundColor: native.fill, padding: 18, gap: 4, }, name: { color: native.label, fontSize: 20, fontWeight: "600", }, email: { color: native.secondaryLabel, fontSize: 15, }, button: { minHeight: 50, borderRadius: 14, alignItems: "center", justifyContent: "center", backgroundColor: native.fill, }, buttonLabel: { color: native.label, fontSize: 17, fontWeight: "600", }, archivedSection: { borderRadius: 16, backgroundColor: native.fill, padding: 18, gap: 14, }, sectionTitle: { color: native.secondaryLabel, fontSize: 14, fontWeight: "600", }, archivedRow: { flexDirection: "row", alignItems: "center", gap: 14, }, archivedName: { flex: 1, color: native.label, fontSize: 16, }, restoreLabel: { color: native.label, fontSize: 14, fontWeight: "600", }, archivedDeleteLabel: { color: "#FF6961", fontSize: 14, }, settingsButton: { minHeight: 62, borderRadius: 14, backgroundColor: native.fill, paddingHorizontal: 16, paddingVertical: 12, flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, avatarSection: { borderRadius: 16, backgroundColor: native.fill, padding: 18, gap: 14, }, avatarOptions: { flexDirection: "row", gap: 12, }, avatarOption: { flex: 1, minHeight: 86, borderRadius: 14, borderWidth: StyleSheet.hairlineWidth, borderColor: native.tertiaryLabel, alignItems: "center", justifyContent: "center", gap: 8, }, avatarOptionSelected: { borderColor: native.label, backgroundColor: native.fillPressed, }, avatarLabel: { color: native.label, fontSize: 14, fontWeight: "600", }, settingsTitle: { color: native.label, fontSize: 17, fontWeight: "600", }, settingsExplanation: { color: native.secondaryLabel, fontSize: 13, marginTop: 3, }, accountPassword: { minHeight: 46, borderRadius: 12, backgroundColor: native.fillPressed, color: native.label, paddingHorizontal: 14, marginTop: 8, }, passwordMessage: { color: native.secondaryLabel, fontSize: 13, marginTop: 8, }, changePasswordButton: { minHeight: 44, borderRadius: 12, alignItems: "center", justifyContent: "center", backgroundColor: native.fillPressed, marginTop: 10, }, changePasswordLabel: { color: native.label, fontSize: 15, fontWeight: "600", }, chevron: { color: native.secondaryLabel, fontSize: 28, fontWeight: "300", }, dangerZone: { marginTop: 12, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, borderColor: "#5A2426", padding: 18, }, dangerTitle: { color: "#FF6961", fontSize: 17, fontWeight: "600", }, explanation: { color: native.secondaryLabel, fontSize: 14, lineHeight: 20, marginTop: 8, }, password: { height: 48, borderRadius: 12, backgroundColor: native.fill, color: native.label, paddingHorizontal: 14, marginTop: 16, fontSize: 16, }, error: { color: "#FF6961", fontSize: 14, marginTop: 10, }, deleteButton: { minHeight: 50, borderRadius: 12, alignItems: "center", justifyContent: "center", backgroundColor: "#C9363E", marginTop: 14, }, deleteLabel: { color: "#FFFFFF", fontSize: 16, fontWeight: "700", }, disabled: { opacity: 0.45, }, pressed: { opacity: 0.7, }, });