ux improvements
This commit is contained in:
@@ -3,7 +3,10 @@ import { StatusBar } from "expo-status-bar";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { NavigationContainer } from "@react-navigation/native";
|
import { NavigationContainer } from "@react-navigation/native";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import {
|
||||||
|
initialWindowMetrics,
|
||||||
|
SafeAreaProvider,
|
||||||
|
} from "react-native-safe-area-context";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
import { createQueryClient } from "@/lib/query-client";
|
import { createQueryClient } from "@/lib/query-client";
|
||||||
import { PusherProvider } from "@/lib/pusher-provider";
|
import { PusherProvider } from "@/lib/pusher-provider";
|
||||||
@@ -23,7 +26,7 @@ export default function App() {
|
|||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<PusherProvider>
|
<PusherProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<RootNavigator />
|
<RootNavigator />
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Modal, Pressable, Text, View } from "react-native";
|
import { Modal, Pressable, Text, View } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import {
|
||||||
|
initialWindowMetrics,
|
||||||
|
SafeAreaProvider,
|
||||||
|
SafeAreaView,
|
||||||
|
} from "react-native-safe-area-context";
|
||||||
import { useVideoPlayer, VideoView } from "expo-video";
|
import { useVideoPlayer, VideoView } from "expo-video";
|
||||||
import { Mic } from "lucide-react-native";
|
import { Mic } from "lucide-react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -63,9 +67,9 @@ export function ReviewSheet({
|
|||||||
visible={open}
|
visible={open}
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
transparent={false}
|
transparent={false}
|
||||||
statusBarTranslucent
|
|
||||||
onRequestClose={onCancel}
|
onRequestClose={onCancel}
|
||||||
>
|
>
|
||||||
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<View className="flex-1 bg-black">
|
<View className="flex-1 bg-black">
|
||||||
{uri ? (
|
{uri ? (
|
||||||
mode === "audio" ? (
|
mode === "audio" ? (
|
||||||
@@ -144,6 +148,7 @@ export function ReviewSheet({
|
|||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
|
</SafeAreaProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import {
|
||||||
|
initialWindowMetrics,
|
||||||
|
SafeAreaProvider,
|
||||||
|
SafeAreaView,
|
||||||
|
} from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from "@/lib/errors";
|
||||||
@@ -85,9 +89,9 @@ export function TextComposeModal({
|
|||||||
visible={open}
|
visible={open}
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
transparent={false}
|
transparent={false}
|
||||||
statusBarTranslucent
|
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
|
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
@@ -142,6 +146,7 @@ export function TextComposeModal({
|
|||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
</SafeAreaProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import {
|
||||||
|
initialWindowMetrics,
|
||||||
|
SafeAreaProvider,
|
||||||
|
SafeAreaView,
|
||||||
|
} from "react-native-safe-area-context";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get("window").width;
|
const SCREEN_WIDTH = Dimensions.get("window").width;
|
||||||
@@ -26,7 +30,6 @@ export function Drawer({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onNavigateAccount,
|
onNavigateAccount,
|
||||||
onNavigateSettings,
|
|
||||||
}: DrawerProps) {
|
}: DrawerProps) {
|
||||||
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
||||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||||
@@ -60,8 +63,12 @@ export function Drawer({
|
|||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
statusBarTranslucent
|
|
||||||
>
|
>
|
||||||
|
{/* Modal mounts a separate native view tree on iOS — without a fresh
|
||||||
|
SafeAreaProvider seeded with initialWindowMetrics, useSafeAreaInsets
|
||||||
|
inside reports {0,0,0,0} on the first frame and content snaps from
|
||||||
|
the status bar down to the safe area once metrics resolve. */}
|
||||||
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents={open ? "auto" : "none"}
|
pointerEvents={open ? "auto" : "none"}
|
||||||
@@ -109,13 +116,6 @@ export function Drawer({
|
|||||||
onNavigateAccount();
|
onNavigateAccount();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<DrawerRow
|
|
||||||
label="Settings"
|
|
||||||
onPress={() => {
|
|
||||||
onClose();
|
|
||||||
onNavigateSettings();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="border-sidebar-border border-t px-2 py-2">
|
<View className="border-sidebar-border border-t px-2 py-2">
|
||||||
@@ -131,6 +131,7 @@ export function Drawer({
|
|||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
</SafeAreaProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
@@ -19,10 +19,23 @@ export function NetworkListScreen({
|
|||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<"NetworkList">) {
|
}: RootStackScreenProps<"NetworkList">) {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const { data, isLoading, isRefetching, refetch, error } = useNetworks();
|
const { data, isLoading, refetch, error } = useNetworks();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
||||||
|
|
||||||
|
// Local refreshing state — driving RefreshControl from react-query's
|
||||||
|
// isRefetching can leave the native spinner visually stuck after the
|
||||||
|
// screen is detached/reattached by native-stack.
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await refetch();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||||
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||||
@@ -60,10 +73,7 @@ export function NetworkListScreen({
|
|||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerClassName="p-4 gap-2"
|
contentContainerClassName="p-4 gap-2"
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
refreshing={isRefetching}
|
|
||||||
onRefresh={() => refetch()}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<NetworkCard
|
<NetworkCard
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface MediaParticleViewProps {
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress: (ratio: number) => void;
|
onProgress: (ratio: number) => void;
|
||||||
|
/** "cover" fills the screen (may crop); "contain" fits the whole frame. */
|
||||||
|
contentFit?: "cover" | "contain";
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICK_MS = 150;
|
const TICK_MS = 150;
|
||||||
@@ -37,6 +39,7 @@ export function MediaParticleView({
|
|||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
contentFit = "cover",
|
||||||
}: MediaParticleViewProps) {
|
}: MediaParticleViewProps) {
|
||||||
const activeObjectId =
|
const activeObjectId =
|
||||||
particle.properties.transcoded_object_id ?? particle.properties.object_id;
|
particle.properties.transcoded_object_id ?? particle.properties.object_id;
|
||||||
@@ -63,6 +66,7 @@ export function MediaParticleView({
|
|||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={onEnded}
|
onEnded={onEnded}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
|
contentFit={contentFit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,6 +78,7 @@ function PlayableMediaView({
|
|||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
contentFit,
|
||||||
}: {
|
}: {
|
||||||
particle: MediaParticle;
|
particle: MediaParticle;
|
||||||
activeObjectId: string;
|
activeObjectId: string;
|
||||||
@@ -81,6 +86,7 @@ function PlayableMediaView({
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress: (ratio: number) => void;
|
onProgress: (ratio: number) => void;
|
||||||
|
contentFit: "cover" | "contain";
|
||||||
}) {
|
}) {
|
||||||
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
||||||
const [resolveError, setResolveError] = useState<Error | null>(null);
|
const [resolveError, setResolveError] = useState<Error | null>(null);
|
||||||
@@ -206,16 +212,16 @@ function PlayableMediaView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video: full-bleed. The PRD calls for fit-fill (cover) so portrait mobile
|
// Video: full-bleed. Default cover so portrait mobile captures fill the
|
||||||
// captures fill the screen — desktop pillarboxes its 4:3 captures to feel
|
// screen; the user can flip to contain via the top-right toggle when desktop
|
||||||
// similarly framed.
|
// captures at odd aspect ratios get cropped uncomfortably.
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-black">
|
<View className="flex-1 bg-black">
|
||||||
<VideoView
|
<VideoView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
player={player}
|
player={player}
|
||||||
nativeControls={false}
|
nativeControls={false}
|
||||||
contentFit="cover"
|
contentFit={contentFit}
|
||||||
allowsFullscreen={false}
|
allowsFullscreen={false}
|
||||||
allowsPictureInPicture={false}
|
allowsPictureInPicture={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import {
|
||||||
|
initialWindowMetrics,
|
||||||
|
SafeAreaProvider,
|
||||||
|
SafeAreaView,
|
||||||
|
} from "react-native-safe-area-context";
|
||||||
import { Send, X } from "lucide-react-native";
|
import { Send, X } from "lucide-react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import {
|
import {
|
||||||
@@ -182,9 +186,9 @@ export function ReactionSheet({
|
|||||||
visible={mounted}
|
visible={mounted}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
statusBarTranslucent
|
|
||||||
onRequestClose={dismiss}
|
onRequestClose={dismiss}
|
||||||
>
|
>
|
||||||
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents={open ? "auto" : "none"}
|
pointerEvents={open ? "auto" : "none"}
|
||||||
@@ -357,6 +361,7 @@ export function ReactionSheet({
|
|||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</View>
|
</View>
|
||||||
|
</SafeAreaProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { Plus } from "lucide-react-native";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||||
|
import type { Human } from "@/api/types";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||||
|
|
||||||
|
interface ReactionStackProps {
|
||||||
|
reactions: Reactions;
|
||||||
|
currentHumanId: string;
|
||||||
|
humans: Human[] | undefined;
|
||||||
|
/** Toggle a reaction (emoji or text) — same contract as ReactionSheet's onToggle. */
|
||||||
|
onToggle: (key: string) => void;
|
||||||
|
/** Open the full reaction sheet for emoji + custom-text picking. */
|
||||||
|
onOpenSheet: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-edge reaction stack — mobile counterpart of desktop's ReactionBar.
|
||||||
|
* Sits vertically centered on the right side of the canvas so the user can
|
||||||
|
* see existing reactions at a glance and tap to toggle their own. The "+"
|
||||||
|
* affordance opens the ReactionSheet for the full picker (emoji or text).
|
||||||
|
*/
|
||||||
|
export function ReactionStack({
|
||||||
|
reactions,
|
||||||
|
currentHumanId,
|
||||||
|
humans,
|
||||||
|
onToggle,
|
||||||
|
onOpenSheet,
|
||||||
|
}: ReactionStackProps) {
|
||||||
|
const activeEmojis = REACTION_EMOJIS.filter(
|
||||||
|
(emoji) => reactions?.[emoji] && (reactions[emoji]?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const activeTextKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(reactions ?? {}).filter(
|
||||||
|
(k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
||||||
|
),
|
||||||
|
[reactions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = (key: string) => {
|
||||||
|
void Haptics.selectionAsync();
|
||||||
|
onToggle(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="items-end gap-1.5">
|
||||||
|
{activeEmojis.map((emoji) => {
|
||||||
|
const reactors = reactions?.[emoji] ?? [];
|
||||||
|
const isMine = reactors.includes(currentHumanId);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={emoji}
|
||||||
|
onPress={() => handleToggle(emoji)}
|
||||||
|
className={cn(
|
||||||
|
"flex-row items-center gap-1 rounded-full px-2 py-1",
|
||||||
|
isMine ? "bg-white/25" : "bg-black/45",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
isMine
|
||||||
|
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-sm">{emoji}</Text>
|
||||||
|
<Text className="text-white/85 text-xs font-medium">
|
||||||
|
{reactors.length}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{activeTextKeys.map((text) => {
|
||||||
|
const reactors = reactions?.[text] ?? [];
|
||||||
|
const isMine = reactors.includes(currentHumanId);
|
||||||
|
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={text}
|
||||||
|
onPress={() => handleToggle(text)}
|
||||||
|
className={cn(
|
||||||
|
"flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5",
|
||||||
|
isMine ? "bg-white/25" : "bg-black/45",
|
||||||
|
)}
|
||||||
|
style={[
|
||||||
|
{ maxWidth: 200 },
|
||||||
|
isMine
|
||||||
|
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
||||||
|
: null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
|
||||||
|
<Text className="text-white text-[9px] font-semibold">
|
||||||
|
{firstReactor.initials}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-white/90 text-xs"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
{reactors.length > 1 ? (
|
||||||
|
<Text className="text-white/60 text-xs">{reactors.length}</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpenSheet}
|
||||||
|
accessibilityLabel="Add reaction"
|
||||||
|
className="h-8 w-8 items-center justify-center rounded-full bg-black/45"
|
||||||
|
>
|
||||||
|
<Plus color="rgba(255,255,255,0.85)" size={16} strokeWidth={2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Pressable, Text, TextInput, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toUserMessage } from "@/lib/errors";
|
||||||
|
import { updateParticleProperties } from "@/lib/firestore-particles";
|
||||||
|
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
|
import { BottomSheet } from "@/components/BottomSheet";
|
||||||
|
|
||||||
|
interface RenameStreamSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
networkId: string;
|
||||||
|
streamId: string;
|
||||||
|
currentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameStreamSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
networkId,
|
||||||
|
streamId,
|
||||||
|
currentName,
|
||||||
|
}: RenameStreamSheetProps) {
|
||||||
|
useSuspendPlayback(open, "rename-stream");
|
||||||
|
|
||||||
|
const [name, setName] = useState(currentName);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(currentName);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [open, currentName]);
|
||||||
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const canSave = !saving && trimmed.length > 0 && trimmed !== currentName;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const docPath = toFirestoreDocPath(particlePath(networkId, [streamId]));
|
||||||
|
await updateParticleProperties<"stream">(docPath, { name: trimmed });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet open={open} onClose={onClose} avoidKeyboard>
|
||||||
|
<View className="flex-row items-center justify-between px-5 pb-3">
|
||||||
|
<Pressable onPress={onClose} hitSlop={12}>
|
||||||
|
<Text className="text-white/70 text-base">Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-white text-base font-semibold">Rename</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
hitSlop={12}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
"text-base font-semibold",
|
||||||
|
canSave ? "text-white" : "text-white/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="px-5 pb-6">
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoFocus
|
||||||
|
selectTextOnFocus
|
||||||
|
placeholder="Stream name"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||||
|
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import {
|
||||||
|
CircleCheckBig,
|
||||||
|
CircleDot,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { BottomSheet } from "@/components/BottomSheet";
|
||||||
|
|
||||||
|
export type StreamActionId =
|
||||||
|
| "toggle-status"
|
||||||
|
| "rename"
|
||||||
|
| "members"
|
||||||
|
| "delete-particle";
|
||||||
|
|
||||||
|
interface StreamActionsSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (action: StreamActionId) => void;
|
||||||
|
streamStatus: "open" | "closed";
|
||||||
|
isCreator: boolean;
|
||||||
|
/** True when the *current* particle is one this user can soft-delete. */
|
||||||
|
canDeleteParticle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamActionsSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
streamStatus,
|
||||||
|
isCreator,
|
||||||
|
canDeleteParticle,
|
||||||
|
}: StreamActionsSheetProps) {
|
||||||
|
const choose = (id: StreamActionId) => {
|
||||||
|
onClose();
|
||||||
|
onSelect(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet open={open} onClose={onClose}>
|
||||||
|
<View className="py-2">
|
||||||
|
<ActionRow
|
||||||
|
icon={
|
||||||
|
streamStatus === "open" ? (
|
||||||
|
<CircleCheckBig color="white" size={20} />
|
||||||
|
) : (
|
||||||
|
<CircleDot color="#22c55e" size={20} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
streamStatus === "open" ? "Close stream" : "Reopen stream"
|
||||||
|
}
|
||||||
|
onPress={() => choose("toggle-status")}
|
||||||
|
/>
|
||||||
|
<ActionRow
|
||||||
|
icon={<Users color="white" size={20} />}
|
||||||
|
label="Members"
|
||||||
|
onPress={() => choose("members")}
|
||||||
|
/>
|
||||||
|
{isCreator ? (
|
||||||
|
<ActionRow
|
||||||
|
icon={<Pencil color="white" size={20} />}
|
||||||
|
label="Rename stream"
|
||||||
|
onPress={() => choose("rename")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canDeleteParticle ? (
|
||||||
|
<ActionRow
|
||||||
|
icon={<Trash2 color="#ef4444" size={20} />}
|
||||||
|
label="Delete particle"
|
||||||
|
tone="destructive"
|
||||||
|
onPress={() => choose("delete-particle")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-5 pt-2 pb-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
className="bg-white/10 active:bg-white/15 rounded-xl py-3 items-center"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-base font-semibold">Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
tone = "default",
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
tone?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className="px-5 py-3.5 flex-row items-center gap-3 active:bg-white/5"
|
||||||
|
>
|
||||||
|
<View className="w-6 items-center">{icon}</View>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
"text-base",
|
||||||
|
tone === "destructive" ? "text-red-400" : "text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { Text, View } from "react-native";
|
|||||||
import type { Network, Particle } from "@/api/types";
|
import type { Network, Particle } from "@/api/types";
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||||
|
import { Avatar } from "@/components/Avatar";
|
||||||
|
import { useStreamPresence } from "./stream-presence-context";
|
||||||
|
|
||||||
interface StreamMetadataHeaderProps {
|
interface StreamMetadataHeaderProps {
|
||||||
particle: Particle | null;
|
particle: Particle | null;
|
||||||
@@ -10,14 +12,13 @@ interface StreamMetadataHeaderProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Avatar + display name + relative time. Sits below the segmented bar so the
|
* Avatar + display name + relative time. Sits below the segmented bar so the
|
||||||
* "who/when" answer is always one glance away — Snapchat-style. Reactions row
|
* "who/when" answer is always one glance away — Snapchat-style.
|
||||||
* is omitted in step 4; the swipe-up reaction sheet (step 5) replaces the
|
|
||||||
* desktop right-edge stack and that's where reaction counts will surface.
|
|
||||||
*/
|
*/
|
||||||
export function StreamMetadataHeader({
|
export function StreamMetadataHeader({
|
||||||
particle,
|
particle,
|
||||||
network,
|
network,
|
||||||
}: StreamMetadataHeaderProps) {
|
}: StreamMetadataHeaderProps) {
|
||||||
|
const { onlineHumanIds } = useStreamPresence();
|
||||||
if (!particle) return null;
|
if (!particle) return null;
|
||||||
const display = resolveHumanDisplay(
|
const display = resolveHumanDisplay(
|
||||||
particle.created_by_human_id,
|
particle.created_by_human_id,
|
||||||
@@ -26,14 +27,18 @@ export function StreamMetadataHeader({
|
|||||||
|
|
||||||
const editedAt =
|
const editedAt =
|
||||||
particle.type === "text" ? particle.properties.edited_at : undefined;
|
particle.type === "text" ? particle.properties.edited_at : undefined;
|
||||||
|
const isOnline = particle.created_by_human_id
|
||||||
|
? onlineHumanIds.has(particle.created_by_human_id)
|
||||||
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-row items-center gap-3">
|
<View className="flex-row items-center gap-3">
|
||||||
<View className="bg-white/15 h-9 w-9 items-center justify-center rounded-full">
|
<Avatar
|
||||||
<Text className="text-white text-xs font-semibold">
|
humanId={particle.created_by_human_id}
|
||||||
{display.initials}
|
humans={network?.humans}
|
||||||
</Text>
|
size="sm"
|
||||||
</View>
|
online={isOnline}
|
||||||
|
/>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
className="text-white text-sm font-semibold"
|
className="text-white text-sm font-semibold"
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { EllipsisVertical, Globe, Maximize2, Minimize2 } from "lucide-react-native";
|
||||||
|
import type { Human, Particle } from "@/api/types";
|
||||||
|
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Avatar } from "@/components/Avatar";
|
||||||
|
import { useStreamPresence } from "./stream-presence-context";
|
||||||
|
|
||||||
|
interface StreamTopActionsProps {
|
||||||
|
networkId: string;
|
||||||
|
streamParticle: Particle & { type: "stream" };
|
||||||
|
humans: Human[];
|
||||||
|
videoFit: "cover" | "contain";
|
||||||
|
onToggleVideoFit: () => void;
|
||||||
|
onOpenMembers: () => void;
|
||||||
|
onOpenActions: () => void;
|
||||||
|
/** True when current particle is a video — fit toggle hidden otherwise. */
|
||||||
|
showFitToggle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AVATARS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-right cluster on StreamView: visibility avatars (with presence ring),
|
||||||
|
* fit/fill toggle, and actions menu trigger. Mirrors desktop's stream-top-bar
|
||||||
|
* but compact for the mobile chrome.
|
||||||
|
*/
|
||||||
|
export function StreamTopActions({
|
||||||
|
networkId,
|
||||||
|
streamParticle,
|
||||||
|
humans,
|
||||||
|
videoFit,
|
||||||
|
onToggleVideoFit,
|
||||||
|
onOpenMembers,
|
||||||
|
onOpenActions,
|
||||||
|
showFitToggle,
|
||||||
|
}: StreamTopActionsProps) {
|
||||||
|
const { onlineHumanIds } = useStreamPresence();
|
||||||
|
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||||
|
const memberIds =
|
||||||
|
visibility.mode === "network"
|
||||||
|
? humans.map((h) => h.id)
|
||||||
|
: visibility.humanIds;
|
||||||
|
const shown = memberIds.slice(0, MAX_AVATARS);
|
||||||
|
const overflow = memberIds.length - shown.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpenMembers}
|
||||||
|
accessibilityLabel="Stream members"
|
||||||
|
className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1"
|
||||||
|
>
|
||||||
|
{visibility.mode === "network" && memberIds.length === 0 ? (
|
||||||
|
<Globe color="rgba(255,255,255,0.85)" size={14} />
|
||||||
|
) : (
|
||||||
|
<View className="flex-row">
|
||||||
|
{shown.map((id, idx) => (
|
||||||
|
<View
|
||||||
|
key={id}
|
||||||
|
style={{ marginLeft: idx === 0 ? 0 : -8 }}
|
||||||
|
>
|
||||||
|
{/* The stack ring matches the chrome's translucent bg so it
|
||||||
|
reads as a separator without painting hard black halos. */}
|
||||||
|
<Avatar
|
||||||
|
humanId={id}
|
||||||
|
humans={humans}
|
||||||
|
size="xs"
|
||||||
|
online={onlineHumanIds.has(id)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{overflow > 0 ? (
|
||||||
|
<Text className="text-white/70 text-[10px] font-medium ml-0.5">
|
||||||
|
+{overflow}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{showFitToggle ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggleVideoFit}
|
||||||
|
accessibilityLabel={
|
||||||
|
videoFit === "cover" ? "Fit video to screen" : "Fill screen with video"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 items-center justify-center rounded-full",
|
||||||
|
"bg-white/10 active:bg-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{videoFit === "cover" ? (
|
||||||
|
<Minimize2 color="white" size={15} strokeWidth={1.8} />
|
||||||
|
) : (
|
||||||
|
<Maximize2 color="white" size={15} strokeWidth={1.8} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={onOpenActions}
|
||||||
|
accessibilityLabel="More actions"
|
||||||
|
className="h-8 w-8 items-center justify-center rounded-full bg-white/10 active:bg-white/20"
|
||||||
|
>
|
||||||
|
<EllipsisVertical color="white" size={16} strokeWidth={1.8} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Dimensions, Pressable, Text, View } from "react-native";
|
import { Alert, Dimensions, Pressable, Text, View } from "react-native";
|
||||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
@@ -24,7 +24,13 @@ import {
|
|||||||
toFirestoreDocPath,
|
toFirestoreDocPath,
|
||||||
type ParticlePath,
|
type ParticlePath,
|
||||||
} from "@/lib/particle-path";
|
} from "@/lib/particle-path";
|
||||||
import { toggleParticleReaction } from "@/lib/firestore-particles";
|
import {
|
||||||
|
softDeleteParticle,
|
||||||
|
toggleParticleReaction,
|
||||||
|
updateStreamStatus,
|
||||||
|
} from "@/lib/firestore-particles";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { toUserMessage } from "@/lib/errors";
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
@@ -49,6 +55,11 @@ import { MediaParticleView } from "./MediaParticleView";
|
|||||||
import { DeletedParticleView } from "./DeletedParticleView";
|
import { DeletedParticleView } from "./DeletedParticleView";
|
||||||
import { FallbackParticleView } from "./FallbackParticleView";
|
import { FallbackParticleView } from "./FallbackParticleView";
|
||||||
import { useExitCountdown } from "./use-exit-countdown";
|
import { useExitCountdown } from "./use-exit-countdown";
|
||||||
|
import { StreamTopActions } from "./StreamTopActions";
|
||||||
|
import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet";
|
||||||
|
import { StreamMembersSheet } from "./StreamMembersSheet";
|
||||||
|
import { RenameStreamSheet } from "./RenameStreamSheet";
|
||||||
|
import { ReactionStack } from "./ReactionStack";
|
||||||
|
|
||||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||||
// Tap-zone split: left 28% goes back, right 72% goes forward — matching the
|
// Tap-zone split: left 28% goes back, right 72% goes forward — matching the
|
||||||
@@ -107,6 +118,93 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
// Reaction sheet — opens via swipe-up on the canvas.
|
// Reaction sheet — opens via swipe-up on the canvas.
|
||||||
const [reactionsOpen, setReactionsOpen] = useState(false);
|
const [reactionsOpen, setReactionsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Top-right cluster sheet state. `videoFit` lets the user toggle expo-video's
|
||||||
|
// contentFit for the active media particle when desktop captures of unusual
|
||||||
|
// aspect ratios get cropped uncomfortably under the default `cover` mode.
|
||||||
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover");
|
||||||
|
|
||||||
|
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
||||||
|
const canDeleteCurrentParticle =
|
||||||
|
!!currentParticle &&
|
||||||
|
!!userId &&
|
||||||
|
currentParticle.created_by_human_id === userId &&
|
||||||
|
currentParticle.type !== "stream" &&
|
||||||
|
currentParticle.type !== "folder" &&
|
||||||
|
!isParticleDeleted(currentParticle);
|
||||||
|
|
||||||
|
const showFitToggle =
|
||||||
|
!!currentParticle &&
|
||||||
|
!isParticleDeleted(currentParticle) &&
|
||||||
|
currentParticle.type === "media" &&
|
||||||
|
!currentParticle.properties.mime_type.startsWith("audio/");
|
||||||
|
|
||||||
|
const handleStreamAction = useCallback(
|
||||||
|
async (action: StreamActionId) => {
|
||||||
|
const streamDocPath = toFirestoreDocPath(
|
||||||
|
particlePath(networkId, [streamParticle.id]),
|
||||||
|
);
|
||||||
|
switch (action) {
|
||||||
|
case "toggle-status": {
|
||||||
|
try {
|
||||||
|
await updateStreamStatus(
|
||||||
|
streamDocPath,
|
||||||
|
streamParticle.status === "open" ? "closed" : "open",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "rename":
|
||||||
|
setRenameOpen(true);
|
||||||
|
return;
|
||||||
|
case "members":
|
||||||
|
setMembersOpen(true);
|
||||||
|
return;
|
||||||
|
case "delete-particle": {
|
||||||
|
if (!currentParticle || !userId) return;
|
||||||
|
if (!canDeleteCurrentParticle) return;
|
||||||
|
Alert.alert(
|
||||||
|
"Delete this particle?",
|
||||||
|
"This cannot be undone. Other viewers will see a \"deleted\" message in its place.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const docPath = toFirestoreDocPath(
|
||||||
|
particlePath(networkId, [
|
||||||
|
streamParticle.id,
|
||||||
|
currentParticle.id,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await softDeleteParticle(docPath, userId);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
networkId,
|
||||||
|
streamParticle.id,
|
||||||
|
streamParticle.status,
|
||||||
|
currentParticle,
|
||||||
|
userId,
|
||||||
|
canDeleteCurrentParticle,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const reactionsOnCurrent =
|
const reactionsOnCurrent =
|
||||||
currentParticle && !isParticleDeleted(currentParticle)
|
currentParticle && !isParticleDeleted(currentParticle)
|
||||||
? currentParticle.type === "media" || currentParticle.type === "text"
|
? currentParticle.type === "media" || currentParticle.type === "text"
|
||||||
@@ -309,6 +407,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={next}
|
onEnded={next}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
|
contentFit={videoFit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -398,20 +497,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
paused={paused}
|
paused={paused}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="mt-3 px-4">
|
|
||||||
<StreamMetadataHeader
|
|
||||||
particle={currentParticle}
|
|
||||||
network={network ?? null}
|
|
||||||
/>
|
|
||||||
{composingUsers.length > 0 ? (
|
|
||||||
<View className="mt-2">
|
|
||||||
<ComposingIndicator
|
|
||||||
users={composingUsers}
|
|
||||||
networkHumans={network?.humans}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bottom chrome: paused pill + exit countdown. Sit above the
|
{/* Bottom chrome: paused pill + exit countdown. Sit above the
|
||||||
@@ -439,6 +524,78 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|
||||||
|
{/* Top metadata + actions row — lifted OUTSIDE the GestureDetector so
|
||||||
|
taps on the action cluster aren't claimed by the stream's tap
|
||||||
|
gesture (which advances/regresses the playhead). The chain uses
|
||||||
|
`box-none` so empty space still falls through to gestures below. */}
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
className="absolute inset-x-0"
|
||||||
|
style={{ top: insets.top + 8 + 24 }}
|
||||||
|
>
|
||||||
|
<View className="px-4" pointerEvents="box-none">
|
||||||
|
<View
|
||||||
|
className="flex-row items-start gap-3"
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
<View className="flex-1" pointerEvents="none">
|
||||||
|
<StreamMetadataHeader
|
||||||
|
particle={currentParticle}
|
||||||
|
network={network ?? null}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<StreamTopActions
|
||||||
|
networkId={networkId}
|
||||||
|
streamParticle={streamParticle}
|
||||||
|
humans={network?.humans ?? []}
|
||||||
|
videoFit={videoFit}
|
||||||
|
onToggleVideoFit={() =>
|
||||||
|
setVideoFit((v) => (v === "cover" ? "contain" : "cover"))
|
||||||
|
}
|
||||||
|
onOpenMembers={() => setMembersOpen(true)}
|
||||||
|
onOpenActions={() => setActionsOpen(true)}
|
||||||
|
showFitToggle={showFitToggle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{composingUsers.length > 0 ? (
|
||||||
|
<View className="mt-2" pointerEvents="none">
|
||||||
|
<ComposingIndicator
|
||||||
|
users={composingUsers}
|
||||||
|
networkHumans={network?.humans}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically
|
||||||
|
centered on the canvas; outside the GestureDetector so each pill
|
||||||
|
tap toggles cleanly without competing with the stream advance/back
|
||||||
|
taps. Hidden during composing so the camera preview is unobstructed. */}
|
||||||
|
{currentParticle &&
|
||||||
|
!composing &&
|
||||||
|
!isParticleDeleted(currentParticle) &&
|
||||||
|
(currentParticle.type === "media" ||
|
||||||
|
currentParticle.type === "text") ? (
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
className="absolute right-3"
|
||||||
|
style={{
|
||||||
|
top: insets.top + 100,
|
||||||
|
bottom: insets.bottom + COMPOSE_DOCK_HEIGHT + 40,
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactionStack
|
||||||
|
reactions={reactionsOnCurrent}
|
||||||
|
currentHumanId={userId}
|
||||||
|
humans={network?.humans}
|
||||||
|
onToggle={handleToggleReaction}
|
||||||
|
onOpenSheet={openReactions}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
||||||
iOS's status-bar tap doesn't fight our gestures. */}
|
iOS's status-bar tap doesn't fight our gestures. */}
|
||||||
<SafeAreaView edges={["top"]} pointerEvents="none" />
|
<SafeAreaView edges={["top"]} pointerEvents="none" />
|
||||||
@@ -458,6 +615,31 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
humans={network?.humans}
|
humans={network?.humans}
|
||||||
onToggle={handleToggleReaction}
|
onToggle={handleToggleReaction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StreamActionsSheet
|
||||||
|
open={actionsOpen}
|
||||||
|
onClose={() => setActionsOpen(false)}
|
||||||
|
onSelect={(action) => void handleStreamAction(action)}
|
||||||
|
streamStatus={streamParticle.status ?? "open"}
|
||||||
|
isCreator={isCreator}
|
||||||
|
canDeleteParticle={canDeleteCurrentParticle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StreamMembersSheet
|
||||||
|
open={membersOpen}
|
||||||
|
onClose={() => setMembersOpen(false)}
|
||||||
|
networkId={networkId}
|
||||||
|
streamParticle={streamParticle}
|
||||||
|
isCreator={isCreator}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RenameStreamSheet
|
||||||
|
open={renameOpen}
|
||||||
|
onClose={() => setRenameOpen(false)}
|
||||||
|
networkId={networkId}
|
||||||
|
streamId={streamParticle.id}
|
||||||
|
currentName={streamParticle.properties.name}
|
||||||
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { Globe, X } from "lucide-react-native";
|
import { ChevronRight, Globe, Lock, X } from "lucide-react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { ComposeDock } from "@/features/compose/ComposeDock";
|
import { ComposeDock } from "@/features/compose/ComposeDock";
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
@@ -17,19 +17,20 @@ import { particlePath } from "@/lib/particle-path";
|
|||||||
import { generateRandomName } from "@/lib/random-name";
|
import { generateRandomName } from "@/lib/random-name";
|
||||||
import { createStreamWithFirstParticle } from "@/lib/upload";
|
import { createStreamWithFirstParticle } from "@/lib/upload";
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
buildNetworkVisibility,
|
||||||
|
parseVisibleTo,
|
||||||
|
} from "@/lib/stream-visibility";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from "@/navigation/types";
|
||||||
|
import { VisibilityPickerSheet } from "./VisibilityPickerSheet";
|
||||||
|
|
||||||
const STREAM_NAME_MAX = 60;
|
const STREAM_NAME_MAX = 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level stream creation. The user names the stream and composes the first
|
* Top-level stream creation. The user names the stream, picks visibility, and
|
||||||
* particle in one screen — desktop's `compose-overlay → ConfigureStreamStep`
|
* composes the first particle on one screen — desktop's compose-overlay flow
|
||||||
* flow collapsed into a touch-native single page.
|
* collapsed into a touch-native single page.
|
||||||
*
|
|
||||||
* Visibility is locked to "everyone in the network" in v1; specific-people
|
|
||||||
* picker is a follow-up (PRD §12.5). Even so, the data path uses the full
|
|
||||||
* `visible_to` array so plumbing the picker later is purely additive.
|
|
||||||
*/
|
*/
|
||||||
export function NewStreamScreen({
|
export function NewStreamScreen({
|
||||||
route,
|
route,
|
||||||
@@ -39,20 +40,16 @@ export function NewStreamScreen({
|
|||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
// Random suggestion is generated once per mount so it's stable across
|
|
||||||
// renders. The user can edit it freely; on submit we use whatever is in
|
|
||||||
// the input, with the suggestion as a fallback.
|
|
||||||
const suggestion = useMemo(() => generateRandomName(), []);
|
const suggestion = useMemo(() => generateRandomName(), []);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [visibleTo, setVisibleTo] = useState<string[]>(() =>
|
||||||
|
buildNetworkVisibility(networkId),
|
||||||
|
);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
const effectiveName = name.trim() || suggestion;
|
const effectiveName = name.trim() || suggestion;
|
||||||
// v1: everyone in the network. The data shape supports per-human ids
|
|
||||||
// via "human:{id}" entries — easy to extend.
|
|
||||||
const visibleTo = useMemo(() => [`network:${networkId}`], [networkId]);
|
|
||||||
|
|
||||||
const handleStreamCreated = (streamId: string) => {
|
const handleStreamCreated = (streamId: string) => {
|
||||||
// Replace the stack so back doesn't take the user to an empty new-stream
|
|
||||||
// screen — instead they go all the way back to the stream list.
|
|
||||||
navigation.replace("StreamView", { networkId, streamId });
|
navigation.replace("StreamView", { networkId, streamId });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,11 +103,16 @@ export function NewStreamScreen({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The dock writes to this path only via the override callbacks above —
|
|
||||||
// the `targetPath` is unused in that mode but the prop is required, so we
|
|
||||||
// pass a placeholder rooted at the network.
|
|
||||||
const placeholderPath = particlePath(networkId, []);
|
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 (
|
return (
|
||||||
<View className="flex-1 bg-black">
|
<View className="flex-1 bg-black">
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
@@ -153,12 +155,24 @@ export function NewStreamScreen({
|
|||||||
<Text className="text-white/60 text-xs uppercase tracking-wide mt-6 mb-2">
|
<Text className="text-white/60 text-xs uppercase tracking-wide mt-6 mb-2">
|
||||||
Visible to
|
Visible to
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-white/10 rounded-xl px-4 py-3 flex-row items-center gap-3">
|
<Pressable
|
||||||
<Globe color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
onPress={() => setPickerOpen(true)}
|
||||||
<Text className="text-white text-base flex-1">
|
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
|
||||||
Everyone in {network?.name ?? "this network"}
|
>
|
||||||
|
{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>
|
</Text>
|
||||||
</View>
|
<ChevronRight
|
||||||
|
color="rgba(255,255,255,0.5)"
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
<View className="mt-6 px-1">
|
<View className="mt-6 px-1">
|
||||||
<Text className="text-white/50 text-sm">
|
<Text className="text-white/50 text-sm">
|
||||||
@@ -176,6 +190,17 @@ export function NewStreamScreen({
|
|||||||
submitMedia={submitMedia}
|
submitMedia={submitMedia}
|
||||||
submitText={submitText}
|
submitText={submitText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<VisibilityPickerSheet
|
||||||
|
open={pickerOpen}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
networkId={networkId}
|
||||||
|
networkName={network?.name}
|
||||||
|
humans={network?.humans ?? []}
|
||||||
|
selfHumanId={userId}
|
||||||
|
visibleTo={visibleTo}
|
||||||
|
onChange={setVisibleTo}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||||
|
import { Check, Globe, Lock, X } from "lucide-react-native";
|
||||||
|
import type { Human } from "@/api/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
|
import {
|
||||||
|
buildCustomVisibility,
|
||||||
|
buildNetworkVisibility,
|
||||||
|
parseVisibleTo,
|
||||||
|
} from "@/lib/stream-visibility";
|
||||||
|
import { BottomSheet } from "@/components/BottomSheet";
|
||||||
|
import { Avatar } from "@/components/Avatar";
|
||||||
|
|
||||||
|
interface VisibilityPickerSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
networkId: string;
|
||||||
|
networkName: string | undefined;
|
||||||
|
humans: Human[];
|
||||||
|
selfHumanId: string | undefined;
|
||||||
|
visibleTo: string[];
|
||||||
|
onChange: (visibleTo: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Touch-native visibility picker. Mirrors desktop's stream-members-overlay
|
||||||
|
* (network-wide vs. specific people) but as a bottom sheet that commits on
|
||||||
|
* close — the parent's `visibleTo` only updates when the user taps Done.
|
||||||
|
*/
|
||||||
|
export function VisibilityPickerSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
networkId,
|
||||||
|
networkName,
|
||||||
|
humans,
|
||||||
|
selfHumanId,
|
||||||
|
visibleTo,
|
||||||
|
onChange,
|
||||||
|
}: VisibilityPickerSheetProps) {
|
||||||
|
const initial = useMemo(
|
||||||
|
() => parseVisibleTo(visibleTo, networkId),
|
||||||
|
[visibleTo, networkId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<"network" | "custom">(initial.mode);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
|
() => new Set(initial.mode === "custom" ? initial.humanIds : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setMode(initial.mode);
|
||||||
|
setSelected(
|
||||||
|
new Set(initial.mode === "custom" ? initial.humanIds : []),
|
||||||
|
);
|
||||||
|
}, [open, initial]);
|
||||||
|
|
||||||
|
const others = humans.filter((h) => h.id !== selfHumanId);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
if (mode === "network") {
|
||||||
|
onChange(buildNetworkVisibility(networkId));
|
||||||
|
} else {
|
||||||
|
const ids = selfHumanId
|
||||||
|
? [selfHumanId, ...Array.from(selected)]
|
||||||
|
: Array.from(selected);
|
||||||
|
onChange(buildCustomVisibility(ids));
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const customCount = selected.size + (selfHumanId ? 1 : 0);
|
||||||
|
const canCommit = mode === "network" || customCount >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet open={open} onClose={onClose} maxHeight="80%">
|
||||||
|
<View className="flex-row items-center justify-between px-5 pb-3">
|
||||||
|
<Pressable onPress={onClose} hitSlop={12}>
|
||||||
|
<X color="rgba(255,255,255,0.7)" size={22} />
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-white text-base font-semibold">Visible to</Text>
|
||||||
|
<Pressable onPress={commit} disabled={!canCommit} hitSlop={12}>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
"text-base font-semibold",
|
||||||
|
canCommit ? "text-white" : "text-white/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-5 pb-3">
|
||||||
|
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
||||||
|
<ModePill
|
||||||
|
active={mode === "network"}
|
||||||
|
icon={<Globe color="white" size={14} />}
|
||||||
|
label="Everyone"
|
||||||
|
onPress={() => setMode("network")}
|
||||||
|
/>
|
||||||
|
<ModePill
|
||||||
|
active={mode === "custom"}
|
||||||
|
icon={<Lock color="white" size={14} />}
|
||||||
|
label="Specific people"
|
||||||
|
onPress={() => setMode("custom")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{mode === "network" ? (
|
||||||
|
<View className="px-5 pb-6">
|
||||||
|
<Text className="text-white/60 text-sm">
|
||||||
|
Everyone in {networkName ?? "this network"} can see this stream.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView contentContainerClassName="px-2 pb-4">
|
||||||
|
{others.length === 0 ? (
|
||||||
|
<Text className="text-white/50 text-sm px-3 py-4">
|
||||||
|
You're the only member of this network. Invite people on desktop,
|
||||||
|
then come back to choose specific viewers.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
others.map((human) => {
|
||||||
|
const display = resolveHumanDisplay(human.id, humans);
|
||||||
|
const isSelected = selected.has(human.id);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={human.id}
|
||||||
|
onPress={() => toggle(human.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-row items-center gap-3 px-3 py-2.5 rounded-lg",
|
||||||
|
isSelected ? "bg-white/10" : "active:bg-white/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
humanId={human.id}
|
||||||
|
humans={humans}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<View
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 items-center justify-center rounded-full border",
|
||||||
|
isSelected
|
||||||
|
? "bg-white border-white"
|
||||||
|
: "border-white/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected ? (
|
||||||
|
<Check color="black" size={14} strokeWidth={3} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModePill({
|
||||||
|
active,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2",
|
||||||
|
active ? "bg-white/15" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
active ? "text-white font-semibold" : "text-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { removeDuplicates } from "@/lib/utils";
|
||||||
|
|
||||||
|
const HUMAN_PREFIX = "human:";
|
||||||
|
const NETWORK_PREFIX = "network:";
|
||||||
|
|
||||||
|
export type StreamVisibility =
|
||||||
|
| { mode: "network" }
|
||||||
|
| { mode: "custom"; humanIds: string[] };
|
||||||
|
|
||||||
|
export function parseVisibleTo(
|
||||||
|
visibleTo: string[],
|
||||||
|
networkId: string,
|
||||||
|
): StreamVisibility {
|
||||||
|
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
|
||||||
|
return { mode: "network" };
|
||||||
|
}
|
||||||
|
const humanIds = visibleTo
|
||||||
|
.filter((v) => v.startsWith(HUMAN_PREFIX))
|
||||||
|
.map((v) => v.slice(HUMAN_PREFIX.length));
|
||||||
|
return { mode: "custom", humanIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNetworkVisibility(networkId: string): string[] {
|
||||||
|
return [`${NETWORK_PREFIX}${networkId}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomVisibility(humanIds: string[]): string[] {
|
||||||
|
return removeDuplicates(humanIds).map((id) => `${HUMAN_PREFIX}${id}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user