Live video on the AT Protocol
79
fork

Configure Feed

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

Merge pull request #143 from streamplace/natb/home-components

design: custom sidebar for non-mobile devices

authored by

Eli Mallon and committed by
GitHub
8223b724 4b7ae4dc

+494 -15
+78
js/app/components/sidebar/sidebar-item.tsx
··· 1 + import { FileQuestion } from "@tamagui/lucide-icons"; 2 + import { Text, View, AnimatePresence } from "tamagui"; 3 + import { Pressable } from "react-native-gesture-handler"; 4 + import { ReactNode, useState } from "react"; 5 + import { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native"; 6 + 7 + export default function SidebarItem({ 8 + icon, 9 + label, 10 + collapsed, 11 + active, 12 + onPress, 13 + style = null, 14 + tint = "rgba(189, 110, 134)", 15 + }: { 16 + icon: React.ComponentType<any> | React.NamedExoticComponent<any>; 17 + label: React.ComponentType<any> | string | ReactNode; 18 + collapsed: boolean; 19 + active: boolean; 20 + onPress: () => void; 21 + style?: 22 + | StyleProp<ViewStyle> 23 + | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>); 24 + tint: string; 25 + }) { 26 + const [hover, setHover] = useState<boolean>(false); 27 + // if we don't have an icon for some reason default to filequestion 28 + const Icon: React.NamedExoticComponent<any> = (icon as any) || FileQuestion; 29 + return ( 30 + <Pressable 31 + onPress={onPress} 32 + style={style} 33 + onHoverIn={() => setHover(true)} 34 + onHoverOut={() => setHover(false)} 35 + role="button" 36 + > 37 + <View 38 + backgroundColor={ 39 + hover || active 40 + ? tint.replace( 41 + ")", 42 + ", " + (active && !hover ? "0.1" : "0.25") + ")", 43 + ) 44 + : undefined 45 + } 46 + borderRadius="$radius.3" 47 + flexDirection="row" 48 + justifyContent="flex-start" 49 + alignItems="center" 50 + paddingHorizontal="$3" 51 + gap="$2" 52 + overflow="hidden" 53 + > 54 + <View width="$3" paddingVertical="$3"> 55 + <Icon color="$color" /> 56 + </View> 57 + <AnimatePresence> 58 + {!collapsed && ( 59 + <Text 60 + // setting to maximum width of the sidebar 61 + // so we don't get collapsing text on collapse 62 + minWidth={270} 63 + maxHeight="auto" 64 + fontSize="$6" 65 + textAlign="left" 66 + animation="quick" 67 + enterStyle={{ opacity: 100, width: "100" }} 68 + exitStyle={{ opacity: 0, width: "100" }} 69 + animateOnly={["opacity"]} 70 + > 71 + {label} 72 + </Text> 73 + )} 74 + </AnimatePresence> 75 + </View> 76 + </Pressable> 77 + ); 78 + }
+137
js/app/components/sidebar/sidebar.tsx
··· 1 + import { YStack, styled, Text, View, Image } from "tamagui"; 2 + import { SharedValue, useAnimatedStyle } from "react-native-reanimated"; 3 + import SidebarItem from "./sidebar-item"; 4 + import { 5 + CommonActions, 6 + DrawerNavigationState, 7 + ParamListBase, 8 + } from "@react-navigation/native"; 9 + import { DrawerNavigationOptions } from "@react-navigation/drawer"; 10 + import { DrawerDescriptorMap } from "@react-navigation/drawer/lib/typescript/src/types"; 11 + import { Platform } from "react-native"; 12 + import { FileQuestion } from "@tamagui/lucide-icons"; 13 + 14 + const AnimatedYStack = styled(YStack, { 15 + name: "AnimatedYStack", 16 + }); 17 + 18 + export interface ExternalDrawerItem { 19 + item: React.NamedExoticComponent<any>; 20 + label: React.ComponentType<any> | string; 21 + onPress: () => void; 22 + } 23 + 24 + interface CustomSidebarProps { 25 + collapsed: boolean; 26 + widthAnim: SharedValue<number>; 27 + descriptors: DrawerDescriptorMap; 28 + state: DrawerNavigationState<ParamListBase>; 29 + externalItems?: ExternalDrawerItem[]; 30 + } 31 + 32 + // Combine standard drawer props with custom props 33 + type SidebarProps = CustomSidebarProps & DrawerNavigationOptions; 34 + 35 + export default function Sidebar({ 36 + state, 37 + descriptors, 38 + collapsed, 39 + widthAnim, 40 + externalItems = [], 41 + }: SidebarProps) { 42 + // Apply the defined type to the component props 43 + const animatedSidebarStyle = useAnimatedStyle(() => { 44 + return { 45 + minWidth: widthAnim.value, 46 + maxWidth: widthAnim.value, 47 + }; 48 + }); 49 + 50 + return ( 51 + <AnimatedYStack 52 + style={animatedSidebarStyle} // Apply the animated style 53 + padding="$2" 54 + gap="$2" 55 + > 56 + <View 57 + marginTop={Platform.OS === "ios" ? 29 : 12} 58 + marginBottom="$5" 59 + paddingLeft="$2.5" 60 + gap="$3" 61 + flexDirection="row" 62 + justifyContent="flex-start" 63 + alignItems="center" 64 + > 65 + <Image 66 + source={require("../../assets/images/cube.png")} 67 + height="$2" 68 + width="$2" 69 + /> 70 + {!collapsed && ( 71 + <Text fontSize="$7" minWidth={200} numberOfLines={1}> 72 + Streamplace 73 + </Text> 74 + )} 75 + </View> 76 + 77 + {state.routes.map((route) => { 78 + const descriptor = descriptors[route.key]; 79 + const options = descriptor?.options ?? {}; 80 + 81 + const label = 82 + typeof options.drawerLabel === "function" 83 + ? options.drawerLabel({ focused: false, color: "$color" }) 84 + : (options.drawerLabel ?? options.title ?? route.name); 85 + 86 + const IconComponent = options.drawerIcon as 87 + | React.ComponentType<any> 88 + | undefined; 89 + 90 + return ( 91 + <SidebarItem 92 + key={route.key} 93 + icon={IconComponent ? IconComponent : FileQuestion} 94 + label={label} 95 + active={descriptor.navigation.isFocused()} 96 + collapsed={collapsed} 97 + onPress={() => { 98 + if (route.name === "Home") { 99 + // copy logic for 'Home' to reset the stack 100 + descriptor.navigation.dispatch( 101 + CommonActions.reset({ 102 + index: 0, 103 + routes: [ 104 + { 105 + name: "Home", 106 + state: { 107 + routes: [{ name: "StreamList" }], 108 + }, 109 + }, 110 + ], 111 + }), 112 + ); 113 + } else { 114 + descriptor.navigation.navigate(route.name); 115 + } 116 + }} 117 + style={options.drawerItemStyle} 118 + tint={options.drawerActiveTintColor as string} // Assuming tint is a string color or undefined 119 + /> 120 + ); 121 + })} 122 + {externalItems.map((i) => { 123 + return ( 124 + <SidebarItem 125 + key={JSON.stringify(i.label)} 126 + icon={i.item} 127 + label={i.label || "Fix this label!"} 128 + active={false} 129 + collapsed={collapsed} 130 + onPress={() => i.onPress()} 131 + tint="rgba(189, 110, 134)" 132 + /> 133 + ); 134 + })} 135 + </AnimatedYStack> 136 + ); 137 + }
+76
js/app/features/base/sidebarSlice.tsx
··· 1 + import { createAppSlice } from "../../hooks/createSlice"; 2 + import WebStorage from "../../storage/storage"; 3 + 4 + const storage = new WebStorage(); 5 + export const SIDEBAR_STORAGE_KEY = "sidebarState"; 6 + 7 + export interface SidebarState { 8 + isCollapsed: boolean; 9 + targetWidth: number; 10 + isLoaded: boolean; 11 + } 12 + 13 + const initialState: SidebarState = { 14 + isCollapsed: false, 15 + targetWidth: 250, 16 + isLoaded: false, 17 + }; 18 + 19 + export const sidebarSlice = createAppSlice({ 20 + name: "sidebar", 21 + initialState, 22 + reducers: (create) => ({ 23 + toggleSidebar: create.reducer((state) => { 24 + state.isCollapsed = !state.isCollapsed; 25 + state.targetWidth = state.isCollapsed ? 64 : 250; 26 + }), 27 + loadStateFromStorage: create.asyncThunk( 28 + async () => { 29 + const storedStateString = await storage.getItem(SIDEBAR_STORAGE_KEY); 30 + if (storedStateString) { 31 + return JSON.parse(storedStateString) as SidebarState; 32 + } 33 + return null; 34 + }, 35 + { 36 + pending: (state) => { 37 + // unlikely that this will hang for a noticeable duration 38 + }, 39 + fulfilled: (state, action) => { 40 + if (action.payload) { 41 + state.isCollapsed = action.payload.isCollapsed; 42 + state.targetWidth = action.payload.targetWidth; 43 + console.log( 44 + "Sidebar state loaded from localStorage:", 45 + action.payload, 46 + ); 47 + } else { 48 + console.log( 49 + "No sidebar state found in localStorage, using defaults.", 50 + ); 51 + } 52 + state.isLoaded = true; 53 + }, 54 + rejected: (state, action) => { 55 + state.isLoaded = true; 56 + console.error( 57 + "Failed to load sidebar state from storage:", 58 + action.error, 59 + ); 60 + }, 61 + }, 62 + ), 63 + }), 64 + selectors: { 65 + selectIsSidebarCollapsed: (state) => state.isCollapsed, 66 + selectSidebarTargetWidth: (state) => state.targetWidth, 67 + selectIsSidebarLoaded: (state) => state.isLoaded, 68 + }, 69 + }); 70 + 71 + export const { toggleSidebar, loadStateFromStorage } = sidebarSlice.actions; 72 + export const { 73 + selectIsSidebarCollapsed, 74 + selectSidebarTargetWidth, 75 + selectIsSidebarLoaded, 76 + } = sidebarSlice.selectors;
+75
js/app/hooks/useSidebarControl.tsx
··· 1 + import { useEffect } from "react"; 2 + import { 3 + SharedValue, 4 + useSharedValue, 5 + withTiming, 6 + } from "react-native-reanimated"; 7 + import { useWindowDimensions } from "tamagui"; 8 + import { useDispatch, useSelector } from "react-redux"; 9 + 10 + import { 11 + toggleSidebar, 12 + selectIsSidebarCollapsed, 13 + selectSidebarTargetWidth, 14 + } from "../features/base/sidebarSlice"; 15 + import { RootState } from "../store/store"; 16 + 17 + // Returns *true* if the screen is > 1024px 18 + function useIsLargeScreen() { 19 + const { width } = useWindowDimensions(); 20 + return width >= 1024; 21 + } 22 + 23 + export interface UseSidebarOutput { 24 + isActive: boolean; 25 + isCollapsed: boolean; 26 + animatedWidth: SharedValue<number>; 27 + toggle: () => void; 28 + } 29 + 30 + /* 31 + * useSidebarControl 32 + * A hook to control the custom sidebar on desktop, using Redux for state. 33 + * 34 + * Returns: An interface containing: 35 + * - isActive: boolean - True if the screen is considered large (width >= 1024px). 36 + * - isCollapsed: boolean - The current collapsed state of the sidebar from Redux. 37 + * - animatedWidth: SharedValue<number> - An animated value controlling the sidebar's width. 38 + * - toggle: () => void - A function to dispatch the Redux action to toggle the sidebar. 39 + */ 40 + export function useSidebarControl(): UseSidebarOutput { 41 + const dispatch = useDispatch(); 42 + const isCollapsed = useSelector((state: RootState) => 43 + selectIsSidebarCollapsed(state), 44 + ); 45 + const targetWidth = useSelector((state: RootState) => 46 + selectSidebarTargetWidth(state), 47 + ); 48 + 49 + const animatedWidth = useSharedValue(targetWidth); 50 + 51 + const isActive = useIsLargeScreen(); 52 + 53 + useEffect(() => { 54 + if (isActive) { 55 + // Only animate if the sidebar is active 56 + animatedWidth.value = withTiming(targetWidth, { duration: 250 }); 57 + } else { 58 + animatedWidth.value = targetWidth; 59 + } 60 + }, [targetWidth, isActive, animatedWidth]); 61 + 62 + const handleToggle = () => { 63 + if (isActive) { 64 + // Only allow toggle if the sidebar functionality is active 65 + dispatch(toggleSidebar()); 66 + } 67 + }; 68 + 69 + return { 70 + isActive, 71 + isCollapsed, 72 + animatedWidth, 73 + toggle: handleToggle, 74 + }; 75 + }
+99 -14
js/app/src/router.tsx
··· 24 24 ShieldQuestion, 25 25 Download, 26 26 Video, 27 + PanelLeftOpen, 28 + PanelLeftClose, 27 29 Book, 28 30 ExternalLink, 29 31 } from "@tamagui/lucide-icons"; ··· 33 35 import StreamList from "components/stream-list/stream-list"; 34 36 import { selectUserProfile } from "features/bluesky/blueskySlice"; 35 37 import usePlatform from "hooks/usePlatform"; 36 - import { useEffect, useState } from "react"; 38 + import { ReactElement, useEffect, useState } from "react"; 37 39 import { 38 40 ImageBackground, 39 41 ImageSourcePropType, 40 42 Linking, 43 + Platform, 41 44 Pressable, 42 45 StatusBar, 43 46 } from "react-native"; 44 47 import { useAppDispatch, useAppSelector } from "store/hooks"; 45 - import { useTheme, Text, View, H3 } from "tamagui"; 48 + import { useTheme, Text, View, H3, useWindowDimensions } from "tamagui"; 46 49 import AppReturnScreen from "./screens/app-return"; 47 50 import MultiScreen from "./screens/multi"; 48 51 import StreamScreen from "./screens/stream"; ··· 65 68 import Popup from "components/popup"; 66 69 import PopoutChat from "./screens/chat-popout"; 67 70 import EmbedScreen from "./screens/embed"; 71 + import { useSidebarControl } from "hooks/useSidebarControl"; 72 + import Sidebar, { ExternalDrawerItem } from "components/sidebar/sidebar"; 73 + 74 + // probabl should move this 75 + import { store } from "store/store"; 76 + import { loadStateFromStorage } from "features/base/sidebarSlice"; 77 + 78 + store.dispatch(loadStateFromStorage()); 79 + 68 80 function HomeScreen() { 69 81 return ( 70 82 <View f={1}> ··· 132 144 const Drawer = createDrawerNavigator(); 133 145 134 146 const NavigationButton = ({ canGoBack }: { canGoBack?: boolean }) => { 147 + const sidebar = useSidebarControl(); 135 148 const navigation = useNavigation(); 149 + 150 + const handlePress = () => { 151 + if (sidebar?.isActive) { 152 + sidebar.toggle(); 153 + } 154 + }; 155 + 156 + const handleGoBackPress = () => { 157 + if (canGoBack) { 158 + navigation.goBack(); 159 + } else { 160 + navigation.dispatch(DrawerActions.toggleDrawer()); 161 + } 162 + }; 163 + 164 + console.log("sidebar", sidebar); 165 + 166 + let icon: ReactElement | null = null; 167 + if (sidebar?.isActive) { 168 + if (sidebar.isCollapsed) { 169 + icon = <PanelLeftOpen />; 170 + } else { 171 + icon = <PanelLeftClose />; 172 + } 173 + } 174 + 136 175 return ( 137 - <Pressable 138 - style={{ padding: 10 }} 139 - onPress={() => { 140 - if (canGoBack) { 141 - navigation.goBack(); 142 - } else { 143 - navigation.dispatch(DrawerActions.toggleDrawer()); 144 - } 145 - }} 176 + <View 177 + flexDirection="row" 178 + marginLeft={Platform.OS === "android" ? "$0" : "$3"} 179 + marginRight={Platform.OS === "android" ? "$3" : "$0"} 146 180 > 147 - {canGoBack ? <ArrowLeft /> : <Menu />} 148 - </Pressable> 181 + {icon && ( 182 + <Pressable style={{ padding: 5 }} onPress={handlePress}> 183 + {icon} 184 + </Pressable> 185 + )} 186 + <Pressable style={{ padding: 5 }} onPress={handleGoBackPress}> 187 + {canGoBack ? <ArrowLeft /> : sidebar?.isActive || <Menu />} 188 + </Pressable> 189 + </View> 149 190 ); 150 191 }; 151 192 ··· 180 221 ); 181 222 }; 182 223 224 + const EXTERNAL_ITEMS: ExternalDrawerItem[] = [ 225 + { 226 + item: Book, 227 + label: ( 228 + <Text alignSelf="flex-start"> 229 + Documentation{" "} 230 + <ExternalLink size={16} pl={4} position="relative" top={2} /> 231 + </Text> 232 + ) as any, 233 + onPress: () => { 234 + const u = new URL(window.location.href); 235 + u.pathname = "/docs"; 236 + Linking.openURL(u.toString()); 237 + }, 238 + }, 239 + ]; 240 + 241 + // TODO: merge in ^ 183 242 function CustomDrawerContent(props) { 184 243 return ( 185 244 <DrawerContentScrollView {...props}> ··· 224 283 const [poppedUp, setPoppedUp] = useState(false); 225 284 const [livePopup, setLivePopup] = useState(false); 226 285 286 + const sidebar = useSidebarControl(); 287 + 227 288 // Top-level stuff to handle push notification registration 228 289 useEffect(() => { 229 290 dispatch(hydrate()); ··· 275 336 <> 276 337 <StatusBar backgroundColor={theme.background.val} /> 277 338 <Drawer.Navigator 339 + // if this isn't here there are issues around drawer width 340 + key={sidebar.isActive ? "1" : "0"} 278 341 initialRouteName="Home" 279 342 screenOptions={{ 343 + // for the custom sidebar 344 + drawerType: sidebar.isActive ? "permanent" : "front", 345 + swipeEnabled: !sidebar.isActive, 346 + drawerStyle: { 347 + // afaict the drawer is a RN Animated component internally 348 + // TODO (nat): look into this and change width prop as needed 349 + width: sidebar.isActive 350 + ? (sidebar.animatedWidth as any) 351 + : undefined, 352 + }, 353 + // rest 280 354 headerLeft: () => <NavigationButton />, 281 355 headerRight: () => <AvatarButton />, 282 356 drawerActiveTintColor: theme.accentColor.val, 283 357 unmountOnBlur: true, 284 358 }} 285 - drawerContent={CustomDrawerContent} 359 + drawerContent={ 360 + sidebar.isActive 361 + ? (props) => ( 362 + <Sidebar 363 + {...props} 364 + collapsed={sidebar.isCollapsed} 365 + widthAnim={sidebar.animatedWidth} 366 + externalItems={EXTERNAL_ITEMS} 367 + /> 368 + ) 369 + : CustomDrawerContent 370 + } 286 371 > 287 372 <Drawer.Screen 288 373 name="Home"
+24
js/app/store/listener.ts
··· 1 + import { createListenerMiddleware, isAnyOf } from "@reduxjs/toolkit"; 2 + import { SIDEBAR_STORAGE_KEY, sidebarSlice } from "features/base/sidebarSlice"; 3 + import { RootState } from "./store"; 4 + import storage from "storage"; 5 + 6 + export const listenerMiddleware = createListenerMiddleware(); 7 + 8 + listenerMiddleware.startListening({ 9 + matcher: isAnyOf(sidebarSlice.actions.toggleSidebar), 10 + effect: async (action, listenerApi) => { 11 + const state = listenerApi.getState() as RootState; 12 + const sidebarStateToSave = state.sidebar; 13 + 14 + try { 15 + await storage.setItem( 16 + SIDEBAR_STORAGE_KEY, 17 + JSON.stringify(sidebarStateToSave), 18 + ); 19 + console.log("Sidebar state saved to localStorage."); 20 + } catch (error) { 21 + console.error("Failed to save sidebar state to storage:", error); 22 + } 23 + }, 24 + });
+5 -1
js/app/store/store.tsx
··· 6 6 import { blueskySlice } from "features/bluesky/blueskySlice"; 7 7 import { platformSlice } from "features/platform/platformSlice"; 8 8 import { playerSlice } from "features/player/playerSlice"; 9 + import { sidebarSlice } from "features/base/sidebarSlice"; 10 + 11 + import { listenerMiddleware } from "./listener"; 9 12 10 13 const rootReducer = combineSlices( 11 14 blueskySlice, 12 15 streamplaceSlice, 13 16 platformSlice, 14 17 playerSlice, 18 + sidebarSlice, 15 19 baseSlice, 16 20 ); 17 21 ··· 32 36 // Ignore these paths in the state 33 37 ignoredPaths: [/^bluesky\..*/], 34 38 }, 35 - }); 39 + }).prepend(listenerMiddleware.middleware); 36 40 }, 37 41 preloadedState, 38 42 });