Files
llink/js/mobile/src/features/networks/Drawer.tsx
T

166 lines
4.9 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
Animated,
Dimensions,
Easing,
Modal,
Pressable,
Text,
View,
} from 'react-native';
import {
initialWindowMetrics,
SafeAreaProvider,
SafeAreaView,
} from 'react-native-safe-area-context';
import { useAuthStore } from '@/stores/auth-store';
const SCREEN_WIDTH = Dimensions.get('window').width;
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
const ANIM_MS = 220;
interface DrawerProps {
open: boolean;
onClose: () => void;
onNavigateAccount: () => void;
onNavigateSettings: () => void;
}
export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
// Lazy-init so each Animated.Value is created once; the setters are never
// called — the values are mutated internally by the native driver.
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
const [backdropOpacity] = useState(() => new Animated.Value(0));
useEffect(() => {
Animated.parallel([
Animated.timing(translateX, {
toValue: open ? 0 : -DRAWER_WIDTH,
duration: ANIM_MS,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: open ? 0.4 : 0,
duration: ANIM_MS,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}, [open, translateX, backdropOpacity]);
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
const isSigningOut = useAuthStore((s) => s.isSigningOut);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
return (
<Modal
visible={open}
transparent
animationType="none"
onRequestClose={onClose}
>
{/* Modal mounts a separate native view tree on iOS — without a fresh
SafeAreaProvider seeded with initialWindowMetrics, useSafeAreaInsets
inside reports {0,0,0,0} on the first frame and content snaps from
the status bar down to the safe area once metrics resolve. */}
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View className="flex-1">
<Animated.View
pointerEvents={open ? 'auto' : 'none'}
style={{ opacity: backdropOpacity }}
className="absolute inset-0 bg-black"
>
<Pressable className="flex-1" onPress={onClose} />
</Animated.View>
<Animated.View
style={{
width: DRAWER_WIDTH,
transform: [{ translateX }],
}}
className="absolute left-0 top-0 bottom-0 bg-sidebar"
>
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
{initials}
</Text>
</View>
<View className="flex-1">
<Text
className="text-sidebar-foreground text-base font-medium"
numberOfLines={1}
>
{user?.email_prefix ?? ''}
</Text>
<Text
className="text-muted-foreground text-xs"
numberOfLines={1}
>
{user?.email ?? ''}
</Text>
</View>
</View>
<View className="flex-1 py-2">
<DrawerRow
label="Account"
onPress={() => {
onClose();
onNavigateAccount();
}}
/>
</View>
<View className="border-sidebar-border border-t px-2 py-2">
<DrawerRow
label={isSigningOut ? 'Signing out...' : 'Sign out'}
disabled={isSigningOut}
onPress={() => {
void signOut();
}}
tone="destructive"
/>
</View>
</SafeAreaView>
</Animated.View>
</View>
</SafeAreaProvider>
</Modal>
);
}
function DrawerRow({
label,
onPress,
disabled,
tone = 'default',
}: {
label: string;
onPress: () => void;
disabled?: boolean;
tone?: 'default' | 'destructive';
}) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
className="px-5 py-3 active:bg-sidebar-accent"
>
<Text
className={`text-base font-medium ${
tone === 'destructive'
? 'text-destructive'
: 'text-sidebar-foreground'
} ${disabled ? 'opacity-50' : ''}`}
>
{label}
</Text>
</Pressable>
);
}