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