Skip to content

Commit 44c6fd0

Browse files
committed
feat(rn-list): support list delete
Signed-off-by: Innei <tukon479@gmail.com>
1 parent bc88121 commit 44c6fd0

File tree

12 files changed

+309
-54
lines changed

12 files changed

+309
-54
lines changed

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"dependencies": {
1818
"@better-auth/expo": "1.1.9-beta.1",
19+
"@expo/react-native-action-sheet": "4.1.0",
1920
"@expo/vector-icons": "^14.0.2",
2021
"@follow/components": "workspace:*",
2122
"@follow/constants": "workspace:*",

apps/mobile/src/components/ui/grouped/GroupedList.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,29 @@ export const GroupedInsetListActionCell: FC<{
166166
)
167167
}
168168

169+
export const GroupedInsetButtonCell: FC<{
170+
label: string
171+
onPress: () => void
172+
disabled?: boolean
173+
style?: "destructive" | "primary"
174+
}> = ({ label, onPress, disabled, style = "primary" }) => {
175+
return (
176+
<Pressable onPress={onPress} disabled={disabled}>
177+
{({ pressed }) => (
178+
<GroupedInsetListBaseCell
179+
className={cn(pressed ? "bg-system-fill" : undefined, disabled && "opacity-40")}
180+
>
181+
<View className="flex-1 items-center justify-center">
182+
<Text className={`${style === "destructive" ? "text-red" : "text-label"}`}>
183+
{label}
184+
</Text>
185+
</View>
186+
</GroupedInsetListBaseCell>
187+
)}
188+
</Pressable>
189+
)
190+
}
191+
169192
export const GroupedInformationCell: FC<{
170193
title: string
171194
description?: string

apps/mobile/src/lib/api-fetch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export const getBizFetchErrorMessage = (error: unknown) => {
7070
return data.message
7171
}
7272
} catch {
73-
return ""
73+
return error.message
7474
}
7575
}
76-
return ""
76+
return error.message
7777
}

apps/mobile/src/modules/settings/routes/Lists.tsx

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { useQuery } from "@tanstack/react-query"
21
import { router } from "expo-router"
3-
import { createElement, useCallback } from "react"
2+
import { createContext, createElement, useCallback, useContext, useMemo } from "react"
43
import type { ListRenderItem } from "react-native"
54
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"
65
import Animated, { LinearTransition } from "react-native-reanimated"
@@ -24,16 +23,16 @@ import { RadaCuteFiIcon } from "@/src/icons/rada_cute_fi"
2423
import { UserAdd2CuteFiIcon } from "@/src/icons/user_add_2_cute_fi"
2524
import { Wallet2CuteFiIcon } from "@/src/icons/wallet_2_cute_fi"
2625
import type { HonoApiClient } from "@/src/morph/types"
27-
import { listSyncServices } from "@/src/store/list/store"
26+
import { useOwnedLists, usePrefetchOwnedLists } from "@/src/store/list/hooks"
27+
import type { ListModel } from "@/src/store/list/store"
2828
import { accentColor } from "@/src/theme/colors"
2929

3030
import { SwipeableGroupProvider, SwipeableItem } from "../../../components/common/SwipeableItem"
3131

