-
Notifications
You must be signed in to change notification settings - Fork 917
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(rn): implement feed drawer (#2443)
* feat: add drawer state management hooks and providers * feat: add useCurrentViewDefinition hook for view definition retrieval * feat: implement feed drawer with collection and feed panels * feat: integrate feed drawer into tab layout and manage drawer state * chore: add @react-navigation/drawer dependency for drawer navigation support * chore: simplify color * refactor: migrate drawer state management to Jotai atoms * feat: enhance collect select state * chore: update api * feat: implement list view * refactor: extract usePrefetchFeed * feat: add loading indicator and separate header components for list and view * fix: merge
- Loading branch information
Showing
13 changed files
with
716 additions
and
102 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { FeedViewType } from "@follow/constants" | ||
import { jotaiStore } from "@follow/utils" | ||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" | ||
import { useCallback, useMemo } from "react" | ||
|
||
import { views } from "@/src/constants/views" | ||
|
||
// drawer open state | ||
|
||
const drawerOpenAtom = atom<boolean>(false) | ||
|
||
export function useFeedDrawer() { | ||
const [state, setState] = useAtom(drawerOpenAtom) | ||
|
||
return { | ||
isDrawerOpen: state, | ||
openDrawer: useCallback(() => setState(true), [setState]), | ||
closeDrawer: useCallback(() => setState(false), [setState]), | ||
toggleDrawer: useCallback(() => setState(!state), [setState, state]), | ||
} | ||
} | ||
|
||
// is drawer swipe disabled | ||
|
||
const isDrawerSwipeDisabledAtom = atom<boolean>(false) | ||
|
||
export function useIsDrawerSwipeDisabled() { | ||
return useAtomValue(isDrawerSwipeDisabledAtom) | ||
} | ||
|
||
export function useSetDrawerSwipeDisabled() { | ||
return useSetAtom(isDrawerSwipeDisabledAtom) | ||
} | ||
|
||
// collection panel selected state | ||
|
||
type CollectionPanelState = | ||
| { | ||
type: "view" | ||
viewId: FeedViewType | ||
} | ||
| { | ||
type: "list" | ||
listId: string | ||
} | ||
|
||
const collectionPanelStateAtom = atom<CollectionPanelState>({ | ||
type: "view", | ||
viewId: FeedViewType.Articles, | ||
}) | ||
|
||
export function useSelectedCollection() { | ||
return useAtomValue(collectionPanelStateAtom) | ||
} | ||
export const selectCollection = (state: CollectionPanelState) => { | ||
jotaiStore.set(collectionPanelStateAtom, state) | ||
} | ||
|
||
export const useViewDefinition = (view: FeedViewType) => { | ||
const viewDef = useMemo(() => views.find((v) => v.view === view), [view]) | ||
if (!viewDef) { | ||
throw new Error(`View ${view} not found`) | ||
} | ||
return viewDef | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import { cn } from "@follow/utils" | ||
import { | ||
Image, | ||
SafeAreaView, | ||
ScrollView, | ||
TouchableOpacity, | ||
useWindowDimensions, | ||
View, | ||
} from "react-native" | ||
|
||
import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" | ||
import type { ViewDefinition } from "@/src/constants/views" | ||
import { views } from "@/src/constants/views" | ||
import { useList } from "@/src/store/list/hooks" | ||
import { useAllListSubscription } from "@/src/store/subscription/hooks" | ||
|
||
import { selectCollection, useSelectedCollection } from "./atoms" | ||
|
||
export const CollectionPanel = () => { | ||
const winDim = useWindowDimensions() | ||
const lists = useAllListSubscription() | ||
|
||
return ( | ||
<SafeAreaView | ||
className="bg-secondary-system-background" | ||
style={{ width: Math.max(50, winDim.width * 0.15) }} | ||
> | ||
<ScrollView contentContainerClassName="flex py-3 gap-3"> | ||
{views.map((viewDef) => ( | ||
<ViewButton key={viewDef.name} viewDef={viewDef} /> | ||
))} | ||
{lists.map((listId) => ( | ||
<ListButton key={listId} listId={listId} /> | ||
))} | ||
</ScrollView> | ||
</SafeAreaView> | ||
) | ||
} | ||
|
||
const ViewButton = ({ viewDef }: { viewDef: ViewDefinition }) => { | ||
const selectedCollection = useSelectedCollection() | ||
const isActive = selectedCollection.type === "view" && selectedCollection.viewId === viewDef.view | ||
|
||
return ( | ||
<TouchableOpacity | ||
className={cn( | ||
"mx-3 flex aspect-square items-center justify-center rounded-lg p-3", | ||
isActive ? "bg-system-fill" : "bg-system-background", | ||
)} | ||
onPress={() => | ||
selectCollection({ | ||
type: "view", | ||
viewId: viewDef.view, | ||
}) | ||
} | ||
> | ||
<viewDef.icon key={viewDef.name} color={viewDef.activeColor} /> | ||
</TouchableOpacity> | ||
) | ||
} | ||
|
||
const ListButton = ({ listId }: { listId: string }) => { | ||
const list = useList(listId) | ||
const selectedCollection = useSelectedCollection() | ||
const isActive = selectedCollection.type === "list" && selectedCollection.listId === listId | ||
if (!list) return null | ||
|
||
return ( | ||
<TouchableOpacity | ||
className={cn( | ||
"mx-3 flex aspect-square items-center justify-center rounded-lg p-3", | ||
isActive ? "bg-system-fill" : "bg-system-background", | ||
)} | ||
onPress={() => | ||
selectCollection({ | ||
type: "list", | ||
listId, | ||
}) | ||
} | ||
> | ||
<View className="overflow-hidden rounded"> | ||
{list.image ? ( | ||
<Image source={{ uri: list.image, width: 24, height: 24 }} resizeMode="cover" /> | ||
) : ( | ||
<FallbackIcon title={list.title} size={24} /> | ||
)} | ||
</View> | ||
</TouchableOpacity> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import type { PropsWithChildren } from "react" | ||
import { useCallback } from "react" | ||
import { useWindowDimensions, View } from "react-native" | ||
import { Drawer } from "react-native-drawer-layout" | ||
import type { PanGestureType } from "react-native-gesture-handler/lib/typescript/handlers/gestures/panGesture" | ||
|
||
import { isIOS } from "@/src/lib/platform" | ||
|
||
import { useFeedDrawer, useIsDrawerSwipeDisabled } from "./atoms" | ||
import { CollectionPanel } from "./collection-panel" | ||
import { FeedPanel } from "./feed-panel" | ||
|
||
export const FeedDrawer = ({ children }: PropsWithChildren) => { | ||
const { isDrawerOpen, openDrawer, closeDrawer } = useFeedDrawer() | ||
const winDim = useWindowDimensions() | ||
const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled() | ||
|
||
const renderDrawerContent = useCallback(() => <DrawerContent />, []) | ||
const configureGestureHandler = useCallback( | ||
(handler: PanGestureType) => { | ||
const swipeEnabled = !isDrawerSwipeDisabled | ||
if (swipeEnabled) { | ||
if (isDrawerOpen) { | ||
return handler.activeOffsetX([-1, 1]) | ||
} else { | ||
return ( | ||
handler | ||
// Any movement to the left is a pager swipe | ||
// so fail the drawer gesture immediately. | ||
.failOffsetX(-1) | ||
// Don't rush declaring that a movement to the right | ||
// is a drawer swipe. It could be a vertical scroll. | ||
.activeOffsetX(5) | ||
) | ||
} | ||
} else { | ||
// Fail the gesture immediately. | ||
// This seems more reliable than the `swipeEnabled` prop. | ||
// With `swipeEnabled` alone, the gesture may freeze after toggling off/on. | ||
return handler.failOffsetX([0, 0]).failOffsetY([0, 0]) | ||
} | ||
}, | ||
[isDrawerOpen, isDrawerSwipeDisabled], | ||
) | ||
|
||
return ( | ||
<Drawer | ||
open={isDrawerOpen} | ||
drawerStyle={{ width: Math.min(400, winDim.width * 0.85) }} | ||
onOpen={openDrawer} | ||
onClose={closeDrawer} | ||
renderDrawerContent={renderDrawerContent} | ||
configureGestureHandler={configureGestureHandler} | ||
swipeEdgeWidth={winDim.width} | ||
swipeMinVelocity={100} | ||
swipeMinDistance={10} | ||
drawerType={isIOS ? "slide" : "front"} | ||
> | ||
{children} | ||
</Drawer> | ||
) | ||
} | ||
|
||
const DrawerContent = () => { | ||
return ( | ||
<View className="bg-system-background flex h-[500px] flex-1 flex-row overflow-hidden"> | ||
<CollectionPanel /> | ||
<FeedPanel /> | ||
</View> | ||
) | ||
} |
Oops, something went wrong.