Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

add submenus to dropdown

+393 -66
+35 -23
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 12 12 import { useLivestreamStore } from "../../../livestream-store"; 13 13 import { PlayerProtocol, usePlayerStore } from "../../../player-store/"; 14 14 import { useGraphManager } from "../../../streamplace-store/graph"; 15 - import { gap, pt, px } from "../../../ui"; 15 + import { gap, pb, pt, px } from "../../../ui"; 16 16 import { 17 17 DropdownMenu, 18 18 DropdownMenuCheckboxItem, ··· 23 23 DropdownMenuRadioGroup, 24 24 DropdownMenuRadioItem, 25 25 DropdownMenuSeparator, 26 + DropdownMenuSub, 27 + DropdownMenuSubContent, 28 + DropdownMenuSubTrigger, 26 29 DropdownMenuTrigger, 27 30 ResponsiveDropdownMenuContent, 28 31 Text, ··· 49 52 50 53 const { profile } = useLivestreamInfo(); 51 54 52 - console.log("profile", profile); 53 55 const avatars = useAvatars(profile?.did ? [profile?.did] : []); 54 56 const ls = useLivestreamStore((x) => x.livestream); 55 57 const segment = useLivestreamStore((x) => x.segment); ··· 171 173 </DropdownMenuGroup> 172 174 )} 173 175 174 - <DropdownMenuGroup title="Resolution"> 175 - <DropdownMenuRadioGroup value={quality} onValueChange={setQuality}> 176 - <DropdownMenuRadioItem value="source"> 177 - <Text>Source (Original Quality)</Text> 178 - </DropdownMenuRadioItem> 179 - {qualities.map((r) => ( 180 - <DropdownMenuRadioItem value={r.name}> 181 - <Text>{r.name}</Text> 182 - </DropdownMenuRadioItem> 183 - ))} 184 - </DropdownMenuRadioGroup> 185 - </DropdownMenuGroup> 186 176 <DropdownMenuGroup title="Advanced"> 187 - <DropdownMenuCheckboxItem 188 - checked={lowLatency} 189 - onCheckedChange={() => setLowLatency(!lowLatency)} 190 - > 191 - <Text>Low Latency</Text> 192 - </DropdownMenuCheckboxItem> 193 - </DropdownMenuGroup> 194 - <DropdownMenuInfo description="Reduces the delay between video and chat for a more real-time experience." /> 195 - <DropdownMenuGroup> 177 + <DropdownMenuSub> 178 + <DropdownMenuSubTrigger subMenuTitle="Connection"> 179 + <Text>Connection Settings</Text> 180 + </DropdownMenuSubTrigger> 181 + <DropdownMenuSubContent> 182 + <DropdownMenuCheckboxItem 183 + checked={lowLatency} 184 + onCheckedChange={() => setLowLatency(!lowLatency)} 185 + > 186 + <Text>Low Latency</Text> 187 + </DropdownMenuCheckboxItem> 188 + <DropdownMenuInfo description="Reduces the delay between video and chat for a more real-time experience." /> 189 + <DropdownMenuSeparator /> 190 + <Text style={[px[2], pt[2], pb[1]]} color="muted"> 191 + Resolution 192 + </Text> 193 + <DropdownMenuRadioGroup 194 + value={quality} 195 + onValueChange={setQuality} 196 + > 197 + <DropdownMenuRadioItem value="source"> 198 + <Text>Source (Original Quality)</Text> 199 + </DropdownMenuRadioItem> 200 + {qualities.map((r) => ( 201 + <DropdownMenuRadioItem key={r.name} value={r.name}> 202 + <Text>{r.name}</Text> 203 + </DropdownMenuRadioItem> 204 + ))} 205 + </DropdownMenuRadioGroup> 206 + </DropdownMenuSubContent> 207 + </DropdownMenuSub> 196 208 <DropdownMenuCheckboxItem 197 209 checked={debugInfo} 198 210 onCheckedChange={() => setShowDebugInfo(!debugInfo)}
+358 -43
js/components/src/components/ui/dropdown.tsx
··· 4 4 Check, 5 5 CheckCircle, 6 6 ChevronDown, 7 + ChevronLeft, 7 8 ChevronRight, 8 9 ChevronUp, 9 10 Circle, 10 11 } from "lucide-react-native"; 11 - import React, { forwardRef, ReactNode, useRef } from "react"; 12 + import React, { 13 + createContext, 14 + forwardRef, 15 + ReactNode, 16 + startTransition, 17 + useContext, 18 + useRef, 19 + useState, 20 + } from "react"; 12 21 import { 13 22 Platform, 14 23 Pressable, ··· 17 26 useWindowDimensions, 18 27 View, 19 28 } from "react-native"; 29 + import Animated, { 30 + runOnJS, 31 + useAnimatedStyle, 32 + useSharedValue, 33 + withTiming, 34 + } from "react-native-reanimated"; 20 35 import { useSafeAreaInsets } from "react-native-safe-area-context"; 21 36 import { 22 37 a, ··· 41 56 TextContext as TextClassContext, 42 57 } from "./primitives/text"; 43 58 import { Text } from "./text"; 59 + 60 + // Navigation stack context for bottom sheet menus 61 + interface NavigationStackItem { 62 + key: string; 63 + title?: string; 64 + content: ReactNode | ((state: { pressed: boolean }) => ReactNode); 65 + } 66 + 67 + interface NavigationStackContextValue { 68 + stack: NavigationStackItem[]; 69 + push: (item: NavigationStackItem) => void; 70 + pop: () => void; 71 + isNested: boolean; 72 + } 73 + 74 + const NavigationStackContext = 75 + createContext<NavigationStackContextValue | null>(null); 76 + 77 + const useNavigationStack = () => { 78 + const context = useContext(NavigationStackContext); 79 + return context; 80 + }; 81 + 82 + // Context to capture submenu content for mobile navigation 83 + interface SubMenuContextValue { 84 + title?: string; 85 + renderContent: () => ReactNode; 86 + setRenderContent: (renderer: () => ReactNode) => void; 87 + setTitle: (title: string) => void; 88 + trigger: () => void; 89 + key: string | null; 90 + } 91 + 92 + const SubMenuContext = createContext<SubMenuContextValue | null>(null); 44 93 45 94 export const DropdownMenu = DropdownMenuPrimitive.Root; 46 95 export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 47 96 export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 48 - export const DropdownMenuSub = DropdownMenuPrimitive.Sub; 49 97 export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 50 98 99 + // Custom DropdownMenuSub that works with mobile navigation 100 + export const DropdownMenuSub = forwardRef<any, any>( 101 + ({ children, ...props }, ref) => { 102 + const navStack = useNavigationStack(); 103 + const [subMenuTitle, setSubMenuTitle] = useState<string | undefined>(); 104 + const renderContentRef = useRef<(() => ReactNode) | null>(null); 105 + const [subMenuKey, setSubMenuKey] = useState<string | null>(null); 106 + 107 + // If we're in a mobile navigation stack, use custom context 108 + if (navStack) { 109 + const trigger = () => { 110 + if (renderContentRef.current) { 111 + const key = `submenu-${Date.now()}`; 112 + setSubMenuKey(key); 113 + navStack.push({ 114 + key, 115 + title: subMenuTitle, 116 + // Store a function that always reads the latest content from the ref 117 + content: (props: any) => { 118 + const renderFn = renderContentRef.current; 119 + return renderFn ? renderFn() : null; 120 + }, 121 + }); 122 + } 123 + }; 124 + 125 + const setRenderContent = (renderer: () => ReactNode) => { 126 + renderContentRef.current = renderer; 127 + }; 128 + 129 + const contextValue = React.useMemo( 130 + () => ({ 131 + renderContent: () => renderContentRef.current?.(), 132 + setRenderContent, 133 + title: subMenuTitle, 134 + setTitle: setSubMenuTitle, 135 + trigger, 136 + key: subMenuKey, 137 + }), 138 + [subMenuTitle, subMenuKey], 139 + ); 140 + 141 + return ( 142 + <SubMenuContext.Provider value={contextValue}> 143 + {children} 144 + </SubMenuContext.Provider> 145 + ); 146 + } 147 + 148 + // Web - use primitive 149 + return ( 150 + <DropdownMenuPrimitive.Sub ref={ref} {...props}> 151 + {children} 152 + </DropdownMenuPrimitive.Sub> 153 + ); 154 + }, 155 + ); 156 + 51 157 export const DropdownMenuBottomSheet = forwardRef< 52 158 any, 53 159 DropdownMenuPrimitive.ContentProps & { ··· 60 166 ) { 61 167 // Use the primitives' context to know if open 62 168 const { onOpenChange } = DropdownMenuPrimitive.useRootContext(); 63 - const { zero: zt } = useTheme(); 169 + const { zero: zt, theme } = useTheme(); 64 170 const sheetRef = useRef<BottomSheet>(null); 65 171 const { width } = useWindowDimensions(); 66 172 const isWide = Platform.OS !== "web" && width >= 800; ··· 69 175 70 176 const insets = useSafeAreaInsets(); 71 177 72 - // close and then closed callback 178 + // Navigation stack state 179 + const [stack, setStack] = useState<NavigationStackItem[]>([ 180 + { key: "root", content: children }, 181 + ]); 73 182 74 - const onBackgroundTap = () => { 75 - // close the bottom sheet 183 + // Update root content when children changes 184 + React.useEffect(() => { 185 + setStack((prev) => { 186 + if (!Array.isArray(prev) || prev.length === 0) { 187 + return [{ key: "root", content: children }]; 188 + } 189 + // Update the root item content 190 + const newStack = [...prev]; 191 + newStack[0] = { ...newStack[0], content: children }; 192 + return newStack; 193 + }); 194 + }, [children]); 76 195 196 + const slideAnim = useSharedValue(0); 197 + 198 + const push = (item: NavigationStackItem) => { 199 + // First, update the stack 200 + setStack((prev) => { 201 + if (!Array.isArray(prev)) 202 + return [{ key: "root", content: children }, item]; 203 + return [...prev, item]; 204 + }); 205 + 206 + // Then animate from right to center 207 + slideAnim.value = width; 208 + slideAnim.value = withTiming(0, { duration: 250 }); 209 + }; 210 + 211 + const popStack = () => { 212 + startTransition(() => { 213 + setStack((prev) => { 214 + if (!Array.isArray(prev) || prev.length <= 1) { 215 + return [{ key: "root", content: children }]; 216 + } 217 + return prev.slice(0, -1); 218 + }); 219 + }); 220 + }; 221 + 222 + const pop = () => { 223 + if (stack.length <= 1) return; 224 + 225 + // Animate out to the right 226 + slideAnim.value = withTiming(width, { duration: 250 }, (finished) => { 227 + if (finished) { 228 + // Reset animation position first 229 + slideAnim.value = 0; 230 + 231 + // Then update stack with startTransition for smoother render 232 + runOnJS(popStack)(); 233 + } 234 + }); 235 + }; 236 + 237 + const updateContent = ( 238 + key: string, 239 + content: ReactNode | ((state: { pressed: boolean }) => ReactNode), 240 + ) => { 241 + setStack((prev) => { 242 + if (!Array.isArray(prev)) return prev; 243 + const newStack = prev.map((item) => 244 + item.key === key ? { ...item, content } : item, 245 + ); 246 + return newStack; 247 + }); 248 + }; 249 + 250 + const animatedStyle = useAnimatedStyle(() => ({ 251 + transform: [{ translateX: slideAnim.value }], 252 + })); 253 + 254 + const currentLevel = stack[stack.length - 1]; 255 + const isNested = stack.length > 1; 256 + 257 + const onBackgroundTap = () => { 77 258 if (sheetRef.current) sheetRef.current?.close(); 78 259 79 260 setTimeout(() => { ··· 81 262 }, 300); 82 263 }; 83 264 265 + // Safety check - if no current level, don't render 266 + if (!currentLevel) { 267 + return null; 268 + } 269 + 84 270 return ( 85 271 <DropdownMenuPrimitive.Portal hostName={portalHost}> 86 - <BottomSheet 87 - ref={sheetRef} 88 - enablePanDownToClose 89 - enableDynamicSizing 90 - detached={isWide} 91 - bottomInset={isWide ? insets.bottom : 0} 92 - backdropComponent={({ style }) => ( 93 - <Pressable 94 - style={[style, StyleSheet.absoluteFill]} 95 - onPress={() => onBackgroundTap()} 96 - /> 97 - )} 98 - onClose={() => onOpenChange?.(false)} 99 - style={[ 100 - overlayStyle, 101 - StyleSheet.flatten(rest.style), 102 - isWide && { marginHorizontal: horizontalMargin }, 103 - ]} 104 - backgroundStyle={[zt.bg.popover, a.radius.all.md, a.shadows.md, p[1]]} 105 - handleIndicatorStyle={[ 106 - a.sizes.width[12], 107 - a.sizes.height[1], 108 - zt.bg.mutedForeground, 109 - ]} 110 - > 111 - <BottomSheetScrollView style={[px[4]]}> 112 - {typeof children === "function" 113 - ? children({ pressed: true }) 114 - : children} 115 - </BottomSheetScrollView> 116 - </BottomSheet> 272 + <NavigationStackContext.Provider value={{ stack, push, pop, isNested }}> 273 + <BottomSheet 274 + ref={sheetRef} 275 + enablePanDownToClose 276 + enableDynamicSizing 277 + detached={isWide} 278 + bottomInset={isWide ? insets.bottom : 0} 279 + backdropComponent={({ style }) => ( 280 + <Pressable 281 + style={[style, StyleSheet.absoluteFill]} 282 + onPress={() => onBackgroundTap()} 283 + /> 284 + )} 285 + onClose={() => onOpenChange?.(false)} 286 + style={[ 287 + overlayStyle, 288 + StyleSheet.flatten(rest.style), 289 + isWide && { marginHorizontal: horizontalMargin }, 290 + ]} 291 + backgroundStyle={[zt.bg.popover, a.radius.all.md, a.shadows.md, p[1]]} 292 + handleIndicatorStyle={[ 293 + a.sizes.width[12], 294 + a.sizes.height[1], 295 + zt.bg.mutedForeground, 296 + ]} 297 + > 298 + {isNested && ( 299 + <View 300 + style={[ 301 + a.layout.flex.row, 302 + a.layout.flex.alignCenter, 303 + px[4], 304 + pt[2], 305 + pb[2], 306 + { 307 + borderBottomWidth: 1, 308 + borderBottomColor: theme.colors.border, 309 + }, 310 + ]} 311 + > 312 + <Pressable 313 + onPress={pop} 314 + style={[ 315 + a.layout.flex.row, 316 + a.layout.flex.alignCenter, 317 + gap.all[2], 318 + ]} 319 + > 320 + <ChevronLeft size={20} color={theme.colors.foreground} /> 321 + {currentLevel.title && <Text>{currentLevel.title}</Text>} 322 + </Pressable> 323 + </View> 324 + )} 325 + <Animated.View style={animatedStyle}> 326 + <BottomSheetScrollView 327 + style={[px[4]]} 328 + contentContainerStyle={{ paddingBottom: insets.bottom + 50 }} 329 + > 330 + {/* Render all stack levels to keep components mounted, but hide non-current ones */} 331 + {stack.map((level, index) => { 332 + const isCurrent = index === stack.length - 1; 333 + return ( 334 + <View 335 + key={level.key} 336 + style={[{ display: isCurrent ? "flex" : "none" }]} 337 + > 338 + {typeof level.content === "function" 339 + ? level.content({ pressed: true }) 340 + : level.content} 341 + </View> 342 + ); 343 + })} 344 + </BottomSheetScrollView> 345 + </Animated.View> 346 + </BottomSheet> 347 + </NavigationStackContext.Provider> 117 348 </DropdownMenuPrimitive.Portal> 118 349 ); 119 350 }); 120 351 121 352 export const DropdownMenuSubTrigger = forwardRef< 122 353 any, 123 - DropdownMenuPrimitive.SubTriggerProps & { inset?: boolean } & { 354 + DropdownMenuPrimitive.SubTriggerProps & { 355 + inset?: boolean; 356 + subMenuTitle?: string; 357 + } & { 124 358 ref?: React.RefObject<DropdownMenuPrimitive.SubTriggerRef>; 125 359 className?: string; 126 360 inset?: boolean; 127 361 children?: React.ReactNode; 128 362 } 129 - >(({ inset, children, ...props }, ref) => { 363 + >(({ inset, children, subMenuTitle, ...props }, ref) => { 364 + const navStack = useNavigationStack(); 365 + const subMenuContext = useContext(SubMenuContext); 366 + const { icons, theme } = useTheme(); 367 + 368 + // Set the title in the submenu context if provided 369 + React.useEffect(() => { 370 + if (subMenuContext && subMenuTitle) { 371 + subMenuContext.setTitle(subMenuTitle); 372 + } 373 + }, [subMenuContext, subMenuTitle]); 374 + 375 + // If we're in a navigation stack (mobile bottom sheet), handle differently 376 + if (navStack && subMenuContext) { 377 + return ( 378 + <Pressable 379 + onPress={() => { 380 + subMenuContext.trigger(); 381 + }} 382 + {...props} 383 + > 384 + <View 385 + style={[ 386 + inset && gap[2], 387 + layout.flex.row, 388 + layout.flex.alignCenter, 389 + a.radius.all.sm, 390 + py[1], 391 + pl[2], 392 + pr[2], 393 + ]} 394 + > 395 + {typeof children === "function" ? ( 396 + children({ pressed: true }) 397 + ) : typeof children === "string" ? ( 398 + <Text>{children}</Text> 399 + ) : ( 400 + children 401 + )} 402 + <View style={[a.layout.position.absolute, a.position.right[1]]}> 403 + <ChevronRight size={18} color={icons.color.muted} /> 404 + </View> 405 + </View> 406 + </Pressable> 407 + ); 408 + } 409 + 410 + // Web behavior - use primitive 130 411 const { open } = DropdownMenuPrimitive.useSubContext(); 131 - const { icons } = useTheme(); 132 412 const Icon = 133 413 Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown; 134 414 return ( ··· 161 441 162 442 export const DropdownMenuSubContent = forwardRef< 163 443 any, 164 - DropdownMenuPrimitive.SubContentProps 165 - >((props, ref) => { 444 + DropdownMenuPrimitive.SubContentProps & { children?: ReactNode } 445 + >(({ children, ...props }, ref) => { 166 446 const { zero: zt } = useTheme(); 447 + const subMenuContext = useContext(SubMenuContext); 448 + const navStack = useNavigationStack(); 449 + const prevChildrenRef = useRef<ReactNode>(null); 450 + 451 + // Register a render function that will be called fresh each time 452 + React.useEffect(() => { 453 + if (subMenuContext && navStack) { 454 + // Only update if children reference actually changed 455 + if (prevChildrenRef.current === children) { 456 + return; 457 + } 458 + 459 + prevChildrenRef.current = children; 460 + 461 + // Pass a function that returns the current children 462 + subMenuContext.setRenderContent(() => children); 463 + 464 + // Force a stack update to trigger rerender with the actual children 465 + if (subMenuContext.key) { 466 + // Store the children directly so React can handle updates 467 + //navStack.updateContent(subMenuContext.key, children); 468 + } 469 + } 470 + }, [children, subMenuContext, navStack]); 471 + 472 + // On mobile, don't render the subcontent here - it'll be rendered in the nav stack 473 + // But keep the component mounted so effects run when children change 474 + if (navStack && subMenuContext) { 475 + // Component stays mounted to track prop changes, but renders nothing 476 + return null; 477 + } 478 + 479 + // Web - use primitive 167 480 return ( 168 481 <DropdownMenuPrimitive.SubContent 169 482 ref={ref} ··· 180 493 a.shadows.md, 181 494 ]} 182 495 {...props} 183 - /> 496 + > 497 + {children} 498 + </DropdownMenuPrimitive.SubContent> 184 499 ); 185 500 }); 186 501