feat(rn-list): manage list

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-01-24 15:28:25 +08:00
parent 527f6ac8b5
commit e3baee3986
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
13 changed files with 348 additions and 96 deletions

View File

@ -1,3 +1,5 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { impactAsync, ImpactFeedbackStyle } from "expo-haptics"
import { atom, useAtomValue, useSetAtom } from "jotai"
import { selectAtom } from "jotai/utils"
import * as React from "react"
@ -17,6 +19,8 @@ interface SwipeableItemProps {
leftActions?: Action[]
rightActions?: Action[]
disabled?: boolean
swipeRightToCallAction?: boolean
}
const styles = StyleSheet.create({
@ -41,18 +45,22 @@ const styles = StyleSheet.create({
},
})
const rectButtonWidth = 74
export const SwipeableItem: React.FC<SwipeableItemProps> = ({
children,
leftActions,
rightActions,
disabled,
swipeRightToCallAction,
}) => {
const [leftHaptic, setLeftHaptic] = React.useState(false)
const [rightHaptic, setRightHaptic] = React.useState(false)
const itemRef = React.useRef<Swipeable | null>(null)
const endDragCallerRef = React.useRef<() => void>(() => {})
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
const width = leftActions?.length ? leftActions.length * 74 : 74
const width = leftActions?.length ? leftActions.length * rectButtonWidth : rectButtonWidth
return (
<>
@ -68,19 +76,9 @@ export const SwipeableItem: React.FC<SwipeableItemProps> = ({
{leftActions?.map((action, index) => {
const trans = progress.interpolate({
inputRange: [0, 1],
outputRange: [-74 * (leftHaptic ? (leftActions?.length ?? 1) : index + 1), 0],
outputRange: [-rectButtonWidth * (leftActions?.length ?? 1), 0],
})
if (index === 0) {
trans.addListener(({ value }) => {
if (value >= (leftActions?.length === 1 ? 40 : 20)) {
setLeftHaptic(true)
} else {
leftHaptic && setLeftHaptic(false)
}
})
}
return (
<Animated.View
key={index}
@ -88,8 +86,8 @@ export const SwipeableItem: React.FC<SwipeableItemProps> = ({
styles.animatedContainer,
{
transform: [{ translateX: trans }],
width: leftHaptic && index === 0 ? "100%" : 74,
left: index * 74,
width: rectButtonWidth,
left: index * rectButtonWidth,
},
]}
>
@ -116,12 +114,8 @@ export const SwipeableItem: React.FC<SwipeableItemProps> = ({
}
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
const width = rightActions?.length ? rightActions.length * 74 : 74
const width = rightActions?.length ? rightActions.length * rectButtonWidth : rectButtonWidth
const parallaxX = progress.interpolate({
inputRange: [0, 1, 1.2],
outputRange: [0, 0, 10],
})
return (
<>
<View
@ -134,54 +128,18 @@ export const SwipeableItem: React.FC<SwipeableItemProps> = ({
/>
<Animated.View style={[styles.actionsWrapper, { width }]}>
{rightActions?.map((action, index) => {
const trans = progress.interpolate({
inputRange: [0, 1],
outputRange: [74 * (rightHaptic ? (rightActions?.length ?? 1) : index + 1), 0],
})
if (index === 0) {
trans.addListener(({ value }) => {
if (value <= (rightActions?.length === 1 ? -40 : -20)) {
setRightHaptic(true)
} else {
rightHaptic && setRightHaptic(false)
}
})
}
return (
<Animated.View
<RightRectButton
endDragCallerRef={endDragCallerRef}
key={index}
style={[
styles.animatedContainer,
{
transform: [{ translateX: trans }],
width: rightHaptic && index === 0 ? "100%" : 74,
left: index * 74,
},
]}
>
<RectButton
style={[
styles.actionContainer,
{
backgroundColor: action.backgroundColor ?? "#fff",
},
]}
onPress={action.onPress}
>
{action.icon}
<Animated.Text
style={[
styles.actionText,
{ color: action.color ?? "#fff" },
{ transform: [{ translateX: parallaxX }] },
]}
>
{action.label}
</Animated.Text>
</RectButton>
</Animated.View>
index={index}
action={action}
length={rightActions?.length ?? 1}
progress={progress}
swipeRightToCallAction={
swipeRightToCallAction && index === rightActions?.length - 1
}
/>
)
})}
</Animated.View>
@ -218,26 +176,16 @@ export const SwipeableItem: React.FC<SwipeableItemProps> = ({
rightThreshold={37}
enableTrackpadTwoFingerGesture
useNativeAnimations
onEnded={(e: any) => {
const { translationX } = e.nativeEvent
if (
leftHaptic &&
translationX >= (leftActions?.length === 1 ? 100 : 60) * (leftActions?.length ?? 1)
) {
leftActions?.[0]?.onPress?.()
onEnded={useTypeScriptHappyCallback(() => {
if (swipeRightToCallAction && endDragCallerRef.current) {
endDragCallerRef.current()
}
if (
rightHaptic &&
translationX <= (rightActions?.length === 1 ? -100 : -60) * (rightActions?.length ?? 1)
) {
rightActions?.[0]?.onPress?.()
}
}}
}, [swipeRightToCallAction, endDragCallerRef])}
renderLeftActions={leftActions?.length ? renderLeftActions : undefined}
renderRightActions={rightActions?.length ? renderRightActions : undefined}
overshootLeft={leftActions?.length ? leftActions?.length >= 1 : undefined}
overshootRight={rightActions?.length ? rightActions?.length >= 1 : undefined}
overshootFriction={10}
overshootFriction={swipeRightToCallAction ? 1 : 10}
>
{children}
</Swipeable>
@ -258,3 +206,97 @@ export const SwipeableGroupProvider = ({ children }: { children: React.ReactNode
return <SwipeableGroupContext.Provider value={ctx}>{children}</SwipeableGroupContext.Provider>
}
const rightActionThreshold = -100
const RightRectButton = React.memo(
({
index,
action,
length = 1,
progress,
swipeRightToCallAction,
endDragCallerRef,
}: {
progress: Animated.AnimatedInterpolation<number>
index: number
action: Action
length: number
swipeRightToCallAction?: boolean
endDragCallerRef: React.MutableRefObject<() => void>
}) => {
const trans = React.useMemo(
() =>
progress.interpolate({
inputRange: [0, 1, 1.2],
outputRange: [rectButtonWidth * length, 0, -40],
}),
[progress, length],
)
const parallaxX = React.useMemo(
() =>
progress.interpolate({
inputRange: [0, 1, 1.2],
outputRange: [0, 0, 10],
}),
[progress],
)
const hapticOnce = React.useRef(false)
React.useEffect(() => {
if (!swipeRightToCallAction) return
const id = trans.addListener(({ value }) => {
if (value <= rightActionThreshold) {
if (hapticOnce.current) return
hapticOnce.current = true
impactAsync(ImpactFeedbackStyle.Light)
endDragCallerRef.current = () => {
action.onPress?.()
}
} else {
hapticOnce.current = false
endDragCallerRef.current = () => {}
}
})
return () => {
trans.removeListener(id)
}
}, [action, endDragCallerRef, swipeRightToCallAction, trans])
return (
<Animated.View
key={index}
style={[
styles.animatedContainer,
{
transform: [{ translateX: trans }],
width: rectButtonWidth,
left: index * rectButtonWidth,
},
]}
>
<RectButton
style={[
styles.actionContainer,
{
backgroundColor: action.backgroundColor ?? "#fff",
},
]}
onPress={action.onPress}
>
{action.icon}
<Animated.Text
style={[
styles.actionText,
{ color: action.color ?? "#fff" },
{ transform: [{ translateX: parallaxX }] },
]}
>
{action.label}
</Animated.Text>
</RectButton>
</Animated.View>
)
},
)

View File

@ -28,6 +28,7 @@ export const GroupedInsetListCard: FC<
? React.Children.map(children, (child, index) => {
const isLast = index === React.Children.count(children) - 1
if (child === null) return null
const isNavigationLink =
React.isValidElement(child) &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type

View File

@ -29,6 +29,7 @@ import type { ListModel } from "@/src/store/list/store"
import { accentColor } from "@/src/theme/colors"
import { SwipeableGroupProvider, SwipeableItem } from "../../../components/common/SwipeableItem"
import { useSettingsNavigation } from "../hooks"
const ListContext = createContext({} as Record<string, HonoApiClient.List_List_Get>)
export const ListsScreen = () => {
@ -122,13 +123,15 @@ const ListItemCell: ListRenderItem<ListModel> = (props) => {
const ListItemCellImpl: ListRenderItem<ListModel> = ({ item: list }) => {
const { title, description } = list
const listData = useContext(ListContext)[list.id]
const navigation = useSettingsNavigation()
return (
<SwipeableItem
swipeRightToCallAction
rightActions={[
{
label: "Manage",
onPress: () => {
router.push(`/manage-list?id=${list.id}`)
navigation.navigate("ManageList", { id: list.id })
},
backgroundColor: accentColor,
},
@ -143,7 +146,7 @@ const ListItemCellImpl: ListRenderItem<ListModel> = ({ item: list }) => {
>
<ItemPressable
className="flex-row p-4"
onPress={() => router.push(`/manage-list?id=${list.id}`)}
onPress={() => navigation.navigate("ManageList", { id: list.id })}
>
<View className="size-16 overflow-hidden rounded-lg">
{list.image ? (

View File

@ -0,0 +1,153 @@
import type { RouteProp } from "@react-navigation/native"
import { useMutation } from "@tanstack/react-query"
import { router } from "expo-router"
import type { MutableRefObject } from "react"
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"
import { Text, View } from "react-native"
import { ModalHeaderSubmitButton } from "@/src/components/common/ModalSharedComponents"
import {
NavigationBlurEffectHeader,
SafeNavigationScrollView,
} from "@/src/components/common/SafeNavigationScrollView"
import {
GroupedInsetListBaseCell,
GroupedInsetListCard,
GroupedInsetListSectionHeader,
} from "@/src/components/ui/grouped/GroupedList"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { ItemPressable } from "@/src/components/ui/pressable/item-pressable"
import { CheckLineIcon } from "@/src/icons/check_line"
import { getBizFetchErrorMessage } from "@/src/lib/api-fetch"
import { toast } from "@/src/lib/toast"
import { useFeed } from "@/src/store/feed/hooks"
import { useList, usePrefetchOwnedLists } from "@/src/store/list/hooks"
import { listSyncServices } from "@/src/store/list/store"
import {
useFeedSubscriptionByView,
usePrefetchSubscription,
useSortedFeedSubscriptionByAlphabet,
} from "@/src/store/subscription/hooks"
import { accentColor } from "@/src/theme/colors"
import type { SettingsStackParamList } from "../types"
const ManageListContext = createContext<{
nextSelectedFeedIdRef: MutableRefObject<Set<string>>
}>(null!)
export const ManageListScreen = ({
route,
}: {
route: RouteProp<SettingsStackParamList, "ManageList">
}) => {
const { id } = route.params
usePrefetchOwnedLists()
const list = useList(id)
return (
<SafeNavigationScrollView className="bg-system-grouped-background mt-6">
<NavigationBlurEffectHeader title={`Manage List - ${list?.title}`} />
{!!list && <ListImpl id={list.id} />}
</SafeNavigationScrollView>
)
}
const ListImpl: React.FC<{ id: string }> = ({ id }) => {
const list = useList(id)!
usePrefetchSubscription(list.view)
const subscriptionIds = useFeedSubscriptionByView(list.view)
const sortedSubscriptionIds = useSortedFeedSubscriptionByAlphabet(subscriptionIds)
const nextSelectedFeedIdRef = useRef(new Set<string>())
const ctxValue = useMemo(() => ({ nextSelectedFeedIdRef }), [nextSelectedFeedIdRef])
const initOnceRef = useRef(false)
useEffect(() => {
if (initOnceRef.current) return
initOnceRef.current = true
nextSelectedFeedIdRef.current = new Set(list.feedIds)
}, [list.feedIds])
const addFeedsToFeedListMutation = useMutation({
mutationFn: () =>
listSyncServices.addFeedsToFeedList({
listId: id,
feedIds: Array.from(nextSelectedFeedIdRef.current),
}),
})
return (
<ManageListContext.Provider value={ctxValue}>
<NavigationBlurEffectHeader
headerRight={() => (
<ModalHeaderSubmitButton
isLoading={addFeedsToFeedListMutation.isPending}
isValid
onPress={() => {
addFeedsToFeedListMutation
.mutateAsync()
.then(() => {
router.back()
})
.catch((error) => {
toast.error(getBizFetchErrorMessage(error))
console.error(error)
})
}}
/>
)}
/>
<GroupedInsetListSectionHeader label="Select feeds to add to the current list" />
<GroupedInsetListCard>
{sortedSubscriptionIds.map((id) => (
<FeedCell key={id} feedId={id} isSelected={list.feedIds.includes(id)} />
))}
</GroupedInsetListCard>
</ManageListContext.Provider>
)
}
const FeedCell = (props: { feedId: string; isSelected: boolean }) => {
const feed = useFeed(props.feedId)
const { nextSelectedFeedIdRef } = useContext(ManageListContext)
const [currentSelected, setCurrentSelected] = useState(props.isSelected)
if (!feed) return null
return (
<ItemPressable
onPress={() => {
const has = nextSelectedFeedIdRef.current.has(feed.id)
if (has) {
nextSelectedFeedIdRef.current.delete(feed.id)
} else {
nextSelectedFeedIdRef.current.add(feed.id)
}
setCurrentSelected(!has)
}}
>
<GroupedInsetListBaseCell>
<View className="flex-1 flex-row items-center gap-4">
<View className="size-4 items-center justify-center">
<View className="overflow-hidden rounded-lg">
<FeedIcon feed={feed} size={24} />
</View>
</View>
<Text className="flex-1" ellipsizeMode="middle" numberOfLines={1}>
{feed?.title || "Untitled Feed"}
</Text>
</View>
<View className="ml-2 flex size-4 shrink-0 items-center justify-center">
{currentSelected && <CheckLineIcon color={accentColor} height={18} width={18} />}
</View>
</GroupedInsetListBaseCell>
</ItemPressable>
)
}

View File

@ -8,6 +8,7 @@ import { DataScreen } from "./Data"
import { FeedsScreen } from "./Feeds"
import { GeneralScreen } from "./General"
import { ListsScreen } from "./Lists"
import { ManageListScreen } from "./ManageList"
import { NotificationsScreen } from "./Notifications"
import { PrivacyScreen } from "./Privacy"
import { ProfileScreen } from "./Profile"
@ -25,5 +26,6 @@ export const SettingRoutes = (Stack: ReturnType<typeof createNativeStackNavigato
<Stack.Screen key="Feeds" name="Feeds" component={FeedsScreen} />,
<Stack.Screen key="Privacy" name="Privacy" component={PrivacyScreen} />,
<Stack.Screen key="About" name="About" component={AboutScreen} />,
<Stack.Screen key="ManageList" name="ManageList" component={ManageListScreen} />,
]
}

View File

@ -10,4 +10,7 @@ export type SettingsStackParamList = {
Feeds: undefined
Privacy: undefined
About: undefined
ManageList: {
id: string
}
}

View File

@ -1,5 +1,6 @@
import type { FeedSchema, InboxSchema } from "../database/schemas/types"
import type { EntryModel } from "../store/entry/types"
import type { FeedModel } from "../store/feed/types"
import type { ListModel } from "../store/list/store"
import type { SubscriptionModel } from "../store/subscription/store"
import type { HonoApiClient } from "./types"
@ -160,6 +161,20 @@ class Morph {
read: false,
}
}
toFeed(data: HonoApiClient.Feed_Get["feed"]): FeedModel {
return {
id: data.id,
title: data.title!,
url: data.url,
image: data.image!,
description: data.description!,
ownerUserId: data.ownerUserId!,
errorAt: data.errorAt!,
errorMessage: data.errorMessage!,
siteUrl: data.siteUrl!,
}
}
}
export const honoMorph = new Morph()

View File

@ -11,4 +11,5 @@ export namespace HonoApiClient {
export type Entry_Post = ExtractData<typeof apiClient.entries.$post>
export type Entry_Get = ExtractData<typeof apiClient.entries.$get>
export type List_List_Get = ExtractData<typeof apiClient.lists.list.$get>[number]
export type Feed_Get = ExtractData<typeof apiClient.feeds.$get>
}

View File

@ -27,12 +27,6 @@ export default function ModalLayout() {
title: "List",
}}
/>
<Stack.Screen
name="manage-list"
options={{
title: "Manage List",
}}
/>
</Stack>
)
}

View File

@ -1,5 +0,0 @@
import { View } from "react-native"
export default function ManageListScreen() {
return <View />
}

View File

@ -4,6 +4,7 @@ import { honoMorph } from "@/src/morph/hono"
import { storeDbMorph } from "@/src/morph/store-db"
import { ListService } from "@/src/services/list"
import { feedActions } from "../feed/store"
import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper"
import { getList } from "./getters"
import type { CreateListModel } from "./types"
@ -158,6 +159,21 @@ class ListSyncServices {
await apiClient.lists.$delete({ json: { listId: params.listId } })
listActions.deleteList({ listId: params.listId })
}
async addFeedsToFeedList(params: { listId: string; feedIds: string[] }) {
const feeds = await apiClient.lists.feeds.$post({
json: params,
})
const list = get().lists[params.listId]
if (!list) return
feeds.data.forEach((feed) => {
feedActions.upsertMany([honoMorph.toFeed(feed)])
})
listActions.upsertMany([
{ ...list, feedIds: [...list.feedIds, ...feeds.data.map((feed) => feed.id)] },
])
}
}
export const listSyncServices = new ListSyncServices()

View File

@ -15,6 +15,11 @@ export const getSubscriptionByView = (view: FeedViewType): string[] => {
.concat(Array.from(state.listIdByView[view]))
}
export const getFeedSubscriptionByView = (view: FeedViewType): string[] => {
const state = get()
return Array.from(state.feedIdByView[view])
}
export const getSubscriptionByCategory = (category: string): string[] => {
const state = get()

View File

@ -6,7 +6,12 @@ import { useCallback } from "react"
import { getFeed } from "../feed/getter"
import { getList } from "../list/getters"
import { getUnreadCount } from "../unread/getter"
import { getSubscription, getSubscriptionByCategory, getSubscriptionByView } from "./getter"
import {
getFeedSubscriptionByView,
getSubscription,
getSubscriptionByCategory,
getSubscriptionByView,
} from "./getter"
import { subscriptionSyncService, useSubscriptionStore } from "./store"
export const usePrefetchSubscription = (view: FeedViewType) => {
@ -42,6 +47,10 @@ export const useSubscriptionByView = (view: FeedViewType) => {
return useSubscriptionStore(useCallback(() => getSubscriptionByView(view), [view]))
}
export const useFeedSubscriptionByView = (view: FeedViewType) => {
return useSubscriptionStore(useCallback(() => getFeedSubscriptionByView(view), [view]))
}
export const useGroupedSubscription = (view: FeedViewType) => {
return useSubscriptionStore(
useCallback(
@ -138,6 +147,19 @@ export const useSortedUngroupedSubscription = (
)
}
export const useSortedFeedSubscriptionByAlphabet = (ids: string[]) => {
return useSubscriptionStore(
useCallback(() => {
return ids.sort((a, b) => {
const leftFeed = getFeed(a)
const rightFeed = getFeed(b)
if (!leftFeed || !rightFeed) return 0
return sortByAlphabet(leftFeed.title!, rightFeed.title!)
})
}, [ids]),
)
}
export const useSubscription = (id: string) => {
return useSubscriptionStore((state) => {
return state.data[id]