From 6f9fb5ba92b303a93d85b24eaac8ea8dbc01d6bb Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 31 Dec 2024 20:25:49 +0800 Subject: [PATCH] feat: rsshub form layer Signed-off-by: Innei --- apps/mobile/package.json | 2 + .../src/components/common/CopyButton.tsx | 79 +++++++++ .../src/components/common/HeaderBlur.tsx | 2 +- apps/mobile/src/components/ui/form/Label.tsx | 20 +++ apps/mobile/src/components/ui/form/Select.tsx | 93 +++++++++++ .../src/components/ui/form/TextField.tsx | 38 +++++ apps/mobile/src/icons/mingcute_down_line.tsx | 26 +++ .../modules/discover/recommendation-item.tsx | 40 +++-- .../src/modules/discover/recommendations.tsx | 15 +- apps/mobile/src/modules/discover/search.tsx | 10 +- .../mobile/src/modules/rsshub/preview-url.tsx | 38 +++++ apps/mobile/src/providers/index.tsx | 5 +- .../src/screens/(modal)/rsshub-form.tsx | 158 +++++++++++++++++- .../src/screens/(stack)/(tabs)/_layout.tsx | 4 +- .../screens/(stack)/feeds/[feedId]/index.tsx | 4 +- apps/renderer/package.json | 1 - .../src/modules/discover/DiscoverFeedForm.tsx | 12 +- packages/utils/exports.ts | 1 + packages/utils/package.json | 1 + .../utils/src}/path-parser.test.ts | 2 +- .../lib => packages/utils/src}/path-parser.ts | 0 pnpm-lock.yaml | 35 +++- 22 files changed, 530 insertions(+), 56 deletions(-) create mode 100644 apps/mobile/src/components/common/CopyButton.tsx create mode 100644 apps/mobile/src/components/ui/form/Label.tsx create mode 100644 apps/mobile/src/components/ui/form/Select.tsx create mode 100644 apps/mobile/src/components/ui/form/TextField.tsx create mode 100644 apps/mobile/src/icons/mingcute_down_line.tsx create mode 100644 apps/mobile/src/modules/rsshub/preview-url.tsx rename {apps/renderer/src/lib/__tests__ => packages/utils/src}/path-parser.test.ts (99%) rename {apps/renderer/src/lib => packages/utils/src}/path-parser.ts (100%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2deb587f6..ed32a873a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -22,8 +22,10 @@ "@follow/models": "workspace:*", "@follow/shared": "workspace:*", "@follow/utils": "workspace:*", + "@gorhom/portal": "1.0.14", "@hookform/resolvers": "3.9.1", "@react-native-cookies/cookies": "^6.2.1", + "@react-native-picker/picker": "2.9.0", "@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/native": "^7.0.0", "@shopify/flash-list": "1.7.1", diff --git a/apps/mobile/src/components/common/CopyButton.tsx b/apps/mobile/src/components/common/CopyButton.tsx new file mode 100644 index 000000000..b62cd233f --- /dev/null +++ b/apps/mobile/src/components/common/CopyButton.tsx @@ -0,0 +1,79 @@ +import { cn } from "@follow/utils/src/utils" +import { useRef } from "react" +import { Pressable } from "react-native" +import Animated, { + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from "react-native-reanimated" +import { useEventCallback } from "usehooks-ts" + +import { CheckFilledIcon } from "@/src/icons/check_filled" +import { Copy2CuteReIcon } from "@/src/icons/copy_2_cute_re" + +type Size = "sm" | "md" +interface CopyButtonProps { + onCopy: () => void + className?: string + size?: Size +} + +const sizeClassNames = { + sm: "size-8", + md: "size-10", +} + +const sizeIconSize = { + sm: 18, + md: 20, +} + +export const CopyButton = ({ onCopy, className, size = "sm" }: CopyButtonProps) => { + const initialIconScale = useSharedValue(1) + const pressedIconScale = useSharedValue(0) + const initialStyle = useAnimatedStyle(() => ({ + transform: [{ scale: initialIconScale.value }], + })) + + const pressedStyle = useAnimatedStyle(() => ({ + position: "absolute", + transform: [{ scale: pressedIconScale.value }], + })) + + const animatedProgressingRef = useRef(false) + const handlePress = useEventCallback(() => { + onCopy() + if (animatedProgressingRef.current) return + animatedProgressingRef.current = true + initialIconScale.value = withTiming(0, { duration: 100 }, () => { + pressedIconScale.value = withTiming(1, { duration: 100 }, () => { + pressedIconScale.value = withDelay( + 1000, + withTiming(0, { duration: 100 }, () => { + initialIconScale.value = withTiming(1, { duration: 100 }, () => { + animatedProgressingRef.current = false + }) + }), + ) + }) + }) + }) + return ( + + + + + + + + + ) +} diff --git a/apps/mobile/src/components/common/HeaderBlur.tsx b/apps/mobile/src/components/common/HeaderBlur.tsx index 8f89b60d6..06f8f0159 100644 --- a/apps/mobile/src/components/common/HeaderBlur.tsx +++ b/apps/mobile/src/components/common/HeaderBlur.tsx @@ -11,6 +11,6 @@ const node = ( }} /> ) -export const HeaderBlur = () => { +export const BlurEffect = () => { return node } diff --git a/apps/mobile/src/components/ui/form/Label.tsx b/apps/mobile/src/components/ui/form/Label.tsx new file mode 100644 index 000000000..04fcb2d9a --- /dev/null +++ b/apps/mobile/src/components/ui/form/Label.tsx @@ -0,0 +1,20 @@ +import { cn } from "@follow/utils/src/utils" +import type { FC, PropsWithChildren } from "react" +import type { StyleProp, ViewStyle } from "react-native" +import { Text, View } from "react-native" + +export const FormLabel: FC< + PropsWithChildren<{ + label: string + optional?: boolean + className?: string + style?: StyleProp + }> +> = ({ label, optional, className, style }) => { + return ( + + {label} + {!optional && *} + + ) +} diff --git a/apps/mobile/src/components/ui/form/Select.tsx b/apps/mobile/src/components/ui/form/Select.tsx new file mode 100644 index 000000000..6d93b8e01 --- /dev/null +++ b/apps/mobile/src/components/ui/form/Select.tsx @@ -0,0 +1,93 @@ +/* eslint-disable @eslint-react/no-array-index-key */ +import { cn } from "@follow/utils" +import { Portal } from "@gorhom/portal" +import { Picker } from "@react-native-picker/picker" +import { useMemo, useState } from "react" +import type { StyleProp, ViewStyle } from "react-native" +import { Pressable, Text, View } from "react-native" +import Animated, { SlideOutDown } from "react-native-reanimated" +import { useEventCallback } from "usehooks-ts" + +import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line" +import { useColor } from "@/src/theme/colors" + +import { BlurEffect } from "../../common/HeaderBlur" + +interface SelectProps { + options: { label: string; value: T }[] + + value: T + onValueChange: (value: T) => void + + wrapperClassName?: string + wrapperStyle?: StyleProp +} +export function Select({ + options, + value, + onValueChange, + wrapperClassName, + wrapperStyle, +}: SelectProps) { + const [isOpen, setIsOpen] = useState(false) + + const [currentValue, setCurrentValue] = useState(() => { + if (!value) { + return options[0].value + } + return value + }) + + const valueToLabelMap = useMemo(() => { + return options.reduce((acc, option) => { + acc.set(option.value, option.label) + return acc + }, new Map()) + }, [options]) + + const handleChangeValue = useEventCallback((value: T) => { + setCurrentValue(value) + onValueChange(value) + }) + + const systemFill = useColor("systemFill") + return ( + <> + {/* Trigger */} + setIsOpen(!isOpen)}> + + {valueToLabelMap.get(currentValue)} + + + + + + {/* Picker */} + {isOpen && ( + + setIsOpen(false)} + className="absolute inset-0 flex flex-row items-end" + > + + + e.stopPropagation()}> + + {options.map((option, index) => ( + + ))} + + + + + + )} + + ) +} diff --git a/apps/mobile/src/components/ui/form/TextField.tsx b/apps/mobile/src/components/ui/form/TextField.tsx new file mode 100644 index 000000000..546578be1 --- /dev/null +++ b/apps/mobile/src/components/ui/form/TextField.tsx @@ -0,0 +1,38 @@ +import { cn } from "@follow/utils/src/utils" +import type { FC } from "react" +import type { StyleProp, TextInputProps, ViewStyle } from "react-native" +import { StyleSheet, TextInput, View } from "react-native" + +interface TextFieldProps { + wrapperClassName?: string + wrapperStyle?: StyleProp +} +export const TextField: FC = ({ + className, + style, + wrapperClassName, + wrapperStyle, + ...rest +}) => { + return ( + + + + ) +} + +const styles = StyleSheet.create({ + textField: { + fontSize: 16, + }, +}) diff --git a/apps/mobile/src/icons/mingcute_down_line.tsx b/apps/mobile/src/icons/mingcute_down_line.tsx new file mode 100644 index 000000000..77d089efc --- /dev/null +++ b/apps/mobile/src/icons/mingcute_down_line.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import Svg, { G, Path } from "react-native-svg" + +interface MingcuteDownLineIconProps { + width?: number + height?: number + color?: string +} + +export const MingcuteDownLineIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: MingcuteDownLineIconProps) => { + return ( + + + + + + + ) +} diff --git a/apps/mobile/src/modules/discover/recommendation-item.tsx b/apps/mobile/src/modules/discover/recommendation-item.tsx index 0a9a06e97..adeb9ff9e 100644 --- a/apps/mobile/src/modules/discover/recommendation-item.tsx +++ b/apps/mobile/src/modules/discover/recommendation-item.tsx @@ -13,7 +13,8 @@ import { RSSHubCategoryCopyMap } from "./copy" export const RecommendationListItem: FC<{ data: RSSHubRouteDeclaration -}> = memo(({ data }) => { + routePrefix: string +}> = memo(({ data, routePrefix }) => { const { maintainers, categories } = useMemo(() => { const maintainers = new Set() const categories = new Set() @@ -90,23 +91,26 @@ export const RecommendationListItem: FC<{ {Object.keys(data.routes).map((route) => ( - { - router.push({ - pathname: "/rsshub-form", - params: { - url: data.url, - route, - }, - }) - }} - key={route} - className="bg-gray-5 h-10 flex-row items-center justify-center overflow-hidden rounded px-2" - > - - {data.routes[route].name} - - + + { + router.push({ + pathname: "/rsshub-form", + params: { + routePrefix, + route: JSON.stringify(data.routes[route]), + name: data.name, + }, + }) + }} + className="bg-gray-5 h-10 flex-row items-center justify-center overflow-hidden rounded px-2" + /> + + + {data.routes[route].name} + + + ))} diff --git a/apps/mobile/src/modules/discover/recommendations.tsx b/apps/mobile/src/modules/discover/recommendations.tsx index 0834e2f89..fda55fbe1 100644 --- a/apps/mobile/src/modules/discover/recommendations.tsx +++ b/apps/mobile/src/modules/discover/recommendations.tsx @@ -6,7 +6,7 @@ import { useHeaderHeight } from "@react-navigation/elements" import { FlashList } from "@shopify/flash-list" import { useQuery } from "@tanstack/react-query" import type { FC } from "react" -import { useCallback, useMemo, useRef, useState } from "react" +import { useCallback, useMemo, useRef } from "react" import { Text, TouchableOpacity, View } from "react-native" import type { PanGestureHandlerGestureEvent } from "react-native-gesture-handler" import { PanGestureHandler } from "react-native-gesture-handler" @@ -183,7 +183,7 @@ const ItemRenderer = ({ // Render item return ( - + ) } @@ -193,8 +193,6 @@ const NavigationSidebar: FC<{ alphabetGroups: (string | { key: string; data: RSSHubRouteDeclaration })[] listRef: React.RefObject> }> = ({ alphabetGroups, listRef }) => { - const [activeLetter, setActiveLetter] = useState("") - const scrollToLetter = useCallback( (letter: string, animated = true) => { const index = alphabetGroups.findIndex((group) => { @@ -237,10 +235,8 @@ const NavigationSidebar: FC<{ const firstChar = letter[0].toUpperCase() const firstCharIsAlphabet = /[A-Z]/.test(firstChar) if (firstCharIsAlphabet) { - setActiveLetter(letter) scrollToLetter(letter, false) } else { - setActiveLetter("#") scrollToLetter("#", false) } }, @@ -256,15 +252,10 @@ const NavigationSidebar: FC<{ hitSlop={5} key={title} onPress={() => { - setActiveLetter(title) scrollToLetter(title) }} > - - {title} - + {title} ))} diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index 84b071235..9e786b060 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -16,7 +16,7 @@ import { } from "react-native" import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context" -import { HeaderBlur } from "@/src/components/common/HeaderBlur" +import { BlurEffect } from "@/src/components/common/HeaderBlur" import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re" import { accentColor, useColor } from "@/src/theme/colors" @@ -29,7 +29,7 @@ export const SearchHeader = () => { return ( - + @@ -44,7 +44,7 @@ export const DiscoverHeader = () => { return ( - + @@ -53,11 +53,11 @@ export const DiscoverHeader = () => { } const PlaceholerSearchBar = () => { - const { colors } = useTheme() const placeholderTextColor = useColor("placeholderText") return ( { router.push("/search") }} diff --git a/apps/mobile/src/modules/rsshub/preview-url.tsx b/apps/mobile/src/modules/rsshub/preview-url.tsx new file mode 100644 index 000000000..a396fb0c6 --- /dev/null +++ b/apps/mobile/src/modules/rsshub/preview-url.tsx @@ -0,0 +1,38 @@ +import { cn, regexpPathToPath } from "@follow/utils" +import type { FC } from "react" +import { useMemo } from "react" +import type { UseFormReturn } from "react-hook-form" +import { Clipboard, Text, View } from "react-native" + +import { CopyButton } from "@/src/components/common/CopyButton" + +export const PreviewUrl: FC<{ + watch: UseFormReturn["watch"] + path: string + routePrefix: string + className?: string +}> = ({ watch, path, routePrefix, className }) => { + const data = watch() + + const fullPath = useMemo(() => { + try { + return regexpPathToPath(path, data) + } catch (err: unknown) { + console.info((err as Error).message) + return path + } + }, [path, data]) + + const renderedPath = `rsshub://${routePrefix}${fullPath}` + return ( + + {renderedPath} + { + Clipboard.setString(renderedPath) + }} + className="absolute right-0 top-0" + /> + + ) +} diff --git a/apps/mobile/src/providers/index.tsx b/apps/mobile/src/providers/index.tsx index d7a78aa8f..d4e8afa38 100644 --- a/apps/mobile/src/providers/index.tsx +++ b/apps/mobile/src/providers/index.tsx @@ -1,4 +1,5 @@ import { jotaiStore } from "@follow/utils" +import { PortalProvider } from "@gorhom/portal" import { ThemeProvider } from "@react-navigation/native" import { QueryClientProvider } from "@tanstack/react-query" import { useDrizzleStudio } from "expo-drizzle-studio-plugin" @@ -28,7 +29,9 @@ export const RootProviders = ({ children }: { children: ReactNode }) => { - {children} + + {children} + diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index 9a1022398..51487aaf0 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -1,12 +1,162 @@ -import { Stack } from "expo-router" +import type { RSSHubParameter, RSSHubParameterObject, RSSHubRoute } from "@follow/models/src/rsshub" +import { parseFullPathParams, parseRegexpPathParams } from "@follow/utils" +import { PortalProvider } from "@gorhom/portal" +import { zodResolver } from "@hookform/resolvers/zod" +import { router, Stack, useLocalSearchParams } from "expo-router" +import { useEffect, useMemo } from "react" +import type { UseFormReturn } from "react-hook-form" +import { useForm } from "react-hook-form" import { View } from "react-native" +import { KeyboardAwareScrollView } from "react-native-keyboard-controller" +import { z } from "zod" import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents" +import { FormLabel } from "@/src/components/ui/form/Label" +import { Select } from "@/src/components/ui/form/Select" +import { TextField } from "@/src/components/ui/form/TextField" +import { PreviewUrl } from "@/src/modules/rsshub/preview-url" +interface RsshubFormParams { + route: RSSHubRoute + routePrefix: string + name: string +} export default function RsshubForm() { + const params = useLocalSearchParams() + + const { route, routePrefix, name } = (params || {}) as Record + + const parsedRoute = useMemo(() => { + if (!route) return null + try { + return typeof route === "string" ? JSON.parse(route) : route + } catch { + return null + } + }, [route]) + + const canBack = router.canDismiss() + useEffect(() => { + if (!parsedRoute && canBack) { + router.dismiss() + } + }, [canBack, parsedRoute]) + if (!parsedRoute || !routePrefix) { + return null + } + return +} + +function FormImpl({ route, routePrefix, name }: RsshubFormParams) { + const { name: routeName } = route + const keys = useMemo( + () => + parseRegexpPathParams(route.path, { + excludeNames: [ + "routeParams", + "functionalFlag", + "fulltext", + "disableEmbed", + "date", + "language", + "lang", + "sort", + ], + }), + [route.path], + ) + + const formPlaceholder = useMemo>(() => { + if (!route.example) return {} + return parseFullPathParams(route.example.replace(`/${routePrefix}`, ""), route.path) + }, [route.example, route.path, routePrefix]) + const dynamicFormSchema = useMemo( + () => + z.object({ + ...Object.fromEntries( + keys.map((keyItem) => [ + keyItem.name, + keyItem.optional ? z.string().optional().nullable() : z.string().min(1), + ]), + ), + }), + [keys], + ) + + const defaultValue = useMemo(() => { + const ret = {} as Record + if (!route.parameters) return ret + for (const key in route.parameters) { + const params = normalizeRSSHubParameters(route.parameters[key]) + if (!params) continue + ret[key] = params.default + } + return ret + }, [route.parameters]) + + const form = useForm>({ + resolver: zodResolver(dynamicFormSchema), + defaultValues: defaultValue, + mode: "all", + }) as UseFormReturn + return ( - - - + + + + + + {/* Form */} + + {keys.map((keyItem) => { + const parameters = normalizeRSSHubParameters(route.parameters[keyItem.name]) + const formRegister = form.register(keyItem.name) + + return ( + + + {!parameters?.options && ( + { + formRegister.onChange({ + target: { value: text }, + }) + }} + /> + )} + + {!!parameters?.options && ( +