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
+60
View File
@@ -0,0 +1,60 @@
import { Text, View } from "react-native";
import type { Human } from "@/api/types";
import { resolveHumanDisplay } from "@/lib/humans";
import { cn } from "@/lib/utils";
type Size = "xs" | "sm" | "md";
interface AvatarProps {
humanId: string | null | undefined;
humans: Human[] | undefined;
size?: Size;
/** True for online presence — adds a green ring (matches desktop). */
online?: boolean;
/** Background ring used to separate stacked avatars from the chrome. */
stackBg?: string;
className?: string;
}
const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
xs: { box: "h-6 w-6", text: "text-[9px]", ring: 1.5 },
sm: { box: "h-9 w-9", text: "text-xs", ring: 2 },
md: { box: "h-10 w-10", text: "text-sm", ring: 2 },
};
/**
* Initials avatar with optional online ring (green) and an optional outer
* stack ring used to visually separate overlapping avatars on a busy chrome.
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`).
*/
export function Avatar({
humanId,
humans,
size = "sm",
online = false,
stackBg,
className,
}: AvatarProps) {
const { initials } = resolveHumanDisplay(humanId, humans);
const dims = sizeMap[size];
return (
<View
className={cn(
"bg-black/15 items-center justify-center rounded-full",
dims.box,
className,
)}
style={{
// Online ring is the priority; if not online, show the stack
// separator ring (if requested) so adjacent avatars stay distinct.
borderWidth: online ? dims.ring : stackBg ? dims.ring : 0,
borderColor: online ? "#22c55e" : stackBg ?? "transparent",
}}
>
<Text className={cn("text-white font-semibold", dims.text)}>
{initials}
</Text>
</View>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from "react";
import {
Dimensions,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
View,
} from "react-native";
import {
initialWindowMetrics,
SafeAreaProvider,
SafeAreaView,
} from "react-native-safe-area-context";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Easing,
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
const SCREEN_HEIGHT = Dimensions.get("window").height;
const ANIMATION_MS = 240;
interface BottomSheetProps {
open: boolean;
onClose: () => void;
/** When true, wrap content in KeyboardAvoidingView so the sheet floats above the keyboard. */
avoidKeyboard?: boolean;
/**
* Cap on the sheet's height. Defaults to 85%; pass a string like "60%" or
* a number of px when content has a stable footprint.
*/
maxHeight?: number | `${number}%`;
children: React.ReactNode;
}
/**
* Shared modal sheet shell. Handles slide-in animation, backdrop fade,
* drag-to-dismiss, and modal-safe SafeAreaProvider seeding so iOS modals get
* correct insets on the first frame. The drag handle at the top is rendered
* here too, so callers don't need to draw it themselves.
*/
export function BottomSheet({
open,
onClose,
avoidKeyboard = false,
maxHeight = "85%",
children,
}: BottomSheetProps) {
// Mount slightly past `open` so the slide-in animation has its starting
// position rendered, and the slide-out animation can play before unmount.
const [mounted, setMounted] = useState(false);
const translateY = useSharedValue(SCREEN_HEIGHT);
useEffect(() => {
if (open) {
setMounted(true);
requestAnimationFrame(() => {
translateY.value = withSpring(0, {
damping: 24,
stiffness: 260,
mass: 0.7,
});
});
} else if (mounted) {
translateY.value = withTiming(
SCREEN_HEIGHT,
{ duration: ANIMATION_MS, easing: Easing.in(Easing.cubic) },
(finished) => {
if (finished) runOnJS(setMounted)(false);
},
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const sheetPan = Gesture.Pan()
.activeOffsetY(10)
.failOffsetX([-25, 25])
.onUpdate((e) => {
"worklet";
translateY.value = Math.max(0, e.translationY);
})
.onEnd((e) => {
"worklet";
if (e.translationY > 120 || e.velocityY > 800) {
runOnJS(onClose)();
} else {
translateY.value = withSpring(0, {
damping: 24,
stiffness: 260,
mass: 0.7,
});
}
});
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const backdropStyle = useAnimatedStyle(() => {
const opacity = interpolate(
translateY.value,
[0, SCREEN_HEIGHT * 0.7],
[0.55, 0],
Extrapolation.CLAMP,
);
return { opacity };
});
if (!mounted) return null;
const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View;
const wrapperProps = avoidKeyboard
? { behavior: Platform.OS === "ios" ? ("padding" as const) : undefined }
: {};
return (
<Modal
visible={mounted}
transparent
animationType="none"
onRequestClose={onClose}
>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View style={{ flex: 1 }}>
<Animated.View
pointerEvents={open ? "auto" : "none"}
style={[
{ position: "absolute", inset: 0, backgroundColor: "black" },
backdropStyle,
]}
>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Wrapper
{...wrapperProps}
style={{ flex: 1, justifyContent: "flex-end" }}
pointerEvents="box-none"
>
<GestureDetector gesture={sheetPan}>
<Animated.View
style={[
{
backgroundColor: "#1c1c1c",
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
overflow: "hidden",
maxHeight,
},
sheetStyle,
]}
>
<SafeAreaView edges={["bottom"]}>
<View className="px-5 pt-3 items-center">
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
</View>
{children}
</SafeAreaView>
</Animated.View>
</GestureDetector>
</Wrapper>
</View>
</SafeAreaProvider>
</Modal>
);
}
@@ -0,0 +1,95 @@
import { useEffect } from "react";
import { Text, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import type { Human } from "@/api/types";
import { resolveHumanDisplay } from "@/lib/humans";
import type { ComposingUser } from "@/features/stream-view/stream-presence-context";
interface ComposingIndicatorProps {
users: ComposingUser[];
networkHumans?: Human[];
}
/**
* Slim horizontal pill stack pinned just under the metadata header. Each
* pill is the typing/recording indicator for a single user. Rendered above
* the particle canvas with a translucent background so it reads on any
* media. Mobile equivalent of desktop's vertical writing-mode indicator.
*/
export function ComposingIndicator({
users,
networkHumans,
}: ComposingIndicatorProps) {
if (users.length === 0) return null;
return (
<View pointerEvents="none" className="flex-row flex-wrap items-center gap-1.5">
{users.map((u) => {
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
const label =
u.mode === "recording"
? `${displayName} is recording`
: u.mode === "screen"
? `${displayName} is sharing`
: `${displayName} is typing`;
return (
<View
key={u.humanId}
className="bg-white/15 flex-row items-center gap-1.5 rounded-full px-2 py-1"
>
<BouncingDots />
<Text className="text-white/80 text-[11px] font-medium">
{label}
</Text>
</View>
);
})}
</View>
);
}
function BouncingDots() {
return (
<View className="flex-row items-end gap-0.5" style={{ height: 8 }}>
<Dot delay={0} />
<Dot delay={150} />
<Dot delay={300} />
</View>
);
}
function Dot({ delay }: { delay: number }) {
const y = useSharedValue(0);
useEffect(() => {
const start = setTimeout(() => {
y.value = withRepeat(
withTiming(-3, {
duration: 360,
easing: Easing.inOut(Easing.quad),
}),
-1,
true,
);
}, delay);
return () => clearTimeout(start);
}, [delay, y]);
const style = useAnimatedStyle(() => ({
transform: [{ translateY: y.value }],
}));
return (
<Animated.View
className="bg-white/85 h-1 w-1 rounded-full"
style={style}
/>
);
}
@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { Text, type TextProps } from "react-native";
import { formatDistanceToNow } from "@/lib/time-utils";
const MINUTE_MS = 60_000;
interface RelativeTimestampProps extends Omit<TextProps, "children"> {
date: Date;
}
/**
* Re-renders once per minute so labels like "5m ago" stay accurate without
* any per-card timer wiring at the call site.
*/
export function RelativeTimestamp({ date, ...rest }: RelativeTimestampProps) {
const [, force] = useState(0);
useEffect(() => {
const interval = setInterval(() => force((n) => n + 1), MINUTE_MS);
return () => clearInterval(interval);
}, []);
return <Text {...rest}>{formatDistanceToNow(date)}</Text>;
}