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
@@ -0,0 +1,206 @@
import { useMemo, useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import { ChevronRight, Globe, Lock, X } from "lucide-react-native";
import { toast } from "sonner-native";
import { ComposeDock } from "@/features/compose/ComposeDock";
import { useNetwork } from "@/hooks/use-networks";
import { particlePath } from "@/lib/particle-path";
import { generateRandomName } from "@/lib/random-name";
import { createStreamWithFirstParticle } from "@/lib/upload";
import { toUserMessage } from "@/lib/errors";
import {
buildNetworkVisibility,
parseVisibleTo,
} from "@/lib/stream-visibility";
import { useAuthStore } from "@/stores/auth-store";
import type { RootStackScreenProps } from "@/navigation/types";
import { VisibilityPickerSheet } from "./VisibilityPickerSheet";
const STREAM_NAME_MAX = 60;
/**
* Top-level stream creation. The user names the stream, picks visibility, and
* composes the first particle on one screen — desktop's compose-overlay flow
* collapsed into a touch-native single page.
*/
export function NewStreamScreen({
route,
navigation,
}: RootStackScreenProps<"NewStream">) {
const { networkId } = route.params;
const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id);
const suggestion = useMemo(() => generateRandomName(), []);
const [name, setName] = useState("");
const [visibleTo, setVisibleTo] = useState<string[]>(() =>
buildNetworkVisibility(networkId),
);
const [pickerOpen, setPickerOpen] = useState(false);
const effectiveName = name.trim() || suggestion;
const handleStreamCreated = (streamId: string) => {
navigation.replace("StreamView", { networkId, streamId });
};
const submitText = async (content: string) => {
if (!userId) throw new Error("Not signed in.");
try {
const { streamId } = await createStreamWithFirstParticle({
networkId,
name: effectiveName,
visibleTo,
createdByHumanId: userId,
firstParticle: { type: "text", content },
});
handleStreamCreated(streamId);
} catch (err) {
toast.error(toUserMessage(err));
throw err;
}
};
const submitMedia = async ({
fileUri,
mimeType,
durationMs,
source,
}: {
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
}) => {
if (!userId) throw new Error("Not signed in.");
try {
const { streamId } = await createStreamWithFirstParticle({
networkId,
name: effectiveName,
visibleTo,
createdByHumanId: userId,
firstParticle: {
type: "media",
fileUri,
mimeType,
durationMs,
source,
},
});
handleStreamCreated(streamId);
} catch (err) {
toast.error(toUserMessage(err));
throw err;
}
};
const placeholderPath = particlePath(networkId, []);
const visibility = parseVisibleTo(visibleTo, networkId);
const visibleSummary =
visibility.mode === "network"
? `Everyone in ${network?.name ?? "this network"}`
: `${visibility.humanIds.length} ${
visibility.humanIds.length === 1 ? "person" : "people"
}`;
return (
<View className="flex-1 bg-black">
<StatusBar style="light" />
<SafeAreaView edges={["top"]}>
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Pressable
onPress={() => navigation.goBack()}
hitSlop={12}
accessibilityLabel="Cancel"
>
<X color="white" size={22} strokeWidth={1.8} />
</Pressable>
<Text className="text-white text-base font-semibold">
New stream
</Text>
<View style={{ width: 22 }} />
</View>
</SafeAreaView>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<View className="flex-1 px-6 pt-4">
<Text className="text-white/60 text-xs uppercase tracking-wide mb-2">
Name
</Text>
<TextInput
value={name}
onChangeText={(v) => setName(v.slice(0, STREAM_NAME_MAX))}
placeholder={suggestion}
placeholderTextColor="rgba(255,255,255,0.35)"
autoCapitalize="none"
autoCorrect={false}
maxLength={STREAM_NAME_MAX}
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
/>
<Text className="text-white/60 text-xs uppercase tracking-wide mt-6 mb-2">
Visible to
</Text>
<Pressable
onPress={() => setPickerOpen(true)}
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
>
{visibility.mode === "network" ? (
<Globe color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
) : (
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
)}
<Text className="text-white text-base flex-1" numberOfLines={1}>
{visibleSummary}
</Text>
<ChevronRight
color="rgba(255,255,255,0.5)"
size={18}
strokeWidth={1.6}
/>
</Pressable>
<View className="mt-6 px-1">
<Text className="text-white/50 text-sm">
Hold the button below to record a voice or video message that's
the first particle in your new stream.
</Text>
</View>
</View>
</KeyboardAvoidingView>
<ComposeDock
networkId={networkId}
targetPath={placeholderPath}
silentPresence
submitMedia={submitMedia}
submitText={submitText}
/>
<VisibilityPickerSheet
open={pickerOpen}
onClose={() => setPickerOpen(false)}
networkId={networkId}
networkName={network?.name}
humans={network?.humans ?? []}
selfHumanId={userId}
visibleTo={visibleTo}
onChange={setVisibleTo}
/>
</View>
);
}