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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user