mobile v0.1 with deployment for ios (#191)
* stage 1: project init * stage 2: skeleton with navigation * step 2.5: streams list * step 4: stream playback experience * step 5-6: compose experience * fix: broken record * transcode media particles to mp4 * build: reproducible go generate * build: rename skaffold module for particle processor worker * infra: increase particle processor worker resources Was dealing with OOM errors * tweaks to mobile * log transcode work * view on desktop placeholder * tweak padding * cap video resolution to save on memory * infra: bump memory limits as insurance * ux improvements * update bundle id for mobile * config for mobile
This commit was merged in pull request #191.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef } 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) {
|
||||
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import type { Network } from "@/api/types";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { Drawer } from "./Drawer";
|
||||
|
||||
export function NetworkListScreen({
|
||||
navigation,
|
||||
}: RootStackScreenProps<"NetworkList">) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const { data, isLoading, refetch, error } = useNetworks();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
// Local refreshing state — driving RefreshControl from react-query's
|
||||
// isRefetching can leave the native spinner visually stuck after the
|
||||
// screen is detached/reattached by native-stack.
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refetch();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Pressable
|
||||
onPress={() => setDrawerOpen(true)}
|
||||
accessibilityLabel="Open menu"
|
||||
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
<Text className="text-muted-foreground text-xs font-semibold">
|
||||
{initials}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text className="text-foreground text-base font-semibold">Flowy</Text>
|
||||
<View className="w-9" />
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-destructive text-center">
|
||||
{toUserMessage(error)}
|
||||
</Text>
|
||||
<Pressable onPress={() => refetch()} className="mt-3 px-4 py-2">
|
||||
<Text className="text-foreground font-medium">Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : !data || data.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerClassName="p-4 gap-2"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<NetworkCard
|
||||
network={item}
|
||||
onPress={() =>
|
||||
navigation.navigate("StreamList", { networkId: item.id })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onNavigateAccount={() => navigation.navigate("Account")}
|
||||
onNavigateSettings={() => navigation.navigate("Settings")}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkCard({
|
||||
network,
|
||||
onPress,
|
||||
}: {
|
||||
network: Network;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="bg-card border-border active:bg-accent rounded-lg border px-4 py-4 flex-row items-center justify-between"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-card-foreground text-base font-semibold">
|
||||
{network.name}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{network.humans.length}{" "}
|
||||
{network.humans.length === 1 ? "member" : "members"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground text-xl">›</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-foreground text-lg font-medium text-center">
|
||||
You aren't in any networks yet.
|
||||
</Text>
|
||||
<Text className="text-muted-foreground mt-2 text-center">
|
||||
Ask a friend for an invite, or create one on desktop.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user