From 93b38a7c70dda2e934a424995bb07e24bdefd2ac Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 31 Dec 2024 23:33:56 +0800 Subject: [PATCH] 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 Signed-off-by: Innei --- apps/mobile/global.d.ts | 7 ++ apps/mobile/package.json | 1 + .../src/components/common/CopyButton.tsx | 38 ++++++++-- apps/mobile/src/components/ui/form/Label.tsx | 2 +- apps/mobile/src/components/ui/form/Select.tsx | 6 +- .../src/components/ui/form/TextField.tsx | 2 +- .../components/ui/typography/MarkdownWeb.tsx | 70 +++++++++++++++++++ .../src/components/ui/typography/MonoText.tsx | 16 +++++ .../mobile/src/modules/rsshub/preview-url.tsx | 21 ++++-- apps/mobile/src/providers/index.tsx | 2 +- .../mobile/src/screens/(headless)/_layout.tsx | 2 +- .../src/screens/(modal)/rsshub-form.tsx | 36 ++++++++-- apps/mobile/src/screens/_layout.tsx | 2 +- apps/mobile/src/theme/colors.ts | 57 ++++++++------- apps/mobile/src/theme/utils.ts | 25 +++++++ apps/mobile/src/theme/web.ts | 24 +++++++ apps/mobile/tailwind.config.ts | 1 + pnpm-lock.yaml | 5 +- 18 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/global.d.ts create mode 100644 apps/mobile/src/components/ui/typography/MarkdownWeb.tsx create mode 100644 apps/mobile/src/components/ui/typography/MonoText.tsx create mode 100644 apps/mobile/src/theme/utils.ts create mode 100644 apps/mobile/src/theme/web.ts diff --git a/apps/mobile/global.d.ts b/apps/mobile/global.d.ts new file mode 100644 index 000000000..8e9d706bf --- /dev/null +++ b/apps/mobile/global.d.ts @@ -0,0 +1,7 @@ +import type { DOMProps } from "expo/dom" +import type { FC } from "react" + +declare global { + export type WebComponent

= FC

+} +export {} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ed32a873a..357f14b05 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/components/common/CopyButton.tsx b/apps/mobile/src/components/common/CopyButton.tsx index b62cd233f..c0ba7f94e 100644 --- a/apps/mobile/src/components/common/CopyButton.tsx +++ b/apps/mobile/src/components/common/CopyButton.tsx @@ -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 ( - - + ) } diff --git a/apps/mobile/src/components/ui/form/Label.tsx b/apps/mobile/src/components/ui/form/Label.tsx index 04fcb2d9a..113272c49 100644 --- a/apps/mobile/src/components/ui/form/Label.tsx +++ b/apps/mobile/src/components/ui/form/Label.tsx @@ -13,7 +13,7 @@ export const FormLabel: FC< > = ({ label, optional, className, style }) => { return ( - {label} + {label} {!optional && *} ) diff --git a/apps/mobile/src/components/ui/form/Select.tsx b/apps/mobile/src/components/ui/form/Select.tsx index 6d93b8e01..e894d8afa 100644 --- a/apps/mobile/src/components/ui/form/Select.tsx +++ b/apps/mobile/src/components/ui/form/Select.tsx @@ -50,21 +50,21 @@ export function Select({ onValueChange(value) }) - const systemFill = useColor("systemFill") + const systemFill = useColor("text") return ( <> {/* Trigger */} setIsOpen(!isOpen)}> {valueToLabelMap.get(currentValue)} - + diff --git a/apps/mobile/src/components/ui/form/TextField.tsx b/apps/mobile/src/components/ui/form/TextField.tsx index 546578be1..41ce18980 100644 --- a/apps/mobile/src/components/ui/form/TextField.tsx +++ b/apps/mobile/src/components/ui/form/TextField.tsx @@ -17,7 +17,7 @@ export const TextField: FC = ({ return ( +} +const parseMarkdown = (content: string, options?: Partial) => { + 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 ( +

+ {parseMarkdown(value).content} +
+ ) +} + +export default MarkdownWeb diff --git a/apps/mobile/src/components/ui/typography/MonoText.tsx b/apps/mobile/src/components/ui/typography/MonoText.tsx new file mode 100644 index 000000000..6cbfba5ad --- /dev/null +++ b/apps/mobile/src/components/ui/typography/MonoText.tsx @@ -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((props, ref) => { + return +}) + +const styles = StyleSheet.create({ + mono: { + fontFamily: Platform.select({ + ios: "Menlo-Regular", + android: "monospace", + }), + }, +}) diff --git a/apps/mobile/src/modules/rsshub/preview-url.tsx b/apps/mobile/src/modules/rsshub/preview-url.tsx index a396fb0c6..ee12ede4f 100644 --- a/apps/mobile/src/modules/rsshub/preview-url.tsx +++ b/apps/mobile/src/modules/rsshub/preview-url.tsx @@ -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["watch"] @@ -25,13 +26,25 @@ export const PreviewUrl: FC<{ const renderedPath = `rsshub://${routePrefix}${fullPath}` return ( - - {renderedPath} + + + + {renderedPath} + + { Clipboard.setString(renderedPath) }} - className="absolute right-0 top-0" + className="absolute right-1.5 top-2" /> ) diff --git a/apps/mobile/src/providers/index.tsx b/apps/mobile/src/providers/index.tsx index d4e8afa38..aedf1c1b6 100644 --- a/apps/mobile/src/providers/index.tsx +++ b/apps/mobile/src/providers/index.tsx @@ -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 }) => { diff --git a/apps/mobile/src/screens/(headless)/_layout.tsx b/apps/mobile/src/screens/(headless)/_layout.tsx index 79fe20b00..66955b9ad 100644 --- a/apps/mobile/src/screens/(headless)/_layout.tsx +++ b/apps/mobile/src/screens/(headless)/_layout.tsx @@ -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() diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index 51487aaf0..b1979ea24 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -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 ( - + {/* Form */} - + + {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 && ( + {parameters.description} + )} ) })} + + + + + ) } +const Maintainers = ({ maintainers }: { maintainers?: string[] }) => { + if (!maintainers || maintainers.length === 0) { + return null + } + + return ( + + This feed is provided by RSSHub, with credit to + {maintainers.map((m) => ( + Linking.openURL(`https://github.com/${m}`)}> + @{m} + + ))} + + ) +} + const normalizeRSSHubParameters = (parameters: RSSHubParameter): RSSHubParameterObject | null => parameters ? typeof parameters === "string" diff --git a/apps/mobile/src/screens/_layout.tsx b/apps/mobile/src/screens/_layout.tsx index b3c05fd0b..7ff4611ba 100644 --- a/apps/mobile/src/screens/_layout.tsx +++ b/apps/mobile/src/screens/_layout.tsx @@ -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() diff --git a/apps/mobile/src/theme/colors.ts b/apps/mobile/src/theme/colors.ts index 74b69c38f..75c5b4314 100644 --- a/apps/mobile/src/theme/colors.ts +++ b/apps/mobile/src/theme/colors.ts @@ -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) => { +export const buildVars = (_vars: Record) => { 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 -} /// 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 diff --git a/apps/mobile/src/theme/utils.ts b/apps/mobile/src/theme/utils.ts new file mode 100644 index 000000000..d69c76d7d --- /dev/null +++ b/apps/mobile/src/theme/utils.ts @@ -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 +} + +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})` +} diff --git a/apps/mobile/src/theme/web.ts b/apps/mobile/src/theme/web.ts new file mode 100644 index 000000000..330951df9 --- /dev/null +++ b/apps/mobile/src/theme/web.ts @@ -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]) +} diff --git a/apps/mobile/tailwind.config.ts b/apps/mobile/tailwind.config.ts index 3915936f4..ae757d5e5 100644 --- a/apps/mobile/tailwind.config.ts +++ b/apps/mobile/tailwind.config.ts @@ -6,6 +6,7 @@ export default resolveConfig({ content: ["./src/**/*.{js,jsx,ts,tsx}"], presets: [require("nativewind/preset")], + plugins: [require("@tailwindcss/typography")], theme: { extend: { fontFamily: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a01941711..2e497cef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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