06bc91ac13
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable client picker with last-client defaults, account refresh/remove, and softer session handling on unauthorized API responses. Co-authored-by: Cursor <cursoragent@cursor.com>
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
|
|
import { fonts, radii, spacing } from "@/constants/theme";
|
|
import { useAccounts } from "@/contexts/AccountsContext";
|
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
|
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
|
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { formatServerHost } from "@/lib/server-mode";
|
|
|
|
function initials(name: string, email: string) {
|
|
const source = name.trim() || email.trim();
|
|
const parts = source.split(/\s+/).filter(Boolean);
|
|
if (parts.length >= 2) {
|
|
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
|
|
}
|
|
return (source[0] ?? "?").toUpperCase();
|
|
}
|
|
|
|
function displayName(name: string, email: string) {
|
|
const trimmed = name.trim();
|
|
if (trimmed) return trimmed.split(/\s+/)[0] ?? trimmed;
|
|
return email.split("@")[0] ?? email;
|
|
}
|
|
|
|
/** Header control to switch signed-in accounts or add another. */
|
|
export function AccountSwitcher() {
|
|
const { colors } = useAppTheme();
|
|
const authClient = useAuthClient();
|
|
const { data: session } = useSession();
|
|
const {
|
|
accounts,
|
|
activeAccount,
|
|
activeAccountId,
|
|
switchAccount,
|
|
removeAccount,
|
|
refreshAccounts,
|
|
clearActiveAccount,
|
|
} = useAccounts();
|
|
const [open, setOpen] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
const label = displayName(
|
|
activeAccount?.name ?? session?.user.name ?? "",
|
|
activeAccount?.email ?? session?.user.email ?? "",
|
|
);
|
|
const avatar = initials(
|
|
activeAccount?.name ?? session?.user.name ?? "",
|
|
activeAccount?.email ?? session?.user.email ?? "",
|
|
);
|
|
|
|
async function handleAddAccount() {
|
|
setOpen(false);
|
|
await startAdditionalAccountSignIn(clearActiveAccount);
|
|
}
|
|
|
|
async function handleSwitch(accountId: string) {
|
|
if (accountId === activeAccountId) {
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
await switchAccount(accountId);
|
|
}
|
|
|
|
async function handleRefresh() {
|
|
setRefreshing(true);
|
|
try {
|
|
await refreshAccounts();
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
|
|
function handleRemove(accountId: string, label: string) {
|
|
confirmRemoveAccount(
|
|
label,
|
|
() => removeAccount(accountId),
|
|
async (result) => {
|
|
if (result.remainingCount === 0) {
|
|
setOpen(false);
|
|
}
|
|
await finishAccountRemoval({
|
|
result,
|
|
clearActiveAccount,
|
|
signOut: () => authClient.signOut(),
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Switch account"
|
|
hitSlop={8}
|
|
onPress={() => setOpen(true)}
|
|
style={styles.hit}
|
|
>
|
|
<View style={[styles.row, { backgroundColor: colors.muted }]}>
|
|
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
|
|
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
|
|
{avatar}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
style={[styles.name, { color: colors.foreground }]}
|
|
numberOfLines={1}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Ionicons name="chevron-down" size={14} color={colors.mutedForeground} />
|
|
</View>
|
|
</Pressable>
|
|
|
|
<Modal animationType="fade" onRequestClose={() => setOpen(false)} transparent visible={open}>
|
|
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
|
<Pressable
|
|
style={[styles.sheet, { backgroundColor: colors.background }]}
|
|
onPress={(event) => event.stopPropagation()}
|
|
>
|
|
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
|
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Accounts</Text>
|
|
<View style={styles.sheetActions}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Refresh accounts"
|
|
disabled={refreshing}
|
|
hitSlop={8}
|
|
onPress={() => void handleRefresh()}
|
|
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
|
>
|
|
{refreshing ? (
|
|
<ActivityIndicator color={colors.primary} size="small" />
|
|
) : (
|
|
<Ionicons name="refresh" size={20} color={colors.primary} />
|
|
)}
|
|
</Pressable>
|
|
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
|
|
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
<ScrollView keyboardShouldPersistTaps="handled">
|
|
{accounts.map((account) => {
|
|
const isActive = account.id === activeAccountId;
|
|
return (
|
|
<Pressable
|
|
key={account.id}
|
|
accessibilityRole="button"
|
|
onPress={() => void handleSwitch(account.id)}
|
|
style={({ pressed }) => [
|
|
styles.accountRow,
|
|
{
|
|
borderBottomColor: colors.border,
|
|
backgroundColor: isActive ? colors.muted : "transparent",
|
|
},
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
|
|
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
|
|
{initials(account.name, account.email)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.accountMeta}>
|
|
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
|
{account.name || account.email}
|
|
</Text>
|
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
|
{account.email}
|
|
</Text>
|
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
|
{formatServerHost(account.instanceUrl)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.accountActions}>
|
|
{isActive ? (
|
|
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
|
) : null}
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Remove ${account.name || account.email}`}
|
|
hitSlop={8}
|
|
onPress={() =>
|
|
handleRemove(account.id, account.name || account.email)
|
|
}
|
|
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
|
>
|
|
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
|
</Pressable>
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
onPress={() => void handleAddAccount()}
|
|
style={({ pressed }) => [
|
|
styles.addRow,
|
|
{ borderTopColor: colors.border },
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<Ionicons name="add-circle-outline" size={22} color={colors.primary} />
|
|
<Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text>
|
|
</Pressable>
|
|
</ScrollView>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
hit: {
|
|
flexShrink: 1,
|
|
maxWidth: "58%",
|
|
},
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
paddingLeft: 4,
|
|
paddingRight: 8,
|
|
minHeight: 32,
|
|
borderRadius: radii.pill,
|
|
},
|
|
avatar: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
avatarText: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 11,
|
|
},
|
|
name: {
|
|
flexShrink: 1,
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 14,
|
|
lineHeight: 18,
|
|
},
|
|
backdrop: {
|
|
flex: 1,
|
|
justifyContent: "flex-end",
|
|
backgroundColor: "rgba(0,0,0,0.45)",
|
|
},
|
|
sheet: {
|
|
borderTopLeftRadius: radii.xl,
|
|
borderTopRightRadius: radii.xl,
|
|
maxHeight: "70%",
|
|
},
|
|
sheetHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.md,
|
|
borderBottomWidth: 1,
|
|
},
|
|
sheetTitle: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 16,
|
|
},
|
|
sheetActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.md,
|
|
},
|
|
iconButton: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
minWidth: 28,
|
|
minHeight: 28,
|
|
},
|
|
done: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 16,
|
|
},
|
|
accountRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.md,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.md,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
},
|
|
accountMeta: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
accountActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.sm,
|
|
},
|
|
accountName: {
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 15,
|
|
},
|
|
accountSub: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 12,
|
|
lineHeight: 16,
|
|
},
|
|
addRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: spacing.sm,
|
|
paddingVertical: spacing.lg,
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
},
|
|
addLabel: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 15,
|
|
},
|
|
pressed: {
|
|
opacity: 0.75,
|
|
},
|
|
});
|