From 41cde9ce5df5d5de1edb731f2d60246f82cd802e Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 29 Apr 2026 17:00:21 -0700 Subject: [PATCH] ux improvements --- js/mobile/src/App.tsx | 7 +- js/mobile/src/components/Avatar.tsx | 60 ++++ js/mobile/src/components/BottomSheet.tsx | 174 +++++++++++ .../src/features/compose/ReviewSheet.tsx | 9 +- .../src/features/compose/TextComposeModal.tsx | 9 +- js/mobile/src/features/networks/Drawer.tsx | 21 +- .../features/networks/NetworkListScreen.tsx | 22 +- .../stream-view/MediaParticleView.tsx | 14 +- .../features/stream-view/ReactionSheet.tsx | 9 +- .../features/stream-view/ReactionStack.tsx | 124 ++++++++ .../stream-view/RenameStreamSheet.tsx | 89 ++++++ .../stream-view/StreamActionsSheet.tsx | 118 ++++++++ .../stream-view/StreamMembersSheet.tsx | 273 ++++++++++++++++++ .../stream-view/StreamMetadataHeader.tsx | 21 +- .../features/stream-view/StreamTopActions.tsx | 110 +++++++ .../src/features/stream-view/StreamView.tsx | 214 +++++++++++++- .../src/features/streams/NewStreamScreen.tsx | 73 +++-- .../streams/VisibilityPickerSheet.tsx | 218 ++++++++++++++ js/mobile/src/lib/stream-visibility.ts | 29 ++ 19 files changed, 1518 insertions(+), 76 deletions(-) create mode 100644 js/mobile/src/components/Avatar.tsx create mode 100644 js/mobile/src/components/BottomSheet.tsx create mode 100644 js/mobile/src/features/stream-view/ReactionStack.tsx create mode 100644 js/mobile/src/features/stream-view/RenameStreamSheet.tsx create mode 100644 js/mobile/src/features/stream-view/StreamActionsSheet.tsx create mode 100644 js/mobile/src/features/stream-view/StreamMembersSheet.tsx create mode 100644 js/mobile/src/features/stream-view/StreamTopActions.tsx create mode 100644 js/mobile/src/features/streams/VisibilityPickerSheet.tsx create mode 100644 js/mobile/src/lib/stream-visibility.ts diff --git a/js/mobile/src/App.tsx b/js/mobile/src/App.tsx index a65c412..f932e26 100644 --- a/js/mobile/src/App.tsx +++ b/js/mobile/src/App.tsx @@ -3,7 +3,10 @@ import { StatusBar } from "expo-status-bar"; import { QueryClientProvider } from "@tanstack/react-query"; import { GestureHandlerRootView } from "react-native-gesture-handler"; 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 { createQueryClient } from "@/lib/query-client"; import { PusherProvider } from "@/lib/pusher-provider"; @@ -23,7 +26,7 @@ export default function App() { - + diff --git a/js/mobile/src/components/Avatar.tsx b/js/mobile/src/components/Avatar.tsx new file mode 100644 index 0000000..bced118 --- /dev/null +++ b/js/mobile/src/components/Avatar.tsx @@ -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 = { + 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 ( + + + {initials} + + + ); +} diff --git a/js/mobile/src/components/BottomSheet.tsx b/js/mobile/src/components/BottomSheet.tsx new file mode 100644 index 0000000..b6ec381 --- /dev/null +++ b/js/mobile/src/components/BottomSheet.tsx @@ -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 ( + + + + + + + + + + + + + + + {children} + + + + + + + + ); +} diff --git a/js/mobile/src/features/compose/ReviewSheet.tsx b/js/mobile/src/features/compose/ReviewSheet.tsx index e54c2f3..47352ea 100644 --- a/js/mobile/src/features/compose/ReviewSheet.tsx +++ b/js/mobile/src/features/compose/ReviewSheet.tsx @@ -1,6 +1,10 @@ import { useEffect, useState } from "react"; 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 { Mic } from "lucide-react-native"; import { toast } from "sonner-native"; @@ -63,9 +67,9 @@ export function ReviewSheet({ visible={open} animationType="fade" transparent={false} - statusBarTranslucent onRequestClose={onCancel} > + {uri ? ( mode === "audio" ? ( @@ -144,6 +148,7 @@ export function ReviewSheet({ + ); } diff --git a/js/mobile/src/features/compose/TextComposeModal.tsx b/js/mobile/src/features/compose/TextComposeModal.tsx index dba0ec3..307a783 100644 --- a/js/mobile/src/features/compose/TextComposeModal.tsx +++ b/js/mobile/src/features/compose/TextComposeModal.tsx @@ -8,7 +8,11 @@ import { TextInput, View, } 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 { cn } from "@/lib/utils"; import { toUserMessage } from "@/lib/errors"; @@ -85,9 +89,9 @@ export function TextComposeModal({ visible={open} animationType="fade" transparent={false} - statusBarTranslucent onRequestClose={onClose} > + + ); } diff --git a/js/mobile/src/features/networks/Drawer.tsx b/js/mobile/src/features/networks/Drawer.tsx index 9fb5d41..dee8a6f 100644 --- a/js/mobile/src/features/networks/Drawer.tsx +++ b/js/mobile/src/features/networks/Drawer.tsx @@ -8,7 +8,11 @@ import { Text, View, } 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"; const SCREEN_WIDTH = Dimensions.get("window").width; @@ -26,7 +30,6 @@ export function Drawer({ open, onClose, onNavigateAccount, - onNavigateSettings, }: DrawerProps) { const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current; const backdropOpacity = useRef(new Animated.Value(0)).current; @@ -60,8 +63,12 @@ export function Drawer({ transparent animationType="none" 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. */} + - { - onClose(); - onNavigateSettings(); - }} - /> @@ -131,6 +131,7 @@ export function Drawer({ + ); } diff --git a/js/mobile/src/features/networks/NetworkListScreen.tsx b/js/mobile/src/features/networks/NetworkListScreen.tsx index 9a49169..9a824b6 100644 --- a/js/mobile/src/features/networks/NetworkListScreen.tsx +++ b/js/mobile/src/features/networks/NetworkListScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { ActivityIndicator, FlatList, @@ -19,10 +19,23 @@ export function NetworkListScreen({ navigation, }: RootStackScreenProps<"NetworkList">) { 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 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 ( @@ -60,10 +73,7 @@ export function NetworkListScreen({ keyExtractor={(item) => item.id} contentContainerClassName="p-4 gap-2" refreshControl={ - refetch()} - /> + } renderItem={({ item }) => ( void; onProgress: (ratio: number) => void; + /** "cover" fills the screen (may crop); "contain" fits the whole frame. */ + contentFit?: "cover" | "contain"; } const TICK_MS = 150; @@ -37,6 +39,7 @@ export function MediaParticleView({ paused, onEnded, onProgress, + contentFit = "cover", }: MediaParticleViewProps) { const activeObjectId = particle.properties.transcoded_object_id ?? particle.properties.object_id; @@ -63,6 +66,7 @@ export function MediaParticleView({ paused={paused} onEnded={onEnded} onProgress={onProgress} + contentFit={contentFit} /> ); } @@ -74,6 +78,7 @@ function PlayableMediaView({ paused, onEnded, onProgress, + contentFit, }: { particle: MediaParticle; activeObjectId: string; @@ -81,6 +86,7 @@ function PlayableMediaView({ paused: boolean; onEnded: () => void; onProgress: (ratio: number) => void; + contentFit: "cover" | "contain"; }) { const [sourceUri, setSourceUri] = useState(null); const [resolveError, setResolveError] = useState(null); @@ -206,16 +212,16 @@ function PlayableMediaView({ ); } - // Video: full-bleed. The PRD calls for fit-fill (cover) so portrait mobile - // captures fill the screen — desktop pillarboxes its 4:3 captures to feel - // similarly framed. + // Video: full-bleed. Default cover so portrait mobile captures fill the + // screen; the user can flip to contain via the top-right toggle when desktop + // captures at odd aspect ratios get cropped uncomfortably. return ( diff --git a/js/mobile/src/features/stream-view/ReactionSheet.tsx b/js/mobile/src/features/stream-view/ReactionSheet.tsx index 547c93f..609f29e 100644 --- a/js/mobile/src/features/stream-view/ReactionSheet.tsx +++ b/js/mobile/src/features/stream-view/ReactionSheet.tsx @@ -9,7 +9,11 @@ import { TextInput, View, } 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 * as Haptics from "expo-haptics"; import { @@ -182,9 +186,9 @@ export function ReactionSheet({ visible={mounted} transparent animationType="none" - statusBarTranslucent onRequestClose={dismiss} > + + ); } diff --git a/js/mobile/src/features/stream-view/ReactionStack.tsx b/js/mobile/src/features/stream-view/ReactionStack.tsx new file mode 100644 index 0000000..3b64714 --- /dev/null +++ b/js/mobile/src/features/stream-view/ReactionStack.tsx @@ -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(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 ( + + {activeEmojis.map((emoji) => { + const reactors = reactions?.[emoji] ?? []; + const isMine = reactors.includes(currentHumanId); + return ( + 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 + } + > + {emoji} + + {reactors.length} + + + ); + })} + + {activeTextKeys.map((text) => { + const reactors = reactions?.[text] ?? []; + const isMine = reactors.includes(currentHumanId); + const firstReactor = resolveHumanDisplay(reactors[0], humans); + return ( + 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, + ]} + > + + + {firstReactor.initials} + + + + {text} + + {reactors.length > 1 ? ( + {reactors.length} + ) : null} + + ); + })} + + + + + + ); +} diff --git a/js/mobile/src/features/stream-view/RenameStreamSheet.tsx b/js/mobile/src/features/stream-view/RenameStreamSheet.tsx new file mode 100644 index 0000000..6a94afb --- /dev/null +++ b/js/mobile/src/features/stream-view/RenameStreamSheet.tsx @@ -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 ( + + + + Cancel + + Rename + + + {saving ? "Saving..." : "Save"} + + + + + + + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamActionsSheet.tsx b/js/mobile/src/features/stream-view/StreamActionsSheet.tsx new file mode 100644 index 0000000..84d083a --- /dev/null +++ b/js/mobile/src/features/stream-view/StreamActionsSheet.tsx @@ -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 ( + + + + ) : ( + + ) + } + label={ + streamStatus === "open" ? "Close stream" : "Reopen stream" + } + onPress={() => choose("toggle-status")} + /> + } + label="Members" + onPress={() => choose("members")} + /> + {isCreator ? ( + } + label="Rename stream" + onPress={() => choose("rename")} + /> + ) : null} + {canDeleteParticle ? ( + } + label="Delete particle" + tone="destructive" + onPress={() => choose("delete-particle")} + /> + ) : null} + + + + + Cancel + + + + ); +} + +function ActionRow({ + icon, + label, + onPress, + tone = "default", +}: { + icon: React.ReactNode; + label: string; + onPress: () => void; + tone?: "default" | "destructive"; +}) { + return ( + + {icon} + + {label} + + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamMembersSheet.tsx b/js/mobile/src/features/stream-view/StreamMembersSheet.tsx new file mode 100644 index 0000000..bcbeeae --- /dev/null +++ b/js/mobile/src/features/stream-view/StreamMembersSheet.tsx @@ -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 ( + + + + Members + + + + + + + + Visibility + + {isCreator ? ( + + } + label="Network-wide" + onPress={setNetworkWide} + /> + } + label="Specific people" + onPress={setCustomOnlyCreator} + /> + + ) : ( + + {visibility.mode === "network" ? ( + <> + + + Everyone in {network?.name ?? "network"} + + + ) : ( + <> + + + {memberIds.length} specific{" "} + {memberIds.length === 1 ? "person" : "people"} + + + )} + + )} + + + + + + {visibility.mode === "network" ? "Has access" : "People"} ·{" "} + {memberIds.length} + + {memberIds.map((id) => { + const display = resolveHumanDisplay(id, humans); + const isCreatorRow = id === creatorId; + const canRemove = + isCreator && visibility.mode === "custom" && !isCreatorRow; + return ( + + + + + {display.displayName} + + {display.exists ? ( + + {display.email} + + ) : null} + + {isCreatorRow ? ( + + Creator + + ) : canRemove ? ( + removeMember(id)} + hitSlop={10} + accessibilityLabel={`Remove ${display.displayName}`} + > + + + ) : null} + + ); + })} + + + {isCreator && + visibility.mode === "custom" && + availableToAdd.length > 0 ? ( + + + Add people + + {availableToAdd.map((human) => { + const display = resolveHumanDisplay(human.id, humans); + return ( + addMember(human.id)} + className="flex-row items-center gap-3 py-2.5 active:bg-white/5 rounded-lg" + > + + + + {display.displayName} + + + {display.email} + + + Add + + ); + })} + + ) : null} + + + ); +} + +function ModePill({ + active, + icon, + label, + onPress, +}: { + active: boolean; + icon: React.ReactNode; + label: string; + onPress: () => void; +}) { + return ( + + {icon} + + {label} + + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx b/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx index 6ad10c0..d756cdd 100644 --- a/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx +++ b/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx @@ -2,6 +2,8 @@ import { Text, View } from "react-native"; import type { Network, Particle } from "@/api/types"; import { resolveHumanDisplay } from "@/lib/humans"; import { RelativeTimestamp } from "@/components/RelativeTimestamp"; +import { Avatar } from "@/components/Avatar"; +import { useStreamPresence } from "./stream-presence-context"; interface StreamMetadataHeaderProps { particle: Particle | null; @@ -10,14 +12,13 @@ interface StreamMetadataHeaderProps { /** * Avatar + display name + relative time. Sits below the segmented bar so the - * "who/when" answer is always one glance away — Snapchat-style. Reactions row - * 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. + * "who/when" answer is always one glance away — Snapchat-style. */ export function StreamMetadataHeader({ particle, network, }: StreamMetadataHeaderProps) { + const { onlineHumanIds } = useStreamPresence(); if (!particle) return null; const display = resolveHumanDisplay( particle.created_by_human_id, @@ -26,14 +27,18 @@ export function StreamMetadataHeader({ const editedAt = particle.type === "text" ? particle.properties.edited_at : undefined; + const isOnline = particle.created_by_human_id + ? onlineHumanIds.has(particle.created_by_human_id) + : false; return ( - - - {display.initials} - - + 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 ( + + + {visibility.mode === "network" && memberIds.length === 0 ? ( + + ) : ( + + {shown.map((id, idx) => ( + + {/* The stack ring matches the chrome's translucent bg so it + reads as a separator without painting hard black halos. */} + + + ))} + + )} + {overflow > 0 ? ( + + +{overflow} + + ) : null} + + + {showFitToggle ? ( + + {videoFit === "cover" ? ( + + ) : ( + + )} + + ) : null} + + + + + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index 56f3ab2..c35d364 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -1,5 +1,5 @@ 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 { StatusBar } from "expo-status-bar"; import * as Haptics from "expo-haptics"; @@ -24,7 +24,13 @@ import { toFirestoreDocPath, type ParticlePath, } 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 { useStreamPlayback } from "@/hooks/use-stream-playback"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; @@ -49,6 +55,11 @@ import { MediaParticleView } from "./MediaParticleView"; import { DeletedParticleView } from "./DeletedParticleView"; import { FallbackParticleView } from "./FallbackParticleView"; 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; // 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. 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 = currentParticle && !isParticleDeleted(currentParticle) ? currentParticle.type === "media" || currentParticle.type === "text" @@ -309,6 +407,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { paused={paused} onEnded={next} onProgress={setProgress} + contentFit={videoFit} /> ); default: @@ -398,20 +497,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { paused={paused} /> - - - {composingUsers.length > 0 ? ( - - - - ) : null} - {/* Bottom chrome: paused pill + exit countdown. Sit above the @@ -439,6 +524,78 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { + {/* 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. */} + + + + + + + + setVideoFit((v) => (v === "cover" ? "contain" : "cover")) + } + onOpenMembers={() => setMembersOpen(true)} + onOpenActions={() => setActionsOpen(true)} + showFitToggle={showFitToggle} + /> + + {composingUsers.length > 0 ? ( + + + + ) : null} + + + + {/* 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") ? ( + + + + ) : null} + {/* Safe-area sentinel for top notch — kept outside GestureDetector so iOS's status-bar tap doesn't fight our gestures. */} @@ -458,6 +615,31 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { humans={network?.humans} onToggle={handleToggleReaction} /> + + setActionsOpen(false)} + onSelect={(action) => void handleStreamAction(action)} + streamStatus={streamParticle.status ?? "open"} + isCreator={isCreator} + canDeleteParticle={canDeleteCurrentParticle} + /> + + setMembersOpen(false)} + networkId={networkId} + streamParticle={streamParticle} + isCreator={isCreator} + /> + + setRenameOpen(false)} + networkId={networkId} + streamId={streamParticle.id} + currentName={streamParticle.properties.name} + /> ); diff --git a/js/mobile/src/features/streams/NewStreamScreen.tsx b/js/mobile/src/features/streams/NewStreamScreen.tsx index 1a1fb82..2be3492 100644 --- a/js/mobile/src/features/streams/NewStreamScreen.tsx +++ b/js/mobile/src/features/streams/NewStreamScreen.tsx @@ -9,7 +9,7 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; 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 { ComposeDock } from "@/features/compose/ComposeDock"; import { useNetwork } from "@/hooks/use-networks"; @@ -17,19 +17,20 @@ import { particlePath } from "@/lib/particle-path"; import { generateRandomName } from "@/lib/random-name"; import { createStreamWithFirstParticle } from "@/lib/upload"; import { toUserMessage } from "@/lib/errors"; +import { + buildNetworkVisibility, + parseVisibleTo, +} from "@/lib/stream-visibility"; import { useAuthStore } from "@/stores/auth-store"; import type { RootStackScreenProps } from "@/navigation/types"; +import { VisibilityPickerSheet } from "./VisibilityPickerSheet"; const STREAM_NAME_MAX = 60; /** - * Top-level stream creation. The user names the stream and composes the first - * particle in one screen — desktop's `compose-overlay → ConfigureStreamStep` - * flow 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. + * Top-level stream creation. The user names the stream, picks visibility, and + * composes the first particle on one screen — desktop's compose-overlay flow + * collapsed into a touch-native single page. */ export function NewStreamScreen({ route, @@ -39,20 +40,16 @@ export function NewStreamScreen({ const network = useNetwork(networkId); 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 [name, setName] = useState(""); + const [visibleTo, setVisibleTo] = useState(() => + buildNetworkVisibility(networkId), + ); + const [pickerOpen, setPickerOpen] = useState(false); 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) => { - // 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 }); }; @@ -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 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 ( @@ -153,12 +155,24 @@ export function NewStreamScreen({ Visible to - - - - Everyone in {network?.name ?? "this network"} + setPickerOpen(true)} + className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3" + > + {visibility.mode === "network" ? ( + + ) : ( + + )} + + {visibleSummary} - + + @@ -176,6 +190,17 @@ export function NewStreamScreen({ submitMedia={submitMedia} submitText={submitText} /> + + setPickerOpen(false)} + networkId={networkId} + networkName={network?.name} + humans={network?.humans ?? []} + selfHumanId={userId} + visibleTo={visibleTo} + onChange={setVisibleTo} + /> ); } diff --git a/js/mobile/src/features/streams/VisibilityPickerSheet.tsx b/js/mobile/src/features/streams/VisibilityPickerSheet.tsx new file mode 100644 index 0000000..4bbc9b1 --- /dev/null +++ b/js/mobile/src/features/streams/VisibilityPickerSheet.tsx @@ -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>( + () => 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 ( + + + + + + Visible to + + + Done + + + + + + + } + label="Everyone" + onPress={() => setMode("network")} + /> + } + label="Specific people" + onPress={() => setMode("custom")} + /> + + + + {mode === "network" ? ( + + + Everyone in {networkName ?? "this network"} can see this stream. + + + ) : ( + + {others.length === 0 ? ( + + You're the only member of this network. Invite people on desktop, + then come back to choose specific viewers. + + ) : ( + others.map((human) => { + const display = resolveHumanDisplay(human.id, humans); + const isSelected = selected.has(human.id); + return ( + 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", + )} + > + + + + {display.displayName} + + + {display.email} + + + + {isSelected ? ( + + ) : null} + + + ); + }) + )} + + )} + + ); +} + +function ModePill({ + active, + icon, + label, + onPress, +}: { + active: boolean; + icon: React.ReactNode; + label: string; + onPress: () => void; +}) { + return ( + + {icon} + + {label} + + + ); +} diff --git a/js/mobile/src/lib/stream-visibility.ts b/js/mobile/src/lib/stream-visibility.ts new file mode 100644 index 0000000..c748769 --- /dev/null +++ b/js/mobile/src/lib/stream-visibility.ts @@ -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}`); +}