32+
const ListContext = createContext({} as Record<string, HonoApiClient.List_List_Get>)
3233
export const ListsScreen = () => {
33-
const { isLoading, data } = useQuery({
34-
queryKey: ["owned", "lists"],
35-
queryFn: () => listSyncServices.fetchOwnedLists(),
36-
})
34+
const { isLoading, data } = usePrefetchOwnedLists()
35+
const lists = useOwnedLists()
3736

3837
return (
3938
<SafeNavigationScrollView nestedScrollEnabled className="bg-system-grouped-background">
@@ -57,27 +56,41 @@ export const ListsScreen = () => {
5756
/>
5857
</GroupedInsetListCard>
5958
</View>
60-
<View className="mt-6">
61-
<GroupedInsetListCard>
62-
{data && (
63-
<SwipeableGroupProvider>
64-
<Animated.FlatList
65-
keyExtractor={keyExtractor}
66-
itemLayoutAnimation={LinearTransition}
67-
scrollEnabled={false}
68-
data={data}
69-
renderItem={ListItemCell}
70-
ItemSeparatorComponent={ItemSeparatorComponent}
71-
/>
72-
</SwipeableGroupProvider>
73-
)}
74-
{isLoading && (
75-
<View className="mt-1">
76-
<LoadingIndicator />
77-
</View>
78-
)}
79-
</GroupedInsetListCard>
80-
</View>
59+
<ListContext.Provider
60+
value={useMemo(
61+
() =>
62+
data?.reduce(
63+
(acc, list) => {
64+
acc[list.id] = list
65+
return acc
66+
},
67+
{} as Record<string, HonoApiClient.List_List_Get>,
68+
) ?? {},
69+
[data],
70+
)}
71+
>
72+
<View className="mt-6">
73+
<GroupedInsetListCard>
74+
{lists.length > 0 && (
75+
<SwipeableGroupProvider>
76+
<Animated.FlatList
77+
keyExtractor={keyExtractor}
78+
itemLayoutAnimation={LinearTransition}
79+
scrollEnabled={false}
80+
data={lists}
81+
renderItem={ListItemCell}
82+
ItemSeparatorComponent={ItemSeparatorComponent}
83+
/>
84+
</SwipeableGroupProvider>
85+
)}
86+
{isLoading && lists.length === 0 && (
87+
<View className="mt-1">
88+
<LoadingIndicator />
89+
</View>
90+
)}
91+
</GroupedInsetListCard>
92+
</View>
93+
</ListContext.Provider>
8194
</SafeNavigationScrollView>
8295
)
8396
}
@@ -100,10 +113,14 @@ const ItemSeparatorComponent = () => {
100113
)
101114
}
102115

103-
const keyExtractor = (item: HonoApiClient.List_List_Get) => item.id
116+
const keyExtractor = (item: ListModel) => item.id
104117

