feat(rn): impl markdown component for rn

- Added `es-toolkit` version 1.29.0 to `pnpm-lock.yaml` and `package.json`.
- Updated `typescript` to version 5.6.3 in `pnpm-lock.yaml`.
- Enhanced `CopyButton` component with new size option "tiny" and improved background color animation.
- Updated `Label`, `Select`, and `TextField` components to adjust height for better UI consistency.
- Refactored `PreviewUrl` component to use `ScrollView` and `MonoText` for improved text display.
- Added Markdown support in `rsshub-form` for better content presentation.
- Updated color handling in `colors.ts` for better theme management.

Signed-off-by: Innei <i@innei.in>
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2024-12-31 23:33:56 +08:00
parent 97bacc99f1
commit 93b38a7c70
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
18 changed files with 269 additions and 48 deletions

7
apps/mobile/global.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
import type { DOMProps } from "expo/dom"
import type { FC } from "react"
declare global {
export type WebComponent<P = object> = FC<P & { dom?: DOMProps }>
}
export {}

View File

@ -33,6 +33,7 @@
"better-auth": "1.1.3",
"cookie-es": "^1.2.2",
"dayjs": "1.11.13",
"es-toolkit": "1.29.0",
"expo": "52.0.18",
"expo-apple-authentication": "~7.1.2",
"expo-blur": "~14.0.1",

View File

@ -2,6 +2,7 @@ import { cn } from "@follow/utils/src/utils"
import { useRef } from "react"
import { Pressable } from "react-native"
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withDelay,
@ -11,8 +12,9 @@ import { useEventCallback } from "usehooks-ts"
import { CheckFilledIcon } from "@/src/icons/check_filled"
import { Copy2CuteReIcon } from "@/src/icons/copy_2_cute_re"
import { useColor } from "@/src/theme/colors"
type Size = "sm" | "md"
type Size = "sm" | "md" | "tiny"
interface CopyButtonProps {
onCopy: () => void
className?: string
@ -20,15 +22,18 @@ interface CopyButtonProps {
}
const sizeClassNames = {
tiny: "size-6",
sm: "size-8",
md: "size-10",
}
const sizeIconSize = {
tiny: 14,
sm: 18,
md: 20,
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export const CopyButton = ({ onCopy, className, size = "sm" }: CopyButtonProps) => {
const initialIconScale = useSharedValue(1)
const pressedIconScale = useSharedValue(0)
@ -36,14 +41,26 @@ export const CopyButton = ({ onCopy, className, size = "sm" }: CopyButtonProps)
transform: [{ scale: initialIconScale.value }],
}))
const initialBgColor = useColor("gray3")
const pressedBgColor = useColor("green")
const pressedStyle = useAnimatedStyle(() => ({
position: "absolute",
transform: [{ scale: pressedIconScale.value }],
}))
const wrapperStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
pressedIconScale.value,
[0, 1],
[initialBgColor, pressedBgColor],
),
}))
const animatedProgressingRef = useRef(false)
const handlePress = useEventCallback(() => {
onCopy()
if (animatedProgressingRef.current) return
animatedProgressingRef.current = true
initialIconScale.value = withTiming(0, { duration: 100 }, () => {
@ -51,18 +68,25 @@ export const CopyButton = ({ onCopy, className, size = "sm" }: CopyButtonProps)
pressedIconScale.value = withDelay(
1000,
withTiming(0, { duration: 100 }, () => {
initialIconScale.value = withTiming(1, { duration: 100 }, () => {
animatedProgressingRef.current = false
})
initialIconScale.value = withTiming(1, { duration: 100 })
}),
)
})
})
setTimeout(
() => {
animatedProgressingRef.current = false
},
100 + 100 + 1000 + 100 + 100,
)
})
return (
<Pressable
<AnimatedPressable
hitSlop={10}
style={wrapperStyle}
className={cn(
"bg-red items-center justify-center rounded-lg",
"bg-gray-4 items-center justify-center rounded-lg",
sizeClassNames[size],
className,
)}
@ -74,6 +98,6 @@ export const CopyButton = ({ onCopy, className, size = "sm" }: CopyButtonProps)
<Animated.View style={pressedStyle}>
<CheckFilledIcon color="#fff" height={sizeIconSize[size]} width={sizeIconSize[size]} />
</Animated.View>
</Pressable>
</AnimatedPressable>
)
}

View File

