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,273 @@
import { useMemo } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { Globe, Lock, X } from "lucide-react-native";
import { toast } from "sonner-native";
import type { Particle } from "@/api/types";
import { resolveHumanDisplay } from "@/lib/humans";
import { useNetwork } from "@/hooks/use-networks";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
import {
buildCustomVisibility,
buildNetworkVisibility,
parseVisibleTo,
} from "@/lib/stream-visibility";
import { toUserMessage } from "@/lib/errors";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { BottomSheet } from "@/components/BottomSheet";
import { Avatar } from "@/components/Avatar";
import { useStreamPresence } from "./stream-presence-context";
interface StreamMembersSheetProps {
open: boolean;
onClose: () => void;
networkId: string;
streamParticle: Particle & { type: "stream" };
isCreator: boolean;
}
/**
* Read-only-for-non-creators view of who can see the stream, plus an inline
* editor for creators to flip between network-wide and per-person and to
* add/remove people. Mobile counterpart of stream-members-overlay.tsx.
*/
export function StreamMembersSheet({
open,
onClose,
networkId,
streamParticle,
isCreator,
}: StreamMembersSheetProps) {
useSuspendPlayback(open, "stream-members");
const { onlineHumanIds } = useStreamPresence();
const network = useNetwork(networkId);
const humans = network?.humans ?? [];
const creatorId = streamParticle.created_by_human_id;
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
const docPath = useMemo(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
[networkId, streamParticle.id],
);
const memberIds =
visibility.mode === "network"
? humans.map((h) => h.id)
: visibility.humanIds;
const memberSet = new Set(memberIds);
const availableToAdd = humans.filter((h) => !memberSet.has(h.id));
const apply = async (next: string[]) => {
try {
await updateParticleVisibleTo(docPath, next);
} catch (err) {
toast.error(toUserMessage(err));
}
};
const setNetworkWide = () => apply(buildNetworkVisibility(networkId));
const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId]));
const removeMember = (id: string) => {
if (visibility.mode !== "custom") return;
if (id === creatorId) return;
const next = visibility.humanIds.filter((x) => x !== id);
if (next.length === 0) return;
void apply(buildCustomVisibility(next));
};
const addMember = (id: string) => {
if (visibility.mode !== "custom") return;
void apply(buildCustomVisibility([...visibility.humanIds, id]));
};
return (
<BottomSheet open={open} onClose={onClose} maxHeight="85%">
<View className="flex-row items-center justify-between px-5 pb-3">
<View style={{ width: 22 }} />
<Text className="text-white text-base font-semibold">Members</Text>
<Pressable onPress={onClose} hitSlop={12}>
<X color="rgba(255,255,255,0.7)" size={22} />
</Pressable>
</View>
<View className="px-5 pb-3">
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
Visibility
</Text>
{isCreator ? (
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
<ModePill
active={visibility.mode === "network"}
icon={<Globe color="white" size={14} />}
label="Network-wide"
onPress={setNetworkWide}
/>
<ModePill
active={visibility.mode === "custom"}
icon={<Lock color="white" size={14} />}
label="Specific people"
onPress={setCustomOnlyCreator}
/>
</View>
) : (
<View className="flex-row items-center gap-2">
{visibility.mode === "network" ? (
<>
<Globe color="rgba(255,255,255,0.5)" size={14} />
<Text className="text-white/70 text-sm">
Everyone in {network?.name ?? "network"}
</Text>
</>
) : (
<>
<Lock color="rgba(255,255,255,0.5)" size={14} />
<Text className="text-white/70 text-sm">
{memberIds.length} specific{" "}
{memberIds.length === 1 ? "person" : "people"}
</Text>
</>
)}
</View>
)}
</View>
<ScrollView contentContainerClassName="pb-4">
<View className="px-5 pt-2">
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
{visibility.mode === "network" ? "Has access" : "People"} ·{" "}
{memberIds.length}
</Text>
{memberIds.map((id) => {
const display = resolveHumanDisplay(id, humans);
const isCreatorRow = id === creatorId;
const canRemove =
isCreator && visibility.mode === "custom" && !isCreatorRow;
return (
<View
key={id}
className="flex-row items-center gap-3 py-2.5"
>
<Avatar
humanId={id}
humans={humans}
size="sm"
online={onlineHumanIds.has(id)}
/>
<View className="flex-1">
<Text
className={
display.exists
? "text-white text-sm font-medium"
: "text-white/50 italic text-sm font-medium"
}
numberOfLines={1}
>
{display.displayName}
</Text>
{display.exists ? (
<Text
className="text-white/40 text-xs"
numberOfLines={1}
>
{display.email}
</Text>
) : null}
</View>
{isCreatorRow ? (
<Text className="text-white/30 text-[10px] uppercase tracking-wider">
Creator
</Text>
) : canRemove ? (
<Pressable
onPress={() => removeMember(id)}
hitSlop={10}
accessibilityLabel={`Remove ${display.displayName}`}
>
<X color="rgba(255,255,255,0.6)" size={18} />
</Pressable>
) : null}
</View>
);
})}
</View>
{isCreator &&
visibility.mode === "custom" &&
availableToAdd.length > 0 ? (
<View className="px-5 pt-4 mt-2 border-t border-white/5">
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mt-3 mb-2">
Add people
</Text>
{availableToAdd.map((human) => {
const display = resolveHumanDisplay(human.id, humans);
return (
<Pressable
key={human.id}
onPress={() => addMember(human.id)}
className="flex-row items-center gap-3 py-2.5 active:bg-white/5 rounded-lg"
>
<Avatar
humanId={human.id}
humans={humans}
size="sm"
online={onlineHumanIds.has(human.id)}
/>
<View className="flex-1">
<Text
className="text-white text-sm font-medium"
numberOfLines={1}
>
{display.displayName}
</Text>
<Text
className="text-white/40 text-xs"
numberOfLines={1}
>
{display.email}
</Text>
</View>
<Text className="text-white/60 text-sm">Add</Text>
</Pressable>
);
})}
</View>
) : null}
</ScrollView>
</BottomSheet>
);
}
function ModePill({
active,
icon,
label,
onPress,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 " +
(active ? "bg-white/15" : "")
}
>
{icon}
<Text
className={
active
? "text-white text-xs font-semibold"
: "text-white/60 text-xs"
}
>
{label}
</Text>
</Pressable>
);
}