From 1439d87f071dd6153519f56091e73f3fabf817b4 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 3 Jan 2025 00:10:02 +0800 Subject: [PATCH 01/70] fix: improve Markdown rendering and UI adjustments in various components - Updated TextField to ensure full width and flex behavior. - Optimized MarkdownWeb to memoize parsed content for better performance. - Adjusted Markdown rendering in rsshub-form and DiscoverFeedForm to fix directive formatting. - Enhanced ModalError to allow text selection in error stack trace. - Minor adjustments in parse-markdown utility for directive handling. These changes enhance the user interface and improve performance in Markdown rendering. Signed-off-by: Innei --- apps/mobile/src/components/ui/form/TextField.tsx | 2 +- apps/mobile/src/components/ui/typography/MarkdownWeb.tsx | 3 ++- apps/mobile/src/screens/(modal)/rsshub-form.tsx | 5 ++++- apps/renderer/src/components/errors/ModalError.tsx | 2 +- apps/renderer/src/modules/discover/DiscoverFeedForm.tsx | 5 +++-- packages/components/src/utils/parse-markdown.tsx | 3 ++- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/ui/form/TextField.tsx b/apps/mobile/src/components/ui/form/TextField.tsx index 41ce189803..408e5b5dc9 100644 --- a/apps/mobile/src/components/ui/form/TextField.tsx +++ b/apps/mobile/src/components/ui/form/TextField.tsx @@ -23,7 +23,7 @@ export const TextField: FC = ({ style={wrapperStyle} > diff --git a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx index d77421ad26..47833fad01 100644 --- a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx +++ b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx @@ -3,6 +3,7 @@ import "@/src/global.css" import { parseMarkdown } from "@follow/components/src/utils/parse-markdown" import { cn } from "@follow/utils" +import { useMemo } from "react" import { useDarkMode } from "usehooks-ts" import { useCSSInjection } from "@/src/theme/web" @@ -13,7 +14,7 @@ const MarkdownWeb: WebComponent<{ value: string }> = ({ value }) => { const { isDarkMode } = useDarkMode() return (
- {parseMarkdown(value).content} + {useMemo(() => parseMarkdown(value).content, [value])}
) } diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index e01db16bda..ce072434e8 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -158,7 +158,10 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { - + diff --git a/apps/renderer/src/components/errors/ModalError.tsx b/apps/renderer/src/components/errors/ModalError.tsx index 1b14fa6fba..4bb89ac089 100644 --- a/apps/renderer/src/components/errors/ModalError.tsx +++ b/apps/renderer/src/components/errors/ModalError.tsx @@ -26,7 +26,7 @@ const ModalErrorFallback: FC = (props) => {
{message}
{import.meta.env.DEV && stack ? ( -
+          
             {attachOpenInEditor(stack)}
           
) : null} diff --git a/apps/renderer/src/modules/discover/DiscoverFeedForm.tsx b/apps/renderer/src/modules/discover/DiscoverFeedForm.tsx index 007b36884d..158c7a5f7a 100644 --- a/apps/renderer/src/modules/discover/DiscoverFeedForm.tsx +++ b/apps/renderer/src/modules/discover/DiscoverFeedForm.tsx @@ -79,7 +79,8 @@ const FeedDescription = ({ description }: { description?: string }) => { <>

{t("discover.feed_description")}

- {description} + {/* Fix markdown directive */} + {description.replaceAll("::: ", ":::")} ) @@ -248,7 +249,7 @@ export const DiscoverFeedForm = ({ )}
{keys.map((keyItem) => { - const parameters = normalizeRSSHubParameters(route.parameters[keyItem.name]) + const parameters = normalizeRSSHubParameters(route.parameters?.[keyItem.name]) const formRegister = form.register(keyItem.name) diff --git a/packages/components/src/utils/parse-markdown.tsx b/packages/components/src/utils/parse-markdown.tsx index e8ef6daa7b..c60fac974b 100644 --- a/packages/components/src/utils/parse-markdown.tsx +++ b/packages/components/src/utils/parse-markdown.tsx @@ -24,16 +24,17 @@ export const parseMarkdown = (content: string, options?: Partial) const { components } = options || {} const pipeline = unified() + .use(remarkDirective) .use(remarkParse) .use(remarkGfm) .use(remarkGithubAlerts) - .use(remarkDirective) .use(remarkCalloutDirectives, { aliases: { danger: "deter", tip: "note", + warning: "warn", }, callouts: { note: { From 4f3d9902af372dd849f02a20dc1ae644d309fd73 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 3 Jan 2025 00:43:14 +0800 Subject: [PATCH 02/70] fix(rn): discover form Signed-off-by: Innei --- .../src/components/ui/form/FormProvider.tsx | 19 ++ .../src/components/ui/form/TextField.tsx | 46 ++--- apps/mobile/src/icons/check_line.tsx | 24 +++ apps/mobile/src/modules/discover/search.tsx | 3 + .../src/screens/(modal)/rsshub-form.tsx | 189 +++++++++++------- apps/mobile/src/theme/colors.ts | 8 +- apps/mobile/src/theme/utils.ts | 8 +- packages/utils/src/color.ts | 34 ++++ 8 files changed, 228 insertions(+), 103 deletions(-) create mode 100644 apps/mobile/src/components/ui/form/FormProvider.tsx create mode 100644 apps/mobile/src/icons/check_line.tsx diff --git a/apps/mobile/src/components/ui/form/FormProvider.tsx b/apps/mobile/src/components/ui/form/FormProvider.tsx new file mode 100644 index 0000000000..ec8dfc1f97 --- /dev/null +++ b/apps/mobile/src/components/ui/form/FormProvider.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react" +import type { FieldValues, UseFormReturn } from "react-hook-form" + +const FormContext = createContext | null>(null) + +export function FormProvider(props: { + form: UseFormReturn + children: React.ReactNode +}) { + return {props.children} +} + +export function useFormContext() { + const context = useContext(FormContext) + if (!context) { + throw new Error("useFormContext must be used within a FormProvider") + } + return context as UseFormReturn +} diff --git a/apps/mobile/src/components/ui/form/TextField.tsx b/apps/mobile/src/components/ui/form/TextField.tsx index 408e5b5dc9..f53005fe39 100644 --- a/apps/mobile/src/components/ui/form/TextField.tsx +++ b/apps/mobile/src/components/ui/form/TextField.tsx @@ -1,5 +1,5 @@ import { cn } from "@follow/utils/src/utils" -import type { FC } from "react" +import { forwardRef } from "react" import type { StyleProp, TextInputProps, ViewStyle } from "react-native" import { StyleSheet, TextInput, View } from "react-native" @@ -7,29 +7,27 @@ interface TextFieldProps { wrapperClassName?: string wrapperStyle?: StyleProp } -export const TextField: FC = ({ - className, - style, - wrapperClassName, - wrapperStyle, - ...rest -}) => { - return ( - - - - ) -} + +export const TextField = forwardRef( + ({ className, style, wrapperClassName, wrapperStyle, ...rest }, ref) => { + return ( + + + + ) + }, +) const styles = StyleSheet.create({ textField: { diff --git a/apps/mobile/src/icons/check_line.tsx b/apps/mobile/src/icons/check_line.tsx new file mode 100644 index 0000000000..49b194185b --- /dev/null +++ b/apps/mobile/src/icons/check_line.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface CheckLineIconProps { + width?: number + height?: number + color?: string +} + +export const CheckLineIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: CheckLineIconProps) => { + return ( + + + + + ) +} diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index 9e786b0608..2d539d44bc 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -38,6 +38,9 @@ export const SearchHeader = () => { } export const DiscoverHeader = () => { + return +} +const DiscoverHeaderImpl = () => { const frame = useSafeAreaFrame() const insets = useSafeAreaInsets() const headerHeight = getDefaultHeaderHeight(frame, false, insets.top) diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index ce072434e8..466f227438 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -1,21 +1,23 @@ import type { RSSHubParameter, RSSHubParameterObject, RSSHubRoute } from "@follow/models/src/rsshub" -import { parseFullPathParams, parseRegexpPathParams } from "@follow/utils" +import { parseFullPathParams, parseRegexpPathParams, withOpacity } 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 { memo, useEffect, useMemo } from "react" +import { Controller, useForm } from "react-hook-form" import { Linking, Text, TouchableOpacity, View } from "react-native" import { KeyboardAwareScrollView } from "react-native-keyboard-controller" import { z } from "zod" import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents" +import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvider" 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 { CheckLineIcon } from "@/src/icons/check_line" import { PreviewUrl } from "@/src/modules/rsshub/preview-url" +import { useColor } from "@/src/theme/colors" interface RsshubFormParams { route: RSSHubRoute @@ -99,72 +101,89 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { 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 && ( - { + formRegister.onChange({ + target: { + [keyItem.name]: value, + }, + }) + }} + /> + )} + + {!!parameters && ( + {parameters.description} + )} + + ) + })} + + + + + + + + + ) } @@ -191,3 +210,39 @@ const normalizeRSSHubParameters = (parameters: RSSHubParameter): RSSHubParameter ? { description: parameters, default: null } : parameters : null + +const ScreenOptions = memo(({ name, routeName }: { name: string; routeName: string }) => { + const form = useFormContext() + + return ( + ( + + + + ), + headerTitle: `${name} - ${routeName}`, + }} + /> + ) +}) + +const ModalHeaderSubmitButton = () => { + return +} +const ModalHeaderSubmitButtonImpl = () => { + const form = useFormContext() + const label = useColor("label") + const { isValid } = form.formState + const submit = form.handleSubmit((data) => { + void data + }) + + return ( + + + + ) +} diff --git a/apps/mobile/src/theme/colors.ts b/apps/mobile/src/theme/colors.ts index d17be298a8..4ef1c7bad7 100644 --- a/apps/mobile/src/theme/colors.ts +++ b/apps/mobile/src/theme/colors.ts @@ -1,3 +1,4 @@ +import { rgbStringToRgb } from "@follow/utils" import { useColorScheme, vars } from "nativewind" import { useMemo } from "react" @@ -132,11 +133,6 @@ export const darkVariants = { /// Utils -const toRgb = (hex: string) => { - const [r, g, b] = hex.split(" ").map((s) => Number.parseInt(s)) - return `rgb(${r} ${g} ${b})` -} - const mergedLightColors = { ...lightVariants, ...lightPalette, @@ -158,7 +154,7 @@ export const colorVariants = { export const useColor = (color: keyof typeof mergedLightColors) => { const { colorScheme } = useColorScheme() const colors = mergedColors[colorScheme || "light"] - return useMemo(() => toRgb(colors[color]), [color, colors]) + return useMemo(() => rgbStringToRgb(colors[color]), [color, colors]) } export const useColors = () => { diff --git a/apps/mobile/src/theme/utils.ts b/apps/mobile/src/theme/utils.ts index d69c76d7d0..5f45304337 100644 --- a/apps/mobile/src/theme/utils.ts +++ b/apps/mobile/src/theme/utils.ts @@ -1,3 +1,4 @@ +import { rgbStringToRgb } from "@follow/utils" import type { StyleProp, ViewStyle } from "react-native" import { Appearance, StyleSheet } from "react-native" @@ -16,10 +17,5 @@ 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})` + return rgbStringToRgb(colors.systemBackground) } diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index ea85e0be85..40ad3bdd5d 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -127,3 +127,37 @@ export function getDominantColor(imageObject: HTMLImageElement) { return `#${((1 << 24) + (i[0] << 16) + (i[1] << 8) + i[2]).toString(16).slice(1)}` } + +export const isHexColor = (color: string) => { + return /^#[0-9a-f]{6}$/i.test(color) +} + +export const isRGBColor = (color: string) => { + return /^rgb\(\d{1,3},\s*\d{1,3},\s*\d{1,3}\)$/.test(color) +} +export const isRGBAColor = (color: string) => { + return /^rgba\(\d{1,3},\s*\d{1,3},\s*\d{1,3},\s*0?\.\d+\)$/.test(color) +} + +export const withOpacity = (color: string, opacity: number) => { + switch (true) { + case isHexColor(color): { + return `${color}${opacity.toString(16).slice(1)}` + } + case isRGBColor(color): { + const [r, g, b] = color.match(/\d+/g)!.map(Number) + return `rgba(${r}, ${g}, ${b}, ${opacity})` + } + case isRGBAColor(color): { + const [r, g, b] = color.match(/\d+/g)!.map(Number) + return `rgba(${r}, ${g}, ${b}, ${opacity})` + } + default: { + return color + } + } +} +export const rgbStringToRgb = (hex: string) => { + const [r, g, b] = hex.split(" ").map((s) => Number.parseInt(s)) + return `rgb(${r}, ${g}, ${b})` +} From 6d10d670cdef764a09d9cd1f142ae079bfb63ec4 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 3 Jan 2025 00:53:38 +0800 Subject: [PATCH 03/70] refactor(rsshub-form): enhance ScreenOptions component and improve header title rendering - Introduced new props for the ScreenOptions component to pass route and routePrefix. - Updated header title rendering to utilize HeaderTitleExtra for better display of route information. - Removed unused PreviewUrl component to streamline the form layout. These changes improve the clarity and functionality of the RSSHub form screen. Signed-off-by: Innei --- .../components/common/HeaderTitleExtra.tsx | 61 +++++++++++++++++++ .../src/screens/(modal)/rsshub-form.tsx | 38 ++++++++---- 2 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/components/common/HeaderTitleExtra.tsx diff --git a/apps/mobile/src/components/common/HeaderTitleExtra.tsx b/apps/mobile/src/components/common/HeaderTitleExtra.tsx new file mode 100644 index 0000000000..6ab1ea7970 --- /dev/null +++ b/apps/mobile/src/components/common/HeaderTitleExtra.tsx @@ -0,0 +1,61 @@ +import { cn } from "@follow/utils" +import { useTheme } from "@react-navigation/native" +import type { StyleProp, TextProps, TextStyle } from "react-native" +import { Animated, Platform, StyleSheet, Text, View } from "react-native" + +type Props = Omit & { + tintColor?: string + children?: string + style?: Animated.WithAnimatedValue> + subText?: string + subTextStyle?: StyleProp + subTextClassName?: string +} + +export function HeaderTitleExtra({ + tintColor, + style, + subText, + subTextStyle, + subTextClassName, + ...rest +}: Props) { + const { colors, fonts } = useTheme() + + return ( + + + + {subText} + + + ) +} + +const styles = StyleSheet.create({ + title: Platform.select({ + ios: { + fontSize: 17, + }, + android: { + fontSize: 20, + }, + default: { + fontSize: 18, + }, + }), +}) diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index 466f227438..0e69c7ce28 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -9,6 +9,7 @@ import { Linking, Text, TouchableOpacity, View } from "react-native" import { KeyboardAwareScrollView } from "react-native-keyboard-controller" import { z } from "zod" +import { HeaderTitleExtra } from "@/src/components/common/HeaderTitleExtra" import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents" import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvider" import { FormLabel } from "@/src/components/ui/form/Label" @@ -16,7 +17,6 @@ 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 { CheckLineIcon } from "@/src/icons/check_line" -import { PreviewUrl } from "@/src/modules/rsshub/preview-url" import { useColor } from "@/src/theme/colors" interface RsshubFormParams { @@ -105,18 +105,15 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { return ( - + - - {/* Form */} - {keys.map((keyItem) => { const parameters = normalizeRSSHubParameters(route.parameters[keyItem.name]) @@ -211,7 +208,13 @@ const normalizeRSSHubParameters = (parameters: RSSHubParameter): RSSHubParameter : parameters : null -const ScreenOptions = memo(({ name, routeName }: { name: string; routeName: string }) => { +type ScreenOptionsProps = { + name: string + routeName: string + route: string + routePrefix: string +} +const ScreenOptions = memo(({ name, routeName, route, routePrefix }: ScreenOptionsProps) => { const form = useFormContext() return ( @@ -223,12 +226,23 @@ const ScreenOptions = memo(({ name, routeName }: { name: string; routeName: stri ), - headerTitle: `${name} - ${routeName}`, + + headerTitle: () => ( + + ), }} /> ) }) +const Title = ({ name, routeName, route, routePrefix }: ScreenOptionsProps) => { + return ( + <HeaderTitleExtra subText={`rsshub://${routePrefix}${route}`}> + {`${name} - ${routeName}`} + </HeaderTitleExtra> + ) +} + const ModalHeaderSubmitButton = () => { return <ModalHeaderSubmitButtonImpl /> } From c7f64e8327c8f6202f64d94d806aff909f85fd0b Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Fri, 3 Jan 2025 01:03:08 +0800 Subject: [PATCH 04/70] chore: debug menu in prod Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/src/screens/(modal)/_layout.tsx | 4 ++++ apps/mobile/src/screens/(modal)/loading.tsx | 12 ++++++++++++ .../src/screens/(stack)/(tabs)/_layout.tsx | 17 ++++++++++++++++- apps/mobile/src/screens/_layout.tsx | 3 +-- 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/screens/(modal)/loading.tsx diff --git a/apps/mobile/src/screens/(modal)/_layout.tsx b/apps/mobile/src/screens/(modal)/_layout.tsx index f1360f5a33..488c53af49 100644 --- a/apps/mobile/src/screens/(modal)/_layout.tsx +++ b/apps/mobile/src/screens/(modal)/_layout.tsx @@ -15,6 +15,10 @@ export default function ModalLayout() { title: "RSSHub Form", }} /> + <Stack.Screen + name="loading" + options={{ presentation: "transparentModal", headerShown: false, animation: "fade" }} + /> </Stack> ) } diff --git a/apps/mobile/src/screens/(modal)/loading.tsx b/apps/mobile/src/screens/(modal)/loading.tsx new file mode 100644 index 0000000000..cd4eb5effc --- /dev/null +++ b/apps/mobile/src/screens/(modal)/loading.tsx @@ -0,0 +1,12 @@ +import { router } from "expo-router" +import { Text, TouchableOpacity, View } from "react-native" + +export default function Loading() { + return ( + <View className="flex-1 items-center justify-center bg-transparent"> + <TouchableOpacity onPress={() => router.back()}> + <Text className="text-text">Loading...</Text> + </TouchableOpacity> + </View> + ) +} diff --git a/apps/mobile/src/screens/(stack)/(tabs)/_layout.tsx b/apps/mobile/src/screens/(stack)/(tabs)/_layout.tsx index c263422ac5..021e956ea7 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/_layout.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/_layout.tsx @@ -1,6 +1,6 @@ import { FeedViewType } from "@follow/constants" import { PlatformPressable } from "@react-navigation/elements/src/PlatformPressable" -import { Tabs } from "expo-router" +import { router, Tabs } from "expo-router" import { View } from "react-native" import { Gesture, GestureDetector } from "react-native-gesture-handler" import { runOnJS } from "react-native-reanimated" @@ -19,6 +19,12 @@ const doubleTap = Gesture.Tap() runOnJS(setCurrentView)(FeedViewType.Articles) }) +const fifthTap = Gesture.Tap() + .numberOfTaps(5) + .onStart(() => { + runOnJS(router.push)("/debug") + }) + export default function TabLayout() { return ( <Tabs @@ -63,6 +69,15 @@ export default function TabLayout() { options={{ title: "Settings", headerShown: false, + tabBarButton(props) { + return ( + <GestureDetector gesture={fifthTap}> + <View className="flex-1"> + <PlatformPressable {...props} /> + </View> + </GestureDetector> + ) + }, tabBarIcon: ({ color, focused }) => { const Icon = !focused ? Settings7CuteReIcon : Setting7CuteFi return <Icon color={color} width={24} height={24} /> diff --git a/apps/mobile/src/screens/_layout.tsx b/apps/mobile/src/screens/_layout.tsx index 7ff4611bad..fb24dff408 100644 --- a/apps/mobile/src/screens/_layout.tsx +++ b/apps/mobile/src/screens/_layout.tsx @@ -27,8 +27,7 @@ export default function RootLayout() { <Stack.Screen name="(modal)" options={{ headerShown: false, presentation: "modal" }} /> </Stack> - {/* {__DEV__ && <DebugButton />} */} - <DebugButton /> + {__DEV__ && <DebugButton />} </RootProviders> ) } From 92ec05e1def77ea98432a3a7675ef4d9382b72b6 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Fri, 3 Jan 2025 01:10:28 +0800 Subject: [PATCH 05/70] refactor(modal): remove loading screen and enhance modal functionality - Removed the loading screen component to streamline the modal navigation. - Updated modal-related imports in the debug and add screens to include Modal. - Adjusted screen options in the modal layout for improved presentation. These changes simplify the modal structure and improve the overall user experience. Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/src/screens/(modal)/_layout.tsx | 4 ---- apps/mobile/src/screens/(modal)/add.tsx | 1 - apps/mobile/src/screens/(modal)/loading.tsx | 12 ------------ 3 files changed, 17 deletions(-) delete mode 100644 apps/mobile/src/screens/(modal)/loading.tsx diff --git a/apps/mobile/src/screens/(modal)/_layout.tsx b/apps/mobile/src/screens/(modal)/_layout.tsx index 488c53af49..f1360f5a33 100644 --- a/apps/mobile/src/screens/(modal)/_layout.tsx +++ b/apps/mobile/src/screens/(modal)/_layout.tsx @@ -15,10 +15,6 @@ export default function ModalLayout() { title: "RSSHub Form", }} /> - <Stack.Screen - name="loading" - options={{ presentation: "transparentModal", headerShown: false, animation: "fade" }} - /> </Stack> ) } diff --git a/apps/mobile/src/screens/(modal)/add.tsx b/apps/mobile/src/screens/(modal)/add.tsx index accdc30b48..28ed250575 100644 --- a/apps/mobile/src/screens/(modal)/add.tsx +++ b/apps/mobile/src/screens/(modal)/add.tsx @@ -17,7 +17,6 @@ export default function Add() { options={{ gestureEnabled: !url, headerLeft: ModalHeaderCloseButton, - presentation: "modal", headerRight: () => ( <TouchableOpacity onPress={() => { diff --git a/apps/mobile/src/screens/(modal)/loading.tsx b/apps/mobile/src/screens/(modal)/loading.tsx deleted file mode 100644 index cd4eb5effc..0000000000 --- a/apps/mobile/src/screens/(modal)/loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { router } from "expo-router" -import { Text, TouchableOpacity, View } from "react-native" - -export default function Loading() { - return ( - <View className="flex-1 items-center justify-center bg-transparent"> - <TouchableOpacity onPress={() => router.back()}> - <Text className="text-text">Loading...</Text> - </TouchableOpacity> - </View> - ) -} From 58e796e3df1844ccb2c4cadb6578051d1114c628 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:11:18 +0800 Subject: [PATCH 06/70] fix: authentication style on mobile --- apps/renderer/src/modules/profile/account-management.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/renderer/src/modules/profile/account-management.tsx b/apps/renderer/src/modules/profile/account-management.tsx index 15453f8667..e4f82cc5ad 100644 --- a/apps/renderer/src/modules/profile/account-management.tsx +++ b/apps/renderer/src/modules/profile/account-management.tsx @@ -65,7 +65,7 @@ export function AccountManagement() { return ( <div className="space-y-2"> <p className="text-sm font-semibold">{t("profile.link_social.authentication")}</p> - <div className="space-x-2"> + <div className="flex flex-wrap items-center gap-2"> {Object.keys(providers || {}) .filter((provider) => provider !== "credential") .map((provider) => ( From 15feb5a35ac6db4ff5c12ff4d1f46c25632d7693 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Fri, 3 Jan 2025 15:26:35 +0800 Subject: [PATCH 07/70] fix(settings): align submit button in ExportFeedsForm for better layout Signed-off-by: Innei <tukon479@gmail.com> --- apps/renderer/src/modules/settings/tabs/data-control.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/renderer/src/modules/settings/tabs/data-control.tsx b/apps/renderer/src/modules/settings/tabs/data-control.tsx index 7a8462c0c0..bdfde552d1 100644 --- a/apps/renderer/src/modules/settings/tabs/data-control.tsx +++ b/apps/renderer/src/modules/settings/tabs/data-control.tsx @@ -216,7 +216,9 @@ const ExportFeedsForm = () => { </FormItem> )} /> - <Button type="submit">{t("ok", { ns: "common" })}</Button> + <div className="flex justify-end"> + <Button type="submit">{t("ok", { ns: "common" })}</Button> + </div> </form> </Form> ) From 834dff8d91406eeb56b035e2da9f9d6e3a853850 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Fri, 3 Jan 2025 17:10:22 +0800 Subject: [PATCH 08/70] feat: integrate LoadingContainer - Added LoadingContainer component to RootLayout for improved loading state management. - Refactored DebugPanel to include useEffect and removed unused code for better clarity and performance. These changes enhance user experience by providing a loading indicator and streamlining the debug interface. Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/src/atoms/app.ts | 13 +++ .../components/common/LoadingContainer.tsx | 100 ++++++++++++++++++ apps/mobile/src/hooks/useLoadingCallback.tsx | 21 ++++ apps/mobile/src/screens/(headless)/debug.tsx | 18 +--- apps/mobile/src/screens/_layout.tsx | 2 + 5 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/atoms/app.ts create mode 100644 apps/mobile/src/components/common/LoadingContainer.tsx create mode 100644 apps/mobile/src/hooks/useLoadingCallback.tsx diff --git a/apps/mobile/src/atoms/app.ts b/apps/mobile/src/atoms/app.ts new file mode 100644 index 0000000000..74da5bd6ad --- /dev/null +++ b/apps/mobile/src/atoms/app.ts @@ -0,0 +1,13 @@ +import { atom } from "jotai" + +export const loadingVisibleAtom = atom(false) + +export const loadingAtom = atom<{ + finish: null | (() => any) + cancel: null | (() => any) + thenable: null | Promise<any> +}>({ + finish: null, + cancel: null, + thenable: null, +}) diff --git a/apps/mobile/src/components/common/LoadingContainer.tsx b/apps/mobile/src/components/common/LoadingContainer.tsx new file mode 100644 index 0000000000..ea5f866dd1 --- /dev/null +++ b/apps/mobile/src/components/common/LoadingContainer.tsx @@ -0,0 +1,100 @@ +import { useAtom } from "jotai" +import { useCallback, useEffect, useRef, useState } from "react" +import { Modal, Text, TouchableOpacity, View } from "react-native" +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated" + +import { loadingAtom, loadingVisibleAtom } from "@/src/atoms/app" +import { Loading3CuteReIcon } from "@/src/icons/loading_3_cute_re" + +export const LoadingContainer = () => { + const rotate = useSharedValue(0) + + const [visible, setVisible] = useAtom(loadingVisibleAtom) + const [showCancelButton, setShowCancelButton] = useState(false) + + const [loadingCaller, setLoadingCaller] = useAtom(loadingAtom) + + const resetLoadingCaller = useCallback(() => { + setLoadingCaller({ + finish: null, + cancel: null, + thenable: null, + }) + }, [setLoadingCaller]) + + useEffect(() => { + rotate.value = withRepeat( + withTiming(360, { duration: 1000, easing: Easing.linear }), + Infinity, + false, + ) + return () => { + rotate.value = 0 + } + }, [rotate]) + + useEffect(() => { + if (loadingCaller.thenable) { + loadingCaller.thenable.finally(() => { + setVisible(false) + setShowCancelButton(false) + + resetLoadingCaller() + + loadingCaller.finish?.() + }) + } + }, [loadingCaller.thenable]) + + const cancelTimerRef = useRef<NodeJS.Timeout | null>(null) + useEffect(() => { + cancelTimerRef.current = setTimeout(() => { + setShowCancelButton(true) + }, 3000) + return () => { + if (cancelTimerRef.current) { + clearTimeout(cancelTimerRef.current) + } + } + }, []) + + const rotateStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotate.value}deg` }], + })) + + const cancel = () => { + setVisible(false) + setShowCancelButton(false) + + if (loadingCaller.cancel) { + loadingCaller.cancel() + } + resetLoadingCaller() + } + + return ( + <Modal visible={visible} animationType="fade" transparent> + <View className="flex-1 items-center justify-center bg-black/30"> + <View className="border-system-fill/40 rounded-2xl border bg-black/50 p-12 drop-shadow dark:bg-white/5"> + <Animated.View style={rotateStyle}> + <Loading3CuteReIcon height={36} width={36} color="#fff" /> + </Animated.View> + </View> + + {showCancelButton && ( + <View className="absolute inset-x-0 bottom-24 flex-row justify-center"> + <TouchableOpacity onPress={cancel}> + <Text className="text-center text-lg text-accent">Cancel</Text> + </TouchableOpacity> + </View> + )} + </View> + </Modal> + ) +} diff --git a/apps/mobile/src/hooks/useLoadingCallback.tsx b/apps/mobile/src/hooks/useLoadingCallback.tsx new file mode 100644 index 0000000000..f4980622e0 --- /dev/null +++ b/apps/mobile/src/hooks/useLoadingCallback.tsx @@ -0,0 +1,21 @@ +import { useSetAtom } from "jotai" +import { useCallback } from "react" + +import { loadingAtom, loadingVisibleAtom } from "../atoms/app" + +export const useLoadingCallback = () => { + const setLoadingCaller = useSetAtom(loadingAtom) + const setVisible = useSetAtom(loadingVisibleAtom) + + return useCallback( + (thenable: Promise<any>, options: { finish: () => any; cancel: () => any }) => { + setLoadingCaller({ + thenable, + finish: options.finish, + cancel: options.cancel, + }) + setVisible(true) + }, + [setLoadingCaller, setVisible], + ) +} diff --git a/apps/mobile/src/screens/(headless)/debug.tsx b/apps/mobile/src/screens/(headless)/debug.tsx index 41da66a11b..76195ffe84 100644 --- a/apps/mobile/src/screens/(headless)/debug.tsx +++ b/apps/mobile/src/screens/(headless)/debug.tsx @@ -19,27 +19,11 @@ import { clearSessionToken, getSessionToken, setSessionToken } from "@/src/lib/c export default function DebugPanel() { const insets = useSafeAreaInsets() - // const isExpanded = useSharedValue(false) + return ( <ScrollView className="flex-1 bg-black" style={{ paddingTop: insets.top }}> <Text className="mt-4 px-8 text-2xl font-medium text-white">Users</Text> - {/* <Button - title="Toggle" - onPress={() => { - isExpanded.value = !isExpanded.value - }} - /> - <AccordionItem isExpanded={isExpanded} viewKey="users"> - {Array.from({ length: 100 }).map((_, index) => { - return ( - <Text key={index} className="text-white"> - {index} - </Text> - ) - })} - </AccordionItem> */} - <View style={styles.container}> <View style={styles.itemContainer}> <UserSessionSetting /> diff --git a/apps/mobile/src/screens/_layout.tsx b/apps/mobile/src/screens/_layout.tsx index fb24dff408..04d773adc9 100644 --- a/apps/mobile/src/screens/_layout.tsx +++ b/apps/mobile/src/screens/_layout.tsx @@ -3,6 +3,7 @@ import "../global.css" import { Stack } from "expo-router" import { useColorScheme } from "nativewind" +import { LoadingContainer } from "../components/common/LoadingContainer" import { DebugButton } from "../modules/debug" import { RootProviders } from "../providers" import { usePrefetchSessionUser } from "../store/user/hooks" @@ -16,6 +17,7 @@ export default function RootLayout() { return ( <RootProviders> <Session /> + <LoadingContainer /> <Stack screenOptions={{ contentStyle: { backgroundColor: systemBackgroundColor }, From 1e8f80ef158ce1e59af7ae106b871a60742038f3 Mon Sep 17 00:00:00 2001 From: Xat <i@xat.sh> Date: Fri, 3 Jan 2025 17:13:31 +0800 Subject: [PATCH 09/70] fix: ignore `node_modules` for tailwindcss to avoid warning (#2425) --- tailwind.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tailwind.config.ts b/tailwind.config.ts index 2b3c778b14..bbd3d6f38f 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -14,6 +14,7 @@ export default resolveConfig({ "./apps/renderer/index.html", "./apps/web/index.html", "./packages/**/*.{ts,tsx}", + "!./packages/**/node_modules", ], future: { hoverOnlyWhenSupported: isWebBuild, From ebc6c4fdb6773f2b7d94b81b839a3a338787c4b3 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Fri, 3 Jan 2025 17:14:08 +0800 Subject: [PATCH 10/70] feat: improve email verification display (#2424) --- .../src/modules/profile/email-management.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/renderer/src/modules/profile/email-management.tsx b/apps/renderer/src/modules/profile/email-management.tsx index d265014c23..582ff93f92 100644 --- a/apps/renderer/src/modules/profile/email-management.tsx +++ b/apps/renderer/src/modules/profile/email-management.tsx @@ -46,8 +46,16 @@ export function EmailManagement() { const { present } = useModalStack() return ( <> - <div className="mb-2"> + <div className="mb-2 flex items-center space-x-1"> <Label className="text-sm">{t("profile.email.label")}</Label> + <span + className={cn( + "rounded-full border px-1 text-[10px] font-semibold", + user?.emailVerified ? "border-green-500 text-green-500" : "border-red-500 text-red-500", + )} + > + {user?.emailVerified ? t("profile.email.verified") : t("profile.email.unverified")} + </span> </div> <p className="group flex gap-2 text-sm text-muted-foreground"> {user?.email} @@ -68,9 +76,6 @@ export function EmailManagement() { className="size-5 p-1 opacity-0 duration-300 group-hover:opacity-100" /> )} - <span className={cn(user?.emailVerified ? "text-green-500" : "text-red-500")}> - {user?.emailVerified ? t("profile.email.verified") : t("profile.email.unverified")} - </span> </p> {!user?.emailVerified && ( <Button From 9d6508b211529b6b2f6085ef0332745dd1db22e8 Mon Sep 17 00:00:00 2001 From: Denisskas <110352433+Denisskas@users.noreply.github.com> Date: Fri, 3 Jan 2025 13:14:32 +0400 Subject: [PATCH 11/70] feat:(locales) update ru local files and create ru file (#2418) * Update ru.json * add in native ru.json * Update in common ru.json * Update in errors ru.json * Update in external ru.json * Update in settings ru.json * Update in shortcuts ru.json * Fix in app ru.json * Fix in errors ru.json * Fix in settings ru.json * Update , external ru.json * Update , settings ru.json * chore: auto-fix linting and formatting issues * Update ru.json * chore: auto-fix linting and formatting issues --------- Co-authored-by: Denisskas <Denisskas@users.noreply.github.com> --- locales/app/ru.json | 251 +++++++++++++++++++++++++++++++++++++- locales/common/ru.json | 16 +++ locales/errors/ru.json | 36 +++++- locales/external/ru.json | 47 ++++++- locales/native/ru.json | 74 +++++++++++ locales/settings/ru.json | 204 ++++++++++++++++++++++++++++++- locales/shortcuts/ru.json | 10 ++ 7 files changed, 629 insertions(+), 9 deletions(-) create mode 100644 locales/native/ru.json diff --git a/locales/app/ru.json b/locales/app/ru.json index 8d386bc30e..e196ab6332 100644 --- a/locales/app/ru.json +++ b/locales/app/ru.json @@ -1,38 +1,145 @@ { + "achievement.all_done": "Все сделано!", + "achievement.alpha_tester": "Альфа-тестер", + "achievement.alpha_tester_description": "Вы альфа-тестер Follow", + "achievement.description": "Будьте хардкорным игроком и минтить в NFT.", + "achievement.feed_booster": "Усилитель канала", + "achievement.feed_booster_description": "Вы усилили канал в Follow", + "achievement.first_claim_feed": "Владелец канала", + "achievement.first_claim_feed_description": "Вы владеете своим каналом в Follow", + "achievement.first_create_list": "Создатель списка", + "achievement.first_create_list_description": "Вы создали свой первый список в Follow", + "achievement.follow_special_feed": "Подписчик специального канала", + "achievement.follow_special_feed_description": "Вы подписались на специальный канал в Follow", + "achievement.list_subscribe_100": "100 подписчиков списка", + "achievement.list_subscribe_100_description": "100 подписчиков подписались на созданный вами список", + "achievement.list_subscribe_50": "50 подписчиков списка", + "achievement.list_subscribe_500": "500 подписчиков списка", + "achievement.list_subscribe_500_description": "500 подписчиков подписались на созданный вами список", + "achievement.list_subscribe_50_description": "50 подписчиков подписались на созданный вами список", + "achievement.nft_coming_soon": "Вы не можете минтить NFT в данный момент. Как только мы будем готовы, они будут автоматически зачислены на ваш аккаунт.", + "achievement.product_hunt_vote": "Голосовавший на Product Hunt", + "achievement.product_hunt_vote_description": "Вы поддержали Follow на Product Hunt", + "activation.activate": "Активировать", + "activation.description": "Во время публичного тестирования вам нужен код приглашения для использования этой функции.", + "activation.title": "Код приглашения", + "ai_daily.header": "Ежедневный отчет AI", + "ai_daily.no_found": "Новости AI не найдены за этот период.", "ai_daily.title": "Главные новости - {{title}}", "ai_daily.tooltip.content": "Вот новости, выбранные ИИ из вашей ленты (<From /> - <To />), которые могут быть для вас важны.", "ai_daily.tooltip.update_schedule": "Обновление ежедневно в 8:00 и 20:00.", "app.copy_logo_svg": "Скопировать логотип SVG", "app.toggle_sidebar": "Переключить боковую панель", + "beta_access": "Бета доступ", + "boost.boost_feed": "Усилить канала", + "boost.boost_feed_description": "Усилите канал, чтобы разблокировать дополнительные преимущества, и те, кто подписался, будут благодарны за это!", + "boost.boost_success": "🎉 Усиление успешно!", + "boost.boost_success_thanks": "Спасибо за вашу поддержку!", + "boost.expired_description": "Вы не можете добавить больше баллов для усиления прямо сейчас, но продолжайте усиливать. Ваше текущее усиление истечет {{expiredDate, datetime}}.", + "boost.feed_being_boosted": "Канал в процессе усиления", + "boost.remaining_boosts_to_level_up": "{{remainingBoostsToLevelUp}} усилений для разблокировки следующего уровня преимуществ!", "discover.any_url_or_keyword": "Любой URL или ключевое слово", + "discover.category.all": "Все", + "discover.category.anime": "Аниме", + "discover.category.bbs": "BBS", + "discover.category.blog": "Блог", + "discover.category.design": "Дизайн", + "discover.category.finance": "Финансы", + "discover.category.forecast": "Прогнозы", + "discover.category.game": "Игры", + "discover.category.government": "Государственные", + "discover.category.journal": "Научный журнал", + "discover.category.live": "Прямой эфир", + "discover.category.multimedia": "Мультимедиа", + "discover.category.new-media": "Новые медиа", + "discover.category.picture": "Изображения", + "discover.category.program-update": "Обновления программы", + "discover.category.programming": "Программирование", + "discover.category.reading": "Чтение", + "discover.category.shopping": "Шоппинг", + "discover.category.social-media": "Социальные сети", + "discover.category.study": "Учёба", + "discover.category.traditional-media": "Новости", + "discover.category.travel": "Путешествия", + "discover.category.university": "Университет", "discover.default_option": " (по умолчанию)", "discover.feed_description": "Описание этого канала следующее, и вы можете заполнить форму параметров с соответствующей информацией.", "discover.feed_maintainers": "Этот канал предоставлен RSSHub, с благодарностью <maintainers />", "discover.import.click_to_upload": "Нажмите, чтобы загрузить файл OPML", + "discover.import.conflictItems": "Конфликтующие элементы", + "discover.import.new_import_opml": "Если вы уже использовали RSS, вы можете экспортировать свою конфигурацию данных в файл OPML и импортировать его здесь", + "discover.import.noItems": "Нет элементов", + "discover.import.opml": "Файл OPML", + "discover.import.parsedErrorItems": "Элементы с ошибкой парсинга", + "discover.import.result": "<SuccessfulNum /> лент успешно импортированы, <ConflictNum /> уже подписаны, и <ErrorNum /> не удалось импортировать.", + "discover.import.successfulItems": "Успешные элементы", + "discover.inbox.actions": "Действия", + "discover.inbox.description": "Вы можете получать информацию по электронной почте и вебхукам через почтовый ящик.", + "discover.inbox.email": "Электронная почта", + "discover.inbox.handle": "Обработчик", + "discover.inbox.secret": "Секрет", + "discover.inbox.title": "Заголовок", + "discover.inbox.webhooks_docs": "Документация вебхуков.", + "discover.inbox_create": "Создать новый почтовый ящик", + "discover.inbox_create_description": "У вас ещё нет почтового ящика. Создайте почтовый ящик, чтобы получать информацию.", + "discover.inbox_create_error": "Не удалось создать почтовый ящик.", + "discover.inbox_create_success": "Почтовый ящик успешно создан.", + "discover.inbox_destroy": "Уничтожить", + "discover.inbox_destroy_confirm": "Подтвердите уничтожение почтового ящика?", + "discover.inbox_destroy_error": "Не удалось уничтожить почтовый ящик.", + "discover.inbox_destroy_success": "Почтовый ящик успешно уничтожен.", + "discover.inbox_destroy_warning": "Внимание: после уничтожения почтовый ящик будет недоступен, а все содержимое будет навсегда удалено и не подлежит восстановлению.", + "discover.inbox_title": "Заголовок", + "discover.inbox_update": "Обновить", + "discover.inbox_update_error": "Не удалось изменить почтовый ящик.", + "discover.inbox_update_success": "Почтовый ящик обновлён успешно.", "discover.popular": "Популярное", "discover.preview": "Предпросмотр", "discover.rss_hub_route": "Маршрут RSSHub", "discover.rss_url": "RSS URL", "discover.select_placeholder": "Выбрать", + "discover.target.feeds": "Каналы", + "discover.target.label": "Поиск по", + "discover.target.lists": "Списки", + "entry_actions.copied_notify": "{{which}} скопировано в буфер обмена.", "entry_actions.copy_link": "Копировать ссылку", + "entry_actions.copy_title": "Копировать заголовок", + "entry_actions.delete": "Удалить", + "entry_actions.deleted": "Удалено.", + "entry_actions.failed_to_delete": "Не удалось удалить.", "entry_actions.failed_to_save_to_eagle": "Не удалось сохранить в Eagle.", "entry_actions.failed_to_save_to_instapaper": "Не удалось сохранить в Instapaper.", + "entry_actions.failed_to_save_to_obsidian": "Не удалось сохранить в Obsidian", + "entry_actions.failed_to_save_to_outline": "Не удалось сохранить в Outline.", + "entry_actions.failed_to_save_to_readeck": "Не удалось сохранить в Readeck.", "entry_actions.failed_to_save_to_readwise": "Не удалось сохранить в Readwise.", "entry_actions.mark_as_read": "Отметить как прочитанное", "entry_actions.mark_as_unread": "Отметить как непрочитанное", - "entry_actions.open_in_browser": "Открыть в браузере", + "entry_actions.open_in_browser": "Открыть в {{which}}", + "entry_actions.recent_reader": "Недавний читатель:", "entry_actions.save_media_to_eagle": "Сохранить медиа в Eagle", "entry_actions.save_to_instapaper": "Сохранить в Instapaper", + "entry_actions.save_to_obsidian": "Сохранить в Obsidian", + "entry_actions.save_to_outline": "Сохранить в Outline", + "entry_actions.save_to_readeck": "Сохранить в Readeck", "entry_actions.save_to_readwise": "Сохранить в Readwise", "entry_actions.saved_to_eagle": "Сохранено в Eagle.", "entry_actions.saved_to_instapaper": "Сохранено в Instapaper.", + "entry_actions.saved_to_obsidian": "Сохранено в Obsidian", + "entry_actions.saved_to_outline": "Сохранено в Outline.", + "entry_actions.saved_to_readeck": "Сохранено в Readeck.", "entry_actions.saved_to_readwise": "Сохранено в Readwise.", "entry_actions.share": "Поделиться", - "entry_actions.star": "Избранное", + "entry_actions.star": "Добавить в избранное", "entry_actions.starred": "Добавлено в избранное.", "entry_actions.tip": "Совет", + "entry_actions.toggle_ai_summary": "Переключить AI сводку", + "entry_actions.toggle_ai_translation": "Переключить AI перевод", "entry_actions.unstar": "Убрать из избранного", "entry_actions.unstarred": "Убрано из избранного.", + "entry_actions.view_source_content": "Посмотреть исходное содержимое", + "entry_column.filtered_content_tip": "Вы скрыли отфильтрованное содержимое.", + "entry_column.filtered_content_tip_2": "Кроме показанных выше элементов, также есть отфильтрованное содержимое.", "entry_column.refreshing": "Обновление новых записей...", "entry_content.ai_summary": "Сводка ИИ", "entry_content.fetching_content": "Получение оригинального контента и обработка...", @@ -42,18 +149,32 @@ "entry_content.readability_notice": "Этот контент предоставлен сервисом Readability. Если вы заметили опечатки, пожалуйста, перейдите на исходный сайт для просмотра оригинального контента.", "entry_content.render_error": "Ошибка отображения:", "entry_content.report_issue": "Сообщить о проблеме", + "entry_content.support_amount": "{{amount}} человек поддержали создателя этой ленты.", + "entry_content.support_creator": "Поддержать создателя", "entry_content.web_app_notice": "Возможно, веб-приложение не поддерживает этот тип контента. Но вы можете скачать настольное приложение.", "entry_list.zero_unread": "Нет непрочитанных", "entry_list_header.daily_report": "Ежедневный отчет", + "entry_list_header.grid": "Сетка", "entry_list_header.hide_no_image_items": "Скрыть записи без изображений", "entry_list_header.items": "записей", + "entry_list_header.masonry": "Мasonry", + "entry_list_header.masonry_column": "Столбец Masonry", "entry_list_header.new_entries_available": "Доступны новые записи", + "entry_list_header.preview_mode": "Режим предпросмотра", "entry_list_header.refetch": "Повторить запрос", "entry_list_header.refresh": "Обновить", "entry_list_header.show_all": "Показать все", "entry_list_header.show_all_items": "Показать все записи", "entry_list_header.show_unread_only": "Показать только непрочитанные", + "entry_list_header.switch_to_normalmode": "Переключиться в нормальный режим", + "entry_list_header.switch_to_widemode": "Переключиться в широкий режим", "entry_list_header.unread": "непрочитано", + "feed.follower_one": "подписчик", + "feed.follower_other": "подписчики", + "feed.followsAndFeeds": "{{subscriptionCount}} {{subscriptionNoun}} и {{feedsCount}} {{feedsNoun}} на {{appName}}", + "feed.followsAndReads": "{{subscriptionCount}} {{subscriptionNoun}} с {{readCount}} недавними {{readNoun}} на {{appName}}", + "feed.read_one": "прочитано", + "feed.read_other": "прочитано", "feed_claim_modal.choose_verification_method": "Есть три способа на выбор, вы можете выбрать один из них для проверки.", "feed_claim_modal.claim_button": "Заявить", "feed_claim_modal.content_instructions": "Скопируйте содержимое ниже и опубликуйте его в своем последнем RSS-канале.", @@ -68,14 +189,20 @@ "feed_claim_modal.tab_content": "Содержимое", "feed_claim_modal.tab_description": "Описание", "feed_claim_modal.tab_rss": "Тег RSS", + "feed_claim_modal.title": "Подтверждение канала", "feed_claim_modal.verify_ownership": "Чтобы заявить этот канал как свой, вам необходимо подтвердить право собственности.", + "feed_form.add_feed": "Добавить канал", "feed_form.add_follow": "Добавить подписку", "feed_form.category": "Категория", "feed_form.category_description": "По умолчанию ваши подписки будут сгруппированы по сайтам.", "feed_form.error_fetching_feed": "Ошибка при получении канала.", + "feed_form.fee": "Плата за подписку", + "feed_form.fee_description": "Для подписки на этот список необходимо заплатить создателю списка.", "feed_form.feed_not_found": "Канал не найден.", "feed_form.feedback": "Обратная связь", + "feed_form.fill_default": "Заполнить", "feed_form.follow": "Подписаться", + "feed_form.follow_with_fee": "Подписаться за {{fee}} Power", "feed_form.followed": "🎉 Подписка оформлена.", "feed_form.private_follow": "Частная подписка", "feed_form.private_follow_description": "Будет ли эта подписка видимой для других на вашей странице профиля.", @@ -91,6 +218,7 @@ "feed_item.claimed_by_unknown": "его владельцем.", "feed_item.claimed_by_you": "Заявлено вами", "feed_item.claimed_feed": "Заявленный канал", + "feed_item.claimed_list": "Заявленный список", "feed_item.error_since": "Ошибка с", "feed_item.not_publicly_visible": "Не виден на вашей публичной странице профиля", "feed_view_type.articles": "Статьи", @@ -99,6 +227,14 @@ "feed_view_type.pictures": "Картинки", "feed_view_type.social_media": "Социальные сети", "feed_view_type.videos": "Видео", + "login.confirm_password.label": "Подтвердите пароль", + "login.continueWith": "Продолжить с {{provider}}", + "login.email": "Электронная почта", + "login.forget_password.note": "Забыли пароль?", + "login.password": "Пароль", + "login.signUp": "Зарегистрироваться с электронной почтой", + "login.submit": "Отправить", + "login.with_email.title": "Войти с электронной почтой", "mark_all_read_button.auto_confirm_info": "Будет автоматически подтверждено через {{countdown}} секунды.", "mark_all_read_button.confirm": "Подтвердить", "mark_all_read_button.confirm_mark_all": "Отметить <which /> как прочитанное?", @@ -106,6 +242,43 @@ "mark_all_read_button.mark_all_as_read": "Отметить все как прочитанное", "mark_all_read_button.mark_as_read": "Отметить <which /> как прочитанное", "mark_all_read_button.undo": "Отменить", + "new_user_guide.intro.description": "Это руководство поможет вам начать работу с приложением.", + "new_user_guide.intro.title": "Добро пожаловать в Follow!", + "new_user_guide.outro.description": "Вы завершили руководство. Приятного путешествия!", + "new_user_guide.outro.title": "Все готово!", + "new_user_guide.step.activation.description": "Не беспокойтесь, вы можете продолжать использовать Follow без кода приглашения.", + "new_user_guide.step.activation.title": "Активируйте вашу учетную запись", + "new_user_guide.step.automation.description": "- Follow использует передовой ИИ для помощи в операциях.\n- Правила действий позволяют автоматизировать различные операции на источниках, соответствующих конкретным условиям.\n- Интеграции позволяют сохранять записи в другие сервисы.", + "new_user_guide.step.behavior.title": "Поведение", + "new_user_guide.step.behavior.unread_question.content": "Выберите, как вы хотите отмечать как прочитанное.", + "new_user_guide.step.behavior.unread_question.description": "Не переживайте, вы можете изменить это позже в настройках.", + "new_user_guide.step.behavior.unread_question.option1": "Радикально: автоматически отмечать как прочитанное при отображении.", + "new_user_guide.step.behavior.unread_question.option2": "Сбалансировано: автоматически отмечать как прочитанное при наведении или прокрутке вне поля зрения.", + "new_user_guide.step.behavior.unread_question.option3": "Консервативно: отмечать как прочитанное только при клике.", + "new_user_guide.step.features.actions.description": "Правила действий позволяют выполнять разные действия для разных лент.\n- Использование ИИ для резюмирования или перевода.\n- Настройка способа чтения записей.\n- Включение уведомлений для новых записей или их отключение.\n- Переписывание или блокировка определенных записей.\n- Отправка новых записей на адрес webhook.", + "new_user_guide.step.features.integration.description": "Интеграции позволяют сохранять записи в другие сервисы. В настоящее время поддерживаемые сервисы:\n- Eagle\n- Readwise\n- Instapaper\n- Obsidian\n- Outline\n- Readeck", + "new_user_guide.step.migrate.profile": "Настройте свой профиль", + "new_user_guide.step.migrate.title": "Перенос из файла OPML", + "new_user_guide.step.migrate.wallet": "Проверьте ваш кошелек", + "new_user_guide.step.power.description": "Follow использует технологию блокчейн в качестве механизма вознаграждения для активных пользователей и выдающихся создателей. Пользователи могут получить дополнительные услуги и преимущества, держа и используя токен Power. Создатели могут получать больше наград за предоставление качественного контента и услуг.", + "new_user_guide.step.rsshub.info": "Всё в формате RSS. Наше сообщество [RSSHub](https://github.com/DIYgod/RSSHub), состоящее более чем из 1,000 разработчиков, потратило шесть лет на адаптацию почти тысячи сайтов, чтобы предоставить весь необходимый контент. Это включает платформы как X (Twitter), Instagram, PlayStation, Spotify, Telegram, YouTube и многие другие. Вы также можете написать собственные скрипты для адаптации дополнительных сайтов.", + "new_user_guide.step.rsshub.title": "Подписка с RSSHub", + "new_user_guide.step.shortcuts.description1": "Горячие клавиши позволяют использовать Follow удобнее и эффективнее.", + "new_user_guide.step.shortcuts.description2": "Нажмите <kbd /> чтобы быстро просмотреть все горячие клавиши в любое время.", + "new_user_guide.step.shortcuts.title": "Горячие клавиши", + "new_user_guide.step.start_question.content": "Вы уже использовали другие RSS-ридеры?", + "new_user_guide.step.start_question.option1": "Да, я использовал другие RSS-ридеры.", + "new_user_guide.step.start_question.option2": "Нет, это мой первый опыт с RSS-ридерами.", + "new_user_guide.step.start_question.title": "Вопрос", + "new_user_guide.step.trending.title": "Популярные ленты", + "new_user_guide.step.views.description": "Follow использует различные виды отображения для разных типов контента, чтобы предложить опыт, равный или лучший оригинальной платформе.", + "new_user_guide.step.views.title": "Просмотр", + "notify.unfollow_feed": "<FeedItem /> был отписан.", + "notify.unfollow_feed_many": "Все выбранные каналы были отписаны.", + "notify.update_info": "{{app_name}} готов к обновлению!", + "notify.update_info_1": "Нажмите, чтобы перезапустить", + "notify.update_info_2": "Нажмите, чтобы перезагрузить страницу", + "notify.update_info_3": "Нажмите для перезагрузки страницы", "player.back_10s": "Назад на 10 сек", "player.close": "Закрыть", "player.download": "Скачать", @@ -119,38 +292,80 @@ "player.playback_rate": "Скорость воспроизведения", "player.unmute": "Включить звук", "player.volume": "Громкость", + "quick_add.placeholder": "Быстрая подписка на канал, введите URL канала...", + "quick_add.title": "Быстрая подписка", + "register.confirm_password": "Подтвердите пароль", + "register.email": "Электронная почта", + "register.label": "Создать аккаунт {{app_name}}", + "register.login": "Войти", + "register.note": "Уже есть аккаунт? <LoginLink />", + "register.password": "Пароль", + "register.submit": "Создать аккаунт", + "resize.tooltip.double_click_to_collapse": "<b>Дважды щелкните</b> для сворачивания", + "resize.tooltip.drag_to_resize": "<b>Перетащите</b> для изменения размера", "search.empty.no_results": "Результатов не найдено.", "search.group.entries": "Записи", "search.group.feeds": "Каналы", "search.options.all": "Все", + "search.options.entry": "Запись", + "search.options.feed": "Канал", "search.options.search_type": "Тип поиска", "search.placeholder": "Поиск...", "search.result_count_local_mode": "(Локальный режим)", "search.tooltip.local_search": "Этот поиск охватывает только локально доступные данные. Попробуйте выполнить повторный запрос, чтобы включить последние данные.", "shortcuts.guide.title": "Руководство по сочетаниям клавиш", + "sidebar.add_more_feeds": "Добавить еще каналы", "sidebar.category_remove_dialog.cancel": "Отменить", "sidebar.category_remove_dialog.continue": "Продолжить", "sidebar.category_remove_dialog.description": "Эта операция удалит вашу категорию, но каналы, которые она содержит, будут сохранены и сгруппированы по веб-сайтам.", + "sidebar.category_remove_dialog.error": "Не удалось удалить категорию", + "sidebar.category_remove_dialog.success": "Категория успешно удалена", "sidebar.category_remove_dialog.title": "Удалить категорию", "sidebar.feed_actions.claim": "Заявить", "sidebar.feed_actions.claim_feed": "Заявить канал", + "sidebar.feed_actions.copy_email_address": "Копировать адрес электронной почты", "sidebar.feed_actions.copy_feed_id": "Скопировать ID канала", "sidebar.feed_actions.copy_feed_url": "Скопировать URL канала", + "sidebar.feed_actions.copy_list_id": "Скопировать ID списка", + "sidebar.feed_actions.copy_list_url": "Скопировать URL списка", + "sidebar.feed_actions.create_list": "Создать новый список", "sidebar.feed_actions.edit": "Редактировать", "sidebar.feed_actions.edit_feed": "Редактировать канал", + "sidebar.feed_actions.edit_inbox": "Редактировать почтовый ящик", + "sidebar.feed_actions.edit_list": "Редактировать список", "sidebar.feed_actions.feed_owned_by_you": "Этот канал принадлежит вам", + "sidebar.feed_actions.list_owned_by_you": "Этот список принадлежит вам", "sidebar.feed_actions.mark_all_as_read": "Отметить все как прочитанное", "sidebar.feed_actions.navigate_to_feed": "Перейти к каналу", - "sidebar.feed_actions.open_feed_in_browser": "Открыть канал в браузере", - "sidebar.feed_actions.open_site_in_browser": "Открыть сайт в браузере", + "sidebar.feed_actions.navigate_to_list": "Перейти к списку", + "sidebar.feed_actions.new_inbox": "Новый почтовый ящик", + "sidebar.feed_actions.open_feed_in_browser": "Открыть канал в {{which}}", + "sidebar.feed_actions.open_list_in_browser": "Открыть список в {{which}}", + "sidebar.feed_actions.open_site_in_browser": "Открыть сайт в {{which}}", + "sidebar.feed_actions.reset_feed": "Сбросить канал", + "sidebar.feed_actions.reset_feed_error": "Не удалось сбросить канал.", + "sidebar.feed_actions.reset_feed_success": "Канал успешно сброшена.", + "sidebar.feed_actions.resetting_feed": "Сброс канала...", "sidebar.feed_actions.unfollow": "Отписаться", "sidebar.feed_actions.unfollow_feed": "Отписаться от канала", + "sidebar.feed_actions.unfollow_feed_many": "Отписаться от всех выбранных каналов", + "sidebar.feed_actions.unfollow_feed_many_confirm": "Подтвердить отписку от всех выбранных каналов?", + "sidebar.feed_actions.unfollow_feed_many_warning": "Предупреждение: эта операция отписывает от всех выбранных каналов и не может быть отменена.", + "sidebar.feed_column.context_menu.add_feeds_to_category": "Переместить в категорию", + "sidebar.feed_column.context_menu.add_feeds_to_list": "Добавить каналы в список", "sidebar.feed_column.context_menu.change_to_other_view": "Изменить на другой вид", + "sidebar.feed_column.context_menu.create_category": "Новая категория", "sidebar.feed_column.context_menu.delete_category": "Удалить категорию", "sidebar.feed_column.context_menu.delete_category_confirmation": "Удалить категорию {{folderName}}?", "sidebar.feed_column.context_menu.mark_as_read": "Отметить как прочитанное", + "sidebar.feed_column.context_menu.new_category_modal.category_name": "Название категории", + "sidebar.feed_column.context_menu.new_category_modal.create": "Создать", "sidebar.feed_column.context_menu.rename_category": "Переименовать категорию", + "sidebar.feed_column.context_menu.rename_category_error": "Не удалось переименовать категорию", + "sidebar.feed_column.context_menu.rename_category_success": "Категория успешно переименована", + "sidebar.feed_column.context_menu.title": "Переместить в новую категорию", "sidebar.select_sort_method": "Выберите метод сортировки", + "signin.continue_with": "Продолжить с {{provider}}", "signin.sign_in_to": "Войти в", "sync_indicator.disabled": "По соображениям безопасности синхронизация отключена.", "sync_indicator.offline": "Офлайн", @@ -165,33 +380,59 @@ "tip_modal.tip_now": "Отправить чаевые", "tip_modal.tip_sent": "Чаевые успешно отправлены! Спасибо за вашу поддержку.", "tip_modal.tip_support": "⭐ Поддержите автора чаевыми!", + "tip_modal.tip_title": "Чаевые Power", "tip_modal.unclaimed_feed": "Этот канал еще никто не заявил. Полученные средства будут надежно храниться в блокчейн-контракте до тех пор, пока канал не будет заявлен.", + "trending.entry": "Популярные записи", + "trending.feed": "Популярные каналы", + "trending.list": "Популярные списки", + "trending.user": "Популярные пользователи", "user_button.account": "Аккаунт", + "user_button.achievement": "Достижения", + "user_button.actions": "Действия", "user_button.download_desktop_app": "Скачать настольное приложение", "user_button.log_out": "Выйти", "user_button.power": "Power", "user_button.preferences": "Настройки", "user_button.profile": "Профиль", + "user_button.zen_mode": "Режим zen", "user_profile.close": "Закрыть", "user_profile.edit": "Редактировать", "user_profile.loading": "Загрузка", "user_profile.share": "Поделиться", "user_profile.toggle_item_style": "Переключить стиль элементов", + "words.achievement": "Достижения", + "words.actions": "Действия", "words.add": "Добавить", + "words.boost": "Усилить", + "words.browser": "Браузер", "words.confirm": "Подтвердить", "words.discover": "Открыть", + "words.email": "Электронная почта", + "words.feeds": "Каналы", "words.import": "Импорт", + "words.inbox": "Почтовый ящик", "words.items": "Элементы", "words.language": "Язык", + "words.link": "Ссылка", + "words.lists": "Списки", "words.load_archived_entries": "Загрузить архивированные записи", "words.login": "Вход", + "words.mint": "Минтить", + "words.newTab": "Новая вкладка", + "words.power": "Power", "words.rss": "RSS", "words.rss3": "RSS3", "words.rsshub": "RSSHub", "words.search": "Поиск", + "words.show_more": "Показать больше", "words.starred": "Избранное", + "words.title": "Заголовок", + "words.transform": "Преобразовать", + "words.trending": "Популярное", + "words.undo": "Отменить", "words.unread": "Непрочитанное", "words.user": "Пользователь", "words.which.all": "все", - "words.zero_items": "Нет элементов" + "words.zero_items": "Нет элементов", + "zen.exit": "Выйти из режима zen" } diff --git a/locales/common/ru.json b/locales/common/ru.json index 41c8ee1230..7839bb1bdc 100644 --- a/locales/common/ru.json +++ b/locales/common/ru.json @@ -1,28 +1,44 @@ { "app.copied_to_clipboard": "Скопировано в буфер обмена", "cancel": "Отменить", + "close": "Закрыть", "confirm": "Подтвердить", "ok": "ОК", "quantifier.piece": "", + "retry": "Повторить попытку", "space": " ", "time.last_night": "Прошлой ночью", "time.the_night_before_last": "Позапрошлой ночью", "time.today": "Сегодня", "time.yesterday": "Вчера", "tips.load-lng-error": "Не удалось загрузить языковой пакет", + "words.actions": "Действия", + "words.ago": "назад", + "words.all": "Все", "words.back": "Назад", "words.copy": "Копировать", + "words.create": "Создать", + "words.delete": "Удалить", + "words.download": "Скачать", "words.edit": "Редактировать", "words.entry": "Запись", + "words.expand": "Расширить", + "words.follow": "Следовать", "words.id": "ID", "words.items_one": "Элемент", "words.items_other": "Элементы", "words.local": "локальный", + "words.manage": "Управлять", "words.record": "запись", "words.record_one": "запись", "words.record_other": "записи", + "words.reset": "Сброс", "words.result": "результат", "words.result_one": "результат", "words.result_other": "результаты", + "words.rsshub": "RSSHub", + "words.save": "Сохранить", + "words.submit": "Отправить", + "words.update": "Обновить", "words.which.all": "Все" } diff --git a/locales/errors/ru.json b/locales/errors/ru.json index 8a4898077d..98bb5722c7 100644 --- a/locales/errors/ru.json +++ b/locales/errors/ru.json @@ -1,8 +1,16 @@ { + "1": "Предыдущая операция не завершена", + "3": "Необработанный контент", "1000": "Неавторизован", "1001": "Не удалось создать сеанс", "1002": "Неверный параметр", "1003": "Неверное приглашение", + "1004": "Нет разрешения", + "1005": "Внутренняя ошибка", + "1100": "Пользователь пробной версии превысил максимальное количество подписок на каналах", + "1101": "Пользователь пробной версии превысил максимальное количество подписок на списки", + "1102": "Пользователь пробной версии превысил максимальное количество подписок на входящие сообщения", + "1103": "Пользователь пробной версии не имеет разрешения", "2000": "Только администраторы могут обновлять ленты", "2001": "Лента не найдена", "2002": "Требуется feedId или URL", @@ -16,6 +24,12 @@ "4002": "Недостаточно средств", "4003": "Недостаточно средств для вывода из ленты", "4004": "Ошибка кошелька целевого пользователя", + "4005": "Ежедневный расчет мощности в процессе", + "4006": "Неверное количество усиления", + "4010": "Airdrop недоступен", + "4011": "Airdrop в процессе отправки", + "4012": "Airdrop уже отправлен", + "4013": "Airdrop не подтвержден", "5000": "Превышен лимит приглашений. Пожалуйста, попробуйте снова через несколько дней.", "5001": "Приглашение уже существует.", "5002": "Код приглашения уже использован.", @@ -24,5 +38,25 @@ "7000": "Настройка не найдена", "7001": "Неверная вкладка настройки", "7002": "Неверное содержимое настройки", - "7003": "Содержимое настройки слишком велико" + "7003": "Содержимое настройки слишком велико", + "8000": "Список не найден", + "8001": "Доступ к списку запрещен", + "8002": "Превышен лимит списка", + "8003": "Канал уже добавлен в список", + "9000": "Достижение не завершено", + "9001": "Достижение уже получено", + "9002": "Достижение на проверке", + "9003": "Проверка достижения не требуется", + "10000": "Входящее сообщение не найдено", + "10001": "Входящее сообщение уже существует", + "10002": "Превышен лимит входящих сообщений", + "10003": "Доступ к входящим сообщениям запрещен", + "11000": "Маршрут RSSHub не найден", + "11001": "Вы не являетесь владельцем этого экземпляра RSSHub", + "11002": "RSSHub используется", + "11003": "RSSHub не найден", + "11004": "Превышен лимит пользователей RSSHub", + "11005": "Покупка RSSHub не найдена", + "11006": "Неверная конфигурация RSSHub", + "12000": "Превышен лимит действий" } diff --git a/locales/external/ru.json b/locales/external/ru.json index d31d937cf7..eb198dcf5c 100644 --- a/locales/external/ru.json +++ b/locales/external/ru.json @@ -1,7 +1,26 @@ { + "copied_link": "Ссылка скопирована в буфер обмена", + "feed.actions.followed": "Подписка оформлена", + "feed.copy_feed_url": "Скопировать URL канала", + "feed.feeds_one": "канал", + "feed.feeds_other": "каналы", + "feed.follow_to_view_all": "Подпишитесь, чтобы увидеть все {{count}} каналов...", + "feed.follower_one": "подписчик", + "feed.follower_other": "подписчики", + "feed.followsAndFeeds": "{{subscriptionCount}} {{subscriptionNoun}} и {{feedsCount}} {{feedsNoun}} в {{appName}}", "feed.followsAndReads": "{{subscriptionCount}} {{subscriptionNoun}} с {{readCount}} {{readNoun}} на {{appName}}", + "feed.madeby": "Создано", + "feed.preview": "Предпросмотр", "feed.read_one": "чтение", "feed.read_other": "чтений", + "feed.view_feed_url": "Просмотреть URL канала", + "feed_item.claimed_by_owner": "Этот канал заявлен", + "feed_item.claimed_by_unknown": "его владельцем.", + "feed_item.claimed_by_you": "Заявлено вами", + "feed_item.claimed_feed": "Заявленный канал", + "feed_item.claimed_list": "Заявленный список", + "feed_item.error_since": "Ошибка с", + "feed_item.not_publicly_visible": "Не отображается на вашей публичной странице профиля", "header.app": "Приложение", "header.download": "Скачать", "invitation.activate": "Активировать", @@ -15,12 +34,38 @@ "invitation.getCodeMessage": "Вы можете получить пригласительный код следующими способами:", "invitation.title": "Пригласительный код", "login.backToWebApp": "Вернуться к веб-приложению", + "login.confirm_password.label": "Подтвердите пароль", + "login.continueWith": "Продолжить через {{provider}}", + "login.email": "Электронная почта", + "login.forget_password.description": "Введите адрес электронной почты, связанный с вашей учетной записью, и мы отправим вам инструкции по сбросу пароля.", + "login.forget_password.label": "Забыли пароль", + "login.forget_password.note": "Забыли пароль?", + "login.forget_password.success": "Письмо успешно отправлено", "login.logInTo": "Войти в ", + "login.logInWithEmail": "Войти с помощью электронной почты", + "login.new_password.label": "Новый пароль", "login.openApp": "Открыть приложение", + "login.or": "Или", + "login.password": "Пароль", "login.redirecting": "Перенаправление", + "login.register": "Создать аккаунт", + "login.reset_password.description": "Введите новый пароль и подтвердите его, чтобы сбросить пароль", + "login.reset_password.label": "Сброс пароля", + "login.reset_password.success": "Пароль успешно сброшен", + "login.signOut": "Выйти", + "login.signUp": "Зарегистрироваться с помощью электронной почты", + "login.submit": "Отправить", "login.welcomeTo": "Добро пожаловать в ", "redirect.continueInBrowser": "Продолжить в браузере", "redirect.instruction": "Сейчас самое время открыть {{app_name}} и безопасно закрыть эту страницу.", "redirect.openApp": "Открыть {{app_name}}", - "redirect.successMessage": "Вы успешно подключились к аккаунту {{app_name}}." + "redirect.successMessage": "Вы успешно подключились к аккаунту {{app_name}}.", + "register.confirm_password": "Подтвердите пароль", + "register.email": "Электронная почта", + "register.label": "Создайте аккаунт {{app_name}}", + "register.login": "Войти", + "register.note": "Уже есть аккаунт? <LoginLink />", + "register.password": "Пароль", + "register.submit": "Создать аккаунт", + "words.email": "Электронная почта" } diff --git a/locales/native/ru.json b/locales/native/ru.json new file mode 100644 index 0000000000..a48fdf8497 --- /dev/null +++ b/locales/native/ru.json @@ -0,0 +1,74 @@ +{ + "contextMenu.copy": "Копировать", + "contextMenu.copyImage": "Копировать изображение", + "contextMenu.copyImageAddress": "Копировать адрес изображения", + "contextMenu.copyLink": "Копировать ссылку", + "contextMenu.copyVideoAddress": "Копировать адрес видео", + "contextMenu.cut": "Вырезать", + "contextMenu.inspect": "Проверить элемент", + "contextMenu.learnSpelling": "Запомнить написание", + "contextMenu.lookUpSelection": "Искать выделенное", + "contextMenu.openImageInBrowser": "Открыть изображение в браузере", + "contextMenu.openLinkInBrowser": "Открыть ссылку в браузере", + "contextMenu.paste": "Вставить", + "contextMenu.saveImage": "Сохранить изображение", + "contextMenu.saveImageAs": "Сохранить изображение как...", + "contextMenu.saveLinkAs": "Сохранить ссылку как...", + "contextMenu.saveVideo": "Сохранить видео", + "contextMenu.saveVideoAs": "Сохранить видео как...", + "contextMenu.searchWithGoogle": "Искать в Google", + "contextMenu.selectAll": "Выбрать всё", + "contextMenu.services": "Сервисы", + "dialog.cancel": "Отмена", + "dialog.clearAllData": "Вы уверены, что хотите очистить все данные?", + "dialog.no": "Нет", + "dialog.open": "Открыть", + "dialog.openExternalApp.message": "Вы уверены, что хотите открыть \"{{url}}\" с помощью других приложений?", + "dialog.openExternalApp.title": "Открыть внешнее приложение?", + "dialog.yes": "Да", + "menu.about": "О программе {{name}}", + "menu.actualSize": "Фактический размер", + "menu.bringAllToFront": "Вывести всё на передний план", + "menu.checkForUpdates": "Проверить обновления", + "menu.clearAllData": "Очистить все данные", + "menu.close": "Закрыть", + "menu.copy": "Копировать", + "menu.cut": "Вырезать", + "menu.debug": "Отладка", + "menu.delete": "Удалить", + "menu.discover": "Обзор", + "menu.edit": "Редактировать", + "menu.file": "Файл", + "menu.followReleases": "Следить за релизами", + "menu.forceReload": "Принудительная перезагрузка", + "menu.front": "Вывести на передний план", + "menu.help": "Помощь", + "menu.hide": "Скрыть {{name}}", + "menu.hideOthers": "Скрыть остальные", + "menu.minimize": "Свернуть", + "menu.open": "Открыть {{name}}", + "menu.openLogFile": "Открыть файл журнала", + "menu.paste": "Вставить", + "menu.pasteAndMatchStyle": "Вставить с сохранением стиля", + "menu.quickAdd": "Быстрое добавление", + "menu.quit": "Выйти из {{name}}", + "menu.quitAndInstallUpdate": "Отладка: выйти и установить обновление", + "menu.redo": "Повторить", + "menu.reload": "Перезагрузить", + "menu.search": "Поиск", + "menu.selectAll": "Выбрать всё", + "menu.services": "Сервисы", + "menu.settings": "Настройки...", + "menu.speech": "Речь", + "menu.startSpeaking": "Начать говорить", + "menu.stopSpeaking": "Прекратить говорить", + "menu.toggleDevTools": "Переключить инструменты разработчика", + "menu.toggleFullScreen": "Переключить полноэкранный режим", + "menu.undo": "Отменить", + "menu.view": "Вид", + "menu.window": "Окно", + "menu.zenMode": "Режим zen", + "menu.zoom": "Масштаб", + "menu.zoomIn": "Увеличить", + "menu.zoomOut": "Уменьшить" +} diff --git a/locales/settings/ru.json b/locales/settings/ru.json index e2d9c1a07f..ba54306212 100644 --- a/locales/settings/ru.json +++ b/locales/settings/ru.json @@ -2,20 +2,33 @@ "about.changelog": "Журнал изменений", "about.feedbackInfo": "{{appName}} ({{commitSha}}) находится на ранних этапах разработки. Если у вас есть отзывы или предложения, пожалуйста, <OpenIssueLink>откройте задачу</OpenIssueLink> <ExternalLinkIcon /> на нашем GitHub.", "about.iconLibrary": "Библиотека иконок защищена авторскими правами <IconLibraryLink /> <ExternalLinkIcon /> и не может быть перераспределена.", + "about.licenseInfo": "Авторское право © 2024 {{appName}}. Все права защищены.", "about.sidebar_title": "О проекте", "about.socialMedia": "Социальные сети", "actions.actionName": "Действие {{number}}", "actions.action_card.add": "Добавить", "actions.action_card.all": "Все", + "actions.action_card.and": "И", + "actions.action_card.block": "Блокировать", "actions.action_card.block_rules": "Правила блокировки", "actions.action_card.custom_filters": "Пользовательские фильтры", "actions.action_card.enable_readability": "Включить читаемость", + "actions.action_card.feed_options.entry_author": "Автор записи", + "actions.action_card.feed_options.entry_content": "Содержание записи", + "actions.action_card.feed_options.entry_media_length": "Длина медиа записи", + "actions.action_card.feed_options.entry_title": "Заголовок записи", + "actions.action_card.feed_options.entry_url": "URL записи", + "actions.action_card.feed_options.feed_category": "Категория канала", + "actions.action_card.feed_options.feed_title": "Заголовок канала", "actions.action_card.feed_options.feed_url": "URL канала", "actions.action_card.feed_options.site_url": "URL сайта", + "actions.action_card.feed_options.subscription_view": "Просмотр подписки", "actions.action_card.field": "Поле", "actions.action_card.from": "От", "actions.action_card.generate_summary": "Создать сводку с помощью ИИ", "actions.action_card.name": "Имя", + "actions.action_card.new_entry_notification": "Уведомление о новой записи", + "actions.action_card.no_translation": "Без перевода", "actions.action_card.operation_options.contains": "содержит", "actions.action_card.operation_options.does_not_contain": "не содержит", "actions.action_card.operation_options.is_equal_to": "равно", @@ -24,11 +37,15 @@ "actions.action_card.operation_options.is_not_equal_to": "не равно", "actions.action_card.operation_options.matches_regex": "соответствует regex", "actions.action_card.operator": "Оператор", + "actions.action_card.or": "Или", "actions.action_card.rewrite_rules": "Правила переписывания", + "actions.action_card.silence": "Без звука", + "actions.action_card.source_content": "Просмотр исходного контента", "actions.action_card.then_do": "Затем сделать…", "actions.action_card.to": "К", "actions.action_card.translate_into": "Перевести на", "actions.action_card.value": "Значение", + "actions.action_card.webhooks": "Вебхуки", "actions.action_card.when_feeds_match": "Когда каналы совпадают…", "actions.newRule": "Новое правило", "actions.save": "Сохранить", @@ -38,11 +55,18 @@ "appearance.code_highlight_theme": "Тема подсветки кода", "appearance.content": "Контент", "appearance.content_font": "Шрифт контента", + "appearance.custom_css.button": "Редактировать", + "appearance.custom_css.description": "Пользовательский стиль CSS для контента", + "appearance.custom_css.label": "Пользовательский CSS", "appearance.custom_font": "Пользовательский шрифт", "appearance.fonts": "Шрифты", "appearance.general": "Общие", "appearance.guess_code_language.description": "Основные языки программирования, которые используют модели для распознавания неотмеченных блоков кода", "appearance.guess_code_language.label": "Распознать язык кода", + "appearance.hide_extra_badge.description": "Скрыть специальный значок потока в боковой панели, например Boost, Claimed", + "appearance.hide_extra_badge.label": "Скрыть специальный значок", + "appearance.hide_recent_reader.description": "Скрыть недавнего читателя в заголовке записи.", + "appearance.hide_recent_reader.label": "Скрыть недавнего читателя", "appearance.misc": "Разное", "appearance.modal_overlay.description": "Показать наложение модального окна", "appearance.modal_overlay.label": "Показать наложение", @@ -53,6 +77,7 @@ "appearance.reduce_motion.label": "Уменьшить движение", "appearance.save": "Сохранить", "appearance.show_dock_badge.label": "Показывать как значок в Dock", + "appearance.sidebar": "Боковая панель", "appearance.sidebar_show_unread_count.label": "Показать в боковой панели", "appearance.sidebar_title": "Внешний вид", "appearance.text_size": "Размер текста", @@ -60,22 +85,69 @@ "appearance.theme.label": "Тема", "appearance.theme.light": "Светлая", "appearance.theme.system": "Системная", + "appearance.thumbnail_ratio.description": "Соотношение изображения в списке записей.", + "appearance.thumbnail_ratio.original": "Оригинал", + "appearance.thumbnail_ratio.square": "Квадрат", + "appearance.thumbnail_ratio.title": "Соотношение изображения", "appearance.title": "Внешний вид", "appearance.ui_font": "Шрифт интерфейса", "appearance.unread_count": "Количество непрочитанного", + "appearance.use_pointer_cursor.description": "Изменить курсор на указатель при наведении на любой интерактивный элемент.", + "appearance.use_pointer_cursor.label": "Использовать указатель", + "appearance.zen_mode.description": "Режим Zen — это режим чтения без отвлекающих факторов, который позволяет сосредоточиться на содержимом без помех. Включение режима Zen скрывает боковую панель.", + "appearance.zen_mode.label": "Режим Zen", + "common.give_star": "<HeartIcon />Любите наш продукт? <Link>Дайте нам звезды на GitHub!</Link>", + "data_control.app_cache_limit.description": "Максимальный размер кэша приложения. Когда кэш достигает этого размера, старейшие элементы будут удалены для освобождения места.", + "data_control.app_cache_limit.label": "Лимит кэша приложения", + "data_control.clean_cache.button": "Очистить кэш", + "data_control.clean_cache.description": "Очистить кэш приложения, чтобы освободить место.", + "data_control.clean_cache.description_web": "Очистить кэш веб-приложения сервисного рабочего для освобождения места.", + "feeds.claimTips": "Чтобы претендовать на ваши потоки и получать чаевые, щелкните правой кнопкой мыши на потоке в списке подписок и выберите 'Претендовать'.", + "feeds.noFeeds": "Нет заявленных потоков", + "feeds.tableHeaders.name": "Имя", + "feeds.tableHeaders.subscriptionCount": "Подписчиков", + "feeds.tableHeaders.tipAmount": "Чаевые", "general.app": "Приложение", + "general.auto_expand_long_social_media.description": "Автоматически разворачивать записи социальных сетей, содержащие длинный текст.", + "general.auto_expand_long_social_media.label": "Разворачивать длинные социальные сети", + "general.auto_group.description": "Автоматически группировать потоки по домену сайта.", + "general.auto_group.label": "Автогруппировка", + "general.cache": "Кэш", + "general.data": "Данные", + "general.data_file.label": "Файл данных", "general.data_persist.description": "Сохранение данных локально для доступа офлайн и локального поиска.", "general.data_persist.label": "Сохранять данные для офлайн использования", + "general.export.button": "Экспортировать", + "general.export.description": "Экспортировать ваши потоки в файл OPML.", + "general.export.folder_mode.description": "Выберите, как вы хотите организовать экспортированные папки.", + "general.export.folder_mode.label": "Режим папок", + "general.export.folder_mode.option.category": "Категория", + "general.export.folder_mode.option.view": "Просмотр", + "general.export.label": "Экспортировать потоки", + "general.export.rsshub_url.description": "Базовый URL для маршрута RSSHub, оставьте пустым для использования https://rsshub.app.", + "general.export.rsshub_url.label": "RSSHub URL", + "general.export_database.button": "Экспортировать", + "general.export_database.description": "Экспортировать вашу базу данных в файл JSON.", + "general.export_database.label": "Экспортировать базу данных", "general.group_by_date.description": "Группировать записи по дате.", "general.group_by_date.label": "Группировать по дате", "general.language": "Язык", "general.launch_at_login": "Запускать при входе", + "general.log_file.button": "Открыть", + "general.log_file.description": "Открыть файл журнала в системе.", + "general.log_file.label": "Файл журнала", "general.mark_as_read.hover.description": "Автоматически отмечать записи как прочитанные при наведении.", "general.mark_as_read.hover.label": "Отметить как прочитанное при наведении", "general.mark_as_read.render.description": "Автоматически отмечать записи одноуровневого контента (например, посты в соцсетях, изображения, видеопросмотры) как прочитанные, когда они отображаются.", "general.mark_as_read.render.label": "Отметить как прочитанное при отображении", "general.mark_as_read.scroll.description": "Автоматически отмечать записи как прочитанные при прокрутке.", "general.mark_as_read.scroll.label": "Отметить как прочитанное при прокрутке", + "general.minimize_to_tray.description": "Свернуть в системный трей при закрытии окна", + "general.minimize_to_tray.label": "Свернуть в трей", + "general.network": "Сеть", + "general.privacy": "Конфиденциальность", + "general.proxy.description": "Настроить прокси для маршрутизации сетевого трафика, например, socks://proxy.example.com:1080", + "general.proxy.label": "Прокси", "general.rebuild_database.button": "Перестроить", "general.rebuild_database.description": "Если у вас возникают проблемы с отображением, перестроение базы данных может их решить.", "general.rebuild_database.label": "Перестроить базу данных", @@ -84,9 +156,15 @@ "general.rebuild_database.warning.line2": "Вы уверены, что хотите продолжить?", "general.send_anonymous_data.description": "При выборе отправки анонимных данных телеметрии вы помогаете улучшить общий пользовательский опыт Follow.", "general.send_anonymous_data.label": "Отправлять анонимные данные", + "general.show_quick_timeline.description": "Показать быструю временную шкалу в верхней части списка потоков.", + "general.show_quick_timeline.label": "Показать временную шкалу списка потоков", "general.show_unread_on_launch.description": "Показывать непрочитанный контент при запуске", "general.show_unread_on_launch.label": "Показывать непрочитанное при запуске", + "general.sidebar": "Боковая панель", "general.sidebar_title": "Общие", + "general.startup_screen.subscription": "Подписка", + "general.startup_screen.timeline": "Таймлайн", + "general.startup_screen.title": "Экран запуска", "general.timeline": "Лента", "general.unread": "Непрочитанное", "general.voices": "Голоса", @@ -98,6 +176,27 @@ "integration.instapaper.password.label": "Пароль Instapaper", "integration.instapaper.title": "Instapaper", "integration.instapaper.username.label": "Имя пользователя Instapaper", + "integration.obsidian.enable.description": "Показать кнопку 'Сохранить в Obsidian', когда она доступна.", + "integration.obsidian.enable.label": "Включить", + "integration.obsidian.title": "Obsidian", + "integration.obsidian.vaultPath.description": "Путь к вашему хранилищу Obsidian.", + "integration.obsidian.vaultPath.label": "Путь к хранилищу Obsidian", + "integration.outline.collection.description": "UUID или urlId коллекции, в которой сохраняется документ.", + "integration.outline.collection.label": "Коллекция Outline", + "integration.outline.enable.description": "Показать кнопку 'Сохранить в Outline', когда она доступна.", + "integration.outline.enable.label": "Включить", + "integration.outline.endpoint.description": "URL: 'https://<YOUR_OUTLINE_DOMAIN>/api'.", + "integration.outline.endpoint.label": "Базовый URL API Outline", + "integration.outline.title": "Outline", + "integration.outline.token.description": "Вы можете получить его из настроек вашей учетной записи Outline.", + "integration.outline.token.label": "API ключ Outline", + "integration.readeck.enable.description": "Показать кнопку 'Сохранить в Readeck', когда она доступна.", + "integration.readeck.enable.label": "Включить", + "integration.readeck.endpoint.description": "URL: 'https://<YOUR_READECK_DOMAIN>'.", + "integration.readeck.endpoint.label": "Базовый URL API Readeck", + "integration.readeck.title": "Readeck", + "integration.readeck.token.description": "Вы можете получить его из настроек вашей учетной записи Readeck.", + "integration.readeck.token.label": "API токен Readeck", "integration.readwise.enable.description": "Отображать кнопку 'Сохранить в Readwise', когда доступно.", "integration.readwise.enable.label": "Включить", "integration.readwise.title": "Readwise", @@ -120,6 +219,7 @@ "invitation.generateButton": "Создать новый код", "invitation.generateCost": "Вы можете потратить {{INVITATION_PRICE}} <PowerIcon /> Power, чтобы сгенерировать пригласительный код для своих друзей.", "invitation.getCodeMessage": "Вы можете получить пригласительный код следующими способами:", + "invitation.limitationMessage": "В зависимости от времени использования вы можете создать до {{limitation}} кодов приглашений.", "invitation.newInvitationSuccess": "🎉 Новый пригласительный код создан, код скопирован", "invitation.noInvitations": "Нет приглашений", "invitation.notUsed": "Не использован", @@ -128,25 +228,104 @@ "invitation.tableHeaders.creationTime": "Время создания", "invitation.tableHeaders.usedBy": "Использовано", "invitation.title": "Пригласительный код", + "lists.create": "Создать новый список", + "lists.created.error": "Не удалось создать список.", + "lists.created.success": "Список успешно создан!", + "lists.delete.confirm": "Подтвердить удаление списка?", + "lists.delete.error": "Не удалось удалить список.", + "lists.delete.success": "Список успешно удален!", + "lists.delete.warning": "Предупреждение: После удаления список станет недоступен, и все содержимое будет навсегда удалено и невозможно восстановить!..", + "lists.description": "Описание", + "lists.earnings": "Заработок", + "lists.edit.error": "Не удалось отредактировать список.", + "lists.edit.label": "Редактировать", + "lists.edit.success": "Список успешно отредактирован!", + "lists.fee.description": "Плата, которую другие должны заплатить вам для подписки на этот список.", + "lists.fee.label": "Плата", + "lists.feeds.actions": "Действия", + "lists.feeds.add.error": "Не удалось добавить канал в список.", + "lists.feeds.add.label": "Добавить", + "lists.feeds.add.success": "Каналы добавлены в список.", + "lists.feeds.delete.error": "Не удалось удалить канал из списка.", + "lists.feeds.delete.success": "Канал удален из списка.", + "lists.feeds.id": "ID канала", + "lists.feeds.label": "Каналы", + "lists.feeds.manage": "Управлять каналами", + "lists.feeds.owner": "Владелец", + "lists.feeds.search": "Поиск канала", + "lists.feeds.title": "Название", + "lists.image": "Изображение", + "lists.info": "Списки - это коллекции каналов, которые вы можете делиться или продавать, чтобы другие могли подписаться. Подписчики будут синхронизировать и получать доступ ко всем каналам в списке.", + "lists.noLists": "Нет списков", + "lists.submit": "Отправить", + "lists.subscriptions": "Подписки", + "lists.title": "Название", + "lists.view": "Просмотр", "profile.avatar.label": "Аватар", + "profile.change_password.label": "Сменить пароль", + "profile.confirm_password.label": "Подтвердите пароль", + "profile.current_password.label": "Текущий пароль", + "profile.email.change": "Сменить email", + "profile.email.changed": "Email изменен.", + "profile.email.changed_verification_sent": "Email для подтверждения нового email был отправлен.", + "profile.email.label": "Email", + "profile.email.send_verification": "Отправить email для подтверждения", + "profile.email.unverified": "Не подтвержден", + "profile.email.verification_sent": "Email для подтверждения отправлен", + "profile.email.verified": "Подтвержден", "profile.handle.description": "Ваш уникальный идентификатор.", "profile.handle.label": "Идентификатор", + "profile.link_social.authentication": "Аутентификация", + "profile.link_social.description": "Вы можете подключить социальные аккаунты только с тем же email.", + "profile.link_social.link": "Подключить", + "profile.link_social.unlink.success": "Социальный аккаунт отсоединен.", "profile.name.description": "Ваше публичное отображаемое имя.", - "profile.name.label": "Отображаемое имя", + "profile.name.label": "Имя", + "profile.new_password.label": "Новый пароль", + "profile.password.label": "Пароль", + "profile.reset_password_mail_sent": "Письмо для сброса пароля отправлено.", "profile.sidebar_title": "Профиль", "profile.submit": "Отправить", "profile.title": "Настройки профиля", "profile.updateSuccess": "Профиль обновлен.", + "profile.update_password_success": "Пароль обновлен.", + "rsshub.addModal.access_key_label": "Ключ доступа (необязательно)", + "rsshub.addModal.add": "Добавить", + "rsshub.addModal.base_url_label": "Базовый URL", + "rsshub.addModal.description": "Чтобы использовать свой собственный экземпляр в Follow, необходимо добавить следующие переменные окружения.", + "rsshub.add_new_instance": "Добавить новый экземпляр", + "rsshub.description": "RSSHub — это открытая сеть RSS, поддерживаемая сообществом. Follow предоставляет встроенный выделенный экземпляр и использует его для поддержки тысяч подписок. Вы также можете достичь более стабильного получения контента, используя собственные или сторонние экземпляры.", + "rsshub.public_instances": "Доступные экземпляры", + "rsshub.table.description": "Описание", + "rsshub.table.edit": "Редактировать", + "rsshub.table.inuse": "В использовании", + "rsshub.table.owner": "Владелец", + "rsshub.table.price": "Ежемесячная плата", + "rsshub.table.unlimited": "Неограниченно", + "rsshub.table.use": "Использовать", + "rsshub.table.userCount": "Количество пользователей", + "rsshub.table.userLimit": "Ограничение пользователей", + "rsshub.useModal.about": "О данном экземпляре", + "rsshub.useModal.month": "месяц", + "rsshub.useModal.months_label": "Количество месяцев, которые вы хотите приобрести", + "rsshub.useModal.purchase_expires_at": "Вы приобрели этот экземпляр, и ваша покупка истекает", + "rsshub.useModal.title": "Экземпляр RSSHub", + "rsshub.useModal.useWith": "Использовать с {{amount}} <Power />", "titles.about": "О проекте", "titles.actions": "Действия", "titles.appearance": "Внешний вид", + "titles.data_control": "Управление данными", + "titles.feeds": "Каналы", "titles.general": "Общие", "titles.integration": "Интеграции", "titles.invitations": "Приглашения", + "titles.lists": "Списки", "titles.power": "Power", "titles.profile": "Профиль", "titles.shortcuts": "Горячие клавиши", "wallet.address.title": "Ваш адрес", + "wallet.balance.activePoints": "Активные очки", + "wallet.balance.dailyReward": "Ваша ежедневная награда", "wallet.balance.title": "Ваш баланс", "wallet.balance.withdrawable": "Доступно для вывода", "wallet.balance.withdrawableTooltip": "Доступный для вывода Power включает как чаевые, которые вы получили, так и Power, который вы пополнили.", @@ -157,15 +336,35 @@ "wallet.create.button": "Создать кошелек", "wallet.create.description": "Создайте бесплатный кошелек для получения <PowerIcon /> <strong>Power</strong>, который можно использовать для вознаграждения создателей контента и получения наград за ваши собственные публикации.", "wallet.power.dailyClaim": "Вы можете ежедневно получать {{amount}} бесплатного Power, который можно использовать для вознаграждения записей RSS на Follow.", + "wallet.power.description2": "Power — это <Link>токен ERC-20</Link> на блокчейне {{blockchainName}}, который можно использовать для покупок и чаевых на Follow.", + "wallet.power.rewardDescription": "Все активные пользователи на Follow имеют право на ежедневные вознаграждения Power.", + "wallet.power.rewardDescription2": "В зависимости от вашего уровня и прошлых действий, вы можете получить <Balance /> вознаграждения сегодня. <Link>Узнать больше.</Link>", + "wallet.ranking.level": "Уровень", + "wallet.ranking.name": "Имя", + "wallet.ranking.power": "Power", + "wallet.ranking.rank": "Ранг", + "wallet.ranking.title": "Рейтинг Power", + "wallet.rewardDescription.description1": "Ежедневные вознаграждения для каждого пользователя зависят от двух факторов: уровня пользователя и активности пользователя.", + "wallet.rewardDescription.description2": "Уровень пользователя: определяется сравнением рейтинга пользователя с рейтингами всех других пользователей.", + "wallet.rewardDescription.description3": "Активность пользователя: взаимодействие с различными функциями Follow может повысить активность. Награды варьируются от минимум 1x до максимум 5x.", + "wallet.rewardDescription.level": "Уровень пользователя", + "wallet.rewardDescription.percentage": "Процент в рейтинге", + "wallet.rewardDescription.reward": "Множитель вознаграждения", + "wallet.rewardDescription.title": "Описание вознаграждения", + "wallet.rewardDescription.total": "Общее вознаграждение в день", "wallet.sidebar_title": "Power", "wallet.transactions.amount": "Сумма", "wallet.transactions.date": "Дата", + "wallet.transactions.description": "Некоторые транзакции влекут за собой комиссию платформы в размере {{percentage}}%, чтобы поддержать дальнейшее развитие Follow. Для подробностей обратитесь к транзакции на блокчейне.", "wallet.transactions.from": "От", + "wallet.transactions.more": "Посмотреть больше через блокчейн-обозреватель.", "wallet.transactions.noTransactions": "Нет транзакций", "wallet.transactions.title": "Транзакции", "wallet.transactions.to": "Кому", "wallet.transactions.tx": "Tx", "wallet.transactions.type": "Тип", + "wallet.transactions.types.airdrop": "Airdrop", + "wallet.transactions.types.all": "Все", "wallet.transactions.types.burn": "сожжение", "wallet.transactions.types.mint": "выпуск", "wallet.transactions.types.purchase": "покупка", @@ -179,5 +378,6 @@ "wallet.withdraw.error": "Ошибка при выводе: {{error}}", "wallet.withdraw.modalTitle": "Вывести Power", "wallet.withdraw.submitButton": "Отправить", - "wallet.withdraw.success": "Вывод успешен!" + "wallet.withdraw.success": "Вывод успешен!", + "wallet.withdraw.toRss3Label": "Вывести как RSS3" } diff --git a/locales/shortcuts/ru.json b/locales/shortcuts/ru.json index 1b4a570568..3cfc02bff5 100644 --- a/locales/shortcuts/ru.json +++ b/locales/shortcuts/ru.json @@ -6,6 +6,7 @@ "keys.entries.refetch": "Повторить запрос", "keys.entries.toggleUnreadOnly": "Показать только непрочитанное", "keys.entry.copyLink": "Скопировать ссылку", + "keys.entry.copyTitle": "Скопировать заголовок", "keys.entry.openInBrowser": "Открыть в браузере", "keys.entry.openInNewTab": "Открыть в новой вкладке", "keys.entry.scrollDown": "Прокрутить вниз", @@ -20,5 +21,14 @@ "keys.feeds.switchToView": "Переключиться на вид", "keys.layout.showShortcuts": "Показать/скрыть горячие клавиши", "keys.layout.toggleSidebar": "Показать/скрыть боковую панель каналов", + "keys.layout.toggleWideMode": "Переключить широкий режим", + "keys.layout.zenMode": "Режим zen", + "keys.misc.quickSearch": "Быстрый поиск", + "keys.type.audio": "аудио", + "keys.type.entries": "записи", + "keys.type.entry": "запись", + "keys.type.feeds": "ленты", + "keys.type.layout": "макет", + "keys.type.misc": "разное", "sidebar_title": "Горячие клавиши" } From f97be400d42cd91bf4230ce7e97d7edaa3f0d46a Mon Sep 17 00:00:00 2001 From: Eric Zhu <eric@zhu.email> Date: Fri, 3 Jan 2025 17:15:20 +0800 Subject: [PATCH 12/70] chore: add additional data attr for targeted customization of article css (#2421) * chore: add additional data attr for targeted customization of article css * update * chore: auto-fix linting and formatting issues --------- Co-authored-by: ericyzhu <ericyzhu@users.noreply.github.com> --- apps/renderer/src/modules/renderer/html.tsx | 12 ++++++++---- locales/settings/zh-CN.json | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/renderer/src/modules/renderer/html.tsx b/apps/renderer/src/modules/renderer/html.tsx index e642299f0d..5e9cc70107 100644 --- a/apps/renderer/src/modules/renderer/html.tsx +++ b/apps/renderer/src/modules/renderer/html.tsx @@ -28,9 +28,11 @@ export function EntryContentHTMLRenderer<AS extends keyof JSX.IntrinsicElements } & HTMLProps<AS>) { const entry = useEntry(entryId) - const feedSiteUrl = useFeedByIdSelector(feedId, (feed) => - "siteUrl" in feed ? feed.siteUrl : undefined, - ) + const { feedSiteUrl, feedUrl } = + useFeedByIdSelector(feedId, (feed) => ({ + feedSiteUrl: "siteUrl" in feed ? feed.siteUrl : undefined, + feedUrl: "url" in feed ? feed.url : undefined, + })) || {} const images: Record<string, MarkdownImage> = useMemo(() => { return ( @@ -63,7 +65,9 @@ export function EntryContentHTMLRenderer<AS extends keyof JSX.IntrinsicElements <MarkdownRenderActionContext.Provider value={actions}> <EntryInfoContext.Provider value={useMemo(() => ({ feedId, entryId }), [feedId, entryId])}> {/* @ts-expect-error */} - <HTML {...props}>{children}</HTML> + <HTML data-feed-url={feedUrl} data-view={view} {...props}> + {children} + </HTML> </EntryInfoContext.Provider> </MarkdownRenderActionContext.Provider> </MarkdownImageRecordContext.Provider> diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 7de8ae87bd..21126bd64b 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -276,12 +276,14 @@ "profile.email.verified": "已验证", "profile.handle.description": "你的唯一标识。", "profile.handle.label": "唯一标识", + "profile.link_social.authentication": "身份验证", "profile.link_social.description": "目前只能连接具有相同邮件地址的社交帐户。", "profile.link_social.link": "连接", "profile.link_social.unlink.success": "已解除社交账户连接。", "profile.name.description": "你的公开显示名称。", "profile.name.label": "显示名称", "profile.new_password.label": "新密码", + "profile.password.label": "密码", "profile.reset_password_mail_sent": "重置密码邮件已发送。", "profile.sidebar_title": "个人资料", "profile.submit": "提交", @@ -377,5 +379,6 @@ "wallet.withdraw.error": "提现失败:{{error}}", "wallet.withdraw.modalTitle": "提现 Power", "wallet.withdraw.submitButton": "提交", - "wallet.withdraw.success": "提现成功!" + "wallet.withdraw.success": "提现成功!", + "wallet.withdraw.toRss3Label": "提现为 RSS3" } From cf7259bdf874bdb900967d870e4a6aa7170e12d8 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Fri, 3 Jan 2025 17:20:44 +0800 Subject: [PATCH 13/70] refactor(rn): debug items Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/src/screens/(headless)/debug.tsx | 179 ++++++++++--------- 1 file changed, 99 insertions(+), 80 deletions(-) diff --git a/apps/mobile/src/screens/(headless)/debug.tsx b/apps/mobile/src/screens/(headless)/debug.tsx index 76195ffe84..cfabb4f426 100644 --- a/apps/mobile/src/screens/(headless)/debug.tsx +++ b/apps/mobile/src/screens/(headless)/debug.tsx @@ -1,6 +1,8 @@ import * as Clipboard from "expo-clipboard" import * as FileSystem from "expo-file-system" import { Sitemap } from "expo-router/build/views/Sitemap" +import type { FC } from "react" +import * as React from "react" import { useRef, useState } from "react" import { Alert, @@ -17,93 +19,110 @@ import { useSafeAreaInsets } from "react-native-safe-area-context" import { getDbPath } from "@/src/database" import { clearSessionToken, getSessionToken, setSessionToken } from "@/src/lib/cookie" +interface MenuSection { + title: string + items: (MenuItem | FC)[] +} + +interface MenuItem { + title: string + onPress: () => Promise<void> | void + textClassName?: string +} + export default function DebugPanel() { const insets = useSafeAreaInsets() + const menuSections: MenuSection[] = [ + { + title: "Users", + items: [ + UserSessionSetting, + { + title: "Get Current Session Token", + onPress: async () => { + const token = await getSessionToken() + Alert.alert(`Current Session Token: ${token?.value}`) + }, + }, + { + title: "Clear Session Token", + onPress: async () => { + await clearSessionToken() + Alert.alert("Session Token Cleared") + }, + }, + ], + }, + { + title: "Data Control", + items: [ + { + title: "Copy Sqlite File Location", + onPress: async () => { + const dbPath = getDbPath() + await Clipboard.setStringAsync(dbPath) + }, + }, + { + title: "Clear Sqlite Data", + textClassName: "!text-red", + onPress: async () => { + Alert.alert("Clear Sqlite Data?", "This will delete all your data", [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear", + style: "destructive", + async onPress() { + const dbPath = getDbPath() + await FileSystem.deleteAsync(dbPath) + await expo.reloadAppAsync("Clear Sqlite Data") + }, + }, + ]) + }, + }, + ], + }, + { + title: "App", + items: [ + { + title: "Reload App", + onPress: () => expo.reloadAppAsync("Reload App"), + }, + ], + }, + ] + return ( <ScrollView className="flex-1 bg-black" style={{ paddingTop: insets.top }}> - <Text className="mt-4 px-8 text-2xl font-medium text-white">Users</Text> - - <View style={styles.container}> - <View style={styles.itemContainer}> - <UserSessionSetting /> - - <TouchableOpacity - style={styles.itemPressable} - onPress={async () => { - const token = await getSessionToken() - Alert.alert(`Current Session Token: ${token?.value}`) - }} - > - <Text style={styles.filename}>Get Current Session Token</Text> - </TouchableOpacity> - - <TouchableOpacity - style={styles.itemPressable} - onPress={async () => { - await clearSessionToken() - Alert.alert("Session Token Cleared") - }} - > - <Text style={styles.filename}>Clear Session Token</Text> - </TouchableOpacity> - </View> - </View> - - <Text className="mt-4 px-8 text-2xl font-medium text-white">Data Control</Text> - <View style={styles.container}> - <View style={styles.itemContainer}> - <TouchableOpacity - style={styles.itemPressable} - onPress={async () => { - const dbPath = getDbPath() - await Clipboard.setStringAsync(dbPath) - }} - > - <Text style={styles.filename}>Copy Sqlite File Location</Text> - </TouchableOpacity> - - <TouchableOpacity - style={styles.itemPressable} - onPress={async () => { - Alert.alert("Clear Sqlite Data?", "This will delete all your data", [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Clear", - style: "destructive", - async onPress() { - const dbPath = getDbPath() - await FileSystem.deleteAsync(dbPath) - // Reload the app - await expo.reloadAppAsync("Clear Sqlite Data") - }, - }, - ]) - }} - > - <Text style={styles.filename} className="!text-red"> - Clear Sqlite Data - </Text> - </TouchableOpacity> - </View> - </View> + {menuSections.map((section) => ( + <View key={section.title}> + <Text className="mt-4 px-8 text-2xl font-medium text-white">{section.title}</Text> + <View style={styles.container}> + <View style={styles.itemContainer}> + {section.items.map((item, index) => { + if (typeof item === "function") { + return React.createElement(item, { key: index }) + } - <Text className="mt-4 px-8 text-2xl font-medium text-white">App</Text> - <View style={styles.container}> - <View style={styles.itemContainer}> - <TouchableOpacity - style={styles.itemPressable} - onPress={() => { - expo.reloadAppAsync("Reload App") - }} - > - <Text style={styles.filename}>Reload App</Text> - </TouchableOpacity> + return ( + <TouchableOpacity + key={item.title} + style={styles.itemPressable} + onPress={item.onPress} + > + <Text style={styles.filename} className={item.textClassName}> + {item.title} + </Text> + </TouchableOpacity> + ) + })} + </View> + </View> </View> - </View> + ))} <Text className="mt-4 px-8 text-2xl font-medium text-white">Sitemap</Text> <Sitemap /> From 85832105b4e6a860d34374cfa7e6c01a06cc54a3 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Fri, 3 Jan 2025 23:58:02 +0800 Subject: [PATCH 14/70] fix(rn): rsshub form styles Signed-off-by: Innei <tukon479@gmail.com> --- .../src/components/ui/form/PickerIos.tsx | 94 +++++++++++++++++++ apps/mobile/src/components/ui/form/Select.tsx | 60 +++++------- .../src/components/ui/form/TextField.tsx | 38 +++++--- .../src/screens/(modal)/rsshub-form.tsx | 7 +- 4 files changed, 147 insertions(+), 52 deletions(-) create mode 100644 apps/mobile/src/components/ui/form/PickerIos.tsx diff --git a/apps/mobile/src/components/ui/form/PickerIos.tsx b/apps/mobile/src/components/ui/form/PickerIos.tsx new file mode 100644 index 0000000000..5c871f276e --- /dev/null +++ b/apps/mobile/src/components/ui/form/PickerIos.tsx @@ -0,0 +1,94 @@ +/* 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 PickerIosProps<T> { + options: { label: string; value: T }[] + + value: T + onValueChange: (value: T) => void + + wrapperClassName?: string + wrapperStyle?: StyleProp<ViewStyle> +} +export function PickerIos<T>({ + options, + value, + onValueChange, + wrapperClassName, + wrapperStyle, +}: PickerIosProps<T>) { + 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<T, string>()) + }, [options]) + + const handleChangeValue = useEventCallback((value: T) => { + setCurrentValue(value) + onValueChange(value) + }) + + const systemFill = useColor("text") + + return ( + <> + {/* Trigger */} + <Pressable onPress={() => setIsOpen(!isOpen)}> + <View + className={cn( + "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} height={16} width={16} /> + </View> + </View> + </Pressable> + {/* Picker */} + {isOpen && ( + <Portal> + <Pressable + onPress={() => setIsOpen(false)} + className="absolute inset-0 flex flex-row items-end" + > + <Animated.View className="relative flex-1" exiting={SlideOutDown}> + <BlurEffect /> + <Pressable onPress={(e) => e.stopPropagation()}> + <Picker selectedValue={currentValue} onValueChange={handleChangeValue}> + {options.map((option, index) => ( + <Picker.Item key={index} label={option.label} value={option.value} /> + ))} + </Picker> + </Pressable> + </Animated.View> + </Pressable> + </Portal> + )} + </> + ) +} diff --git a/apps/mobile/src/components/ui/form/Select.tsx b/apps/mobile/src/components/ui/form/Select.tsx index e894d8afae..1ce3ccc56d 100644 --- a/apps/mobile/src/components/ui/form/Select.tsx +++ b/apps/mobile/src/components/ui/form/Select.tsx @@ -1,17 +1,14 @@ -/* 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 { Text, View } from "react-native" +import ContextMenu from "react-native-context-menu-view" import { useEventCallback } from "usehooks-ts" import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line" import { useColor } from "@/src/theme/colors" -import { BlurEffect } from "../../common/HeaderBlur" +import { FormLabel } from "./Label" interface SelectProps<T> { options: { label: string; value: T }[] @@ -21,6 +18,8 @@ interface SelectProps<T> { wrapperClassName?: string wrapperStyle?: StyleProp<ViewStyle> + + label?: string } export function Select<T>({ options, @@ -28,9 +27,8 @@ export function Select<T>({ onValueChange, wrapperClassName, wrapperStyle, + label, }: SelectProps<T>) { - const [isOpen, setIsOpen] = useState(false) - const [currentValue, setCurrentValue] = useState(() => { if (!value) { return options[0].value @@ -51,43 +49,37 @@ export function Select<T>({ }) const systemFill = useColor("text") + return ( - <> + <View className="w-full flex-1 flex-row items-center"> + {!!label && <FormLabel className="pl-1" label={label} />} + <View className="flex-1" /> {/* Trigger */} - <Pressable onPress={() => setIsOpen(!isOpen)}> + <ContextMenu + dropdownMenuMode + actions={options.map((option) => ({ + title: option.label, + selected: option.value === currentValue, + }))} + onPress={(e) => { + const { index } = e.nativeEvent + handleChangeValue(options[index].value) + }} + > <View className={cn( - "border-system-fill/80 bg-system-fill/30 h-10 flex-row items-center rounded-lg border pl-4 pr-2", + "border-system-fill/80 bg-system-fill/30 h-8 flex-row items-center rounded-lg border pl-3 pr-2", + "min-w-[80px]", wrapperClassName, )} style={wrapperStyle} > <Text className="text-text">{valueToLabelMap.get(currentValue)}</Text> - <View className="ml-auto shrink-0"> + <View className="ml-auto shrink-0 pl-2"> <MingcuteDownLineIcon color={systemFill} height={16} width={16} /> </View> </View> - </Pressable> - {/* Picker */} - {isOpen && ( - <Portal> - <Pressable - onPress={() => setIsOpen(false)} - className="absolute inset-0 flex flex-row items-end" - > - <Animated.View className="relative flex-1" exiting={SlideOutDown}> - <BlurEffect /> - <Pressable onPress={(e) => e.stopPropagation()}> - <Picker selectedValue={currentValue} onValueChange={handleChangeValue}> - {options.map((option, index) => ( - <Picker.Item key={index} label={option.label} value={option.value} /> - ))} - </Picker> - </Pressable> - </Animated.View> - </Pressable> - </Portal> - )} - </> + </ContextMenu> + </View> ) } diff --git a/apps/mobile/src/components/ui/form/TextField.tsx b/apps/mobile/src/components/ui/form/TextField.tsx index f53005fe39..0fa45cf2a2 100644 --- a/apps/mobile/src/components/ui/form/TextField.tsx +++ b/apps/mobile/src/components/ui/form/TextField.tsx @@ -3,28 +3,36 @@ import { forwardRef } from "react" import type { StyleProp, TextInputProps, ViewStyle } from "react-native" import { StyleSheet, TextInput, View } from "react-native" +import { FormLabel } from "./Label" + interface TextFieldProps { wrapperClassName?: string wrapperStyle?: StyleProp<ViewStyle> + + label?: string + required?: boolean } export const TextField = forwardRef<TextInput, TextInputProps & TextFieldProps>( - ({ className, style, wrapperClassName, wrapperStyle, ...rest }, ref) => { + ({ className, style, wrapperClassName, wrapperStyle, label, required, ...rest }, ref) => { return ( - <View - className={cn( - "bg-system-fill/40 relative h-10 flex-row items-center rounded-lg px-4", - wrapperClassName, - )} - style={wrapperStyle} - > - <TextInput - ref={ref} - className={cn("text-text placeholder:text-placeholder-text w-full flex-1", className)} - style={StyleSheet.flatten([styles.textField, style])} - {...rest} - /> - </View> + <> + {!!label && <FormLabel className="pl-1" label={label} optional={!required} />} + <View + className={cn( + "bg-system-fill/40 relative h-10 flex-row items-center rounded-lg px-4", + wrapperClassName, + )} + style={wrapperStyle} + > + <TextInput + ref={ref} + className={cn("text-text placeholder:text-placeholder-text w-full flex-1", className)} + style={StyleSheet.flatten([styles.textField, style])} + {...rest} + /> + </View> + </> ) }, ) diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index 0e69c7ce28..ae3277be24 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -12,7 +12,6 @@ import { z } from "zod" import { HeaderTitleExtra } from "@/src/components/common/HeaderTitleExtra" import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents" import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvider" -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" @@ -121,7 +120,6 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { return ( <View key={keyItem.name}> - <FormLabel className="pl-1" label={keyItem.name} optional={keyItem.optional} /> {!parameters?.options && ( <Controller name={keyItem.name} @@ -136,6 +134,8 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { }} render={({ field: { onChange, onBlur, ref, value } }) => ( <TextField + label={keyItem.name} + required={!keyItem.optional} wrapperClassName="mt-2" placeholder={formPlaceholder[keyItem.name]} onBlur={onBlur} @@ -150,6 +150,7 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { {!!parameters?.options && ( <Select + label={keyItem.name} wrapperClassName="mt-2" options={parameters.options} value={form.getValues(keyItem.name)} @@ -164,7 +165,7 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { )} {!!parameters && ( - <Text className="text-text/80 ml-2 mt-2 text-xs">{parameters.description}</Text> + <Text className="text-text/80 ml-1 mt-2 text-xs">{parameters.description}</Text> )} </View> ) From 1e8196aad4037af6c9d5d29bb6361bbb19b5d00c Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Sat, 4 Jan 2025 00:07:32 +0800 Subject: [PATCH 15/70] feat(rn): init follow feed modal Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/src/screens/(modal)/_layout.tsx | 6 ++ apps/mobile/src/screens/(modal)/follow.tsx | 11 +++ .../src/screens/(modal)/rsshub-form.tsx | 67 +++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/screens/(modal)/follow.tsx diff --git a/apps/mobile/src/screens/(modal)/_layout.tsx b/apps/mobile/src/screens/(modal)/_layout.tsx index f1360f5a33..556f4460ef 100644 --- a/apps/mobile/src/screens/(modal)/_layout.tsx +++ b/apps/mobile/src/screens/(modal)/_layout.tsx @@ -15,6 +15,12 @@ export default function ModalLayout() { title: "RSSHub Form", }} /> + <Stack.Screen + name="follow" + options={{ + title: "Follow", + }} + /> </Stack> ) } diff --git a/apps/mobile/src/screens/(modal)/follow.tsx b/apps/mobile/src/screens/(modal)/follow.tsx new file mode 100644 index 0000000000..373ee9bfaa --- /dev/null +++ b/apps/mobile/src/screens/(modal)/follow.tsx @@ -0,0 +1,11 @@ +import { useLocalSearchParams } from "expo-router" +import { Text, View } from "react-native" + +export default function Follow() { + const { url } = useLocalSearchParams() + return ( + <View> + <Text className="text-text">{url}</Text> + </View> + ) +} diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index ae3277be24..e5b1069b47 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -1,5 +1,11 @@ import type { RSSHubParameter, RSSHubParameterObject, RSSHubRoute } from "@follow/models/src/rsshub" -import { parseFullPathParams, parseRegexpPathParams, withOpacity } from "@follow/utils" +import { + MissingOptionalParamError, + parseFullPathParams, + parseRegexpPathParams, + regexpPathToPath, + withOpacity, +} from "@follow/utils" import { PortalProvider } from "@gorhom/portal" import { zodResolver } from "@hookform/resolvers/zod" import { router, Stack, useLocalSearchParams } from "expo-router" @@ -224,7 +230,7 @@ const ScreenOptions = memo(({ name, routeName, route, routePrefix }: ScreenOptio headerLeft: ModalHeaderCloseButton, headerRight: () => ( <FormProvider form={form}> - <ModalHeaderSubmitButton /> + <ModalHeaderSubmitButton routePrefix={routePrefix} route={route} /> </FormProvider> ), @@ -244,15 +250,62 @@ const Title = ({ name, routeName, route, routePrefix }: ScreenOptionsProps) => { ) } -const ModalHeaderSubmitButton = () => { - return <ModalHeaderSubmitButtonImpl /> +type ModalHeaderSubmitButtonProps = { + routePrefix: string + route: string +} +const ModalHeaderSubmitButton = ({ routePrefix, route }: ModalHeaderSubmitButtonProps) => { + return <ModalHeaderSubmitButtonImpl routePrefix={routePrefix} route={route} /> } -const ModalHeaderSubmitButtonImpl = () => { + +const routeParamsKeyPrefix = "route-params-" + +const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitButtonProps) => { const form = useFormContext() const label = useColor("label") const { isValid } = form.formState - const submit = form.handleSubmit((data) => { - void data + const submit = form.handleSubmit((_data) => { + const data = Object.fromEntries( + Object.entries(_data).filter(([key]) => !key.startsWith(routeParamsKeyPrefix)), + ) + + try { + const routeParamsPath = encodeURIComponent( + Object.entries(_data) + .filter(([key, value]) => key.startsWith(routeParamsKeyPrefix) && value) + .map(([key, value]) => [key.slice(routeParamsKeyPrefix.length), value]) + .map(([key, value]) => `${key}=${value}`) + .join("&"), + ) + + const fillRegexpPath = regexpPathToPath( + routeParamsPath ? route.slice(0, route.indexOf("/:routeParams")) : route, + data, + ) + const url = `rsshub://${routePrefix}${fillRegexpPath}` + + const finalUrl = routeParamsPath ? `${url}/${routeParamsPath}` : url + + if (router.canDismiss()) { + router.dismiss() + } + requestAnimationFrame(() => { + router.push({ + pathname: "/follow", + params: { + url: finalUrl, + }, + }) + }) + } catch (err: unknown) { + if (err instanceof MissingOptionalParamError) { + // toast.error(err.message) + // const idx = keys.findIndex((item) => item.name === err.param) + // form.setFocus(keys[idx === 0 ? 0 : idx - 1].name, { + // shouldSelect: true, + // }) + } + } }) return ( From 1de1cd9ae18d89e88805816b7fdea4e1438cd39f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=86=E4=B8=BA=E5=90=9B=E6=95=85?= <prinorange@outlook.com> Date: Mon, 6 Jan 2025 08:59:57 +0800 Subject: [PATCH 16/70] fix: fix decoding error(utf-8,gbk,iso-8859 and other charsets) in readability (issue #2435) (#2449) --- apps/main/package.json | 1 + apps/main/src/lib/readability.ts | 35 +++++++++++++++++++++----------- pnpm-lock.yaml | 30 +++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/apps/main/package.json b/apps/main/package.json index 4df5592b97..d7aab971b1 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -31,6 +31,7 @@ "@openpanel/web": "1.0.1", "@sentry/electron": "5.7.0", "builder-util-runtime": "9.2.10", + "chardet": "^2.0.0", "cookie-es": "^1.2.2", "dompurify": "~3.2.2", "electron-context-menu": "4.0.4", diff --git a/apps/main/src/lib/readability.ts b/apps/main/src/lib/readability.ts index 3dfa9e177e..a67d15ac0f 100644 --- a/apps/main/src/lib/readability.ts +++ b/apps/main/src/lib/readability.ts @@ -1,5 +1,6 @@ import { Readability } from "@mozilla/readability" import { name, version } from "@pkg" +import chardet from "chardet" import DOMPurify from "dompurify" import { parseHTML } from "linkedom" import { fetch } from "ofetch" @@ -21,24 +22,34 @@ function sanitizeHTMLString(dirtyDocumentString: string) { return sanitizedDocumentString } +/** + * Decodes the response body of a `fetch` request into a string, ensuring proper character set handling. + * @throws Will return "Failed to decode response content." if the decoding process encounters any errors. + */ +async function decodeResponseBodyChars(res: Response) { + // Read the response body as an ArrayBuffer + const buffer = await res.arrayBuffer() + // Step 1: Get charset from Content-Type header + const contentType = res.headers.get("content-type") + const httpCharset = contentType?.match(/charset=([\w-]+)/i)?.[1] + // Step 2: Use charset from Content-Type header or fall back to chardet + const detectedCharset = httpCharset || chardet.detect(Buffer.from(buffer)) || "utf-8" + // Step 3: Decode the response body using the detected charset + try { + const decodedText = new TextDecoder(detectedCharset, { fatal: false }).decode(buffer) + return decodedText + } catch { + return "Failed to decode response content." + } +} + export async function readability(url: string) { const dirtyDocumentString = await fetch(url, { headers: { "User-Agent": userAgents, Accept: "text/html", }, - }).then(async (res) => { - const contentType = res.headers.get("content-type") - // text/html; charset=GBK - if (!contentType) return res.text() - const charset = contentType.match(/charset=([a-zA-Z-\d]+)/)?.[1] - if (charset) { - const blob = await res.blob() - const buffer = await blob.arrayBuffer() - return new TextDecoder(charset).decode(buffer) - } - return res.text() - }) + }).then(decodeResponseBodyChars) const sanitizedDocumentString = sanitizeHTMLString(dirtyDocumentString) const baseUrl = new URL(url).origin diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 943839e19d..891cf0c6b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: builder-util-runtime: specifier: 9.2.10 version: 9.2.10 + chardet: + specifier: ^2.0.0 + version: 2.0.0 cookie-es: specifier: ^1.2.2 version: 1.2.2 @@ -5079,24 +5082,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.6.2': resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.6.2': resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.6.2': resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.6.2': resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} @@ -5207,51 +5214,61 @@ packages: resolution: {integrity: sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.28.1': resolution: {integrity: sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.28.1': resolution: {integrity: sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.28.1': resolution: {integrity: sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.28.1': resolution: {integrity: sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.28.1': resolution: {integrity: sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.28.1': resolution: {integrity: sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.28.1': resolution: {integrity: sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.28.1': resolution: {integrity: sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.28.1': resolution: {integrity: sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.28.1': resolution: {integrity: sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==} @@ -6704,6 +6721,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.0.0: + resolution: {integrity: sha512-xVgPpulCooDjY6zH4m9YW3jbkaBe3FKIAvF5sj5t7aBNsVl2ljIE+xwJ4iNgiDZHFQvNIpjdKdVOQvvk5ZfxbQ==} + charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} @@ -10217,48 +10237,56 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.28.2: resolution: {integrity: sha512-nhfjYkfymWZSxdtTNMWyhFk2ImUm0X7NAgJWFwnsYPOfmtWQEapzG/DXZTfEfMjSzERNUNJoQjPAbdqgB+sjiw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.27.0: resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.28.2: resolution: {integrity: sha512-1SPG1ZTNnphWvAv8RVOymlZ8BDtAg69Hbo7n4QxARvkFVCJAt0cgjAw1Fox0WEhf4PwnyoOBaVH0Z5YNgzt4dA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.27.0: resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.28.2: resolution: {integrity: sha512-ZhQy0FcO//INWUdo/iEdbefntTdpPVQ0XJwwtdbBuMQe+uxqZoytm9M+iqR9O5noWFaxK+nbS2iR/I80Q2Ofpg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.27.0: resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.28.2: resolution: {integrity: sha512-alb/j1NMrgQmSFyzTbN1/pvMPM+gdDw7YBuQ5VSgcFDypN3Ah0BzC2dTZbzwzaMdUVDszX6zH5MzjfVN1oGuww==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.27.0: resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==} @@ -21595,6 +21623,8 @@ snapshots: character-reference-invalid@2.0.1: {} + chardet@2.0.0: {} + charenc@0.0.2: {} check-error@2.1.1: {} From dbae887df1fd23a92593682d74907d3851fdf183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E7=A9=BA=E7=9A=84=E7=9B=A1=E9=A0=AD?= <9551552+ghostendsky@users.noreply.github.com> Date: Mon, 6 Jan 2025 09:00:40 +0800 Subject: [PATCH 17/70] feat(locales): update zh-TW translations (#2446) Co-authored-by: ghostendsky <ghostendsky@users.noreply.github.com> --- locales/app/zh-TW.json | 16 +++++++++++ locales/common/zh-TW.json | 1 + locales/errors/zh-TW.json | 6 ++++ locales/external/zh-TW.json | 7 +++-- locales/settings/zh-TW.json | 55 ++++++++++++++++++++++++++++++------ locales/shortcuts/zh-TW.json | 10 +++---- 6 files changed, 78 insertions(+), 17 deletions(-) diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index 931d3fb09f..ae0b0a5a3d 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -228,6 +228,14 @@ "feed_view_type.pictures": "圖片", "feed_view_type.social_media": "社交媒體", "feed_view_type.videos": "影片", + "login.confirm_password.label": "確認密碼", + "login.continueWith": "透過 {{provider}} 登入", + "login.email": "電子信箱", + "login.forget_password.note": "忘記密碼了嗎?", + "login.password": "密碼", + "login.signUp": "使用電子信箱註冊", + "login.submit": "送出", + "login.with_email.title": "使用電子信箱登入", "mark_all_read_button.auto_confirm_info": "將在 {{countdown}} 秒後自動確認。", "mark_all_read_button.confirm": "確認", "mark_all_read_button.confirm_mark_all": "將 <which /> 標記為已讀?", @@ -287,6 +295,13 @@ "player.volume": "音量", "quick_add.placeholder": "在此輸入訂閱源網址已快速訂閱...", "quick_add.title": "快速訂閱", + "register.confirm_password": "確認密碼", + "register.email": "電子信箱", + "register.label": "創建 {{app_name}} 帳號", + "register.login": "登入", + "register.note": "已經有帳號了嗎? <LoginLink />", + "register.password": "密碼", + "register.submit": "創建帳號", "resize.tooltip.double_click_to_collapse": "<b>雙擊</b>以摺疊", "resize.tooltip.drag_to_resize": "<b>拖動</b>以調整大小", "search.empty.no_results": "未找到結果。", @@ -393,6 +408,7 @@ "words.browser": "瀏覽器", "words.confirm": "確認", "words.discover": "發現", + "words.email": "電子信箱", "words.feeds": "摘要", "words.import": "匯入", "words.inbox": "收件匣", diff --git a/locales/common/zh-TW.json b/locales/common/zh-TW.json index abc3a15650..e055cb6982 100644 --- a/locales/common/zh-TW.json +++ b/locales/common/zh-TW.json @@ -36,6 +36,7 @@ "words.result": "結果", "words.result_one": "結果", "words.result_other": "結果", + "words.rsshub": "RSSHub", "words.save": "儲存", "words.submit": "提交", "words.update": "更新", diff --git a/locales/errors/zh-TW.json b/locales/errors/zh-TW.json index 328614a4b3..dabecc951c 100644 --- a/locales/errors/zh-TW.json +++ b/locales/errors/zh-TW.json @@ -52,5 +52,11 @@ "10002": "超過收件匣限制", "10003": "收件匣無權限", "11000": "RSSHub 路由不存在", + "11001": "你不是此 RSSHub 實例伺服器的建立者", + "11002": "RSSHub 伺服器正在使用中", + "11003": "未找到 RSSHub 伺服器", + "11004": "已超過 RSSHub 伺服器使用者限制", + "11005": "未找到 RSSHub 伺服器贊助紀錄", + "11006": "RSSHub 設定無效", "12000": "超過自動化規則限制" } diff --git a/locales/external/zh-TW.json b/locales/external/zh-TW.json index 0897ab11f5..14af2c5435 100644 --- a/locales/external/zh-TW.json +++ b/locales/external/zh-TW.json @@ -48,7 +48,7 @@ "login.or": "或", "login.password": "密碼", "login.redirecting": "正在重定向", - "login.register": "註冊一個新帳號", + "login.register": "創建帳號", "login.reset_password.description": "輸入新密碼並確認以重設您的密碼", "login.reset_password.label": "重設密碼", "login.reset_password.success": "密碼已成功重設", @@ -62,9 +62,10 @@ "redirect.successMessage": "您已成功連接至 {{app_name}} 帳戶。", "register.confirm_password": "確認密碼", "register.email": "電子信箱", - "register.label": "創建一個 {{app_name}} 帳號", + "register.label": "創建 {{app_name}} 帳號", "register.login": "登入", "register.note": "已經有帳號了嗎? <LoginLink />", "register.password": "密碼", - "register.submit": "創建帳號" + "register.submit": "創建帳號", + "words.email": "電子信箱" } diff --git a/locales/settings/zh-TW.json b/locales/settings/zh-TW.json index cc903d1904..2903e50b68 100644 --- a/locales/settings/zh-TW.json +++ b/locales/settings/zh-TW.json @@ -5,7 +5,7 @@ "about.licenseInfo": "版權所有 © 2024 {{appName}}。保留所有權利。", "about.sidebar_title": "關於", "about.socialMedia": "社群媒體", - "actions.actionName": "自動化操作 {{number}}", + "actions.actionName": "規則 {{number}}", "actions.action_card.add": "新增", "actions.action_card.all": "全部", "actions.action_card.and": "和", @@ -49,9 +49,9 @@ "actions.action_card.when_feeds_match": "當摘要匹配時…", "actions.newRule": "新增規則", "actions.save": "儲存", - "actions.saveSuccess": "🎉 操作已儲存。", - "actions.sidebar_title": "操作", - "actions.title": "操作", + "actions.saveSuccess": "🎉 規則已儲存。", + "actions.sidebar_title": "自動化操作", + "actions.title": "自動化操作", "appearance.code_highlight_theme": "語法突顯主題", "appearance.content": "內容", "appearance.content_font": "內容字體", @@ -103,7 +103,7 @@ "data_control.clean_cache.description": "清理程式快取以釋放空間。", "data_control.clean_cache.description_web": "清理網頁應用服務快取以釋放空間。", "feeds.claimTips": "要認領您的 feed 並接收贊助,請在您的訂閱列表中右鍵點擊該 feed,然後選擇「認領」。", - "feeds.noFeeds": "無認領的 feed", + "feeds.noFeeds": "沒有已認領的訂閱源", "feeds.tableHeaders.name": "名稱", "feeds.tableHeaders.subscriptionCount": "訂閱數", "feeds.tableHeaders.tipAmount": "收到的贊助", @@ -118,8 +118,14 @@ "general.data_persist.description": "本機儲存資料以啟用離線瀏覽和離線搜尋。", "general.data_persist.label": "離線時儲存資料", "general.export.button": "匯出", - "general.export.description": "匯出你的訂閱到 OPML 文件", - "general.export.label": "匯出訂閱", + "general.export.description": "匯出你的訂閱到 OPML 文件。", + "general.export.folder_mode.description": "決定您想要如何組織匯出資料夾。", + "general.export.folder_mode.label": "資料夾模式", + "general.export.folder_mode.option.category": "類別", + "general.export.folder_mode.option.view": "視圖", + "general.export.label": "匯出訂閱源", + "general.export.rsshub_url.description": "RSSHub 路由的預設基礎 URL,留空則使用 https://rsshub.app。", + "general.export.rsshub_url.label": "RSSHub URL", "general.export_database.button": "匯出", "general.export_database.description": "将你的資料庫匯出成 JSON 檔案。", "general.export_database.label": "匯出資料庫", @@ -209,7 +215,7 @@ "invitation.confirmModal.continue": "繼續", "invitation.confirmModal.message": "產生邀請碼將會花費您 {{INVITATION_PRICE}} <PowerIcon>Power</PowerIcon>。", "invitation.confirmModal.title": "確認", - "invitation.earlyAccess": "Follow 目前處於 <strong>早期開發</strong> 狀態,需要邀請碼才能使用。", + "invitation.earlyAccess": "Follow 目前處於<strong>早期開發</strong>狀態,需要邀請碼才能使用。", "invitation.earlyAccessMessage": "😰 抱歉,關注目前處於搶先體驗階段,需要邀請碼才能使用。", "invitation.generateButton": "產生邀請碼", "invitation.generateCost": "您可以花費 {{INVITATION_PRICE}} <PowerIcon>Power</PowerIcon> 為您的朋友產生邀請碼。", @@ -260,6 +266,9 @@ "profile.change_password.label": "修改密碼", "profile.confirm_password.label": "確認密碼", "profile.current_password.label": "目前密碼", + "profile.email.change": "變更電子信箱", + "profile.email.changed": "電子信箱已變更。", + "profile.email.changed_verification_sent": "已發送驗證新電子信箱的信件。", "profile.email.label": "電子信箱", "profile.email.send_verification": "發送驗證信件", "profile.email.unverified": "尚未驗證", @@ -267,15 +276,42 @@ "profile.email.verified": "已驗證", "profile.handle.description": "唯一識別碼", "profile.handle.label": "唯一識別碼", + "profile.link_social.authentication": "驗證", + "profile.link_social.description": "您目前只能連結具有相同電子信箱的社群帳號。", + "profile.link_social.link": "連結", + "profile.link_social.unlink.success": "已中斷社群帳號連結。", "profile.name.description": "您的公開顯示名稱。", "profile.name.label": "顯示名稱", "profile.new_password.label": "新密碼", + "profile.password.label": "密碼", "profile.reset_password_mail_sent": "重設密碼信件已發送。", "profile.sidebar_title": "個人資料", "profile.submit": "送出", "profile.title": "配置文件設定", "profile.updateSuccess": "個人資料已更新。", "profile.update_password_success": "密碼已更新。", + "rsshub.addModal.access_key_label": "存取金鑰(選填)", + "rsshub.addModal.add": "新增", + "rsshub.addModal.base_url_label": "基礎 URL", + "rsshub.addModal.description": "要在 Follow 中使用自己的 RSSHub 實例伺服器,必須將以下環境變數新增到伺服器中。", + "rsshub.add_new_instance": "新增 RSSHub 實例伺服器", + "rsshub.description": "RSSHub 是由社群驅動的開源 RSS 網路。Follow 提供了內建的專用實例伺服器來支持數以千計的訂閱內容,你也可以通過使用自己的或第三方的實例伺服器來實現更穩定的內容獲取。", + "rsshub.public_instances": "實例伺服器", + "rsshub.table.description": "描述", + "rsshub.table.edit": "編輯", + "rsshub.table.inuse": "使用中", + "rsshub.table.owner": "建立者", + "rsshub.table.price": "每月價格", + "rsshub.table.unlimited": "無限制", + "rsshub.table.use": "使用", + "rsshub.table.userCount": "使用者數量", + "rsshub.table.userLimit": "使用者限制", + "rsshub.useModal.about": "關於此實例伺服器", + "rsshub.useModal.month": "個月", + "rsshub.useModal.months_label": "你想訂閱的月份數量", + "rsshub.useModal.purchase_expires_at": "你已訂閱此實例伺服器,到期時間為", + "rsshub.useModal.title": "RSSHub 實例伺服器", + "rsshub.useModal.useWith": "使用 {{amount}} <Power />", "titles.about": "關於", "titles.actions": "自動化操作", "titles.appearance": "外觀", @@ -343,5 +379,6 @@ "wallet.withdraw.error": "提取失敗:{{error}}", "wallet.withdraw.modalTitle": "提取 Power", "wallet.withdraw.submitButton": "送出", - "wallet.withdraw.success": "提取成功!" + "wallet.withdraw.success": "提取成功!", + "wallet.withdraw.toRss3Label": "提取為 RSS3" } diff --git a/locales/shortcuts/zh-TW.json b/locales/shortcuts/zh-TW.json index 8fc30b73b1..e58edcae39 100644 --- a/locales/shortcuts/zh-TW.json +++ b/locales/shortcuts/zh-TW.json @@ -3,7 +3,7 @@ "keys.entries.markAllAsRead": "全部標記為已讀", "keys.entries.next": "下一條目", "keys.entries.previous": "上一條目", - "keys.entries.refetch": "重新獲取", + "keys.entries.refetch": "重新整理", "keys.entries.toggleUnreadOnly": "切換僅顯示未讀", "keys.entry.copyLink": "複製連結", "keys.entry.copyTitle": "複製標題", @@ -12,13 +12,13 @@ "keys.entry.scrollDown": "向下捲動", "keys.entry.scrollUp": "向上捲動", "keys.entry.share": "分享", - "keys.entry.tip": "贊助 Power", - "keys.entry.toggleRead": "切換已讀/未讀", + "keys.entry.tip": "贊助", + "keys.entry.toggleRead": "切換標記為已讀/未讀", "keys.entry.toggleStarred": "切換收藏/取消收藏", "keys.entry.tts": "播放語音朗讀", "keys.feeds.add": "新增訂閱", - "keys.feeds.switchBetweenViews": "切換視圖", - "keys.feeds.switchToView": "切換到視圖", + "keys.feeds.switchBetweenViews": "在類別之間切換", + "keys.feeds.switchToView": "切換到指定類別", "keys.layout.showShortcuts": "顯示/隱藏快捷鍵", "keys.layout.toggleSidebar": "顯示/隱藏摘要側邊欄", "keys.layout.toggleWideMode": "切換寬屏模式", From b4edc426df34549dbb870b13ab3a8ca949eedd6b Mon Sep 17 00:00:00 2001 From: Cesaryuan <35998162+cesaryuan@users.noreply.github.com> Date: Mon, 6 Jan 2025 09:01:44 +0800 Subject: [PATCH 18/70] fix: tray icon appears small and blurry on Windows, Close #2077 (#2461) --- apps/main/src/helper.ts | 2 +- apps/main/src/lib/tray.ts | 2 +- resources/icon-no-padding.ico | Bin 0 -> 410598 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 resources/icon-no-padding.ico diff --git a/apps/main/src/helper.ts b/apps/main/src/helper.ts index 28bf1885aa..618fe834ec 100644 --- a/apps/main/src/helper.ts +++ b/apps/main/src/helper.ts @@ -15,7 +15,7 @@ export const getTrayIconPath = () => { } if (isWindows) { // https://www.electronjs.org/docs/latest/api/tray#:~:text=Windows,best%20visual%20effects. - return path.join(__dirname, "../../resources/icon.ico") + return path.join(__dirname, "../../resources/icon-no-padding.ico") } return getIconPath() } diff --git a/apps/main/src/lib/tray.ts b/apps/main/src/lib/tray.ts index 43683dc3b2..60c83612bb 100644 --- a/apps/main/src/lib/tray.ts +++ b/apps/main/src/lib/tray.ts @@ -20,7 +20,7 @@ export const registerAppTray = () => { const icon = nativeImage.createFromPath(getTrayIconPath()) // See https://stackoverflow.com/questions/41664208/electron-tray-icon-change-depending-on-dark-theme/41998326#41998326 - const trayIcon = icon.resize({ width: 16 }) + const trayIcon = isMacOS ? icon.resize({ width: 16 }) : icon trayIcon.setTemplateImage(true) tray = new Tray(trayIcon) diff --git a/resources/icon-no-padding.ico b/resources/icon-no-padding.ico new file mode 100644 index 0000000000000000000000000000000000000000..c3ec37b528b3cbdc0350b2fc6b1e3bfc4f896b52 GIT binary patch literal 410598 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 O6rcbFC_sU~348-?&$^WW literal 0 HcmV?d00001 From 99011ebcf2cd3c4096520394537bb97b5ea08865 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Mon, 6 Jan 2025 14:13:20 +0800 Subject: [PATCH 19/70] feat: enhance table with new translations and tooltip for descriptions (#2471) --- .../(main)/(layer)/(subview)/rsshub/index.tsx | 15 +++++++++------ locales/settings/en.json | 2 ++ locales/settings/zh-CN.json | 2 ++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx b/apps/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx index 42fc7940bb..03a0a129d6 100644 --- a/apps/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx +++ b/apps/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx @@ -8,6 +8,7 @@ import { TableHeader, TableRow, } from "@follow/components/ui/table/index.jsx" +import { EllipsisTextWithTooltip } from "@follow/components/ui/typography/index.js" import type { RSSHubModel } from "@follow/models" import { useTranslation } from "react-i18next" @@ -68,7 +69,7 @@ function List({ data }: { data?: RSSHubModel[] }) { <Table containerClassName="mt-2 overflow-x-auto"> <TableHeader> <TableRow> - <TableHead className="w-[50px] font-bold" size="sm" /> + <TableHead className="font-bold" size="sm" /> <TableHead className="w-[150px] font-bold" size="sm"> {t("rsshub.table.owner")} </TableHead> @@ -89,7 +90,7 @@ function List({ data }: { data?: RSSHubModel[] }) { </TableHeader> <TableBody className="border-t-[12px] border-transparent [&_td]:!px-3"> <TableRow> - <TableCell className="font-bold">Official</TableCell> + <TableCell className="text-nowrap font-bold">{t("rsshub.table.official")}</TableCell> <TableCell> <span className="flex items-center gap-2"> <Logo className="size-6" /> @@ -120,14 +121,14 @@ function List({ data }: { data?: RSSHubModel[] }) { {data?.map((instance) => { return ( <TableRow key={instance.id}> - <TableCell> + <TableCell className="text-nowrap"> {(() => { const flag: string[] = [] if (status?.data?.usage?.rsshubId === instance.id) { - flag.push("In use") + flag.push(t("rsshub.table.inuse")) } if (instance.ownerUserId === me?.id) { - flag.push("Yours") + flag.push(t("rsshub.table.yours")) } return flag.join(" / ") })()} @@ -140,7 +141,9 @@ function List({ data }: { data?: RSSHubModel[] }) { /> </TableCell> <TableCell> - <div className="line-clamp-2">{instance.description}</div> + <EllipsisTextWithTooltip className="line-clamp-2"> + {instance.description} + </EllipsisTextWithTooltip> </TableCell> <TableCell> <span className="flex items-center justify-end gap-1"> diff --git a/locales/settings/en.json b/locales/settings/en.json index a7e1900908..c377b5d8aa 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -300,12 +300,14 @@ "rsshub.table.description": "Description", "rsshub.table.edit": "Edit", "rsshub.table.inuse": "In Use", + "rsshub.table.official": "Official", "rsshub.table.owner": "Owner", "rsshub.table.price": "Monthly Price", "rsshub.table.unlimited": "Unlimited", "rsshub.table.use": "Use", "rsshub.table.userCount": "User Count", "rsshub.table.userLimit": "User Limit", + "rsshub.table.yours": "Yours", "rsshub.useModal.about": "About this Instance", "rsshub.useModal.month": "month", "rsshub.useModal.months_label": "The number of months you want to purchase", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 21126bd64b..d05bc109c9 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -300,12 +300,14 @@ "rsshub.table.description": "描述", "rsshub.table.edit": "编辑", "rsshub.table.inuse": "使用中", + "rsshub.table.official": "官方", "rsshub.table.owner": "所有者", "rsshub.table.price": "月度价格", "rsshub.table.unlimited": "无限制", "rsshub.table.use": "使用", "rsshub.table.userCount": "用户数量", "rsshub.table.userLimit": "用户限制", + "rsshub.table.yours": "你的", "rsshub.useModal.about": "关于此实例", "rsshub.useModal.month": "个月", "rsshub.useModal.months_label": "你想购买的月份数量", From b46e1780f783c24e5706b9c350b980c62a3cd9c6 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Mon, 6 Jan 2025 14:26:43 +0800 Subject: [PATCH 20/70] feat: enhance deep link handling in FollowWebView component to support new routes for adding and following items Signed-off-by: Innei <tukon479@gmail.com> --- .../src/components/common/FollowWebView.tsx | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/components/common/FollowWebView.tsx b/apps/mobile/src/components/common/FollowWebView.tsx index f960cfcec6..474f2fba20 100644 --- a/apps/mobile/src/components/common/FollowWebView.tsx +++ b/apps/mobile/src/components/common/FollowWebView.tsx @@ -117,24 +117,35 @@ const useDeepLink = ({ }) => { const handleDeepLink = useCallback( async (url: string) => { - const { queryParams } = Linking.parse(url) - if (!queryParams) { - console.error("Invalid URL! queryParams is not available", url) - return - } - const id = queryParams["id"] ?? undefined - const isList = queryParams["type"] === "list" - // const urlParam = queryParams["url"] ?? undefined - if (!id || typeof id !== "string") { - console.error("Invalid URL! id is not a string", url) - return - } - const injectJavaScript = webViewRef.current?.injectJavaScript - if (!injectJavaScript) { - console.error("injectJavaScript is not available") - return + const { queryParams, path, hostname } = Linking.parse(url) + + const pathname = (hostname || "") + (path || "") + const pathnameTrimmed = pathname?.endsWith("/") ? pathname.slice(0, -1) : pathname + + switch (pathnameTrimmed) { + case "/add": + case "/follow": { + if (!queryParams) { + console.error("Invalid URL! queryParams is not available", url) + return + } + + const id = queryParams["id"] ?? undefined + const isList = queryParams["type"] === "list" + // const urlParam = queryParams["url"] ?? undefined + if (!id || typeof id !== "string") { + console.error("Invalid URL! id is not a string", url) + return + } + const injectJavaScript = webViewRef.current?.injectJavaScript + if (!injectJavaScript) { + console.error("injectJavaScript is not available") + return + } + callWebviewExpose(injectJavaScript).follow({ id, isList }) + return + } } - callWebviewExpose(injectJavaScript).follow({ id, isList }) }, [webViewRef], ) From ff1dc03304e1d4911c5ae7df34b793dc4132bce3 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Mon, 6 Jan 2025 17:57:24 +0800 Subject: [PATCH 21/70] fix: error and warning in mobile (#2475) --- apps/renderer/src/modules/entry-column/list.tsx | 1 + packages/components/src/ui/sheet/Sheet.tsx | 1 + packages/components/src/utils/selector.tsx | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/renderer/src/modules/entry-column/list.tsx b/apps/renderer/src/modules/entry-column/list.tsx index bd102c9ae0..9f0181e54f 100644 --- a/apps/renderer/src/modules/entry-column/list.tsx +++ b/apps/renderer/src/modules/entry-column/list.tsx @@ -233,6 +233,7 @@ export const EntryList: FC<EntryListProps> = memo( ref={rowVirtualizer.measureElement} className="absolute left-0 top-0 w-full will-change-transform" key={virtualRow.key} + data-index={virtualRow.index} style={{ transform, }} diff --git a/packages/components/src/ui/sheet/Sheet.tsx b/packages/components/src/ui/sheet/Sheet.tsx index 3b03fd14e8..1f3d6ef435 100644 --- a/packages/components/src/ui/sheet/Sheet.tsx +++ b/packages/components/src/ui/sheet/Sheet.tsx @@ -110,6 +110,7 @@ export const PresentSheet = forwardRef<SheetRef, PropsWithChildren<PresentSheetP style={{ zIndex: contentZIndex, }} + aria-describedby={undefined} className={cn( "fixed inset-x-0 bottom-0 flex max-h-[calc(100svh-3rem)] flex-col rounded-t-[10px] border-t bg-theme-modal-background-opaque pt-4", modalClassName, diff --git a/packages/components/src/utils/selector.tsx b/packages/components/src/utils/selector.tsx index 943c97b1a2..d8673d4545 100644 --- a/packages/components/src/utils/selector.tsx +++ b/packages/components/src/utils/selector.tsx @@ -26,12 +26,12 @@ export function withResponsiveSyncComponent<P extends object, R = any>( ) { return forwardRef<R, P>(function ResponsiveLayout(props: PropsWithoutRef<P>, ref) { const isMobile = useMobile() - const componentProps = { ...props, ref } as P & RefAttributes<R> + const componentProps = { ...props } as P & RefAttributes<R> return isMobile ? ( <MobileComponent {...componentProps} /> ) : ( - <DesktopComponent {...componentProps} /> + <DesktopComponent {...componentProps} ref={ref} /> ) }) } From 3cca62c26f83aa99c2cd1793f17f8b929b46b588 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Mon, 6 Jan 2025 20:01:44 +0800 Subject: [PATCH 22/70] feat: update mobile app structure and enhance loading functionality Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/package.json | 8 ++- apps/mobile/src/atoms/app.ts | 8 ++- .../components/common/LoadingContainer.tsx | 68 ++++++++++++------- apps/mobile/src/hooks/useLoadingCallback.tsx | 12 +++- apps/mobile/src/main.ts | 5 -- apps/mobile/src/main.tsx | 12 ++++ .../src/modules/discover/content-selector.tsx | 5 -- .../src/modules/discover/recommendations.tsx | 2 + apps/mobile/src/screens/(headless)/debug.tsx | 1 + .../src/screens/(modal)/rsshub-form.tsx | 35 ++++++---- .../src/screens/(stack)/(tabs)/discover.tsx | 4 +- apps/mobile/src/screens/_layout.tsx | 2 +- apps/mobile/src/services/feed.ts | 2 +- apps/mobile/src/store/feed/store.ts | 58 ++++++++++++---- apps/mobile/src/store/feed/types.ts | 5 ++ .../src/modules/discover/DiscoverFeedForm.tsx | 2 +- pnpm-lock.yaml | 25 +------ 17 files changed, 162 insertions(+), 92 deletions(-) delete mode 100644 apps/mobile/src/main.ts create mode 100644 apps/mobile/src/main.tsx delete mode 100644 apps/mobile/src/modules/discover/content-selector.tsx create mode 100644 apps/mobile/src/store/feed/types.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c4c809891f..f1a9e9a3e9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -2,7 +2,7 @@ "name": "@follow/mobile", "version": "1.0.0", "private": true, - "main": "src/main.ts", + "main": "src/main.tsx", "scripts": { "android": "expo run:android", "db:generate": "drizzle-kit generate", @@ -57,6 +57,7 @@ "hono": "4.6.13", "immer": "10.1.1", "jotai": "2.10.3", + "nanoid": "5.0.9", "nativewind": "4.1.23", "ofetch": "1.4.1", "react": "^18.3.1", @@ -90,5 +91,10 @@ "expo-drizzle-studio-plugin": "0.1.1", "postcss": "8.4.49", "react-test-renderer": "18.3.1" + }, + "codegenConfig": { + "name": "FullscreenPortalManager", + "type": "modules", + "jsSrcsDir": "./specs" } } diff --git a/apps/mobile/src/atoms/app.ts b/apps/mobile/src/atoms/app.ts index 74da5bd6ad..8cd6b4801b 100644 --- a/apps/mobile/src/atoms/app.ts +++ b/apps/mobile/src/atoms/app.ts @@ -3,11 +3,15 @@ import { atom } from "jotai" export const loadingVisibleAtom = atom(false) export const loadingAtom = atom<{ - finish: null | (() => any) - cancel: null | (() => any) + finish?: null | (() => any) + cancel?: null | (() => any) + error?: null | ((err: any) => any) + done?: null | ((r: unknown) => any) thenable: null | Promise<any> }>({ finish: null, cancel: null, + error: null, + done: null, thenable: null, }) diff --git a/apps/mobile/src/components/common/LoadingContainer.tsx b/apps/mobile/src/components/common/LoadingContainer.tsx index ea5f866dd1..097f961d7c 100644 --- a/apps/mobile/src/components/common/LoadingContainer.tsx +++ b/apps/mobile/src/components/common/LoadingContainer.tsx @@ -1,8 +1,10 @@ import { useAtom } from "jotai" import { useCallback, useEffect, useRef, useState } from "react" -import { Modal, Text, TouchableOpacity, View } from "react-native" +import { Text, TouchableOpacity, View } from "react-native" import Animated, { Easing, + FadeIn, + FadeOut, useAnimatedStyle, useSharedValue, withRepeat, @@ -12,6 +14,8 @@ import Animated, { import { loadingAtom, loadingVisibleAtom } from "@/src/atoms/app" import { Loading3CuteReIcon } from "@/src/icons/loading_3_cute_re" +import { BlurEffect } from "./HeaderBlur" + export const LoadingContainer = () => { const rotate = useSharedValue(0) @@ -25,6 +29,8 @@ export const LoadingContainer = () => { finish: null, cancel: null, thenable: null, + done: null, + error: null, }) }, [setLoadingCaller]) @@ -41,16 +47,23 @@ export const LoadingContainer = () => { useEffect(() => { if (loadingCaller.thenable) { - loadingCaller.thenable.finally(() => { - setVisible(false) - setShowCancelButton(false) + loadingCaller.thenable + .then((r) => { + loadingCaller.done?.(r) + }) + .catch((err) => { + loadingCaller.error?.(err) + }) + .finally(() => { + setVisible(false) + setShowCancelButton(false) - resetLoadingCaller() + resetLoadingCaller() - loadingCaller.finish?.() - }) + loadingCaller.finish?.() + }) } - }, [loadingCaller.thenable]) + }, [loadingCaller.thenable, loadingCaller.done, loadingCaller.error, loadingCaller.finish]) const cancelTimerRef = useRef<NodeJS.Timeout | null>(null) useEffect(() => { @@ -78,23 +91,30 @@ export const LoadingContainer = () => { resetLoadingCaller() } - return ( - <Modal visible={visible} animationType="fade" transparent> - <View className="flex-1 items-center justify-center bg-black/30"> - <View className="border-system-fill/40 rounded-2xl border bg-black/50 p-12 drop-shadow dark:bg-white/5"> - <Animated.View style={rotateStyle}> - <Loading3CuteReIcon height={36} width={36} color="#fff" /> - </Animated.View> - </View> + if (!visible) { + return null + } - {showCancelButton && ( - <View className="absolute inset-x-0 bottom-24 flex-row justify-center"> - <TouchableOpacity onPress={cancel}> - <Text className="text-center text-lg text-accent">Cancel</Text> - </TouchableOpacity> - </View> - )} + return ( + <Animated.View + entering={FadeIn} + exiting={FadeOut} + className="absolute inset-0 flex-1 items-center justify-center" + > + <View className="border-system-fill/40 relative rounded-2xl border p-12"> + <BlurEffect /> + <Animated.View style={rotateStyle}> + <Loading3CuteReIcon height={36} width={36} color="#fff" /> + </Animated.View> </View> - </Modal> + + {showCancelButton && ( + <View className="absolute inset-x-0 bottom-24 flex-row justify-center"> + <TouchableOpacity onPress={cancel}> + <Text className="text-center text-lg text-accent">Cancel</Text> + </TouchableOpacity> + </View> + )} + </Animated.View> ) } diff --git a/apps/mobile/src/hooks/useLoadingCallback.tsx b/apps/mobile/src/hooks/useLoadingCallback.tsx index f4980622e0..08178d6508 100644 --- a/apps/mobile/src/hooks/useLoadingCallback.tsx +++ b/apps/mobile/src/hooks/useLoadingCallback.tsx @@ -8,11 +8,21 @@ export const useLoadingCallback = () => { const setVisible = useSetAtom(loadingVisibleAtom) return useCallback( - (thenable: Promise<any>, options: { finish: () => any; cancel: () => any }) => { + ( + thenable: Promise<any>, + options: Partial<{ + finish: () => any + cancel: () => any + done: (r: unknown) => any + error: (err: any) => any + }>, + ) => { setLoadingCaller({ thenable, finish: options.finish, cancel: options.cancel, + done: options.done, + error: options.error, }) setVisible(true) }, diff --git a/apps/mobile/src/main.ts b/apps/mobile/src/main.ts deleted file mode 100644 index 15ceea7665..0000000000 --- a/apps/mobile/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { initializeApp } from "./initialize" - -initializeApp().then(() => { - require("expo-router/entry") -}) diff --git a/apps/mobile/src/main.tsx b/apps/mobile/src/main.tsx new file mode 100644 index 0000000000..5ebc2d8a8d --- /dev/null +++ b/apps/mobile/src/main.tsx @@ -0,0 +1,12 @@ +import "@expo/metro-runtime" + +import { App } from "expo-router/build/qualified-entry" +import { renderRootComponent } from "expo-router/build/renderRootComponent" + +import { initializeApp } from "./initialize" + +initializeApp().then(() => { + // This file should only import and register the root. No components or exports + // should be added here. + renderRootComponent(App) +}) diff --git a/apps/mobile/src/modules/discover/content-selector.tsx b/apps/mobile/src/modules/discover/content-selector.tsx deleted file mode 100644 index a5625cb290..0000000000 --- a/apps/mobile/src/modules/discover/content-selector.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Recommendations } from "./recommendations" - -export const DiscoverContentSelector = () => { - return <Recommendations /> -} diff --git a/apps/mobile/src/modules/discover/recommendations.tsx b/apps/mobile/src/modules/discover/recommendations.tsx index fda55fbe1f..e81c6cefdf 100644 --- a/apps/mobile/src/modules/discover/recommendations.tsx +++ b/apps/mobile/src/modules/discover/recommendations.tsx @@ -152,11 +152,13 @@ const Tab: TabComponent = ({ tab }) => { return ( <View className="bg-system-background flex-1"> <FlashList + estimatedItemSize={150} ref={listRef} data={alphabetGroups} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={ItemRenderer} + scrollIndicatorInsets={{ right: -2 }} contentContainerStyle={{ paddingBottom: tabHeight }} removeClippedSubviews /> diff --git a/apps/mobile/src/screens/(headless)/debug.tsx b/apps/mobile/src/screens/(headless)/debug.tsx index cfabb4f426..ba75dc77b5 100644 --- a/apps/mobile/src/screens/(headless)/debug.tsx +++ b/apps/mobile/src/screens/(headless)/debug.tsx @@ -84,6 +84,7 @@ export default function DebugPanel() { }, ], }, + { title: "App", items: [ diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index e5b1069b47..dd0c56c5de 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -21,7 +21,9 @@ import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvi 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 { useLoadingCallback } from "@/src/hooks/useLoadingCallback" import { CheckLineIcon } from "@/src/icons/check_line" +import { feedSyncServices } from "@/src/store/feed/store" import { useColor } from "@/src/theme/colors" interface RsshubFormParams { @@ -179,12 +181,14 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { </View> <Maintainers maintainers={route.maintainers} /> - <View className="mx-4 mt-4"> - <MarkdownWeb - value={route.description.replaceAll("::: ", ":::")} - dom={{ matchContents: true, scrollEnabled: false }} - /> - </View> + {!!route.description && ( + <View className="mx-4 mt-4"> + <MarkdownWeb + value={route.description.replaceAll("::: ", ":::")} + dom={{ matchContents: true, scrollEnabled: false }} + /> + </View> + )} </KeyboardAwareScrollView> </PortalProvider> </FormProvider> @@ -264,6 +268,8 @@ const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitBu const form = useFormContext() const label = useColor("label") const { isValid } = form.formState + + const loadingFn = useLoadingCallback() const submit = form.handleSubmit((_data) => { const data = Object.fromEntries( Object.entries(_data).filter(([key]) => !key.startsWith(routeParamsKeyPrefix)), @@ -289,13 +295,16 @@ const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitBu if (router.canDismiss()) { router.dismiss() } - requestAnimationFrame(() => { - router.push({ - pathname: "/follow", - params: { - url: finalUrl, - }, - }) + + loadingFn(feedSyncServices.fetchFeedById({ url: finalUrl }), { + finish: () => { + router.push({ + pathname: "/follow", + params: { + url: finalUrl, + }, + }) + }, }) } catch (err: unknown) { if (err instanceof MissingOptionalParamError) { diff --git a/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx b/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx index b95bd541c5..a7a0ca66d5 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx @@ -1,6 +1,6 @@ import { Stack } from "expo-router" -import { DiscoverContentSelector } from "@/src/modules/discover/content-selector" +import { Recommendations } from "@/src/modules/discover/recommendations" import { DiscoverHeader } from "@/src/modules/discover/search" export default function Discover() { @@ -15,7 +15,7 @@ export default function Discover() { }} /> - <DiscoverContentSelector /> + <Recommendations /> </> ) } diff --git a/apps/mobile/src/screens/_layout.tsx b/apps/mobile/src/screens/_layout.tsx index 04d773adc9..6dc4754a10 100644 --- a/apps/mobile/src/screens/_layout.tsx +++ b/apps/mobile/src/screens/_layout.tsx @@ -17,7 +17,6 @@ export default function RootLayout() { return ( <RootProviders> <Session /> - <LoadingContainer /> <Stack screenOptions={{ contentStyle: { backgroundColor: systemBackgroundColor }, @@ -30,6 +29,7 @@ export default function RootLayout() { </Stack> {__DEV__ && <DebugButton />} + <LoadingContainer /> </RootProviders> ) } diff --git a/apps/mobile/src/services/feed.ts b/apps/mobile/src/services/feed.ts index c69be703a4..5c7174230a 100644 --- a/apps/mobile/src/services/feed.ts +++ b/apps/mobile/src/services/feed.ts @@ -22,7 +22,7 @@ class FeedServiceStatic implements Hydratable, Resetable { async hydrate() { const feeds = await db.query.feedsTable.findMany() - feedActions.upsertMany(feeds) + feedActions.upsertManyInSession(feeds) } } diff --git a/apps/mobile/src/store/feed/store.ts b/apps/mobile/src/store/feed/store.ts index e4ff05e3f4..af209db18c 100644 --- a/apps/mobile/src/store/feed/store.ts +++ b/apps/mobile/src/store/feed/store.ts @@ -1,10 +1,14 @@ +import { nanoid } from "nanoid" + import type { FeedSchema } from "@/src/database/schemas/types" +import { apiClient } from "@/src/lib/api-fetch" import { FeedService } from "@/src/services/feed" import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper" +import type { FeedModel } from "./types" interface FeedState { - feeds: Record<string, FeedSchema> + feeds: Record<string, FeedModel> } export const useFeedStore = createZustandStore<FeedState>("feed")(() => ({ @@ -20,16 +24,20 @@ class FeedActions { set({ feeds: {} }) } + upsertManyInSession(feeds: FeedSchema[]) { + immerSet((draft) => { + for (const feed of feeds) { + draft.feeds[feed.id] = feed + } + }) + } + upsertMany(feeds: FeedSchema[]) { if (feeds.length === 0) return const tx = createTransaction() tx.store(() => { - immerSet((draft) => { - for (const feed of feeds) { - draft.feeds[feed.id] = feed - } - }) + this.upsertManyInSession(feeds) }) tx.persist(async () => { @@ -40,14 +48,6 @@ class FeedActions { } private patch(feedId: string, patch: Partial<FeedSchema>) { - // set((state) => - // produce(state, (state) => { - // const feed = state.feeds[feedId] - // if (!feed) return - - // Object.assign(feed, patch) - // }), - // ) immerSet((state) => { const feed = state.feeds[feedId] if (!feed) return @@ -55,4 +55,34 @@ class FeedActions { }) } } + +type FeedQueryParams = { + id?: string + url?: string +} + +class FeedSyncServices { + async fetchFeedById({ id, url }: FeedQueryParams) { + const res = await apiClient.feeds.$get({ + query: { + id, + url, + }, + }) + + const nonce = nanoid(8) + + const finalData = res.data.feed as FeedModel + if (!finalData.id) { + finalData["nonce"] = nonce + } + feedActions.upsertMany([finalData]) + + return { + ...res.data, + feed: !finalData.id ? { ...finalData, id: nonce } : finalData, + } + } +} +export const feedSyncServices = new FeedSyncServices() export const feedActions = new FeedActions() diff --git a/apps/mobile/src/store/feed/types.ts b/apps/mobile/src/store/feed/types.ts new file mode 100644 index 0000000000..3127792ec3 --- /dev/null +++ b/apps/mobile/src/store/feed/types.ts @@ -0,0 +1,5 @@ +import type { FeedSchema } from "@/src/database/schemas/types" + +export type FeedModel = FeedSchema & { + nonce?: string +} diff --git a/apps/renderer/src/modules/discover/DiscoverFeedForm.tsx b/apps/renderer/src/modules/discover/DiscoverFeedForm.tsx index 158c7a5f7a..023547b780 100644 --- a/apps/renderer/src/modules/discover/DiscoverFeedForm.tsx +++ b/apps/renderer/src/modules/discover/DiscoverFeedForm.tsx @@ -331,7 +331,7 @@ export const DiscoverFeedForm = ({ )} <div className={cn( - "sticky bottom-0 -mt-4 mb-1 flex w-full justify-end py-3", + "sticky bottom-0 -mt-4 mb-1 flex w-full justify-end pt-3", submitButtonClassName, )} > diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 891cf0c6b3..6dee14b678 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -526,6 +526,9 @@ importers: jotai: specifier: 2.10.3 version: 2.10.3(@types/react@18.3.14)(react@18.3.1) + nanoid: + specifier: 5.0.9 + version: 5.0.9 nativewind: specifier: 4.1.23 version: 4.1.23(nstbpzfxmampvbdkx7dnhhhjzi) @@ -5082,28 +5085,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.6.2': resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.6.2': resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.6.2': resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.6.2': resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} @@ -5214,61 +5213,51 @@ packages: resolution: {integrity: sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.28.1': resolution: {integrity: sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.28.1': resolution: {integrity: sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.28.1': resolution: {integrity: sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.28.1': resolution: {integrity: sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.28.1': resolution: {integrity: sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.28.1': resolution: {integrity: sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.28.1': resolution: {integrity: sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.28.1': resolution: {integrity: sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.28.1': resolution: {integrity: sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.28.1': resolution: {integrity: sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==} @@ -10237,56 +10226,48 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-gnu@1.28.2: resolution: {integrity: sha512-nhfjYkfymWZSxdtTNMWyhFk2ImUm0X7NAgJWFwnsYPOfmtWQEapzG/DXZTfEfMjSzERNUNJoQjPAbdqgB+sjiw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.27.0: resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-arm64-musl@1.28.2: resolution: {integrity: sha512-1SPG1ZTNnphWvAv8RVOymlZ8BDtAg69Hbo7n4QxARvkFVCJAt0cgjAw1Fox0WEhf4PwnyoOBaVH0Z5YNgzt4dA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.27.0: resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-gnu@1.28.2: resolution: {integrity: sha512-ZhQy0FcO//INWUdo/iEdbefntTdpPVQ0XJwwtdbBuMQe+uxqZoytm9M+iqR9O5noWFaxK+nbS2iR/I80Q2Ofpg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.27.0: resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-linux-x64-musl@1.28.2: resolution: {integrity: sha512-alb/j1NMrgQmSFyzTbN1/pvMPM+gdDw7YBuQ5VSgcFDypN3Ah0BzC2dTZbzwzaMdUVDszX6zH5MzjfVN1oGuww==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.27.0: resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==} From e77a0e1043bb59439c009daff4e4fa1bc7df3677 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Mon, 6 Jan 2025 20:13:21 +0800 Subject: [PATCH 23/70] fix: login page styles Signed-off-by: Innei <tukon479@gmail.com> --- apps/server/client/modules/login/index.tsx | 25 ++++++++++------------ 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/server/client/modules/login/index.tsx b/apps/server/client/modules/login/index.tsx index 3f22cce049..c231e94088 100644 --- a/apps/server/client/modules/login/index.tsx +++ b/apps/server/client/modules/login/index.tsx @@ -3,7 +3,6 @@ import { queryClient } from "@client/lib/query-client" import { useSession } from "@client/query/auth" import { useAuthProviders } from "@client/query/users" import { Logo } from "@follow/components/icons/logo.jsx" -import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.jsx" import { Button, MotionButtonBase } from "@follow/components/ui/button/index.js" import { Divider } from "@follow/components/ui/divider/index.js" import { @@ -194,7 +193,7 @@ export function Login() { const Content = useMemo(() => { switch (true) { case redirecting: { - return <div>{t("login.redirecting")}</div> + return <div className="center">{t("login.redirecting")}</div> } default: { return <div className="flex flex-col gap-3">{LoginOrStatusContent}</div> @@ -205,19 +204,17 @@ export function Login() { return ( <div className="flex h-screen w-full flex-col items-center justify-center"> <Logo className="size-16" /> - {isLoading && <LoadingCircle className="mt-8" size="large" />} - <AutoResizeHeight> - <> - {!isAuthenticated && !isLoading && ( - <h1 className="center mb-6 mt-8 flex text-2xl font-bold"> - {t("login.logInTo")} - {` ${APP_NAME}`} - </h1> - )} - {Content} - </> - </AutoResizeHeight> + <> + {!isAuthenticated && !isLoading && ( + <h1 className="center mb-6 mt-8 flex text-2xl font-bold"> + {t("login.logInTo")} + {` ${APP_NAME}`} + </h1> + )} + {Content} + {isLoading && <LoadingCircle className="mt-8" size="large" />} + </> </div> ) } From 9f6934f844c995a1dcbb326123420e6ee9b05a96 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 6 Jan 2025 20:40:29 +0800 Subject: [PATCH 24/70] fix(mobile): background color --- apps/mobile/src/screens/(headless)/index.tsx | 2 +- packages/components/assets/colors-media.css | 30 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/screens/(headless)/index.tsx b/apps/mobile/src/screens/(headless)/index.tsx index d0b9ea8ba1..8553007f77 100644 --- a/apps/mobile/src/screens/(headless)/index.tsx +++ b/apps/mobile/src/screens/(headless)/index.tsx @@ -34,7 +34,7 @@ export default function Index() { } return ( - <View className="flex-1 items-center justify-center pt-safe"> + <View className="flex-1 items-center justify-center pt-safe dark:bg-[#121212]"> {isCookieReady && <FollowWebView webViewRef={webViewRef} />} {__DEV__ && ( diff --git a/packages/components/assets/colors-media.css b/packages/components/assets/colors-media.css index 8af4028092..b96becfa50 100644 --- a/packages/components/assets/colors-media.css +++ b/packages/components/assets/colors-media.css @@ -1,3 +1,4 @@ +/* merged from colors.css and tailwind.css */ :root { --fo-a: 21.6 100% 50%; @@ -22,6 +23,21 @@ --fo-button-hover: theme(colors.zinc.500/0.1); --fo-image-placeholder: theme(colors.zinc.100); --fo-text-placeholder: theme(colors.zinc.400); + + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --radius: 0.5rem; } @media (prefers-color-scheme: dark) { @@ -49,6 +65,20 @@ --fo-button-hover: theme(colors.neutral.400/0.15); --fo-image-placeholder: theme(colors.neutral.800); --fo-text-placeholder: theme(colors.zinc.500); + + --background: 0 0% 7.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 0 0% 22.1%; } } From 9019e9bbc6ebcdcbd8a0f21844e04f1df17dd84a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 6 Jan 2025 21:07:44 +0800 Subject: [PATCH 25/70] fix(mobile): update email login style --- apps/mobile/src/modules/login/email.tsx | 51 +++++++++++++++---------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/modules/login/email.tsx b/apps/mobile/src/modules/login/email.tsx index 7b44837959..ed567994e5 100644 --- a/apps/mobile/src/modules/login/email.tsx +++ b/apps/mobile/src/modules/login/email.tsx @@ -54,26 +54,35 @@ export function EmailLogin() { return ( <View className="mx-auto flex w-full max-w-sm gap-6"> <View className="gap-4"> - <Input - autoCapitalize="none" - autoCorrect={false} - keyboardType="email-address" - autoComplete="email" - placeholder="Email" - control={control} - name="email" - className="border-gray-3 placeholder:font-sn placeholder:text-placeholder-text rounded-lg border px-3 py-2 focus:border-accent" - /> - <Input - autoCapitalize="none" - autoCorrect={false} - autoComplete="current-password" - placeholder="Password" - control={control} - name="password" - secureTextEntry - className="border-gray-3 placeholder:font-sn placeholder:text-placeholder-text rounded-lg border px-3 py-2 focus:border-accent" - /> + <View className="border-b-separator border-b-hairline" /> + <View className="flex-row"> + <ThemedText className="w-28">Account</ThemedText> + <Input + autoCapitalize="none" + autoCorrect={false} + keyboardType="email-address" + autoComplete="email" + control={control} + name="email" + placeholder="Email" + className="placeholder:font-sn text-text flex-1" + /> + </View> + <View className="border-b-separator border-b-hairline" /> + <View className="flex-row"> + <ThemedText className="w-28">Password</ThemedText> + <Input + autoCapitalize="none" + autoCorrect={false} + autoComplete="current-password" + control={control} + name="password" + placeholder="Enter password" + className="placeholder:font-sn text-text flex-1" + secureTextEntry + /> + </View> + <View className="border-b-separator border-b-hairline" /> </View> <TouchableOpacity disabled={submitMutation.isPending || !formState.isValid} @@ -83,7 +92,7 @@ export function EmailLogin() { {submitMutation.isPending ? ( <ActivityIndicator className="text-white" /> ) : ( - <ThemedText className="text-center text-white">Continue with Email</ThemedText> + <ThemedText className="text-center text-white">Continue</ThemedText> )} </TouchableOpacity> </View> From 9beff379f621e7756d55c4b6b7c0d3a9a6b1fecd Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Mon, 6 Jan 2025 21:09:07 +0800 Subject: [PATCH 26/70] feat(rn): follow modal form Signed-off-by: Innei <tukon479@gmail.com> --- .../components/common/LoadingContainer.tsx | 1 + apps/mobile/src/components/ui/form/Select.tsx | 6 +- apps/mobile/src/components/ui/form/Switch.tsx | 33 ++++++ .../src/components/ui/form/TextField.tsx | 13 +- apps/mobile/src/screens/(modal)/follow.tsx | 112 +++++++++++++++++- .../src/screens/(modal)/rsshub-form.tsx | 29 ++--- apps/mobile/src/store/feed/store.ts | 9 +- apps/mobile/src/store/subscription/hooks.ts | 3 + 8 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 apps/mobile/src/components/ui/form/Switch.tsx diff --git a/apps/mobile/src/components/common/LoadingContainer.tsx b/apps/mobile/src/components/common/LoadingContainer.tsx index 097f961d7c..6c8ee836e4 100644 --- a/apps/mobile/src/components/common/LoadingContainer.tsx +++ b/apps/mobile/src/components/common/LoadingContainer.tsx @@ -52,6 +52,7 @@ export const LoadingContainer = () => { loadingCaller.done?.(r) }) .catch((err) => { + console.error(err) loadingCaller.error?.(err) }) .finally(() => { diff --git a/apps/mobile/src/components/ui/form/Select.tsx b/apps/mobile/src/components/ui/form/Select.tsx index 1ce3ccc56d..0f2798fd08 100644 --- a/apps/mobile/src/components/ui/form/Select.tsx +++ b/apps/mobile/src/components/ui/form/Select.tsx @@ -1,5 +1,5 @@ import { cn } from "@follow/utils" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import type { StyleProp, ViewStyle } from "react-native" import { Text, View } from "react-native" import ContextMenu from "react-native-context-menu-view" @@ -48,6 +48,10 @@ export function Select<T>({ onValueChange(value) }) + useEffect(() => { + onValueChange(currentValue) + }, []) + const systemFill = useColor("text") return ( diff --git a/apps/mobile/src/components/ui/form/Switch.tsx b/apps/mobile/src/components/ui/form/Switch.tsx new file mode 100644 index 0000000000..4de0edfd29 --- /dev/null +++ b/apps/mobile/src/components/ui/form/Switch.tsx @@ -0,0 +1,33 @@ +import { forwardRef } from "react" +import type { StyleProp, SwitchProps, ViewStyle } from "react-native" +import { Switch, Text, View } from "react-native" + +import { accentColor } from "@/src/theme/colors" + +import { FormLabel } from "./Label" + +interface Props { + wrapperClassName?: string + wrapperStyle?: StyleProp<ViewStyle> + + label?: string + description?: string +} + +export const FormSwitch = forwardRef<Switch, Props & SwitchProps>( + ({ wrapperClassName, wrapperStyle, label, description, ...rest }, ref) => { + return ( + <View className={"w-full flex-row"}> + <View className="flex-1"> + {!!label && <FormLabel className="pl-1" label={label} optional />} + {!!description && ( + <Text className="text-system-secondary-label text-secondary-text mb-1 pl-1 text-sm"> + {description} + </Text> + )} + </View> + <Switch trackColor={{ true: accentColor }} ref={ref} {...rest} /> + </View> + ) + }, +) diff --git a/apps/mobile/src/components/ui/form/TextField.tsx b/apps/mobile/src/components/ui/form/TextField.tsx index 0fa45cf2a2..4895a8cad7 100644 --- a/apps/mobile/src/components/ui/form/TextField.tsx +++ b/apps/mobile/src/components/ui/form/TextField.tsx @@ -1,7 +1,7 @@ import { cn } from "@follow/utils/src/utils" import { forwardRef } from "react" import type { StyleProp, TextInputProps, ViewStyle } from "react-native" -import { StyleSheet, TextInput, View } from "react-native" +import { StyleSheet, Text, TextInput, View } from "react-native" import { FormLabel } from "./Label" @@ -10,14 +10,23 @@ interface TextFieldProps { wrapperStyle?: StyleProp<ViewStyle> label?: string + description?: string required?: boolean } export const TextField = forwardRef<TextInput, TextInputProps & TextFieldProps>( - ({ className, style, wrapperClassName, wrapperStyle, label, required, ...rest }, ref) => { + ( + { className, style, wrapperClassName, wrapperStyle, label, description, required, ...rest }, + ref, + ) => { return ( <> {!!label && <FormLabel className="pl-1" label={label} optional={!required} />} + {!!description && ( + <Text className="text-system-secondary-label text-secondary-text mb-1 pl-1 text-sm"> + {description} + </Text> + )} <View className={cn( "bg-system-fill/40 relative h-10 flex-row items-center rounded-lg px-4", diff --git a/apps/mobile/src/screens/(modal)/follow.tsx b/apps/mobile/src/screens/(modal)/follow.tsx index 373ee9bfaa..e1f509fdb1 100644 --- a/apps/mobile/src/screens/(modal)/follow.tsx +++ b/apps/mobile/src/screens/(modal)/follow.tsx @@ -1,11 +1,111 @@ -import { useLocalSearchParams } from "expo-router" -import { Text, View } from "react-native" +import { FeedViewType } from "@follow/constants" +import { zodResolver } from "@hookform/resolvers/zod" +import { Stack, useLocalSearchParams } from "expo-router" +import { Controller, useForm } from "react-hook-form" +import { ScrollView, Text, View } from "react-native" +import { z } from "zod" + +import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents" +import { FormProvider } from "@/src/components/ui/form/FormProvider" +import { FormSwitch } from "@/src/components/ui/form/Switch" +import { TextField } from "@/src/components/ui/form/TextField" +import { FeedIcon } from "@/src/components/ui/icon/feed-icon" +import { useFeed } from "@/src/store/feed/hooks" + +const formSchema = z.object({ + view: z.string(), + category: z.string().nullable().optional(), + isPrivate: z.boolean().optional(), + title: z.string().optional(), +}) +const defaultValues = { view: FeedViewType.Articles.toString() } export default function Follow() { - const { url } = useLocalSearchParams() + const { id } = useLocalSearchParams() + + const feed = useFeed(id as string) + // const hasSub = useSubscriptionByFeedId(feed?.id || "") + // const isSubscribed = !!feedQuery.data?.subscription || hasSub + + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues, + }) + return ( - <View> - <Text className="text-text">{url}</Text> - </View> + <ScrollView contentContainerClassName="px-2 pt-4 gap-y-4"> + <Stack.Screen + options={{ + title: `Follow - ${feed?.title}`, + headerLeft: ModalHeaderCloseButton, + }} + /> + + {/* Group 1 */} + <View className="bg-system-grouped-background-2 rounded-lg p-4"> + <View className="flex flex-row gap-4"> + <View className="size-[50px] overflow-hidden rounded-lg"> + <FeedIcon feed={feed} size={50} /> + </View> + <View className="flex-1 flex-col gap-y-1"> + <Text className="text-text text-lg font-semibold">{feed?.title}</Text> + <Text className="text-system-secondary-label text-secondary-text text-sm"> + {feed?.description} + </Text> + </View> + </View> + </View> + {/* Group 2 */} + <View className="bg-system-grouped-background-2 gap-y-4 rounded-lg p-4"> + <FormProvider form={form}> + <View> + <Controller + name="title" + control={form.control} + render={({ field: { onChange, ref, value } }) => ( + <TextField + label="Title" + description="Custom title for this Feed. Leave empty to use the default." + onChangeText={onChange} + value={value} + ref={ref} + /> + )} + /> + </View> + + <View> + <Controller + name="category" + control={form.control} + render={({ field: { onChange, ref, value } }) => ( + <TextField + label="Category" + description="By default, your follows will be grouped by website." + onChangeText={onChange} + value={value || ""} + ref={ref} + /> + )} + /> + </View> + + <View> + <Controller + name="isPrivate" + control={form.control} + render={({ field: { onChange, value } }) => ( + <FormSwitch + value={value} + label="Private" + description="Private feeds are only visible to you." + onValueChange={onChange} + /> + )} + /> + </View> + </FormProvider> + </View> + </ScrollView> ) } diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index dd0c56c5de..1f25341dd6 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -24,6 +24,7 @@ import MarkdownWeb from "@/src/components/ui/typography/MarkdownWeb" import { useLoadingCallback } from "@/src/hooks/useLoadingCallback" import { CheckLineIcon } from "@/src/icons/check_line" import { feedSyncServices } from "@/src/store/feed/store" +import type { FeedModel } from "@/src/store/feed/types" import { useColor } from "@/src/theme/colors" interface RsshubFormParams { @@ -124,7 +125,6 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { <View className="bg-system-grouped-background-2 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) return ( <View key={keyItem.name}> @@ -157,18 +157,18 @@ function FormImpl({ route, routePrefix, name }: RsshubFormParams) { )} {!!parameters?.options && ( - <Select - label={keyItem.name} - wrapperClassName="mt-2" - options={parameters.options} - value={form.getValues(keyItem.name)} - onValueChange={(value) => { - formRegister.onChange({ - target: { - [keyItem.name]: value, - }, - }) - }} + <Controller + name={keyItem.name} + control={form.control} + render={({ field: { onChange, value } }) => ( + <Select + label={keyItem.name} + wrapperClassName="mt-2" + options={parameters.options ?? []} + value={value} + onValueChange={onChange} + /> + )} /> )} @@ -297,11 +297,12 @@ const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitBu } loadingFn(feedSyncServices.fetchFeedById({ url: finalUrl }), { - finish: () => { + done: (feed) => { router.push({ pathname: "/follow", params: { url: finalUrl, + id: (feed as FeedModel)?.id, }, }) }, diff --git a/apps/mobile/src/store/feed/store.ts b/apps/mobile/src/store/feed/store.ts index af209db18c..79f723f7fa 100644 --- a/apps/mobile/src/store/feed/store.ts +++ b/apps/mobile/src/store/feed/store.ts @@ -1,5 +1,3 @@ -import { nanoid } from "nanoid" - import type { FeedSchema } from "@/src/database/schemas/types" import { apiClient } from "@/src/lib/api-fetch" import { FeedService } from "@/src/services/feed" @@ -70,7 +68,7 @@ class FeedSyncServices { }, }) - const nonce = nanoid(8) + const nonce = Math.random().toString(36).slice(2, 15) const finalData = res.data.feed as FeedModel if (!finalData.id) { @@ -78,10 +76,7 @@ class FeedSyncServices { } feedActions.upsertMany([finalData]) - return { - ...res.data, - feed: !finalData.id ? { ...finalData, id: nonce } : finalData, - } + return !finalData.id ? { ...finalData, id: nonce } : finalData } } export const feedSyncServices = new FeedSyncServices() diff --git a/apps/mobile/src/store/subscription/hooks.ts b/apps/mobile/src/store/subscription/hooks.ts index 9565cea29a..b276b45b8f 100644 --- a/apps/mobile/src/store/subscription/hooks.ts +++ b/apps/mobile/src/store/subscription/hooks.ts @@ -190,3 +190,6 @@ export const useListSubscriptionCategory = (view: FeedViewType) => { ), ) } + +export const useSubscriptionByFeedId = (feedId: string) => + useSubscriptionStore(useCallback((state) => state.data[feedId] || null, [feedId])) From 9ea93b80238f47788e0f984f03faf3c9a3434a81 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 6 Jan 2025 21:32:34 +0800 Subject: [PATCH 27/70] feat(mobile): introduce expo image --- apps/mobile/package.json | 1 + .../src/components/ui/icon/feed-icon.tsx | 6 ++--- pnpm-lock.yaml | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f1a9e9a3e9..6ac04dbccd 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -43,6 +43,7 @@ "expo-file-system": "~18.0.6", "expo-font": "~13.0.1", "expo-haptics": "~14.0.0", + "expo-image": "~2.0.3", "expo-linear-gradient": "~14.0.1", "expo-linking": "~7.0.3", "expo-router": "4.0.11", diff --git a/apps/mobile/src/components/ui/icon/feed-icon.tsx b/apps/mobile/src/components/ui/icon/feed-icon.tsx index 48a4db177a..50258aadec 100644 --- a/apps/mobile/src/components/ui/icon/feed-icon.tsx +++ b/apps/mobile/src/components/ui/icon/feed-icon.tsx @@ -1,9 +1,9 @@ import type { FeedViewType } from "@follow/constants" import { getUrlIcon } from "@follow/utils/src/utils" +import type { ImageProps } from "expo-image" +import { Image } from "expo-image" import type { ReactNode } from "react" import { useMemo } from "react" -import type { ImageProps } from "react-native" -import { Image } from "react-native" import type { FeedSchema } from "@/src/database/schemas/types" @@ -57,5 +57,5 @@ export function FeedIcon({ } }, [fallback, feed, siteUrl]) - return <Image height={size} width={size} source={{ uri: src }} {...props} /> + return <Image style={{ height: size, width: size }} source={src} {...props} /> } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dee14b678..96f838ee6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -484,6 +484,9 @@ importers: expo-haptics: specifier: ~14.0.0 version: 14.0.0(expo@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)) + expo-image: + specifier: ~2.0.3 + version: 2.0.3(apfxbceiepjzb6wdecgahzu7ta) expo-linear-gradient: specifier: ~14.0.1 version: 14.0.1(vc5zx7mqwgzirpvpjamp5nboge) @@ -8488,6 +8491,17 @@ packages: peerDependencies: expo: '*' + expo-image@2.0.3: + resolution: {integrity: sha512-+YnHTQv8jbXaut3FY7TDhNiSiGZ927C329mHvTZWV4Fyj32/Hjhhmk7dqq9I6LrA0nqBBiJjFj3u6VdHvCBnZg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + expo-json-utils@0.14.0: resolution: {integrity: sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw==} @@ -23848,6 +23862,14 @@ snapshots: dependencies: expo: 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) + expo-image@2.0.3(apfxbceiepjzb6wdecgahzu7ta): + dependencies: + expo: 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) + 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) + optionalDependencies: + react-native-web: 0.19.13(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + expo-json-utils@0.14.0: {} expo-keep-awake@14.0.1(expo@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))(react@18.3.1): From f9d5d7698c0a3b498684468abf4ce50b3c223390 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Mon, 6 Jan 2025 23:32:58 +0800 Subject: [PATCH 28/70] feat(rn): feed form and subscribe - Removed unused code from package.json. - Simplified description rendering in FormSwitch component. - Enhanced Follow modal with new subscription logic and improved form handling. - Updated error messages in locales for better clarity. - Added new properties to the subscription sync service for better data handling. This commit improves code readability and enhances the user experience in the subscription process. Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/package.json | 5 -- apps/mobile/src/components/ui/form/Switch.tsx | 4 +- .../mobile/src/modules/feed/view-selector.tsx | 38 ++++++++++++++ apps/mobile/src/screens/(modal)/follow.tsx | 50 ++++++++++++++++++- .../src/screens/(modal)/rsshub-form.tsx | 2 + apps/mobile/src/store/subscription/store.ts | 41 +++++++++++++++ apps/mobile/src/store/subscription/types.ts | 11 ++++ locales/errors/en.json | 16 +++--- locales/errors/ja.json | 1 - locales/errors/ru.json | 7 --- locales/errors/zh-CN.json | 7 --- locales/errors/zh-HK.json | 1 - locales/errors/zh-TW.json | 7 --- packages/shared/src/hono.ts | 47 +++++++++++++++++ 14 files changed, 196 insertions(+), 41 deletions(-) create mode 100644 apps/mobile/src/modules/feed/view-selector.tsx create mode 100644 apps/mobile/src/store/subscription/types.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6ac04dbccd..df72e915a9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -92,10 +92,5 @@ "expo-drizzle-studio-plugin": "0.1.1", "postcss": "8.4.49", "react-test-renderer": "18.3.1" - }, - "codegenConfig": { - "name": "FullscreenPortalManager", - "type": "modules", - "jsSrcsDir": "./specs" } } diff --git a/apps/mobile/src/components/ui/form/Switch.tsx b/apps/mobile/src/components/ui/form/Switch.tsx index 4de0edfd29..bfb706ca12 100644 --- a/apps/mobile/src/components/ui/form/Switch.tsx +++ b/apps/mobile/src/components/ui/form/Switch.tsx @@ -21,9 +21,7 @@ export const FormSwitch = forwardRef<Switch, Props & SwitchProps>( <View className="flex-1"> {!!label && <FormLabel className="pl-1" label={label} optional />} {!!description && ( - <Text className="text-system-secondary-label text-secondary-text mb-1 pl-1 text-sm"> - {description} - </Text> + <Text className="text-secondary-text mb-1 pl-1 text-sm">{description}</Text> )} </View> <Switch trackColor={{ true: accentColor }} ref={ref} {...rest} /> diff --git a/apps/mobile/src/modules/feed/view-selector.tsx b/apps/mobile/src/modules/feed/view-selector.tsx new file mode 100644 index 0000000000..7c5f6858e3 --- /dev/null +++ b/apps/mobile/src/modules/feed/view-selector.tsx @@ -0,0 +1,38 @@ +import type { FeedViewType } from "@follow/constants" +import { cn } from "@follow/utils" +import { Text, TouchableOpacity, View } from "react-native" + +import { Grid } from "@/src/components/ui/grid" +import { views } from "@/src/constants/views" + +interface Props { + value: FeedViewType + onChange: (value: FeedViewType) => void + + className?: string +} + +export const FeedViewSelector = ({ value, onChange, className }: Props) => { + return ( + <Grid columns={views.length} gap={5} className={className}> + {views.map((view) => { + const isSelected = +value === +view.view + return ( + <TouchableOpacity key={view.name} onPress={() => onChange(view.view)}> + <View className="flex-1 items-center"> + <view.icon color={isSelected ? view.activeColor : "gray"} height={18} width={18} /> + <Text + className={cn( + "mt-1 whitespace-nowrap text-[8px] font-medium", + isSelected ? "text-accent" : "text-secondary-label", + )} + > + {view.name} + </Text> + </View> + </TouchableOpacity> + ) + })} + </Grid> + ) +} diff --git a/apps/mobile/src/screens/(modal)/follow.tsx b/apps/mobile/src/screens/(modal)/follow.tsx index e1f509fdb1..2ebe599f68 100644 --- a/apps/mobile/src/screens/(modal)/follow.tsx +++ b/apps/mobile/src/screens/(modal)/follow.tsx @@ -1,16 +1,23 @@ import { FeedViewType } from "@follow/constants" +import { withOpacity } from "@follow/utils" import { zodResolver } from "@hookform/resolvers/zod" -import { Stack, useLocalSearchParams } from "expo-router" +import { router, Stack, useLocalSearchParams } from "expo-router" import { Controller, useForm } from "react-hook-form" -import { ScrollView, Text, View } from "react-native" +import { ScrollView, Text, TouchableOpacity, View } from "react-native" import { z } from "zod" import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents" import { FormProvider } from "@/src/components/ui/form/FormProvider" +import { FormLabel } from "@/src/components/ui/form/Label" import { FormSwitch } from "@/src/components/ui/form/Switch" import { TextField } from "@/src/components/ui/form/TextField" import { FeedIcon } from "@/src/components/ui/icon/feed-icon" +import { CheckLineIcon } from "@/src/icons/check_line" +import { FeedViewSelector } from "@/src/modules/feed/view-selector" import { useFeed } from "@/src/store/feed/hooks" +import { subscriptionSyncService } from "@/src/store/subscription/store" +import type { SubscriptionForm } from "@/src/store/subscription/types" +import { useColor } from "@/src/theme/colors" const formSchema = z.object({ view: z.string(), @@ -32,12 +39,39 @@ export default function Follow() { defaultValues, }) + const submit = async () => { + const values = form.getValues() + const body: SubscriptionForm = { + url: feed.url, + view: Number.parseInt(values.view), + category: values.category ?? "", + isPrivate: values.isPrivate ?? false, + title: values.title ?? "", + feedId: feed.id, + } + + await subscriptionSyncService.subscribe(body) + + if (router.canDismiss()) { + router.dismiss() + } + } + + const { isValid, isDirty } = form.formState + const label = useColor("label") + return ( <ScrollView contentContainerClassName="px-2 pt-4 gap-y-4"> <Stack.Screen options={{ title: `Follow - ${feed?.title}`, headerLeft: ModalHeaderCloseButton, + gestureEnabled: !isDirty, + headerRight: () => ( + <TouchableOpacity onPress={form.handleSubmit(submit)} disabled={!isValid}> + <CheckLineIcon color={isValid ? label : withOpacity(label, 0.5)} /> + </TouchableOpacity> + ), }} /> @@ -104,6 +138,18 @@ export default function Follow() { )} /> </View> + + <View className="-mx-4"> + <FormLabel className="mb-4 pl-5" label="View" optional /> + + <Controller + name="view" + control={form.control} + render={({ field: { onChange, value } }) => ( + <FeedViewSelector value={value as any as FeedViewType} onChange={onChange} /> + )} + /> + </View> </FormProvider> </View> </ScrollView> diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index 1f25341dd6..ce6f9bc014 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -232,6 +232,8 @@ const ScreenOptions = memo(({ name, routeName, route, routePrefix }: ScreenOptio <Stack.Screen options={{ headerLeft: ModalHeaderCloseButton, + gestureEnabled: !form.formState.isDirty, + headerRight: () => ( <FormProvider form={form}> <ModalHeaderSubmitButton routePrefix={routePrefix} route={route} /> diff --git a/apps/mobile/src/store/subscription/store.ts b/apps/mobile/src/store/subscription/store.ts index 4ae967a4c4..b8bd94d454 100644 --- a/apps/mobile/src/store/subscription/store.ts +++ b/apps/mobile/src/store/subscription/store.ts @@ -10,6 +10,8 @@ import { feedActions } from "../feed/store" import { inboxActions } from "../inbox/store" import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper" import { listActions } from "../list/store" +import { whoami } from "../user/getters" +import type { SubscriptionForm } from "./types" import { getInboxStoreId, getSubscriptionStoreId } from "./utils" type FeedId = string @@ -188,6 +190,45 @@ class SubscriptionSyncService { await tx.run() } + async subscribe(subscription: SubscriptionForm) { + const data = await apiClient.subscriptions.$post({ + json: { + url: subscription.url, + view: subscription.view, + category: subscription.category, + isPrivate: subscription.isPrivate, + title: subscription.title, + listId: subscription.listId, + }, + }) + + if (data.feed) { + feedActions.upsertMany([data.feed]) + } + + if (data.list) { + listActions.upsertMany([ + { + ...data.list, + userId: data.list.ownerUserId, + }, + ]) + } + + // Insert to subscription + subscriptionActions.upsertMany([ + { + ...subscription, + type: data.list ? "list" : "feed", + createdAt: new Date().toISOString(), + feedId: data.feed?.id ?? null, + listId: data.list?.id ?? null, + inboxId: null, + userId: whoami()?.id ?? "", + }, + ]) + } + async unsubscribe(subscriptionId: string) { const subscription = get().data[subscriptionId] diff --git a/apps/mobile/src/store/subscription/types.ts b/apps/mobile/src/store/subscription/types.ts new file mode 100644 index 0000000000..4cc9d5b372 --- /dev/null +++ b/apps/mobile/src/store/subscription/types.ts @@ -0,0 +1,11 @@ +import type { FeedViewType } from "@follow/constants" + +export interface SubscriptionForm { + url?: string + view: FeedViewType + category: string + isPrivate: boolean + title: string + feedId?: string + listId?: string +} diff --git a/locales/errors/en.json b/locales/errors/en.json index 639270d484..d496494c0c 100644 --- a/locales/errors/en.json +++ b/locales/errors/en.json @@ -51,12 +51,12 @@ "10001": "Inbox already exists", "10002": "Inbox limit exceeded", "10003": "Inbox permission denied", - "11000": "RSSHub route not found", - "11001": "You are not the owner of this RSSHub instance", - "11002": "RSSHub is in use", - "11003": "RSSHub not found", - "11004": "RSSHub user limit exceeded", - "11005": "RSSHub purchase not found", - "11006": "RSSHub config invalid", - "12000": "Action limit exceeded" + "12000": "Action limit exceeded", + "13000": "RSSHub route not found", + "13001": "You are not the owner of this RSSHub instance", + "13002": "RSSHub is in use", + "13003": "RSSHub not found", + "13004": "RSSHub user limit exceeded", + "13005": "RSSHub purchase not found", + "13006": "RSSHub config invalid" } diff --git a/locales/errors/ja.json b/locales/errors/ja.json index 3510ee0408..8ec5f92665 100644 --- a/locales/errors/ja.json +++ b/locales/errors/ja.json @@ -51,6 +51,5 @@ "10001": "受信箱は既に存在します", "10002": "受信箱の上限が超過しています", "10003": "受信箱の権限がありません", - "11000": "RSSHub ルートがありません", "12000": "アクションの最大数に達しました" } diff --git a/locales/errors/ru.json b/locales/errors/ru.json index 98bb5722c7..1b5ad251ed 100644 --- a/locales/errors/ru.json +++ b/locales/errors/ru.json @@ -51,12 +51,5 @@ "10001": "Входящее сообщение уже существует", "10002": "Превышен лимит входящих сообщений", "10003": "Доступ к входящим сообщениям запрещен", - "11000": "Маршрут RSSHub не найден", - "11001": "Вы не являетесь владельцем этого экземпляра RSSHub", - "11002": "RSSHub используется", - "11003": "RSSHub не найден", - "11004": "Превышен лимит пользователей RSSHub", - "11005": "Покупка RSSHub не найдена", - "11006": "Неверная конфигурация RSSHub", "12000": "Превышен лимит действий" } diff --git a/locales/errors/zh-CN.json b/locales/errors/zh-CN.json index 5f16ee735e..ab0cca93dd 100644 --- a/locales/errors/zh-CN.json +++ b/locales/errors/zh-CN.json @@ -51,12 +51,5 @@ "10001": "收件箱已存在", "10002": "收件箱限制已超出", "10003": "收件箱无权限", - "11000": "RSSHub 路由不存在", - "11001": "你不是此 RSSHub 实例的所有者", - "11002": "RSSHub 正在使用中", - "11003": "未找到 RSSHub", - "11004": "RSSHub 用户限制已超出", - "11005": "未找到 RSSHub 购买记录", - "11006": "RSSHub 配置无效", "12000": "自动化规则限制已超出" } diff --git a/locales/errors/zh-HK.json b/locales/errors/zh-HK.json index ea602a60f8..e483085376 100644 --- a/locales/errors/zh-HK.json +++ b/locales/errors/zh-HK.json @@ -51,6 +51,5 @@ "10001": "收件箱已存在", "10002": "超出收件箱限制", "10003": "收件箱無權限", - "11000": "RSSHub 路由不存在", "12000": "操作次數超出限制" } diff --git a/locales/errors/zh-TW.json b/locales/errors/zh-TW.json index dabecc951c..1ebf09dff5 100644 --- a/locales/errors/zh-TW.json +++ b/locales/errors/zh-TW.json @@ -51,12 +51,5 @@ "10001": "收件匣已存在", "10002": "超過收件匣限制", "10003": "收件匣無權限", - "11000": "RSSHub 路由不存在", - "11001": "你不是此 RSSHub 實例伺服器的建立者", - "11002": "RSSHub 伺服器正在使用中", - "11003": "未找到 RSSHub 伺服器", - "11004": "已超過 RSSHub 伺服器使用者限制", - "11005": "未找到 RSSHub 伺服器贊助紀錄", - "11006": "RSSHub 設定無效", "12000": "超過自動化規則限制" } diff --git a/packages/shared/src/hono.ts b/packages/shared/src/hono.ts index 70e6243adc..89f75b4565 100644 --- a/packages/shared/src/hono.ts +++ b/packages/shared/src/hono.ts @@ -9753,6 +9753,10 @@ declare const auth: { advanced: { generateId: false; }; + session: { + updateAge: number; + expiresIn: number; + }; basePath: string; trustedOrigins: string[]; user: { @@ -11681,6 +11685,34 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ }; output: { code: 0; + feed: { + description: string | null; + title: string | null; + id: string; + image: string | null; + url: string; + siteUrl: string | null; + checkedAt: string; + lastModifiedHeader: string | null; + etagHeader: string | null; + ttl: number | null; + errorMessage: string | null; + errorAt: string | null; + ownerUserId: string | null; + language: string | null; + } | null; + list: { + description: string | null; + title: string; + id: string; + image: string | null; + view: number; + ownerUserId: string; + language: string | null; + feedIds: string[]; + fee: number; + timelineUpdatedAt: string; + } | null; }; outputFormat: "json"; status: 200; @@ -12882,6 +12914,21 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ status: 200; }; }; +} & { + "/accounts": { + $get: { + input: {}; + output: { + code: 0; + data: { + duplicateEmails: string[]; + duplicateAccountIds: string[]; + }; + }; + outputFormat: "json"; + status: 200; + }; + }; }, "/probes"> | hono_types.MergeSchemaPath<{ "/": { $post: { From e2c9459db8cc0137bb63ea83b1cd7f2cb2a3dab4 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Mon, 6 Jan 2025 23:46:50 +0800 Subject: [PATCH 29/70] fix(rn): delete subscribe in db persist Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/src/modules/subscription/list.tsx | 16 ++++------------ apps/mobile/src/services/subscription.ts | 4 +++- apps/mobile/src/store/subscription/store.ts | 2 +- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/modules/subscription/list.tsx b/apps/mobile/src/modules/subscription/list.tsx index 55b8723648..0151658693 100644 --- a/apps/mobile/src/modules/subscription/list.tsx +++ b/apps/mobile/src/modules/subscription/list.tsx @@ -8,20 +8,12 @@ import { useAtom } from "jotai" import { useColorScheme } from "nativewind" import type { FC } from "react" import { createContext, memo, useContext, useEffect, useMemo, useRef } from "react" -import { - Animated, - Easing, - Image, - StyleSheet, - Text, - TouchableOpacity, - useAnimatedValue, - View, -} from "react-native" +import { Animated, Easing, Image, StyleSheet, Text, useAnimatedValue, View } from "react-native" import PagerView from "react-native-pager-view" import { useSharedValue } from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" +import { AnimatedTouchableOpacity } from "@/src/components/common/AnimatedComponents" import { AccordionItem } from "@/src/components/ui/accordion" import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" import { FeedIcon } from "@/src/components/ui/icon/feed-icon" @@ -293,8 +285,6 @@ const UnGroupedList: FC<{ const GroupedContext = createContext<string | null>(null) -const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) - // const CategoryList: FC<{ // grouped: Record<string, string[]> // }> = ({ grouped }) => { @@ -416,6 +406,7 @@ const SubscriptionItem = memo(({ id, className }: { id: string; className?: stri // prevOpenedRow = swipeableRef.current // }} // > + // <ReAnimated.View key={id} layout={CurvedTransition} exiting={FadeOut}> <SubscriptionFeedItemContextMenu id={id} view={view}> <ItemPressable className={cn( @@ -442,6 +433,7 @@ const SubscriptionItem = memo(({ id, className }: { id: string; className?: stri )} </ItemPressable> </SubscriptionFeedItemContextMenu> + // </ReAnimated.View> // </Swipeable> ) }) diff --git a/apps/mobile/src/services/subscription.ts b/apps/mobile/src/services/subscription.ts index 24bb83cc95..49643d98f7 100644 --- a/apps/mobile/src/services/subscription.ts +++ b/apps/mobile/src/services/subscription.ts @@ -43,7 +43,9 @@ class SubscriptionServiceStatic implements Hydratable, Resetable { ) } - async delete(id: string) { + async delete(subscription: SubscriptionSchema) { + const { id } = subscription + const result = await db.query.subscriptionsTable.findFirst({ where: eq(subscriptionsTable.id, id), columns: { diff --git a/apps/mobile/src/store/subscription/store.ts b/apps/mobile/src/store/subscription/store.ts index b8bd94d454..470337c87a 100644 --- a/apps/mobile/src/store/subscription/store.ts +++ b/apps/mobile/src/store/subscription/store.ts @@ -252,7 +252,7 @@ class SubscriptionSyncService { }) tx.persist(() => { - return SubscriptionService.delete(subscriptionId) + return SubscriptionService.delete(storeDbMorph.toSubscriptionSchema(subscription)) }) tx.request(async () => { From 38fe11075424a31a60c9db1c2758980f957c19c2 Mon Sep 17 00:00:00 2001 From: Whitewater <me@waterwater.moe> Date: Tue, 7 Jan 2025 12:26:24 +0800 Subject: [PATCH 30/70] feat: customize toolbar (#2468) * feat: add customizable toolbar modal * refactor: extract SortableActionButton component * refactor: enhance CustomizeToolbar with drag-and-drop reordering and state management * refactor: update CustomizeToolbar for improved state management * refactor: enhance drag-and-drop functionality * feat: add CustomizeToolbarCommand * chore: update i18n * feat: extract customizable toolbar actions state * feat: integrate customizable toolbar order for entry header and more actions * chore: add @dnd-kit/sortable dependency * feat: add reset action order functionality * feat: sync action order * chore: filter out read action * chore: add changeset --- apps/renderer/package.json | 1 + apps/renderer/src/atoms/settings/ui.ts | 5 + .../src/hooks/biz/useEntryActions.tsx | 4 + .../src/modules/command/command-manager.ts | 4 +- .../src/modules/command/commands/id.ts | 9 +- .../commands/{theme.tsx => settings.tsx} | 31 +++- .../src/modules/command/commands/types.ts | 11 +- .../src/modules/customize-toolbar/constant.ts | 26 ++++ .../src/modules/customize-toolbar/dnd.tsx | 83 ++++++++++ .../src/modules/customize-toolbar/hooks.ts | 32 ++++ .../src/modules/customize-toolbar/modal.tsx | 144 ++++++++++++++++++ .../entry-content/actions/header-actions.tsx | 24 +-- .../entry-content/actions/more-actions.tsx | 23 +-- changelog/next.md | 3 + locales/settings/en.json | 1 + packages/shared/src/interface/settings.ts | 6 + pnpm-lock.yaml | 16 ++ 17 files changed, 391 insertions(+), 32 deletions(-) rename apps/renderer/src/modules/command/commands/{theme.tsx => settings.tsx} (56%) create mode 100644 apps/renderer/src/modules/customize-toolbar/constant.ts create mode 100644 apps/renderer/src/modules/customize-toolbar/dnd.tsx create mode 100644 apps/renderer/src/modules/customize-toolbar/hooks.ts create mode 100644 apps/renderer/src/modules/customize-toolbar/modal.tsx diff --git a/apps/renderer/package.json b/apps/renderer/package.json index e2616ad8af..054822739e 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@egoist/tipc": "0.3.2", "@electron-toolkit/preload": "^3.0.1", "@follow/electron-main": "workspace:*", diff --git a/apps/renderer/src/atoms/settings/ui.ts b/apps/renderer/src/atoms/settings/ui.ts index fd84071230..5133b35f1c 100644 --- a/apps/renderer/src/atoms/settings/ui.ts +++ b/apps/renderer/src/atoms/settings/ui.ts @@ -4,6 +4,8 @@ import { jotaiStore } from "@follow/utils/jotai" import { atom, useAtomValue, useSetAtom } from "jotai" import { useEventCallback } from "usehooks-ts" +import { DEFAULT_ACTION_ORDER } from "~/modules/customize-toolbar/constant" + export const createDefaultSettings = (): UISettings => ({ // Sidebar entryColWidth: 356, @@ -40,6 +42,9 @@ export const createDefaultSettings = (): UISettings => ({ pictureViewMasonry: true, pictureViewFilterNoImage: false, wideMode: false, + + // Action Order + toolbarOrder: DEFAULT_ACTION_ORDER, }) const zenModeAtom = atom(false) diff --git a/apps/renderer/src/hooks/biz/useEntryActions.tsx b/apps/renderer/src/hooks/biz/useEntryActions.tsx index d0665b9215..2c0233ad2b 100644 --- a/apps/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/renderer/src/hooks/biz/useEntryActions.tsx @@ -200,6 +200,10 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee hide: !hasEntry || !entry.read || !!entry.collections || !!inList, shortcut: shortcuts.entry.toggleRead.key, }, + { + id: COMMAND_ID.settings.customizeToolbar, + onClick: runCmdFn(COMMAND_ID.settings.customizeToolbar, []), + }, ].filter((config) => !config.hide) }, [ entry?.collections, diff --git a/apps/renderer/src/modules/command/command-manager.ts b/apps/renderer/src/modules/command/command-manager.ts index a06780cfb0..63eb4b6748 100644 --- a/apps/renderer/src/modules/command/command-manager.ts +++ b/apps/renderer/src/modules/command/command-manager.ts @@ -1,10 +1,10 @@ import { useRegisterEntryCommands } from "./commands/entry" import { useRegisterIntegrationCommands } from "./commands/integration" import { useRegisterListCommands } from "./commands/list" -import { useRegisterThemeCommands } from "./commands/theme" +import { useRegisterSettingsCommands } from "./commands/settings" export function useRegisterFollowCommands() { - useRegisterThemeCommands() + useRegisterSettingsCommands() useRegisterListCommands() useRegisterEntryCommands() useRegisterIntegrationCommands() diff --git a/apps/renderer/src/modules/command/commands/id.ts b/apps/renderer/src/modules/command/commands/id.ts index fa461fed3a..bf044ea928 100644 --- a/apps/renderer/src/modules/command/commands/id.ts +++ b/apps/renderer/src/modules/command/commands/id.ts @@ -31,9 +31,10 @@ export const COMMAND_ID = { copyUrl: "list:copy-url", copyId: "list:copy-id", }, - theme: { - toAuto: "follow:change-color-mode-to-auto", - toDark: "follow:change-color-mode-to-dark", - toLight: "follow:change-color-mode-to-light", + settings: { + changeThemeToAuto: "follow:change-color-mode-to-auto", + changeThemeToDark: "follow:change-color-mode-to-dark", + changeThemeToLight: "follow:change-color-mode-to-light", + customizeToolbar: "follow:customize-toolbar", }, } as const diff --git a/apps/renderer/src/modules/command/commands/theme.tsx b/apps/renderer/src/modules/command/commands/settings.tsx similarity index 56% rename from apps/renderer/src/modules/command/commands/theme.tsx rename to apps/renderer/src/modules/command/commands/settings.tsx index 8757b503cb..2dc753a12d 100644 --- a/apps/renderer/src/modules/command/commands/theme.tsx +++ b/apps/renderer/src/modules/command/commands/settings.tsx @@ -2,17 +2,40 @@ import { useThemeAtomValue } from "@follow/hooks" import { useTranslation } from "react-i18next" import { useSetTheme } from "~/hooks/common" +import { useShowCustomizeToolbarModal } from "~/modules/customize-toolbar/modal" import { useRegisterCommandEffect } from "../hooks/use-register-command" +import { COMMAND_ID } from "./id" -export const useRegisterThemeCommands = () => { +export const useRegisterSettingsCommands = () => { + useCustomizeToolbarCommand() + useRegisterThemeCommands() +} + +const useCustomizeToolbarCommand = () => { + const [t] = useTranslation("settings") + const showModal = useShowCustomizeToolbarModal() + useRegisterCommandEffect([ + { + id: COMMAND_ID.settings.customizeToolbar, + label: t("customizeToolbar.title"), + category: "follow:settings", + icon: <i className="i-mgc-settings-7-cute-re" />, + run() { + showModal() + }, + }, + ]) +} + +const useRegisterThemeCommands = () => { const [t] = useTranslation("settings") const theme = useThemeAtomValue() const setTheme = useSetTheme() useRegisterCommandEffect([ { - id: "follow:change-color-mode-to-auto", + id: COMMAND_ID.settings.changeThemeToAuto, label: `To ${t("appearance.theme.system")}`, category: "follow:settings", icon: <i className="i-mgc-settings-7-cute-re" />, @@ -22,7 +45,7 @@ export const useRegisterThemeCommands = () => { }, }, { - id: "follow:change-color-mode-to-dark", + id: COMMAND_ID.settings.changeThemeToDark, label: `To ${t("appearance.theme.dark")}`, category: "follow:settings", icon: <i className="i-mingcute-moon-line" />, @@ -32,7 +55,7 @@ export const useRegisterThemeCommands = () => { }, }, { - id: "follow:change-color-mode-to-light", + id: COMMAND_ID.settings.changeThemeToLight, label: `To ${t("appearance.theme.light")}`, category: "follow:settings", icon: <i className="i-mingcute-sun-line" />, diff --git a/apps/renderer/src/modules/command/commands/types.ts b/apps/renderer/src/modules/command/commands/types.ts index cbdc318387..f6a4f97bf2 100644 --- a/apps/renderer/src/modules/command/commands/types.ts +++ b/apps/renderer/src/modules/command/commands/types.ts @@ -89,6 +89,15 @@ export type EntryCommand = | ToggleAISummaryCommand | ToggleAITranslationCommand +// Settings commands + +export type CustomizeToolbarCommand = Command<{ + id: typeof COMMAND_ID.settings.customizeToolbar + fn: () => void +}> + +export type SettingsCommand = CustomizeToolbarCommand + // Integration commands export type SaveToEagleCommand = Command<{ @@ -129,4 +138,4 @@ export type IntegrationCommand = | SaveToOutlineCommand | SaveToReadeckCommand -export type BasicCommand = EntryCommand | IntegrationCommand +export type BasicCommand = EntryCommand | SettingsCommand | IntegrationCommand diff --git a/apps/renderer/src/modules/customize-toolbar/constant.ts b/apps/renderer/src/modules/customize-toolbar/constant.ts new file mode 100644 index 0000000000..402a4094f7 --- /dev/null +++ b/apps/renderer/src/modules/customize-toolbar/constant.ts @@ -0,0 +1,26 @@ +import type { UniqueIdentifier } from "@dnd-kit/core" + +import { COMMAND_ID } from "../command/commands/id" + +export interface ToolbarActionOrder { + main: UniqueIdentifier[] + more: UniqueIdentifier[] +} + +export const DEFAULT_ACTION_ORDER: ToolbarActionOrder = { + main: Object.values(COMMAND_ID.entry) + .filter((id) => !([COMMAND_ID.entry.read, COMMAND_ID.entry.unread] as string[]).includes(id)) + .filter( + (id) => + !([COMMAND_ID.entry.copyLink, COMMAND_ID.entry.openInBrowser] as string[]).includes(id), + ), + more: [ + ...Object.values(COMMAND_ID.integration), + ...Object.values(COMMAND_ID.entry) + .filter((id) => !([COMMAND_ID.entry.read, COMMAND_ID.entry.unread] as string[]).includes(id)) + .filter((id) => + ([COMMAND_ID.entry.copyLink, COMMAND_ID.entry.openInBrowser] as string[]).includes(id), + ), + COMMAND_ID.settings.customizeToolbar, + ], +} diff --git a/apps/renderer/src/modules/customize-toolbar/dnd.tsx b/apps/renderer/src/modules/customize-toolbar/dnd.tsx new file mode 100644 index 0000000000..d8c0e846d9 --- /dev/null +++ b/apps/renderer/src/modules/customize-toolbar/dnd.tsx @@ -0,0 +1,83 @@ +import type { UniqueIdentifier } from "@dnd-kit/core" +import { useSortable } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import type { ReactNode } from "react" +import { useMemo } from "react" + +import { getCommand } from "../command/hooks/use-command" +import type { FollowCommandId } from "../command/types" + +const SortableItem = ({ id, children }: { id: UniqueIdentifier; children: ReactNode }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + animateLayoutChanges: () => true, // Enable layout animations + transition: { + duration: 400, + easing: "cubic-bezier(0.25, 1, 0.5, 1)", + }, + }) + + const style = useMemo(() => { + return { + transform: CSS.Transform.toString(transform), + transition, + width: "100px", // Fixed width + height: "80px", // Fixed height + zIndex: isDragging ? 999 : undefined, + } + }, [transform, transition, isDragging]) + + return ( + <div + ref={setNodeRef} + style={style} + className={` ${isDragging ? "cursor-grabbing opacity-90" : "cursor-grab"} transition-colors duration-200`} + {...attributes} + {...listeners} + > + {children} + </div> + ) +} + +export const SortableActionButton = ({ id }: { id: UniqueIdentifier }) => { + const cmd = getCommand(id as FollowCommandId) + if (!cmd) return null + return ( + <SortableItem id={id}> + <div className="flex flex-col items-center rounded-lg p-2 hover:bg-theme-button-hover"> + <div className="flex size-8 items-center justify-center text-xl">{cmd.icon}</div> + <div className="mt-1 text-center text-xs text-neutral-500 dark:text-neutral-400"> + {cmd.label.title} + </div> + </div> + </SortableItem> + ) +} + +export function DroppableContainer({ + id, + children, +}: { + children: ReactNode + id: UniqueIdentifier +}) { + const { attributes, isDragging, listeners, setNodeRef, transition, transform } = useSortable({ + id, + }) + return ( + <div + className="flex min-h-[120px] w-full flex-wrap items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 p-2 pb-6 shadow-sm dark:border-neutral-800 dark:bg-neutral-900" + ref={setNodeRef} + style={{ + transition, + transform: CSS.Translate.toString(transform), + opacity: isDragging ? 0.5 : undefined, + }} + {...attributes} + {...listeners} + > + {children} + </div> + ) +} diff --git a/apps/renderer/src/modules/customize-toolbar/hooks.ts b/apps/renderer/src/modules/customize-toolbar/hooks.ts new file mode 100644 index 0000000000..97d5146424 --- /dev/null +++ b/apps/renderer/src/modules/customize-toolbar/hooks.ts @@ -0,0 +1,32 @@ +import type { UniqueIdentifier } from "@dnd-kit/core" +import { useMemo } from "react" + +import { useUISettingSelector } from "~/atoms/settings/ui" + +export const useToolbarOrderMap = () => { + const actionOrder = useUISettingSelector((s) => s.toolbarOrder) + + const actionOrderMap = useMemo(() => { + const actionOrderMap = new Map< + UniqueIdentifier, + { + type: "main" | "more" + order: number + } + >() + actionOrder.main.forEach((id, index) => + actionOrderMap.set(id, { + type: "main", + order: index, + }), + ) + actionOrder.more.forEach((id, index) => + actionOrderMap.set(id, { + type: "more", + order: index, + }), + ) + return actionOrderMap + }, [actionOrder]) + return actionOrderMap +} diff --git a/apps/renderer/src/modules/customize-toolbar/modal.tsx b/apps/renderer/src/modules/customize-toolbar/modal.tsx new file mode 100644 index 0000000000..91ce7e5131 --- /dev/null +++ b/apps/renderer/src/modules/customize-toolbar/modal.tsx @@ -0,0 +1,144 @@ +import type { DragOverEvent } from "@dnd-kit/core" +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { Button } from "@follow/components/ui/button/index.js" +import { useCallback } from "react" +import { useTranslation } from "react-i18next" + +import { setUISetting, useUISettingSelector } from "~/atoms/settings/ui" +import { useModalStack } from "~/components/ui/modal/stacked/hooks" + +import { DEFAULT_ACTION_ORDER } from "./constant" +import { DroppableContainer, SortableActionButton } from "./dnd" + +const CustomizeToolbar = () => { + const actionOrder = useUISettingSelector((s) => s.toolbarOrder) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ) + + const handleDragOver = useCallback( + ({ active, over }: DragOverEvent) => { + if (!over) return + const activeId = active.id + const overId = over.id + const isActiveInMain = actionOrder.main.includes(activeId) + const isOverInMain = overId === "container-main" || actionOrder.main.includes(overId) + const isCrossContainer = isActiveInMain !== isOverInMain + + if (isCrossContainer) { + // Moving between containers + const sourceList = isActiveInMain ? "main" : "more" + const targetList = isActiveInMain ? "more" : "main" + const item = actionOrder[sourceList].find((item) => item === activeId) + if (!item) return + const newIndexOfOver = actionOrder[targetList].indexOf(overId) + setUISetting("toolbarOrder", { + ...actionOrder, + [sourceList]: actionOrder[sourceList].filter((item) => item !== activeId), + [targetList]: [ + ...actionOrder[targetList].slice(0, newIndexOfOver), + item, + ...actionOrder[targetList].slice(newIndexOfOver), + ], + }) + return + } + // Reordering within container + const list = isActiveInMain ? "main" : "more" + const items = actionOrder[list] + const oldIndex = items.indexOf(activeId) + const newIndex = items.indexOf(overId) + + setUISetting("toolbarOrder", { + ...actionOrder, + [list]: arrayMove(items, oldIndex, newIndex), + }) + }, + [actionOrder], + ) + + const resetActionOrder = useCallback(() => { + setUISetting("toolbarOrder", DEFAULT_ACTION_ORDER) + }, []) + + return ( + <div className="mx-auto w-full max-w-[800px] space-y-4"> + <div className="mb-4"> + <h2 className="text-lg font-semibold">Quick Actions</h2> + <p className="text-sm text-gray-500">Customize and reorder your frequently used actions</p> + </div> + {/* Refer to https://github.com/clauderic/dnd-kit/blob/master/stories/2%20-%20Presets/Sortable/MultipleContainers.tsx */} + <DndContext sensors={sensors} collisionDetection={closestCenter} onDragOver={handleDragOver}> + <div className="space-y-4"> + {/* Main toolbar */} + + <DroppableContainer id="container-main"> + <SortableContext + items={actionOrder.main.map((item) => item)} + strategy={verticalListSortingStrategy} + > + {actionOrder.main.map((id) => ( + <SortableActionButton key={id} id={id} /> + ))} + </SortableContext> + </DroppableContainer> + + {/* More panel */} + <div className="mb-4"> + <h2 className="text-lg font-semibold">More Actions</h2> + <p className="text-sm text-gray-500">Will be shown in the dropdown menu</p> + </div> + + <DroppableContainer id="container-more"> + <SortableContext + items={actionOrder.more.map((item) => item)} + strategy={verticalListSortingStrategy} + > + {actionOrder.more.map((id) => ( + <SortableActionButton key={id} id={id} /> + ))} + </SortableContext> + </DroppableContainer> + </div> + </DndContext> + + <div className="flex justify-end"> + <Button variant="outline" onClick={resetActionOrder}> + Reset to Default Layout + </Button> + </div> + </div> + ) +} + +export const useShowCustomizeToolbarModal = () => { + const [t] = useTranslation("settings") + const { present } = useModalStack() + + return useCallback(() => { + present({ + id: "customize-toolbar", + title: t("customizeToolbar.title"), + content: () => <CustomizeToolbar />, + overlay: true, + clickOutsideToDismiss: true, + }) + }, [present, t]) +} diff --git a/apps/renderer/src/modules/entry-content/actions/header-actions.tsx b/apps/renderer/src/modules/entry-content/actions/header-actions.tsx index 0f8318ac34..344a64379b 100644 --- a/apps/renderer/src/modules/entry-content/actions/header-actions.tsx +++ b/apps/renderer/src/modules/entry-content/actions/header-actions.tsx @@ -6,10 +6,12 @@ import { shortcuts } from "~/constants/shortcuts" import { useEntryActions } from "~/hooks/biz/useEntryActions" import { COMMAND_ID } from "~/modules/command/commands/id" import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey" +import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks" import { useEntry } from "~/store/entry/hooks" export const EntryHeaderActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => { const actionConfigs = useEntryActions({ entryId, view }) + const orderMap = useToolbarOrderMap() const entry = useEntry(entryId) const hasModal = useHasModal() @@ -22,18 +24,16 @@ export const EntryHeaderActions = ({ entryId, view }: { entryId: string; view?: }) return actionConfigs - .filter( - (item) => - !item.id.startsWith("integration") && - !( - [ - COMMAND_ID.entry.read, - COMMAND_ID.entry.unread, - COMMAND_ID.entry.copyLink, - COMMAND_ID.entry.openInBrowser, - ] as string[] - ).includes(item.id), - ) + .filter((item) => { + const order = orderMap.get(item.id) + if (!order) return false + return order.type === "main" + }) + .sort((a, b) => { + const orderA = orderMap.get(a.id)?.order || 0 + const orderB = orderMap.get(b.id)?.order || 0 + return orderA - orderB + }) .map((config) => { return ( <CommandActionButton diff --git a/apps/renderer/src/modules/entry-content/actions/more-actions.tsx b/apps/renderer/src/modules/entry-content/actions/more-actions.tsx index cab0b16dda..e8f39d9107 100644 --- a/apps/renderer/src/modules/entry-content/actions/more-actions.tsx +++ b/apps/renderer/src/modules/entry-content/actions/more-actions.tsx @@ -9,22 +9,27 @@ import { DropdownMenuTrigger, } from "~/components/ui/dropdown-menu/dropdown-menu" import { useEntryActions } from "~/hooks/biz/useEntryActions" -import { COMMAND_ID } from "~/modules/command/commands/id" import { useCommand } from "~/modules/command/hooks/use-command" import type { FollowCommandId } from "~/modules/command/types" +import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks" export const MoreActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => { const actionConfigs = useEntryActions({ entryId, view }) + const orderMap = useToolbarOrderMap() const availableActions = useMemo( () => - actionConfigs.filter( - (item) => - item.id.startsWith("integration") || - ([COMMAND_ID.entry.copyLink, COMMAND_ID.entry.openInBrowser] as string[]).includes( - item.id, - ), - ), - [actionConfigs], + actionConfigs + .filter((item) => { + const order = orderMap.get(item.id) + if (!order) return false + return order.type !== "main" + }) + .sort((a, b) => { + const orderA = orderMap.get(a.id)?.order || 0 + const orderB = orderMap.get(b.id)?.order || 0 + return orderA - orderB + }), + [actionConfigs, orderMap], ) if (availableActions.length === 0) { diff --git a/changelog/next.md b/changelog/next.md index 17888d80b6..d27f3fdffa 100644 --- a/changelog/next.md +++ b/changelog/next.md @@ -2,6 +2,9 @@ ## New Features +- **Customize toolbar**: Customize the toolbar to display the items you most frequently use. + ![Customize toolbar](https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true) + ## Improvements ## Bug Fixes diff --git a/locales/settings/en.json b/locales/settings/en.json index c377b5d8aa..c53a76421d 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -97,6 +97,7 @@ "appearance.zen_mode.description": "Zen mode is an undisturbed reading mode that allows you to focus on the content without any interference. Enabling Zen mode will hide the sidebar.", "appearance.zen_mode.label": "Zen mode", "common.give_star": "<HeartIcon />Love our product? <Link>Give us a star on GitHub!</Link>", + "customizeToolbar.title": "Customize Toolbar", "data_control.app_cache_limit.description": "The maximum size of the app cache. Once the cache reaches this size, the oldest items will be deleted to free up space.", "data_control.app_cache_limit.label": "App Cache Limit", "data_control.clean_cache.button": "Clean Cache", diff --git a/packages/shared/src/interface/settings.ts b/packages/shared/src/interface/settings.ts index b66b6b72bd..d9375de77e 100644 --- a/packages/shared/src/interface/settings.ts +++ b/packages/shared/src/interface/settings.ts @@ -52,6 +52,12 @@ export interface UISettings { pictureViewMasonry: boolean pictureViewFilterNoImage: boolean wideMode: boolean + + // Action Order + toolbarOrder: { + main: (string | number)[] + more: (string | number)[] + } } export interface IntegrationSettings { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96f838ee6a..30d64bcd7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -632,6 +632,9 @@ importers: '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@egoist/tipc': specifier: 0.3.2 version: 0.3.2(electron@33.3.0)(react@18.3.1) @@ -2253,6 +2256,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + '@dnd-kit/utilities@3.2.2': resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} peerDependencies: @@ -15739,6 +15748,13 @@ snapshots: react-dom: 18.3.1(react@18.3.1) tslib: 2.8.1 + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + '@dnd-kit/utilities@3.2.2(react@18.3.1)': dependencies: react: 18.3.1 From 6ed05cea8bff558930a94c0742abb3d350580e54 Mon Sep 17 00:00:00 2001 From: dai <dosada@gmail.com> Date: Tue, 7 Jan 2025 15:07:46 +0900 Subject: [PATCH 31/70] feat(locales): update Japanese translations for RSSHub and related settings (#2474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update ja.json * chore: update 6 ja.json files chore: update and bump 6 ja.json files. * chore: update error/ja.json fix locale/error/ja.json * chore: auto-fix linting and formatting issues * chore: update app/ja.json * chore: update ja.json * chore: update ja.json * chore: update ja.json and tested local. * chore: update ja.json update settings/ja.json * feat(i18n): update Japanese locale files with new error messages and dialog options * chore(vscode): set default formatter for JSON and JSONC files * Remove JSON and JSONC formatter settings so sorry, @hyoban 😢 Perhaps my local settings are in. I have restored it to the original. Thanks for your kindness. You have always been a great help. * chore: auto-fix linting and formatting issues * chore: update ja.json sync and update settings/ja.json * chore: update Japanese translations for clarity and consistency * chore: update some ja.json * chore(locales): update Japanese translations with new entry actions and settings * fix: update Japanese translations for user actions and login prompts * feat(locales): add Japanese translations for login and registration features * feat(locales): update Japanese translations for RSSHub and related settings * chore: auto-fix linting and formatting issues --------- Co-authored-by: dai <dai@users.noreply.github.com> Co-authored-by: Innei <i@innei.in> Co-authored-by: Innei <Innei@users.noreply.github.com> --- locales/common/ja.json | 1 + locales/settings/ja.json | 42 +++++++++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/locales/common/ja.json b/locales/common/ja.json index a3e3e4afb3..8b761d1653 100644 --- a/locales/common/ja.json +++ b/locales/common/ja.json @@ -36,6 +36,7 @@ "words.result": "結果", "words.result_one": "結果", "words.result_other": "結果", + "words.rsshub": "RSSHub", "words.save": "保存", "words.submit": "送信", "words.update": "更新", diff --git a/locales/settings/ja.json b/locales/settings/ja.json index 1a2e6b5def..c2445f82b5 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -3,7 +3,7 @@ "about.feedbackInfo": "{{appName}} ({{commitSha}}) は開発の初期段階にあります。フィードバックや提案があれば、気軽に <OpenIssueLink>GitHub で課題を報告してください</OpenIssueLink> <ExternalLinkIcon />。", "about.iconLibrary": "使用されているアイコンライブラリは <IconLibraryLink /> <ExternalLinkIcon /> によって著作権が保護されており、再配布できません。", "about.licenseInfo": "Copyright © 2024 {{appName}}. All rights reserved.", - "about.sidebar_title": "about", + "about.sidebar_title": "About", "about.socialMedia": "ソーシャルメディア", "actions.actionName": "アクション {{number}}", "actions.action_card.add": "追加", @@ -119,7 +119,13 @@ "general.data_persist.label": "オフライン使用のためにデータを保持", "general.export.button": "エクスポート", "general.export.description": "あなたのフィードを OPML ファイルにエクスポートします。", + "general.export.folder_mode.description": "エクスポートするフォルダーを決めて管理します。", + "general.export.folder_mode.label": "フォルダーモード", + "general.export.folder_mode.option.category": "カテゴリー", + "general.export.folder_mode.option.view": "表示", "general.export.label": "フィードをエクスポート", + "general.export.rsshub_url.description": "RSSHub ルートの基準となるデフォルト URL を指定します、空欄だと https://rsshub.app で設定します。", + "general.export.rsshub_url.label": "RSSHub URL", "general.export_database.button": "エクスポート", "general.export_database.description": "データベースを JSON ファイルでエクスポート", "general.export_database.label": "データベースをエクスポート", @@ -260,6 +266,9 @@ "profile.change_password.label": "パスワードを変更", "profile.confirm_password.label": "パスワードの確認", "profile.current_password.label": "現在のパスワード", + "profile.email.change": "Email を変更", + "profile.email.changed": "Email 変更しました。", + "profile.email.changed_verification_sent": "新たな Email に確認メールを送信しました。", "profile.email.label": "Email", "profile.email.send_verification": "確認メールを送信する", "profile.email.unverified": "未確認", @@ -276,12 +285,30 @@ "profile.title": "プロフィール設定", "profile.updateSuccess": "プロフィールが更新されました。", "profile.update_password_success": "パスワードが更新されました。", - "titles.about": "about", + "rsshub.add_new_instance": "新たなインスタンスを追加", + "rsshub.description": "RSSHub コミュニティ駆動のオープンソース RSS ネットワークです。Follow は内蔵の専用インスタンスを提供し、そのインスタンスを使って数千のサブスクリプションコンテンツをサポートします。また独自あるいはサードパーティのインスタンスを使用することで、より安定したコンテンツ取得を実現できます。", + "rsshub.public_instances": "利用可能なインスタンス", + "rsshub.table.description": "説明", + "rsshub.table.edit": "編集", + "rsshub.table.inuse": "利用中", + "rsshub.table.owner": "所有者", + "rsshub.table.price": "月額の費用", + "rsshub.table.unlimited": "無制限", + "rsshub.table.use": "利用", + "rsshub.table.userCount": "ユーザー数", + "rsshub.table.userLimit": "ユーザー制限", + "rsshub.useModal.about": "このインスタンスについて", + "rsshub.useModal.month": "月", + "rsshub.useModal.months_label": "購入したい月数", + "rsshub.useModal.purchase_expires_at": "このインスタンスを購入しました、利用期限は", + "rsshub.useModal.title": "RSSHub インスタンス", + "rsshub.useModal.useWith": "使用する {{amount}} <Power />", + "titles.about": "About", "titles.actions": "アクション", "titles.appearance": "外観", - "titles.data_control": "データのコントロール", + "titles.data_control": "データコントロール", "titles.feeds": "フィード", - "titles.general": "一般", + "titles.general": "一般設定", "titles.integration": "統合", "titles.invitations": "招待", "titles.lists": "リスト", @@ -297,10 +324,10 @@ "wallet.claim.button.claim": "デイリー Power を取得", "wallet.claim.button.claimed": "今日取得済み", "wallet.claim.tooltip.alreadyClaimed": "今日はすでに取得済みです。", - "wallet.claim.tooltip.canClaim": "{{amount}}のデイリー Power を今すぐ取得しましょう!", + "wallet.claim.tooltip.canClaim": "{{amount}} のデイリー Power を今すぐ取得しましょう!", "wallet.create.button": "ウォレットを作成", "wallet.create.description": "<PowerIcon /> <strong>Power</strong>を受け取るための無料ウォレットを作成し、クリエイターに報酬を与えたり、コンテンツ貢献で報酬を得ることができます。", - "wallet.power.dailyClaim": "毎日{{amount}}の無料 Power を取得でき、それを使って Follow の RSS エントリにチップを送ることができます。", + "wallet.power.dailyClaim": "毎日 {{amount}} の無料 Power を取得でき、それを使って Follow の RSS エントリにチップを送ることができます。", "wallet.power.description2": "Power はブロックチェーン {{blockchainName}} 上にある <Link>ERC-20 トークン</Link> で、Follow 内でのチップや購入に使用できます。", "wallet.power.rewardDescription": "Follow のすべてのアクティブ ユーザーは、デイリー Power 特典の対象となります。", "wallet.power.rewardDescription2": "あなたのレベルや日々のアクティビティによって <Balance /> が本日の報酬として提供されます。<Link>さらなる情報はこちら</Link>", @@ -343,5 +370,6 @@ "wallet.withdraw.error": "引き出しに失敗しました:{{error}}", "wallet.withdraw.modalTitle": "Power を引き出す", "wallet.withdraw.submitButton": "送信", - "wallet.withdraw.success": "引き出しが成功しました!" + "wallet.withdraw.success": "引き出しが成功しました!", + "wallet.withdraw.toRss3Label": "RSS3 で引き出す" } From 265023126e78501f1bca4866e46adb939662896d Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Tue, 7 Jan 2025 16:50:14 +0800 Subject: [PATCH 32/70] refactor(rn): loading component Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/package.json | 1 + apps/mobile/postcss.config.js | 6 +- .../common/FullWindowOverlay.ios.tsx | 1 + .../components/common/FullWindowOverlay.tsx | 1 + .../components/common/LoadingContainer.tsx | 121 ------------------ .../common/ModalSharedComponents.tsx | 36 +++++- .../components/common/RotateableLoading.tsx | 39 ++++++ apps/mobile/src/lib/loading.tsx | 60 +++++++++ apps/mobile/src/main.tsx | 16 ++- apps/mobile/src/screens/(headless)/debug.tsx | 13 ++ .../src/screens/(modal)/rsshub-form.tsx | 47 +++---- apps/mobile/src/screens/_layout.tsx | 2 - pnpm-lock.yaml | 8 ++ 13 files changed, 200 insertions(+), 151 deletions(-) create mode 100644 apps/mobile/src/components/common/FullWindowOverlay.ios.tsx create mode 100644 apps/mobile/src/components/common/FullWindowOverlay.tsx delete mode 100644 apps/mobile/src/components/common/LoadingContainer.tsx create mode 100644 apps/mobile/src/components/common/RotateableLoading.tsx create mode 100644 apps/mobile/src/lib/loading.tsx diff --git a/apps/mobile/package.json b/apps/mobile/package.json index df72e915a9..790d8dfa54 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -70,6 +70,7 @@ "react-native-keyboard-controller": "^1.15.0", "react-native-pager-view": "6.6.1", "react-native-reanimated": "~3.16.5", + "react-native-root-siblings": "5.0.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.1.0", "react-native-svg": "15.8.0", diff --git a/apps/mobile/postcss.config.js b/apps/mobile/postcss.config.js index c0c06b2e4b..ef15a530ac 100644 --- a/apps/mobile/postcss.config.js +++ b/apps/mobile/postcss.config.js @@ -1,8 +1,8 @@ module.exports = { plugins: { - tailwindcss: { - config: "./tailwind.dom.config.ts", - }, + // tailwindcss: { + // config: "./tailwind.dom.config.ts", + // }, autoprefixer: {}, }, } diff --git a/apps/mobile/src/components/common/FullWindowOverlay.ios.tsx b/apps/mobile/src/components/common/FullWindowOverlay.ios.tsx new file mode 100644 index 0000000000..2bcdac7b69 --- /dev/null +++ b/apps/mobile/src/components/common/FullWindowOverlay.ios.tsx @@ -0,0 +1 @@ +export { FullWindowOverlay } from "react-native-screens" diff --git a/apps/mobile/src/components/common/FullWindowOverlay.tsx b/apps/mobile/src/components/common/FullWindowOverlay.tsx new file mode 100644 index 0000000000..391ba102e7 --- /dev/null +++ b/apps/mobile/src/components/common/FullWindowOverlay.tsx @@ -0,0 +1 @@ +export { Fragment as FullWindowOverlay } from "react" diff --git a/apps/mobile/src/components/common/LoadingContainer.tsx b/apps/mobile/src/components/common/LoadingContainer.tsx deleted file mode 100644 index 6c8ee836e4..0000000000 --- a/apps/mobile/src/components/common/LoadingContainer.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useAtom } from "jotai" -import { useCallback, useEffect, useRef, useState } from "react" -import { Text, TouchableOpacity, View } from "react-native" -import Animated, { - Easing, - FadeIn, - FadeOut, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from "react-native-reanimated" - -import { loadingAtom, loadingVisibleAtom } from "@/src/atoms/app" -import { Loading3CuteReIcon } from "@/src/icons/loading_3_cute_re" - -import { BlurEffect } from "./HeaderBlur" - -export const LoadingContainer = () => { - const rotate = useSharedValue(0) - - const [visible, setVisible] = useAtom(loadingVisibleAtom) - const [showCancelButton, setShowCancelButton] = useState(false) - - const [loadingCaller, setLoadingCaller] = useAtom(loadingAtom) - - const resetLoadingCaller = useCallback(() => { - setLoadingCaller({ - finish: null, - cancel: null, - thenable: null, - done: null, - error: null, - }) - }, [setLoadingCaller]) - - useEffect(() => { - rotate.value = withRepeat( - withTiming(360, { duration: 1000, easing: Easing.linear }), - Infinity, - false, - ) - return () => { - rotate.value = 0 - } - }, [rotate]) - - useEffect(() => { - if (loadingCaller.thenable) { - loadingCaller.thenable - .then((r) => { - loadingCaller.done?.(r) - }) - .catch((err) => { - console.error(err) - loadingCaller.error?.(err) - }) - .finally(() => { - setVisible(false) - setShowCancelButton(false) - - resetLoadingCaller() - - loadingCaller.finish?.() - }) - } - }, [loadingCaller.thenable, loadingCaller.done, loadingCaller.error, loadingCaller.finish]) - - const cancelTimerRef = useRef<NodeJS.Timeout | null>(null) - useEffect(() => { - cancelTimerRef.current = setTimeout(() => { - setShowCancelButton(true) - }, 3000) - return () => { - if (cancelTimerRef.current) { - clearTimeout(cancelTimerRef.current) - } - } - }, []) - - const rotateStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${rotate.value}deg` }], - })) - - const cancel = () => { - setVisible(false) - setShowCancelButton(false) - - if (loadingCaller.cancel) { - loadingCaller.cancel() - } - resetLoadingCaller() - } - - if (!visible) { - return null - } - - return ( - <Animated.View - entering={FadeIn} - exiting={FadeOut} - className="absolute inset-0 flex-1 items-center justify-center" - > - <View className="border-system-fill/40 relative rounded-2xl border p-12"> - <BlurEffect /> - <Animated.View style={rotateStyle}> - <Loading3CuteReIcon height={36} width={36} color="#fff" /> - </Animated.View> - </View> - - {showCancelButton && ( - <View className="absolute inset-x-0 bottom-24 flex-row justify-center"> - <TouchableOpacity onPress={cancel}> - <Text className="text-center text-lg text-accent">Cancel</Text> - </TouchableOpacity> - </View> - )} - </Animated.View> - ) -} diff --git a/apps/mobile/src/components/common/ModalSharedComponents.tsx b/apps/mobile/src/components/common/ModalSharedComponents.tsx index 4ad15e3793..04d7bfe4b8 100644 --- a/apps/mobile/src/components/common/ModalSharedComponents.tsx +++ b/apps/mobile/src/components/common/ModalSharedComponents.tsx @@ -1,9 +1,13 @@ +import { withOpacity } from "@follow/utils" import { router } from "expo-router" import { TouchableOpacity } from "react-native" +import { CheckLineIcon } from "@/src/icons/check_line" import { CloseCuteReIcon } from "@/src/icons/close_cute_re" import { useColor } from "@/src/theme/colors" +import { RotateableLoading } from "./RotateableLoading" + export const ModalHeaderCloseButton = () => { return <ModalHeaderCloseButtonImpl /> } @@ -12,7 +16,37 @@ const ModalHeaderCloseButtonImpl = () => { const label = useColor("label") return ( <TouchableOpacity onPress={() => router.dismiss()}> - <CloseCuteReIcon color={label} /> + <CloseCuteReIcon height={20} width={20} color={label} /> + </TouchableOpacity> + ) +} + +export interface ModalHeaderShubmitButtonProps { + isValid: boolean + onPress: () => void + isLoading?: boolean +} +export const ModalHeaderShubmitButton = ({ + isValid, + onPress, + isLoading, +}: ModalHeaderShubmitButtonProps) => { + return <ModalHeaderShubmitButtonImpl isValid={isValid} onPress={onPress} isLoading={isLoading} /> +} + +const ModalHeaderShubmitButtonImpl = ({ + isValid, + onPress, + isLoading, +}: ModalHeaderShubmitButtonProps) => { + const label = useColor("label") + return ( + <TouchableOpacity onPress={onPress} disabled={!isValid || isLoading}> + {isLoading ? ( + <RotateableLoading size={20} color={withOpacity(label, 0.5)} /> + ) : ( + <CheckLineIcon height={20} width={20} color={isValid ? label : withOpacity(label, 0.5)} /> + )} </TouchableOpacity> ) } diff --git a/apps/mobile/src/components/common/RotateableLoading.tsx b/apps/mobile/src/components/common/RotateableLoading.tsx new file mode 100644 index 0000000000..805ab1f79d --- /dev/null +++ b/apps/mobile/src/components/common/RotateableLoading.tsx @@ -0,0 +1,39 @@ +import type { FC } from "react" +import { useEffect } from "react" +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated" + +import { Loading3CuteReIcon } from "@/src/icons/loading_3_cute_re" + +export interface RotateableLoadingProps { + size?: number + color?: string +} +export const RotateableLoading: FC<RotateableLoadingProps> = ({ size = 36, color = "#fff" }) => { + const rotate = useSharedValue(0) + useEffect(() => { + rotate.value = withRepeat( + withTiming(360, { duration: 1000, easing: Easing.linear }), + Infinity, + false, + ) + return () => { + rotate.value = 0 + } + }, [rotate]) + + const rotateStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotate.value}deg` }], + })) + + return ( + <Animated.View style={rotateStyle}> + <Loading3CuteReIcon height={size} width={size} color={color} /> + </Animated.View> + ) +} diff --git a/apps/mobile/src/lib/loading.tsx b/apps/mobile/src/lib/loading.tsx new file mode 100644 index 0000000000..a90defc168 --- /dev/null +++ b/apps/mobile/src/lib/loading.tsx @@ -0,0 +1,60 @@ +import type { FC } from "react" +import { useEffect, useRef, useState } from "react" +import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native" +import RootSiblings from "react-native-root-siblings" + +import { FullWindowOverlay } from "../components/common/FullWindowOverlay" +import { RotateableLoading } from "../components/common/RotateableLoading" +import { CloseCuteReIcon } from "../icons/close_cute_re" + +class LoadingStatic { + async start<T>(promise: Promise<T>) { + const siblings = new RootSiblings(<LoadingContainer cancel={() => siblings.destroy()} />) + + try { + return await promise + } finally { + siblings.destroy() + } + } +} + +export const loading = new LoadingStatic() + +const LoadingContainer: FC<{ + cancel: () => void +}> = ({ cancel }) => { + const cancelTimerRef = useRef<NodeJS.Timeout | null>(null) + + const [showCancelButton, setShowCancelButton] = useState(false) + useEffect(() => { + cancelTimerRef.current = setTimeout(() => { + setShowCancelButton(true) + }, 3000) + return () => { + if (cancelTimerRef.current) { + clearTimeout(cancelTimerRef.current) + } + } + }, []) + + return ( + <FullWindowOverlay> + {/* Pressable to prevent the overlay from being clicked */} + <Pressable style={StyleSheet.absoluteFillObject} className="items-center justify-center"> + <View className="relative rounded-2xl border border-white/20 bg-black/90 p-12"> + <RotateableLoading /> + </View> + {showCancelButton && ( + <View className="absolute inset-x-0 bottom-24 flex-row justify-center"> + <TouchableOpacity onPress={cancel}> + <View className="rounded-full border border-white/30 p-2"> + <CloseCuteReIcon color="gray" height={20} width={20} /> + </View> + </TouchableOpacity> + </View> + )} + </Pressable> + </FullWindowOverlay> + ) +} diff --git a/apps/mobile/src/main.tsx b/apps/mobile/src/main.tsx index 5ebc2d8a8d..1fddfc654e 100644 --- a/apps/mobile/src/main.tsx +++ b/apps/mobile/src/main.tsx @@ -1,12 +1,24 @@ import "@expo/metro-runtime" +import { registerRootComponent } from "expo" import { App } from "expo-router/build/qualified-entry" -import { renderRootComponent } from "expo-router/build/renderRootComponent" +import { RootSiblingParent } from "react-native-root-siblings" +// import { renderRootComponent } from "expo" import { initializeApp } from "./initialize" initializeApp().then(() => { // This file should only import and register the root. No components or exports // should be added here. - renderRootComponent(App) + // renderRootComponent(App) }) + +const MApp = () => { + return ( + <RootSiblingParent> + <App /> + </RootSiblingParent> + ) +} + +registerRootComponent(MApp) diff --git a/apps/mobile/src/screens/(headless)/debug.tsx b/apps/mobile/src/screens/(headless)/debug.tsx index ba75dc77b5..af1903bc00 100644 --- a/apps/mobile/src/screens/(headless)/debug.tsx +++ b/apps/mobile/src/screens/(headless)/debug.tsx @@ -1,3 +1,4 @@ +import { sleep } from "@follow/utils" import * as Clipboard from "expo-clipboard" import * as FileSystem from "expo-file-system" import { Sitemap } from "expo-router/build/views/Sitemap" @@ -18,6 +19,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context" import { getDbPath } from "@/src/database" import { clearSessionToken, getSessionToken, setSessionToken } from "@/src/lib/cookie" +import { loading } from "@/src/lib/loading" interface MenuSection { title: string @@ -84,6 +86,17 @@ export default function DebugPanel() { }, ], }, + { + title: "Debug", + items: [ + { + title: "Loading", + onPress: () => { + loading.start(sleep(2000)) + }, + }, + ], + }, { title: "App", diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index ce6f9bc014..f2f5206abc 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -4,28 +4,26 @@ import { parseFullPathParams, parseRegexpPathParams, regexpPathToPath, - withOpacity, } from "@follow/utils" import { PortalProvider } from "@gorhom/portal" import { zodResolver } from "@hookform/resolvers/zod" import { router, Stack, useLocalSearchParams } from "expo-router" -import { memo, useEffect, useMemo } from "react" +import { memo, useEffect, useMemo, useState } from "react" import { Controller, useForm } from "react-hook-form" import { Linking, Text, TouchableOpacity, View } from "react-native" import { KeyboardAwareScrollView } from "react-native-keyboard-controller" import { z } from "zod" import { HeaderTitleExtra } from "@/src/components/common/HeaderTitleExtra" -import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents" +import { + ModalHeaderCloseButton, + ModalHeaderShubmitButton, +} from "@/src/components/common/ModalSharedComponents" import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvider" 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 { useLoadingCallback } from "@/src/hooks/useLoadingCallback" -import { CheckLineIcon } from "@/src/icons/check_line" import { feedSyncServices } from "@/src/store/feed/store" -import type { FeedModel } from "@/src/store/feed/types" -import { useColor } from "@/src/theme/colors" interface RsshubFormParams { route: RSSHubRoute @@ -268,10 +266,10 @@ const routeParamsKeyPrefix = "route-params-" const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitButtonProps) => { const form = useFormContext() - const label = useColor("label") const { isValid } = form.formState - const loadingFn = useLoadingCallback() + const [isLoading, setIsLoading] = useState(false) + const submit = form.handleSubmit((_data) => { const data = Object.fromEntries( Object.entries(_data).filter(([key]) => !key.startsWith(routeParamsKeyPrefix)), @@ -294,21 +292,30 @@ const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitBu const finalUrl = routeParamsPath ? `${url}/${routeParamsPath}` : url - if (router.canDismiss()) { - router.dismiss() - } + // if (router.canDismiss()) { + // router.dismiss() + // } - loadingFn(feedSyncServices.fetchFeedById({ url: finalUrl }), { - done: (feed) => { + setIsLoading(true) + + feedSyncServices + .fetchFeedById({ url: finalUrl }) + .then((feed) => { router.push({ pathname: "/follow", params: { url: finalUrl, - id: (feed as FeedModel)?.id, + id: feed?.id, }, }) - }, - }) + }) + .catch(() => { + // TODO impl toast + // toast.error("Failed to fetch feed") + }) + .finally(() => { + setIsLoading(false) + }) } catch (err: unknown) { if (err instanceof MissingOptionalParamError) { // toast.error(err.message) @@ -320,9 +327,5 @@ const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitBu } }) - return ( - <TouchableOpacity onPress={submit} disabled={!isValid}> - <CheckLineIcon color={isValid ? label : withOpacity(label, 0.5)} /> - </TouchableOpacity> - ) + return <ModalHeaderShubmitButton isLoading={isLoading} isValid={isValid} onPress={submit} /> } diff --git a/apps/mobile/src/screens/_layout.tsx b/apps/mobile/src/screens/_layout.tsx index 6dc4754a10..fb24dff408 100644 --- a/apps/mobile/src/screens/_layout.tsx +++ b/apps/mobile/src/screens/_layout.tsx @@ -3,7 +3,6 @@ import "../global.css" import { Stack } from "expo-router" import { useColorScheme } from "nativewind" -import { LoadingContainer } from "../components/common/LoadingContainer" import { DebugButton } from "../modules/debug" import { RootProviders } from "../providers" import { usePrefetchSessionUser } from "../store/user/hooks" @@ -29,7 +28,6 @@ export default function RootLayout() { </Stack> {__DEV__ && <DebugButton />} - <LoadingContainer /> </RootProviders> ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30d64bcd7c..1500a748ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,6 +565,9 @@ importers: react-native-reanimated: specifier: ~3.16.5 version: 3.16.5(@babel/core@7.26.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))(react@18.3.1) + react-native-root-siblings: + specifier: 5.0.1 + version: 5.0.1 react-native-safe-area-context: specifier: 4.12.0 version: 4.12.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))(react@18.3.1) @@ -12368,6 +12371,9 @@ packages: react: '*' react-native: '*' + react-native-root-siblings@5.0.1: + resolution: {integrity: sha512-Ay3k/fBj6ReUkWX5WNS+oEAcgPLEGOK8n7K/L7D85mf3xvd8rm/b4spsv26E4HlFzluVx5HKbxEt9cl0wQ1u3g==} + react-native-safe-area-context@4.12.0: resolution: {integrity: sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==} peerDependencies: @@ -28384,6 +28390,8 @@ snapshots: transitivePeerDependencies: - supports-color + react-native-root-siblings@5.0.1: {} + react-native-safe-area-context@4.12.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))(react@18.3.1): dependencies: react: 18.3.1 From cf31c0f5fae3fd6dac1e1e571e870ea57e05ed0b Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Tue, 7 Jan 2025 17:02:11 +0800 Subject: [PATCH 33/70] feat(modal): enhance Follow modal with improved navigation and loading state Signed-off-by: Innei <tukon479@gmail.com> --- .../common/ModalSharedComponents.tsx | 16 +++++++++-- apps/mobile/src/icons/mingcute_left_line.tsx | 26 +++++++++++++++++ apps/mobile/src/screens/(modal)/follow.tsx | 28 +++++++++++-------- 3 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/icons/mingcute_left_line.tsx diff --git a/apps/mobile/src/components/common/ModalSharedComponents.tsx b/apps/mobile/src/components/common/ModalSharedComponents.tsx index 04d7bfe4b8..e095bf043d 100644 --- a/apps/mobile/src/components/common/ModalSharedComponents.tsx +++ b/apps/mobile/src/components/common/ModalSharedComponents.tsx @@ -1,9 +1,10 @@ import { withOpacity } from "@follow/utils" -import { router } from "expo-router" +import { router, useNavigation } from "expo-router" import { TouchableOpacity } from "react-native" import { CheckLineIcon } from "@/src/icons/check_line" import { CloseCuteReIcon } from "@/src/icons/close_cute_re" +import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line" import { useColor } from "@/src/theme/colors" import { RotateableLoading } from "./RotateableLoading" @@ -14,9 +15,20 @@ export const ModalHeaderCloseButton = () => { const ModalHeaderCloseButtonImpl = () => { const label = useColor("label") + + const navigation = useNavigation() + + const state = navigation.getState() + + const routeOnlyOne = state.routes.length === 1 + return ( <TouchableOpacity onPress={() => router.dismiss()}> - <CloseCuteReIcon height={20} width={20} color={label} /> + {routeOnlyOne ? ( + <CloseCuteReIcon height={20} width={20} color={label} /> + ) : ( + <MingcuteLeftLineIcon height={20} width={20} color={label} /> + )} </TouchableOpacity> ) } diff --git a/apps/mobile/src/icons/mingcute_left_line.tsx b/apps/mobile/src/icons/mingcute_left_line.tsx new file mode 100644 index 0000000000..841a3b1a29 --- /dev/null +++ b/apps/mobile/src/icons/mingcute_left_line.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import Svg, { G, Path } from "react-native-svg" + +interface MingcuteLeftLineIconProps { + width?: number + height?: number + color?: string +} + +export const MingcuteLeftLineIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: MingcuteLeftLineIconProps) => { + return ( + <Svg width={width} height={height} viewBox="0 0 24 24"> + <G fill="none" fillRule="evenodd"> + <Path d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01-.184-.092Z" /> + <Path + fill={color} + d="M8.293 12.707a1 1 0 0 1 0-1.414l5.657-5.657a1 1 0 1 1 1.414 1.414L10.414 12l4.95 4.95a1 1 0 0 1-1.414 1.414l-5.657-5.657Z" + /> + </G> + </Svg> + ) +} diff --git a/apps/mobile/src/screens/(modal)/follow.tsx b/apps/mobile/src/screens/(modal)/follow.tsx index 2ebe599f68..653bc96aac 100644 --- a/apps/mobile/src/screens/(modal)/follow.tsx +++ b/apps/mobile/src/screens/(modal)/follow.tsx @@ -1,23 +1,24 @@ import { FeedViewType } from "@follow/constants" -import { withOpacity } from "@follow/utils" import { zodResolver } from "@hookform/resolvers/zod" import { router, Stack, useLocalSearchParams } from "expo-router" +import { useState } from "react" import { Controller, useForm } from "react-hook-form" -import { ScrollView, Text, TouchableOpacity, View } from "react-native" +import { ScrollView, Text, View } from "react-native" import { z } from "zod" -import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents" +import { + ModalHeaderCloseButton, + ModalHeaderShubmitButton, +} from "@/src/components/common/ModalSharedComponents" import { FormProvider } from "@/src/components/ui/form/FormProvider" import { FormLabel } from "@/src/components/ui/form/Label" import { FormSwitch } from "@/src/components/ui/form/Switch" import { TextField } from "@/src/components/ui/form/TextField" import { FeedIcon } from "@/src/components/ui/icon/feed-icon" -import { CheckLineIcon } from "@/src/icons/check_line" import { FeedViewSelector } from "@/src/modules/feed/view-selector" import { useFeed } from "@/src/store/feed/hooks" import { subscriptionSyncService } from "@/src/store/subscription/store" import type { SubscriptionForm } from "@/src/store/subscription/types" -import { useColor } from "@/src/theme/colors" const formSchema = z.object({ view: z.string(), @@ -39,7 +40,9 @@ export default function Follow() { defaultValues, }) + const [isLoading, setIsLoading] = useState(false) const submit = async () => { + setIsLoading(true) const values = form.getValues() const body: SubscriptionForm = { url: feed.url, @@ -50,15 +53,16 @@ export default function Follow() { feedId: feed.id, } - await subscriptionSyncService.subscribe(body) + await subscriptionSyncService.subscribe(body).finally(() => { + setIsLoading(false) + }) if (router.canDismiss()) { - router.dismiss() + router.dismissAll() } } const { isValid, isDirty } = form.formState - const label = useColor("label") return ( <ScrollView contentContainerClassName="px-2 pt-4 gap-y-4"> @@ -68,9 +72,11 @@ export default function Follow() { headerLeft: ModalHeaderCloseButton, gestureEnabled: !isDirty, headerRight: () => ( - <TouchableOpacity onPress={form.handleSubmit(submit)} disabled={!isValid}> - <CheckLineIcon color={isValid ? label : withOpacity(label, 0.5)} /> - </TouchableOpacity> + <ModalHeaderShubmitButton + isValid={isValid} + onPress={form.handleSubmit(submit)} + isLoading={isLoading} + /> ), }} /> From 7a7dae4d19433ceca9e56d6f705e813aa57e4681 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 7 Jan 2025 17:21:33 +0800 Subject: [PATCH 34/70] chore: manually specify tailwind config file --- .vscode/settings.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b591448ff..725c5c2aa2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,6 +16,11 @@ ["[a-zA-Z]+[cC]lass[nN]ame[\"'`]?:\\s*[\"'`]([^\"'`]*)[\"'`]", "([^\"'`]*)"], ["[a-zA-Z]+[cC]lass[nN]ame\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]", "([^\"'`]*)"] ], + "tailwindCSS.experimental.configFile": { + "apps/mobile/tailwind.config.ts": "apps/mobile/**", + "apps/server/tailwind.config.ts": "apps/server/**", + "tailwind.config.ts": ["!apps/mobile/**", "!apps/server/**", "**"] + }, "typescript.tsserver.maxTsServerMemory": 8096, "typescript.tsserver.nodePath": "node", // If you do not want to autofix some rules on save From 5a7160d5af8e86c4b78e46bd2a5c141f3004c9f0 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Tue, 7 Jan 2025 18:16:34 +0800 Subject: [PATCH 35/70] fix: prevent media overflow --- apps/renderer/src/components/ui/media.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/renderer/src/components/ui/media.tsx b/apps/renderer/src/components/ui/media.tsx index 7501aec40b..dd7e8648a9 100644 --- a/apps/renderer/src/components/ui/media.tsx +++ b/apps/renderer/src/components/ui/media.tsx @@ -258,7 +258,7 @@ const MediaImpl: FC<MediaProps> = ({ } else { return ( <div - className={cn("relative rounded", className)} + className={cn("relative overflow-hidden rounded", className)} data-state={mediaLoadState} style={props.style} > From 950b5af8ec0316875ed3ce5a99baaaecb56c6d37 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Wed, 8 Jan 2025 00:05:26 +0800 Subject: [PATCH 36/70] feat(rn-component): implement toast manager Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/postcss.config.js | 3 - .../src/components/ui/toast/CenteredToast.tsx | 74 ++++++++++++ .../components/ui/toast/ToastContainer.tsx | 50 ++++++++ .../src/components/ui/toast/constants.ts | 9 ++ apps/mobile/src/components/ui/toast/ctx.tsx | 13 +++ .../src/components/ui/toast/manager.tsx | 108 ++++++++++++++++++ apps/mobile/src/components/ui/toast/types.ts | 31 +++++ apps/mobile/src/icons/close_circle_fill.tsx | 14 +++ apps/mobile/src/icons/info_circle_fill.tsx | 14 +++ apps/mobile/src/lib/toast.tsx | 26 +++++ apps/mobile/src/screens/(headless)/debug.tsx | 11 ++ .../src/screens/(modal)/rsshub-form.tsx | 6 +- packages/utils/src/color.ts | 6 +- 13 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/components/ui/toast/CenteredToast.tsx create mode 100644 apps/mobile/src/components/ui/toast/ToastContainer.tsx create mode 100644 apps/mobile/src/components/ui/toast/constants.ts create mode 100644 apps/mobile/src/components/ui/toast/ctx.tsx create mode 100644 apps/mobile/src/components/ui/toast/manager.tsx create mode 100644 apps/mobile/src/components/ui/toast/types.ts create mode 100644 apps/mobile/src/icons/close_circle_fill.tsx create mode 100644 apps/mobile/src/icons/info_circle_fill.tsx create mode 100644 apps/mobile/src/lib/toast.tsx diff --git a/apps/mobile/postcss.config.js b/apps/mobile/postcss.config.js index ef15a530ac..90d9fffcb1 100644 --- a/apps/mobile/postcss.config.js +++ b/apps/mobile/postcss.config.js @@ -1,8 +1,5 @@ module.exports = { plugins: { - // tailwindcss: { - // config: "./tailwind.dom.config.ts", - // }, autoprefixer: {}, }, } diff --git a/apps/mobile/src/components/ui/toast/CenteredToast.tsx b/apps/mobile/src/components/ui/toast/CenteredToast.tsx new file mode 100644 index 0000000000..b64321776e --- /dev/null +++ b/apps/mobile/src/components/ui/toast/CenteredToast.tsx @@ -0,0 +1,74 @@ +import { withOpacity } from "@follow/utils" +import { createElement, useContext, useEffect, useState } from "react" +import { StyleSheet, Text, View } from "react-native" +import Animated, { FadeOut } from "react-native-reanimated" + +import { toastTypeToIcons } from "./constants" +import { ToastActionContext } from "./ctx" +import type { ToastProps } from "./types" + +export const CenteredToast = (props: ToastProps) => { + const renderMessage = props.render ? null : props.message ? ( + <Text className="font-semibold text-white">{props.message}</Text> + ) : null + const { register } = useContext(ToastActionContext) + useEffect(() => { + const disposer = register(props.currentIndex, { + dimiss: async () => {}, + }) + return () => { + disposer() + } + }, [props.currentIndex, register]) + const renderIcon = + props.icon === false + ? null + : (props.icon ?? ( + <View className="mr-2"> + {createElement(toastTypeToIcons[props.type], { + color: "white", + height: 20, + width: 20, + })} + </View> + )) + + const [measureHeight, setMeasureHeight] = useState(-1) + return ( + <Animated.View + onLayout={({ nativeEvent }) => { + setMeasureHeight(nativeEvent.layout.height) + }} + exiting={FadeOut} + style={StyleSheet.flatten([ + styles.toast, + measureHeight === -1 ? styles.hidden : {}, + measureHeight > 50 ? styles.rounded : styles.roundedFull, + ])} + > + {renderIcon} + {renderMessage} + </Animated.View> + ) +} + +const styles = StyleSheet.create({ + toast: { + borderWidth: StyleSheet.hairlineWidth, + flexDirection: "row", + paddingHorizontal: 16, + paddingVertical: 12, + borderColor: withOpacity("#ffffff", 0.3), + backgroundColor: withOpacity("#000000", 0.9), + }, + + hidden: { + opacity: 0, + }, + rounded: { + borderRadius: 16, + }, + roundedFull: { + borderRadius: 9999, + }, +}) diff --git a/apps/mobile/src/components/ui/toast/ToastContainer.tsx b/apps/mobile/src/components/ui/toast/ToastContainer.tsx new file mode 100644 index 0000000000..ee6dff9d97 --- /dev/null +++ b/apps/mobile/src/components/ui/toast/ToastContainer.tsx @@ -0,0 +1,50 @@ +import { useAtomValue } from "jotai" +import { useContext, useMemo } from "react" +import { View } from "react-native" + +import { CenteredToast } from "./CenteredToast" +import { ToastContainerContext } from "./ctx" +import type { ToastProps } from "./types" + +export const ToastContainer = () => { + const stackAtom = useContext(ToastContainerContext) + const stack = useAtomValue(stackAtom) + + const { renderCenterReplaceToast, renderBottomStackToasts } = useMemo(() => { + const { centerToasts, bottomToasts } = stack.reduce( + (acc, toast) => { + if (toast.variant === "center-replace") { + acc.centerToasts.push(toast) + } else if (toast.variant === "bottom-stack") { + acc.bottomToasts.push(toast) + } + return acc + }, + { centerToasts: [] as ToastProps[], bottomToasts: [] as ToastProps[] }, + ) + + const renderCenterReplaceToast = + centerToasts.length > 0 + ? centerToasts.reduce((latest, toast) => + latest.currentIndex > toast.currentIndex ? latest : toast, + ) + : null + + const renderBottomStackToasts = bottomToasts.sort((a, b) => a.currentIndex - b.currentIndex) + + return { renderCenterReplaceToast, renderBottomStackToasts } + }, [stack]) + + void renderBottomStackToasts + + return ( + <View className="absolute inset-0" pointerEvents="box-only"> + {/* Center replace container */} + <View className="absolute inset-0 items-center justify-center px-5" pointerEvents="box-only"> + {renderCenterReplaceToast && <CenteredToast {...renderCenterReplaceToast} />} + </View> + {/* Bottom stack */} + {/* <View className="absolute bottom-safe" pointerEvents="box-only"></View> */} + </View> + ) +} diff --git a/apps/mobile/src/components/ui/toast/constants.ts b/apps/mobile/src/components/ui/toast/constants.ts new file mode 100644 index 0000000000..1d1dc5af8d --- /dev/null +++ b/apps/mobile/src/components/ui/toast/constants.ts @@ -0,0 +1,9 @@ +import { CheckCircleFilledIcon } from "@/src/icons/check_circle_filled" +import { CloseCircleFillIcon } from "@/src/icons/close_circle_fill" +import { InfoCircleFillIcon } from "@/src/icons/info_circle_fill" + +export const toastTypeToIcons = { + success: CheckCircleFilledIcon, + error: CloseCircleFillIcon, + info: InfoCircleFillIcon, +} as const diff --git a/apps/mobile/src/components/ui/toast/ctx.tsx b/apps/mobile/src/components/ui/toast/ctx.tsx new file mode 100644 index 0000000000..18e2a156bf --- /dev/null +++ b/apps/mobile/src/components/ui/toast/ctx.tsx @@ -0,0 +1,13 @@ +import type { PrimitiveAtom } from "jotai" +import { createContext } from "react" + +import type { ToastProps, ToastRef } from "./types" + +export const ToastContainerContext = createContext<PrimitiveAtom<ToastProps[]>>(null!) + +type Disposer = () => void +interface ToastActionContext { + register: (currentIndex: number, ref: ToastRef) => Disposer +} + +export const ToastActionContext = createContext<ToastActionContext>(null!) diff --git a/apps/mobile/src/components/ui/toast/manager.tsx b/apps/mobile/src/components/ui/toast/manager.tsx new file mode 100644 index 0000000000..8c10e7f614 --- /dev/null +++ b/apps/mobile/src/components/ui/toast/manager.tsx @@ -0,0 +1,108 @@ +import { jotaiStore } from "@follow/utils" +import { atom, Provider } from "jotai" +import RootSiblings from "react-native-root-siblings" + +import { FullWindowOverlay } from "../../common/FullWindowOverlay" +import { ToastActionContext, ToastContainerContext } from "./ctx" +import { ToastContainer } from "./ToastContainer" +import type { BottomToastProps, CenterToastProps, ToastProps, ToastRef } from "./types" + +export class ToastManager { + private stackAtom = atom<ToastProps[]>([]) + private portal: RootSiblings | null = null + + private propsMap = {} as Record<number, ToastProps> + private currentIndex = 0 + + private defaultProps: Omit<ToastProps, "currentIndex"> = { + duration: 3000, + action: [], + type: "info", + variant: "bottom-stack", + message: "", + render: null, + icon: null, + canClose: true, + } + + private toastRefs = {} as Record<number, ToastRef> + + private register(currentIndex: number, ref: ToastRef) { + this.toastRefs[currentIndex] = ref + return () => { + delete this.toastRefs[currentIndex] + } + } + + mount() { + this.portal = new RootSiblings( + ( + <FullWindowOverlay> + <Provider store={jotaiStore}> + <ToastContainerContext.Provider value={this.stackAtom}> + <ToastActionContext.Provider value={{ register: this.register.bind(this) }}> + <ToastContainer /> + </ToastActionContext.Provider> + </ToastContainerContext.Provider> + </Provider> + </FullWindowOverlay> + ), + ) + } + + private push(props: ToastProps) { + this.propsMap[props.currentIndex] = props + jotaiStore.set(this.stackAtom, [...jotaiStore.get(this.stackAtom), props]) + } + + private remove(index: number) { + delete this.propsMap[index] + jotaiStore.set( + this.stackAtom, + jotaiStore.get(this.stackAtom).filter((toast) => toast.currentIndex !== index), + ) + } + + private scheduleDismiss(index: number) { + const props = this.propsMap[index] + + if (props.duration === Infinity) { + return + } + + setTimeout(async () => { + await this.toastRefs[index].dimiss() + this.remove(index) + }, props.duration) + } + + // @ts-expect-error + show(props: CenterToastProps): Promise<() => void> + show(props: BottomToastProps): Promise<() => void> + show(props: Omit<Partial<ToastProps>, "currentIndex">) { + if (!this.portal) { + this.mount() + } + + const nextProps = { ...this.defaultProps, ...props } + + if (nextProps.canClose === false) { + nextProps.duration = Infinity + } + + if (nextProps.variant === "center-replace") { + // Find and remove the toast if it exists + const index = jotaiStore + .get(this.stackAtom) + .findIndex((toast) => toast.variant === "center-replace") + if (index !== -1) { + this.remove(index) + } + } + + const currentIndex = ++this.currentIndex + this.push({ ...nextProps, currentIndex }) + this.scheduleDismiss(currentIndex) + return () => this.remove(currentIndex) + } +} diff --git a/apps/mobile/src/components/ui/toast/types.ts b/apps/mobile/src/components/ui/toast/types.ts new file mode 100644 index 0000000000..d176d94ccb --- /dev/null +++ b/apps/mobile/src/components/ui/toast/types.ts @@ -0,0 +1,31 @@ +export interface ToastProps { + currentIndex: number + variant: "bottom-stack" | "center-replace" + type: "success" | "error" | "info" + message: string + render: React.ReactNode + + action: { + label: React.ReactNode + onPress: () => void + variant?: "normal" | "destructive" + }[] + duration: number + icon?: React.ReactNode | false + canClose?: boolean +} + +export type CenterToastProps = Partial< + Pick<ToastProps, "message" | "render" | "type" | "duration" | "icon"> +> & { + variant: "center-replace" +} + +export type BottomToastProps = Partial<ToastProps> & { + variant: "bottom-stack" + canClose?: boolean +} + +export interface ToastRef { + dimiss: () => Promise<void> +} diff --git a/apps/mobile/src/icons/close_circle_fill.tsx b/apps/mobile/src/icons/close_circle_fill.tsx new file mode 100644 index 0000000000..8597960ba3 --- /dev/null +++ b/apps/mobile/src/icons/close_circle_fill.tsx @@ -0,0 +1,14 @@ +import type { SvgProps } from "react-native-svg" +import { G, Path, Svg } from "react-native-svg" + +export const CloseCircleFillIcon = (props: SvgProps & { color?: string }) => ( + <Svg width={24} height={24} viewBox="0 0 24 24" {...props}> + <G fill="none"> + <Path d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01-.184-.092Z" /> + <Path + fill={props.color || "#10161F"} + d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2ZM9.879 8.464a1 1 0 0 0-1.498 1.32l.084.095 2.12 2.12-2.12 2.122a1 1 0 0 0 1.32 1.498l.094-.083L12 13.414l2.121 2.122a1 1 0 0 0 1.498-1.32l-.083-.095L13.414 12l2.122-2.121a1 1 0 0 0-1.32-1.498l-.095.083L12 10.586 9.879 8.464Z" + /> + </G> + </Svg> +) diff --git a/apps/mobile/src/icons/info_circle_fill.tsx b/apps/mobile/src/icons/info_circle_fill.tsx new file mode 100644 index 0000000000..22381e0b17 --- /dev/null +++ b/apps/mobile/src/icons/info_circle_fill.tsx @@ -0,0 +1,14 @@ +import type { SvgProps } from "react-native-svg" +import { G, Path, Svg } from "react-native-svg" + +export const InfoCircleFillIcon = (props: SvgProps & { color?: string }) => ( + <Svg width={24} height={24} viewBox="0 0 24 24" {...props}> + <G fill="none"> + <Path d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01-.184-.092Z" /> + <Path + fill={props.color || "#10161F"} + d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm-.01 8H11a1 1 0 0 0-.117 1.993L11 12v4.99c0 .52.394.95.9 1.004l.11.006h.49a1 1 0 0 0 .596-1.803L13 16.134V11.01c0-.52-.394-.95-.9-1.004L11.99 10ZM12 7a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z" + /> + </G> + </Svg> +) diff --git a/apps/mobile/src/lib/toast.tsx b/apps/mobile/src/lib/toast.tsx new file mode 100644 index 0000000000..31c500b1de --- /dev/null +++ b/apps/mobile/src/lib/toast.tsx @@ -0,0 +1,26 @@ +import { ToastManager } from "../components/ui/toast/manager" +import type { ToastProps } from "../components/ui/toast/types" + +export const toastInstance = new ToastManager() + +type CommandToastOptions = Partial<Pick<ToastProps, "duration" | "icon" | "render" | "message">> +type Toast = { + // [key in "error" | "success" | "info"]: (message: string) => void; + show: typeof toastInstance.show + error: (message: string, options?: CommandToastOptions) => void + success: (message: string, options?: CommandToastOptions) => void + info: (message: string, options?: CommandToastOptions) => void +} +export const toast = { + show: toastInstance.show.bind(toastInstance), +} as Toast +;(["error", "success", "info"] as const).forEach((type) => { + toast[type] = (message: string, options: CommandToastOptions = {}) => { + toastInstance.show({ + type, + message, + variant: "center-replace", + ...options, + }) + } +}) diff --git a/apps/mobile/src/screens/(headless)/debug.tsx b/apps/mobile/src/screens/(headless)/debug.tsx index af1903bc00..13cfca3c14 100644 --- a/apps/mobile/src/screens/(headless)/debug.tsx +++ b/apps/mobile/src/screens/(headless)/debug.tsx @@ -20,6 +20,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context" import { getDbPath } from "@/src/database" import { clearSessionToken, getSessionToken, setSessionToken } from "@/src/lib/cookie" import { loading } from "@/src/lib/loading" +import { toast } from "@/src/lib/toast" interface MenuSection { title: string @@ -95,6 +96,16 @@ export default function DebugPanel() { loading.start(sleep(2000)) }, }, + { + title: "Toast", + onPress: () => { + toast.show({ + message: "Hello, world!".repeat(10), + type: "success", + variant: "center-replace", + }) + }, + }, ], }, diff --git a/apps/mobile/src/screens/(modal)/rsshub-form.tsx b/apps/mobile/src/screens/(modal)/rsshub-form.tsx index f2f5206abc..1b86665c5c 100644 --- a/apps/mobile/src/screens/(modal)/rsshub-form.tsx +++ b/apps/mobile/src/screens/(modal)/rsshub-form.tsx @@ -23,6 +23,7 @@ import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvi 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 { toast } from "@/src/lib/toast" import { feedSyncServices } from "@/src/store/feed/store" interface RsshubFormParams { @@ -310,15 +311,14 @@ const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitBu }) }) .catch(() => { - // TODO impl toast - // toast.error("Failed to fetch feed") + toast.error("Failed to fetch feed") }) .finally(() => { setIsLoading(false) }) } catch (err: unknown) { if (err instanceof MissingOptionalParamError) { - // toast.error(err.message) + toast.error(err.message) // const idx = keys.findIndex((item) => item.name === err.param) // form.setFocus(keys[idx === 0 ? 0 : idx - 1].name, { // shouldSelect: true, diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index 40ad3bdd5d..291a91b007 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -142,7 +142,11 @@ export const isRGBAColor = (color: string) => { export const withOpacity = (color: string, opacity: number) => { switch (true) { case isHexColor(color): { - return `${color}${opacity.toString(16).slice(1)}` + // Convert decimal opacity to hex (0-255) + const alpha = Math.round(opacity * 255) + .toString(16) + .padStart(2, "0") + return `${color}${alpha}` } case isRGBColor(color): { const [r, g, b] = color.match(/\d+/g)!.map(Number) From ae1cb5ee5dc1de7ddfc03781987cb7c172baf65d Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Wed, 8 Jan 2025 02:52:30 +0800 Subject: [PATCH 37/70] chore: hide customize toolbar action in mobile --- apps/renderer/src/modules/customize-toolbar/modal.tsx | 4 +--- apps/renderer/src/modules/entry-content/header.mobile.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/renderer/src/modules/customize-toolbar/modal.tsx b/apps/renderer/src/modules/customize-toolbar/modal.tsx index 91ce7e5131..06aae22b49 100644 --- a/apps/renderer/src/modules/customize-toolbar/modal.tsx +++ b/apps/renderer/src/modules/customize-toolbar/modal.tsx @@ -46,15 +46,13 @@ const CustomizeToolbar = () => { // Moving between containers const sourceList = isActiveInMain ? "main" : "more" const targetList = isActiveInMain ? "more" : "main" - const item = actionOrder[sourceList].find((item) => item === activeId) - if (!item) return const newIndexOfOver = actionOrder[targetList].indexOf(overId) setUISetting("toolbarOrder", { ...actionOrder, [sourceList]: actionOrder[sourceList].filter((item) => item !== activeId), [targetList]: [ ...actionOrder[targetList].slice(0, newIndexOfOver), - item, + activeId, ...actionOrder[targetList].slice(newIndexOfOver), ], }) diff --git a/apps/renderer/src/modules/entry-content/header.mobile.tsx b/apps/renderer/src/modules/entry-content/header.mobile.tsx index cca4a34b21..d674d63dfd 100644 --- a/apps/renderer/src/modules/entry-content/header.mobile.tsx +++ b/apps/renderer/src/modules/entry-content/header.mobile.tsx @@ -28,7 +28,12 @@ function EntryHeaderImpl({ view, entryId, className }: EntryHeaderProps) { const actionConfigs = useEntryActions({ entryId, view }).filter( (item) => !( - [COMMAND_ID.entry.read, COMMAND_ID.entry.unread, COMMAND_ID.entry.copyLink] as string[] + [ + COMMAND_ID.entry.read, + COMMAND_ID.entry.unread, + COMMAND_ID.entry.copyLink, + COMMAND_ID.settings.customizeToolbar, + ] as string[] ).includes(item.id), ) From 9c14985ea07b134cfa711eb5b68cd3d69901d682 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:41:22 +0800 Subject: [PATCH 38/70] refactor: fixed customize toolbar action --- .../src/modules/customize-toolbar/constant.ts | 16 ++++++++++------ .../entry-content/actions/more-actions.tsx | 19 ++++++++++++++++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/renderer/src/modules/customize-toolbar/constant.ts b/apps/renderer/src/modules/customize-toolbar/constant.ts index 402a4094f7..698ba47edd 100644 --- a/apps/renderer/src/modules/customize-toolbar/constant.ts +++ b/apps/renderer/src/modules/customize-toolbar/constant.ts @@ -16,11 +16,15 @@ export const DEFAULT_ACTION_ORDER: ToolbarActionOrder = { ), more: [ ...Object.values(COMMAND_ID.integration), - ...Object.values(COMMAND_ID.entry) - .filter((id) => !([COMMAND_ID.entry.read, COMMAND_ID.entry.unread] as string[]).includes(id)) - .filter((id) => - ([COMMAND_ID.entry.copyLink, COMMAND_ID.entry.openInBrowser] as string[]).includes(id), - ), - COMMAND_ID.settings.customizeToolbar, + ...Object.values(COMMAND_ID.entry).filter((id) => + ( + [ + COMMAND_ID.entry.copyLink, + COMMAND_ID.entry.openInBrowser, + COMMAND_ID.entry.read, + COMMAND_ID.entry.unread, + ] as string[] + ).includes(id), + ), ], } diff --git a/apps/renderer/src/modules/entry-content/actions/more-actions.tsx b/apps/renderer/src/modules/entry-content/actions/more-actions.tsx index e8f39d9107..d69297813c 100644 --- a/apps/renderer/src/modules/entry-content/actions/more-actions.tsx +++ b/apps/renderer/src/modules/entry-content/actions/more-actions.tsx @@ -6,9 +6,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu/dropdown-menu" import { useEntryActions } from "~/hooks/biz/useEntryActions" +import { COMMAND_ID } from "~/modules/command/commands/id" import { useCommand } from "~/modules/command/hooks/use-command" import type { FollowCommandId } from "~/modules/command/types" import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks" @@ -21,17 +23,24 @@ export const MoreActions = ({ entryId, view }: { entryId: string; view?: FeedVie actionConfigs .filter((item) => { const order = orderMap.get(item.id) - if (!order) return false + // If the order is not set, it should be in the "more" menu + if (!order) return true return order.type !== "main" }) + .filter((item) => item.id !== COMMAND_ID.settings.customizeToolbar) .sort((a, b) => { - const orderA = orderMap.get(a.id)?.order || 0 - const orderB = orderMap.get(b.id)?.order || 0 + const orderA = orderMap.get(a.id)?.order || Infinity + const orderB = orderMap.get(b.id)?.order || Infinity return orderA - orderB }), [actionConfigs, orderMap], ) + const extraAction = useMemo( + () => actionConfigs.filter((item) => item.id === COMMAND_ID.settings.customizeToolbar), + [actionConfigs], + ) + if (availableActions.length === 0) { return null } @@ -45,6 +54,10 @@ export const MoreActions = ({ entryId, view }: { entryId: string; view?: FeedVie {availableActions.map((config) => ( <CommandDropdownMenuItem key={config.id} commandId={config.id} onClick={config.onClick} /> ))} + <DropdownMenuSeparator /> + {extraAction.map((config) => ( + <CommandDropdownMenuItem key={config.id} commandId={config.id} onClick={config.onClick} /> + ))} </DropdownMenuContent> </DropdownMenu> ) From 6c3e8e82c64005466f21c3ee2680574da43fd604 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Wed, 8 Jan 2025 18:13:29 +0800 Subject: [PATCH 39/70] feat: add loading indicator and UI improvements in transaction (#2480) --- .../src/modules/power/transaction-section/index.tsx | 12 +++++++++--- .../power/transaction-section/tx-table.mobile.tsx | 6 ++++-- .../power/transaction-section/tx-table.shared.tsx | 7 ++++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/renderer/src/modules/power/transaction-section/index.tsx b/apps/renderer/src/modules/power/transaction-section/index.tsx index c8c7f88c7d..53369561e2 100644 --- a/apps/renderer/src/modules/power/transaction-section/index.tsx +++ b/apps/renderer/src/modules/power/transaction-section/index.tsx @@ -1,3 +1,4 @@ +import { LoadingCircle } from "@follow/components/ui/loading/index.js" import { Tabs, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx" import { TransactionTypes } from "@follow/models/types" import { useState } from "react" @@ -57,9 +58,14 @@ export const TransactionsSection: Component = ({ className }) => { {t("wallet.transactions.more")} </a> )} - {!transactions.data?.length && ( - <div className="my-2 w-full text-sm text-zinc-400"> - {t("wallet.transactions.noTransactions")} + + {(transactions.isFetching || !transactions.data?.length) && ( + <div className="my-2 flex w-full justify-center text-sm text-zinc-400"> + {transactions.isFetching ? ( + <LoadingCircle size="medium" /> + ) : ( + t("wallet.transactions.noTransactions") + )} </div> )} </div> diff --git a/apps/renderer/src/modules/power/transaction-section/tx-table.mobile.tsx b/apps/renderer/src/modules/power/transaction-section/tx-table.mobile.tsx index 01723ea2d3..65c2cbc0d5 100644 --- a/apps/renderer/src/modules/power/transaction-section/tx-table.mobile.tsx +++ b/apps/renderer/src/modules/power/transaction-section/tx-table.mobile.tsx @@ -15,8 +15,10 @@ const UserTooltip = ({ user, currentUserId }: { user: any; currentUserId?: strin const { t } = useTranslation("settings") return ( <Tooltip> - <TooltipTrigger> - <UserRenderer hideName user={user} iconClassName="size-6" /> + <TooltipTrigger asChild> + <div> + <UserRenderer hideName user={user} iconClassName="size-6" /> + </div> </TooltipTrigger> <TooltipContent> {user?.id === currentUserId ? ( diff --git a/apps/renderer/src/modules/power/transaction-section/tx-table.shared.tsx b/apps/renderer/src/modules/power/transaction-section/tx-table.shared.tsx index 54f81e67cc..0700b8e4de 100644 --- a/apps/renderer/src/modules/power/transaction-section/tx-table.shared.tsx +++ b/apps/renderer/src/modules/power/transaction-section/tx-table.shared.tsx @@ -78,7 +78,12 @@ export const UserRenderer = ({ {name === APP_NAME ? ( <Logo className={cn("aspect-square size-4", iconClassName)} /> ) : ( - <UserAvatar userId={user?.id} hideName className="h-auto p-0" avatarClassName="size-4" /> + <UserAvatar + userId={user?.id} + hideName + className="h-auto p-0" + avatarClassName={cn("size-4", iconClassName)} + /> )} {!hideName && ( From 834c93fc00216bb1c912c0cb7cf9c4505012d6d6 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:59:03 +0800 Subject: [PATCH 40/70] chore: bring back tailwind config for dom --- apps/mobile/postcss.config.js | 3 +++ .../components/{common/Html.tsx => ui/typography/HtmlWeb.tsx} | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) rename apps/mobile/src/components/{common/Html.tsx => ui/typography/HtmlWeb.tsx} (90%) diff --git a/apps/mobile/postcss.config.js b/apps/mobile/postcss.config.js index 90d9fffcb1..c0c06b2e4b 100644 --- a/apps/mobile/postcss.config.js +++ b/apps/mobile/postcss.config.js @@ -1,5 +1,8 @@ module.exports = { plugins: { + tailwindcss: { + config: "./tailwind.dom.config.ts", + }, autoprefixer: {}, }, } diff --git a/apps/mobile/src/components/common/Html.tsx b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx similarity index 90% rename from apps/mobile/src/components/common/Html.tsx rename to apps/mobile/src/components/ui/typography/HtmlWeb.tsx index c0b45a735b..d152110cb1 100644 --- a/apps/mobile/src/components/common/Html.tsx +++ b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx @@ -5,7 +5,7 @@ import "@follow/components/assets/tailwind.css" import type { HtmlProps } from "@follow/components" import { Html } from "@follow/components" -export default function HtmlRender({ +export default function HtmlWeb({ content, dom, ...options From 9c3400a5d5ea7b34b64c2c831a86f1461affb259 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Wed, 8 Jan 2025 23:56:49 +0800 Subject: [PATCH 41/70] refactor(rn): extract tab bar as a component Signed-off-by: Innei <tukon479@gmail.com> --- .../src/components/ui/tabview/TabBar.tsx | 206 ++++++++++++++++++ .../src/components/ui/tabview/index.tsx | 163 ++------------ .../mobile/src/components/ui/tabview/types.ts | 5 + ...on-item.tsx => RecommendationListItem.tsx} | 0 ...ecommendations.tsx => Recommendations.tsx} | 2 +- .../src/modules/discover/SearchTabBar.tsx | 31 +++ apps/mobile/src/modules/discover/constants.ts | 6 + apps/mobile/src/modules/discover/ctx.tsx | 7 +- apps/mobile/src/modules/discover/search.tsx | 13 +- .../src/screens/(stack)/(tabs)/discover.tsx | 2 +- 10 files changed, 280 insertions(+), 155 deletions(-) create mode 100644 apps/mobile/src/components/ui/tabview/TabBar.tsx create mode 100644 apps/mobile/src/components/ui/tabview/types.ts rename apps/mobile/src/modules/discover/{recommendation-item.tsx => RecommendationListItem.tsx} (100%) rename apps/mobile/src/modules/discover/{recommendations.tsx => Recommendations.tsx} (99%) create mode 100644 apps/mobile/src/modules/discover/SearchTabBar.tsx create mode 100644 apps/mobile/src/modules/discover/constants.ts diff --git a/apps/mobile/src/components/ui/tabview/TabBar.tsx b/apps/mobile/src/components/ui/tabview/TabBar.tsx new file mode 100644 index 0000000000..bf64ec3f22 --- /dev/null +++ b/apps/mobile/src/components/ui/tabview/TabBar.tsx @@ -0,0 +1,206 @@ +import { cn } from "@follow/utils" +import type { FC } from "react" +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react" +import type { + Animated as AnimatedNative, + StyleProp, + TouchableOpacityProps, + ViewStyle, +} from "react-native" +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native" +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated" + +import { accentColor } from "@/src/theme/colors" + +import type { Tab } from "./types" + +interface TabBarProps { + tabs: Tab[] + + tabbarClassName?: string + tabbarStyle?: StyleProp<ViewStyle> + + TabItem?: FC<{ isSelected: boolean; tab: Tab } & Pick<TouchableOpacityProps, "onLayout">> + + onTabItemPress?: (index: number) => void + currentTab?: number + + tabScrollContainerAnimatedX?: AnimatedNative.Value +} + +const springConfig = { + stiffness: 100, + damping: 10, +} + +export const TabBar = forwardRef<ScrollView, TabBarProps>( + ( + { + tabs, + TabItem = Pressable, + tabbarClassName, + tabbarStyle, + + onTabItemPress, + currentTab: tab, + tabScrollContainerAnimatedX: pagerOffsetX, + }, + ref, + ) => { + const [currentTab, setCurrentTab] = useState(tab || 0) + const [tabWidths, setTabWidths] = useState<number[]>([]) + const [tabPositions, setTabPositions] = useState<number[]>([]) + const indicatorPosition = useSharedValue(0) + + useEffect(() => { + if (typeof tab === "number") { + setCurrentTab(tab) + } + }, [tab]) + + const sharedPagerOffsetX = useSharedValue(0) + const [tabBarWidth, setTabBarWidth] = useState(0) + useEffect(() => { + if (pagerOffsetX) { + return + } + sharedPagerOffsetX.value = withSpring(currentTab * tabBarWidth, springConfig) + }, [currentTab, pagerOffsetX, sharedPagerOffsetX, tabBarWidth]) + useEffect(() => { + if (!pagerOffsetX) return + const id = pagerOffsetX.addListener(({ value }) => { + sharedPagerOffsetX.value = value + }) + return () => { + pagerOffsetX.removeListener(id) + } + }, [pagerOffsetX, sharedPagerOffsetX]) + const tabRef = useRef<ScrollView>(null) + + useEffect(() => { + if (!pagerOffsetX) return + const listener = pagerOffsetX.addListener(({ value }) => { + // Calculate which tab should be active based on scroll position + const tabIndex = Math.round(value / tabBarWidth) + if (tabIndex !== currentTab) { + setCurrentTab(tabIndex) + onTabItemPress?.(tabIndex) + } + }) + + return () => pagerOffsetX.removeListener(listener) + }, [currentTab, onTabItemPress, pagerOffsetX, tabBarWidth]) + + useImperativeHandle(ref, () => tabRef.current!) + useEffect(() => { + if (tabWidths.length > 0) { + indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig) + + if (tabRef.current) { + const x = currentTab > 0 ? tabPositions[currentTab - 1] + tabWidths[currentTab - 1] : 0 + + const isCurrentTabVisible = + sharedPagerOffsetX.value < tabPositions[currentTab] && + sharedPagerOffsetX.value + tabWidths[currentTab] > tabPositions[currentTab] + + if (!isCurrentTabVisible) { + tabRef.current.scrollTo({ x, y: 0, animated: true }) + } + } + } + }, [currentTab, indicatorPosition, sharedPagerOffsetX.value, tabPositions, tabWidths]) + + const indicatorStyle = useAnimatedStyle(() => { + const scrollProgress = sharedPagerOffsetX.value / tabBarWidth + + const currentIndex = Math.floor(scrollProgress) + const nextIndex = Math.min(currentIndex + 1, tabs.length - 1) + const progress = scrollProgress - currentIndex + + // Interpolate between current and next tab positions + const xPosition = + tabPositions[currentIndex] + + (tabPositions[nextIndex] - tabPositions[currentIndex]) * progress + + // Interpolate between current and next tab widths + const width = + tabWidths[currentIndex] + (tabWidths[nextIndex] - tabWidths[currentIndex]) * progress + + return { + transform: [{ translateX: xPosition }], + width, + backgroundColor: tabs[currentTab].activeColor || accentColor, + } + }) + + return ( + <ScrollView + onLayout={(event) => { + setTabBarWidth(event.nativeEvent.layout.width) + }} + showsHorizontalScrollIndicator={false} + className={cn( + "border-tertiary-system-background relative shrink-0 grow-0", + tabbarClassName, + )} + horizontal + ref={tabRef} + contentContainerStyle={styles.tabScroller} + style={[styles.root, tabbarStyle]} + > + {tabs.map((tab, index) => ( + <TabItem + onPress={() => { + setCurrentTab(index) + onTabItemPress?.(index) + }} + key={tab.value} + isSelected={index === currentTab} + onLayout={(event) => { + const { width, x } = event.nativeEvent.layout + setTabWidths((prev) => { + const newWidths = [...prev] + newWidths[index] = width + return newWidths + }) + setTabPositions((prev) => { + const newPositions = [...prev] + newPositions[index] = x + return newPositions + }) + }} + tab={tab} + > + <TabItemInner tab={tab} isSelected={index === currentTab} /> + </TabItem> + ))} + + <Animated.View style={[styles.indicator, indicatorStyle]} /> + </ScrollView> + ) + }, +) + +const styles = StyleSheet.create({ + tabScroller: { + alignItems: "center", + flexDirection: "row", + paddingHorizontal: 4, + }, + root: { paddingHorizontal: 6 }, + + indicator: { + position: "absolute", + bottom: 0, + height: 2, + borderRadius: 1, + }, +}) + +const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => { + return ( + <View className="p-2"> + <Text style={{ color: isSelected ? accentColor : "gray" }}>{tab.name}</Text> + </View> + ) +} diff --git a/apps/mobile/src/components/ui/tabview/index.tsx b/apps/mobile/src/components/ui/tabview/index.tsx index 4295478584..b2e8dfc9fe 100644 --- a/apps/mobile/src/components/ui/tabview/index.tsx +++ b/apps/mobile/src/components/ui/tabview/index.tsx @@ -1,23 +1,18 @@ import { cn } from "@follow/utils" import type { FC } from "react" -import { useEffect, useRef, useState } from "react" -import type { StyleProp, TouchableOpacityProps, ViewStyle } from "react-native" +import { useCallback, useEffect, useRef, useState } from "react" +import type { ScrollView, StyleProp, TouchableOpacityProps, ViewStyle } from "react-native" import { Animated as RnAnimated, Pressable, - ScrollView, - StyleSheet, - Text, useAnimatedValue, useWindowDimensions, View, } from "react-native" -import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated" import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils" -import { accentColor } from "@/src/theme/colors" - import { AnimatedScrollView } from "../../common/AnimatedComponents" +import { TabBar } from "./TabBar" type Tab = { name: string @@ -45,11 +40,6 @@ interface TabViewProps { lazyOnce?: boolean } -const springConfig = { - stiffness: 100, - damping: 10, -} - export const TabView: FC<TabViewProps> = ({ tabs, Tab = View, @@ -67,80 +57,12 @@ export const TabView: FC<TabViewProps> = ({ lazyOnce, lazyTab, }) => { - const tabRef = useRef<ScrollView>(null) - - const [tabWidths, setTabWidths] = useState<number[]>([]) - const [tabPositions, setTabPositions] = useState<number[]>([]) - const [currentTab, setCurrentTab] = useState(initialTab ?? 0) const pagerOffsetX = useAnimatedValue(0) - const sharedPagerOffsetX = useSharedValue(0) - useEffect(() => { - const id = pagerOffsetX.addListener(({ value }) => { - sharedPagerOffsetX.value = value - }) - return () => { - pagerOffsetX.removeListener(id) - } - }, [pagerOffsetX, sharedPagerOffsetX]) - const indicatorPosition = useSharedValue(0) const { width: windowWidth } = useWindowDimensions() - useEffect(() => { - if (tabWidths.length > 0) { - indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig) - - if (tabRef.current) { - const x = currentTab > 0 ? tabPositions[currentTab - 1] + tabWidths[currentTab - 1] : 0 - - const isCurrentTabVisible = - sharedPagerOffsetX.value < tabPositions[currentTab] && - sharedPagerOffsetX.value + tabWidths[currentTab] > tabPositions[currentTab] - - if (!isCurrentTabVisible) { - tabRef.current.scrollTo({ x, y: 0, animated: true }) - } - } - } - }, [currentTab, indicatorPosition, sharedPagerOffsetX.value, tabPositions, tabWidths]) - - const indicatorStyle = useAnimatedStyle(() => { - const scrollProgress = sharedPagerOffsetX.value / windowWidth - - const currentIndex = Math.floor(scrollProgress) - const nextIndex = Math.min(currentIndex + 1, tabs.length - 1) - const progress = scrollProgress - currentIndex - - // Interpolate between current and next tab positions - const xPosition = - tabPositions[currentIndex] + (tabPositions[nextIndex] - tabPositions[currentIndex]) * progress - - // Interpolate between current and next tab widths - const width = - tabWidths[currentIndex] + (tabWidths[nextIndex] - tabWidths[currentIndex]) * progress - - return { - transform: [{ translateX: xPosition }], - width, - backgroundColor: tabs[currentTab].activeColor || accentColor, - } - }) - - useEffect(() => { - const listener = pagerOffsetX.addListener(({ value }) => { - // Calculate which tab should be active based on scroll position - const tabIndex = Math.round(value / windowWidth) - if (tabIndex !== currentTab) { - setCurrentTab(tabIndex) - onTabChange?.(tabIndex) - } - }) - - return () => pagerOffsetX.removeListener(listener) - }, [tabWidths, tabPositions, currentTab, pagerOffsetX, windowWidth, onTabChange]) - const [lazyTabSet, setLazyTabSet] = useState(() => new Set<number>()) const shouldRenderCurrentTab = (index: number) => { @@ -162,47 +84,22 @@ export const TabView: FC<TabViewProps> = ({ return ( <> - <ScrollView - showsHorizontalScrollIndicator={false} - className={cn( - "border-tertiary-system-background relative shrink-0 grow-0", - tabbarClassName, + <TabBar + onTabItemPress={useCallback( + (index: number) => { + contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true }) + setCurrentTab(index) + onTabChange?.(index) + }, + [onTabChange, windowWidth], )} - horizontal - ref={tabRef} - contentContainerStyle={styles.tabScroller} - style={[styles.root, tabbarStyle]} - > - {tabs.map((tab, index) => ( - <TabItem - onPress={() => { - // setCurrentTab(index) - contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true }) - onTabChange?.(index) - }} - key={tab.value} - isSelected={index === currentTab} - onLayout={(event) => { - const { width, x } = event.nativeEvent.layout - setTabWidths((prev) => { - const newWidths = [...prev] - newWidths[index] = width - return newWidths - }) - setTabPositions((prev) => { - const newPositions = [...prev] - newPositions[index] = x - return newPositions - }) - }} - tab={tab} - > - <TabItemInner tab={tab} isSelected={index === currentTab} /> - </TabItem> - ))} - - <Animated.View style={[styles.indicator, indicatorStyle]} /> - </ScrollView> + tabs={tabs} + currentTab={currentTab} + tabbarClassName={tabbarClassName} + tabbarStyle={tabbarStyle} + TabItem={TabItem} + tabScrollContainerAnimatedX={pagerOffsetX} + /> <AnimatedScrollView onScroll={RnAnimated.event([{ nativeEvent: { contentOffset: { x: pagerOffsetX } } }], { @@ -227,27 +124,3 @@ export const TabView: FC<TabViewProps> = ({ </> ) } - -const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => { - return ( - <View className="p-2"> - <Text style={{ color: isSelected ? accentColor : "gray" }}>{tab.name}</Text> - </View> - ) -} - -const styles = StyleSheet.create({ - tabScroller: { - alignItems: "center", - flexDirection: "row", - paddingHorizontal: 4, - }, - - root: { paddingHorizontal: 6 }, - indicator: { - position: "absolute", - bottom: 0, - height: 2, - borderRadius: 1, - }, -}) diff --git a/apps/mobile/src/components/ui/tabview/types.ts b/apps/mobile/src/components/ui/tabview/types.ts new file mode 100644 index 0000000000..2e6dca7686 --- /dev/null +++ b/apps/mobile/src/components/ui/tabview/types.ts @@ -0,0 +1,5 @@ +export type Tab = { + name: string + activeColor?: string + value: string +} diff --git a/apps/mobile/src/modules/discover/recommendation-item.tsx b/apps/mobile/src/modules/discover/RecommendationListItem.tsx similarity index 100% rename from apps/mobile/src/modules/discover/recommendation-item.tsx rename to apps/mobile/src/modules/discover/RecommendationListItem.tsx diff --git a/apps/mobile/src/modules/discover/recommendations.tsx b/apps/mobile/src/modules/discover/Recommendations.tsx similarity index 99% rename from apps/mobile/src/modules/discover/recommendations.tsx rename to apps/mobile/src/modules/discover/Recommendations.tsx index e81c6cefdf..c22e54fcde 100644 --- a/apps/mobile/src/modules/discover/recommendations.tsx +++ b/apps/mobile/src/modules/discover/Recommendations.tsx @@ -16,7 +16,7 @@ import { TabView } from "@/src/components/ui/tabview" import { apiClient } from "@/src/lib/api-fetch" import { RSSHubCategoryCopyMap } from "./copy" -import { RecommendationListItem } from "./recommendation-item" +import { RecommendationListItem } from "./RecommendationListItem" export const Recommendations = () => { const headerHeight = useHeaderHeight() diff --git a/apps/mobile/src/modules/discover/SearchTabBar.tsx b/apps/mobile/src/modules/discover/SearchTabBar.tsx new file mode 100644 index 0000000000..10ee593692 --- /dev/null +++ b/apps/mobile/src/modules/discover/SearchTabBar.tsx @@ -0,0 +1,31 @@ +import { useAtom } from "jotai" +import { View } from "react-native" + +import { TabBar } from "@/src/components/ui/tabview/TabBar" +import type { Tab } from "@/src/components/ui/tabview/types" + +import { SearchType } from "./constants" +import { useDiscoverPageContext } from "./ctx" + +const Tabs: Tab[] = [ + { name: "All", value: SearchType.AGGREGATE }, + { name: "RSS", value: SearchType.RSS }, + { name: "RSSHub", value: SearchType.RSSHUB }, + { name: "User", value: SearchType.USER }, +] +export const SearchTabBar = () => { + const { searchTypeAtom } = useDiscoverPageContext() + const [searchType, setSearchType] = useAtom(searchTypeAtom) + + return ( + <View> + <TabBar + tabs={Tabs} + currentTab={Tabs.findIndex((tab) => tab.value === searchType)} + onTabItemPress={(index) => { + setSearchType(Tabs[index].value as SearchType) + }} + /> + </View> + ) +} diff --git a/apps/mobile/src/modules/discover/constants.ts b/apps/mobile/src/modules/discover/constants.ts new file mode 100644 index 0000000000..afa88faefd --- /dev/null +++ b/apps/mobile/src/modules/discover/constants.ts @@ -0,0 +1,6 @@ +export enum SearchType { + AGGREGATE = "aggregate", + RSS = "rss", + RSSHUB = "rsshub", + USER = "user", +} diff --git a/apps/mobile/src/modules/discover/ctx.tsx b/apps/mobile/src/modules/discover/ctx.tsx index 482bfd7478..e4eb6899f4 100644 --- a/apps/mobile/src/modules/discover/ctx.tsx +++ b/apps/mobile/src/modules/discover/ctx.tsx @@ -2,9 +2,13 @@ import type { PrimitiveAtom } from "jotai" import { atom } from "jotai" import { createContext, useContext, useState } from "react" +import { SearchType } from "./constants" + interface DiscoverPageContextType { searchFocusedAtom: PrimitiveAtom<boolean> searchValueAtom: PrimitiveAtom<string> + + searchTypeAtom: PrimitiveAtom<SearchType> } export const DiscoverPageContext = createContext<DiscoverPageContextType>(null!) @@ -12,10 +16,11 @@ export const DiscoverPageProvider = ({ children }: { children: React.ReactNode } const [atomRefs] = useState((): DiscoverPageContextType => { const searchFocusedAtom = atom(true) const searchValueAtom = atom("") - + const searchTypeAtom = atom(SearchType.AGGREGATE) return { searchFocusedAtom, searchValueAtom, + searchTypeAtom, } }) return <DiscoverPageContext.Provider value={atomRefs}>{children}</DiscoverPageContext.Provider> diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index 2d539d44bc..ac0b9fea31 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -1,5 +1,4 @@ import { getDefaultHeaderHeight } from "@react-navigation/elements" -import { useTheme } from "@react-navigation/native" import { router } from "expo-router" import { useAtom, useAtomValue, useSetAtom } from "jotai" import { useEffect, useRef } from "react" @@ -21,6 +20,7 @@ import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re" import { accentColor, useColor } from "@/src/theme/colors" import { useDiscoverPageContext } from "./ctx" +import { SearchTabBar } from "./SearchTabBar" export const SearchHeader = () => { const frame = useSafeAreaFrame() @@ -28,11 +28,12 @@ export const SearchHeader = () => { const headerHeight = getDefaultHeaderHeight(frame, false, insets.top) return ( - <View style={{ height: headerHeight, paddingTop: insets.top }} className="relative"> + <View style={{ minHeight: headerHeight, paddingTop: insets.top }} className="relative"> <BlurEffect /> <View style={styles.header}> <ComposeSearchBar /> </View> + <SearchTabBar /> </View> ) } @@ -105,7 +106,6 @@ const ComposeSearchBar = () => { } const SearchInput = () => { - const { colors } = useTheme() const { searchFocusedAtom, searchValueAtom } = useDiscoverPageContext() const [isFocused, setIsFocused] = useAtom(searchFocusedAtom) const placeholderTextColor = useColor("placeholderText") @@ -173,7 +173,7 @@ const SearchInput = () => { }, [isFocused]) return ( - <View style={{ backgroundColor: colors.card, ...styles.searchbar }}> + <View style={styles.searchbar} className="dark:bg-gray-6 bg-gray-5"> {focusOrHasValue && ( <Animated.View style={{ @@ -221,7 +221,6 @@ const SearchInput = () => { const styles = StyleSheet.create({ header: { flex: 1, - alignItems: "center", marginTop: -3, flexDirection: "row", @@ -229,15 +228,15 @@ const styles = StyleSheet.create({ marginHorizontal: 16, position: "relative", }, + searchbar: { flex: 1, display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "center", - borderRadius: 50, - height: "100%", + height: 32, position: "relative", }, searchInput: { diff --git a/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx b/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx index a7a0ca66d5..e67313974d 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx @@ -1,6 +1,6 @@ import { Stack } from "expo-router" -import { Recommendations } from "@/src/modules/discover/recommendations" +import { Recommendations } from "@/src/modules/discover/Recommendations" import { DiscoverHeader } from "@/src/modules/discover/search" export default function Discover() { From fc3a3cef23532143853625a1af6cc2bbe3c28a3d Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Thu, 9 Jan 2025 00:02:37 +0800 Subject: [PATCH 42/70] fix(rn): check tab current index frequency Signed-off-by: Innei <tukon479@gmail.com> --- .../src/components/ui/tabview/TabBar.tsx | 29 +++++++++++-------- .../ui/tabview/{index.tsx => TabView.tsx} | 0 .../src/modules/discover/Recommendations.tsx | 4 +-- 3 files changed, 19 insertions(+), 14 deletions(-) rename apps/mobile/src/components/ui/tabview/{index.tsx => TabView.tsx} (100%) diff --git a/apps/mobile/src/components/ui/tabview/TabBar.tsx b/apps/mobile/src/components/ui/tabview/TabBar.tsx index bf64ec3f22..e2b9faf70a 100644 --- a/apps/mobile/src/components/ui/tabview/TabBar.tsx +++ b/apps/mobile/src/components/ui/tabview/TabBar.tsx @@ -1,6 +1,7 @@ import { cn } from "@follow/utils" +import { debounce } from "es-toolkit/compat" import type { FC } from "react" -import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react" +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react" import type { Animated as AnimatedNative, StyleProp, @@ -77,19 +78,24 @@ export const TabBar = forwardRef<ScrollView, TabBarProps>( }, [pagerOffsetX, sharedPagerOffsetX]) const tabRef = useRef<ScrollView>(null) + const handleChangeTabIndex = useCallback((index: number) => { + setCurrentTab(index) + onTabItemPress?.(index) + }, []) useEffect(() => { if (!pagerOffsetX) return - const listener = pagerOffsetX.addListener(({ value }) => { - // Calculate which tab should be active based on scroll position - const tabIndex = Math.round(value / tabBarWidth) - if (tabIndex !== currentTab) { - setCurrentTab(tabIndex) - onTabItemPress?.(tabIndex) - } - }) + const listener = pagerOffsetX.addListener( + debounce(({ value }) => { + // Calculate which tab should be active based on scroll position + const tabIndex = Math.round(value / tabBarWidth) + if (tabIndex !== currentTab) { + handleChangeTabIndex(tabIndex) + } + }, 36), + ) return () => pagerOffsetX.removeListener(listener) - }, [currentTab, onTabItemPress, pagerOffsetX, tabBarWidth]) + }, [currentTab, handleChangeTabIndex, onTabItemPress, pagerOffsetX, tabBarWidth]) useImperativeHandle(ref, () => tabRef.current!) useEffect(() => { @@ -151,8 +157,7 @@ export const TabBar = forwardRef<ScrollView, TabBarProps>( {tabs.map((tab, index) => ( <TabItem onPress={() => { - setCurrentTab(index) - onTabItemPress?.(index) + handleChangeTabIndex(index) }} key={tab.value} isSelected={index === currentTab} diff --git a/apps/mobile/src/components/ui/tabview/index.tsx b/apps/mobile/src/components/ui/tabview/TabView.tsx similarity index 100% rename from apps/mobile/src/components/ui/tabview/index.tsx rename to apps/mobile/src/components/ui/tabview/TabView.tsx diff --git a/apps/mobile/src/modules/discover/Recommendations.tsx b/apps/mobile/src/modules/discover/Recommendations.tsx index c22e54fcde..2eae69b2eb 100644 --- a/apps/mobile/src/modules/discover/Recommendations.tsx +++ b/apps/mobile/src/modules/discover/Recommendations.tsx @@ -11,8 +11,8 @@ import { Text, TouchableOpacity, View } from "react-native" import type { PanGestureHandlerGestureEvent } from "react-native-gesture-handler" import { PanGestureHandler } from "react-native-gesture-handler" -import type { TabComponent } from "@/src/components/ui/tabview" -import { TabView } from "@/src/components/ui/tabview" +import type { TabComponent } from "@/src/components/ui/tabview/TabView" +import { TabView } from "@/src/components/ui/tabview/TabView" import { apiClient } from "@/src/lib/api-fetch" import { RSSHubCategoryCopyMap } from "./copy" From 949a07ed0e3cb8b8ea582ebd926f143da7386736 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Thu, 9 Jan 2025 13:43:25 +0800 Subject: [PATCH 43/70] fix(mobile): improve responsive design in CornerPlayer --- .../src/modules/player/corner-player.tsx | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/renderer/src/modules/player/corner-player.tsx b/apps/renderer/src/modules/player/corner-player.tsx index 3b46bedb07..c6de8eb50b 100644 --- a/apps/renderer/src/modules/player/corner-player.tsx +++ b/apps/renderer/src/modules/player/corner-player.tsx @@ -181,11 +181,17 @@ const CornerPlayerImpl = ({ hideControls, rounded }: ControlButtonProps) => { > {/* play cover */} <div className="relative h-[3.625rem] shrink-0"> - <FeedIcon feed={feed} entry={entry.entries} size={58} fallback={false} noMargin /> + <FeedIcon + feed={feed} + entry={entry.entries} + size={isMobile ? 65.25 : 58} + fallback={false} + noMargin + /> <div className={cn( - "center absolute inset-0 w-full opacity-0 transition-all duration-200 ease-in-out group-hover:opacity-100", - isMobile && "opacity-100", + "center absolute inset-0 w-full opacity-0 transition-all duration-200 ease-in-out", + isMobile ? "opacity-100" : "group-hover:opacity-100", )} > <button @@ -212,7 +218,12 @@ const CornerPlayerImpl = ({ hideControls, rounded }: ControlButtonProps) => { > {entry.entries.title} </Marquee> - <div className="mt-0.5 overflow-hidden truncate text-xs text-muted-foreground group-hover:opacity-0"> + <div + className={cn( + "mt-0.5 overflow-hidden truncate text-xs text-muted-foreground", + !isMobile && "group-hover:opacity-0", + )} + > {feed.title} </div> @@ -225,8 +236,10 @@ const CornerPlayerImpl = ({ hideControls, rounded }: ControlButtonProps) => { {!hideControls && ( <div className={cn( - "absolute inset-x-0 top-0 z-[-1] flex justify-between border-t bg-theme-modal-background-opaque p-1 opacity-100 transition-all duration-200 ease-in-out group-hover:-translate-y-full group-hover:opacity-100", - isMobile && "-translate-y-full opacity-100", + "absolute inset-x-0 top-0 z-[-1] flex justify-between border-t bg-theme-modal-background-opaque p-1 opacity-0 transition-all duration-200 ease-in-out", + isMobile + ? "-translate-y-full opacity-100" + : "group-hover:-translate-y-full group-hover:opacity-100", )} > <div className="flex items-center"> @@ -321,8 +334,8 @@ const PlayerProgress = () => { <div className="relative mt-2"> <div className={cn( - "absolute bottom-2 flex w-full items-center justify-between text-theme-disabled opacity-0 duration-150 ease-in-out group-hover:opacity-100", - isMobile && "opacity-100", + "absolute bottom-2 flex w-full items-center justify-between text-theme-disabled opacity-0 duration-150 ease-in-out", + isMobile ? "opacity-100" : "group-hover:opacity-100", )} > <div className="text-xs">{currentTimeIndicator}</div> From 468a3e490a7c0c3424b721011f2dc72503ba479f Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Thu, 9 Jan 2025 14:38:05 +0800 Subject: [PATCH 44/70] feat(rn): fix dismiss modal when follow done Signed-off-by: Innei <tukon479@gmail.com> --- .../common/ModalSharedComponents.tsx | 9 ++-- apps/mobile/src/hooks/useIsRouteOnlyOne.ts | 10 +++++ .../src/modules/discover/SearchTabBar.tsx | 8 ++-- apps/mobile/src/modules/discover/constants.ts | 8 ++-- apps/mobile/src/modules/discover/ctx.tsx | 23 +++++++++- apps/mobile/src/modules/discover/search.tsx | 13 +++++- apps/mobile/src/screens/(headless)/search.tsx | 45 ++++++++++++++++--- apps/mobile/src/screens/(modal)/follow.tsx | 11 ++++- 8 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/src/hooks/useIsRouteOnlyOne.ts diff --git a/apps/mobile/src/components/common/ModalSharedComponents.tsx b/apps/mobile/src/components/common/ModalSharedComponents.tsx index e095bf043d..f6dcc31603 100644 --- a/apps/mobile/src/components/common/ModalSharedComponents.tsx +++ b/apps/mobile/src/components/common/ModalSharedComponents.tsx @@ -1,7 +1,8 @@ import { withOpacity } from "@follow/utils" -import { router, useNavigation } from "expo-router" +import { router } from "expo-router" import { TouchableOpacity } from "react-native" +import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne" import { CheckLineIcon } from "@/src/icons/check_line" import { CloseCuteReIcon } from "@/src/icons/close_cute_re" import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line" @@ -16,11 +17,7 @@ export const ModalHeaderCloseButton = () => { const ModalHeaderCloseButtonImpl = () => { const label = useColor("label") - const navigation = useNavigation() - - const state = navigation.getState() - - const routeOnlyOne = state.routes.length === 1 + const routeOnlyOne = useIsRouteOnlyOne() return ( <TouchableOpacity onPress={() => router.dismiss()}> diff --git a/apps/mobile/src/hooks/useIsRouteOnlyOne.ts b/apps/mobile/src/hooks/useIsRouteOnlyOne.ts new file mode 100644 index 0000000000..4f50c82524 --- /dev/null +++ b/apps/mobile/src/hooks/useIsRouteOnlyOne.ts @@ -0,0 +1,10 @@ +import { useNavigation } from "expo-router" + +export const useIsRouteOnlyOne = () => { + const navigation = useNavigation() + const state = navigation.getState() + + const routeOnlyOne = state.routes.length === 1 + + return routeOnlyOne +} diff --git a/apps/mobile/src/modules/discover/SearchTabBar.tsx b/apps/mobile/src/modules/discover/SearchTabBar.tsx index 10ee593692..4a7767d2fe 100644 --- a/apps/mobile/src/modules/discover/SearchTabBar.tsx +++ b/apps/mobile/src/modules/discover/SearchTabBar.tsx @@ -8,10 +8,10 @@ import { SearchType } from "./constants" import { useDiscoverPageContext } from "./ctx" const Tabs: Tab[] = [ - { name: "All", value: SearchType.AGGREGATE }, - { name: "RSS", value: SearchType.RSS }, - { name: "RSSHub", value: SearchType.RSSHUB }, - { name: "User", value: SearchType.USER }, + { name: "Feed", value: SearchType.Feed }, + { name: "List", value: SearchType.List }, + { name: "User", value: SearchType.User }, + { name: "RSSHub", value: SearchType.RSSHub }, ] export const SearchTabBar = () => { const { searchTypeAtom } = useDiscoverPageContext() diff --git a/apps/mobile/src/modules/discover/constants.ts b/apps/mobile/src/modules/discover/constants.ts index afa88faefd..e9e17e8925 100644 --- a/apps/mobile/src/modules/discover/constants.ts +++ b/apps/mobile/src/modules/discover/constants.ts @@ -1,6 +1,6 @@ export enum SearchType { - AGGREGATE = "aggregate", - RSS = "rss", - RSSHUB = "rsshub", - USER = "user", + Feed = "feed", + List = "list", + User = "user", + RSSHub = "rsshub", } diff --git a/apps/mobile/src/modules/discover/ctx.tsx b/apps/mobile/src/modules/discover/ctx.tsx index e4eb6899f4..1b30390b40 100644 --- a/apps/mobile/src/modules/discover/ctx.tsx +++ b/apps/mobile/src/modules/discover/ctx.tsx @@ -1,5 +1,6 @@ import type { PrimitiveAtom } from "jotai" import { atom } from "jotai" +import type { Dispatch, SetStateAction } from "react" import { createContext, useContext, useState } from "react" import { SearchType } from "./constants" @@ -12,11 +13,31 @@ interface DiscoverPageContextType { } export const DiscoverPageContext = createContext<DiscoverPageContextType>(null!) +const SearchBarHeightContext = createContext<number>(0) +const setSearchBarHeightContext = createContext<Dispatch<SetStateAction<number>>>(() => {}) +export const SearchBarHeightProvider = ({ children }: { children: React.ReactNode }) => { + const [searchBarHeight, setSearchBarHeight] = useState(0) + return ( + <SearchBarHeightContext.Provider value={searchBarHeight}> + <setSearchBarHeightContext.Provider value={setSearchBarHeight}> + {children} + </setSearchBarHeightContext.Provider> + </SearchBarHeightContext.Provider> + ) +} + +export const useSearchBarHeight = () => { + return useContext(SearchBarHeightContext) +} +export const useSetSearchBarHeight = () => { + return useContext(setSearchBarHeightContext) +} + export const DiscoverPageProvider = ({ children }: { children: React.ReactNode }) => { const [atomRefs] = useState((): DiscoverPageContextType => { const searchFocusedAtom = atom(true) const searchValueAtom = atom("") - const searchTypeAtom = atom(SearchType.AGGREGATE) + const searchTypeAtom = atom(SearchType.Feed) return { searchFocusedAtom, searchValueAtom, diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index ac0b9fea31..fa390925ce 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -1,7 +1,9 @@ import { getDefaultHeaderHeight } from "@react-navigation/elements" import { router } from "expo-router" import { useAtom, useAtomValue, useSetAtom } from "jotai" +import type { FC } from "react" import { useEffect, useRef } from "react" +import type { LayoutChangeEvent } from "react-native" import { Animated, Easing, @@ -22,13 +24,19 @@ import { accentColor, useColor } from "@/src/theme/colors" import { useDiscoverPageContext } from "./ctx" import { SearchTabBar } from "./SearchTabBar" -export const SearchHeader = () => { +export const SearchHeader: FC<{ + onLayout: (e: LayoutChangeEvent) => void +}> = ({ onLayout }) => { const frame = useSafeAreaFrame() const insets = useSafeAreaInsets() const headerHeight = getDefaultHeaderHeight(frame, false, insets.top) return ( - <View style={{ minHeight: headerHeight, paddingTop: insets.top }} className="relative"> + <View + style={{ minHeight: headerHeight, paddingTop: insets.top }} + className="relative" + onLayout={onLayout} + > <BlurEffect /> <View style={styles.header}> <ComposeSearchBar /> @@ -197,6 +205,7 @@ const SearchInput = () => { cursorColor={accentColor} selectionColor={accentColor} style={styles.searchInput} + className="text-text" onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} onChangeText={(text) => setSearchValue(text)} diff --git a/apps/mobile/src/screens/(headless)/search.tsx b/apps/mobile/src/screens/(headless)/search.tsx index 482a3bfa23..72af536bf3 100644 --- a/apps/mobile/src/screens/(headless)/search.tsx +++ b/apps/mobile/src/screens/(headless)/search.tsx @@ -1,26 +1,61 @@ import { Stack } from "expo-router" -import { Text, View } from "react-native" +import { ScrollView, Text, View } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" import { DiscoverPageContext, DiscoverPageProvider, + SearchBarHeightProvider, useDiscoverPageContext, + useSearchBarHeight, + useSetSearchBarHeight, } from "@/src/modules/discover/ctx" import { SearchHeader } from "@/src/modules/discover/search" const Search = () => { return ( - <View> + <View className="flex-1"> <DiscoverPageProvider> - <SearchbarMount /> - <Text>Search</Text> + <SearchBarHeightProvider> + <SearchbarMount /> + <Content /> + </SearchBarHeightProvider> </DiscoverPageProvider> </View> ) } +const Content = () => { + const searchBarHeight = useSearchBarHeight() + const insets = useSafeAreaInsets() + return ( + <ScrollView + style={{ paddingTop: searchBarHeight - insets.top }} + automaticallyAdjustContentInsets + contentInsetAdjustmentBehavior="always" + className="flex-1" + > + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + <Text className="text-text">Search</Text> + </ScrollView> + ) +} const SearchbarMount = () => { const ctx = useDiscoverPageContext() + const setSearchBarHeight = useSetSearchBarHeight() + return ( <Stack.Screen options={{ @@ -30,7 +65,7 @@ const SearchbarMount = () => { header: () => { return ( <DiscoverPageContext.Provider value={ctx}> - <SearchHeader /> + <SearchHeader onLayout={(e) => setSearchBarHeight(e.nativeEvent.layout.height)} /> </DiscoverPageContext.Provider> ) }, diff --git a/apps/mobile/src/screens/(modal)/follow.tsx b/apps/mobile/src/screens/(modal)/follow.tsx index 653bc96aac..81c8fc97a1 100644 --- a/apps/mobile/src/screens/(modal)/follow.tsx +++ b/apps/mobile/src/screens/(modal)/follow.tsx @@ -1,6 +1,7 @@ import { FeedViewType } from "@follow/constants" import { zodResolver } from "@hookform/resolvers/zod" -import { router, Stack, useLocalSearchParams } from "expo-router" +import { StackActions } from "@react-navigation/native" +import { router, Stack, useLocalSearchParams, useNavigation } from "expo-router" import { useState } from "react" import { Controller, useForm } from "react-hook-form" import { ScrollView, Text, View } from "react-native" @@ -15,6 +16,7 @@ import { FormLabel } from "@/src/components/ui/form/Label" import { FormSwitch } from "@/src/components/ui/form/Switch" import { TextField } from "@/src/components/ui/form/TextField" import { FeedIcon } from "@/src/components/ui/icon/feed-icon" +import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne" import { FeedViewSelector } from "@/src/modules/feed/view-selector" import { useFeed } from "@/src/store/feed/hooks" import { subscriptionSyncService } from "@/src/store/subscription/store" @@ -41,6 +43,9 @@ export default function Follow() { }) const [isLoading, setIsLoading] = useState(false) + const routeOnlyOne = useIsRouteOnlyOne() + const navigate = useNavigation() + const parentRoute = navigate.getParent() const submit = async () => { setIsLoading(true) const values = form.getValues() @@ -59,6 +64,10 @@ export default function Follow() { if (router.canDismiss()) { router.dismissAll() + + if (!routeOnlyOne) { + parentRoute?.dispatch(StackActions.popToTop()) + } } } From 9745f4d9d0248073e15658ef710c5b2083b273ba Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Thu, 9 Jan 2025 17:25:26 +0800 Subject: [PATCH 45/70] feat(rn): subscription list item transition Signed-off-by: Innei <tukon479@gmail.com> --- .../{index.tsx => AccordionItem.tsx} | 0 .../{list.tsx => SubscriptionLists.tsx} | 167 +++++++++--------- .../screens/(stack)/(tabs)/subscription.tsx | 4 +- 3 files changed, 89 insertions(+), 82 deletions(-) rename apps/mobile/src/components/ui/accordion/{index.tsx => AccordionItem.tsx} (100%) rename apps/mobile/src/modules/subscription/{list.tsx => SubscriptionLists.tsx} (79%) diff --git a/apps/mobile/src/components/ui/accordion/index.tsx b/apps/mobile/src/components/ui/accordion/AccordionItem.tsx similarity index 100% rename from apps/mobile/src/components/ui/accordion/index.tsx rename to apps/mobile/src/components/ui/accordion/AccordionItem.tsx diff --git a/apps/mobile/src/modules/subscription/list.tsx b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx similarity index 79% rename from apps/mobile/src/modules/subscription/list.tsx rename to apps/mobile/src/modules/subscription/SubscriptionLists.tsx index 0151658693..5f1f647f26 100644 --- a/apps/mobile/src/modules/subscription/list.tsx +++ b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx @@ -2,19 +2,22 @@ import { FeedViewType } from "@follow/constants" import { cn } from "@follow/utils" import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" import { useHeaderHeight } from "@react-navigation/elements" -import { FlashList } from "@shopify/flash-list" import { router } from "expo-router" import { useAtom } from "jotai" import { useColorScheme } from "nativewind" import type { FC } from "react" -import { createContext, memo, useContext, useEffect, useMemo, useRef } from "react" -import { Animated, Easing, Image, StyleSheet, Text, useAnimatedValue, View } from "react-native" +import { createContext, memo, useContext, useEffect, useMemo, useRef, useState } from "react" +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native" import PagerView from "react-native-pager-view" -import { useSharedValue } from "react-native-reanimated" +import ReAnimated, { + FadeOutUp, + LinearTransition, + useAnimatedStyle, + useSharedValue, + withSpring, +} from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" -import { AnimatedTouchableOpacity } from "@/src/components/common/AnimatedComponents" -import { AccordionItem } from "@/src/components/ui/accordion" import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" import { FeedIcon } from "@/src/components/ui/icon/feed-icon" import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" @@ -45,7 +48,7 @@ import { SubscriptionListItemContextMenu } from "../context-menu/lists" import { useFeedListSortMethod, useFeedListSortOrder, viewAtom } from "./atoms" import { useViewPageCurrentView, ViewPageCurrentViewProvider } from "./ctx" -export const SubscriptionList = memo(() => { +export const SubscriptionLists = memo(() => { const [currentView, setCurrentView] = useAtom(viewAtom) const pagerRef = useRef<PagerView>(null) @@ -79,7 +82,7 @@ export const SubscriptionList = memo(() => { ].map((view) => { return ( <ViewPageCurrentViewProvider key={view} value={view}> - <RecycleList view={view} /> + <SubscriptionList view={view} /> </ViewPageCurrentViewProvider> ) })} @@ -87,7 +90,13 @@ export const SubscriptionList = memo(() => { </> ) }) -const RecycleList = ({ view }: { view: FeedViewType }) => { +const keyExtractor = (item: string | { category: string; subscriptionIds: string[] }) => { + if (typeof item === "string") { + return item + } + return item.category +} +const SubscriptionList = ({ view }: { view: FeedViewType }) => { const headerHeight = useHeaderHeight() const insets = useSafeAreaInsets() const tabHeight = useBottomTabBarHeight() @@ -105,7 +114,7 @@ const RecycleList = ({ view }: { view: FeedViewType }) => { ) return ( - <FlashList + <ReAnimated.FlatList contentInsetAdjustmentBehavior="automatic" scrollIndicatorInsets={{ bottom: tabHeight - insets.bottom, @@ -116,9 +125,10 @@ const RecycleList = ({ view }: { view: FeedViewType }) => { paddingBottom: tabHeight, }} data={data} - estimatedItemSize={48} ListHeaderComponent={ListHeaderComponent} renderItem={ItemRender} + keyExtractor={keyExtractor} + itemLayoutAnimation={LinearTransition} extraData={{ total: data.length, }} @@ -298,54 +308,50 @@ const GroupedContext = createContext<string | null>(null) const CategoryGrouped = memo( ({ category, subscriptionIds }: { category: string; subscriptionIds: string[] }) => { const unreadCounts = useUnreadCounts(subscriptionIds) - const isExpanded = useSharedValue(false) - const rotateValue = useAnimatedValue(1) + const [expanded, setExpanded] = useState(false) + const rotateSharedValue = useSharedValue(0) + const rotateStyle = useAnimatedStyle(() => { + return { + transform: [{ rotate: `${rotateSharedValue.value}deg` }], + } + }, [rotateSharedValue]) return ( - <SubscriptionFeedCategoryContextMenu category={category} feedIds={subscriptionIds}> - <ItemPressable - onPress={() => { - // TODO navigate to category - }} - className="border-item-pressed h-12 flex-row items-center border-b px-3" - > - <AnimatedTouchableOpacity - hitSlop={10} + <> + <SubscriptionFeedCategoryContextMenu category={category} feedIds={subscriptionIds}> + <ItemPressable onPress={() => { - Animated.timing(rotateValue, { - toValue: isExpanded.value ? 1 : 0, - easing: Easing.linear, - - useNativeDriver: true, - }).start() - isExpanded.value = !isExpanded.value + // TODO navigate to category }} - style={[ - { - transform: [ - { - rotate: rotateValue.interpolate({ - inputRange: [0, 1], - outputRange: ["90deg", "0deg"], - }), - }, - ], - }, - style.accordionIcon, - ]} + className="border-item-pressed h-12 flex-row items-center border-b px-3" > - <MingcuteRightLine color="gray" height={18} width={18} /> - </AnimatedTouchableOpacity> - <Text className="text-text ml-3">{category}</Text> - {!!unreadCounts && ( - <Text className="text-tertiary-label ml-auto text-xs">{unreadCounts}</Text> - )} - </ItemPressable> - <AccordionItem isExpanded={isExpanded} viewKey={category}> + <TouchableOpacity + hitSlop={10} + onPress={() => { + rotateSharedValue.value = withSpring(expanded ? 90 : 0, {}) + setExpanded(!expanded) + }} + style={style.accordionIcon} + > + <ReAnimated.View style={rotateStyle}> + <MingcuteRightLine color="gray" height={18} width={18} /> + </ReAnimated.View> + </TouchableOpacity> + <Text className="text-text ml-3">{category}</Text> + {!!unreadCounts && ( + <Text className="text-tertiary-label ml-auto text-xs">{unreadCounts}</Text> + )} + </ItemPressable> + </SubscriptionFeedCategoryContextMenu> + {/* <AccordionItem isExpanded={isExpanded} viewKey={category}> */} + {expanded && ( <GroupedContext.Provider value={category}> - <UnGroupedList subscriptionIds={subscriptionIds} /> + <View> + <UnGroupedList subscriptionIds={subscriptionIds} /> + </View> </GroupedContext.Provider> - </AccordionItem> - </SubscriptionFeedCategoryContextMenu> + )} + {/* </AccordionItem> */} + </> ) }, ) @@ -407,33 +413,34 @@ const SubscriptionItem = memo(({ id, className }: { id: string; className?: stri // }} // > // <ReAnimated.View key={id} layout={CurvedTransition} exiting={FadeOut}> - <SubscriptionFeedItemContextMenu id={id} view={view}> - <ItemPressable - className={cn( - "flex h-12 flex-row items-center", - inGrouped ? "pl-8 pr-4" : "px-4", - "border-item-pressed border-b", - className, - )} - onPress={() => { - router.push({ - pathname: `/feeds/[feedId]`, - params: { - feedId: id, - }, - }) - }} - > - <View className="dark:border-tertiary-system-background mr-3 size-5 items-center justify-center overflow-hidden rounded-full border border-transparent dark:bg-[#222]"> - <FeedIcon feed={feed} /> - </View> - <Text className="text-text">{subscription.title || feed.title}</Text> - {!!unreadCount && ( - <Text className="text-tertiary-label ml-auto text-xs">{unreadCount}</Text> - )} - </ItemPressable> - </SubscriptionFeedItemContextMenu> - // </ReAnimated.View> + <ReAnimated.View exiting={FadeOutUp}> + <SubscriptionFeedItemContextMenu id={id} view={view}> + <ItemPressable + className={cn( + "flex h-12 flex-row items-center", + inGrouped ? "pl-8 pr-4" : "px-4", + "border-item-pressed border-b", + className, + )} + onPress={() => { + router.push({ + pathname: `/feeds/[feedId]`, + params: { + feedId: id, + }, + }) + }} + > + <View className="dark:border-tertiary-system-background mr-3 size-5 items-center justify-center overflow-hidden rounded-full border border-transparent dark:bg-[#222]"> + <FeedIcon feed={feed} /> + </View> + <Text className="text-text">{subscription.title || feed.title}</Text> + {!!unreadCount && ( + <Text className="text-tertiary-label ml-auto text-xs">{unreadCount}</Text> + )} + </ItemPressable> + </SubscriptionFeedItemContextMenu> + </ReAnimated.View> // </Swipeable> ) }) diff --git a/apps/mobile/src/screens/(stack)/(tabs)/subscription.tsx b/apps/mobile/src/screens/(stack)/(tabs)/subscription.tsx index 216f21511b..a2d51462e9 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/subscription.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/subscription.tsx @@ -6,7 +6,7 @@ import { views } from "@/src/constants/views" import { AddCuteReIcon } from "@/src/icons/add_cute_re" import { useCurrentView } from "@/src/modules/subscription/atoms" import { SortActionButton } from "@/src/modules/subscription/header-actions" -import { SubscriptionList } from "@/src/modules/subscription/list" +import { SubscriptionLists } from "@/src/modules/subscription/SubscriptionLists" import { usePrefetchUnread } from "@/src/store/unread/hooks" import { accentColor } from "@/src/theme/colors" @@ -27,7 +27,7 @@ export default function FeedList() { headerTransparent: true, }} /> - <SubscriptionList /> + <SubscriptionLists /> <ViewTab /> </> ) From ca4d3ea8b41a706743be0884558adf116c70ca60 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:34:39 +0800 Subject: [PATCH 46/70] fix: remove noScale arg --- apps/renderer/src/components/ui/media.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/renderer/src/components/ui/media.tsx b/apps/renderer/src/components/ui/media.tsx index dd7e8648a9..cc5522c547 100644 --- a/apps/renderer/src/components/ui/media.tsx +++ b/apps/renderer/src/components/ui/media.tsx @@ -300,7 +300,6 @@ const MediaImpl: FC<MediaProps> = ({ width={Number.parseInt(props.width as string)} height={Number.parseInt(props.height as string)} containerWidth={containerWidth} - noScale={inline} fitContent={fitContent} > <div @@ -360,7 +359,6 @@ const AspectRatio = ({ containerWidth, children, style, - noScale, fitContent, ...props }: { @@ -369,19 +367,14 @@ const AspectRatio = ({ containerWidth?: number children: React.ReactNode style?: React.CSSProperties - /** - * Keep the content size for inline image usage - */ - noScale?: boolean /** * If `fit` is true, the content width may be increased to fit the container width */ fitContent?: boolean [key: string]: any }) => { - const scaleFactor = noScale - ? 1 - : containerWidth && width + const scaleFactor = + containerWidth && width ? fitContent ? containerWidth / width : Math.min(1, containerWidth / width) From e6ba5159755aabf9db06cd190932a510873f2d24 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Thu, 9 Jan 2025 18:49:32 +0800 Subject: [PATCH 47/70] chore(release): release v0.3.1-beta.0 --- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++++- changelog/0.3.1.md | 10 ++++++++++ changelog/next.md | 3 --- package.json | 4 ++-- 4 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 changelog/0.3.1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9480e2f354..0798978108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG -# [0.3.0-beta.0](https://github.com/RSSNext/follow/compare/v0.2.7-beta.0...v0.3.0-beta.0) (2025-01-02) +## [0.3.1-beta.0](https://github.com/RSSNext/follow/compare/v0.2.7-beta.0...v0.3.1-beta.0) (2025-01-09) ### Bug Fixes @@ -44,6 +44,7 @@ * audio player on mobile ([1e936e2](https://github.com/RSSNext/follow/commit/1e936e2c593adafa92d54ddc542ff30688c0e3bc)) * audio player width, fixed [#1934](https://github.com/RSSNext/follow/issues/1934) ([238938a](https://github.com/RSSNext/follow/commit/238938a4da5b854f9f3422b93059b9f82f60d09b)) * auth in electron ([405ee42](https://github.com/RSSNext/follow/commit/405ee42e8a1232424bb667cd56fffd3d0b463a4e)) +* authentication style on mobile ([58e796e](https://github.com/RSSNext/follow/commit/58e796e3df1844ccb2c4cadb6578051d1114c628)) * **auth:** handle optional icon class names for providers in login components ([de8d337](https://github.com/RSSNext/follow/commit/de8d3376713be0b696579e16395b887b2658d59f)) * **auth:** redirect url ([e7d5576](https://github.com/RSSNext/follow/commit/e7d5576b63a77cd4f4e25a3124f9685f408a73de)) * auto archived list flash ([#1269](https://github.com/RSSNext/follow/issues/1269)) ([8d2478c](https://github.com/RSSNext/follow/commit/8d2478c6a93aa9f5a99fddc55bbb8e532e1a0fe1)) @@ -103,6 +104,7 @@ * detecting windows11 ([#1170](https://github.com/RSSNext/follow/issues/1170)) ([2813f1d](https://github.com/RSSNext/follow/commit/2813f1d6540fcb215287dd32cbc0f8cf87cb1cb0)) * **devtool:** fix devtool font in windows ([2b64659](https://github.com/RSSNext/follow/commit/2b6465921c23d2357413423c27da89fe7bd06568)) * dialog did not close after confirmation ([#1628](https://github.com/RSSNext/follow/issues/1628)) ([6a5ec1b](https://github.com/RSSNext/follow/commit/6a5ec1b044f35e7ffa537436d0a7caba2f8d488e)) +* dialog for update password ([78df5b9](https://github.com/RSSNext/follow/commit/78df5b9073988f7046362aa4e819f79c0eb9f2f0)) * disable `pointerDownOutside` trigger `onDismiss` ([#1215](https://github.com/RSSNext/follow/issues/1215)) ([2bf5245](https://github.com/RSSNext/follow/commit/2bf5245126bc9ef890c06afe5a825d1e1ab4059c)) * disable auto load archive for inbox and list ([7825e16](https://github.com/RSSNext/follow/commit/7825e16b6d1230ba2c13e5ef00474fc05cc510e5)) * disable horizontal auto-scroll ([8666b41](https://github.com/RSSNext/follow/commit/8666b41f2dded5b6b5cb86b7824683b48582cf1d)), closes [#1512](https://github.com/RSSNext/follow/issues/1512) @@ -130,6 +132,7 @@ * entry title can selectable, fixed [#1428](https://github.com/RSSNext/follow/issues/1428) ([6320c00](https://github.com/RSSNext/follow/commit/6320c00373a98846cdd92235b044fa1fd1b8266e)) * entry title truncate ([685ce3c](https://github.com/RSSNext/follow/commit/685ce3cc4165c35918e92b06951d2479ce5adb37)) * **entry-column:** pre-render cached entries ([5065c8e](https://github.com/RSSNext/follow/commit/5065c8e52dcba1a8d8f16ce3729b7c2eb7f5a192)) +* error and warning in mobile ([#2475](https://github.com/RSSNext/follow/issues/2475)) ([ff1dc03](https://github.com/RSSNext/follow/commit/ff1dc03304e1d4911c5ae7df34b793dc4132bce3)) * escape for seo meta tags ([9564257](https://github.com/RSSNext/follow/commit/9564257564e417e18bdeedded9397d469e6daf14)), closes [#1232](https://github.com/RSSNext/follow/issues/1232) * explicitly assign a value to `cancelId` for ‘Clear All Data' dialog ([#1624](https://github.com/RSSNext/follow/issues/1624)) ([fdb87cf](https://github.com/RSSNext/follow/commit/fdb87cf5cdf9623f16eafa7e9ae25f4fc7950e05)) * **external:** add global 404 page ([5e49b81](https://github.com/RSSNext/follow/commit/5e49b8114e0446b734a54eea3291721d8f9656b3)) @@ -150,6 +153,7 @@ * fill missing userId when open tip modal, close [#2248](https://github.com/RSSNext/follow/issues/2248) ([53ad291](https://github.com/RSSNext/follow/commit/53ad291820ccefc6badee8d3dc6af276c31c3cc6)) * filter no picture breaking logic ([9a84ed4](https://github.com/RSSNext/follow/commit/9a84ed40a16ea58c2c1cdc8cf9c46009fe46faea)) * firefix icon, fix [#2370](https://github.com/RSSNext/follow/issues/2370) ([bbf7acf](https://github.com/RSSNext/follow/commit/bbf7acfd3dddbcd4ee25fea640b12be194ee7596)) +* fix decoding error(utf-8,gbk,iso-8859 and other charsets) in readability (issue [#2435](https://github.com/RSSNext/follow/issues/2435)) ([#2449](https://github.com/RSSNext/follow/issues/2449)) ([1de1cd9](https://github.com/RSSNext/follow/commit/1de1cd9ae18d89e88805816b7fdea4e1438cd39f)) * flickering issue when FAB button disappears ([#2390](https://github.com/RSSNext/follow/issues/2390)) ([2bc8968](https://github.com/RSSNext/follow/commit/2bc89685b5abee504f6c4dc0aad479a5b931266e)) * float sidebar missing background ([b9982e0](https://github.com/RSSNext/follow/commit/b9982e0a6b44f476621daf30f93f8e740a544a02)) * follow external server fetch ua ([072dec0](https://github.com/RSSNext/follow/commit/072dec02b787bcdd8af7f5c0bf25398e2da8c15e)) @@ -173,11 +177,13 @@ * **i18n:** update Japanese translations for better clarity ([#1842](https://github.com/RSSNext/follow/issues/1842)) ([bf9fb22](https://github.com/RSSNext/follow/commit/bf9fb2289ae7d6c553ec10ba28cab9f04e9f1bcd)) * **i18n:** update label for notification badge settings in zh-CN locale and others ([#1455](https://github.com/RSSNext/follow/issues/1455)) ([ec82f03](https://github.com/RSSNext/follow/commit/ec82f036b44a3d0d463c48c1d793fc517def1d07)) * ignore `file` and editor protocols ([2badb9b](https://github.com/RSSNext/follow/commit/2badb9b1c0bf6628d2a2fc177063fd0c7c7fb08b)) +* ignore `node_modules` for tailwindcss to avoid warning ([#2425](https://github.com/RSSNext/follow/issues/2425)) ([1e8f80e](https://github.com/RSSNext/follow/commit/1e8f80ef158ce1e59af7ae106b871a60742038f3)) * image blurhash out when image loaded ([5b237ca](https://github.com/RSSNext/follow/commit/5b237caf803ed5a2cb95a7fb6e0f2fb7940f5da9)) * image blurhash placeholder ([3ca9b5a](https://github.com/RSSNext/follow/commit/3ca9b5a65978e854dafdfe73d81c873379c82c42)) * image error ui ([#2124](https://github.com/RSSNext/follow/issues/2124)) ([008cc98](https://github.com/RSSNext/follow/commit/008cc98f8a2624d4360e7c6bb8ba7176436f8579)) * immer object extensible ([6ad35ad](https://github.com/RSSNext/follow/commit/6ad35ad6712a68db9e231bf5733a2956ccecda90)) * import ([9d6e7d4](https://github.com/RSSNext/follow/commit/9d6e7d44363efc50a592e13e7c20aa6eca2e03d0)) +* improve Markdown rendering and UI adjustments in various components ([1439d87](https://github.com/RSSNext/follow/commit/1439d87f071dd6153519f56091e73f3fabf817b4)) * improve multi select behavior ([24017df](https://github.com/RSSNext/follow/commit/24017dfb3190c91969ec68d3d88544e051880469)) * improve perform for feed column ([#1708](https://github.com/RSSNext/follow/issues/1708)) ([1f349ed](https://github.com/RSSNext/follow/commit/1f349ed73e912825fb34877f5db9df13c6c6f879)) * improve protocol handling ([0a0b39b](https://github.com/RSSNext/follow/commit/0a0b39b5f8581250c7146cd5cd078b9a0cec89d2)) @@ -194,6 +200,7 @@ * lock ([97c1ece](https://github.com/RSSNext/follow/commit/97c1ece9e3d855c7dbcf7238e7953e9bd206811c)) * login override provider icon ([96b8929](https://github.com/RSSNext/follow/commit/96b892923ab1e791d29f0464b847a6873c61f075)) * login page redirection ([fedf2af](https://github.com/RSSNext/follow/commit/fedf2af98459c5e0a50678fcc9923dc288190662)) +* login page styles ([e77a0e1](https://github.com/RSSNext/follow/commit/e77a0e1043bb59439c009daff4e4fa1bc7df3677)) * mark as read when navigating ([423777e](https://github.com/RSSNext/follow/commit/423777e1372631b8ea8d06913ce55fbdf97f1f07)) * mark feed unread dirty, refetch unread next time, fixed [#1830](https://github.com/RSSNext/follow/issues/1830) ([58d0e9c](https://github.com/RSSNext/follow/commit/58d0e9c1902157c2e9fb1444bc1c0db0b775485b)) * mark single feed as all read ([a4acd2f](https://github.com/RSSNext/follow/commit/a4acd2fb65335f228875ed5b38f03a9c71b76e1b)) @@ -204,6 +211,9 @@ * missing site url in feed selector ([ea677ac](https://github.com/RSSNext/follow/commit/ea677ac6a3a43489a9508a32ecb22fa38ecf4f27)) * mobile need login modal ([0fb16f2](https://github.com/RSSNext/follow/commit/0fb16f2352e9776731522204b838be59f66127a4)) * mobile pop back to entry list will refresh data ([3c18768](https://github.com/RSSNext/follow/commit/3c18768968aeb4635933ae616c52de55b6fad2ab)) +* **mobile:** background color ([9f6934f](https://github.com/RSSNext/follow/commit/9f6934f844c995a1dcbb326123420e6ee9b05a96)) +* **mobile:** improve responsive design in CornerPlayer ([949a07e](https://github.com/RSSNext/follow/commit/949a07ed0e3cb8b8ea582ebd926f143da7386736)) +* **mobile:** update email login style ([9019e9b](https://github.com/RSSNext/follow/commit/9019e9bbc6ebcdcbd8a0f21844e04f1df17dd84a)) * modal bottom buttons align ([#1216](https://github.com/RSSNext/follow/issues/1216)) ([b97096d](https://github.com/RSSNext/follow/commit/b97096dbd06bb7687b6c1313de45d8e91dee25af)) * modal close button overlaps the select content ([#1166](https://github.com/RSSNext/follow/issues/1166)) ([3c103ed](https://github.com/RSSNext/follow/commit/3c103ed15daeb1f57a001d615c047c303087ba46)) * modal exiting transition type ([5bb0100](https://github.com/RSSNext/follow/commit/5bb01006ad04f8e5ed93076905b78b74ce293f79)) @@ -239,6 +249,7 @@ * prevent default click behavior in Media ([#1905](https://github.com/RSSNext/follow/issues/1905)) ([97dee6e](https://github.com/RSSNext/follow/commit/97dee6eda40e63dec2e66aa20da31a5d1ceed875)) * prevent default for cmd+k ([81d49f0](https://github.com/RSSNext/follow/commit/81d49f0893100de85a22d114765f76e7c9dea36c)) * prevent default scrolling behavior while using arrow keys to switch between entries ([#1447](https://github.com/RSSNext/follow/issues/1447)) ([ed5ee50](https://github.com/RSSNext/follow/commit/ed5ee50fe2676309e5451d73531baf59bbf0d746)) +* prevent media overflow ([5a7160d](https://github.com/RSSNext/follow/commit/5a7160d5af8e86c4b78e46bd2a5c141f3004c9f0)) * prevent overscroll bounce in some scene ([11803c8](https://github.com/RSSNext/follow/commit/11803c84b38a219d40cc0c97a0e6ec2c826dbd81)) * prevent right cilck on content menu ([#1525](https://github.com/RSSNext/follow/issues/1525)) ([ca6428f](https://github.com/RSSNext/follow/commit/ca6428f21926524d08c8aae121e9c705a147540e)) * prevent withdrawal of zero amount to avoid unnecessary fees ([#1422](https://github.com/RSSNext/follow/issues/1422)) ([6584526](https://github.com/RSSNext/follow/commit/65845268f9940247d3b645b1588d1564ec0b4cff)) @@ -255,6 +266,7 @@ * remove hardcode minfest ([0432618](https://github.com/RSSNext/follow/commit/04326186b71dc840e1454e6a1573257c97db32c8)) * remove immer set to avoid object extensible ([7e5a791](https://github.com/RSSNext/follow/commit/7e5a79155a083a8242579dee18d019049b7ad6f5)) * remove mouse enter event on mobile ([279e402](https://github.com/RSSNext/follow/commit/279e4025a5b14ad5cf5972bf439d2a30ce7d7d79)) +* remove noScale arg ([ca4d3ea](https://github.com/RSSNext/follow/commit/ca4d3ea8b41a706743be0884558adf116c70ca60)) * remove prev entries ids ref ([996629c](https://github.com/RSSNext/follow/commit/996629c14c752f76b65d423ebc23d60b315dee77)) * remove skeleton when app load but in 404 ([54d4e52](https://github.com/RSSNext/follow/commit/54d4e529c796e6cc22c36b2a9a1e949b1dad5b37)) * remove stack when close sheet ([eeb785d](https://github.com/RSSNext/follow/commit/eeb785de3f822166a04b3e6916fc107fb600db25)), closes [#1910](https://github.com/RSSNext/follow/issues/1910) @@ -268,6 +280,10 @@ * revert electron bump ([#1991](https://github.com/RSSNext/follow/issues/1991)) ([200415c](https://github.com/RSSNext/follow/commit/200415c77f52d11a43c2610c5c58c61fbbfb23f8)) * revert merge chunk ([4a9679b](https://github.com/RSSNext/follow/commit/4a9679bad109a786b80939ca9ead32f1505be186)) * **rn:** adjust ui colors ([33d9e14](https://github.com/RSSNext/follow/commit/33d9e14d7c377c76dc349c1fc6ae901da2fe30c7)) +* **rn:** check tab current index frequency ([fc3a3ce](https://github.com/RSSNext/follow/commit/fc3a3cef23532143853625a1af6cc2bbe3c28a3d)) +* **rn:** delete subscribe in db persist ([e2c9459](https://github.com/RSSNext/follow/commit/e2c9459db8cc0137bb63ea83b1cd7f2cb2a3dab4)) +* **rn:** discover form ([4f3d990](https://github.com/RSSNext/follow/commit/4f3d9902af372dd849f02a20dc1ae644d309fd73)) +* **rn:** rsshub form styles ([8583210](https://github.com/RSSNext/follow/commit/85832105b4e6a860d34374cfa7e6c01a06cc54a3)) * **rsshub:** adjust table cell width ([ca8d19d](https://github.com/RSSNext/follow/commit/ca8d19dc394d7f9907a40834abcdd0e14ddc2b47)) * scroll bar z index ([5057999](https://github.com/RSSNext/follow/commit/50579995367b871e84768c5983b3302f75f8e077)), closes [#1233](https://github.com/RSSNext/follow/issues/1233) * scroll out mark read logic ([eaecb25](https://github.com/RSSNext/follow/commit/eaecb252d322b5f2e8e89873523dfbe8d213848c)) @@ -288,6 +304,7 @@ * set windows env ([1ea103b](https://github.com/RSSNext/follow/commit/1ea103b7ee1689ba9683c341a2afacacf2884fbd)) * setting loader type error ([da8aad7](https://github.com/RSSNext/follow/commit/da8aad7327d45cd899f8091cb0315ff2497b2ab9)) * setting loader type error ([#1469](https://github.com/RSSNext/follow/issues/1469)) ([6ef5622](https://github.com/RSSNext/follow/commit/6ef5622a32eaaebfeec32cca1000bf908f742e34)) +* **settings:** align submit button in ExportFeedsForm for better layout ([15feb5a](https://github.com/RSSNext/follow/commit/15feb5a35ac6db4ff5c12ff4d1f46c25632d7693)) * **share:** normal list item layout ([3a0c039](https://github.com/RSSNext/follow/commit/3a0c0393e01e09ebf86ab1415b3d84bca6d2b882)) * **share:** og image grid width and image align top to description ([14e37da](https://github.com/RSSNext/follow/commit/14e37da0cebbdb1b283c562c21acc2f8ba385bf9)) * sheet stack dismiss transition ([6a30f23](https://github.com/RSSNext/follow/commit/6a30f2363e3d99c95b3b7a5415c4fd898d1c12a4)) @@ -325,6 +342,7 @@ * **toc:** should focus when toc item clicked ([f51dfdd](https://github.com/RSSNext/follow/commit/f51dfddc9ef40f4a7f226f32f9357e03a04f6ee0)) * translate form style ([6f3bb61](https://github.com/RSSNext/follow/commit/6f3bb61023dde1d58164425d8649c90bb4f680c6)), closes [#1184](https://github.com/RSSNext/follow/issues/1184) * **translations:** update zh-CN locale files for accuracy ([#1739](https://github.com/RSSNext/follow/issues/1739)) ([83c9f71](https://github.com/RSSNext/follow/commit/83c9f717dd7092dc4a4b1106961d746988066fd9)) +* tray icon appears small and blurry on Windows, Close [#2077](https://github.com/RSSNext/follow/issues/2077) ([#2461](https://github.com/RSSNext/follow/issues/2461)) ([b4edc42](https://github.com/RSSNext/follow/commit/b4edc426df34549dbb870b13ab3a8ca949eedd6b)) * trending api based on language ([83ca873](https://github.com/RSSNext/follow/commit/83ca87349598acc361c8128bbcb014d89fcb56cf)) * trending in mobile ([128c8ca](https://github.com/RSSNext/follow/commit/128c8ca44d059120e89fd40b4b0d76063114b30e)) * trial limit ([eee2bf8](https://github.com/RSSNext/follow/commit/eee2bf8771d2d30bba1f534984e3ca8d5e26cf04)) @@ -371,6 +389,7 @@ * vercel rewrite config ([#1203](https://github.com/RSSNext/follow/issues/1203)) ([c954d61](https://github.com/RSSNext/follow/commit/c954d61f074e282a9a86cbf1d1546748871c20e7)) * video media can not play in video view ([4cbf8a5](https://github.com/RSSNext/follow/commit/4cbf8a54fcf11fb71ff935baee52c79a83f5035c)), closes [#1645](https://github.com/RSSNext/follow/issues/1645) * **video:** open entry url directly on mobile ([0eed6d1](https://github.com/RSSNext/follow/commit/0eed6d1b237a612981938b32ce884b5f0c1bd13b)) +* View freeze while using swipe gesture to go back in safari ([#2412](https://github.com/RSSNext/follow/issues/2412)) ([9ee3d3d](https://github.com/RSSNext/follow/commit/9ee3d3d8a3a34d922e7ed34428aedd110f56b5bf)) * When pasting the entire URL, auto adjust the URL prefix ([#1762](https://github.com/RSSNext/follow/issues/1762)) ([3c7aeac](https://github.com/RSSNext/follow/commit/3c7aeacc90cbdcbe6bf4b06572d74f269f2c8854)), closes [#1761](https://github.com/RSSNext/follow/issues/1761) * window titlebar maximize state sync, fixed [#1982](https://github.com/RSSNext/follow/issues/1982) ([#1989](https://github.com/RSSNext/follow/issues/1989)) ([7ad1e30](https://github.com/RSSNext/follow/commit/7ad1e305c3167633d58b6b621dba2cd63d1cf528)) * wrap with Ellipsis ([3ba9093](https://github.com/RSSNext/follow/commit/3ba90936931ad36790a68ee90a2c1582054bb90a)) @@ -393,6 +412,7 @@ * add hydrate data type-safe helper ([cb6a65b](https://github.com/RSSNext/follow/commit/cb6a65b7cc1651d86b83b5b4089a7145cd59b0be)) * add image preview in picture gallery images ([59728a8](https://github.com/RSSNext/follow/commit/59728a89295c2a9c6130651d06699eef027fef5e)) * add list madeby ([03858b1](https://github.com/RSSNext/follow/commit/03858b18b8de5cffb2add05d72fdbf5e229e6cf4)) +* add loading indicator and UI improvements in transaction ([#2480](https://github.com/RSSNext/follow/issues/2480)) ([6c3e8e8](https://github.com/RSSNext/follow/commit/6c3e8e82c64005466f21c3ee2680574da43fd604)) * add media session action handlers for play and pause ([#2394](https://github.com/RSSNext/follow/issues/2394)) ([e7a1da3](https://github.com/RSSNext/follow/commit/e7a1da32be21849f30c085046983efed0b4654ad)) * add mobile top timeline setting ([fe48d7c](https://github.com/RSSNext/follow/commit/fe48d7cf02a202a1f14bbfe3abe4c813629ac968)) * add more actions to entry header ([#1993](https://github.com/RSSNext/follow/issues/1993)) ([5c4b5f0](https://github.com/RSSNext/follow/commit/5c4b5f0095cff53f1ce70d66f2275d0a9c73e985)) @@ -420,6 +440,7 @@ * copy button for ai summary ([b3ee572](https://github.com/RSSNext/follow/commit/b3ee5727e29d809092a8f4631d3e13c8d81095b9)) * copy profile email ([1e12588](https://github.com/RSSNext/follow/commit/1e1258811163d72ae36f29714eef5d50834c348b)) * customizable columns for masonry view, closed [#1749](https://github.com/RSSNext/follow/issues/1749) ([0e0ce84](https://github.com/RSSNext/follow/commit/0e0ce843235f01f33f4c5b9708aa67dac5901b46)) +* customize toolbar ([#2468](https://github.com/RSSNext/follow/issues/2468)) ([38fe110](https://github.com/RSSNext/follow/commit/38fe11075424a31a60c9db1c2758980f957c19c2)) * discover rsshub card background use single color ([7eeea5e](https://github.com/RSSNext/follow/commit/7eeea5e694c142803a37564ef8886d4fc4d2dab4)) * **discover:** enhance RSSHub recommendations with filters ([#1481](https://github.com/RSSNext/follow/issues/1481)) ([eb70126](https://github.com/RSSNext/follow/commit/eb70126b8283b6e0b246f86751e588a37cb34902)) * **discover:** implement searchable header and enhance UI with animated search bar ([2300996](https://github.com/RSSNext/follow/commit/2300996b87c2850f8f496bdd982e7ca8e5938d7f)) @@ -429,7 +450,9 @@ * edit rsshub instance ([7f080aa](https://github.com/RSSNext/follow/commit/7f080aa00b36d42f614bce6408a4400748ee3fb4)) * email verification ([3497623](https://github.com/RSSNext/follow/commit/3497623b853f8321ed81788e32d43846be6d6135)) * email verification ([d4905fd](https://github.com/RSSNext/follow/commit/d4905fd0082b41de3f76afa597d67030761a4ae8)) +* enhance deep link handling in FollowWebView component to support new routes for adding and following items ([b46e178](https://github.com/RSSNext/follow/commit/b46e1780f783c24e5706b9c350b980c62a3cd9c6)) * enhance SocialMediaItem component with dynamic title styling and action bar positioning ([5d2fe70](https://github.com/RSSNext/follow/commit/5d2fe70b52a857c16ed7220fc36ecd5111f8cd4d)) +* enhance table with new translations and tooltip for descriptions ([#2471](https://github.com/RSSNext/follow/issues/2471)) ([99011eb](https://github.com/RSSNext/follow/commit/99011ebcf2cd3c4096520394537bb97b5ea08865)) * entry image gallery modal ([e0d3e17](https://github.com/RSSNext/follow/commit/e0d3e17da4ee17217d7b78871b546f43af87d893)) * export database ([85b4502](https://github.com/RSSNext/follow/commit/85b4502f9c113b8de73ccf4a167aa514a3c149ea)) * **external:** move `login` and `redirect` route to external ([7916803](https://github.com/RSSNext/follow/commit/791680332d5e1c2c52eda792dd7ff69281f25adb)) @@ -447,7 +470,9 @@ * **i18n:** translations (zh-TW) ([#2166](https://github.com/RSSNext/follow/issues/2166)) ([45e37a0](https://github.com/RSSNext/follow/commit/45e37a07e8eee65ae5b02b524b5e09185c804c08)) * **icon:** use gradient fallback background ([e827002](https://github.com/RSSNext/follow/commit/e8270025e469d9a0463d267d3da591a2991951bd)) * image zoom ([1e47ba2](https://github.com/RSSNext/follow/commit/1e47ba25671000408e69fc3bae2c4626d0bd664e)), closes [#1183](https://github.com/RSSNext/follow/issues/1183) +* improve email verification display ([#2424](https://github.com/RSSNext/follow/issues/2424)) ([ebc6c4f](https://github.com/RSSNext/follow/commit/ebc6c4fdb6773f2b7d94b81b839a3a338787c4b3)) * **infra:** electron app can hot update renderer layer ([#1209](https://github.com/RSSNext/follow/issues/1209)) ([ca4751a](https://github.com/RSSNext/follow/commit/ca4751acd275579614a477d133ed643fca3fbf1a)) +* integrate LoadingContainer ([834dff8](https://github.com/RSSNext/follow/commit/834dff8d91406eeb56b035e2da9f9d6e3a853850)) * integrate react-query for fetching unread feed items by view ([d4dd4fb](https://github.com/RSSNext/follow/commit/d4dd4fb68d5a1cebdaeeea1aff88c2fbe2fba5e9)) * **integration:** add outline integration ([#1229](https://github.com/RSSNext/follow/issues/1229)) ([0d0266b](https://github.com/RSSNext/follow/commit/0d0266b25189a74efdc63ce0062f3a379d4a0729)) * **integration:** Add readeck integration ([#1972](https://github.com/RSSNext/follow/issues/1972)) ([1ce3f5b](https://github.com/RSSNext/follow/commit/1ce3f5b8ba95b16ad1b12e702027cede3590491a)) @@ -455,7 +480,9 @@ * list preview ([ae390f7](https://github.com/RSSNext/follow/commit/ae390f7afabf3e312217bf13a175e2036c2c2763)) * load archived entries automatically ([5fe9e0c](https://github.com/RSSNext/follow/commit/5fe9e0c0e24460ca0fe435db03b753e9dcd3df17)) * **locales:** enhance zh-HK translations ([#2208](https://github.com/RSSNext/follow/issues/2208)) ([2ecd89f](https://github.com/RSSNext/follow/commit/2ecd89ffde7a24580fb879192abdfa7ff95c6988)) +* **locales:** update Japanese translations for RSSHub and related settings ([#2474](https://github.com/RSSNext/follow/issues/2474)) ([6ed05ce](https://github.com/RSSNext/follow/commit/6ed05cea8bff558930a94c0742abb3d350580e54)) * **locales:** update zh-TW translations ([#2290](https://github.com/RSSNext/follow/issues/2290)) ([f734e6c](https://github.com/RSSNext/follow/commit/f734e6cfa3348f8906b48cbae8188531fd266e5e)) +* **locales:** update zh-TW translations ([#2446](https://github.com/RSSNext/follow/issues/2446)) ([dbae887](https://github.com/RSSNext/follow/commit/dbae887df1fd23a92593682d74907d3851fdf183)) * manual action ([#1867](https://github.com/RSSNext/follow/issues/1867)) ([0eedbba](https://github.com/RSSNext/follow/commit/0eedbbadc263b474e2d4bfd5b6c498ac39c16c34)) * manually link social account ([fd373fb](https://github.com/RSSNext/follow/commit/fd373fb5271e4d9a31e37d49830d3fe3d4927922)) * **mark-all-button:** add countdown to auto-confirm message ([#1414](https://github.com/RSSNext/follow/issues/1414)) ([e1a5fc6](https://github.com/RSSNext/follow/commit/e1a5fc63f20c941140071789c9b67685da19ea5c)) @@ -463,6 +490,8 @@ * migrate to better auth ([#1951](https://github.com/RSSNext/follow/issues/1951)) ([9102203](https://github.com/RSSNext/follow/commit/910220395631bcf86f56bfc2b2168ac95852444c)) * mobile app architecture and initial screens ([#2144](https://github.com/RSSNext/follow/issues/2144)) ([4b6a0fc](https://github.com/RSSNext/follow/commit/4b6a0fca2e3243061c7befc837e705262f17b664)) * mobile design ([#1568](https://github.com/RSSNext/follow/issues/1568)) ([edd4f9e](https://github.com/RSSNext/follow/commit/edd4f9e5a6dc041e9eae2cee5ddf4eb624527ef5)), closes [#1575](https://github.com/RSSNext/follow/issues/1575) +* **mobile:** introduce expo image ([9ea93b8](https://github.com/RSSNext/follow/commit/9ea93b80238f47788e0f984f03faf3c9a3434a81)) +* **modal:** enhance Follow modal with improved navigation and loading state ([cf31c0f](https://github.com/RSSNext/follow/commit/cf31c0f5fae3fd6dac1e1e571e870ea57e05ed0b)) * move feed to new category in context menu ([#2072](https://github.com/RSSNext/follow/issues/2072)) ([d684e14](https://github.com/RSSNext/follow/commit/d684e14056581744ac78ca697fa5ca059294245e)) * move hideExtraBadge ([2f14c30](https://github.com/RSSNext/follow/commit/2f14c30307a895bc866ac57989df1dc624eab4e6)) * move replaceImgUrlIfNeed ([4a4ecee](https://github.com/RSSNext/follow/commit/4a4ecee7f199ea292a6da7293388ef249e91c766)) @@ -487,8 +516,14 @@ * **renderer:** prevent currently executing async entry action from being executed again ([#1348](https://github.com/RSSNext/follow/issues/1348)) ([be82fe2](https://github.com/RSSNext/follow/commit/be82fe2a8ce37bac7205a1a43abeca396fdadba6)) * replace twMacro with unplugin-ast ([#1462](https://github.com/RSSNext/follow/issues/1462)) ([05da9ca](https://github.com/RSSNext/follow/commit/05da9ca7d3d1f66bd22250599df8b66ea0fd3a43)) * reset feed ([#1419](https://github.com/RSSNext/follow/issues/1419)) ([9066758](https://github.com/RSSNext/follow/commit/9066758c322b8b31c1a9a137be017283fa92bea8)) +* **rn-component:** implement toast manager ([950b5af](https://github.com/RSSNext/follow/commit/950b5af8ec0316875ed3ce5a99baaaecb56c6d37)) +* **rn:** feed form and subscribe ([f9d5d76](https://github.com/RSSNext/follow/commit/f9d5d7698c0a3b498684468abf4ce50b3c223390)) +* **rn:** fix dismiss modal when follow done ([468a3e4](https://github.com/RSSNext/follow/commit/468a3e490a7c0c3424b721011f2dc72503ba479f)) +* **rn:** follow modal form ([9beff37](https://github.com/RSSNext/follow/commit/9beff379f621e7756d55c4b6b7c0d3a9a6b1fecd)) * **rn:** impl markdown component for rn ([93b38a7](https://github.com/RSSNext/follow/commit/93b38a7c70dda2e934a424995bb07e24bdefd2ac)) * **rn:** implements discover page ([#2385](https://github.com/RSSNext/follow/issues/2385)) ([66f97c0](https://github.com/RSSNext/follow/commit/66f97c0057f571717b3b33929baf5a13fc7e677c)) +* **rn:** init follow feed modal ([1e8196a](https://github.com/RSSNext/follow/commit/1e8196aad4037af6c9d5d29bb6361bbb19b5d00c)) +* **rn:** subscription list item transition ([9745f4d](https://github.com/RSSNext/follow/commit/9745f4d9d0248073e15658ef710c5b2083b273ba)) * **rn:** TabView component ([#2358](https://github.com/RSSNext/follow/issues/2358)) ([b0f7e82](https://github.com/RSSNext/follow/commit/b0f7e82ad3ee73d26f7d3a1cfb825848e8c1efc3)) * rsshub add modal content ([613b3b5](https://github.com/RSSNext/follow/commit/613b3b5c1978a656023a6596c7d421be66211aed)) * rsshub add modal loading status ([b044713](https://github.com/RSSNext/follow/commit/b044713528f3f17780ef019c6218535e35255cf4)) @@ -513,6 +548,7 @@ * unified feed title ([b86afe5](https://github.com/RSSNext/follow/commit/b86afe5a9789ad13c5a026adf553d2c6bc333bff)) * uniq macos entry column position ([4b63023](https://github.com/RSSNext/follow/commit/4b63023389e125353de343b679840e5fbca1a4d3)) * unlink account ([6691cb2](https://github.com/RSSNext/follow/commit/6691cb2367e59cbbd3d0176c10fa3f57865ee39d)) +* update mobile app structure and enhance loading functionality ([3cca62c](https://github.com/RSSNext/follow/commit/3cca62c26f83aa99c2cd1793f17f8b929b46b588)) * update og image ([c58df35](https://github.com/RSSNext/follow/commit/c58df35d1f48e7374973a393c9b7efea38d12494)) * update og image ([142f9f5](https://github.com/RSSNext/follow/commit/142f9f57ce2697ab82fd5e1dd2cee7d0f7500721)) * update server hono ([5beaca3](https://github.com/RSSNext/follow/commit/5beaca3c772d7bc097fd53779c581eb6b41c9eaa)) diff --git a/changelog/0.3.1.md b/changelog/0.3.1.md new file mode 100644 index 0000000000..4753141441 --- /dev/null +++ b/changelog/0.3.1.md @@ -0,0 +1,10 @@ +# What's new in v0.3.1 + +## New Features + +- **Customize toolbar**: Customize the toolbar to display the items you most frequently use. + ![Customize toolbar](https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true) + +## Improvements + +## Bug Fixes diff --git a/changelog/next.md b/changelog/next.md index d27f3fdffa..17888d80b6 100644 --- a/changelog/next.md +++ b/changelog/next.md @@ -2,9 +2,6 @@ ## New Features -- **Customize toolbar**: Customize the toolbar to display the items you most frequently use. - ![Customize toolbar](https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true) - ## Improvements ## Bug Fixes diff --git a/package.json b/package.json index 45879630e4..748545d952 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "Follow", "type": "module", - "version": "0.3.0-beta.0", + "version": "0.3.1-beta.0", "private": true, "packageManager": "pnpm@9.12.3", "description": "Follow your favorites in one inbox", @@ -183,5 +183,5 @@ ] }, "productName": "Follow", - "mainHash": "294c4e51fece0fca36f45a28ed8b1077075a30bcb4fab458d1e0b370b8a0fb6a" + "mainHash": "b53d32acaa385ba52e4cc2e78a9710907229d10c52e90689b0abc0ae5d003f1b" } From 037791eae13bf0b9322eba332a852f73cd737bd0 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Thu, 9 Jan 2025 18:53:42 +0800 Subject: [PATCH 48/70] feat(podcast): add PodcastButton component to mobile float bar (#2514) --- .../feed-column/components/PodcastButton.tsx | 122 ++++++++++++++++++ .../feed-column/float-bar.mobile.tsx | 34 +---- .../src/modules/player/corner-player.tsx | 2 +- 3 files changed, 129 insertions(+), 29 deletions(-) create mode 100644 apps/renderer/src/modules/app-layout/feed-column/components/PodcastButton.tsx diff --git a/apps/renderer/src/modules/app-layout/feed-column/components/PodcastButton.tsx b/apps/renderer/src/modules/app-layout/feed-column/components/PodcastButton.tsx new file mode 100644 index 0000000000..59a73bfdbe --- /dev/null +++ b/apps/renderer/src/modules/app-layout/feed-column/components/PodcastButton.tsx @@ -0,0 +1,122 @@ +import { PresentSheet } from "@follow/components/ui/sheet/Sheet.js" +import type { FeedModel } from "@follow/models" +import { cn } from "@follow/utils/utils" +import { useState } from "react" +import Marquee from "react-fast-marquee" + +import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player" +import { RelativeTime } from "~/components/ui/datetime" +import { FeedIcon } from "~/modules/feed/feed-icon" +import { PlayerProgress } from "~/modules/player/corner-player" +import { useEntry } from "~/store/entry" + +const handleClickPlay = () => { + AudioPlayer.togglePlayAndPause() +} + +export const PodcastButton = ({ feed }: { feed: FeedModel }) => { + const entryId = useAudioPlayerAtomSelector((v) => v.entryId) + const status = useAudioPlayerAtomSelector((v) => v.status) + const isMute = useAudioPlayerAtomSelector((v) => v.isMute) + const playerValue = { entryId, status, isMute } + + const entry = useEntry(playerValue.entryId) + + if (!entry || !feed) return null + + return ( + <PresentSheet + zIndex={99} + content={ + <> + <div className="mb-6 flex gap-4"> + <FeedIcon feed={feed} entry={entry.entries} size={58} fallback={false} noMargin /> + <div className="flex flex-col justify-center"> + <Marquee + play={playerValue.status === "playing"} + className="mask-horizontal font-medium" + speed={30} + > + {entry.entries.title} + </Marquee> + <div className="mt-0.5 overflow-hidden truncate text-xs text-muted-foreground"> + <span>{feed.title}</span> + <span> · </span> + <span> + {!!entry.entries.publishedAt && <RelativeTime date={entry.entries.publishedAt} />} + </span> + </div> + </div> + </div> + + <PlayerProgress /> + + <div className="mt-2 flex items-center justify-center gap-2"> + <div className="w-10"> + <PlaybackRateButton /> + </div> + <div className="flex flex-1 justify-center gap-4"> + <ActionIcon className="i-mgc-back-2-cute-re" onClick={() => AudioPlayer.back(10)} /> + + <ActionIcon + className={cn("size-6", { + "i-mgc-pause-cute-fi": playerValue.status === "playing", + "i-mgc-loading-3-cute-re animate-spin": playerValue.status === "loading", + "i-mgc-play-cute-fi": playerValue.status === "paused", + })} + onClick={handleClickPlay} + /> + + <ActionIcon + className="i-mgc-forward-2-cute-re" + onClick={() => AudioPlayer.forward(10)} + /> + </div> + <div className="w-10"> + <ActionIcon + className="i-mgc-close-cute-re" + onClick={() => { + AudioPlayer.close() + }} + /> + </div> + </div> + </> + } + > + <div className="flex size-5 items-center justify-center"> + <FeedIcon feed={feed} size={22} noMargin /> + </div> + </PresentSheet> + ) +} + +const ActionIcon = ({ className, onClick }: { className?: string; onClick?: () => void }) => ( + <button + type="button" + className="center size-10 rounded-full text-muted-foreground" + onClick={onClick} + > + <i className={className} /> + </button> +) + +const PlaybackRateButton = () => { + const playbackRate = useAudioPlayerAtomSelector((v) => v.playbackRate) + const rates = [0.5, 0.75, 1, 1.25, 1.5, 2] + const [currentIndex, setCurrentIndex] = useState(playbackRate ? rates.indexOf(playbackRate) : 2) + + const handleClick = () => { + const nextIndex = (currentIndex + 1) % rates.length + setCurrentIndex(nextIndex) + AudioPlayer.setPlaybackRate(rates[nextIndex]) + } + + return ( + <button onClick={handleClick}> + <span className="block font-mono text-xs text-muted-foreground"> + {rates[currentIndex].toFixed(2)}x + </span> + </button> + ) +} diff --git a/apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx b/apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx index 228f0a17ca..f817e18016 100644 --- a/apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx +++ b/apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx @@ -10,14 +10,13 @@ import { useCallback, useEffect, useRef, useState } from "react" import { useAudioPlayerAtomSelector } from "~/atoms/player" import { useUISettingKey } from "~/atoms/settings/ui" import { useSidebarActiveView } from "~/atoms/sidebar" -import { FeedIcon } from "~/modules/feed/feed-icon" -import { CornerPlayer } from "~/modules/player/corner-player" import { useEntry } from "~/store/entry" import { useFeedById } from "~/store/feed" import { feedIconSelector } from "~/store/feed/selector" import { useUnreadByView } from "~/store/unread/hooks" import { ProfileButton } from "../../user/ProfileButton" +import { PodcastButton } from "./components/PodcastButton" export const MobileFloatBar = ({ scrollContainer, @@ -134,20 +133,13 @@ const ViewTabs = ({ onViewChange }: { onViewChange?: (view: number) => void }) = ) } -const PlayerIcon = ({ - isScrollDown, - onLogoClick, -}: { - isScrollDown: boolean - onLogoClick?: () => void -}) => { - const { isPlaying, entryId } = useAudioPlayerAtomSelector( - useCallback((state) => ({ isPlaying: state.status === "playing", entryId: state.entryId }), []), +const PlayerIcon = ({ onLogoClick }: { isScrollDown: boolean; onLogoClick?: () => void }) => { + const { show, entryId } = useAudioPlayerAtomSelector( + useCallback((state) => ({ entryId: state.entryId, show: state.show }), []), ) const feedId = useEntry(entryId, (s) => s.feedId) const feed = useFeedById(feedId, feedIconSelector) - const [isShowPlayer, setIsShowPlayer] = useState(false) - if (!feed || !isPlaying) { + if (!feed || !show) { return ( <MotionButtonBase onClick={onLogoClick}> <Logo className="size-5 shrink-0" /> @@ -155,19 +147,5 @@ const PlayerIcon = ({ ) } - return ( - <> - <button type="button" className="size-5 shrink-0" onClick={() => setIsShowPlayer((v) => !v)}> - <FeedIcon feed={feed} noMargin /> - </button> - - {isShowPlayer && !isScrollDown && ( - <CornerPlayer - className="absolute inset-x-0 mx-auto w-full max-w-[350px] bottom-safe-or-12" - hideControls - rounded - /> - )} - </> - ) + return <PodcastButton feed={feed} /> } diff --git a/apps/renderer/src/modules/player/corner-player.tsx b/apps/renderer/src/modules/player/corner-player.tsx index c6de8eb50b..dcc27fa49d 100644 --- a/apps/renderer/src/modules/player/corner-player.tsx +++ b/apps/renderer/src/modules/player/corner-player.tsx @@ -300,7 +300,7 @@ const CornerPlayerImpl = ({ hideControls, rounded }: ControlButtonProps) => { } const ONE_HOUR_IN_SECONDS = 60 * 60 -const PlayerProgress = () => { +export const PlayerProgress = () => { const isMobile = useMobile() const playerValue = useAudioPlayerAtomValue() From 294b508c36f2629ef65886af55031be8dd623f54 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Thu, 9 Jan 2025 18:57:42 +0800 Subject: [PATCH 49/70] chore: update changelog Signed-off-by: Innei <tukon479@gmail.com> --- changelog/0.3.1.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog/0.3.1.md b/changelog/0.3.1.md index 4753141441..0b674f6d1a 100644 --- a/changelog/0.3.1.md +++ b/changelog/0.3.1.md @@ -7,4 +7,7 @@ ## Improvements +- **Podcast Player**: Re-designed the podcast player in mobile to be more user-friendly. + ![Podcast Player](https://github.com/RSSNext/assets/blob/8f778dac8bb2e765acab2157497e4a77a60c5a0b/mobile-audio-player.png?raw=true) + ## Bug Fixes From 5ae616d69150496cb65f1ef65a9afb42665f740c Mon Sep 17 00:00:00 2001 From: Eric Zhu <eric@zhu.email> Date: Thu, 9 Jan 2025 18:58:48 +0800 Subject: [PATCH 50/70] chore: update zh-cn translations (#2510) --- locales/errors/zh-CN.json | 9 ++++++++- locales/settings/zh-CN.json | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/locales/errors/zh-CN.json b/locales/errors/zh-CN.json index ab0cca93dd..0c36039aeb 100644 --- a/locales/errors/zh-CN.json +++ b/locales/errors/zh-CN.json @@ -51,5 +51,12 @@ "10001": "收件箱已存在", "10002": "收件箱限制已超出", "10003": "收件箱无权限", - "12000": "自动化规则限制已超出" + "12000": "自动化规则限制已超出", + "13000": "未找到 RSSHub 路由", + "13001": "你不是此 RSSHub 实例的所有者", + "13002": "RSSHub 正在使用中", + "13003": "未找到 RSSHub", + "13004": "RSSHub 用户限制已超出", + "13005": "未找到 RSSHub 购买记录", + "13006": "RSSHub 配置无效" } diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index d05bc109c9..baa7dfc73f 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -97,6 +97,7 @@ "appearance.zen_mode.description": "禅定模式是一种不受干扰的阅读模式,让你能够专注于内容而不受任何干扰。启用禅定模式将会隐藏侧边栏。", "appearance.zen_mode.label": "禅定模式", "common.give_star": "<HeartIcon />喜欢我们的产品吗? <Link>在 GitHub 上给我们「标星」吧!</Link>", + "customizeToolbar.title": "自定义工具栏", "data_control.app_cache_limit.description": "应用缓存大小的上限。一旦缓存达到此上限,最早的项目将被删除以释放空间。", "data_control.app_cache_limit.label": "应用缓存限制", "data_control.clean_cache.button": "清理缓存", From 8abde402b2bab6bcb6fe1fcd349055b418d1ae5f Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Thu, 9 Jan 2025 19:39:24 +0800 Subject: [PATCH 51/70] feat(rn): subscription list refresh contril Signed-off-by: Innei <tukon479@gmail.com> --- apps/mobile/src/constants/ui.ts | 1 - .../modules/subscription/CategoryGrouped.tsx | 69 ++++ .../subscription/SubscriptionLists.tsx | 353 +++--------------- .../modules/subscription/UnGroupedList.tsx | 25 ++ .../src/modules/subscription/ViewTab.tsx | 6 +- .../src/modules/subscription/constants.ts | 1 + apps/mobile/src/modules/subscription/ctx.ts | 1 + .../modules/subscription/items/InboxItem.tsx | 35 ++ .../items/ListSubscriptionItem.tsx | 33 ++ .../subscription/items/SubscriptionItem.tsx | 100 +++++ .../screens/(stack)/(tabs)/subscription.tsx | 1 + 11 files changed, 325 insertions(+), 300 deletions(-) delete mode 100644 apps/mobile/src/constants/ui.ts create mode 100644 apps/mobile/src/modules/subscription/CategoryGrouped.tsx create mode 100644 apps/mobile/src/modules/subscription/UnGroupedList.tsx create mode 100644 apps/mobile/src/modules/subscription/constants.ts create mode 100644 apps/mobile/src/modules/subscription/items/InboxItem.tsx create mode 100644 apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx create mode 100644 apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx diff --git a/apps/mobile/src/constants/ui.ts b/apps/mobile/src/constants/ui.ts deleted file mode 100644 index caa3ddddd9..0000000000 --- a/apps/mobile/src/constants/ui.ts +++ /dev/null @@ -1 +0,0 @@ -export const bottomViewTabHeight = 35 diff --git a/apps/mobile/src/modules/subscription/CategoryGrouped.tsx b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx new file mode 100644 index 0000000000..c3b715e1e9 --- /dev/null +++ b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx @@ -0,0 +1,69 @@ +import { memo, useState } from "react" +import { Text, TouchableOpacity, View } from "react-native" +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated" + +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { MingcuteRightLine } from "@/src/icons/mingcute_right_line" +import { useUnreadCounts } from "@/src/store/unread/hooks" + +import { SubscriptionFeedCategoryContextMenu } from "../context-menu/feeds" +import { GroupedContext } from "./ctx" +import { UnGroupedList } from "./UnGroupedList" + +// const CategoryList: FC<{ +// grouped: Record<string, string[]> +// }> = ({ grouped }) => { +// const sortedGrouped = useSortedGroupedSubscription(grouped, "alphabet") +// return sortedGrouped.map(({ category, subscriptionIds }) => { +// return <CategoryGrouped key={category} category={category} subscriptionIds={subscriptionIds} /> +// }) +// } +export const CategoryGrouped = memo( + ({ category, subscriptionIds }: { category: string; subscriptionIds: string[] }) => { + const unreadCounts = useUnreadCounts(subscriptionIds) + const [expanded, setExpanded] = useState(false) + const rotateSharedValue = useSharedValue(0) + const rotateStyle = useAnimatedStyle(() => { + return { + transform: [{ rotate: `${rotateSharedValue.value}deg` }], + } + }, [rotateSharedValue]) + return ( + <> + <SubscriptionFeedCategoryContextMenu category={category} feedIds={subscriptionIds}> + <ItemPressable + onPress={() => { + // TODO navigate to category + }} + className="border-item-pressed h-12 flex-row items-center border-b px-3" + > + <TouchableOpacity + hitSlop={10} + onPress={() => { + rotateSharedValue.value = withSpring(expanded ? 0 : 90, {}) + setExpanded(!expanded) + }} + className="size-5 flex-row items-center justify-center" + > + <Animated.View style={rotateStyle}> + <MingcuteRightLine color="gray" height={18} width={18} /> + </Animated.View> + </TouchableOpacity> + <Text className="text-text ml-3">{category}</Text> + {!!unreadCounts && ( + <Text className="text-tertiary-label ml-auto text-xs">{unreadCounts}</Text> + )} + </ItemPressable> + </SubscriptionFeedCategoryContextMenu> + + {expanded && ( + <GroupedContext.Provider value={category}> + <View> + <UnGroupedList subscriptionIds={subscriptionIds} /> + </View> + </GroupedContext.Provider> + )} + </> + ) + }, +) diff --git a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx index 5f1f647f26..a353c8620d 100644 --- a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx +++ b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx @@ -1,32 +1,16 @@ import { FeedViewType } from "@follow/constants" -import { cn } from "@follow/utils" import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" import { useHeaderHeight } from "@react-navigation/elements" -import { router } from "expo-router" import { useAtom } from "jotai" -import { useColorScheme } from "nativewind" -import type { FC } from "react" -import { createContext, memo, useContext, useEffect, useMemo, useRef, useState } from "react" -import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native" +import { memo, useEffect, useMemo, useRef, useState } from "react" +import { RefreshControl, StyleSheet, Text, View } from "react-native" import PagerView from "react-native-pager-view" -import ReAnimated, { - FadeOutUp, - LinearTransition, - useAnimatedStyle, - useSharedValue, - withSpring, -} from "react-native-reanimated" +import ReAnimated, { LinearTransition } from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" +import { useEventCallback } from "usehooks-ts" -import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" -import { FeedIcon } from "@/src/components/ui/icon/feed-icon" import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" -import { bottomViewTabHeight } from "@/src/constants/ui" -import { InboxCuteFiIcon } from "@/src/icons/inbox_cute_fi" -import { MingcuteRightLine } from "@/src/icons/mingcute_right_line" import { StarCuteFiIcon } from "@/src/icons/star_cute_fi" -import { useFeed } from "@/src/store/feed/hooks" -import { useList } from "@/src/store/list/hooks" import { useGroupedSubscription, useInboxSubscription, @@ -35,18 +19,16 @@ import { useSortedGroupedSubscription, useSortedListSubscription, useSortedUngroupedSubscription, - useSubscription, } from "@/src/store/subscription/hooks" -import { getInboxStoreId } from "@/src/store/subscription/utils" -import { useUnreadCount, useUnreadCounts } from "@/src/store/unread/hooks" +import { subscriptionSyncService } from "@/src/store/subscription/store" -import { - SubscriptionFeedCategoryContextMenu, - SubscriptionFeedItemContextMenu, -} from "../context-menu/feeds" -import { SubscriptionListItemContextMenu } from "../context-menu/lists" import { useFeedListSortMethod, useFeedListSortOrder, viewAtom } from "./atoms" +import { CategoryGrouped } from "./CategoryGrouped" +import { ViewTabHeight } from "./constants" import { useViewPageCurrentView, ViewPageCurrentViewProvider } from "./ctx" +import { InboxItem } from "./items/InboxItem" +import { ListSubscriptionItem } from "./items/ListSubscriptionItem" +import { SubscriptionItem } from "./items/SubscriptionItem" export const SubscriptionLists = memo(() => { const [currentView, setCurrentView] = useAtom(viewAtom) @@ -58,36 +40,32 @@ export const SubscriptionLists = memo(() => { }, [currentView]) return ( - <> - <StarItem /> - - <PagerView - pageMargin={1} - onPageSelected={({ nativeEvent }) => { - setCurrentView(nativeEvent.position) - }} - scrollEnabled - style={style.flex} - initialPage={0} - ref={pagerRef} - offscreenPageLimit={3} - > - {[ - FeedViewType.Articles, - FeedViewType.SocialMedia, - FeedViewType.Pictures, - FeedViewType.Videos, - FeedViewType.Audios, - FeedViewType.Notifications, - ].map((view) => { - return ( - <ViewPageCurrentViewProvider key={view} value={view}> - <SubscriptionList view={view} /> - </ViewPageCurrentViewProvider> - ) - })} - </PagerView> - </> + <PagerView + pageMargin={1} + onPageSelected={({ nativeEvent }) => { + setCurrentView(nativeEvent.position) + }} + scrollEnabled + style={style.flex} + initialPage={0} + ref={pagerRef} + offscreenPageLimit={3} + > + {[ + FeedViewType.Articles, + FeedViewType.SocialMedia, + FeedViewType.Pictures, + FeedViewType.Videos, + FeedViewType.Audios, + FeedViewType.Notifications, + ].map((view) => { + return ( + <ViewPageCurrentViewProvider key={view} value={view}> + <SubscriptionList view={view} /> + </ViewPageCurrentViewProvider> + ) + })} + </PagerView> ) }) const keyExtractor = (item: string | { category: string; subscriptionIds: string[] }) => { @@ -113,15 +91,34 @@ const SubscriptionList = ({ view }: { view: FeedViewType }) => { [sortedGrouped, sortedUnGrouped], ) + const [refreshing, setRefreshing] = useState(false) + const onRefresh = useEventCallback(() => { + return subscriptionSyncService.fetch(view) + }) + + const offsetTop = headerHeight - insets.top + ViewTabHeight * 2 + 23 + return ( <ReAnimated.FlatList + refreshControl={ + <RefreshControl + progressViewOffset={offsetTop} + onRefresh={() => { + setRefreshing(true) + onRefresh().finally(() => { + setRefreshing(false) + }) + }} + refreshing={refreshing} + /> + } contentInsetAdjustmentBehavior="automatic" scrollIndicatorInsets={{ bottom: tabHeight - insets.bottom, - top: headerHeight - insets.top + bottomViewTabHeight, + top: offsetTop, }} contentContainerStyle={{ - paddingTop: headerHeight - insets.top + bottomViewTabHeight, + paddingTop: offsetTop, paddingBottom: tabHeight, }} data={data} @@ -173,23 +170,6 @@ const ListHeaderComponent = () => { ) } -// This not used FlashList -// const ViewPage = memo(({ view }: { view: FeedViewType }) => { -// const { grouped, unGrouped } = useGroupedSubscription(view) -// usePrefetchSubscription(view) - -// return ( -// <ViewPageCurrentViewProvider value={view}> -// <StarItem /> -// {view === FeedViewType.Articles && <InboxList />} -// <ListList /> -// <Text className="text-tertiary-label mb-2 ml-3 mt-4 text-sm font-medium">Feeds</Text> -// <CategoryList grouped={grouped} /> -// <UnGroupedList subscriptionIds={unGrouped} /> -// </ViewPageCurrentViewProvider> -// ) -// }) - const InboxList = () => { const inboxes = useInboxSubscription(FeedViewType.Articles) if (inboxes.length === 0) return null @@ -203,27 +183,6 @@ const InboxList = () => { ) } -const InboxItem = memo(({ id }: { id: string }) => { - const subscription = useSubscription(getInboxStoreId(id)) - const unreadCount = useUnreadCount(id) - const { colorScheme } = useColorScheme() - if (!subscription) return null - return ( - <ItemPressable className="border-item-pressed h-12 flex-row items-center border-b px-3"> - <View className="ml-0.5 overflow-hidden rounded"> - <InboxCuteFiIcon - height={20} - width={20} - color={colorScheme === "dark" ? "white" : "black"} - /> - </View> - - <Text className="text-text ml-2.5">{subscription.title}</Text> - {!!unreadCount && <Text className="text-tertiary-label ml-auto text-xs">{unreadCount}</Text>} - </ItemPressable> - ) -}) - const StarItem = () => { return ( <ItemPressable @@ -253,206 +212,8 @@ const ListList = () => { ) } -const ListSubscriptionItem = memo(({ id }: { id: string; className?: string }) => { - const list = useList(id) - const unreadCount = useUnreadCount(id) - if (!list) return null - return ( - <SubscriptionListItemContextMenu id={id}> - <ItemPressable className="border-item-pressed h-12 flex-row items-center border-b px-3"> - <View className="overflow-hidden rounded"> - {!!list.image && ( - <Image source={{ uri: list.image, width: 24, height: 24 }} resizeMode="cover" /> - )} - {!list.image && <FallbackIcon title={list.title} size={24} />} - </View> - - <Text className="text-text ml-2">{list.title}</Text> - {!!unreadCount && <View className="bg-tertiary-label ml-auto size-1 rounded-full" />} - </ItemPressable> - </SubscriptionListItemContextMenu> - ) -}) - -const UnGroupedList: FC<{ - subscriptionIds: string[] -}> = ({ subscriptionIds }) => { - const sortBy = useFeedListSortMethod() - const sortOrder = useFeedListSortOrder() - const sortedSubscriptionIds = useSortedUngroupedSubscription(subscriptionIds, sortBy, sortOrder) - const lastSubscriptionId = sortedSubscriptionIds.at(-1) - - return sortedSubscriptionIds.map((id) => { - return ( - <SubscriptionItem - key={id} - id={id} - className={id === lastSubscriptionId ? "border-b-transparent" : ""} - /> - ) - }) -} - -const GroupedContext = createContext<string | null>(null) - -// const CategoryList: FC<{ -// grouped: Record<string, string[]> -// }> = ({ grouped }) => { -// const sortedGrouped = useSortedGroupedSubscription(grouped, "alphabet") - -// return sortedGrouped.map(({ category, subscriptionIds }) => { -// return <CategoryGrouped key={category} category={category} subscriptionIds={subscriptionIds} /> -// }) -// } - -const CategoryGrouped = memo( - ({ category, subscriptionIds }: { category: string; subscriptionIds: string[] }) => { - const unreadCounts = useUnreadCounts(subscriptionIds) - const [expanded, setExpanded] = useState(false) - const rotateSharedValue = useSharedValue(0) - const rotateStyle = useAnimatedStyle(() => { - return { - transform: [{ rotate: `${rotateSharedValue.value}deg` }], - } - }, [rotateSharedValue]) - return ( - <> - <SubscriptionFeedCategoryContextMenu category={category} feedIds={subscriptionIds}> - <ItemPressable - onPress={() => { - // TODO navigate to category - }} - className="border-item-pressed h-12 flex-row items-center border-b px-3" - > - <TouchableOpacity - hitSlop={10} - onPress={() => { - rotateSharedValue.value = withSpring(expanded ? 90 : 0, {}) - setExpanded(!expanded) - }} - style={style.accordionIcon} - > - <ReAnimated.View style={rotateStyle}> - <MingcuteRightLine color="gray" height={18} width={18} /> - </ReAnimated.View> - </TouchableOpacity> - <Text className="text-text ml-3">{category}</Text> - {!!unreadCounts && ( - <Text className="text-tertiary-label ml-auto text-xs">{unreadCounts}</Text> - )} - </ItemPressable> - </SubscriptionFeedCategoryContextMenu> - {/* <AccordionItem isExpanded={isExpanded} viewKey={category}> */} - {expanded && ( - <GroupedContext.Provider value={category}> - <View> - <UnGroupedList subscriptionIds={subscriptionIds} /> - </View> - </GroupedContext.Provider> - )} - {/* </AccordionItem> */} - </> - ) - }, -) - -// const renderRightActions = () => { -// return ( -// <ReAnimated.View entering={FadeIn} className="flex-row items-center"> -// <TouchableOpacity -// className="bg-red h-full justify-center px-4" -// onPress={() => { -// // TODO: Handle unsubscribe -// }} -// > -// <Text className="text-base font-semibold text-white">Unsubscribe</Text> -// </TouchableOpacity> -// </ReAnimated.View> -// ) -// } - -// const renderLeftActions = () => { -// return ( -// <ReAnimated.View entering={FadeIn} className="flex-row items-center"> -// <TouchableOpacity -// className="bg-blue h-full justify-center px-4" -// onPress={() => { -// // TODO: Handle unsubscribe -// }} -// > -// <Text className="text-base font-semibold text-white">Read</Text> -// </TouchableOpacity> -// </ReAnimated.View> -// ) -// } - -// let prevOpenedRow: SwipeableMethods | null = null -const SubscriptionItem = memo(({ id, className }: { id: string; className?: string }) => { - const subscription = useSubscription(id) - const unreadCount = useUnreadCount(id) - const feed = useFeed(id) - const inGrouped = !!useContext(GroupedContext) - const view = useViewPageCurrentView() - // const swipeableRef: SwipeableRef = useRef(null) - - if (!subscription || !feed) return null - - return ( - // FIXME: Here leads to very serious performance issues, the frame rate of both the UI and JS threads has dropped - // <Swipeable - // renderRightActions={renderRightActions} - // renderLeftActions={renderLeftActions} - // leftThreshold={40} - // rightThreshold={40} - // ref={swipeableRef} - // onSwipeableWillOpen={() => { - // if (prevOpenedRow && prevOpenedRow !== swipeableRef.current) { - // prevOpenedRow.close() - // } - // prevOpenedRow = swipeableRef.current - // }} - // > - // <ReAnimated.View key={id} layout={CurvedTransition} exiting={FadeOut}> - <ReAnimated.View exiting={FadeOutUp}> - <SubscriptionFeedItemContextMenu id={id} view={view}> - <ItemPressable - className={cn( - "flex h-12 flex-row items-center", - inGrouped ? "pl-8 pr-4" : "px-4", - "border-item-pressed border-b", - className, - )} - onPress={() => { - router.push({ - pathname: `/feeds/[feedId]`, - params: { - feedId: id, - }, - }) - }} - > - <View className="dark:border-tertiary-system-background mr-3 size-5 items-center justify-center overflow-hidden rounded-full border border-transparent dark:bg-[#222]"> - <FeedIcon feed={feed} /> - </View> - <Text className="text-text">{subscription.title || feed.title}</Text> - {!!unreadCount && ( - <Text className="text-tertiary-label ml-auto text-xs">{unreadCount}</Text> - )} - </ItemPressable> - </SubscriptionFeedItemContextMenu> - </ReAnimated.View> - // </Swipeable> - ) -}) - const style = StyleSheet.create({ flex: { flex: 1, }, - accordionIcon: { - height: 20, - width: 20, - alignItems: "center", - justifyContent: "center", - }, }) diff --git a/apps/mobile/src/modules/subscription/UnGroupedList.tsx b/apps/mobile/src/modules/subscription/UnGroupedList.tsx new file mode 100644 index 0000000000..129a7f2949 --- /dev/null +++ b/apps/mobile/src/modules/subscription/UnGroupedList.tsx @@ -0,0 +1,25 @@ +import type { FC } from "react" + +import { useSortedUngroupedSubscription } from "@/src/store/subscription/hooks" + +import { useFeedListSortMethod, useFeedListSortOrder } from "./atoms" +import { SubscriptionItem } from "./items/SubscriptionItem" + +export const UnGroupedList: FC<{ + subscriptionIds: string[] +}> = ({ subscriptionIds }) => { + const sortBy = useFeedListSortMethod() + const sortOrder = useFeedListSortOrder() + const sortedSubscriptionIds = useSortedUngroupedSubscription(subscriptionIds, sortBy, sortOrder) + const lastSubscriptionId = sortedSubscriptionIds.at(-1) + + return sortedSubscriptionIds.map((id) => { + return ( + <SubscriptionItem + key={id} + id={id} + className={id === lastSubscriptionId ? "border-b-transparent" : ""} + /> + ) + }) +} diff --git a/apps/mobile/src/modules/subscription/ViewTab.tsx b/apps/mobile/src/modules/subscription/ViewTab.tsx index 173d1d9426..dab6189f6b 100644 --- a/apps/mobile/src/modules/subscription/ViewTab.tsx +++ b/apps/mobile/src/modules/subscription/ViewTab.tsx @@ -8,13 +8,13 @@ import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-na import { ThemedBlurView } from "@/src/components/common/ThemedBlurView" import { ContextMenu } from "@/src/components/ui/context-menu" -import { bottomViewTabHeight } from "@/src/constants/ui" import type { ViewDefinition } from "@/src/constants/views" import { views } from "@/src/constants/views" import { useUnreadCountByView } from "@/src/store/unread/hooks" import { unreadSyncService } from "@/src/store/unread/store" import { offsetAtom, setCurrentView, viewAtom } from "./atoms" +import { ViewTabHeight } from "./constants" const springConfig: WithSpringConfig = { damping: 20, @@ -65,10 +65,10 @@ export const ViewTab = () => { return ( <ThemedBlurView - style={[styles.tabContainer, { height: headerHeight + bottomViewTabHeight }]} + style={[styles.tabContainer, { height: headerHeight + ViewTabHeight }]} className="border-system-fill/60 relative border-b" > - <View className="absolute inset-x-0 bottom-0" style={{ height: bottomViewTabHeight }}> + <View className="absolute inset-x-0 bottom-0" style={{ height: ViewTabHeight }}> <ScrollView onScroll={(event) => { scrollOffsetX.current = event.nativeEvent.contentOffset.x diff --git a/apps/mobile/src/modules/subscription/constants.ts b/apps/mobile/src/modules/subscription/constants.ts new file mode 100644 index 0000000000..73a0d731ce --- /dev/null +++ b/apps/mobile/src/modules/subscription/constants.ts @@ -0,0 +1 @@ +export const ViewTabHeight = 35 diff --git a/apps/mobile/src/modules/subscription/ctx.ts b/apps/mobile/src/modules/subscription/ctx.ts index 70d178c998..7e7cb1a5b7 100644 --- a/apps/mobile/src/modules/subscription/ctx.ts +++ b/apps/mobile/src/modules/subscription/ctx.ts @@ -4,3 +4,4 @@ import { createContext, useContext } from "react" const ViewPageCurrentViewContext = createContext<FeedViewType>(null!) export const ViewPageCurrentViewProvider = ViewPageCurrentViewContext.Provider export const useViewPageCurrentView = () => useContext(ViewPageCurrentViewContext) +export const GroupedContext = createContext<string | null>(null) diff --git a/apps/mobile/src/modules/subscription/items/InboxItem.tsx b/apps/mobile/src/modules/subscription/items/InboxItem.tsx new file mode 100644 index 0000000000..625ad1b1eb --- /dev/null +++ b/apps/mobile/src/modules/subscription/items/InboxItem.tsx @@ -0,0 +1,35 @@ +import { useColorScheme } from "nativewind" +import { memo } from "react" +import { Text, View } from "react-native" +import Animated, { FadeOutUp } from "react-native-reanimated" + +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { InboxCuteFiIcon } from "@/src/icons/inbox_cute_fi" +import { useSubscription } from "@/src/store/subscription/hooks" +import { getInboxStoreId } from "@/src/store/subscription/utils" +import { useUnreadCount } from "@/src/store/unread/hooks" + +export const InboxItem = memo(({ id }: { id: string }) => { + const subscription = useSubscription(getInboxStoreId(id)) + const unreadCount = useUnreadCount(id) + const { colorScheme } = useColorScheme() + if (!subscription) return null + return ( + <Animated.View exiting={FadeOutUp}> + <ItemPressable className="border-item-pressed h-12 flex-row items-center border-b px-3"> + <View className="ml-0.5 overflow-hidden rounded"> + <InboxCuteFiIcon + height={20} + width={20} + color={colorScheme === "dark" ? "white" : "black"} + /> + </View> + + <Text className="text-text ml-2.5">{subscription.title}</Text> + {!!unreadCount && ( + <Text className="text-tertiary-label ml-auto text-xs">{unreadCount}</Text> + )} + </ItemPressable> + </Animated.View> + ) +}) diff --git a/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx new file mode 100644 index 0000000000..5a77b06f0d --- /dev/null +++ b/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx @@ -0,0 +1,33 @@ +import { memo } from "react" +import { Image, Text, View } from "react-native" +import Animated, { FadeOutUp } from "react-native-reanimated" + +import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { useList } from "@/src/store/list/hooks" +import { useUnreadCount } from "@/src/store/unread/hooks" + +import { SubscriptionListItemContextMenu } from "../../context-menu/lists" + +export const ListSubscriptionItem = memo(({ id }: { id: string; className?: string }) => { + const list = useList(id) + const unreadCount = useUnreadCount(id) + if (!list) return null + return ( + <Animated.View exiting={FadeOutUp}> + <SubscriptionListItemContextMenu id={id}> + <ItemPressable className="border-item-pressed h-12 flex-row items-center border-b px-3"> + <View className="overflow-hidden rounded"> + {!!list.image && ( + <Image source={{ uri: list.image, width: 24, height: 24 }} resizeMode="cover" /> + )} + {!list.image && <FallbackIcon title={list.title} size={24} />} + </View> + + <Text className="text-text ml-2">{list.title}</Text> + {!!unreadCount && <View className="bg-tertiary-label ml-auto size-1 rounded-full" />} + </ItemPressable> + </SubscriptionListItemContextMenu> + </Animated.View> + ) +}) diff --git a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx new file mode 100644 index 0000000000..fba7c030dc --- /dev/null +++ b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx @@ -0,0 +1,100 @@ +import { cn } from "@follow/utils" +import { router } from "expo-router" +import { memo, useContext } from "react" +import { Text, View } from "react-native" +import Animated, { FadeOutUp } from "react-native-reanimated" + +import { FeedIcon } from "@/src/components/ui/icon/feed-icon" +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { useFeed } from "@/src/store/feed/hooks" +import { useSubscription } from "@/src/store/subscription/hooks" +import { useUnreadCount } from "@/src/store/unread/hooks" + +import { SubscriptionFeedItemContextMenu } from "../../context-menu/feeds" +import { GroupedContext, useViewPageCurrentView } from "../ctx" + +// const renderRightActions = () => { +// return ( +// <ReAnimated.View entering={FadeIn} className="flex-row items-center"> +// <TouchableOpacity +// className="bg-red h-full justify-center px-4" +// onPress={() => { +// // TODO: Handle unsubscribe +// }} +// > +// <Text className="text-base font-semibold text-white">Unsubscribe</Text> +// </TouchableOpacity> +// </ReAnimated.View> +// ) +// } +// const renderLeftActions = () => { +// return ( +// <ReAnimated.View entering={FadeIn} className="flex-row items-center"> +// <TouchableOpacity +// className="bg-blue h-full justify-center px-4" +// onPress={() => { +// // TODO: Handle unsubscribe +// }} +// > +// <Text className="text-base font-semibold text-white">Read</Text> +// </TouchableOpacity> +// </ReAnimated.View> +// ) +// } +// let prevOpenedRow: SwipeableMethods | null = null +export const SubscriptionItem = memo(({ id, className }: { id: string; className?: string }) => { + const subscription = useSubscription(id) + const unreadCount = useUnreadCount(id) + const feed = useFeed(id) + const inGrouped = !!useContext(GroupedContext) + const view = useViewPageCurrentView() + // const swipeableRef: SwipeableRef = useRef(null) + if (!subscription || !feed) return null + + return ( + // FIXME: Here leads to very serious performance issues, the frame rate of both the UI and JS threads has dropped + // <Swipeable + // renderRightActions={renderRightActions} + // renderLeftActions={renderLeftActions} + // leftThreshold={40} + // rightThreshold={40} + // ref={swipeableRef} + // onSwipeableWillOpen={() => { + // if (prevOpenedRow && prevOpenedRow !== swipeableRef.current) { + // prevOpenedRow.close() + // } + // prevOpenedRow = swipeableRef.current + // }} + // > + // <ReAnimated.View key={id} layout={CurvedTransition} exiting={FadeOut}> + <Animated.View exiting={FadeOutUp}> + <SubscriptionFeedItemContextMenu id={id} view={view}> + <ItemPressable + className={cn( + "flex h-12 flex-row items-center", + inGrouped ? "pl-8 pr-4" : "px-4", + "border-item-pressed border-b", + className, + )} + onPress={() => { + router.push({ + pathname: `/feeds/[feedId]`, + params: { + feedId: id, + }, + }) + }} + > + <View className="dark:border-tertiary-system-background mr-3 size-5 items-center justify-center overflow-hidden rounded-full border border-transparent dark:bg-[#222]"> + <FeedIcon feed={feed} /> + </View> + <Text className="text-text">{subscription.title || feed.title}</Text> + {!!unreadCount && ( + <Text className="text-tertiary-label ml-auto text-xs">{unreadCount}</Text> + )} + </ItemPressable> + </SubscriptionFeedItemContextMenu> + </Animated.View> + // </Swipeable> + ) +}) diff --git a/apps/mobile/src/screens/(stack)/(tabs)/subscription.tsx b/apps/mobile/src/screens/(stack)/(tabs)/subscription.tsx index a2d51462e9..7ce324b78d 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/subscription.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/subscription.tsx @@ -27,6 +27,7 @@ export default function FeedList() { headerTransparent: true, }} /> + <SubscriptionLists /> <ViewTab /> </> From 79f03b2d5cd62363902d7337761e748ae12b0510 Mon Sep 17 00:00:00 2001 From: Whitewater <me@waterwater.moe> Date: Thu, 9 Jan 2025 20:03:31 +0800 Subject: [PATCH 52/70] refactor: update header action (#2511) * refactor: use useSortedEntryActions * fix: action bar z index * chore: update i18n * chore: tweak action bar styles --- .../src/hooks/biz/useEntryActions.tsx | 50 +++++++++++++++++++ .../entry-column/Items/social-media-item.tsx | 30 +++-------- .../entry-column/layouts/EntryItemWrapper.tsx | 2 +- .../src/modules/entry-column/list.tsx | 2 +- .../entry-content/actions/header-actions.tsx | 41 ++++++--------- .../entry-content/actions/more-actions.tsx | 23 ++------- .../modules/entry-content/header.mobile.tsx | 24 ++++----- 7 files changed, 88 insertions(+), 84 deletions(-) diff --git a/apps/renderer/src/hooks/biz/useEntryActions.tsx b/apps/renderer/src/hooks/biz/useEntryActions.tsx index 2c0233ad2b..a2fea4aa1c 100644 --- a/apps/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/renderer/src/hooks/biz/useEntryActions.tsx @@ -17,6 +17,7 @@ import { tipcClient } from "~/lib/client" import { COMMAND_ID } from "~/modules/command/commands/id" import { useRunCommandFn } from "~/modules/command/hooks/use-command" import type { FollowCommandId } from "~/modules/command/types" +import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks" import { useEntry } from "~/store/entry" import { useFeedById } from "~/store/feed" import { useInboxById } from "~/store/inbox" @@ -227,3 +228,52 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee return actionConfigs } + +export const useSortedEntryActions = ({ + entryId, + view, +}: { + entryId: string + view?: FeedViewType +}) => { + const entryActions = useEntryActions({ entryId, view }) + const orderMap = useToolbarOrderMap() + const mainAction = useMemo( + () => + entryActions + .filter((item) => { + const order = orderMap.get(item.id) + if (!order) return false + return order.type === "main" + }) + .sort((a, b) => { + const orderA = orderMap.get(a.id)?.order || 0 + const orderB = orderMap.get(b.id)?.order || 0 + return orderA - orderB + }), + [entryActions, orderMap], + ) + + const moreAction = useMemo( + () => + entryActions + .filter((item) => { + const order = orderMap.get(item.id) + // If the order is not set, it should be in the "more" menu + if (!order) return true + return order.type !== "main" + }) + // .filter((item) => item.id !== COMMAND_ID.settings.customizeToolbar) + .sort((a, b) => { + const orderA = orderMap.get(a.id)?.order || Infinity + const orderB = orderMap.get(b.id)?.order || Infinity + return orderA - orderB + }), + [entryActions, orderMap], + ) + + return { + mainAction, + moreAction, + } +} diff --git a/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx b/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx index 12fc955502..45559a1a67 100644 --- a/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx +++ b/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx @@ -15,7 +15,7 @@ import { RelativeTime } from "~/components/ui/datetime" import { Media } from "~/components/ui/media" import { usePreviewMedia } from "~/components/ui/media/hooks" import { useAsRead } from "~/hooks/biz/useAsRead" -import { useEntryActions } from "~/hooks/biz/useEntryActions" +import { useSortedEntryActions } from "~/hooks/biz/useEntryActions" import { getImageProxyUrl } from "~/lib/img-proxy" import { jotaiStore } from "~/lib/jotai" import { parseSocialMedia } from "~/lib/parsers" @@ -55,7 +55,6 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl }, []) const autoExpandLongSocialMedia = useGeneralSettingKey("autoExpandLongSocialMedia") - const [actionRef, setActionRef] = useState<HTMLDivElement | null>(null) const titleRef = useRef<HTMLDivElement>(null) if (!entry || !feed) return null @@ -83,25 +82,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl <div className="-mt-0.5 flex-1 text-sm"> <div className="flex select-none flex-wrap space-x-1 leading-6" ref={titleRef}> <span className="inline-flex min-w-0 items-center gap-1 text-base font-semibold"> - <FeedTitle - style={ - showAction && titleRef.current && actionRef && ref.current - ? { - paddingRight: - [...titleRef.current.childNodes.values()].reduce( - (acc, node) => acc + (node as HTMLElement).offsetWidth, - 0, - ) + - actionRef.offsetWidth > - ref.current.offsetWidth - ? actionRef.offsetWidth - : 0, - } - : undefined - } - feed={feed} - title={entry.entries.author || feed.title} - /> + <FeedTitle feed={feed} title={entry.entries.author || feed.title} /> {parsed?.type === "x" && ( <i className="i-mgc-twitter-cute-fi size-3 text-[#4A99E9]" /> )} @@ -137,7 +118,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl </div> {showAction && !isMobile && ( - <div className={"absolute right-1 top-1.5"} ref={setActionRef}> + <div className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-neutral-900 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900"> <ActionBar entryId={entryId} /> </div> )} @@ -148,9 +129,10 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl SocialMediaItem.wrapperClassName = tw`w-[645px] max-w-full m-auto` const ActionBar = ({ entryId }: { entryId: string }) => { - const entryActions = useEntryActions({ entryId }) + const { mainAction: entryActions } = useSortedEntryActions({ entryId }) + return ( - <div className="flex origin-right scale-90 items-center gap-1"> + <div className="flex items-center gap-1"> {entryActions .filter( (item) => diff --git a/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx b/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx index e38c42d79a..66041dc2f9 100644 --- a/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx +++ b/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx @@ -114,7 +114,7 @@ export const EntryItemWrapper: FC< return { type: "text" as const, - label: cmd?.label.title || "aa", + label: cmd?.label.title || "", click: () => item.onClick(), shortcut: item.shortcut, } diff --git a/apps/renderer/src/modules/entry-column/list.tsx b/apps/renderer/src/modules/entry-column/list.tsx index 9f0181e54f..3447035057 100644 --- a/apps/renderer/src/modules/entry-column/list.tsx +++ b/apps/renderer/src/modules/entry-column/list.tsx @@ -272,7 +272,7 @@ export const EntryList: FC<EntryListProps> = memo( className="absolute left-0 top-0 w-full will-change-transform" style={{ transform, - paddingTop: sticky ? "1.75rem" : undefined, + paddingTop: sticky ? "3.5rem" : undefined, }} ref={rowVirtualizer.measureElement} data-index={virtualRow.index} diff --git a/apps/renderer/src/modules/entry-content/actions/header-actions.tsx b/apps/renderer/src/modules/entry-content/actions/header-actions.tsx index 344a64379b..f6bcbbd179 100644 --- a/apps/renderer/src/modules/entry-content/actions/header-actions.tsx +++ b/apps/renderer/src/modules/entry-content/actions/header-actions.tsx @@ -3,15 +3,13 @@ import type { FeedViewType } from "@follow/constants" import { CommandActionButton } from "~/components/ui/button/CommandActionButton" import { useHasModal } from "~/components/ui/modal/stacked/hooks" import { shortcuts } from "~/constants/shortcuts" -import { useEntryActions } from "~/hooks/biz/useEntryActions" +import { useSortedEntryActions } from "~/hooks/biz/useEntryActions" import { COMMAND_ID } from "~/modules/command/commands/id" import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey" -import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks" import { useEntry } from "~/store/entry/hooks" export const EntryHeaderActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => { - const actionConfigs = useEntryActions({ entryId, view }) - const orderMap = useToolbarOrderMap() + const { mainAction: actionConfigs } = useSortedEntryActions({ entryId, view }) const entry = useEntry(entryId) const hasModal = useHasModal() @@ -23,27 +21,16 @@ export const EntryHeaderActions = ({ entryId, view }: { entryId: string; view?: args: [{ entryId }], }) - return actionConfigs - .filter((item) => { - const order = orderMap.get(item.id) - if (!order) return false - return order.type === "main" - }) - .sort((a, b) => { - const orderA = orderMap.get(a.id)?.order || 0 - const orderB = orderMap.get(b.id)?.order || 0 - return orderA - orderB - }) - .map((config) => { - return ( - <CommandActionButton - active={config.active} - key={config.id} - disableTriggerShortcut={hasModal} - commandId={config.id} - onClick={config.onClick} - shortcut={config.shortcut} - /> - ) - }) + return actionConfigs.map((config) => { + return ( + <CommandActionButton + active={config.active} + key={config.id} + disableTriggerShortcut={hasModal} + commandId={config.id} + onClick={config.onClick} + shortcut={config.shortcut} + /> + ) + }) } diff --git a/apps/renderer/src/modules/entry-content/actions/more-actions.tsx b/apps/renderer/src/modules/entry-content/actions/more-actions.tsx index d69297813c..ddff0a5028 100644 --- a/apps/renderer/src/modules/entry-content/actions/more-actions.tsx +++ b/apps/renderer/src/modules/entry-content/actions/more-actions.tsx @@ -9,31 +9,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu/dropdown-menu" -import { useEntryActions } from "~/hooks/biz/useEntryActions" +import { useSortedEntryActions } from "~/hooks/biz/useEntryActions" import { COMMAND_ID } from "~/modules/command/commands/id" import { useCommand } from "~/modules/command/hooks/use-command" import type { FollowCommandId } from "~/modules/command/types" -import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks" export const MoreActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => { - const actionConfigs = useEntryActions({ entryId, view }) - const orderMap = useToolbarOrderMap() + const { moreAction: actionConfigs } = useSortedEntryActions({ entryId, view }) const availableActions = useMemo( - () => - actionConfigs - .filter((item) => { - const order = orderMap.get(item.id) - // If the order is not set, it should be in the "more" menu - if (!order) return true - return order.type !== "main" - }) - .filter((item) => item.id !== COMMAND_ID.settings.customizeToolbar) - .sort((a, b) => { - const orderA = orderMap.get(a.id)?.order || Infinity - const orderB = orderMap.get(b.id)?.order || Infinity - return orderA - orderB - }), - [actionConfigs, orderMap], + () => actionConfigs.filter((item) => item.id !== COMMAND_ID.settings.customizeToolbar), + [actionConfigs], ) const extraAction = useMemo( diff --git a/apps/renderer/src/modules/entry-content/header.mobile.tsx b/apps/renderer/src/modules/entry-content/header.mobile.tsx index d674d63dfd..fb180c2424 100644 --- a/apps/renderer/src/modules/entry-content/header.mobile.tsx +++ b/apps/renderer/src/modules/entry-content/header.mobile.tsx @@ -4,7 +4,7 @@ import { findElementInShadowDOM } from "@follow/utils/dom" import { clsx, cn } from "@follow/utils/utils" import { DismissableLayer } from "@radix-ui/react-dismissable-layer" import { AnimatePresence, m } from "framer-motion" -import { memo, useEffect, useState } from "react" +import { memo, useEffect, useMemo, useState } from "react" import { RemoveScroll } from "react-remove-scroll" import { useEventCallback } from "usehooks-ts" @@ -14,7 +14,7 @@ import { CommandActionButton } from "~/components/ui/button/CommandActionButton" import { useScrollTracking, useTocItems } from "~/components/ui/markdown/components/hooks" import { ENTRY_CONTENT_RENDER_CONTAINER_ID } from "~/constants/dom" import type { EntryActionItem } from "~/hooks/biz/useEntryActions" -import { useEntryActions } from "~/hooks/biz/useEntryActions" +import { useSortedEntryActions } from "~/hooks/biz/useEntryActions" import { useEntry } from "~/store/entry/hooks" import { COMMAND_ID } from "../command/commands/id" @@ -25,16 +25,16 @@ import type { EntryHeaderProps } from "./header.shared" function EntryHeaderImpl({ view, entryId, className }: EntryHeaderProps) { const entry = useEntry(entryId) - const actionConfigs = useEntryActions({ entryId, view }).filter( - (item) => - !( - [ - COMMAND_ID.entry.read, - COMMAND_ID.entry.unread, - COMMAND_ID.entry.copyLink, - COMMAND_ID.settings.customizeToolbar, - ] as string[] - ).includes(item.id), + const sortedActionConfigs = useSortedEntryActions({ entryId, view }) + const actionConfigs = useMemo( + () => + [...sortedActionConfigs.mainAction, ...sortedActionConfigs.moreAction].filter( + (item) => + !([COMMAND_ID.entry.copyLink, COMMAND_ID.settings.customizeToolbar] as string[]).includes( + item.id, + ), + ), + [sortedActionConfigs.mainAction, sortedActionConfigs.moreAction], ) const entryTitleMeta = useEntryTitleMeta() From 7e627fd9a0f7bcda9258b62ecebb2b28a717fc41 Mon Sep 17 00:00:00 2001 From: dai <dosada@gmail.com> Date: Thu, 9 Jan 2025 21:16:35 +0900 Subject: [PATCH 53/70] feat(i18n): update Japanese locale with new toolbar customization and profile link social fields (#2498) --- locales/settings/ja.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/locales/settings/ja.json b/locales/settings/ja.json index c2445f82b5..adeec99866 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -97,6 +97,7 @@ "appearance.zen_mode.description": "Zen モードは、邪魔されることなくコンテンツに集中できる読書モードです。Zenモードを有効にすると、サイドバーが非表示になります。", "appearance.zen_mode.label": "Zen モード", "common.give_star": "<HeartIcon />私たちの製品が好きですか?<Link>GitHub で Star を付けましょう!</Link>", + "customizeToolbar.title": "ツールバーをカスタマイズ", "data_control.app_cache_limit.description": "アプリの最大キャッシュを設定します。 このサイズに達すると空き容量を確保するために古いアイテムから削除されます。", "data_control.app_cache_limit.label": "キャッシュリミット", "data_control.clean_cache.button": "キャッシュをクリアー", @@ -234,7 +235,7 @@ "lists.created.success": "リストを作成しました", "lists.delete.confirm": "リストを削除しようとしていますよろしいですか?", "lists.delete.error": "リストの削除に失敗しました", - "lists.delete.success": "リストを削除しました", + "lists.delete.success": "リストを削除しました!", "lists.delete.warning": "警告: 一度リストを削除するとそのリストは永久に利用できず、元にも戻せません!..", "lists.description": "説明", "lists.earnings": "収益", @@ -276,27 +277,38 @@ "profile.email.verified": "確認済み", "profile.handle.description": "あなた個人の識別子です", "profile.handle.label": "ハンドル", + "profile.link_social.authentication": "認証", + "profile.link_social.description": "現在同じメールを使用しているソーシャルアカウントがか接続できます。", + "profile.link_social.link": "リンク", + "profile.link_social.unlink.success": "ソーシャルアカウントのリンクを解除しました。", "profile.name.description": "公開表示名", "profile.name.label": "表示名", "profile.new_password.label": "新しいパスワード", + "profile.password.label": "パスワード", "profile.reset_password_mail_sent": "パスワードリセットメールを送信しました", "profile.sidebar_title": "プロフィール", "profile.submit": "送信", "profile.title": "プロフィール設定", "profile.updateSuccess": "プロフィールが更新されました。", "profile.update_password_success": "パスワードが更新されました。", + "rsshub.addModal.access_key_label": "アクセスキー (オプション)", + "rsshub.addModal.add": "追加", + "rsshub.addModal.base_url_label": "Base URL", + "rsshub.addModal.description": "Follow であなた所有のインスタンスを使うには、以下の環境変数を追加する必要があります。", "rsshub.add_new_instance": "新たなインスタンスを追加", "rsshub.description": "RSSHub コミュニティ駆動のオープンソース RSS ネットワークです。Follow は内蔵の専用インスタンスを提供し、そのインスタンスを使って数千のサブスクリプションコンテンツをサポートします。また独自あるいはサードパーティのインスタンスを使用することで、より安定したコンテンツ取得を実現できます。", "rsshub.public_instances": "利用可能なインスタンス", "rsshub.table.description": "説明", "rsshub.table.edit": "編集", "rsshub.table.inuse": "利用中", + "rsshub.table.official": "公式", "rsshub.table.owner": "所有者", "rsshub.table.price": "月額の費用", "rsshub.table.unlimited": "無制限", "rsshub.table.use": "利用", "rsshub.table.userCount": "ユーザー数", "rsshub.table.userLimit": "ユーザー制限", + "rsshub.table.yours": "あなたの", "rsshub.useModal.about": "このインスタンスについて", "rsshub.useModal.month": "月", "rsshub.useModal.months_label": "購入したい月数", From 51065f484e0f1dd515369fc398f019fc8af71ccf Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Thu, 9 Jan 2025 20:22:49 +0800 Subject: [PATCH 54/70] style: update background color of ActionBar in SocialMediaItem component in light mode Signed-off-by: Innei <tukon479@gmail.com> --- .../src/modules/entry-column/Items/social-media-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx b/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx index 45559a1a67..8513749d30 100644 --- a/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx +++ b/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx @@ -118,7 +118,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl </div> {showAction && !isMobile && ( - <div className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-neutral-900 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900"> + <div className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900"> <ActionBar entryId={entryId} /> </div> )} From 0ab3ba9a3eff93cee0983928a66467a327a7d3b9 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Thu, 9 Jan 2025 22:42:01 +0800 Subject: [PATCH 55/70] feat(rn/search): implement search tabview Signed-off-by: Innei <tukon479@gmail.com> --- .../src/components/ui/loading/index.tsx | 5 +- .../src/modules/discover/SearchTabBar.tsx | 38 +++-- apps/mobile/src/modules/discover/constants.ts | 7 + apps/mobile/src/modules/discover/ctx.tsx | 33 ++++- .../discover/search-tabs/SearchFeed.tsx | 56 ++++++++ .../discover/search-tabs/SearchList.tsx | 16 +++ .../discover/search-tabs/SearchRSSHub.tsx | 11 ++ .../discover/search-tabs/SearchUser.tsx | 11 ++ .../modules/discover/search-tabs/__base.tsx | 61 ++++++++ apps/mobile/src/modules/discover/search.tsx | 58 ++++---- apps/mobile/src/screens/(headless)/search.tsx | 130 +++++++++++++----- packages/hooks/exports.ts | 1 + packages/hooks/package.json | 1 + 13 files changed, 335 insertions(+), 93 deletions(-) create mode 100644 apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx create mode 100644 apps/mobile/src/modules/discover/search-tabs/SearchList.tsx create mode 100644 apps/mobile/src/modules/discover/search-tabs/SearchRSSHub.tsx create mode 100644 apps/mobile/src/modules/discover/search-tabs/SearchUser.tsx create mode 100644 apps/mobile/src/modules/discover/search-tabs/__base.tsx create mode 100644 packages/hooks/exports.ts diff --git a/apps/mobile/src/components/ui/loading/index.tsx b/apps/mobile/src/components/ui/loading/index.tsx index bd9f645e44..fc5b3f03cc 100644 --- a/apps/mobile/src/components/ui/loading/index.tsx +++ b/apps/mobile/src/components/ui/loading/index.tsx @@ -16,8 +16,9 @@ import { Loading3CuteLiIcon } from "@/src/icons/loading_3_cute_li" export const LoadingIndicator: FC< { size?: number + color?: string } & PropsWithChildren -> = ({ size = 60, children }) => { +> = ({ size = 60, color, children }) => { const rotateValue = useSharedValue(0) const rotation = useDerivedValue(() => { @@ -41,7 +42,7 @@ export const LoadingIndicator: FC< return ( <View className="flex-1 items-center justify-center"> <Animated.View style={[animatedStyle, { width: size, height: size }]} className={"mb-2"}> - <Loading3CuteLiIcon width={size} height={size} /> + <Loading3CuteLiIcon width={size} height={size} color={color} /> </Animated.View> {children} </View> diff --git a/apps/mobile/src/modules/discover/SearchTabBar.tsx b/apps/mobile/src/modules/discover/SearchTabBar.tsx index 4a7767d2fe..57040028f0 100644 --- a/apps/mobile/src/modules/discover/SearchTabBar.tsx +++ b/apps/mobile/src/modules/discover/SearchTabBar.tsx @@ -1,31 +1,27 @@ import { useAtom } from "jotai" -import { View } from "react-native" +import type { FC } from "react" +import type { Animated } from "react-native" import { TabBar } from "@/src/components/ui/tabview/TabBar" -import type { Tab } from "@/src/components/ui/tabview/types" -import { SearchType } from "./constants" -import { useDiscoverPageContext } from "./ctx" +import type { SearchType } from "./constants" +import { SearchTabs } from "./constants" +import { useSearchPageContext } from "./ctx" -const Tabs: Tab[] = [ - { name: "Feed", value: SearchType.Feed }, - { name: "List", value: SearchType.List }, - { name: "User", value: SearchType.User }, - { name: "RSSHub", value: SearchType.RSSHub }, -] -export const SearchTabBar = () => { - const { searchTypeAtom } = useDiscoverPageContext() +export const SearchTabBar: FC<{ + animatedX: Animated.Value +}> = ({ animatedX }) => { + const { searchTypeAtom } = useSearchPageContext() const [searchType, setSearchType] = useAtom(searchTypeAtom) return ( - <View> - <TabBar - tabs={Tabs} - currentTab={Tabs.findIndex((tab) => tab.value === searchType)} - onTabItemPress={(index) => { - setSearchType(Tabs[index].value as SearchType) - }} - /> - </View> + <TabBar + tabScrollContainerAnimatedX={animatedX} + tabs={SearchTabs} + currentTab={SearchTabs.findIndex((tab) => tab.value === searchType)} + onTabItemPress={(index) => { + setSearchType(SearchTabs[index].value as SearchType) + }} + /> ) } diff --git a/apps/mobile/src/modules/discover/constants.ts b/apps/mobile/src/modules/discover/constants.ts index e9e17e8925..0c2f22c960 100644 --- a/apps/mobile/src/modules/discover/constants.ts +++ b/apps/mobile/src/modules/discover/constants.ts @@ -4,3 +4,10 @@ export enum SearchType { User = "user", RSSHub = "rsshub", } + +export const SearchTabs = [ + { name: "Feed", value: SearchType.Feed }, + { name: "List", value: SearchType.List }, + { name: "User", value: SearchType.User }, + { name: "RSSHub", value: SearchType.RSSHub }, +] diff --git a/apps/mobile/src/modules/discover/ctx.tsx b/apps/mobile/src/modules/discover/ctx.tsx index 1b30390b40..77a9720d08 100644 --- a/apps/mobile/src/modules/discover/ctx.tsx +++ b/apps/mobile/src/modules/discover/ctx.tsx @@ -2,16 +2,18 @@ import type { PrimitiveAtom } from "jotai" import { atom } from "jotai" import type { Dispatch, SetStateAction } from "react" import { createContext, useContext, useState } from "react" +import type { Animated } from "react-native" +import { useAnimatedValue } from "react-native" import { SearchType } from "./constants" -interface DiscoverPageContextType { +interface SearchPageContextType { searchFocusedAtom: PrimitiveAtom<boolean> searchValueAtom: PrimitiveAtom<string> searchTypeAtom: PrimitiveAtom<SearchType> } -export const DiscoverPageContext = createContext<DiscoverPageContextType>(null!) +export const SearchPageContext = createContext<SearchPageContextType>(null!) const SearchBarHeightContext = createContext<number>(0) const setSearchBarHeightContext = createContext<Dispatch<SetStateAction<number>>>(() => {}) @@ -33,8 +35,8 @@ export const useSetSearchBarHeight = () => { return useContext(setSearchBarHeightContext) } -export const DiscoverPageProvider = ({ children }: { children: React.ReactNode }) => { - const [atomRefs] = useState((): DiscoverPageContextType => { +export const SearchPageProvider = ({ children }: { children: React.ReactNode }) => { + const [atomRefs] = useState((): SearchPageContextType => { const searchFocusedAtom = atom(true) const searchValueAtom = atom("") const searchTypeAtom = atom(SearchType.Feed) @@ -44,11 +46,28 @@ export const DiscoverPageProvider = ({ children }: { children: React.ReactNode } searchTypeAtom, } }) - return <DiscoverPageContext.Provider value={atomRefs}>{children}</DiscoverPageContext.Provider> + return <SearchPageContext.Provider value={atomRefs}>{children}</SearchPageContext.Provider> } -export const useDiscoverPageContext = () => { - const ctx = useContext(DiscoverPageContext) +const SearchPageScrollContainerAnimatedXContext = createContext<Animated.Value>(null!) +export const SearchPageScrollContainerAnimatedXProvider = ({ + children, +}: { + children: React.ReactNode +}) => { + const scrollContainerAnimatedX = useAnimatedValue(0) + return ( + <SearchPageScrollContainerAnimatedXContext.Provider value={scrollContainerAnimatedX}> + {children} + </SearchPageScrollContainerAnimatedXContext.Provider> + ) +} + +export const useSearchPageScrollContainerAnimatedX = () => { + return useContext(SearchPageScrollContainerAnimatedXContext) +} +export const useSearchPageContext = () => { + const ctx = useContext(SearchPageContext) if (!ctx) throw new Error("useDiscoverPageContext must be used within a DiscoverPageProvider") return ctx } diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx new file mode 100644 index 0000000000..bc4de97881 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query" +import { useAtomValue } from "jotai" +import { memo } from "react" +import { Text } from "react-native" + +import { LoadingIndicator } from "@/src/components/ui/loading" +import { apiClient } from "@/src/lib/api-fetch" + +import { useSearchPageContext } from "../ctx" +import { BaseSearchPageFlatList, BaseSearchPageRootView, BaseSearchPageScrollView } from "./__base" + +type SearchResultItem = Awaited<ReturnType<typeof apiClient.discover.$post>>["data"][number] + +export const SearchFeed = () => { + const { searchValueAtom } = useSearchPageContext() + const searchValue = useAtomValue(searchValueAtom) + + const { data, isLoading } = useQuery({ + queryKey: ["searchFeed", searchValue], + queryFn: () => { + return apiClient.discover.$post({ + json: { + keyword: searchValue, + target: "feeds", + }, + }) + }, + enabled: !!searchValue, + }) + + if (isLoading) { + return ( + <BaseSearchPageRootView> + <LoadingIndicator color="#fff" size={32} /> + </BaseSearchPageRootView> + ) + } + + return ( + <BaseSearchPageFlatList + keyExtractor={keyExtractor} + renderScrollComponent={(props) => <BaseSearchPageScrollView {...props} />} + data={data?.data} + renderItem={renderItem} + /> + ) +} +const keyExtractor = (item: SearchResultItem) => item.feed?.id ?? Math.random().toString() + +const renderItem = ({ item }: { item: SearchResultItem }) => ( + <SearchFeedItem key={item.feed?.id} item={item} /> +) + +const SearchFeedItem = memo(({ item }: { item: SearchResultItem }) => { + return <Text className="text-text">{item.feed?.title}</Text> +}) diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx new file mode 100644 index 0000000000..244064eb60 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx @@ -0,0 +1,16 @@ +import { useAtomValue } from "jotai" +import { Text } from "react-native" + +import { useSearchPageContext } from "../ctx" +import { BaseSearchPageScrollView } from "./__base" + +export const SearchList = () => { + const { searchValueAtom } = useSearchPageContext() + const searchValue = useAtomValue(searchValueAtom) + + return ( + <BaseSearchPageScrollView> + <Text className="text-text">{searchValue}</Text> + </BaseSearchPageScrollView> + ) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchRSSHub.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchRSSHub.tsx new file mode 100644 index 0000000000..7083fa8aea --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchRSSHub.tsx @@ -0,0 +1,11 @@ +import { Text } from "react-native" + +import { BaseSearchPageScrollView } from "./__base" + +export const SearchRSSHub = () => { + return ( + <BaseSearchPageScrollView> + <Text>RSSHub</Text> + </BaseSearchPageScrollView> + ) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchUser.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchUser.tsx new file mode 100644 index 0000000000..3d0ca7c2eb --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchUser.tsx @@ -0,0 +1,11 @@ +import { Text } from "react-native" + +import { BaseSearchPageScrollView } from "./__base" + +export const SearchUser = () => { + return ( + <BaseSearchPageScrollView> + <Text>User</Text> + </BaseSearchPageScrollView> + ) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/__base.tsx b/apps/mobile/src/modules/discover/search-tabs/__base.tsx new file mode 100644 index 0000000000..e482077e60 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/__base.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from "react" +import type { ScrollViewProps } from "react-native" +import { ScrollView, useWindowDimensions, View } from "react-native" +import type { FlatListPropsWithLayout } from "react-native-reanimated" +import Animated, { LinearTransition } from "react-native-reanimated" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +import { useSearchBarHeight } from "../ctx" + +export const BaseSearchPageScrollView = forwardRef<ScrollView, ScrollViewProps>( + ({ children, ...props }, ref) => { + const searchBarHeight = useSearchBarHeight() + const insets = useSafeAreaInsets() + const windowWidth = useWindowDimensions().width + const offsetTop = searchBarHeight - insets.top + return ( + <ScrollView + ref={ref} + style={{ paddingTop: offsetTop, width: windowWidth }} + automaticallyAdjustContentInsets + contentInsetAdjustmentBehavior="always" + className="flex-1" + scrollIndicatorInsets={{ bottom: insets.bottom, top: offsetTop }} + {...props} + > + {children} + </ScrollView> + ) + }, +) + +export const BaseSearchPageRootView = ({ children }: { children: React.ReactNode }) => { + const windowWidth = useWindowDimensions().width + const insets = useSafeAreaInsets() + const searchBarHeight = useSearchBarHeight() + const offsetTop = searchBarHeight - insets.top + return ( + <View className="flex-1" style={{ paddingTop: offsetTop, width: windowWidth }}> + {children} + </View> + ) +} + +export function BaseSearchPageFlatList<T>({ ...props }: FlatListPropsWithLayout<T>) { + const insets = useSafeAreaInsets() + const searchBarHeight = useSearchBarHeight() + const offsetTop = searchBarHeight - insets.top + const windowWidth = useWindowDimensions().width + return ( + <Animated.FlatList + itemLayoutAnimation={LinearTransition} + className="flex-1" + style={{ width: windowWidth }} + contentContainerStyle={{ paddingTop: offsetTop }} + scrollIndicatorInsets={{ bottom: insets.bottom, top: offsetTop }} + automaticallyAdjustContentInsets + contentInsetAdjustmentBehavior="always" + {...props} + /> + ) +} diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index fa390925ce..04f6da96f4 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -2,7 +2,7 @@ import { getDefaultHeaderHeight } from "@react-navigation/elements" import { router } from "expo-router" import { useAtom, useAtomValue, useSetAtom } from "jotai" import type { FC } from "react" -import { useEffect, useRef } from "react" +import { useEffect, useRef, useState } from "react" import type { LayoutChangeEvent } from "react-native" import { Animated, @@ -21,12 +21,13 @@ import { BlurEffect } from "@/src/components/common/HeaderBlur" import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re" import { accentColor, useColor } from "@/src/theme/colors" -import { useDiscoverPageContext } from "./ctx" +import { useSearchPageContext } from "./ctx" import { SearchTabBar } from "./SearchTabBar" export const SearchHeader: FC<{ + animatedX: Animated.Value onLayout: (e: LayoutChangeEvent) => void -}> = ({ onLayout }) => { +}> = ({ animatedX, onLayout }) => { const frame = useSafeAreaFrame() const insets = useSafeAreaInsets() const headerHeight = getDefaultHeaderHeight(frame, false, insets.top) @@ -41,7 +42,7 @@ export const SearchHeader: FC<{ <View style={styles.header}> <ComposeSearchBar /> </View> - <SearchTabBar /> + <SearchTabBar animatedX={animatedX} /> </View> ) } @@ -88,33 +89,32 @@ const PlaceholerSearchBar = () => { } const ComposeSearchBar = () => { - const { searchFocusedAtom, searchValueAtom } = useDiscoverPageContext() - const [isFocused, setIsFocused] = useAtom(searchFocusedAtom) + const { searchFocusedAtom, searchValueAtom } = useSearchPageContext() + const setIsFocused = useSetAtom(searchFocusedAtom) const setSearchValue = useSetAtom(searchValueAtom) return ( <> <SearchInput /> - {isFocused && ( - <TouchableOpacity - hitSlop={10} - onPress={() => { - setIsFocused(false) - setSearchValue("") - if (router.canGoBack()) { - router.back() - } - }} - > - <Text className="ml-2 text-accent">Cancel</Text> - </TouchableOpacity> - )} + <TouchableOpacity + hitSlop={10} + onPress={() => { + setIsFocused(false) + setSearchValue("") + + if (router.canGoBack()) { + router.back() + } + }} + > + <Text className="ml-2 text-accent">Cancel</Text> + </TouchableOpacity> </> ) } const SearchInput = () => { - const { searchFocusedAtom, searchValueAtom } = useDiscoverPageContext() + const { searchFocusedAtom, searchValueAtom } = useSearchPageContext() const [isFocused, setIsFocused] = useAtom(searchFocusedAtom) const placeholderTextColor = useColor("placeholderText") const searchValue = useAtomValue(searchValueAtom) @@ -125,7 +125,9 @@ const SearchInput = () => { const skeletonTranslateXValue = useAnimatedValue(0) const placeholderOpacityValue = useAnimatedValue(1) - const focusOrHasValue = isFocused || searchValue + const [tempSearchValue, setTempSearchValue] = useState(searchValue) + + const focusOrHasValue = isFocused || searchValue || tempSearchValue useEffect(() => { if (focusOrHasValue) { @@ -190,7 +192,7 @@ const SearchInput = () => { className="absolute inset-y-0 left-3 flex flex-row items-center justify-center" > <Search2CuteReIcon color={placeholderTextColor} height={18} width={18} /> - {!searchValue && ( + {!searchValue && !tempSearchValue && ( <Text className="text-placeholder-text ml-2" style={styles.searchPlaceholderText}> Search </Text> @@ -201,14 +203,20 @@ const SearchInput = () => { enterKeyHint="search" autoFocus={isFocused} ref={inputRef} - value={searchValue} + onSubmitEditing={() => { + setSearchValue(tempSearchValue) + setTempSearchValue("") + }} + defaultValue={searchValue} cursorColor={accentColor} selectionColor={accentColor} style={styles.searchInput} className="text-text" onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} - onChangeText={(text) => setSearchValue(text)} + onChangeText={(text) => { + setTempSearchValue(text) + }} /> <Animated.View diff --git a/apps/mobile/src/screens/(headless)/search.tsx b/apps/mobile/src/screens/(headless)/search.tsx index 72af536bf3..f8d8989c97 100644 --- a/apps/mobile/src/screens/(headless)/search.tsx +++ b/apps/mobile/src/screens/(headless)/search.tsx @@ -1,59 +1,110 @@ import { Stack } from "expo-router" -import { ScrollView, Text, View } from "react-native" -import { useSafeAreaInsets } from "react-native-safe-area-context" +import { useAtomValue } from "jotai" +import * as React from "react" +import { useEffect, useRef, useState } from "react" +import type { ScrollView } from "react-native" +import { Animated, Dimensions, View } from "react-native" +import { AnimatedScrollView } from "@/src/components/common/AnimatedComponents" +import { SearchTabs, SearchType } from "@/src/modules/discover/constants" import { - DiscoverPageContext, - DiscoverPageProvider, SearchBarHeightProvider, - useDiscoverPageContext, - useSearchBarHeight, + SearchPageContext, + SearchPageProvider, + SearchPageScrollContainerAnimatedXProvider, + useSearchPageContext, + useSearchPageScrollContainerAnimatedX, useSetSearchBarHeight, } from "@/src/modules/discover/ctx" import { SearchHeader } from "@/src/modules/discover/search" +import { SearchFeed } from "@/src/modules/discover/search-tabs/SearchFeed" +import { SearchList } from "@/src/modules/discover/search-tabs/SearchList" +import { SearchRSSHub } from "@/src/modules/discover/search-tabs/SearchRSSHub" +import { SearchUser } from "@/src/modules/discover/search-tabs/SearchUser" const Search = () => { return ( <View className="flex-1"> - <DiscoverPageProvider> - <SearchBarHeightProvider> - <SearchbarMount /> - <Content /> - </SearchBarHeightProvider> - </DiscoverPageProvider> + <SearchPageScrollContainerAnimatedXProvider> + <SearchPageProvider> + <SearchBarHeightProvider> + <SearchbarMount /> + <Content /> + </SearchBarHeightProvider> + </SearchPageProvider> + </SearchPageScrollContainerAnimatedXProvider> </View> ) } +const SearchType2RenderContent: Record<SearchType, React.FC> = { + [SearchType.Feed]: SearchFeed, + [SearchType.List]: SearchList, + [SearchType.User]: SearchUser, + [SearchType.RSSHub]: SearchRSSHub, +} +const PlaceholderLazyView = () => { + const windowWidth = Dimensions.get("window").width + return <View className="flex-1" style={{ width: windowWidth }} /> +} const Content = () => { - const searchBarHeight = useSearchBarHeight() - const insets = useSafeAreaInsets() + const scrollContainerAnimatedX = useSearchPageScrollContainerAnimatedX() + const { searchTypeAtom } = useSearchPageContext() + const searchType = useAtomValue(searchTypeAtom) + + const scrollRef = useRef<ScrollView>(null) + useEffect(() => { + if (scrollRef.current) { + const pageIndex = SearchTabs.findIndex((tab) => tab.value === searchType) + scrollRef.current.scrollTo({ + x: pageIndex * Dimensions.get("window").width, + y: 0, + animated: true, + }) + } + }, [searchType]) + + const [loadedContentSet, setLoadedContentSet] = useState(() => new Set()) + + useEffect(() => { + setLoadedContentSet((prev) => { + const newSet = new Set(prev) + newSet.add(searchType) + return newSet + }) + }, [searchType]) + return ( - <ScrollView - style={{ paddingTop: searchBarHeight - insets.top }} - automaticallyAdjustContentInsets - contentInsetAdjustmentBehavior="always" - className="flex-1" + <AnimatedScrollView + ref={scrollRef} + horizontal + pagingEnabled + nestedScrollEnabled + scrollEnabled + showsHorizontalScrollIndicator={false} + className={"flex-1"} + scrollEventThrottle={16} + onScroll={Animated.event( + [{ nativeEvent: { contentOffset: { x: scrollContainerAnimatedX } } }], + { + useNativeDriver: true, + }, + )} > - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - <Text className="text-text">Search</Text> - </ScrollView> + {SearchTabs.map(({ value }) => + loadedContentSet.has(value) ? ( + React.createElement(SearchType2RenderContent[value], { key: value }) + ) : ( + <PlaceholderLazyView key={value} /> + ), + )} + </AnimatedScrollView> ) } + const SearchbarMount = () => { - const ctx = useDiscoverPageContext() + const ctx = useSearchPageContext() + const scrollContainerAnimatedX = useSearchPageScrollContainerAnimatedX() const setSearchBarHeight = useSetSearchBarHeight() return ( @@ -64,9 +115,12 @@ const SearchbarMount = () => { header: () => { return ( - <DiscoverPageContext.Provider value={ctx}> - <SearchHeader onLayout={(e) => setSearchBarHeight(e.nativeEvent.layout.height)} /> - </DiscoverPageContext.Provider> + <SearchPageContext.Provider value={ctx}> + <SearchHeader + animatedX={scrollContainerAnimatedX} + onLayout={(e) => setSearchBarHeight(e.nativeEvent.layout.height)} + /> + </SearchPageContext.Provider> ) }, }} diff --git a/packages/hooks/exports.ts b/packages/hooks/exports.ts new file mode 100644 index 0000000000..40f0b93bfb --- /dev/null +++ b/packages/hooks/exports.ts @@ -0,0 +1 @@ +export { useTypeScriptHappyCallback } from "./src/useTypescriptHappyCallback" diff --git a/packages/hooks/package.json b/packages/hooks/package.json index c47d61f88e..709262d519 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -9,6 +9,7 @@ "require": "./src/index.js" } }, + "main": "./exports.ts", "peerDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" From 3a6f28796699c3e0e04620b6f3f4cc180a1bb98a Mon Sep 17 00:00:00 2001 From: DIYgod <i@diygod.me> Date: Thu, 9 Jan 2025 22:47:24 +0800 Subject: [PATCH 56/70] feat: email verification toast --- .../app-layout/feed-column/index.shared.tsx | 49 +- packages/shared/src/hono.ts | 3979 ++++++++++++++++- 2 files changed, 3845 insertions(+), 183 deletions(-) diff --git a/apps/renderer/src/modules/app-layout/feed-column/index.shared.tsx b/apps/renderer/src/modules/app-layout/feed-column/index.shared.tsx index fb8eddebba..0c4b7ec909 100644 --- a/apps/renderer/src/modules/app-layout/feed-column/index.shared.tsx +++ b/apps/renderer/src/modules/app-layout/feed-column/index.shared.tsx @@ -1,4 +1,8 @@ -import { lazy, Suspense } from "react" +import { sendVerificationEmail } from "@follow/shared/auth" +import { WEB_URL } from "@follow/shared/constants" +import { cn } from "@follow/utils/utils" +import { lazy, Suspense, useEffect, useState } from "react" +import { toast } from "sonner" import { useWhoami } from "~/atoms/user" import { useAuthQuery } from "~/hooks/common/useBizQuery" @@ -13,9 +17,52 @@ export function NewUserGuide() { const { data: remoteSettings, isLoading } = useAuthQuery(settings.get(), {}) const isNewUser = !isLoading && remoteSettings && Object.keys(remoteSettings.updated ?? {}).length === 0 + + useEffect(() => { + if (user?.email && !user.emailVerified) { + toast.error(<EmailVerificationToast user={user} />, { + duration: Infinity, + }) + } + }, [user?.emailVerified]) + return user && isNewUser ? ( <Suspense> <LazyNewUserGuideModal /> </Suspense> ) : null } + +function EmailVerificationToast({ + user, +}: { + user: { + email: string + } +}) { + const [isEmailVerificationSent, setIsEmailVerificationSent] = useState(false) + return ( + <div data-content className="flex w-full flex-col gap-2"> + <div data-title>Please verify your email ({user.email}) to continue</div> + <button + type="button" + data-button="true" + data-action="true" + className={cn( + "font-sans font-medium", + isEmailVerificationSent && "!cursor-progress opacity-50", + )} + disabled={isEmailVerificationSent} + onClick={() => { + sendVerificationEmail({ + email: user.email, + callbackURL: `${WEB_URL}/login`, + }) + setIsEmailVerificationSent(true) + }} + > + Send verification email + </button> + </div> + ) +} diff --git a/packages/shared/src/hono.ts b/packages/shared/src/hono.ts index 89f75b4565..14f5f0edc6 100644 --- a/packages/shared/src/hono.ts +++ b/packages/shared/src/hono.ts @@ -10,6 +10,7 @@ import { AnyPgColumn } from 'drizzle-orm/pg-core'; import * as drizzle_orm from 'drizzle-orm'; import { InferInsertModel, SQL } from 'drizzle-orm'; import * as better_auth_adapters_drizzle from 'better-auth/adapters/drizzle'; +import * as better_auth_plugins from 'better-auth/plugins'; import * as better_auth from 'better-auth'; type Env = { @@ -17,9 +18,9 @@ type Env = { }; declare const authPlugins: ({ - id: "getProviders"; + id: "customGetProviders"; endpoints: { - getProviders: { + customGetProviders: { <C extends [(better_call.Context<"/get-providers", { method: "GET"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -34,9 +35,9 @@ declare const authPlugins: ({ }; }; } | { - id: "createSession"; + id: "customCreateSession"; endpoints: { - createSession: { + customCreateSession: { <C extends [(better_call.Context<"/create-session", { method: "GET"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -80,9 +81,9 @@ declare const authPlugins: ({ }; }; } | { - id: "updateUserccc"; + id: "customUpdateUser"; endpoints: { - updateUserccc: { + customUpdateUser: { <C extends [(better_call.Context<"/update-user-ccc", { method: "POST"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -5261,6 +5262,23 @@ declare const user: drizzle_orm_pg_core.PgTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + twoFactorEnabled: drizzle_orm_pg_core.PgColumn<{ + name: "two_factor_enabled"; + tableName: "user"; + dataType: "boolean"; + columnType: "PgBoolean"; + data: boolean; + driverParam: boolean; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; }; dialect: "pg"; }>; @@ -5404,6 +5422,23 @@ declare const users: drizzle_orm_pg_core.PgTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + twoFactorEnabled: drizzle_orm_pg_core.PgColumn<{ + name: "two_factor_enabled"; + tableName: "user"; + dataType: "boolean"; + columnType: "PgBoolean"; + data: boolean; + driverParam: boolean; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; }; dialect: "pg"; }>; @@ -5417,6 +5452,7 @@ declare const usersOpenApiSchema: zod.ZodObject<Omit<{ handle: zod.ZodNullable<zod.ZodString>; createdAt: zod.ZodDate; updatedAt: zod.ZodDate; + twoFactorEnabled: zod.ZodNullable<zod.ZodBoolean>; }, "email">, "strip", zod.ZodTypeAny, { name: string | null; id: string; @@ -5425,6 +5461,7 @@ declare const usersOpenApiSchema: zod.ZodObject<Omit<{ handle: string | null; createdAt: Date; updatedAt: Date; + twoFactorEnabled: boolean | null; }, { name: string | null; id: string; @@ -5433,6 +5470,7 @@ declare const usersOpenApiSchema: zod.ZodObject<Omit<{ handle: string | null; createdAt: Date; updatedAt: Date; + twoFactorEnabled: boolean | null; }>; declare const account: drizzle_orm_pg_core.PgTableWithColumns<{ name: "account"; @@ -5914,6 +5952,81 @@ declare const verification: drizzle_orm_pg_core.PgTableWithColumns<{ }; dialect: "pg"; }>; +declare const twoFactor: drizzle_orm_pg_core.PgTableWithColumns<{ + name: "two_factor"; + schema: undefined; + columns: { + id: drizzle_orm_pg_core.PgColumn<{ + name: "id"; + tableName: "two_factor"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: true; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + secret: drizzle_orm_pg_core.PgColumn<{ + name: "secret"; + tableName: "two_factor"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + backupCodes: drizzle_orm_pg_core.PgColumn<{ + name: "backup_codes"; + tableName: "two_factor"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + userId: drizzle_orm_pg_core.PgColumn<{ + name: "user_id"; + tableName: "two_factor"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; +}>; declare const usersRelations: drizzle_orm.Relations<"user", { subscriptions: drizzle_orm.Many<"subscriptions">; listsSubscriptions: drizzle_orm.Many<"lists_subscriptions">; @@ -7113,6 +7226,16 @@ declare const auth: { updatedAt: Date; image?: string | null | undefined | undefined; handle: string; + } & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined | undefined; + twoFactorEnabled: boolean | null | undefined; + handle: string; }; } | null>; path: "/get-session"; @@ -7255,10 +7378,16 @@ declare const auth: { name: string; }> & zod.ZodObject<{ handle: zod.ZodString; + } | { + handle: zod.ZodString; }, zod.UnknownKeysParam, zod.ZodTypeAny, { handle: string; + } | { + handle: string; }, { handle: string; + } | { + handle: string; }>; metadata: { openapi: { @@ -7376,10 +7505,16 @@ declare const auth: { name: string; }> & zod.ZodObject<{ handle: zod.ZodString; + } | { + handle: zod.ZodString; }, zod.UnknownKeysParam, zod.ZodTypeAny, { handle: string; + } | { + handle: string; }, { handle: string; + } | { + handle: string; }>; metadata: { openapi: { @@ -8351,10 +8486,16 @@ declare const auth: { method: "POST"; body: zod.ZodObject<{ handle: zod.ZodString; + } | { + handle: zod.ZodString; }, zod.UnknownKeysParam, zod.ZodTypeAny, { handle: string; + } | { + handle: string; }, { handle: string; + } | { + handle: string; }> & zod.ZodObject<{ name: zod.ZodOptional<zod.ZodString>; image: zod.ZodOptional<zod.ZodString | zod.ZodNull>; @@ -8439,10 +8580,16 @@ declare const auth: { method: "POST"; body: zod.ZodObject<{ handle: zod.ZodString; + } | { + handle: zod.ZodString; }, zod.UnknownKeysParam, zod.ZodTypeAny, { handle: string; + } | { + handle: string; }, { handle: string; + } | { + handle: string; }> & zod.ZodObject<{ name: zod.ZodOptional<zod.ZodString>; image: zod.ZodOptional<zod.ZodString | zod.ZodNull>; @@ -9567,7 +9714,7 @@ declare const auth: { headers: Headers; }; } & { - getProviders: { + customGetProviders: { <C extends [(better_call.Context<"/get-providers", { method: "GET"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -9581,7 +9728,7 @@ declare const auth: { headers: Headers; }; } & { - createSession: { + customCreateSession: { <C extends [(better_call.Context<"/create-session", { method: "GET"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -9621,7 +9768,7 @@ declare const auth: { headers: Headers; }; } & { - updateUserccc: { + customUpdateUser: { <C extends [(better_call.Context<"/update-user-ccc", { method: "POST"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -9635,78 +9782,1786 @@ declare const auth: { headers: Headers; }; } & { - getSession: { - <C extends [(better_call.Context<"/get-session", { - method: "GET"; - metadata: { - CUSTOM_SESSION: boolean; - }; - query: zod.ZodOptional<zod.ZodObject<{ - disableCookieCache: zod.ZodOptional<zod.ZodUnion<[zod.ZodBoolean, zod.ZodEffects<zod.ZodString, boolean, string>]>>; - disableRefresh: zod.ZodOptional<zod.ZodBoolean>; + enableTwoFactor: { + <C extends [better_call.Context<"/two-factor/enable", { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; }, "strip", zod.ZodTypeAny, { - disableCookieCache?: boolean | undefined; - disableRefresh?: boolean | undefined; + password: string; }, { - disableCookieCache?: string | boolean | undefined; - disableRefresh?: boolean | undefined; - }>>; - }> | undefined)?]>(...ctx: C): Promise<C extends [{ + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + totpURI: { + type: string; + description: string; + }; + backupCodes: { + type: string; + items: { + type: string; + }; + description: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ asResponse: true; }] ? Response : { - user: { - id: string; - email: string; - emailVerified: boolean; - name: string; - createdAt: Date; - updatedAt: Date; - image?: string | null | undefined | undefined; - } & { - image: string | null; - handle: string | null; - }; - session: { - id: string; - createdAt: Date; - updatedAt: Date; - userId: string; - expiresAt: Date; - token: string; - ipAddress?: string | null | undefined | undefined; - userAgent?: string | null | undefined | undefined; - }; - invitation: { - code: string; - createdAt: Date | null; - usedAt: Date | null; - fromUserId: string; - toUserId: string | null; - } | undefined; - role: "user" | "trial"; - } | null>; - path: "/get-session"; + totpURI: string; + backupCodes: string[]; + }>; + path: "/two-factor/enable"; options: { - method: "GET"; - metadata: { - CUSTOM_SESSION: boolean; - }; - query: zod.ZodOptional<zod.ZodObject<{ - disableCookieCache: zod.ZodOptional<zod.ZodUnion<[zod.ZodBoolean, zod.ZodEffects<zod.ZodString, boolean, string>]>>; - disableRefresh: zod.ZodOptional<zod.ZodBoolean>; + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; }, "strip", zod.ZodTypeAny, { - disableCookieCache?: boolean | undefined; - disableRefresh?: boolean | undefined; + password: string; }, { - disableCookieCache?: string | boolean | undefined; - disableRefresh?: boolean | undefined; - }>>; - }; - method: better_call.Method | better_call.Method[]; - headers: Headers; - }; - }>; - options: { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + totpURI: { + type: string; + description: string; + }; + backupCodes: { + type: string; + items: { + type: string; + }; + description: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + disableTwoFactor: { + <C extends [better_call.Context<"/two-factor/disable", { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + status: boolean; + }>; + path: "/two-factor/disable"; + options: { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + verifyBackupCode: { + <C extends [better_call.Context<"/two-factor/verify-backup-code", { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + disableSession: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + code: string; + disableSession?: boolean | undefined; + }, { + code: string; + disableSession?: boolean | undefined; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + user: better_auth_plugins.UserWithTwoFactor; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + } & { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + } & { + session: better_auth.Session & Record<string, any>; + user: better_auth.User & Record<string, any>; + }; + }>; + path: "/two-factor/verify-backup-code"; + options: { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + disableSession: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + code: string; + disableSession?: boolean | undefined; + }, { + code: string; + disableSession?: boolean | undefined; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + generateBackupCodes: { + <C extends [better_call.Context<"/two-factor/generate-backup-codes", { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + status: boolean; + backupCodes: string[]; + }>; + path: "/two-factor/generate-backup-codes"; + options: { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + viewBackupCodes: { + <C extends [better_call.Context<"/two-factor/view-backup-codes", { + method: "GET"; + body: zod.ZodObject<{ + userId: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + userId: string; + }, { + userId: string; + }>; + metadata: { + SERVER_ONLY: true; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + status: boolean; + backupCodes: string[]; + }>; + path: "/two-factor/view-backup-codes"; + options: { + method: "GET"; + body: zod.ZodObject<{ + userId: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + userId: string; + }, { + userId: string; + }>; + metadata: { + SERVER_ONLY: true; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + sendTwoFactorOTP: { + <C extends [better_call.Context<"/two-factor/send-otp", { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + status: boolean; + }>; + path: "/two-factor/send-otp"; + options: { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + verifyTwoFactorOTP: { + <C extends [better_call.Context<"/two-factor/verify-otp", { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + code: string; + }, { + code: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }>; + path: "/two-factor/verify-otp"; + options: { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + code: string; + }, { + code: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + generateTOTP: { + <C extends [(better_call.Context<"/totp/generate", { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + code: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }> | undefined)?]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + code: string; + }>; + path: "/totp/generate"; + options: { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + code: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + getTOTPURI: { + <C extends [better_call.Context<"/two-factor/get-totp-uri", { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + totpURI: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + totpURI: string; + }>; + path: "/two-factor/get-totp-uri"; + options: { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + totpURI: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + verifyTOTP: { + <C extends [better_call.Context<"/two-factor/verify-totp", { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + code: string; + }, { + code: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }>; + path: "/two-factor/verify-totp"; + options: { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + code: string; + }, { + code: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + } & { + getSession: { + <C extends [(better_call.Context<"/get-session", { + method: "GET"; + metadata: { + CUSTOM_SESSION: boolean; + }; + query: zod.ZodOptional<zod.ZodObject<{ + disableCookieCache: zod.ZodOptional<zod.ZodUnion<[zod.ZodBoolean, zod.ZodEffects<zod.ZodString, boolean, string>]>>; + disableRefresh: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + disableCookieCache?: boolean | undefined; + disableRefresh?: boolean | undefined; + }, { + disableCookieCache?: string | boolean | undefined; + disableRefresh?: boolean | undefined; + }>>; + }> | undefined)?]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined | undefined; + } & { + image: string | null; + handle: string | null; + }; + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined | undefined; + userAgent?: string | null | undefined | undefined; + }; + invitation: { + code: string; + createdAt: Date | null; + usedAt: Date | null; + fromUserId: string; + toUserId: string | null; + } | undefined; + role: "user" | "trial"; + } | null>; + path: "/get-session"; + options: { + method: "GET"; + metadata: { + CUSTOM_SESSION: boolean; + }; + query: zod.ZodOptional<zod.ZodObject<{ + disableCookieCache: zod.ZodOptional<zod.ZodUnion<[zod.ZodBoolean, zod.ZodEffects<zod.ZodString, boolean, string>]>>; + disableRefresh: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + disableCookieCache?: boolean | undefined; + disableRefresh?: boolean | undefined; + }, { + disableCookieCache?: string | boolean | undefined; + disableRefresh?: boolean | undefined; + }>>; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + }>; + options: { appName: string; database: (options: better_auth.BetterAuthOptions) => { id: string; @@ -9728,91 +11583,1863 @@ declare const auth: { field: string; direction: "asc" | "desc"; }; - offset?: number; - }): Promise<any[]>; - update<T>(data: { - model: string; - where: better_auth.Where[]; - update: Record<string, any>; - }): Promise<any>; - updateMany(data: { - model: string; - where: better_auth.Where[]; - update: Record<string, any>; - }): Promise<any>; - delete<T>(data: { - model: string; - where: better_auth.Where[]; - }): Promise<void>; - deleteMany(data: { - model: string; - where: better_auth.Where[]; - }): Promise<any>; - options: better_auth_adapters_drizzle.DrizzleAdapterConfig; - }; - advanced: { - generateId: false; - }; - session: { - updateAge: number; - expiresIn: number; - }; - basePath: string; - trustedOrigins: string[]; - user: { - additionalFields: { - handle: { - type: "string"; + offset?: number; + }): Promise<any[]>; + update<T>(data: { + model: string; + where: better_auth.Where[]; + update: Record<string, any>; + }): Promise<any>; + updateMany(data: { + model: string; + where: better_auth.Where[]; + update: Record<string, any>; + }): Promise<any>; + delete<T>(data: { + model: string; + where: better_auth.Where[]; + }): Promise<void>; + deleteMany(data: { + model: string; + where: better_auth.Where[]; + }): Promise<any>; + options: better_auth_adapters_drizzle.DrizzleAdapterConfig; + }; + advanced: { + generateId: false; + }; + session: { + updateAge: number; + expiresIn: number; + }; + basePath: string; + trustedOrigins: string[]; + user: { + additionalFields: { + handle: { + type: "string"; + }; + }; + changeEmail: { + enabled: true; + sendChangeEmailVerification: ({ newEmail, url }: { + user: better_auth.User; + newEmail: string; + url: string; + token: string; + }) => Promise<void>; + }; + }; + account: { + accountLinking: { + enabled: true; + trustedProviders: ("github" | "apple" | "google")[]; + }; + }; + socialProviders: { + google: { + clientId: string; + clientSecret: string; + }; + github: { + clientId: string; + clientSecret: string; + }; + apple: { + enabled: boolean; + clientId: string; + clientSecret: string; + appBundleIdentifier: string | undefined; + }; + }; + emailAndPassword: { + enabled: true; + sendResetPassword({ user, url }: { + user: better_auth.User; + url: string; + token: string; + }): Promise<void>; + }; + emailVerification: { + sendVerificationEmail({ user, url }: { + user: better_auth.User; + url: string; + token: string; + }): Promise<void>; + }; + plugins: (better_auth.BetterAuthPlugin | { + id: "two-factor"; + endpoints: { + enableTwoFactor: { + <C extends [better_call.Context<"/two-factor/enable", { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + totpURI: { + type: string; + description: string; + }; + backupCodes: { + type: string; + items: { + type: string; + }; + description: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + totpURI: string; + backupCodes: string[]; + }>; + path: "/two-factor/enable"; + options: { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + totpURI: { + type: string; + description: string; + }; + backupCodes: { + type: string; + items: { + type: string; + }; + description: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + disableTwoFactor: { + <C extends [better_call.Context<"/two-factor/disable", { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + status: boolean; + }>; + path: "/two-factor/disable"; + options: { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + verifyBackupCode: { + <C extends [better_call.Context<"/two-factor/verify-backup-code", { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + disableSession: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + code: string; + disableSession?: boolean | undefined; + }, { + code: string; + disableSession?: boolean | undefined; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + user: better_auth_plugins.UserWithTwoFactor; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + } & { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + } & { + session: better_auth.Session & Record<string, any>; + user: better_auth.User & Record<string, any>; + }; + }>; + path: "/two-factor/verify-backup-code"; + options: { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + disableSession: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + code: string; + disableSession?: boolean | undefined; + }, { + code: string; + disableSession?: boolean | undefined; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + generateBackupCodes: { + <C extends [better_call.Context<"/two-factor/generate-backup-codes", { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + status: boolean; + backupCodes: string[]; + }>; + path: "/two-factor/generate-backup-codes"; + options: { + method: "POST"; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + viewBackupCodes: { + <C extends [better_call.Context<"/two-factor/view-backup-codes", { + method: "GET"; + body: zod.ZodObject<{ + userId: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + userId: string; + }, { + userId: string; + }>; + metadata: { + SERVER_ONLY: true; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + status: boolean; + backupCodes: string[]; + }>; + path: "/two-factor/view-backup-codes"; + options: { + method: "GET"; + body: zod.ZodObject<{ + userId: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + userId: string; + }, { + userId: string; + }>; + metadata: { + SERVER_ONLY: true; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + sendTwoFactorOTP: { + <C extends [better_call.Context<"/two-factor/send-otp", { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + status: boolean; + }>; + path: "/two-factor/send-otp"; + options: { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + verifyTwoFactorOTP: { + <C extends [better_call.Context<"/two-factor/verify-otp", { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + code: string; + }, { + code: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }>; + path: "/two-factor/verify-otp"; + options: { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + code: string; + }, { + code: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + generateTOTP: { + <C extends [(better_call.Context<"/totp/generate", { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + code: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }> | undefined)?]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + code: string; + }>; + path: "/totp/generate"; + options: { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + code: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + getTOTPURI: { + <C extends [better_call.Context<"/two-factor/get-totp-uri", { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + totpURI: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + totpURI: string; + }>; + path: "/two-factor/get-totp-uri"; + options: { + method: "POST"; + use: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, better_call.EndpointOptions>[]; + body: zod.ZodObject<{ + password: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + password: string; + }, { + password: string; + }>; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + totpURI: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; + }; + verifyTOTP: { + <C extends [better_call.Context<"/two-factor/verify-totp", { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + code: string; + }, { + code: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }>]>(...ctx: C): Promise<C extends [{ + asResponse: true; + }] ? Response : { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }>; + path: "/two-factor/verify-totp"; + options: { + method: "POST"; + body: zod.ZodObject<{ + code: zod.ZodString; + }, "strip", zod.ZodTypeAny, { + code: string; + }, { + code: string; + }>; + use: better_call.Endpoint<better_call.Handler<string, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }, { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: better_auth_plugins.UserWithTwoFactor; + }; + } | { + valid: () => Promise<{ + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + token: string; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null | undefined; + createdAt: Date; + updatedAt: Date; + }; + }; + _flag: "json"; + }>; + invalid: () => Promise<never>; + session: { + session: Record<string, any> & { + id: string; + createdAt: Date; + updatedAt: Date; + userId: string; + expiresAt: Date; + token: string; + ipAddress?: string | null | undefined; + userAgent?: string | null | undefined; + }; + user: Record<string, any> & { + id: string; + email: string; + emailVerified: boolean; + name: string; + createdAt: Date; + updatedAt: Date; + image?: string | null | undefined; + }; + }; + }>, { + body: zod.ZodObject<{ + trustDevice: zod.ZodOptional<zod.ZodBoolean>; + }, "strip", zod.ZodTypeAny, { + trustDevice?: boolean | undefined; + }, { + trustDevice?: boolean | undefined; + }>; + } & { + method: "*"; + }>[]; + metadata: { + openapi: { + summary: string; + description: string; + responses: { + 200: { + description: string; + content: { + "application/json": { + schema: { + type: "object"; + properties: { + status: { + type: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + }; + method: better_call.Method | better_call.Method[]; + headers: Headers; }; }; - changeEmail: { - enabled: true; - sendChangeEmailVerification: ({ newEmail, url }: { - user: better_auth.User; - newEmail: string; - url: string; - token: string; - }) => Promise<void>; - }; - }; - account: { - accountLinking: { - enabled: true; - trustedProviders: ("github" | "apple" | "google")[]; - }; - }; - socialProviders: { - google: { - clientId: string; - clientSecret: string; - }; - github: { - clientId: string; - clientSecret: string; + options: better_auth_plugins.TwoFactorOptions | undefined; + hooks: { + after: { + matcher(context: better_auth.HookEndpointContext<{ + returned: better_call.APIError | Response | Record<string, any>; + endpoint: better_call.Endpoint; + }>): boolean; + handler: better_call.Endpoint<better_call.Handler<string, better_call.EndpointOptions, { + response: { + body: any; + status: number; + statusText: string; + headers: Record<string, string> | undefined; + }; + body: { + twoFactorRedirect: boolean; + }; + _flag: "json"; + } | undefined>, better_call.EndpointOptions>; + }[]; }; - apple: { - enabled: boolean; - clientId: string; - clientSecret: string; - appBundleIdentifier: string | undefined; + schema: { + user: { + fields: { + twoFactorEnabled: { + type: "boolean"; + required: false; + defaultValue: false; + input: false; + }; + }; + }; + twoFactor: { + fields: { + secret: { + type: "string"; + required: true; + returned: false; + }; + backupCodes: { + type: "string"; + required: true; + returned: false; + }; + userId: { + type: "string"; + required: true; + returned: false; + references: { + model: string; + field: string; + }; + }; + }; + }; }; - }; - emailAndPassword: { - enabled: true; - sendResetPassword({ user, url }: { - user: better_auth.User; - url: string; - token: string; - }): Promise<void>; - }; - emailVerification: { - sendVerificationEmail({ user, url }: { - user: better_auth.User; - url: string; - token: string; - }): Promise<void>; - }; - plugins: (better_auth.BetterAuthPlugin | { + rateLimit: { + pathMatcher(path: string): boolean; + window: number; + max: number; + }[]; + } | { id: "custom-session"; endpoints: { getSession: { @@ -9887,9 +13514,9 @@ declare const auth: { }; }; } | { - id: "getProviders"; + id: "customGetProviders"; endpoints: { - getProviders: { + customGetProviders: { <C extends [(better_call.Context<"/get-providers", { method: "GET"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -9904,9 +13531,9 @@ declare const auth: { }; }; } | { - id: "createSession"; + id: "customCreateSession"; endpoints: { - createSession: { + customCreateSession: { <C extends [(better_call.Context<"/create-session", { method: "GET"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -9950,9 +13577,9 @@ declare const auth: { }; }; } | { - id: "updateUserccc"; + id: "customUpdateUser"; endpoints: { - updateUserccc: { + customUpdateUser: { <C extends [(better_call.Context<"/update-user-ccc", { method: "POST"; }> | undefined)?]>(...ctx: C): Promise<C extends [{ @@ -9990,6 +13617,7 @@ declare const auth: { updatedAt: Date; image?: string | null | undefined | undefined; handle: string; + twoFactorEnabled: boolean | null | undefined; }; }; }; @@ -11360,6 +14988,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ handle: string | null; createdAt: string; updatedAt: string; + twoFactorEnabled: boolean | null; }; }; outputFormat: "json"; @@ -11385,6 +15014,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ handle: string | null; createdAt: string; updatedAt: string; + twoFactorEnabled: boolean | null; }; }; }; @@ -11574,6 +15204,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ handle: string | null; createdAt: string; updatedAt: string; + twoFactorEnabled: boolean | null; }[]; }; } | { @@ -11932,6 +15563,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ handle: string | null; createdAt: string; updatedAt: string; + twoFactorEnabled: boolean | null; } | null | undefined; toUser?: { name: string | null; @@ -11941,6 +15573,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ handle: string | null; createdAt: string; updatedAt: string; + twoFactorEnabled: boolean | null; } | null | undefined; toFeed?: { type: "feed"; @@ -11992,26 +15625,6 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ status: 200; }; }; -} & { - "/withdraw": { - $post: { - input: { - json: { - amount: string; - address: string; - toRss3?: boolean | undefined; - }; - }; - output: { - code: 0; - data: { - transactionHash: string; - }; - }; - outputFormat: "json"; - status: 200; - }; - }; } & { "/claim-check": { $get: { @@ -12089,6 +15702,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ handle: string | null; createdAt: string; updatedAt: string; + twoFactorEnabled: boolean | null; }; userId: string; rank: number | null; @@ -12844,6 +16458,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ handle: string | null; createdAt: string; updatedAt: string; + twoFactorEnabled: boolean | null; }[]; }; outputFormat: "json"; @@ -13057,4 +16672,4 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({ }, "/rsshub">, "/">; type AppType = typeof _routes; -export { type ActionsModel, type AirdropActivity, type AppType, type AttachmentsModel, type AuthSession, type AuthUser, CommonEntryFields, type ConditionItem, type DetailModel, type EntriesModel, type EntryReadHistoriesModel, type ExtraModel, type FeedModel, type MediaModel, type MessagingData, MessagingType, type SettingsModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activityEnum, airdrops, airdropsOpenAPISchema, attachmentsZodSchema, authPlugins, boosts, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, entryReadHistories, entryReadHistoriesOpenAPISchema, entryReadHistoriesRelations, extraZodSchema, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, type inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, listsTimeline, listsTimelineOpenAPISchema, listsTimelineRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, rsshub, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, user, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations }; +export { type ActionsModel, type AirdropActivity, type AppType, type AttachmentsModel, type AuthSession, type AuthUser, CommonEntryFields, type ConditionItem, type DetailModel, type EntriesModel, type EntryReadHistoriesModel, type ExtraModel, type FeedModel, type MediaModel, type MessagingData, MessagingType, type SettingsModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activityEnum, airdrops, airdropsOpenAPISchema, attachmentsZodSchema, authPlugins, boosts, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, entryReadHistories, entryReadHistoriesOpenAPISchema, entryReadHistoriesRelations, extraZodSchema, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, type inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, listsTimeline, listsTimelineOpenAPISchema, listsTimelineRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, rsshub, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, twoFactor, user, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations }; From ac76cde0ca99e3f9ca842be84603f5bd6e5a0ad4 Mon Sep 17 00:00:00 2001 From: DIYgod <i@diygod.me> Date: Thu, 9 Jan 2025 22:53:06 +0800 Subject: [PATCH 57/70] fix: rsshub link in mobile --- apps/renderer/src/modules/user/ProfileButton.mobile.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/renderer/src/modules/user/ProfileButton.mobile.tsx b/apps/renderer/src/modules/user/ProfileButton.mobile.tsx index db223fc5cd..1669eadd87 100644 --- a/apps/renderer/src/modules/user/ProfileButton.mobile.tsx +++ b/apps/renderer/src/modules/user/ProfileButton.mobile.tsx @@ -8,6 +8,7 @@ import type { FC } from "react" import { useTranslation } from "react-i18next" import { Link } from "react-router" +import rsshubLogoUrl from "~/assets/rsshub-icon.png?url" import { useUserRole, useWhoami } from "~/atoms/user" import { useSignOut } from "~/hooks/biz/useSignOut" import { useWallet } from "~/queries/wallet" @@ -108,6 +109,11 @@ export const ProfileButton: FC<ProfileButtonProps> = () => { }} icon={<i className="i-mgc-settings-7-cute-re" />} /> + <Item + label={t("words.rsshub")} + link="/rsshub" + icon={<img src={rsshubLogoUrl} className="size-3 grayscale" />} + /> <Item label={t("user_button.log_out")} onClick={signOut} From fe7b62a03c2270cbabb06660f4d2954c71549bbe Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:46:12 +0800 Subject: [PATCH 58/70] chore: fix type check --- .../src/modules/power/my-wallet-section/withdraw.tsx | 1 + packages/models/src/types.ts | 2 +- packages/shared/src/auth.ts | 8 ++++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/renderer/src/modules/power/my-wallet-section/withdraw.tsx b/apps/renderer/src/modules/power/my-wallet-section/withdraw.tsx index 67be4840ae..1dfdfb7341 100644 --- a/apps/renderer/src/modules/power/my-wallet-section/withdraw.tsx +++ b/apps/renderer/src/modules/power/my-wallet-section/withdraw.tsx @@ -78,6 +78,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { toRss3?: boolean }) => { const amountBigInt = from(amount, 18)[0] + // @ts-expect-error FIXME: remove this line after API is back await apiClient.wallets.transactions.withdraw.$post({ json: { address, diff --git a/packages/models/src/types.ts b/packages/models/src/types.ts index 9ffe7d4a77..8c80c7e0b4 100644 --- a/packages/models/src/types.ts +++ b/packages/models/src/types.ts @@ -6,7 +6,7 @@ declare const _apiClient: ReturnType<typeof hc<AppType>> export type UserModel = Omit< typeof users.$inferSelect, - "createdAt" | "updatedAt" | "email" | "emailVerified" + "createdAt" | "updatedAt" | "email" | "emailVerified" | "twoFactorEnabled" > & { email?: string } diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index bb8d235641..3bab9c2999 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -9,12 +9,12 @@ import { IN_ELECTRON, WEB_URL } from "./constants" type AuthPlugin = (typeof authPlugins)[number] const serverPlugins = [ { - id: "getProviders", - $InferServerPlugin: {} as Extract<AuthPlugin, { id: "getProviders" }>, + id: "customGetProviders", + $InferServerPlugin: {} as Extract<AuthPlugin, { id: "customGetProviders" }>, }, { - id: "createSession", - $InferServerPlugin: {} as Extract<AuthPlugin, { id: "createSession" }>, + id: "customCreateSession", + $InferServerPlugin: {} as Extract<AuthPlugin, { id: "customCreateSession" }>, }, { id: "getAccountInfo", From d4ed2b3415a6fc497bcd9b09f4988872ac0c6d4b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:04:51 +0800 Subject: [PATCH 59/70] chore: rename --- CONTRIBUTE.md => CONTRIBUTING.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CONTRIBUTE.md => CONTRIBUTING.md (100%) diff --git a/CONTRIBUTE.md b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTE.md rename to CONTRIBUTING.md From 58e12c54098bf8ac5d2ab142d4bff85f5437e094 Mon Sep 17 00:00:00 2001 From: DIYgod <i@diygod.me> Date: Fri, 10 Jan 2025 10:23:40 +0800 Subject: [PATCH 60/70] docs: fix video preview --- changelog/0.3.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/0.3.1.md b/changelog/0.3.1.md index 0b674f6d1a..d9377902d9 100644 --- a/changelog/0.3.1.md +++ b/changelog/0.3.1.md @@ -3,7 +3,7 @@ ## New Features - **Customize toolbar**: Customize the toolbar to display the items you most frequently use. - ![Customize toolbar](https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true) + <video src="https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true" controls muted autoPlay loop /> ## Improvements From 8499079e503ad8cfc35e390755cec159f9eb3b7a Mon Sep 17 00:00:00 2001 From: DIYgod <i@diygod.me> Date: Fri, 10 Jan 2025 11:44:26 +0800 Subject: [PATCH 61/70] Revert "docs: fix video preview" This reverts commit 58e12c54098bf8ac5d2ab142d4bff85f5437e094. --- changelog/0.3.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/0.3.1.md b/changelog/0.3.1.md index d9377902d9..0b674f6d1a 100644 --- a/changelog/0.3.1.md +++ b/changelog/0.3.1.md @@ -3,7 +3,7 @@ ## New Features - **Customize toolbar**: Customize the toolbar to display the items you most frequently use. - <video src="https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true" controls muted autoPlay loop /> + ![Customize toolbar](https://github.com/RSSNext/assets/blob/main/customize-toolbar.mp4?raw=true) ## Improvements From 44afb1e888e64737eb4f06b3ecc6b26366487978 Mon Sep 17 00:00:00 2001 From: DIYgod <i@diygod.me> Date: Fri, 10 Jan 2025 11:45:15 +0800 Subject: [PATCH 62/70] fix: markdown mp4 check --- apps/renderer/src/lib/parse-markdown.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/renderer/src/lib/parse-markdown.ts b/apps/renderer/src/lib/parse-markdown.ts index f3ba091efd..a6f2f9b5ff 100644 --- a/apps/renderer/src/lib/parse-markdown.ts +++ b/apps/renderer/src/lib/parse-markdown.ts @@ -12,11 +12,16 @@ export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>) a: ({ node, ...props }) => createElement(MarkdownLink, { ...props } as any), img: ({ node, ...props }) => { const { src } = props - const isVideo = src?.endsWith(".mp4") - if (isVideo) { - return createElement(VideoPlayer, { - src: src as string, - }) + try { + const path = new URL(src || "").pathname + const isVideo = path.endsWith(".mp4") + if (isVideo) { + return createElement(VideoPlayer, { + src: src as string, + }) + } + } catch { + // ignore } return createElement("img", { ...props } as any) }, From d525170461b7e475a8635562a32c0d48d95b2abc Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:03:32 +0800 Subject: [PATCH 63/70] chore: update changelog --- changelog/0.3.1.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog/0.3.1.md b/changelog/0.3.1.md index 0b674f6d1a..c09b7af193 100644 --- a/changelog/0.3.1.md +++ b/changelog/0.3.1.md @@ -9,5 +9,6 @@ - **Podcast Player**: Re-designed the podcast player in mobile to be more user-friendly. ![Podcast Player](https://github.com/RSSNext/assets/blob/8f778dac8bb2e765acab2157497e4a77a60c5a0b/mobile-audio-player.png?raw=true) +- The toolbar style of the social view has been redesigned. Now the toolbar no longer jitters or gets obstructed when hovering over entries. ## Bug Fixes From e10e0bf180881f03333debb27079ca710c771f95 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:05:33 +0800 Subject: [PATCH 64/70] chore: update changelog --- changelog/0.3.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/0.3.1.md b/changelog/0.3.1.md index c09b7af193..c1e591406d 100644 --- a/changelog/0.3.1.md +++ b/changelog/0.3.1.md @@ -9,6 +9,6 @@ - **Podcast Player**: Re-designed the podcast player in mobile to be more user-friendly. ![Podcast Player](https://github.com/RSSNext/assets/blob/8f778dac8bb2e765acab2157497e4a77a60c5a0b/mobile-audio-player.png?raw=true) -- The toolbar style of the social view has been redesigned. Now the toolbar no longer jitters or gets obstructed when hovering over entries. +- **Social View Action**: Redesigned the toolbar style in the social view. Now, the toolbar no longer jitters or gets obstructed when hovering over entries. ## Bug Fixes From 5780aea3a4f1d2da1389a1140008a402498db82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E7=A9=BA=E7=9A=84=E7=9B=A1=E9=A0=AD?= <9551552+ghostendsky@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:07:50 +0800 Subject: [PATCH 65/70] feat(locales): update zh-TW translations (#2522) --- locales/app/zh-TW.json | 246 +++++++++++++++++------------------ locales/common/zh-TW.json | 12 +- locales/errors/zh-TW.json | 17 ++- locales/external/zh-TW.json | 28 ++-- locales/native/zh-TW.json | 10 +- locales/settings/zh-TW.json | 85 ++++++------ locales/shortcuts/zh-TW.json | 6 +- 7 files changed, 207 insertions(+), 197 deletions(-) diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index ae0b0a5a3d..b9df31ea17 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -3,20 +3,20 @@ "achievement.alpha_tester": "Alpha 測試者", "achievement.alpha_tester_description": "您參與了 Follow 的 Alpha 測試。", "achievement.description": "成為硬派玩家,鑄造 NFT。", - "achievement.feed_booster": "訂閱源加成者", - "achievement.feed_booster_description": "在 Follow 上加成訂閱源", - "achievement.first_claim_feed": "訂閱源所有者", - "achievement.first_claim_feed_description": "在 Follow 上認證訂閱源", - "achievement.first_create_list": "首次創建列表", - "achievement.first_create_list_description": "您在 Follow 上創建了您的第一個列表", - "achievement.follow_special_feed": "關注特別訂閱", - "achievement.follow_special_feed_description": "您關注了一個特別的訂閱", + "achievement.feed_booster": "RSS 摘要加成者", + "achievement.feed_booster_description": "在 Follow 上加成 RSS 摘要", + "achievement.first_claim_feed": "RSS 摘要作者", + "achievement.first_claim_feed_description": "在 Follow 上認證 RSS 摘要", + "achievement.first_create_list": "列表創建者", + "achievement.first_create_list_description": "在 Follow 上創建一個列表", + "achievement.follow_special_feed": "特別 RSS 摘要跟隨者", + "achievement.follow_special_feed_description": "在 Follow 上跟隨了一個特別的 RSS 摘要", "achievement.list_subscribe_100": "100 名列表訂閱者", - "achievement.list_subscribe_100_description": "100 名訂閱者訂閱了您創建的列表", + "achievement.list_subscribe_100_description": "創建的列表有 100 名訂閱者", "achievement.list_subscribe_50": "50 名列表訂閱者", "achievement.list_subscribe_500": "500 名列表訂閱者", - "achievement.list_subscribe_500_description": "500 名訂閱者訂閱了您創建的列表", - "achievement.list_subscribe_50_description": "50 名訂閱者訂閱了您創建的列表", + "achievement.list_subscribe_500_description": "創建的列表有 500 名訂閱者", + "achievement.list_subscribe_50_description": "創建的列表有 50 名訂閱者", "achievement.nft_coming_soon": "目前您無法鑄造 NFT。一旦我們準備好,NFT 將自動轉移到您的帳戶。", "achievement.product_hunt_vote": "Product Hunt 投票", "achievement.product_hunt_vote_description": "您在 Product Hunt 上投票給了 Follow", @@ -31,8 +31,8 @@ "app.copy_logo_svg": "複製 Logo SVG", "app.toggle_sidebar": "切換側邊欄", "beta_access": "Beta 版本", - "boost.boost_feed": "加成訂閱", - "boost.boost_feed_description": "加成訂閱源以解鎖更多獎勵,訂閱者將會感激這些獎勵!", + "boost.boost_feed": "加成", + "boost.boost_feed_description": "加成 RSS 摘要以解鎖更多獎勵,訂閱者將會感激這些獎勵!", "boost.boost_success": "🎉 加成成功!", "boost.boost_success_thanks": "感謝您的支持!", "boost.expired_description": "您現在加成不會增加更多經驗值,但仍可以繼續加成。您當前的加成將於 {{expiredDate, datetime}} 到期。", @@ -63,20 +63,20 @@ "discover.category.travel": "出行旅遊", "discover.category.university": "大學資訊", "discover.default_option": "(預設)", - "discover.feed_description": "該 feed 的描述如下,您可以使用相關資訊填寫參數表單。", - "discover.feed_maintainers": "此 feed 由 RSSHub 提供,感謝 <maintainers /> 的支持", + "discover.feed_description": "根據描述完成目標 RSS 摘要的相關資訊。", + "discover.feed_maintainers": "由 RSSHub 提供,感謝 <maintainers /> 的支持", "discover.import.click_to_upload": "點擊上傳 OPML 文件", "discover.import.conflictItems": "衝突項目", - "discover.import.new_import_opml": "如果您以前使用過RSS,您可以將資料配置匯出到OPML文件,並在此處匯入", + "discover.import.new_import_opml": "如果您以前使用過 RSS,您可以將資料配置匯出到 OPML 文件,並在此處匯入", "discover.import.noItems": "沒有項目", "discover.import.opml": "OPML 檔案", "discover.import.parsedErrorItems": "解析錯誤項目", - "discover.import.result": "成功匯入了 <SuccessfulNum /> 個訂閱源,<ConflictNum /> 個已經訂閱,<ErrorNum /> 個匯入失敗。", + "discover.import.result": "成功匯入了 <SuccessfulNum /> 個 RSS 摘要,<ConflictNum /> 個已經訂閱,<ErrorNum /> 個匯入失敗。", "discover.import.successfulItems": "成功項目", - "discover.inbox.actions": "自動化操作", + "discover.inbox.actions": "操作", "discover.inbox.description": "你可以透過電子信箱和 Webhooks 在收件匣接收資訊。", "discover.inbox.email": "電子信箱", - "discover.inbox.handle": "代碼", + "discover.inbox.handle": "名稱", "discover.inbox.secret": "密鑰", "discover.inbox.title": "標題", "discover.inbox.webhooks_docs": "Webhooks 文件", @@ -88,8 +88,8 @@ "discover.inbox_destroy_confirm": "確認刪除收件匣?", "discover.inbox_destroy_error": "刪除收件匣失敗", "discover.inbox_destroy_success": "收件匣刪除成功", - "discover.inbox_destroy_warning": "警告:一旦刪除,電子信箱將不再可用,所有內容將會永久刪除且無法恢復。", - "discover.inbox_handle": "代碼", + "discover.inbox_destroy_warning": "警告:一旦刪除,電子信箱將不再可用,所有項目將會永久刪除且無法恢復。", + "discover.inbox_handle": "名稱", "discover.inbox_title": "標題", "discover.inbox_update": "更新", "discover.inbox_update_error": "更改收件匣失敗", @@ -99,7 +99,7 @@ "discover.rss_hub_route": "RSSHub 路由", "discover.rss_url": "RSS URL", "discover.select_placeholder": "選擇", - "discover.target.feeds": "訂閱源", + "discover.target.feeds": "RSS 摘要", "discover.target.label": "搜尋", "discover.target.lists": "列表", "entry_actions.copied_notify": "{{which}}已複製到剪貼簿", @@ -134,7 +134,7 @@ "entry_actions.star": "收藏", "entry_actions.starred": "已收藏", "entry_actions.tip": "贊助", - "entry_actions.toggle_ai_summary": "切換 AI 摘要", + "entry_actions.toggle_ai_summary": "切換 AI 總結", "entry_actions.toggle_ai_translation": "切換 AI 翻譯", "entry_actions.unstar": "取消收藏", "entry_actions.unstarred": "已取消收藏", @@ -142,86 +142,86 @@ "entry_column.filtered_content_tip": "您已隱藏部分過濾的內容。", "entry_column.filtered_content_tip_2": "除了上方顯示的條目外,還有一些被過濾的內容。", "entry_column.refreshing": "正在更新", - "entry_content.ai_summary": "AI 摘要", - "entry_content.fetching_content": "正在獲取原始內容並處理……", - "entry_content.header.play_tts": "播放 TTS", + "entry_content.ai_summary": "AI 總結", + "entry_content.fetching_content": "正在獲取原始內容並處理", + "entry_content.header.play_tts": "播放文字轉語音", "entry_content.header.readability": "可讀模式", "entry_content.no_content": "無內容", "entry_content.readability_notice": "此內容由'可讀模式'提供。如果您發現排版異常,請前往網站查看原始內容。", "entry_content.render_error": "渲染錯誤:", "entry_content.report_issue": "報告問題", - "entry_content.support_amount": "{{amount}} 人支持了此頻道的創作者。", - "entry_content.support_creator": "支持創作者", + "entry_content.support_amount": "{{amount}} 人贊助了此 RSS 摘要的作者。", + "entry_content.support_creator": "贊助作者", "entry_content.web_app_notice": "Web 應用程式不支援此類型內容。您可以下載桌面應用程式。", - "entry_list.zero_unread": "零未讀", + "entry_list.zero_unread": "全部已讀", "entry_list_header.daily_report": "每日報告", "entry_list_header.grid": "格線佈局", - "entry_list_header.hide_no_image_items": "隱藏無圖條目", - "entry_list_header.items": "項目", + "entry_list_header.hide_no_image_items": "隱藏無圖片項目", + "entry_list_header.items": "內容", "entry_list_header.masonry": "瀑布式佈局", "entry_list_header.masonry_column": "佈局列數", - "entry_list_header.new_entries_available": "有新項目可用", + "entry_list_header.new_entries_available": "有新內容", "entry_list_header.preview_mode": "預覽模式", "entry_list_header.refetch": "重新獲取", "entry_list_header.refresh": "重新整理", "entry_list_header.show_all": "顯示全部", - "entry_list_header.show_all_items": "顯示所有條目", + "entry_list_header.show_all_items": "顯示所有內容", "entry_list_header.show_unread_only": "僅顯示未讀", "entry_list_header.switch_to_normalmode": "切換到正常模式", "entry_list_header.switch_to_widemode": "切換到寬螢幕模式", "entry_list_header.unread": "未讀", - "feed.follower_one": "訂閱者", - "feed.follower_other": "訂閱者", - "feed.followsAndFeeds": "{{appName}} 上有 {{subscriptionCount}} 個 {{subscriptionNoun}} 和 {{feedsCount}} 個 {{feedsNoun}}", - "feed.followsAndReads": "{{appName}} 上有 {{subscriptionCount}} 個 {{subscriptionNoun}} 和 {{readCount}} 篇最近的 {{readNoun}}", + "feed.follower_one": "跟隨者", + "feed.follower_other": "跟隨者", + "feed.followsAndFeeds": "在 {{appName}} 上有 {{subscriptionCount}} 個 {{subscriptionNoun}} 和 {{feedsCount}} 個 {{feedsNoun}}", + "feed.followsAndReads": "在 {{appName}} 上有 {{subscriptionCount}} 個 {{subscriptionNoun}} 和 {{readCount}} 次近期 {{readNoun}}", "feed.read_one": "閱讀", - "feed.read_other": "閱讀次數", + "feed.read_other": "閱讀", "feed_claim_modal.choose_verification_method": "有三種驗證方式,您可以選擇其中一種進行驗證。", "feed_claim_modal.claim_button": "認領", - "feed_claim_modal.content_instructions": "複製以下內容並將其發佈到您的最新 RSS 摘要中。", + "feed_claim_modal.content_instructions": "複製以下內容,發佈到需要驗證的 RSS 摘要。", "feed_claim_modal.description_current": "當前描述:", - "feed_claim_modal.description_instructions": "複製以下內容並將其貼入您的 RSS 摘要中的 <code /> 欄位。", - "feed_claim_modal.failed_to_load": "無法載入認領訊息", - "feed_claim_modal.rss_format_choice": "RSS 生成器通常有兩種格式可供選擇。請根據需要複製以下 XML 和 JSON 格式。", - "feed_claim_modal.rss_instructions": "複製以下程式碼並將其黏貼到您的 RSS 生成器中。", + "feed_claim_modal.description_instructions": "複製以下內容,新增到需要驗證的 RSS 摘要 <code /> 欄位內。", + "feed_claim_modal.failed_to_load": "認領資料讀取失敗", + "feed_claim_modal.rss_format_choice": "RSS 產生工具通常有兩種格式可供選擇。請根據需要複製以下內容。", + "feed_claim_modal.rss_instructions": "複製以下內容並將其黏貼到您的 RSS 產生工具。", "feed_claim_modal.rss_json_format": "JSON 格式", "feed_claim_modal.rss_xml_format": "XML 格式", - "feed_claim_modal.rsshub_notice": "此摘要由 RSSHub 提供,快取時間為 1 小時。發佈內容後,請最多等待 1 小時以顯示變更。", + "feed_claim_modal.rsshub_notice": "此 RSS 摘要由 RSSHub 提供,快取時間為 1 小時,最久可能會有將近 1 小時的延遲。", "feed_claim_modal.tab_content": "內容", "feed_claim_modal.tab_description": "描述", "feed_claim_modal.tab_rss": "RSS 標籤", - "feed_claim_modal.title": "認領摘要", - "feed_claim_modal.verify_ownership": "要認領此摘要為您的內容,您需要驗證所有權。", - "feed_form.add_feed": "新增訂閱源", - "feed_form.add_follow": "關注", + "feed_claim_modal.title": "認領 RSS 摘要", + "feed_claim_modal.verify_ownership": "要證明你是此 RSS 摘要的作者,您需要完成驗證。", + "feed_form.add_feed": "新增 RSS 摘要", + "feed_form.add_follow": "新增跟隨", "feed_form.category": "類別", - "feed_form.category_description": "預設情況下,您的關注將按網站分組。", - "feed_form.error_fetching_feed": "獲取摘要時出錯。", - "feed_form.fee": "訂閱費", - "feed_form.fee_description": "若需訂閱此列表,需支付訂閱費用", - "feed_form.feed_not_found": "未找到摘要。", + "feed_form.category_description": "預設情況下,您的跟隨將按網站域名分組。", + "feed_form.error_fetching_feed": "獲取 RSS 摘要出錯。", + "feed_form.fee": "跟隨費用", + "feed_form.fee_description": "若需跟隨此列表,需支付費用", + "feed_form.feed_not_found": "未找到 RSS 摘要", "feed_form.feedback": "回饋", "feed_form.fill_default": "填充", - "feed_form.follow": "關注", - "feed_form.follow_with_fee": "使用 {{fee}} Power 訂閱", - "feed_form.followed": "🎉 已關注。", - "feed_form.private_follow": "私人關注", - "feed_form.private_follow_description": "此關注是否在您的個人資料頁面上公開顯示。", + "feed_form.follow": "跟隨", + "feed_form.follow_with_fee": "使用 {{fee}} Power 跟隨", + "feed_form.followed": "🎉 跟隨成功。", + "feed_form.private_follow": "私人跟隨", + "feed_form.private_follow_description": "啟用後,此跟隨將不再顯示於個人資料頁面上。", "feed_form.retry": "重試", "feed_form.title": "標題", - "feed_form.title_description": "為此摘要自訂標題。留空以使用預設標題。", - "feed_form.unfollow": "取消關注", + "feed_form.title_description": "此 RSS 摘要的自定義標題。留空以使用預設標題。", + "feed_form.unfollow": "取消跟隨", "feed_form.update": "更新", - "feed_form.update_follow": "更新關注", - "feed_form.updated": "🎉 已更新。", - "feed_form.view": "檢視", - "feed_item.claimed_by_owner": "此摘要", - "feed_item.claimed_by_unknown": "其擁有者。", - "feed_item.claimed_by_you": "您已認領", - "feed_item.claimed_feed": "已認領摘要", + "feed_form.update_follow": "更新跟隨", + "feed_form.updated": "🎉 更新成功", + "feed_form.view": "視圖", + "feed_item.claimed_by_owner": "RSS 摘要作者", + "feed_item.claimed_by_unknown": "未知作者", + "feed_item.claimed_by_you": "RSS 摘要由你認領", + "feed_item.claimed_feed": "已認領 RSS 摘要", "feed_item.claimed_list": "已認領列表", - "feed_item.error_since": "錯誤發生時間", - "feed_item.not_publicly_visible": "在您的個人頁面上不公開顯示", + "feed_item.error_since": "RSS 摘要失效:", + "feed_item.not_publicly_visible": "在您的個人頁面上隱藏", "feed_view_type.articles": "文章", "feed_view_type.audios": "音訊", "feed_view_type.notifications": "通知", @@ -253,16 +253,16 @@ "new_user_guide.step.behavior.title": "行為設定", "new_user_guide.step.behavior.unread_question.content": "選擇您想要的標記已讀方式。", "new_user_guide.step.behavior.unread_question.description": "別擔心,您可以在偏好設定中隨時更改。", - "new_user_guide.step.behavior.unread_question.option1": "激進:顯示時自動標記為已讀。", + "new_user_guide.step.behavior.unread_question.option1": "主動:顯示時自動標記為已讀。", "new_user_guide.step.behavior.unread_question.option2": "平衡:滑過或離開視圖時自動標記為已讀。", - "new_user_guide.step.behavior.unread_question.option3": "保守:僅在點擊時標記為已讀。", - "new_user_guide.step.features.actions.description": "指令允許您為不同的訂閱源設定操作。\n- 使用 AI 進行總結或翻譯。\n- 設定條目的閱讀方式。\n- 為新條目啟用通知或靜音。\n- 覆寫或封鎖特定條目。\n- 將新條目發送到 webhook。", - "new_user_guide.step.features.integration.description": "整合功能允許您將條目保存到其他服務。目前支持的服務包括:\n- Eagle\n- Readwise\n- Instapaper\n- Obsidian\n- Outline\n- Readeck", + "new_user_guide.step.behavior.unread_question.option3": "被動:僅在點擊時標記為已讀。", + "new_user_guide.step.features.actions.description": "自動化操作允許您為不同的 RSS 摘要設定操作。\n- 使用 AI 進行總結或翻譯。\n- 設定項目的閱讀方式。\n- 為新項目啟用通知或靜音。\n- 覆寫或封鎖特定項目。\n- 將新項目發送到 webhook。", + "new_user_guide.step.features.integration.description": "整合功能允許您將項目儲存到其他服務。目前支持的服務有:\n- Eagle\n- Readwise\n- Instapaper\n- Obsidian\n- Outline\n- Readeck", "new_user_guide.step.migrate.profile": "設定您的個人資料", "new_user_guide.step.migrate.title": "從 OPML 文件導入", "new_user_guide.step.migrate.wallet": "檢查您的錢包", - "new_user_guide.step.power.description": "Follow 使用區塊鏈技術來獎勵活躍用戶和優秀創作者。用戶可以透過 Power Token 獲得更多服務和福利,創作者可以透過提供高品質的內容和服務獲得更多獎勵。", - "new_user_guide.step.rsshub.info": "萬物皆可 RSS. 我們的 [RSSHub](https://github.com/DIYgod/RSSHub) 社群由超過 1,000 名開發者組成,花了六年時間支援了近千個網站,為您提供幾乎所有需要的內容。這些網站包括 X(Twitter)、Instagram、PlayStation、Spotify、Telegram、YouTube 等平台。您也可以撰寫自己的腳本來新增更多網站支援。", + "new_user_guide.step.power.description": "Follow 使用區塊鏈技術來獎勵活躍使用者和優秀創作者。使用者可以透過 Power Token 獲得更多服務和福利,創作者可以透過提供高品質的內容和服務獲得更多獎勵。", + "new_user_guide.step.rsshub.info": "萬物皆可 RSS 我們的 [RSSHub](https://github.com/DIYgod/RSSHub) 社群由超過 1,000 名開發者組成,花了六年時間支援了近千個網站,為您提供幾乎所有需要的內容。這些網站包括 X(Twitter)、Instagram、PlayStation、Spotify、Telegram、YouTube 等平台。您也可以撰寫自己的腳本來新增更多網站支援。", "new_user_guide.step.rsshub.title": "從 RSSHub 訂閱", "new_user_guide.step.shortcuts.description1": "快捷鍵讓您更方便且有效率的使用 Follow。", "new_user_guide.step.shortcuts.description2": "按 <kbd /> 隨時快速查看所有快捷鍵。", @@ -270,12 +270,12 @@ "new_user_guide.step.start_question.content": "您曾經使用過其他 RSS 閱讀器嗎?", "new_user_guide.step.start_question.option1": "是的,我使用過其他 RSS 閱讀器。", "new_user_guide.step.start_question.option2": "沒有,這是我第一次使用 RSS 閱讀器。", - "new_user_guide.step.start_question.title": "使用問卷", - "new_user_guide.step.trending.title": "熱門訂閱", + "new_user_guide.step.start_question.title": "問卷", + "new_user_guide.step.trending.title": "熱門 RSS 摘要", "new_user_guide.step.views.description": "Follow 針對不同類型的內容提供不同的視圖,讓您的使用體驗與原平台一樣出色,甚至更佳。", "new_user_guide.step.views.title": "視圖", - "notify.unfollow_feed": "已取消關注 <FeedItem />", - "notify.unfollow_feed_many": "所有選擇的訂閱已取消關注。", + "notify.unfollow_feed": "已取消跟隨 <FeedItem />", + "notify.unfollow_feed_many": "已取消跟隨正在選擇的 RSS 摘要。", "notify.update_info": "{{app_name}} 已準備好更新!", "notify.update_info_1": "點擊重新啟動", "notify.update_info_2": "點擊重新整理頁面", @@ -287,14 +287,14 @@ "player.forward_10s": "前進 10 秒", "player.full_screen": "全螢幕", "player.mute": "靜音", - "player.open_entry": "打開項目", + "player.open_entry": "開啟條目", "player.pause": "暫停", "player.play": "播放", "player.playback_rate": "播放速率", "player.unmute": "取消靜音", "player.volume": "音量", - "quick_add.placeholder": "在此輸入訂閱源網址已快速訂閱...", - "quick_add.title": "快速訂閱", + "quick_add.placeholder": "在此輸入 RSS 摘要網址已快速跟隨...", + "quick_add.title": "快速跟隨", "register.confirm_password": "確認密碼", "register.email": "電子信箱", "register.label": "創建 {{app_name}} 帳號", @@ -304,56 +304,56 @@ "register.submit": "創建帳號", "resize.tooltip.double_click_to_collapse": "<b>雙擊</b>以摺疊", "resize.tooltip.drag_to_resize": "<b>拖動</b>以調整大小", - "search.empty.no_results": "未找到結果。", - "search.group.entries": "項目", - "search.group.feeds": "摘要", + "search.empty.no_results": "搜尋結果為空", + "search.group.entries": "條目", + "search.group.feeds": "RSS 摘要", "search.options.all": "全部", "search.options.entry": "條目", - "search.options.feed": "訂閱源", + "search.options.feed": "RSS 摘要", "search.options.search_type": "搜尋類型", - "search.placeholder": "搜尋……", + "search.placeholder": "搜尋...", "search.result_count_local_mode": "(本地模式)", - "search.tooltip.local_search": "此搜尋涵蓋本地可用資料。嘗試重新獲取以包含最新資料。", + "search.tooltip.local_search": "此搜尋涵蓋本地可用資料。嘗試重新獲取以獲得更多結果。", "shortcuts.guide.title": "快捷鍵指南", - "sidebar.add_more_feeds": "新增摘要", + "sidebar.add_more_feeds": "新增 RSS 摘要", "sidebar.category_remove_dialog.cancel": "取消", "sidebar.category_remove_dialog.continue": "繼續", - "sidebar.category_remove_dialog.description": "此操作將刪除您的類別,但它包含的摘要將被保留並按網站分組。", + "sidebar.category_remove_dialog.description": "正在刪除類別,但仍保留 RSS 摘要並按網站分組。", "sidebar.category_remove_dialog.error": "刪除類別失敗", "sidebar.category_remove_dialog.success": "類別刪除成功", "sidebar.category_remove_dialog.title": "移除類別", "sidebar.feed_actions.claim": "認領", - "sidebar.feed_actions.claim_feed": "認領摘要", + "sidebar.feed_actions.claim_feed": "認領RSS 摘要", "sidebar.feed_actions.copy_email_address": "複製電子信箱", - "sidebar.feed_actions.copy_feed_id": "複製摘要 ID", - "sidebar.feed_actions.copy_feed_url": "複製摘要 URL", + "sidebar.feed_actions.copy_feed_id": "複製 RSS 摘要 ID", + "sidebar.feed_actions.copy_feed_url": "複製 RSS 摘要 URL", "sidebar.feed_actions.copy_list_id": "複製列表 ID", "sidebar.feed_actions.copy_list_url": "複製列表連結", "sidebar.feed_actions.create_list": "建立列表", "sidebar.feed_actions.edit": "編輯", - "sidebar.feed_actions.edit_feed": "編輯摘要", + "sidebar.feed_actions.edit_feed": "編輯 RSS 摘要", "sidebar.feed_actions.edit_inbox": "編輯收件匣", "sidebar.feed_actions.edit_list": "編輯列表", - "sidebar.feed_actions.feed_owned_by_you": "此摘要由你所擁有", - "sidebar.feed_actions.list_owned_by_you": "該列表歸你所有", + "sidebar.feed_actions.feed_owned_by_you": "此 RSS 摘要由你所擁有", + "sidebar.feed_actions.list_owned_by_you": "此列表由你所擁有", "sidebar.feed_actions.mark_all_as_read": "全部標記為已讀", - "sidebar.feed_actions.navigate_to_feed": "導航到摘要", + "sidebar.feed_actions.navigate_to_feed": "導航到 RSS 摘要", "sidebar.feed_actions.navigate_to_list": "導航到列表", - "sidebar.feed_actions.new_inbox": "新收件匣", - "sidebar.feed_actions.open_feed_in_browser": "使用{{which}}打開這個摘要", + "sidebar.feed_actions.new_inbox": "新建收件匣", + "sidebar.feed_actions.open_feed_in_browser": "在{{which}}開啟 RSS 摘要", "sidebar.feed_actions.open_list_in_browser": "在{{which}}開啟列表", - "sidebar.feed_actions.open_site_in_browser": "使用{{which}}打開這個網站", - "sidebar.feed_actions.reset_feed": "重置訂閱源", - "sidebar.feed_actions.reset_feed_error": "重置訂閱源失敗。", - "sidebar.feed_actions.reset_feed_success": "訂閱源重置成功。", - "sidebar.feed_actions.resetting_feed": "正在重置訂閱源…", - "sidebar.feed_actions.unfollow": "取消關注", - "sidebar.feed_actions.unfollow_feed": "取消關注摘要", - "sidebar.feed_actions.unfollow_feed_many": "取消關注所有選取的訂閱", - "sidebar.feed_actions.unfollow_feed_many_confirm": "確認取消關注所有選取的訂閱嗎?", - "sidebar.feed_actions.unfollow_feed_many_warning": "警告:此操作將取消關注所有選取的訂閱,且無法復原。", - "sidebar.feed_column.context_menu.add_feeds_to_category": "新增摘要到類別", - "sidebar.feed_column.context_menu.add_feeds_to_list": "新增摘要到列表", + "sidebar.feed_actions.open_site_in_browser": "在{{which}}開啟網站", + "sidebar.feed_actions.reset_feed": "重置 RSS 摘要", + "sidebar.feed_actions.reset_feed_error": "重置 RSS 摘要失敗。", + "sidebar.feed_actions.reset_feed_success": "RSS 摘要重置成功。", + "sidebar.feed_actions.resetting_feed": "正在重置 RSS 摘要…", + "sidebar.feed_actions.unfollow": "取消跟隨", + "sidebar.feed_actions.unfollow_feed": "取消跟隨 RSS 摘要", + "sidebar.feed_actions.unfollow_feed_many": "取消跟隨所有選取的 RSS 摘要", + "sidebar.feed_actions.unfollow_feed_many_confirm": "確認取消跟隨所有選取的 RSS 摘要嗎?", + "sidebar.feed_actions.unfollow_feed_many_warning": "警告:此操作將取消跟隨所有選取的 RSS 摘要,且無法復原。", + "sidebar.feed_column.context_menu.add_feeds_to_category": "新增 RSS 摘要到類別", + "sidebar.feed_column.context_menu.add_feeds_to_list": "新增 RSS 摘要到列表", "sidebar.feed_column.context_menu.change_to_other_view": "切換到其他視圖", "sidebar.feed_column.context_menu.create_category": "新增類別", "sidebar.feed_column.context_menu.delete_category": "刪除類別", @@ -361,10 +361,10 @@ "sidebar.feed_column.context_menu.mark_as_read": "標記為已讀", "sidebar.feed_column.context_menu.new_category_modal.category_name": "類別名稱", "sidebar.feed_column.context_menu.new_category_modal.create": "創建", - "sidebar.feed_column.context_menu.rename_category": "重命名類別", + "sidebar.feed_column.context_menu.rename_category": "重新命名類別", "sidebar.feed_column.context_menu.rename_category_error": "重命名類別失敗", - "sidebar.feed_column.context_menu.rename_category_success": "類別重命名成功", - "sidebar.feed_column.context_menu.title": "新增摘要到新類別", + "sidebar.feed_column.context_menu.rename_category_success": "類別重新命名成功", + "sidebar.feed_column.context_menu.title": "新增 RSS 摘要到新類別", "sidebar.select_sort_method": "選擇排序方式", "signin.continue_with": "透過 {{provider}} 登入", "signin.sign_in_to": "登入", @@ -372,22 +372,22 @@ "sync_indicator.offline": "離線", "sync_indicator.synced": "已與伺服器同步", "tip_modal.amount": "金額", - "tip_modal.claim_feed": "認領此摘要", + "tip_modal.claim_feed": "認領此 RSS 摘要", "tip_modal.create_wallet": "免費建立", - "tip_modal.feed_owner": "摘要擁有者", - "tip_modal.low_balance": "您的餘額不足以支付此小費。請調整金額。", + "tip_modal.feed_owner": "RSS 摘要作者", + "tip_modal.low_balance": "您的餘額不足以付款此贊助。請調整贊助金額。", "tip_modal.no_wallet": "您尚未擁有錢包。請創建錢包以進行贊助。", "tip_modal.tip_amount_sent": "已經發送給作者。", "tip_modal.tip_now": "立刻贊助", "tip_modal.tip_sent": "贊助成功!謝謝您的支持", "tip_modal.tip_support": "⭐ 贊助以表示你的支持!", "tip_modal.tip_title": "贊助 Power", - "tip_modal.unclaimed_feed": "目前尚無人認領此摘要。收到的 Power 將安全地保存在區塊鏈合約中,直到被認領為止。", + "tip_modal.unclaimed_feed": "目前尚無人認領此 RSS 摘要。收到的 Power 將安全的存放在區塊鏈合約中,直到被認領為止。", "trending.entry": "熱門條目", - "trending.feed": "熱門動態", + "trending.feed": "熱門 RSS 摘要", "trending.list": "熱門列表", - "trending.user": "熱門用戶", - "user_button.account": "帳戶", + "trending.user": "熱門使用者", + "user_button.account": "帳號", "user_button.achievement": "成就", "user_button.actions": "自動化操作", "user_button.download_desktop_app": "下載桌面應用程式", @@ -409,7 +409,7 @@ "words.confirm": "確認", "words.discover": "發現", "words.email": "電子信箱", - "words.feeds": "摘要", + "words.feeds": "RSS 摘要", "words.import": "匯入", "words.inbox": "收件匣", "words.items": "內容", @@ -430,7 +430,7 @@ "words.title": "標題", "words.transform": "轉換", "words.trending": "趨勢", - "words.undo": "撤銷", + "words.undo": "復原", "words.unread": "未讀", "words.user": "使用者", "words.which.all": "全部", diff --git a/locales/common/zh-TW.json b/locales/common/zh-TW.json index e055cb6982..28b7d0783b 100644 --- a/locales/common/zh-TW.json +++ b/locales/common/zh-TW.json @@ -4,7 +4,7 @@ "close": "關閉", "confirm": "確認", "ok": "確定", - "quantifier.piece": "", + "quantifier.piece": "條", "retry": "重試", "space": " ", "time.last_night": "昨晚", @@ -12,7 +12,7 @@ "time.today": "今天", "time.yesterday": "昨天", "tips.load-lng-error": "語言套件讀取失敗", - "words.actions": "自動化操作", + "words.actions": "操作", "words.ago": "前", "words.all": "全部", "words.back": "返回", @@ -23,10 +23,10 @@ "words.edit": "編輯", "words.entry": "條目", "words.expand": "展開", - "words.follow": "關注", - "words.id": "識別碼", - "words.items_one": "項目", - "words.items_other": "項目", + "words.follow": "跟隨", + "words.id": "ID", + "words.items_one": "內容", + "words.items_other": "內容", "words.local": "本地", "words.manage": "管理", "words.record": "記錄", diff --git a/locales/errors/zh-TW.json b/locales/errors/zh-TW.json index 1ebf09dff5..d834c91ab9 100644 --- a/locales/errors/zh-TW.json +++ b/locales/errors/zh-TW.json @@ -7,7 +7,7 @@ "1003": "邀請無效", "1004": "無權限", "1005": "內部錯誤", - "1100": "超出預覽版訂閲源最大數量限制", + "1100": "超出預覽版 RSS 摘要最大數量限制", "1101": "超出預覽版訂閲列表最大數量限制", "1102": "超出預覽版收件箱最大數量限制", "1103": "預覽版無權限", @@ -22,10 +22,10 @@ "4000": "已兌換", "4001": "使用者錢包錯誤", "4002": "餘額不足", - "4003": "動態可提領餘額不足", + "4003": "可提領餘額不足", "4004": "目標使用者錢包錯誤", "4005": "每日 power 計算中", - "4006": "無效助力數量", + "4006": "無效加成數量", "4010": "空投不符合資格", "4011": "空投正在發送", "4012": "空投已發送", @@ -42,7 +42,7 @@ "8000": "找不到列表", "8001": "沒有權限存取列表", "8002": "超出列表限制", - "8003": "此來源已經加入列表", + "8003": "此 RSS 摘要已經加入列表", "9000": "尚未完成成就", "9001": "成就已領取", "9002": "成就審核中", @@ -51,5 +51,12 @@ "10001": "收件匣已存在", "10002": "超過收件匣限制", "10003": "收件匣無權限", - "12000": "超過自動化規則限制" + "12000": "超過自動化操作限制", + "13000": "未找到 RSSHub 路由", + "13001": "你不是此 RSSHub 實例伺服器建立者", + "13002": "RSSHub 正在使用中", + "13003": "未找到 RSSHub", + "13004": "超過 RSSHub 使用者限制", + "13005": "未找到 RSSHub 購買記錄", + "13006": "RSSHub 配置無效" } diff --git a/locales/external/zh-TW.json b/locales/external/zh-TW.json index 14af2c5435..bd7ebe7e67 100644 --- a/locales/external/zh-TW.json +++ b/locales/external/zh-TW.json @@ -1,26 +1,26 @@ { "copied_link": "連結已複製到剪貼簿", - "feed.actions.followed": "已訂閲", + "feed.actions.followed": "已跟隨", "feed.copy_feed_url": "複製鏈接", - "feed.feeds_one": "訂閲源", - "feed.feeds_other": "訂閲源", - "feed.follow_to_view_all": "跟隨以查看所有 {{count}} 個訂閲源...", - "feed.follower_one": "追隨者", - "feed.follower_other": "追隨者", + "feed.feeds_one": "RSS 摘要", + "feed.feeds_other": "RSS 摘要", + "feed.follow_to_view_all": "跟隨以查看所有的 {{count}} 個 RSS 摘要...", + "feed.follower_one": "跟隨者", + "feed.follower_other": "跟隨者", "feed.followsAndFeeds": "在 {{appName}} 上有 {{subscriptionCount}} 個 {{subscriptionNoun}} 和 {{feedsCount}} 個 {{feedsNoun}}", "feed.followsAndReads": "在 {{appName}} 上有 {{subscriptionCount}} 個 {{subscriptionNoun}},{{readCount}} 次近期 {{readNoun}}", "feed.madeby": "作者:", "feed.preview": "預覽", "feed.read_one": "閱讀", - "feed.read_other": "閱讀數", + "feed.read_other": "閱讀", "feed.view_feed_url": "查看鏈接", - "feed_item.claimed_by_owner": "訂閱源擁有者", - "feed_item.claimed_by_unknown": "未知擁有者", - "feed_item.claimed_by_you": "訂閱源由你認領", - "feed_item.claimed_feed": "已認領訂閱源", + "feed_item.claimed_by_owner": "RSS 摘要作者", + "feed_item.claimed_by_unknown": "未知作者", + "feed_item.claimed_by_you": "RSS 摘要由你認領", + "feed_item.claimed_feed": "已認領 RSS 摘要", "feed_item.claimed_list": "已認領列表", - "feed_item.error_since": "訂閱源失效:", - "feed_item.not_publicly_visible": "在您的個人頁面上不公開顯示", + "feed_item.error_since": "RSS 摘要失效:", + "feed_item.not_publicly_visible": "在您的個人頁面上隱藏", "header.app": "應用程式", "header.download": "下載", "invitation.activate": "啟用", @@ -30,7 +30,7 @@ "invitation.earlyAccess": "Follow 目前處於搶先體驗階段,需要邀請碼才能使用。", "invitation.earlyAccessMessage": "😰 抱歉,Follow 目前處於搶先體驗階段,需要邀請碼才能使用。", "invitation.generateButton": "產生新邀請碼", - "invitation.generateCost": "您可以花費 {{INVITATION_PRICE}} 點 Power 為您的朋友產生邀請碼。", + "invitation.generateCost": "您可以花費 {{INVITATION_PRICE}} Power 為您的朋友產生邀請碼。", "invitation.getCodeMessage": "您可以通過以下方式獲取邀請碼:", "invitation.title": "邀請碼", "login.backToWebApp": "返回網頁應用程式", diff --git a/locales/native/zh-TW.json b/locales/native/zh-TW.json index a152940fb8..b9346ad02d 100644 --- a/locales/native/zh-TW.json +++ b/locales/native/zh-TW.json @@ -7,15 +7,15 @@ "contextMenu.cut": "剪下", "contextMenu.inspect": "檢查元素", "contextMenu.learnSpelling": "學習拼寫", - "contextMenu.lookUpSelection": "查找選擇", + "contextMenu.lookUpSelection": "在字典中查詢", "contextMenu.openImageInBrowser": "在瀏覽器中開啟圖片", "contextMenu.openLinkInBrowser": "在瀏覽器中開啟連結", "contextMenu.paste": "貼上", "contextMenu.saveImage": "儲存圖片", - "contextMenu.saveImageAs": "圖片另存新檔...", - "contextMenu.saveLinkAs": "連結另存新檔...", + "contextMenu.saveImageAs": "圖片另存為...", + "contextMenu.saveLinkAs": "連結另存為...", "contextMenu.saveVideo": "儲存影片", - "contextMenu.saveVideoAs": "影片另存新檔...", + "contextMenu.saveVideoAs": "影片另存為...", "contextMenu.searchWithGoogle": "使用 Google 搜尋", "contextMenu.selectAll": "全選", "contextMenu.services": "服務", @@ -39,7 +39,7 @@ "menu.discover": "探索", "menu.edit": "編輯", "menu.file": "檔案", - "menu.followReleases": "關注版本發佈", + "menu.followReleases": "Follow 發佈", "menu.forceReload": "強制重新載入", "menu.front": "置於最上層", "menu.help": "說明", diff --git a/locales/settings/zh-TW.json b/locales/settings/zh-TW.json index 2903e50b68..636f825d5d 100644 --- a/locales/settings/zh-TW.json +++ b/locales/settings/zh-TW.json @@ -18,14 +18,14 @@ "actions.action_card.feed_options.entry_media_length": "條目媒體數量", "actions.action_card.feed_options.entry_title": "條目標題", "actions.action_card.feed_options.entry_url": "條目 URL", - "actions.action_card.feed_options.feed_category": "訂閱源分類", - "actions.action_card.feed_options.feed_title": "訂閱源標題", - "actions.action_card.feed_options.feed_url": "摘要 URL", + "actions.action_card.feed_options.feed_category": "RSS 摘要分類", + "actions.action_card.feed_options.feed_title": "RSS 摘要標題", + "actions.action_card.feed_options.feed_url": "RSS 摘要 URL", "actions.action_card.feed_options.site_url": "網站 URL", "actions.action_card.feed_options.subscription_view": "訂閱視圖", "actions.action_card.field": "欄位", "actions.action_card.from": "從", - "actions.action_card.generate_summary": "使用 AI 生成摘要", + "actions.action_card.generate_summary": "使用 AI 產生總結", "actions.action_card.name": "名稱", "actions.action_card.new_entry_notification": "新條目通知", "actions.action_card.no_translation": "無翻譯", @@ -46,10 +46,10 @@ "actions.action_card.translate_into": "翻譯成", "actions.action_card.value": "值", "actions.action_card.webhooks": "Webhooks", - "actions.action_card.when_feeds_match": "當摘要匹配時…", + "actions.action_card.when_feeds_match": "當 RSS 摘要匹配時…", "actions.newRule": "新增規則", "actions.save": "儲存", - "actions.saveSuccess": "🎉 規則已儲存。", + "actions.saveSuccess": "🎉 規則已儲存", "actions.sidebar_title": "自動化操作", "actions.title": "自動化操作", "appearance.code_highlight_theme": "語法突顯主題", @@ -97,20 +97,21 @@ "appearance.zen_mode.description": "禪定模式是一種防干擾的閱讀模式,你可以專注於內容而不受其他干擾。啟用禪定模式將會隱藏側邊欄。", "appearance.zen_mode.label": "禪定模式", "common.give_star": "<HeartIcon />喜歡我們的產品嗎? <Link>在 GitHub 上給我們 star 吧!</Link>", + "customizeToolbar.title": "自訂工具欄", "data_control.app_cache_limit.description": "程式快取大小的上限。一旦快取達到此上限,最早的項目將被刪除以釋放空間。", "data_control.app_cache_limit.label": "程式快取限制", "data_control.clean_cache.button": "清理快取", "data_control.clean_cache.description": "清理程式快取以釋放空間。", "data_control.clean_cache.description_web": "清理網頁應用服務快取以釋放空間。", - "feeds.claimTips": "要認領您的 feed 並接收贊助,請在您的訂閱列表中右鍵點擊該 feed,然後選擇「認領」。", - "feeds.noFeeds": "沒有已認領的訂閱源", + "feeds.claimTips": "要認領您的 RSS 摘要並接收贊助,請在您的訂閱列表中右鍵點擊該 RSS 摘要,然後選擇「認領」。", + "feeds.noFeeds": "沒有已認領的 RSS 摘要", "feeds.tableHeaders.name": "名稱", "feeds.tableHeaders.subscriptionCount": "訂閱數", "feeds.tableHeaders.tipAmount": "收到的贊助", "general.app": "App", "general.auto_expand_long_social_media.description": "自動擴展包含長文字的社群媒體條目。", "general.auto_expand_long_social_media.label": "拓展長社群媒體", - "general.auto_group.description": "自動依照網址域名分類訂閱源。", + "general.auto_group.description": "自動依照網址域名分類 RSS 摘要。", "general.auto_group.label": "自動分類", "general.cache": "快取", "general.data": "資料", @@ -123,7 +124,7 @@ "general.export.folder_mode.label": "資料夾模式", "general.export.folder_mode.option.category": "類別", "general.export.folder_mode.option.view": "視圖", - "general.export.label": "匯出訂閱源", + "general.export.label": "匯出 RSS 摘要", "general.export.rsshub_url.description": "RSSHub 路由的預設基礎 URL,留空則使用 https://rsshub.app。", "general.export.rsshub_url.label": "RSSHub URL", "general.export_database.button": "匯出", @@ -156,8 +157,8 @@ "general.rebuild_database.warning.line2": "您確定要繼續嗎?", "general.send_anonymous_data.description": "選擇傳送匿名使用資料,您將幫助改善 Follow 的整體使用體驗。", "general.send_anonymous_data.label": "傳送匿名資料", - "general.show_quick_timeline.description": "在訂閱源列表頂部顯示快速時間軸。", - "general.show_quick_timeline.label": "顯示訂閱源列表時間軸", + "general.show_quick_timeline.description": "在 RSS 摘要列表頂部顯示快速時間軸。", + "general.show_quick_timeline.label": "顯示 RSS 摘要列表時間軸", "general.show_unread_on_launch.description": "啟動時顯示未讀內容", "general.show_unread_on_launch.label": "啟動時顯示未讀內容", "general.sidebar": "側邊欄", @@ -201,8 +202,8 @@ "integration.readwise.enable.description": "顯示「儲存到 Readwise」按钮(如果可用)。", "integration.readwise.enable.label": "啟用", "integration.readwise.title": "Readwise", - "integration.readwise.token.description": "您可以在這裡取得", - "integration.readwise.token.label": "Readwise 令牌", + "integration.readwise.token.description": "您可以在這裡取得 Token", + "integration.readwise.token.label": "Readwise Token", "integration.sidebar_title": "整合功能", "integration.tip": "提示:您的敏感資料儲存於本機,不會上傳到伺服器。", "integration.title": "整合功能", @@ -244,19 +245,19 @@ "lists.fee.description": "其他人訂閱此列表需要支付的費用。", "lists.fee.label": "費用", "lists.feeds.actions": "操作", - "lists.feeds.add.error": "將訂閱源新增到列表失敗。", + "lists.feeds.add.error": "將 RSS 摘要新增到列表失敗。", "lists.feeds.add.label": "新增", - "lists.feeds.add.success": "訂閱源已新增到列表。", - "lists.feeds.delete.error": "從列表中移除訂閱源失敗。", - "lists.feeds.delete.success": "訂閱源已從列表中移除。", - "lists.feeds.id": "訂閱源 ID", - "lists.feeds.label": "訂閱源", - "lists.feeds.manage": "管理訂閱源", + "lists.feeds.add.success": "RSS 摘要已新增到列表。", + "lists.feeds.delete.error": "從列表中移除 RSS 摘要失敗。", + "lists.feeds.delete.success": "RSS 摘要已從列表中移除。", + "lists.feeds.id": "RSS 摘要 ID", + "lists.feeds.label": "RSS 摘要", + "lists.feeds.manage": "管理 RSS 摘要", "lists.feeds.owner": "擁有者", - "lists.feeds.search": "搜尋訂閱源", + "lists.feeds.search": "搜尋 RSS 摘要", "lists.feeds.title": "標題", "lists.image": "圖片", - "lists.info": "列表是您可以分享或出售給他人訂閱的訂閱源集合。訂閱者將同步並訪問列表中的所有訂閱源。", + "lists.info": "列表是您可以分享或出售給他人訂閱的 RSS 摘要集合。訂閱者將同步並訪問列表中的所有 RSS 摘要。", "lists.noLists": "沒有列表", "lists.submit": "送出", "lists.subscriptions": "訂閱", @@ -274,20 +275,20 @@ "profile.email.unverified": "尚未驗證", "profile.email.verification_sent": "驗證信件已發送", "profile.email.verified": "已驗證", - "profile.handle.description": "唯一識別碼", - "profile.handle.label": "唯一識別碼", + "profile.handle.description": "你的獨一無二名稱。", + "profile.handle.label": "名稱", "profile.link_social.authentication": "驗證", "profile.link_social.description": "您目前只能連結具有相同電子信箱的社群帳號。", "profile.link_social.link": "連結", "profile.link_social.unlink.success": "已中斷社群帳號連結。", - "profile.name.description": "您的公開顯示名稱。", + "profile.name.description": "你的公開顯示名稱。", "profile.name.label": "顯示名稱", "profile.new_password.label": "新密碼", "profile.password.label": "密碼", "profile.reset_password_mail_sent": "重設密碼信件已發送。", "profile.sidebar_title": "個人資料", "profile.submit": "送出", - "profile.title": "配置文件設定", + "profile.title": "個人資料設定", "profile.updateSuccess": "個人資料已更新。", "profile.update_password_success": "密碼已更新。", "rsshub.addModal.access_key_label": "存取金鑰(選填)", @@ -300,23 +301,25 @@ "rsshub.table.description": "描述", "rsshub.table.edit": "編輯", "rsshub.table.inuse": "使用中", + "rsshub.table.official": "官方", "rsshub.table.owner": "建立者", "rsshub.table.price": "每月價格", "rsshub.table.unlimited": "無限制", "rsshub.table.use": "使用", "rsshub.table.userCount": "使用者數量", "rsshub.table.userLimit": "使用者限制", + "rsshub.table.yours": "你的", "rsshub.useModal.about": "關於此實例伺服器", "rsshub.useModal.month": "個月", - "rsshub.useModal.months_label": "你想訂閱的月份數量", - "rsshub.useModal.purchase_expires_at": "你已訂閱此實例伺服器,到期時間為", + "rsshub.useModal.months_label": "你想購買的月份數量", + "rsshub.useModal.purchase_expires_at": "你已購買此實例伺服器,到期時間為", "rsshub.useModal.title": "RSSHub 實例伺服器", "rsshub.useModal.useWith": "使用 {{amount}} <Power />", "titles.about": "關於", "titles.actions": "自動化操作", "titles.appearance": "外觀", "titles.data_control": "資料管理", - "titles.feeds": "訂閱源", + "titles.feeds": "RSS 摘要", "titles.general": "一般", "titles.integration": "整合", "titles.invitations": "邀請", @@ -324,10 +327,10 @@ "titles.power": "Power", "titles.profile": "個人資料", "titles.shortcuts": "快捷鍵", - "wallet.address.title": "您的地址", + "wallet.address.title": "錢包地址", "wallet.balance.activePoints": "活躍度", "wallet.balance.dailyReward": "每日獎勵", - "wallet.balance.title": "您的餘額", + "wallet.balance.title": "餘額", "wallet.balance.withdrawable": "可提取", "wallet.balance.withdrawableTooltip": "可提取的 Power 包括您收到的贊助和儲值的 Power。", "wallet.claim.button.claim": "領取每日 Power", @@ -354,11 +357,11 @@ "wallet.rewardDescription.title": "獎勵描述", "wallet.rewardDescription.total": "每日獎池", "wallet.sidebar_title": "Power", - "wallet.transactions.amount": "數量", + "wallet.transactions.amount": "額度", "wallet.transactions.date": "日期", "wallet.transactions.description": "部分交易會收取 {{percentage}}% 的平臺手續費用於 Follow 的發展,詳情請查看區塊鏈交易記錄。", "wallet.transactions.from": "發送者", - "wallet.transactions.more": "通過區塊鏈瀏覽器查看更多交易", + "wallet.transactions.more": "通過區塊鏈瀏覽器查看更多交易…", "wallet.transactions.noTransactions": "無交易紀錄", "wallet.transactions.title": "交易紀錄", "wallet.transactions.to": "接收者", @@ -373,12 +376,12 @@ "wallet.transactions.types.withdraw": "提領", "wallet.transactions.you": "您", "wallet.withdraw.addressLabel": "您的以太坊地址", - "wallet.withdraw.amountLabel": "數量", - "wallet.withdraw.availableBalance": "您的錢包中有 <Balance></Balance> 可提取 Power。", - "wallet.withdraw.button": "提取", - "wallet.withdraw.error": "提取失敗:{{error}}", - "wallet.withdraw.modalTitle": "提取 Power", + "wallet.withdraw.amountLabel": "額度", + "wallet.withdraw.availableBalance": "您的錢包中有 <Balance></Balance> Power 可提領。", + "wallet.withdraw.button": "提領", + "wallet.withdraw.error": "提領失敗:{{error}}", + "wallet.withdraw.modalTitle": "提領 Power", "wallet.withdraw.submitButton": "送出", - "wallet.withdraw.success": "提取成功!", - "wallet.withdraw.toRss3Label": "提取為 RSS3" + "wallet.withdraw.success": "提領成功!", + "wallet.withdraw.toRss3Label": "提領為 RSS3" } diff --git a/locales/shortcuts/zh-TW.json b/locales/shortcuts/zh-TW.json index e58edcae39..04c1b17943 100644 --- a/locales/shortcuts/zh-TW.json +++ b/locales/shortcuts/zh-TW.json @@ -15,8 +15,8 @@ "keys.entry.tip": "贊助", "keys.entry.toggleRead": "切換標記為已讀/未讀", "keys.entry.toggleStarred": "切換收藏/取消收藏", - "keys.entry.tts": "播放語音朗讀", - "keys.feeds.add": "新增訂閱", + "keys.entry.tts": "播放文字轉語音", + "keys.feeds.add": "新增 RSS 摘要", "keys.feeds.switchBetweenViews": "在類別之間切換", "keys.feeds.switchToView": "切換到指定類別", "keys.layout.showShortcuts": "顯示/隱藏快捷鍵", @@ -27,7 +27,7 @@ "keys.type.audio": "音訊", "keys.type.entries": "條目列表", "keys.type.entry": "條目", - "keys.type.feeds": "訂閲源", + "keys.type.feeds": "RSS 摘要", "keys.type.layout": "佈局", "keys.type.misc": "雜項", "sidebar_title": "快捷鍵" From f3158c423075c38fe7c3a6d4b2699c69aa251447 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Fri, 10 Jan 2025 13:12:34 +0800 Subject: [PATCH 66/70] chore: improve props handling in CommandActionButton (#2526) * fix: warning and error in console * update --- .../components/ui/button/CommandActionButton.tsx | 13 +++---------- apps/renderer/src/modules/entry-column/grid.tsx | 4 ++-- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/renderer/src/components/ui/button/CommandActionButton.tsx b/apps/renderer/src/components/ui/button/CommandActionButton.tsx index e2f6a254e6..d3ae89e7fa 100644 --- a/apps/renderer/src/components/ui/button/CommandActionButton.tsx +++ b/apps/renderer/src/components/ui/button/CommandActionButton.tsx @@ -11,18 +11,11 @@ export interface CommandActionButtonProps extends ActionButtonProps { } export const CommandActionButton = forwardRef<HTMLButtonElement, CommandActionButtonProps>( (props, ref) => { - const command = useCommand(props.commandId) + const { commandId, ...rest } = props + const command = useCommand(commandId) if (!command) return null const { icon, label } = command - return ( - <ActionButton - ref={ref} - {...props} - icon={icon} - onClick={props.onClick} - tooltip={label.title} - /> - ) + return <ActionButton ref={ref} icon={icon} tooltip={label.title} {...rest} /> }, ) diff --git a/apps/renderer/src/modules/entry-column/grid.tsx b/apps/renderer/src/modules/entry-column/grid.tsx index 5ccf4cc8e5..37d6118a5b 100644 --- a/apps/renderer/src/modules/entry-column/grid.tsx +++ b/apps/renderer/src/modules/entry-column/grid.tsx @@ -298,12 +298,12 @@ const VirtualGridImpl: FC< {columnVirtualizer.getVirtualItems().map((virtualColumn) => ( <div ref={columnVirtualizer.measureElement} - key={virtualColumn.index} + key={virtualColumn.key} + data-index={virtualColumn.index} className="absolute left-0 top-0" style={{ height: `${virtualRow.size}px`, width: `${virtualColumn.size}px`, - transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`, }} > From f9c7e55ef9411cb5021ffd81bf6aeb9bfe132ab3 Mon Sep 17 00:00:00 2001 From: DIYgod <i@diygod.me> Date: Fri, 10 Jan 2025 13:47:35 +0800 Subject: [PATCH 67/70] feat: full width rsshub page --- .../pages/(main)/(layer)/(subview)/rsshub/index.tsx | 10 +++++++--- locales/settings/en.json | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx b/apps/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx index 03a0a129d6..aceab81ed7 100644 --- a/apps/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx +++ b/apps/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx @@ -33,12 +33,12 @@ export function Component() { const list = useAuthQuery(Queries.rsshub.list()) return ( - <div className="relative flex w-full max-w-4xl flex-col items-center gap-8 px-4 pb-8 lg:pb-4"> + <div className="relative flex w-full flex-col items-center gap-8 px-4 pb-8 lg:px-20 lg:pb-4"> <div className="center"> <img src={RSSHubIcon} className="mt-12 size-20" /> </div> <div className="text-2xl font-bold">{t("words.rsshub", { ns: "common" })}</div> - <div className="text-sm">{t("rsshub.description")}</div> + <div className="max-w-4xl text-sm">{t("rsshub.description")}</div> <Button onClick={() => present({ @@ -152,7 +152,11 @@ function List({ data }: { data?: RSSHubModel[] }) { </TableCell> <TableCell className="text-right">{instance.userCount}</TableCell> <TableCell className="text-right"> - {instance.userLimit || t("rsshub.table.unlimited")} + {instance.userLimit === null + ? t("rsshub.table.unlimited") + : instance.userLimit > 1 + ? instance.userLimit + : t("rsshub.table.private")} </TableCell> <TableCell className="text-right"> <div className="flex w-max items-center gap-2"> diff --git a/locales/settings/en.json b/locales/settings/en.json index c53a76421d..c4d88e0961 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -304,6 +304,7 @@ "rsshub.table.official": "Official", "rsshub.table.owner": "Owner", "rsshub.table.price": "Monthly Price", + "rsshub.table.private": "Private", "rsshub.table.unlimited": "Unlimited", "rsshub.table.use": "Use", "rsshub.table.userCount": "User Count", From f3f86b7c0ddc1cae0c26246bc8e91cbb79e64189 Mon Sep 17 00:00:00 2001 From: Innei <tukon479@gmail.com> Date: Fri, 10 Jan 2025 14:40:21 +0800 Subject: [PATCH 68/70] fix: adjust padding based on FeedViewType in EntryList component Signed-off-by: Innei <tukon479@gmail.com> --- apps/renderer/src/modules/entry-column/list.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/renderer/src/modules/entry-column/list.tsx b/apps/renderer/src/modules/entry-column/list.tsx index 3447035057..94c95ba76a 100644 --- a/apps/renderer/src/modules/entry-column/list.tsx +++ b/apps/renderer/src/modules/entry-column/list.tsx @@ -1,6 +1,6 @@ import { EmptyIcon } from "@follow/components/icons/empty.jsx" import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" -import type { FeedViewType } from "@follow/constants" +import { FeedViewType } from "@follow/constants" import { useTypeScriptHappyCallback } from "@follow/hooks" import { LRUCache } from "@follow/utils/lru-cache" import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual" @@ -272,7 +272,11 @@ export const EntryList: FC<EntryListProps> = memo( className="absolute left-0 top-0 w-full will-change-transform" style={{ transform, - paddingTop: sticky ? "3.5rem" : undefined, + paddingTop: sticky + ? view === FeedViewType.SocialMedia + ? "3.5rem" + : "1.5rem" + : undefined, }} ref={rowVirtualizer.measureElement} data-index={virtualRow.index} From e628e7a253b4581992150781afd1659b29ebf1fe Mon Sep 17 00:00:00 2001 From: DIYgod <i@diygod.me> Date: Fri, 10 Jan 2025 14:57:46 +0800 Subject: [PATCH 69/70] fix: share button in electron --- apps/renderer/src/hooks/biz/useEntryActions.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/renderer/src/hooks/biz/useEntryActions.tsx b/apps/renderer/src/hooks/biz/useEntryActions.tsx index a2fea4aa1c..ea523816ac 100644 --- a/apps/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/renderer/src/hooks/biz/useEntryActions.tsx @@ -1,5 +1,6 @@ import { isMobile } from "@follow/components/hooks/useMobile.js" import { FeedViewType } from "@follow/constants" +import { IN_ELECTRON } from "@follow/shared/constants" import { useCallback, useMemo } from "react" import { useShowAISummary } from "~/atoms/ai-summary" @@ -186,7 +187,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee { id: COMMAND_ID.entry.share, onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]), - hide: !entry?.entries.url || !("share" in navigator), + hide: !entry?.entries.url || !("share" in navigator || IN_ELECTRON), shortcut: shortcuts.entry.share.key, }, { From 6a88f054581182a1b1a86bc266caecdf35cb1d60 Mon Sep 17 00:00:00 2001 From: DIYgod <i@diygod.me> Date: Fri, 10 Jan 2025 15:08:14 +0800 Subject: [PATCH 70/70] docs: update changelog --- changelog/0.3.1.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog/0.3.1.md b/changelog/0.3.1.md index c1e591406d..dfc530741d 100644 --- a/changelog/0.3.1.md +++ b/changelog/0.3.1.md @@ -9,6 +9,7 @@ - **Podcast Player**: Re-designed the podcast player in mobile to be more user-friendly. ![Podcast Player](https://github.com/RSSNext/assets/blob/8f778dac8bb2e765acab2157497e4a77a60c5a0b/mobile-audio-player.png?raw=true) -- **Social View Action**: Redesigned the toolbar style in the social view. Now, the toolbar no longer jitters or gets obstructed when hovering over entries. +- **Social Media View Action**: Redesigned the toolbar style in the social media view. Now, the toolbar no longer jitters or gets obstructed when hovering over entries. + ![Social Media View Action](https://github.com/RSSNext/assets/blob/main/social-media-actions.png?raw=true) ## Bug Fixes