Skip to content

Commit

Permalink
feat(rn): implement feed drawer (#2443)
Browse files Browse the repository at this point in the history
* 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
lawvs authored Jan 14, 2025
1 parent 5bccea2 commit b581393
Show file tree
Hide file tree
Showing 13 changed files with 716 additions and 102 deletions.
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@react-native-cookies/cookies": "^6.2.1",
"@react-native-picker/picker": "2.9.0",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/drawer": "^7.1.1",
"@react-navigation/native": "^7.0.0",
"@shopify/flash-list": "1.7.1",
"@tanstack/react-query": "5.62.3",
Expand Down
65 changes: 65 additions & 0 deletions apps/mobile/src/modules/feed-drawer/atoms.ts
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
}
90 changes: 90 additions & 0 deletions apps/mobile/src/modules/feed-drawer/collection-panel.tsx
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>
)
}
71 changes: 71 additions & 0 deletions apps/mobile/src/modules/feed-drawer/drawer.tsx
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>
)
}
Loading

0 comments on commit b581393

Please sign in to comment.