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:
Arjun Patel
2026-04-29 17:39:11 -07:00
committed by GitHub
parent 3a11a82cd3
commit e3461dd5cd
110 changed files with 14682 additions and 22 deletions
+167
View File
@@ -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>
);
}