@ -13,7 +13,7 @@ export const FormLabel: FC<
> = ({ label, optional, className, style }) => {
return (
<View className={cn("flex-row", className)} style={style}>
<Text className="text-label text-lg font-medium capitalize">{label}</Text>
<Text className="text-label font-medium capitalize">{label}</Text>
{!optional && <Text className="text-red ml-1 align-sub">*</Text>}
</View>
)

View File

@ -50,21 +50,21 @@ export function Select<T>({
onValueChange(value)
})
const systemFill = useColor("systemFill")
const systemFill = useColor("text")
return (
<>
{/* Trigger */}
<Pressable onPress={() => setIsOpen(!isOpen)}>
<View
className={cn(
"border-system-fill/80 bg-system-fill/30 h-12 flex-row items-center rounded-lg border pl-4 pr-2",
"border-system-fill/80 bg-system-fill/30 h-10 flex-row items-center rounded-lg border pl-4 pr-2",
wrapperClassName,
)}
style={wrapperStyle}
>
<Text className="text-text">{valueToLabelMap.get(currentValue)}</Text>
<View className="ml-auto shrink-0">
<MingcuteDownLineIcon color={systemFill} />
<MingcuteDownLineIcon color={systemFill} height={16} width={16} />
</View>
</View>
</Pressable>

View File

@ -17,7 +17,7 @@ export const TextField: FC<TextInputProps & TextFieldProps> = ({
return (
<View
className={cn(
"bg-system-fill/40 relative h-12 flex-row items-center rounded-lg px-4",
"bg-system-fill/40 relative h-10 flex-row items-center rounded-lg px-4",
wrapperClassName,
)}
style={wrapperStyle}

View File

@ -0,0 +1,70 @@
"use dom"
import "@/src/global.css"
import "remark-gh-alerts/styles/github-base.css"
import "remark-gh-alerts/styles/github-colors-dark-media.css"
import "remark-gh-alerts/styles/github-colors-light.css"
import { cn } from "@follow/utils"
import type { Components } from "hast-util-to-jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import { Fragment, jsx, jsxs } from "react/jsx-runtime"
import rehypeStringify from "rehype-stringify"
import remarkDirective from "remark-directive"
import remarkGfm from "remark-gfm"
import remarkGithubAlerts from "remark-gh-alerts"
import remarkParse from "remark-parse"
import remarkRehype from "remark-rehype"
import { unified } from "unified"
import { useDarkMode } from "usehooks-ts"
import { VFile } from "vfile"
import { useCSSInjection } from "@/src/theme/web"
export interface RemarkOptions {
components: Partial<Components>
}
const parseMarkdown = (content: string, options?: Partial<RemarkOptions>) => {
const file = new VFile(content)
const { components } = options || {}
const pipeline = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkGithubAlerts)
.use(remarkDirective)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStringify, { allowDangerousHtml: true })
const tree = pipeline.parse(content)
const hastTree = pipeline.runSync(tree, file)
return {
content: toJsxRuntime(hastTree, {
Fragment,
ignoreInvalidStyle: true,
jsx: (type, props, key) => jsx(type as any, props, key),
jsxs: (type, props, key) => jsxs(type as any, props, key),
passNode: true,
components: {
...components,
},
}),
}
}
const MarkdownWeb: WebComponent<{ value: string }> = ({ value }) => {
useCSSInjection()
const { isDarkMode } = useDarkMode()
return (
<div className={cn("text-text prose min-w-0", isDarkMode ? "prose-invert" : "prose")}>
{parseMarkdown(value).content}
</div>
)
}
export default MarkdownWeb

View File

@ -0,0 +1,16 @@
import { forwardRef } from "react"
import type { TextProps } from "react-native"
import { Platform, StyleSheet, Text } from "react-native"
export const MonoText = forwardRef<Text, TextProps>((props, ref) => {
return <Text ref={ref} {...props} style={StyleSheet.flatten([props.style, styles.mono])} />
})
const styles = StyleSheet.create({
mono: {
fontFamily: Platform.select({
ios: "Menlo-Regular",
android: "monospace",
}),
},
})

View File

@ -2,9 +2,10 @@ 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 { Clipboard, ScrollView, View } from "react-native"
import { CopyButton } from "@/src/components/common/CopyButton"
import { MonoText } from "@/src/components/ui/typography/MonoText"
export const PreviewUrl: FC<{
watch: UseFormReturn<any>["watch"]
@ -25,13 +26,25 @@ export const PreviewUrl: FC<{
const renderedPath = `rsshub://${routePrefix}${fullPath}`
return (
<View className={cn("relative min-w-0", className)}>
<Text className="text-text/80 w-full whitespace-pre-line break-words">{renderedPath}</Text>
<View className={cn("bg-gray-2/20 relative min-w-0 rounded-lg px-4 py-3", className)}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="pr-12"
>
<MonoText
className="text-text/80 w-full whitespace-nowrap break-words text-sm"
numberOfLines={1}
>
{renderedPath}
</MonoText>
</ScrollView>
<CopyButton
size="tiny"
onCopy={() => {
Clipboard.setString(renderedPath)
}}
className="absolute right-0 top-0"
className="absolute right-1.5 top-2"
/>
</View>
)

View File

@ -12,8 +12,8 @@ import { KeyboardProvider } from "react-native-keyboard-controller"
import { sqlite } from "../database"
import { queryClient } from "../lib/query-client"
import { getCurrentColors } from "../theme/colors"
import { DarkTheme, DefaultTheme } from "../theme/navigation"
import { getCurrentColors } from "../theme/utils"
import { MigrationProvider } from "./migration"
export const RootProviders = ({ children }: { children: ReactNode }) => {

View File

@ -1,7 +1,7 @@
import { Stack } from "expo-router"
import { useColorScheme } from "react-native"
import { getSystemBackgroundColor } from "@/src/theme/colors"
import { getSystemBackgroundColor } from "@/src/theme/utils"
export default function HeadlessLayout() {
useColorScheme()

View File

@ -6,7 +6,7 @@ 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 { Linking, Text, TouchableOpacity, View } from "react-native"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { z } from "zod"
@ -14,6 +14,7 @@ import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedCompo
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 MarkdownWeb from "@/src/components/ui/typography/MarkdownWeb"
import { PreviewUrl } from "@/src/modules/rsshub/preview-url"
interface RsshubFormParams {
@ -102,19 +103,20 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) {
return (
<PortalProvider>
<KeyboardAwareScrollView>
<KeyboardAwareScrollView className="bg-system-grouped-background">
<Stack.Screen
options={{ headerLeft: ModalHeaderCloseButton, headerTitle: `${name} - ${routeName}` }}
/>
<PreviewUrl
className="my-6 px-6"
className="m-2 mb-6"
watch={form.watch}
path={route.path}
routePrefix={routePrefix}
/>
{/* Form */}
<View className="gap-4 px-2">
<View className="bg-system-background mx-2 gap-4 rounded-lg px-3 py-6">
{keys.map((keyItem) => {
const parameters = normalizeRSSHubParameters(route.parameters[keyItem.name])
const formRegister = form.register(keyItem.name)
@ -145,15 +147,41 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) {
}}
/>
)}
{!!parameters && (
<Text className="text-text/80 ml-2 mt-2 text-xs">{parameters.description}</Text>
)}
</View>
)
})}
</View>
<Maintainers maintainers={route.maintainers} />
<View className="mx-4 mt-4">
<MarkdownWeb value={route.description} dom={{ matchContents: true }} />
</View>
</KeyboardAwareScrollView>
</PortalProvider>
)
}
const Maintainers = ({ maintainers }: { maintainers?: string[] }) => {
if (!maintainers || maintainers.length === 0) {
return null
}
return (
<View className="text-text/80 mx-4 mt-2 flex flex-row flex-wrap gap-x-1 text-sm">
<Text className="text-text/80 text-xs">This feed is provided by RSSHub, with credit to </Text>
{maintainers.map((m) => (
<TouchableOpacity key={m} onPress={() => Linking.openURL(`https://github.com/${m}`)}>
<Text className="text-text/50 text-xs">@{m}</Text>
</TouchableOpacity>
))}
</View>
)
}
const normalizeRSSHubParameters = (parameters: RSSHubParameter): RSSHubParameterObject | null =>
parameters
? typeof parameters === "string"

View File

@ -6,7 +6,7 @@ import { useColorScheme } from "nativewind"
import { DebugButton } from "../modules/debug"
import { RootProviders } from "../providers"
import { usePrefetchSessionUser } from "../store/user/hooks"
import { getSystemBackgroundColor } from "../theme/colors"
import { getSystemBackgroundColor } from "../theme/utils"
export default function RootLayout() {
useColorScheme()

View File

@ -1,17 +1,18 @@
import { useColorScheme, vars } from "nativewind"
import { useMemo } from "react"
import type { StyleProp, ViewStyle } from "react-native"
import { Appearance, StyleSheet } from "react-native"
// @ts-expect-error
const IS_DOM = typeof ReactNativeWebView !== "undefined"
const varPrefix = "--color"
export const accentColor = "#FF5C00"
const buildVars = (_vars: Record<string, string>) => {
export const buildVars = (_vars: Record<string, string>) => {
const cssVars = {} as Record<`${typeof varPrefix}-${string}`, string>
for (const [key, value] of Object.entries(_vars)) {
cssVars[`${varPrefix}-${key}`] = value
}
return vars(cssVars)
return IS_DOM ? cssVars : vars(cssVars)
}
const lightPalette = {
@ -54,13 +55,13 @@ const darkPalette = {
gray5: "44 44 46",
gray6: "28 28 30",
}
const palette = {
export const palette = {
// iOS color palette https://developer.apple.com/design/human-interface-guidelines/color
light: buildVars(lightPalette),
dark: buildVars(darkPalette),
}
const lightVariants = {
export const lightVariants = {
// UIKit Colors
label: "0 0 0",
secondaryLabel: "122 122 122",
@ -92,7 +93,7 @@ const lightVariants = {
// Extended colors
disabled: "235 235 228",
}
const darkVariants = {
export const darkVariants = {
// UIKit Colors
label: "255 255 255",
secondaryLabel: "172 172 178",
@ -124,16 +125,6 @@ const darkVariants = {
// Extended colors
disabled: "85 85 85",
}
const variants = {
light: buildVars(lightVariants),
dark: buildVars(darkVariants),
}
export const getCurrentColors = () => {
const colorScheme = Appearance.getColorScheme() || "light"
return StyleSheet.compose(variants[colorScheme], palette[colorScheme]) as StyleProp<ViewStyle>
}
/// Utils
@ -142,15 +133,33 @@ const toRgb = (hex: string) => {
return `rgb(${r} ${g} ${b})`
}
export const getSystemBackgroundColor = () => {
const colorScheme = Appearance.getColorScheme() || "light"
const colors = colorScheme === "light" ? lightVariants : darkVariants
return toRgb(colors.systemBackground)
const mergedLightColors = {
...lightVariants,
...lightPalette,
}
const mergedDarkColors = {
...darkVariants,
...darkPalette,
}
const mergedColors = {
light: mergedLightColors,
dark: mergedDarkColors,
}
export const useColor = (color: keyof typeof lightVariants | keyof typeof darkVariants) => {
export const colorVariants = {
light: buildVars(lightVariants),
dark: buildVars(darkVariants),
}
export const useColor = (color: keyof typeof mergedLightColors) => {
const { colorScheme } = useColorScheme()
const colors = colorScheme === "light" ? lightVariants : darkVariants
const colors = mergedColors[colorScheme || "light"]
return useMemo(() => toRgb(colors[color]), [color, colors])
}
export const useColors = () => {
const { colorScheme } = useColorScheme()
return mergedColors[colorScheme || "light"]
}
export type Colors = typeof mergedLightColors

View File

@ -0,0 +1,25 @@
import type { StyleProp, ViewStyle } from "react-native"
import { Appearance, StyleSheet } from "react-native"
import { colorVariants, darkVariants, lightVariants, palette } from "./colors"
export const getCurrentColors = () => {
const colorScheme = Appearance.getColorScheme() || "light"
return StyleSheet.compose(
colorVariants[colorScheme],
palette[colorScheme],
) as StyleProp<ViewStyle>
}
export const getSystemBackgroundColor = () => {
const colorScheme = Appearance.getColorScheme() || "light"
const colors = colorScheme === "light" ? lightVariants : darkVariants
return toRgb(colors.systemBackground)
}
const toRgb = (hex: string) => {
const [r, g, b] = hex.split(" ").map((s) => Number.parseInt(s))
return `rgb(${r} ${g} ${b})`
}

View File

@ -0,0 +1,24 @@
import { useInsertionEffect } from "react"
import { useDarkMode } from "usehooks-ts"
import { colorVariants, palette } from "./colors"
export const useCSSInjection = () => {
const isDark = useDarkMode().isDarkMode
useInsertionEffect(() => {
const style = document.createElement("style")
const vars1 = colorVariants[isDark ? "dark" : "light"]
const vars2 = palette[isDark ? "dark" : "light"]
style.innerHTML = `:root {${[...Object.entries(vars1), ...Object.entries(vars2)]
.map(([key, value]) => `${key}: ${value};`)
.join("\n")}}`
document.head.append(style)
return () => {
style.remove()
}
}, [isDark])
}

View File

@ -6,6 +6,7 @@ export default resolveConfig({
content: ["./src/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
plugins: [require("@tailwindcss/typography")],
theme: {
extend: {
fontFamily: {

View File

@ -460,6 +460,9 @@ importers:
dayjs:
specifier: 1.11.13
version: 1.11.13
es-toolkit:
specifier: 1.29.0
version: 1.29.0
expo:
specifier: 52.0.18
version: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
@ -13945,7 +13948,7 @@ packages:
'@microsoft/api-extractor': ^7.36.0
'@swc/core': ^1
postcss: ^8.4.12
typescript: '>=4.5.0'
typescript: 5.6.3
peerDependenciesMeta:
'@microsoft/api-extractor':
optional: true