105-
const ListItemCell: ListRenderItem<HonoApiClient.List_List_Get> = ({ item: list }) => {
118+
const ListItemCell: ListRenderItem<ListModel> = (props) => {
119+
return <ListItemCellImpl {...props} />
120+
}
121+
const ListItemCellImpl: ListRenderItem<ListModel> = ({ item: list }) => {
106122
const { title, description } = list
123+
const listData = useContext(ListContext)[list.id]
107124
return (
108125
<SwipeableItem
109126
rightActions={[
@@ -132,9 +149,11 @@ const ListItemCell: ListRenderItem<HonoApiClient.List_List_Get> = ({ item: list
132149
>
133150
{title}
134151
</Text>
135-
<Text className="text-secondary-label text-base" numberOfLines={4}>
136-
{description}
137-
</Text>
152+
{!!description && (
153+
<Text className="text-secondary-label text-base" numberOfLines={4}>
154+
{description}
155+
</Text>
156+
)}
138157
<View className="flex-row items-center gap-1">
139158
{!!views[list.view]?.icon &&
140159
createElement(views[list.view]!.icon, {
@@ -158,14 +177,14 @@ const ListItemCell: ListRenderItem<HonoApiClient.List_List_Get> = ({ item: list
158177

159178
<View className="flex-row items-center gap-1">
160179
<UserAdd2CuteFiIcon height={16} width={16} color={accentColor} />
161-
<Text className="text-secondary-label text-sm">{list.subscriptionCount || 0}</Text>
180+
<Text className="text-secondary-label text-sm">{listData?.subscriptionCount || 0}</Text>
162181
</View>
163182

164-
{!!list.purchaseAmount && (
183+
{!!listData?.purchaseAmount && (
165184
<View className="flex-row items-center gap-1">
166185
<Wallet2CuteFiIcon height={16} width={16} color={accentColor} />
167186
<Balance className="text-secondary-label text-sm">
168-
{BigInt(list.purchaseAmount)}
187+
{BigInt(listData.purchaseAmount)}
169188
</Balance>
170189
</View>
171190
)}

apps/mobile/src/providers/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ActionSheetProvider } from "@expo/react-native-action-sheet"
12
import { jotaiStore } from "@follow/utils"
23
import { PortalProvider } from "@gorhom/portal"
34
import { ThemeProvider } from "@react-navigation/native"
@@ -30,7 +31,9 @@ export const RootProviders = ({ children }: { children: ReactNode }) => {
3031
<QueryClientProvider client={queryClient}>
3132
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
3233
<GestureHandlerRootView>
33-
<PortalProvider>{children}</PortalProvider>
34+
<ActionSheetProvider>
35+
<PortalProvider>{children}</PortalProvider>
36+
</ActionSheetProvider>
3437
</GestureHandlerRootView>
3538
</ThemeProvider>
3639
</QueryClientProvider>

apps/mobile/src/screens/(modal)/list.tsx

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { useActionSheet } from "@expo/react-native-action-sheet"
12
import { FeedViewType } from "@follow/constants"
23
import { zodResolver } from "@hookform/resolvers/zod"
3-
import { Stack, useLocalSearchParams } from "expo-router"
4-
import { memo } from "react"
4+
import { router, Stack, useLocalSearchParams } from "expo-router"
5+
import { memo, useState } from "react"
56
import { Controller, useForm } from "react-hook-form"
67
import { View } from "react-native"
78
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
@@ -14,32 +15,58 @@ import {
1415
import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvider"
1516
import { FormLabel } from "@/src/components/ui/form/Label"
1617
import { NumberField, TextField } from "@/src/components/ui/form/TextField"
17-
import { GroupedInsetListCard } from "@/src/components/ui/grouped/GroupedList"
18+
import {
19+
GroupedInsetButtonCell,
20+
GroupedInsetListCard,
21+
} from "@/src/components/ui/grouped/GroupedList"
1822
import { PowerIcon } from "@/src/icons/power"
23+
import { getBizFetchErrorMessage } from "@/src/lib/api-fetch"
24+
import { toast } from "@/src/lib/toast"
1925
import { FeedViewSelector } from "@/src/modules/feed/view-selector"
26+
import { getList } from "@/src/store/list/getters"
2027
import { useList } from "@/src/store/list/hooks"
28+
import type { ListModel } from "@/src/store/list/store"
29+
import { listSyncServices } from "@/src/store/list/store"
30+
import type { CreateListModel } from "@/src/store/list/types"
2131
import { accentColor } from "@/src/theme/colors"
2232

2333
const listSchema = z.object({
2434
title: z.string().min(1),
25-
description: z.string().min(1),
26-
image: z.string().url(),
35+
description: z.string().nullable().optional(),
36+
image: z
37+
.string()
38+
.url()
39+
.nullable()
40+
.optional()
41+
.transform((val) => (val === "" ? null : val)),
42+
2743
fee: z.number().min(0),
28-
view: z.nativeEnum(FeedViewType),
44+
view: z.number().int(),
2945
})
46+
47+
const defaultValues = {
48+
title: "",
49+
description: null,
50+
image: null,
51+
fee: 0,
52+
view: FeedViewType.Articles,
53+
} as ListModel
3054
export default function ListScreen() {
3155
const listId = useLocalSearchParams<{ id?: string }>().id
3256

3357
const list = useList(listId || "")
3458
const form = useForm({
35-
defaultValues: list,
59+
defaultValues: list || defaultValues,
3660
resolver: zodResolver(listSchema),
3761
mode: "all",
3862
})
63+
const isEditing = !!listId
64+
const { showActionSheetWithOptions } = useActionSheet()
65+
3966
return (
4067
<FormProvider form={form}>
4168
<KeyboardAwareScrollView className="bg-system-grouped-background flex-1 pb-safe">
42-
<ScreenOptions title={list?.title} />
69+
<ScreenOptions title={list?.title} listId={listId} />
4370

4471
<GroupedInsetListCard showSeparator={false} className="mt-6 px-3 py-6">
4572
<Controller
@@ -88,11 +115,14 @@ export default function ListScreen() {
88115
control={form.control}
89116
render={({ field: { onChange, onBlur, ref, value } }) => (
90117
<TextField
118+
autoCapitalize="none"
91119
label="Image"
92120
wrapperClassName="mt-2"
93121
placeholder="https://"
94122
onBlur={onBlur}
95-
onChangeText={onChange}
123+
onChangeText={(val) => {
124+
onChange(val === "" ? null : val)
125+
}}
96126
defaultValue={list?.image ?? ""}
97127
value={value ?? ""}
98128
ref={ref}
@@ -132,26 +162,96 @@ export default function ListScreen() {
132162
/>
133163
</View>
134164
</GroupedInsetListCard>
165+
166+
{isEditing && (
167+
<GroupedInsetListCard className="mt-6">
168+
<GroupedInsetButtonCell
169+
label="Delete"
170+
style="destructive"
171+
onPress={() => {
172+
showActionSheetWithOptions(
173+
{
174+
options: ["Delete", "Cancel"],
175+
cancelButtonIndex: 1,
176+
destructiveButtonIndex: 0,
177+
},
178+
async (index) => {
179+
if (index === 0) {
180+
await listSyncServices.deleteList({ listId: listId! })
181+
router.dismiss()
182+
}
183+
},
184+
)
185+
}}
186+
/>
187+
</GroupedInsetListCard>
188+
)}
135189
</KeyboardAwareScrollView>
136190
</FormProvider>
137191
)
138192
}
139193

140194
interface ScreenOptionsProps {
141195
title?: string
196+
listId?: string
142197
}
143-
const ScreenOptions = memo(({ title }: ScreenOptionsProps) => {
198+
const ScreenOptions = memo(({ title, listId }: ScreenOptionsProps) => {
144199
const form = useFormContext()
145200

201+
const { isValid, isDirty } = form.formState
202+
203+
const isEditing = !!listId
204+
const [isLoading, setIsLoading] = useState(false)
205+
146206
return (
147207
<Stack.Screen
148208
options={{
149209
headerLeft: ModalHeaderCloseButton,
150-
gestureEnabled: !form.formState.isDirty,
210+
gestureEnabled: !isDirty,
151211

152212
headerRight: () => (
153213
<FormProvider form={form}>
154-
<ModalHeaderSubmitButton isValid onPress={() => {}} />
214+
<ModalHeaderSubmitButton
215+
isValid={isValid}
216+
isLoading={isLoading}
217+
onPress={form.handleSubmit((values) => {
218+
if (!isEditing) {
219+
setIsLoading(true)
220+
listSyncServices
221+
.createList({
222+
list: values as CreateListModel,
223+
})
224+
.catch((error) => {
225+
toast.error(getBizFetchErrorMessage(error))
226+
console.error(error)
227+
})
228+
.finally(() => {
229+
setIsLoading(false)
230+
router.dismiss()
231+
})
232+
return
233+
}
234+
const list = getList(listId!)
235+
if (!list) return
236+
setIsLoading(true)
237+
listSyncServices
238+
.updateList({
239+
listId: listId!,
240+
list: {
241+
...list,
242+
...values,
243+
},
244+
})
245+
.catch((error) => {
246+
toast.error(getBizFetchErrorMessage(error))
247+
console.error(error)
248+
})
249+
.finally(() => {
250+
setIsLoading(false)
251+
router.dismiss()
252+
})
253+
})}
254+
/>
155255
</FormProvider>
156256
),
157257

0 commit comments

Comments
 (0)