From 203c253522be14016b335ae3e8598f39de809eb6 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 21 Jan 2025 00:10:24 +0800 Subject: [PATCH] feat(rn-settings): impl gerenate settings --- apps/mobile/app.config.ts | 1 + apps/mobile/package.json | 1 + apps/mobile/src/atoms/settings/general.ts | 44 +++++ .../src/atoms/settings/internal/helper.ts | 160 +++++++++++++++ .../src/components/common/ThemedBlurView.tsx | 2 +- .../components/ui/dropdown/DropdownMenu.tsx | 1 + apps/mobile/src/components/ui/form/Select.tsx | 59 +++--- apps/mobile/src/components/ui/form/Switch.tsx | 23 ++- .../src/components/ui/grouped/GroupedList.tsx | 44 ++++- .../src/components/ui/switch/Switch.tsx | 182 ++++++++++++++++++ .../mobile/src/interfaces/settings/general.ts | 20 ++ apps/mobile/src/lib/language.ts | 33 ++++ .../src/modules/settings/SettingsList.tsx | 15 +- .../src/modules/settings/routes/About.tsx | 2 +- .../src/modules/settings/routes/General.tsx | 137 ++++++++++++- .../src/screens/(stack)/(tabs)/_layout.tsx | 5 +- apps/mobile/src/theme/navigation.ts | 2 + packages/hooks/exports.ts | 1 + pnpm-lock.yaml | 40 ++-- 19 files changed, 701 insertions(+), 71 deletions(-) create mode 100644 apps/mobile/src/atoms/settings/general.ts create mode 100644 apps/mobile/src/atoms/settings/internal/helper.ts create mode 100644 apps/mobile/src/components/ui/switch/Switch.tsx create mode 100644 apps/mobile/src/interfaces/settings/general.ts create mode 100644 apps/mobile/src/lib/language.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ea2d31647a..35636dac38 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -50,6 +50,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ favicon: iconPath, }, plugins: [ + "expo-localization", [ "expo-router", { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e021e9862d..bf99768840 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -50,6 +50,7 @@ "expo-image": "~2.0.3", "expo-linear-gradient": "~14.0.1", "expo-linking": "~7.0.3", + "expo-localization": "~16.0.1", "expo-router": "4.0.11", "expo-secure-store": "^14.0.1", "expo-sharing": "~13.0.0", diff --git a/apps/mobile/src/atoms/settings/general.ts b/apps/mobile/src/atoms/settings/general.ts new file mode 100644 index 0000000000..756503704d --- /dev/null +++ b/apps/mobile/src/atoms/settings/general.ts @@ -0,0 +1,44 @@ +import type { GeneralSettings } from "@/src/interfaces/settings/general" + +import { createSettingAtom } from "./internal/helper" + +const createDefaultSettings = (): GeneralSettings => ({ + // App + + language: "en", + translationLanguage: "zh-CN", + + // Data control + + sendAnonymousData: true, + + autoGroup: true, + + // view + unreadOnly: true, + // mark unread + scrollMarkUnread: true, + + renderMarkUnread: false, + // UX + groupByDate: true, + autoExpandLongSocialMedia: false, + + // Secure + jumpOutLinkWarn: true, + // TTS + voice: "en-US-AndrewMultilingualNeural", +}) + +export const { + useSettingKey: useGeneralSettingKey, + useSettingSelector: useGeneralSettingSelector, + useSettingKeys: useGeneralSettingKeys, + setSetting: setGeneralSetting, + clearSettings: clearGeneralSettings, + initializeDefaultSettings: initializeDefaultGeneralSettings, + getSettings: getGeneralSettings, + useSettingValue: useGeneralSettingValue, + + settingAtom: __generalSettingAtom, +} = createSettingAtom("general", createDefaultSettings) diff --git a/apps/mobile/src/atoms/settings/internal/helper.ts b/apps/mobile/src/atoms/settings/internal/helper.ts new file mode 100644 index 0000000000..d6d37fce4e --- /dev/null +++ b/apps/mobile/src/atoms/settings/internal/helper.ts @@ -0,0 +1,160 @@ +import { useRefValue } from "@follow/hooks" +import { createAtomHooks } from "@follow/utils" +import type { SetStateAction, WritableAtom } from "jotai" +import { atom as jotaiAtom, useAtomValue } from "jotai" +import { atomWithStorage, selectAtom } from "jotai/utils" +import { useMemo } from "react" +import { shallow } from "zustand/shallow" + +import { JotaiPersistSyncStorage } from "@/src/lib/jotai" + +const getStorageNS = (settingKey: string) => `follow-rn-${settingKey}` +type Nullable = T | null | undefined + +export const createSettingAtom = ( + settingKey: string, + createDefaultSettings: () => T, +) => { + const atom = atomWithStorage( + getStorageNS(settingKey), + createDefaultSettings(), + JotaiPersistSyncStorage, + { + getOnInit: true, + }, + ) as WritableAtom], void> + + const [, , useSettingValue, , getSettings, setSettings] = createAtomHooks(atom) + + const initializeDefaultSettings = () => { + const currentSettings = getSettings() + const defaultSettings = createDefaultSettings() + if (typeof currentSettings !== "object") setSettings(defaultSettings) + const newSettings = { ...defaultSettings, ...currentSettings } + setSettings(newSettings) + } + + const selectAtomCacheMap = {} as Record, any> + + const noopAtom = jotaiAtom(null) + + const useMaybeSettingKey = >(key: Nullable) => { + // @ts-expect-error + let selectedAtom: Record[T] | null = null + if (key) { + selectedAtom = selectAtomCacheMap[key] + if (!selectedAtom) { + selectedAtom = selectAtom(atom, (s) => s[key]) + selectAtomCacheMap[key] = selectedAtom + } + } else { + selectedAtom = noopAtom + } + + return useAtomValue(selectedAtom) as ReturnType[T] + } + + const useSettingKey = >(key: T) => { + return useMaybeSettingKey(key) as ReturnType[T] + } + + function useSettingKeys< + T extends keyof ReturnType, + K1 extends T, + K2 extends T, + K3 extends T, + K4 extends T, + K5 extends T, + K6 extends T, + K7 extends T, + K8 extends T, + K9 extends T, + K10 extends T, + >(keys: [K1, K2?, K3?, K4?, K5?, K6?, K7?, K8?, K9?, K10?]) { + return [ + useMaybeSettingKey(keys[0]), + useMaybeSettingKey(keys[1]), + useMaybeSettingKey(keys[2]), + useMaybeSettingKey(keys[3]), + useMaybeSettingKey(keys[4]), + useMaybeSettingKey(keys[5]), + useMaybeSettingKey(keys[6]), + useMaybeSettingKey(keys[7]), + useMaybeSettingKey(keys[8]), + useMaybeSettingKey(keys[9]), + ] as [ + ReturnType[K1], + ReturnType[K2], + ReturnType[K3], + ReturnType[K4], + ReturnType[K5], + ReturnType[K6], + ReturnType[K7], + ReturnType[K8], + ReturnType[K9], + ReturnType[K10], + ] + } + + const useSettingSelector = < + T extends keyof ReturnType, + S extends ReturnType, + R = S[T], + >( + selector: (s: S) => R, + ): R => { + const stableSelector = useRefValue(selector) + + return useAtomValue( + // @ts-expect-error + useMemo(() => selectAtom(atom, stableSelector.current, shallow), [stableSelector]), + ) + } + + const setSetting = >( + key: K, + value: ReturnType[K], + ) => { + const updated = Date.now() + setSettings({ + ...getSettings(), + [key]: value, + + updated, + }) + } + + const clearSettings = () => { + setSettings(createDefaultSettings()) + } + + Object.defineProperty(useSettingValue, "select", { + value: useSettingSelector, + }) + + return { + useSettingKey, + useSettingSelector, + setSetting, + clearSettings, + initializeDefaultSettings, + + useSettingValue, + useSettingKeys, + getSettings, + + settingAtom: atom, + } as { + useSettingKey: typeof useSettingKey + useSettingSelector: typeof useSettingSelector + setSetting: typeof setSetting + clearSettings: typeof clearSettings + initializeDefaultSettings: typeof initializeDefaultSettings + useSettingValue: typeof useSettingValue & { + select: T>>(key: T) => Awaited + } + useSettingKeys: typeof useSettingKeys + getSettings: typeof getSettings + settingAtom: typeof atom + } +} diff --git a/apps/mobile/src/components/common/ThemedBlurView.tsx b/apps/mobile/src/components/common/ThemedBlurView.tsx index 806c3bdf83..7da5c91b0b 100644 --- a/apps/mobile/src/components/common/ThemedBlurView.tsx +++ b/apps/mobile/src/components/common/ThemedBlurView.tsx @@ -8,7 +8,7 @@ export const ThemedBlurView = forwardRef(({ tint, ...re return ( ) diff --git a/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx b/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx index 1e5379066c..cf61d2d8ce 100644 --- a/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx +++ b/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx @@ -29,6 +29,7 @@ export function DropdownMenu({ const isActionMenu = options.every((option) => "title" in option) return ( ({ onValueChange(currentValue) }, []) - return ( - - {!!label && } + const Trigger = ( + + options={options.map((option) => ({ + label: option.label, + value: option.value, + }))} + currentValue={currentValue} + handleChangeValue={handleChangeValue} + > + - {/* Trigger */} - - options={options.map((option) => ({ - label: option.label, - value: option.value, - }))} - currentValue={currentValue} - handleChangeValue={handleChangeValue} + wrapperClassName, + )} + style={wrapperStyle} > - - {valueToLabelMap.get(currentValue)} - - - + + {valueToLabelMap.get(currentValue)} + + + - + + + ) + + if (!label) { + return Trigger + } + + return ( + + + + + {Trigger} ) } diff --git a/apps/mobile/src/components/ui/form/Switch.tsx b/apps/mobile/src/components/ui/form/Switch.tsx index 7d179faefb..b724ed52c1 100644 --- a/apps/mobile/src/components/ui/form/Switch.tsx +++ b/apps/mobile/src/components/ui/form/Switch.tsx @@ -1,9 +1,9 @@ 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 type { StyleProp, Switch as NativeSwitch, ViewStyle } from "react-native" +import { Text, View } from "react-native" +import type { SwitchProps, SwitchRef } from "../switch/Switch" +import { Switch } from "../switch/Switch" import { FormLabel } from "./Label" interface Props { @@ -12,19 +12,26 @@ interface Props { label?: string description?: string + + size?: "sm" | "default" } -export const FormSwitch = forwardRef( - ({ wrapperClassName, wrapperStyle, label, description, ...rest }, ref) => { +export const FormSwitch = forwardRef( + ({ wrapperClassName, wrapperStyle, label, description, size = "default", ...rest }, ref) => { + const Trigger = + + if (!label) { + return Trigger + } return ( - {!!label && } + {!!description && ( {description} )} - + {Trigger} ) }, diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index 348d734b80..631417b10f 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -3,8 +3,9 @@ import type { FC, PropsWithChildren } from "react" import * as React from "react" import { Fragment } from "react" import type { ViewProps } from "react-native" -import { Pressable, StyleSheet, Text, View } from "react-native" +import { Pressable, StyleSheet, Switch, Text, View } from "react-native" +import { setGeneralSetting } from "@/src/atoms/settings/general" import { RightCuteReIcon } from "@/src/icons/right_cute_re" import { useColor } from "@/src/theme/colors" @@ -23,12 +24,18 @@ export const GroupedInsetListCard: FC = ({ > {React.Children.map(children, (child, index) => { const isLast = index === React.Children.count(children) - 1 + + const isNavigationLink = + React.isValidElement(child) && + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + (child.type as Function).name === GroupedInsetListNavigationLink.name + return ( {child} {!isLast && ( )} @@ -51,9 +58,15 @@ export const GroupedInsetListSectionHeader: FC<{ ) } -export const GroupedInsetListItem: FC = ({ children, ...props }) => { +export const GroupedInsetListBaseCell: FC = ({ + children, + ...props +}) => { return ( - + {children} ) @@ -69,8 +82,8 @@ export const GroupedInsetListNavigationLink: FC<{ return ( {({ pressed }) => ( - - + + {icon} {label} @@ -79,7 +92,7 @@ export const GroupedInsetListNavigationLink: FC<{ - + )} ) @@ -101,3 +114,20 @@ export const GroupedInsetListNavigationLinkIcon: FC< ) } + +export const GroupedInsetListCell: FC<{ + label: string + description?: string + children: React.ReactNode +}> = ({ label, description, children }) => { + return ( + + + {label} + {!!description && {description}} + + + {children} + + ) +} diff --git a/apps/mobile/src/components/ui/switch/Switch.tsx b/apps/mobile/src/components/ui/switch/Switch.tsx new file mode 100644 index 0000000000..0348dc0d2d --- /dev/null +++ b/apps/mobile/src/components/ui/switch/Switch.tsx @@ -0,0 +1,182 @@ +import type { useState } from "react" +import { forwardRef, useEffect, useImperativeHandle, useMemo } from "react" +import type { SwitchChangeEvent } from "react-native" +import { Animated, Easing, Pressable, StyleSheet, useAnimatedValue, View } from "react-native" + +import { accentColor, useColor } from "@/src/theme/colors" + +export interface SwitchProps { + onChange?: ((event: SwitchChangeEvent) => Promise | void) | null | undefined + + /** + * Invoked with the new value when the value changes. + */ + onValueChange?: ((value: boolean) => Promise | void) | null | undefined + + /** + * The value of the switch. If true the switch will be turned on. + * Default value is false. + */ + value?: boolean | undefined + + size?: "sm" | "default" +} + +export type SwitchRef = { + value: boolean +} +export const Switch = forwardRef( + ({ value, onValueChange, onChange, size = "default" }, ref) => { + const animatedValue = useAnimatedValue(0) + const circleWidthAnimatedValue = useAnimatedValue(0) + const translateX = useAnimatedValue(0) + const colorAnimatedValue = useAnimatedValue(0) + + useEffect(() => { + Animated.timing(animatedValue, { + toValue: value ? 1 : 0, + duration: 110, + easing: Easing.linear, + useNativeDriver: false, + }).start() + + Animated.timing(colorAnimatedValue, { + toValue: value ? 1 : 0, + duration: 300, + easing: Easing.linear, + useNativeDriver: false, + }).start() + }, [value]) + + const onTouchStart = () => { + Animated.timing(circleWidthAnimatedValue, { + toValue: 1, + duration: 200, + useNativeDriver: false, + }).start() + if (value) + Animated.timing(translateX, { + toValue: size === "sm" ? -4 : -7, + duration: 200, + useNativeDriver: false, + }).start() + } + + const onTouchEnd = () => { + Animated.timing(circleWidthAnimatedValue, { + toValue: 0, + duration: 100, + useNativeDriver: false, + }).start() + Animated.timing(translateX, { + toValue: 0, + duration: 100, + useNativeDriver: false, + }).start() + } + + const moveToggle = useMemo( + () => + animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: size === "sm" ? [2, 20] : [2.3, 22], + }), + [animatedValue, size], + ) + + const circleWidth = useMemo( + () => + circleWidthAnimatedValue.interpolate({ + inputRange: [0, 1], + outputRange: size === "sm" ? [18, 21] : [27.8, 35], + }), + [circleWidthAnimatedValue, size], + ) + + useImperativeHandle(ref, () => ({ + value: !!value, + })) + + const activeBgColor = accentColor + const inactiveBgColor = useColor("secondarySystemFill") + const bgColor = colorAnimatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [inactiveBgColor, activeBgColor], + }) + + return ( + + { + onValueChange?.(!value) + onChange?.({ target: { value: !value } as any } as SwitchChangeEvent) + }} + > + + + + + + ) + }, +) + +const styles = StyleSheet.create({ + container: { display: "flex", justifyContent: "space-between" }, + toggleContainer: { + width: 52, + height: 32.7, + borderRadius: 4000, + justifyContent: "center", + }, + toggleContainerSm: { + width: 40, + height: 24, + borderRadius: 4000, + justifyContent: "center", + }, + toggleWheelStyle: { + height: 28.5, + backgroundColor: "#ffffff", + borderRadius: 200, + shadowColor: "#515151", + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0.2, + shadowRadius: 2.5, + elevation: 1.5, + }, + toggleWheelStyleSm: { + height: 20, + backgroundColor: "#ffffff", + borderRadius: 200, + shadowColor: "#515151", + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0.2, + shadowRadius: 2.5, + elevation: 1.5, + }, +}) diff --git a/apps/mobile/src/interfaces/settings/general.ts b/apps/mobile/src/interfaces/settings/general.ts new file mode 100644 index 0000000000..990adc3e29 --- /dev/null +++ b/apps/mobile/src/interfaces/settings/general.ts @@ -0,0 +1,20 @@ +export interface GeneralSettings { + language: string + translationLanguage: string + + sendAnonymousData: boolean + unreadOnly: boolean + scrollMarkUnread: boolean + + renderMarkUnread: boolean + groupByDate: boolean + jumpOutLinkWarn: boolean + // TTS + voice: string + autoGroup: boolean + + /** + * Auto expand long social media + */ + autoExpandLongSocialMedia: boolean +} diff --git a/apps/mobile/src/lib/language.ts b/apps/mobile/src/lib/language.ts new file mode 100644 index 0000000000..cbcf4e61a4 --- /dev/null +++ b/apps/mobile/src/lib/language.ts @@ -0,0 +1,33 @@ +import type { languageSchema } from "@follow/shared/src/hono" +import type { z } from "zod" + +export type SupportedLanguages = z.infer +export const LanguageMap: Record< + SupportedLanguages, + { + label: string + value: string + code: string + } +> = { + en: { + value: "en", + label: "English", + code: "eng", + }, + ja: { + value: "ja", + label: "Japanese", + code: "jpn", + }, + "zh-CN": { + value: "zh-CN", + label: "Simplified Chinese", + code: "cmn", + }, + "zh-TW": { + value: "zh-TW", + label: "Traditional Chinese (Taiwan)", + code: "cmn", + }, +} diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index 93e5ba6b0f..c18426a769 100644 --- a/apps/mobile/src/modules/settings/SettingsList.tsx +++ b/apps/mobile/src/modules/settings/SettingsList.tsx @@ -88,7 +88,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [ onPress: (navigation) => { navigation.navigate("Data") }, - iconBackgroundColor: "#F59E0B", + iconBackgroundColor: "#CBAD6D", }, ] @@ -101,19 +101,20 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [ }, iconBackgroundColor: "#059669", }, + { - label: "Lists", - icon: RadaCuteFiIcon, + label: "Feeds", + icon: CertificateCuteFiIcon, onPress: (navigation) => { - navigation.navigate("Lists") + navigation.navigate("Feeds") }, iconBackgroundColor: "#10B981", }, { - label: "Feeds", - icon: CertificateCuteFiIcon, + label: "Lists", + icon: RadaCuteFiIcon, onPress: (navigation) => { - navigation.navigate("Feeds") + navigation.navigate("Lists") }, iconBackgroundColor: "#34D399", }, diff --git a/apps/mobile/src/modules/settings/routes/About.tsx b/apps/mobile/src/modules/settings/routes/About.tsx index ae28d9b537..bfc13c009a 100644 --- a/apps/mobile/src/modules/settings/routes/About.tsx +++ b/apps/mobile/src/modules/settings/routes/About.tsx @@ -81,7 +81,7 @@ export const AboutScreen = () => { label={link.title} icon={ - + } onPress={() => Linking.openURL(link.url)} diff --git a/apps/mobile/src/modules/settings/routes/General.tsx b/apps/mobile/src/modules/settings/routes/General.tsx index 5332b07f30..3e99304e5b 100644 --- a/apps/mobile/src/modules/settings/routes/General.tsx +++ b/apps/mobile/src/modules/settings/routes/General.tsx @@ -1,9 +1,140 @@ +import { useLocales } from "expo-localization" import { Text, View } from "react-native" +import { setGeneralSetting, useGeneralSettingKey } from "@/src/atoms/settings/general" +import { + NavigationBlurEffectHeader, + SafeNavigationScrollView, +} from "@/src/components/common/SafeNavigationScrollView" +import { Select } from "@/src/components/ui/form/Select" +import { + GroupedInsetListBaseCell, + GroupedInsetListCard, + GroupedInsetListCell, + GroupedInsetListSectionHeader, +} from "@/src/components/ui/grouped/GroupedList" +import { Switch } from "@/src/components/ui/switch/Switch" +import { LanguageMap } from "@/src/lib/language" + export const GeneralScreen = () => { + const locales = useLocales() + const translationLanguage = useGeneralSettingKey("translationLanguage") + const autoGroup = useGeneralSettingKey("autoGroup") + const showUnreadOnLaunch = useGeneralSettingKey("unreadOnly") + const groupByDate = useGeneralSettingKey("groupByDate") + const expandLongSocialMedia = useGeneralSettingKey("autoExpandLongSocialMedia") + const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread") + const markAsReadWhenInView = useGeneralSettingKey("renderMarkUnread") + return ( - - General Settings - + + + {/* Language */} + + + + + Language + + {locales[0]?.languageTag} + + + + Translation Language +