BangSo/apps/mobile/app/account.tsx

736 lines
22 KiB
TypeScript
Raw Normal View History

2026-09-01 16:51:19 +00:00
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<MobileMe | null>(null);
const [password, setPassword] = useState("");
const [pending, setPending] = useState(false);
const [avatarPending, setAvatarPending] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [notifications, setNotifications] = useState<LiveNotificationSettings>(
DEFAULT_LIVE_NOTIFICATION_SETTINGS,
);
const [notificationsReady, setNotificationsReady] = useState(Platform.OS !== "android");
const [notificationPending, setNotificationPending] = useState(false);
const [notificationError, setNotificationError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [passwordPending, setPasswordPending] = useState(false);
const [passwordMessage, setPasswordMessage] = useState<string | null>(null);
const [archivedBots, setArchivedBots] = useState<MobileBot[]>([]);
const [usage, setUsage] = useState<{
runs: number;
inputTokens: number;
outputTokens: number;
} | null>(null);
const { avatarStyle, updateAvatarStyle } = useAvatarStyle();
useEffect(() => {
void rpc<MobileMe>("me")
.then(setMe)
.catch(() => undefined);
void rpc<MobileBot[]>("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 = (
<View accessibilityLabel="Usage" style={styles.profile}>
<Text style={styles.settingsTitle}>Usage</Text>
{usage ? (
<Text style={styles.email}>
{usage.runs} runs · {usage.inputTokens + usage.outputTokens} tokens
</Text>
) : null}
<Text style={styles.settingsExplanation}>Model spend uses your provider keys.</Text>
</View>
);
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 (
<SafeAreaView edges={["bottom"]} style={styles.screen}>
<ScrollView contentContainerStyle={styles.content}>
{focus === "usage" ? usageBlock : null}
<View style={styles.profile}>
<Text style={styles.name}>{me?.name || "Your account"}</Text>
{me?.email ? <Text style={styles.email}>{me.email}</Text> : null}
</View>
{focus !== "usage" ? usageBlock : null}
<View accessibilityLabel="Password" style={styles.profile}>
<Text style={styles.settingsTitle}>Password</Text>
<AccountPasswordInput
label="Current password"
value={currentPassword}
onChange={setCurrentPassword}
autoComplete="current-password"
/>
<AccountPasswordInput
label="New password"
value={newPassword}
onChange={setNewPassword}
autoComplete="new-password"
/>
<AccountPasswordInput
label="Confirm password"
value={passwordConfirmation}
onChange={setPasswordConfirmation}
autoComplete="new-password"
/>
{passwordMessage ? <Text style={styles.passwordMessage}>{passwordMessage}</Text> : null}
<Pressable
accessibilityRole="button"
disabled={passwordPending || !currentPassword || newPassword.length < 8}
onPress={() => void handlePasswordChange()}
style={({ pressed }) => [
styles.changePasswordButton,
(passwordPending || !currentPassword || newPassword.length < 8) && styles.disabled,
pressed && styles.pressed,
]}
>
{passwordPending ? (
<ActivityIndicator color={native.label} />
) : (
<Text style={styles.changePasswordLabel}>Change password</Text>
)}
</Pressable>
</View>
<View accessibilityLabel="Avatar style" style={styles.avatarSection}>
<Text style={styles.settingsTitle}>Avatars</Text>
<View style={styles.avatarOptions}>
{(["robot", "organic"] as const).map((style) => {
const selected = avatarStyle === style;
return (
<Pressable
key={style}
accessibilityLabel={`${style === "robot" ? "Robot" : "Organic"} avatars`}
accessibilityRole="button"
accessibilityState={{ selected, disabled: avatarPending }}
disabled={avatarPending}
onPress={() => void selectAvatarStyle(style)}
style={({ pressed }) => [
styles.avatarOption,
selected && styles.avatarOptionSelected,
pressed && styles.pressed,
]}
>
<BotAvatar
color={style === "robot" ? "#8B5CF6" : "#D62F8B"}
identity="avatar-preview"
size={42}
variant={style}
/>
<Text style={styles.avatarLabel}>{style === "robot" ? "Robot" : "Organic"}</Text>
</Pressable>
);
})}
</View>
{avatarError ? <Text style={styles.error}>{avatarError}</Text> : null}
</View>
{Platform.OS === "android" ? (
<View accessibilityLabel="Notifications" style={styles.profile}>
<Text style={styles.settingsTitle}>Notifications</Text>
<NotificationSwitch
label="Live working status"
detail="While agents are working"
value={notifications.liveConnection}
disabled={notificationPending || !notificationsReady}
onChange={(liveConnection) =>
void updateNotifications({ ...notifications, liveConnection })
}
/>
<NotificationSwitch
label="Agent messages"
detail="Replies and completed work"
value={notifications.messages}
disabled={notificationPending || !notificationsReady}
onChange={(messages) => void updateNotifications({ ...notifications, messages })}
/>
<NotificationSwitch
label="Scheduled tasks"
detail="Alerts from routines"
value={notifications.scheduledTasks}
disabled={notificationPending || !notificationsReady}
onChange={(scheduledTasks) =>
void updateNotifications({ ...notifications, scheduledTasks })
}
/>
<NotificationSwitch
label="Needs attention"
detail="Questions, approvals, takeover"
value={notifications.needsAttention}
disabled={notificationPending || !notificationsReady}
onChange={(needsAttention) =>
void updateNotifications({ ...notifications, needsAttention })
}
/>
<Pressable
accessibilityRole="button"
onPress={() => void openPromotedNotificationSettings()}
style={{ minHeight: 44, justifyContent: "center" }}
>
<Text style={{ color: "#4C8DFF", fontSize: 14 }}>Live update settings</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={() => void openLiveNotificationSettings()}
style={{ minHeight: 44, justifyContent: "center" }}
>
<Text style={{ color: "#4C8DFF", fontSize: 14 }}>Notification settings</Text>
</Pressable>
{notificationError ? <Text style={styles.error}>{notificationError}</Text> : null}
</View>
) : null}
<Pressable
accessibilityRole="button"
disabled={pending}
onPress={() => router.push("/models")}
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
>
<View>
<Text style={styles.settingsTitle}>Models</Text>
<Text style={styles.settingsExplanation}>Choose your provider and active model</Text>
</View>
<Text style={styles.chevron}></Text>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={pending}
onPress={() => router.push("/voice")}
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
>
<View>
<Text style={styles.settingsTitle}>Voice</Text>
<Text style={styles.settingsExplanation}>
Speak replies aloud with ElevenLabs, OpenAI, or Cartesia
</Text>
</View>
<Text style={styles.chevron}></Text>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={pending}
onPress={() => router.push("/integrations")}
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
>
<View>
<Text style={styles.settingsTitle}>Integrations</Text>
<Text style={styles.settingsExplanation}>Connect apps.</Text>
</View>
<Text style={styles.chevron}></Text>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={pending}
onPress={() => void handleSignOut()}
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
>
<Text style={styles.buttonLabel}>Sign out</Text>
</Pressable>
{archivedBots.length > 0 ? (
<View style={styles.archivedSection}>
<Text style={styles.sectionTitle}>Archived bots</Text>
{archivedBots.map((bot) => (
<View key={bot.id} style={styles.archivedRow}>
<Text numberOfLines={1} style={styles.archivedName}>
{bot.name}
</Text>
<Pressable onPress={() => void restoreBot(bot.id)} hitSlop={8}>
<Text style={styles.restoreLabel}>Restore</Text>
</Pressable>
<Pressable
onPress={() =>
confirmDeleteBot(bot, () =>
setArchivedBots((bots) => bots.filter((item) => item.id !== bot.id)),
)
}
hitSlop={8}
>
<Text style={styles.archivedDeleteLabel}>Delete</Text>
</Pressable>
</View>
))}
</View>
) : null}
<View style={styles.dangerZone}>
<Text style={styles.dangerTitle}>Delete account</Text>
<Text style={styles.explanation}>
Enter your current password, then confirm permanent deletion of your account and all
associated data.
</Text>
<TextInput
accessibilityLabel="Current password"
autoCapitalize="none"
autoCorrect={false}
editable={!pending}
onChangeText={(value) => {
setPassword(value);
setError(null);
}}
placeholder="Current password"
placeholderTextColor={native.tertiaryLabel}
secureTextEntry
style={styles.password}
textContentType="password"
value={password}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
accessibilityRole="button"
disabled={pending || !password}
onPress={confirmDeletion}
style={({ pressed }) => [
styles.deleteButton,
(pending || !password) && styles.disabled,
pressed && styles.pressed,
]}
>
{pending ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.deleteLabel}>Delete account</Text>
)}
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
}
function NotificationSwitch({
label,
detail,
value,
disabled,
onChange,
}: {
label: string;
detail: string;
value: boolean;
disabled: boolean;
onChange: (value: boolean) => void;
}) {
return (
<View
style={{
minHeight: 54,
flexDirection: "row",
alignItems: "center",
gap: 12,
}}
>
<View style={{ flex: 1 }}>
<Text style={{ color: native.label, fontSize: 15 }}>{label}</Text>
<Text style={{ color: native.secondaryLabel, fontSize: 12.5, marginTop: 2 }}>{detail}</Text>
</View>
<Switch
accessibilityLabel={label}
accessibilityHint={detail}
disabled={disabled}
value={value}
onValueChange={onChange}
/>
</View>
);
}
function AccountPasswordInput({
label,
value,
onChange,
autoComplete,
}: {
label: string;
value: string;
onChange: (value: string) => void;
autoComplete: "current-password" | "new-password";
}) {
return (
<TextInput
accessibilityLabel={label}
autoCapitalize="none"
autoComplete={autoComplete}
autoCorrect={false}
onChangeText={onChange}
placeholder={label}
placeholderTextColor={native.tertiaryLabel}
secureTextEntry
style={styles.accountPassword}
value={value}
/>
);
}
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,
},
});