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