From 94d3eb6dfb16e65e9938222b9c9535c61bf2a7bb Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 13 Mar 2025 10:34:35 +0800 Subject: [PATCH] feat(mobile): action page (#3019) --- .../layouts/header/NavigationHeader.tsx | 12 +- .../src/components/ui/grouped/GroupedList.tsx | 44 ++- .../src/modules/settings/SettingsList.tsx | 1 - .../modules/settings/actions/components.tsx | 40 +++ .../src/modules/settings/actions/constant.ts | 181 +++++++++++ apps/mobile/src/modules/settings/hooks.ts | 2 + .../src/modules/settings/routes/Actions.tsx | 173 ++++++++++- .../modules/settings/routes/EditCondition.tsx | 150 +++++++++ .../settings/routes/EditRewriteRules.tsx | 75 +++++ .../src/modules/settings/routes/EditRule.tsx | 284 ++++++++++++++++++ .../modules/settings/routes/EditWebhooks.tsx | 53 ++++ .../src/modules/settings/routes/index.tsx | 45 ++- apps/mobile/src/modules/settings/types.ts | 6 + apps/mobile/src/morph/types.ts | 4 + apps/mobile/src/store/action/hooks.ts | 65 ++++ apps/mobile/src/store/action/store.ts | 216 +++++++++++++ apps/mobile/src/store/action/types.ts | 20 ++ 17 files changed, 1358 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/modules/settings/actions/components.tsx create mode 100644 apps/mobile/src/modules/settings/actions/constant.ts create mode 100644 apps/mobile/src/modules/settings/routes/EditCondition.tsx create mode 100644 apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx create mode 100644 apps/mobile/src/modules/settings/routes/EditRule.tsx create mode 100644 apps/mobile/src/modules/settings/routes/EditWebhooks.tsx create mode 100644 apps/mobile/src/store/action/hooks.ts create mode 100644 apps/mobile/src/store/action/store.ts create mode 100644 apps/mobile/src/store/action/types.ts diff --git a/apps/mobile/src/components/layouts/header/NavigationHeader.tsx b/apps/mobile/src/components/layouts/header/NavigationHeader.tsx index 4bbafbe63..2be2ff1ea 100644 --- a/apps/mobile/src/components/layouts/header/NavigationHeader.tsx +++ b/apps/mobile/src/components/layouts/header/NavigationHeader.tsx @@ -1,3 +1,4 @@ +import { cn } from "@follow/utils" import { getDefaultHeaderHeight, HeaderTitle } from "@react-navigation/elements" import { router, useNavigation } from "expo-router" import type { FC, PropsWithChildren, ReactNode } from "react" @@ -295,12 +296,21 @@ export const DefaultHeaderBackButton = ({ canGoBack }: { canGoBack: boolean }) = export const UINavigationHeaderActionButton = ({ children, onPress, + disabled, + className, }: { children: ReactNode onPress?: () => void + disabled?: boolean + className?: string }) => { return ( - + {children} ) diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index 15da17889..f7af1a7ad 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -6,8 +6,9 @@ import type { PressableProps, ViewProps } from "react-native" import { Pressable, StyleSheet, Text, View } from "react-native" import Animated, { FadeIn, FadeOut } from "react-native-reanimated" +import { CheckFilledIcon } from "@/src/icons/check_filled" import { MingcuteRightLine } from "@/src/icons/mingcute_right_line" -import { useColor } from "@/src/theme/colors" +import { accentColor, useColor } from "@/src/theme/colors" export enum GroupedInsetListCardItemStyle { NavigationLink = "NavigationLink", @@ -150,7 +151,7 @@ export const GroupedInsetListCell: FC< } & BaseCellClassNames > = ({ label, description, children, leftClassName, rightClassName }) => { return ( - + {label} {!!description && ( @@ -163,15 +164,48 @@ export const GroupedInsetListCell: FC< ) } +export const GroupedInsetListActionCellRadio: FC<{ + label: string + description?: string + onPress?: () => void + disabled?: boolean + selected?: boolean +}> = ({ label, description, onPress, disabled, selected }) => { + return ( + + {({ pressed }) => ( + + + {label} + {!!description && ( + {description} + )} + + + + {selected && } + + + )} + + ) +} + export const GroupedInsetListActionCell: FC<{ label: string description?: string - onPress: () => void + onPress?: () => void disabled?: boolean }> = ({ label, description, onPress, disabled }) => { const rightIconColor = useColor("tertiaryLabel") return ( - + {({ pressed }) => ( void + onPress?: () => void disabled?: boolean style?: "destructive" | "primary" }> = ({ label, onPress, disabled, style = "primary" }) => { diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index dd3091a65..29e0c872e 100644 --- a/apps/mobile/src/modules/settings/SettingsList.tsx +++ b/apps/mobile/src/modules/settings/SettingsList.tsx @@ -97,7 +97,6 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [ navigation.navigate("Actions") }, iconBackgroundColor: "#059669", - todo: true, anonymous: false, }, diff --git a/apps/mobile/src/modules/settings/actions/components.tsx b/apps/mobile/src/modules/settings/actions/components.tsx new file mode 100644 index 000000000..d2f486e48 --- /dev/null +++ b/apps/mobile/src/modules/settings/actions/components.tsx @@ -0,0 +1,40 @@ +import { Text } from "react-native" +import * as DropdownMenu from "zeego/dropdown-menu" + +import { GroupedInsetListCell } from "@/src/components/ui/grouped/GroupedList" +import { actionActions } from "@/src/store/action/store" +import type { ActionRule } from "@/src/store/action/types" + +import { translationOptions } from "./constant" + +export const ActionFormTranslation: React.FC<{ rule: ActionRule }> = ({ rule }) => { + const currentTranslation = translationOptions.find( + (translation) => translation.value === rule.result?.translation, + ) + return ( + + + + {currentTranslation?.label || "Select"} + + + {translationOptions.map((translation) => ( + { + actionActions.patchRule(rule.index, { result: { translation: translation.value } }) + }} + > + {translation.label} + + ))} + + + + ) +} diff --git a/apps/mobile/src/modules/settings/actions/constant.ts b/apps/mobile/src/modules/settings/actions/constant.ts new file mode 100644 index 000000000..441cfdd15 --- /dev/null +++ b/apps/mobile/src/modules/settings/actions/constant.ts @@ -0,0 +1,181 @@ +import type { SupportedLanguages } from "@/src/lib/language" +import { actionActions } from "@/src/store/action/store" +import type { ActionId, ActionRule } from "@/src/store/action/types" + +import type { SettingsNavigation } from "../hooks" +import { ActionFormTranslation } from "./components" + +export const filterFieldOptions = [ + { + label: "Subscription View", + value: "view", + type: "view", + }, + { + label: "Feed Title", + value: "title", + }, + { + label: "Feed Category", + value: "category", + }, + { + label: "Site URL", + value: "site_url", + }, + { + label: "Feed URL", + value: "feed_url", + }, + { + label: "Entry Title", + value: "entry_title", + }, + { + label: "Entry Content", + value: "entry_content", + }, + { + label: "Entry URL", + value: "entry_url", + }, + { + label: "Entry Author", + value: "entry_author", + }, + { + label: "Entry Media Length", + value: "entry_media_length", + type: "number", + }, +] + +export const filterOperatorOptions = [ + { + label: "contains", + value: "contains", + types: ["text"], + }, + { + label: "does not contain", + value: "not_contains", + types: ["text"], + }, + { + label: "is equal to", + value: "eq", + types: ["number", "text", "view"], + }, + { + label: "is not equal to", + value: "not_eq", + types: ["number", "text", "view"], + }, + { + label: "is greater than", + value: "gt", + types: ["number"], + }, + { + label: "is less than", + value: "lt", + types: ["number"], + }, + { + label: "matches regex", + value: "regex", + types: ["text"], + }, +] + +export const availableActionList: Array<{ + value: ActionId + label: string + onEnable?: (index: number) => void + onNavigate?: (router: SettingsNavigation, index: number) => void + component?: React.FC<{ rule: ActionRule }> +}> = [ + { + value: "summary", + label: "Generate summary using AI", + }, + { + value: "translation", + label: "Translate into", + onEnable: (index) => { + actionActions.patchRule(index, { result: { translation: "zh-CN" } }) + }, + component: ActionFormTranslation, + }, + { + value: "readability", + label: "Enable readability", + }, + { + value: "sourceContent", + label: "View source content", + }, + { + value: "newEntryNotification", + label: "Notification of new entry", + }, + { + value: "silence", + label: "Silence", + }, + { + value: "block", + label: "Block", + }, + { + value: "rewriteRules", + label: "Rewrite Rules", + onEnable: (index: number) => { + actionActions.patchRule(index, { + result: { + rewriteRules: [ + { + from: "", + to: "", + }, + ], + }, + }) + }, + onNavigate: (router, index) => { + router.navigate("EditRewriteRules", { index }) + }, + }, + { + value: "webhooks", + label: "Webhooks", + onEnable: (index) => { + actionActions.patchRule(index, { result: { webhooks: [""] } }) + }, + onNavigate: (router, index) => { + router.navigate("EditWebhooks", { index }) + }, + }, +] + +export const translationOptions: { + label: string + value: SupportedLanguages +}[] = [ + { + label: "English", + value: "en", + }, + { + label: "日本語", + value: "ja", + }, + { + label: "简体中文", + value: "zh-CN", + }, + { + label: "繁體中文", + value: "zh-TW", + }, +] diff --git a/apps/mobile/src/modules/settings/hooks.ts b/apps/mobile/src/modules/settings/hooks.ts index 48aba77c7..0b88f6f79 100644 --- a/apps/mobile/src/modules/settings/hooks.ts +++ b/apps/mobile/src/modules/settings/hooks.ts @@ -6,3 +6,5 @@ import type { SettingsStackParamList } from "./types" export const useSettingsNavigation = () => { return useNavigation>() } + +export type SettingsNavigation = ReturnType diff --git a/apps/mobile/src/modules/settings/routes/Actions.tsx b/apps/mobile/src/modules/settings/routes/Actions.tsx index 7f161086d..cbc946f2a 100644 --- a/apps/mobile/src/modules/settings/routes/Actions.tsx +++ b/apps/mobile/src/modules/settings/routes/Actions.tsx @@ -1,9 +1,174 @@ -import { Text, View } from "react-native" +import { withOpacity } from "@follow/utils" +import { useCallback } from "react" +import type { ListRenderItem } from "react-native" +import { ActivityIndicator, Text, View } from "react-native" +import Animated, { LinearTransition } from "react-native-reanimated" +import { useColor } from "react-native-uikit-colors" + +import { RotateableLoading } from "@/src/components/common/RotateableLoading" +import { SwipeableGroupProvider, SwipeableItem } from "@/src/components/common/SwipeableItem" +import { UINavigationHeaderActionButton } from "@/src/components/layouts/header/NavigationHeader" +import { + NavigationBlurEffectHeader, + SafeNavigationScrollView, +} from "@/src/components/layouts/views/SafeNavigationScrollView" +import { + GroupedInformationCell, + GroupedInsetListCard, +} from "@/src/components/ui/grouped/GroupedList" +import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" +import { Switch } from "@/src/components/ui/switch/Switch" +import { AddCuteReIcon } from "@/src/icons/add_cute_re" +import { CheckLineIcon } from "@/src/icons/check_line" +import { Magic2CuteFiIcon } from "@/src/icons/magic_2_cute_fi" +import { + useActionRules, + useIsActionDataDirty, + usePrefetchActions, + useUpdateActionsMutation, +} from "@/src/store/action/hooks" +import { actionActions } from "@/src/store/action/store" +import type { ActionRule } from "@/src/store/action/types" + +import { useSettingsNavigation } from "../hooks" export const ActionsScreen = () => { + const { isLoading } = usePrefetchActions() + const rules = useActionRules() + const isDirty = useIsActionDataDirty() + return ( - - Actions Settings - + + ( + + ), + [isDirty], + )} + /> + + + + } + iconBackgroundColor="#059669" + /> + + + + + + {rules.length > 0 ? ( + + + + ) : isLoading && rules.length === 0 ? ( + + + + ) : null} + + + + + ) +} + +const NewRuleButton = () => { + const label = useColor("label") + return ( + + { + actionActions.addRule() + }} + className="flex-row items-center gap-3 py-4" + > + + New Rule + + + ) +} + +const SaveRuleButton = ({ disabled }: { disabled?: boolean }) => { + const { mutate, isPending } = useUpdateActionsMutation() + const label = useColor("label") + return ( + + {isPending ? ( + + ) : ( + + )} + + ) +} + +const ItemSeparatorComponent = () => { + return ( + + ) +} + +const keyExtractor = (item: ActionRule) => item.index.toString() +const ListItemCell: ListRenderItem = (props) => { + return +} +const ListItemCellImpl: ListRenderItem = ({ item: rule }) => { + const navigation = useSettingsNavigation() + return ( + { + actionActions.deleteRule(rule.index) + }, + backgroundColor: "red", + }, + { + label: "Edit", + onPress: () => { + navigation.navigate("EditRule", { index: rule.index }) + }, + backgroundColor: "#0ea5e9", + }, + ]} + > + navigation.navigate("EditRule", { index: rule.index })} + > + {rule.name} + { + actionActions.patchRule(rule.index, { + result: { + disabled: !rule.result.disabled, + }, + }) + }} + /> + + ) } diff --git a/apps/mobile/src/modules/settings/routes/EditCondition.tsx b/apps/mobile/src/modules/settings/routes/EditCondition.tsx new file mode 100644 index 000000000..5732bdf7d --- /dev/null +++ b/apps/mobile/src/modules/settings/routes/EditCondition.tsx @@ -0,0 +1,150 @@ +import type { RouteProp } from "@react-navigation/native" +import { Text, View } from "react-native" +import * as DropdownMenu from "zeego/dropdown-menu" + +import { ModalHeader } from "@/src/components/layouts/header/ModalHeader" +import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView" +import { PlainTextField } from "@/src/components/ui/form/TextField" +import { + GroupedInsetListBaseCell, + GroupedInsetListCard, + GroupedInsetListSectionHeader, +} from "@/src/components/ui/grouped/GroupedList" +import { views } from "@/src/constants/views" +import { useActionRuleCondition } from "@/src/store/action/hooks" +import { actionActions } from "@/src/store/action/store" +import type { ConditionIndex } from "@/src/store/action/types" +import { accentColor } from "@/src/theme/colors" + +import { filterFieldOptions, filterOperatorOptions } from "../actions/constant" +import type { SettingsStackParamList } from "../types" + +export function EditConditionScreen({ + route, +}: { + route: RouteProp +}) { + return ( + + + + + + ) +} + +function ConditionForm({ index }: { index: ConditionIndex }) { + const item = useActionRuleCondition(index)! + const currentField = filterFieldOptions.find((field) => field.value === item.field) + const currentOperator = filterOperatorOptions.find((field) => field.value === item.operator) + const currentView = + currentField?.type === "view" + ? views.find((view) => view.view === Number(item.value)) + : undefined + + return ( + <> + + + + Field + + + {currentField?.label || "Select"} + + + {filterFieldOptions.map((field) => ( + { + actionActions.pathCondition(index, { + field: field.value as any, + }) + }} + > + {field.label} + + ))} + + + + + + Operator + + + {currentOperator?.label || "Select"} + + + {filterOperatorOptions + .filter((operator) => operator.types.includes(currentField?.type ?? "text")) + .map((operator) => ( + { + actionActions.pathCondition(index, { + operator: operator.value as any, + }) + }} + > + {operator.label} + + ))} + + + + + + Value + {currentField?.type === "view" ? ( + + + + {currentView?.icon({ + height: 17, + width: 17, + color: currentView?.activeColor, + })} + {currentView?.name || "Select"} + + + + {views.map((field) => ( + { + actionActions.pathCondition(index, { + value: String(field.view), + }) + }} + > + {field.name} + + ))} + + + ) : ( + { + actionActions.pathCondition(index, { value }) + }} + hitSlop={10} + selectionColor={accentColor} + placeholder="Enter value" + /> + )} + + + {__DEV__ && ( + + {JSON.stringify(item)} + + )} + + ) +} diff --git a/apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx b/apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx new file mode 100644 index 000000000..1f92c7d25 --- /dev/null +++ b/apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx @@ -0,0 +1,75 @@ +import type { RouteProp } from "@react-navigation/native" +import { Text } from "react-native" + +import { ModalHeader } from "@/src/components/layouts/header/ModalHeader" +import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView" +import { PlainTextField } from "@/src/components/ui/form/TextField" +import { + GroupedInsetListBaseCell, + GroupedInsetListCard, + GroupedInsetListSectionHeader, + GroupedPlainButtonCell, +} from "@/src/components/ui/grouped/GroupedList" +import { useActionRule } from "@/src/store/action/hooks" +import { actionActions } from "@/src/store/action/store" + +import type { SettingsStackParamList } from "../types" + +export const EditRewriteRulesScreen = ({ + route, +}: { + route: RouteProp +}) => { + const { index } = route.params + const rule = useActionRule(index) + + return ( + + + + {rule?.result.rewriteRules?.map((rewriteRule, rewriteRuleIndex) => ( + + + From + { + actionActions.updateRewriteRule({ + index, + rewriteRuleIndex, + key: "from", + value, + }) + }} + /> + + + To + { + actionActions.updateRewriteRule({ + index, + rewriteRuleIndex, + key: "to", + value, + }) + }} + /> + + + ))} + + { + actionActions.addRewriteRule(index) + }} + /> + + {__DEV__ && {JSON.stringify(rule?.result.rewriteRules, null, 2)}} + + ) +} diff --git a/apps/mobile/src/modules/settings/routes/EditRule.tsx b/apps/mobile/src/modules/settings/routes/EditRule.tsx new file mode 100644 index 000000000..dec0b2395 --- /dev/null +++ b/apps/mobile/src/modules/settings/routes/EditRule.tsx @@ -0,0 +1,284 @@ +import type { RouteProp } from "@react-navigation/native" +import { Fragment } from "react" +import { Text, View } from "react-native" +import * as DropdownMenu from "zeego/dropdown-menu" + +import { SwipeableItem } from "@/src/components/common/SwipeableItem" +import { + NavigationBlurEffectHeader, + SafeNavigationScrollView, +} from "@/src/components/layouts/views/SafeNavigationScrollView" +import { PlainTextField } from "@/src/components/ui/form/TextField" +import { + GroupedInsetListActionCell, + GroupedInsetListActionCellRadio, + GroupedInsetListCard, + GroupedInsetListCell, + GroupedInsetListSectionHeader, + GroupedPlainButtonCell, +} from "@/src/components/ui/grouped/GroupedList" +import { views } from "@/src/constants/views" +import { useActionRule } from "@/src/store/action/hooks" +import { actionActions } from "@/src/store/action/store" +import type { ActionFilter, ActionRule } from "@/src/store/action/types" +import { accentColor } from "@/src/theme/colors" + +import { availableActionList, filterFieldOptions, filterOperatorOptions } from "../actions/constant" +import { useSettingsNavigation } from "../hooks" +import type { SettingsStackParamList } from "../types" + +export const EditRuleScreen = ({ + route, +}: { + route: RouteProp +}) => { + const { index } = route.params + const rule = useActionRule(index) + + return ( + + + + + ) +} + +const RuleImpl: React.FC<{ index: number }> = ({ index }) => { + const rule = useActionRule(index) + + if (!rule) { + return No rule available + } + + return ( + + + + + + {__DEV__ && ( + + {JSON.stringify(rule, null, 2)} + + )} + + ) +} + +const NameSection: React.FC<{ rule: ActionRule }> = ({ rule }) => { + return ( + + + + { + actionActions.patchRule(rule.index, { name: text }) + }} + /> + + + + ) +} + +const FilterSection: React.FC<{ rule: ActionRule }> = ({ rule }) => { + const hasCustomFilters = rule.condition.length > 0 + return ( + + + + { + actionActions.toggleRuleFilter(rule.index) + }} + /> + { + actionActions.toggleRuleFilter(rule.index) + }} + /> + + + ) +} + +const ConditionSection: React.FC<{ filter: ActionFilter; index: number }> = ({ filter, index }) => { + const navigation = useSettingsNavigation() + + if (filter.length === 0) return null + return ( + + + + {filter.map((group, groupIndex) => { + return ( + + {group.map((item, itemIndex) => { + const currentField = filterFieldOptions.find((field) => field.value === item.field) + const currentOperator = filterOperatorOptions.find( + (field) => field.value === item.operator, + ) + const currentValue = + currentField?.type === "view" + ? views.find((view) => view.view === Number(item.value))?.name + : item.value + return ( + + { + actionActions.deleteConditionItem({ + ruleIndex: index, + groupIndex, + conditionIndex: itemIndex, + }) + }, + backgroundColor: "red", + }, + { + label: "Edit", + onPress: () => { + navigation.navigate("EditCondition", { + ruleIndex: index, + groupIndex, + conditionIndex: itemIndex, + }) + }, + backgroundColor: "#0ea5e9", + }, + ]} + > + { + navigation.navigate("EditCondition", { + ruleIndex: index, + groupIndex, + conditionIndex: itemIndex, + }) + }} + /> + + {itemIndex === group.length - 1 && ( + { + actionActions.addConditionItem({ ruleIndex: index, groupIndex }) + setTimeout(() => { + navigation.navigate("EditCondition", { + ruleIndex: index, + groupIndex, + conditionIndex: group.length, + }) + }, 0) + }} + /> + )} + + ) + })} + + ) + })} + { + actionActions.addConditionGroup({ ruleIndex: index }) + }} + /> + + + ) +} + +const ActionSection: React.FC<{ rule: ActionRule }> = ({ rule }) => { + const enabledActions = availableActionList.filter( + (action) => rule.result[action.value] !== undefined, + ) + const notEnabledActions = availableActionList.filter( + (action) => rule.result[action.value] === undefined, + ) + + const navigation = useSettingsNavigation() + + return ( + + + + {enabledActions.map((action) => ( + { + actionActions.deleteRuleAction(rule.index, action.value) + }, + backgroundColor: "red", + }, + ]} + > + {action.component ? ( + + ) : action.onNavigate ? ( + action.onNavigate?.(navigation, rule.index)} + /> + ) : ( + + )} + + ))} + {notEnabledActions.length > 0 && ( + + + + + + {notEnabledActions.map((action) => ( + { + if (action.onEnable) { + action.onEnable(rule.index) + } else { + actionActions.patchRule(rule.index, { result: { [action.value]: true } }) + } + }} + > + {action.label} + + ))} + + + )} + + + ) +} diff --git a/apps/mobile/src/modules/settings/routes/EditWebhooks.tsx b/apps/mobile/src/modules/settings/routes/EditWebhooks.tsx new file mode 100644 index 000000000..93604d6f5 --- /dev/null +++ b/apps/mobile/src/modules/settings/routes/EditWebhooks.tsx @@ -0,0 +1,53 @@ +import type { RouteProp } from "@react-navigation/native" +import { Text } from "react-native" + +import { ModalHeader } from "@/src/components/layouts/header/ModalHeader" +import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView" +import { PlainTextField } from "@/src/components/ui/form/TextField" +import { + GroupedInsetButtonCell, + GroupedInsetListBaseCell, + GroupedInsetListCard, + GroupedInsetListSectionHeader, +} from "@/src/components/ui/grouped/GroupedList" +import { useActionRule } from "@/src/store/action/hooks" +import { actionActions } from "@/src/store/action/store" + +import type { SettingsStackParamList } from "../types" + +export const EditWebhooksScreen = ({ + route, +}: { + route: RouteProp +}) => { + const { index } = route.params + const rule = useActionRule(index) + + return ( + + + + + {rule?.result.webhooks?.map((webhook, webhookIndex) => ( + + { + actionActions.updateWebhook({ index, webhookIndex, value }) + }} + /> + + ))} + { + actionActions.addWebhook(index) + }} + /> + + {__DEV__ && {JSON.stringify(rule?.result.webhooks, null, 2)}} + + ) +} diff --git a/apps/mobile/src/modules/settings/routes/index.tsx b/apps/mobile/src/modules/settings/routes/index.tsx index 44ecfa44c..8346e54c7 100644 --- a/apps/mobile/src/modules/settings/routes/index.tsx +++ b/apps/mobile/src/modules/settings/routes/index.tsx @@ -6,7 +6,11 @@ import { AchievementScreen } from "./Achievement" import { ActionsScreen } from "./Actions" import { AppearanceScreen } from "./Appearance" import { DataScreen } from "./Data" +import { EditConditionScreen } from "./EditCondition" import { EditProfileScreen } from "./EditProfile" +import { EditRewriteRulesScreen } from "./EditRewriteRules" +import { EditRuleScreen } from "./EditRule" +import { EditWebhooksScreen } from "./EditWebhooks" import { FeedsScreen } from "./Feeds" import { GeneralScreen } from "./General" import { ListsScreen } from "./Lists" @@ -31,8 +35,45 @@ const SettingFlatRoutes = (Stack: TypedNavigator) => { - {/* @ts-expect-error */} - + + + + + diff --git a/apps/mobile/src/modules/settings/types.ts b/apps/mobile/src/modules/settings/types.ts index ef8cd6ae5..3a6345c68 100644 --- a/apps/mobile/src/modules/settings/types.ts +++ b/apps/mobile/src/modules/settings/types.ts @@ -1,3 +1,5 @@ +import type { ConditionIndex } from "@/src/store/action/types" + export type SettingsStackParamList = { Profile: undefined Achievement: undefined @@ -12,6 +14,10 @@ export type SettingsStackParamList = { Privacy: undefined About: undefined ManageList: { id: string } + EditRule: { index: number } + EditCondition: ConditionIndex + EditRewriteRules: { index: number } + EditWebhooks: { index: number } EditProfile: undefined ResetPassword: undefined Setting2FA: { totpURI: string } diff --git a/apps/mobile/src/morph/types.ts b/apps/mobile/src/morph/types.ts index e5c41e70b..285de1404 100644 --- a/apps/mobile/src/morph/types.ts +++ b/apps/mobile/src/morph/types.ts @@ -15,5 +15,9 @@ export namespace HonoApiClient { export type List_List_Get = ExtractData[number] export type Feed_Get = ExtractData + export type ActionRule = Exclude< + ExtractData["rules"], + undefined | null + >[number] export type ActionSettings = Exclude } diff --git a/apps/mobile/src/store/action/hooks.ts b/apps/mobile/src/store/action/hooks.ts new file mode 100644 index 000000000..a017be6de --- /dev/null +++ b/apps/mobile/src/store/action/hooks.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery } from "@tanstack/react-query" +import { router } from "expo-router" +import { FetchError } from "ofetch" +import { useCallback } from "react" + +import { toast } from "@/src/lib/toast" + +import { actionSyncService, useActionStore } from "./store" + +export const usePrefetchActions = () => { + return useQuery({ + queryKey: ["action", "rules"], + queryFn: () => actionSyncService.fetchRules(), + }) +} + +export const useUpdateActionsMutation = () => { + return useMutation({ + mutationFn: () => actionSyncService.saveRules(), + onSuccess() { + router.back() + toast.success("Actions saved") + }, + onError(err) { + if (err instanceof FetchError && err.response?._data) { + const { message } = err.response._data + toast.error(message) + return + } + + toast.error("Error saving actions") + }, + }) +} + +export const useActionRules = () => { + return useActionStore((state) => state.rules) +} + +export const useActionRule = (index?: number) => { + return useActionStore( + useCallback((state) => (index !== undefined ? state.rules[index] : undefined), [index]), + ) +} + +export function useActionRuleCondition({ + ruleIndex, + groupIndex, + conditionIndex, +}: { + ruleIndex: number + groupIndex: number + conditionIndex: number +}) { + return useActionStore( + useCallback( + (state) => state.rules[ruleIndex]?.condition[groupIndex]?.[conditionIndex], + [ruleIndex, groupIndex, conditionIndex], + ), + ) +} + +export const useIsActionDataDirty = () => { + return useActionStore((state) => state.isDirty) +} diff --git a/apps/mobile/src/store/action/store.ts b/apps/mobile/src/store/action/store.ts new file mode 100644 index 000000000..d3f4f684c --- /dev/null +++ b/apps/mobile/src/store/action/store.ts @@ -0,0 +1,216 @@ +import { merge } from "es-toolkit/compat" + +import { apiClient } from "@/src/lib/api-fetch" + +import { createImmerSetter, createZustandStore } from "../internal/helper" +import type { ActionFilterItem, ActionId, ActionRule, ActionRules, ConditionIndex } from "./types" + +type ActionStore = { + rules: ActionRules + isDirty?: boolean +} + +export const useActionStore = createZustandStore("action")(() => ({ + rules: [], + isDirty: false, +})) + +const immerSet = createImmerSetter(useActionStore) + +class ActionSyncService { + async fetchRules() { + const res = await apiClient.actions.$get() + if (res.data) { + actionActions.updateRules( + (res.data.rules ?? []).map((rule, index) => ({ ...rule, index })) as any, + ) + + actionActions.setDirty(false) + } + return res + } + + async saveRules() { + const { rules, isDirty } = useActionStore.getState() + if (!isDirty) { + return null + } + + const res = await apiClient.actions.$put({ json: { rules: rules as any } }) + actionActions.setDirty(false) + return res + } +} + +class ActionActions { + updateRules(rules: ActionRules) { + immerSet((state) => { + state.rules = rules + state.isDirty = true + }) + } + + patchRule(index: number, rule: Partial) { + immerSet((state) => { + if (state.rules[index]) { + state.rules[index] = merge(state.rules[index], rule) + state.isDirty = true + } + }) + } + + addRule() { + immerSet((state) => { + state.rules.push({ + name: `Action ${state.rules.length + 1}`, + condition: [], + index: state.rules.length, + result: {}, + }) + state.isDirty = true + }) + } + + pathCondition(index: ConditionIndex, condition: Partial) { + immerSet((state) => { + const rule = state.rules[index.ruleIndex] + if (!rule) return + const group = rule.condition[index.groupIndex] + if (!group) return + group[index.conditionIndex] = merge(group[index.conditionIndex], condition) + state.isDirty = true + }) + } + + addConditionItem(index: Omit) { + immerSet((state) => { + const rule = state.rules[index.ruleIndex] + if (!rule) return + const group = rule.condition[index.groupIndex] + if (!group) return + group.push({}) + state.isDirty = true + }) + } + deleteConditionItem(index: ConditionIndex) { + immerSet((state) => { + const rule = state.rules[index.ruleIndex] + if (!rule) return + const group = rule.condition[index.groupIndex] + if (!group) return + group.splice(index.conditionIndex, 1) + if (group.length === 0) { + rule.condition.splice(index.groupIndex, 1) + } + state.isDirty = true + }) + } + + addConditionGroup(index: Omit) { + immerSet((state) => { + const rule = state.rules[index.ruleIndex] + if (!rule) return + rule.condition.push([{}]) + state.isDirty = true + }) + } + + toggleRuleFilter(index: number) { + immerSet((state) => { + if (state.rules[index]) { + const hasCustomFilters = state.rules[index].condition.length > 0 + state.rules[index].condition = hasCustomFilters ? [] : [[{}]] + state.isDirty = true + } + }) + } + + deleteRuleAction(index: number, actionId: ActionId) { + immerSet((state) => { + if (state.rules[index]) { + delete state.rules[index].result[actionId] + state.isDirty = true + } + }) + } + + deleteRule(index: number) { + immerSet((state) => { + state.rules.splice(index, 1) + state.isDirty = true + }) + } + + setDirty(isDirty: boolean) { + immerSet((state) => { + state.isDirty = isDirty + }) + } + + addWebhook(index: number) { + immerSet((state) => { + const rule = state.rules[index] + if (!rule) return + const { webhooks } = rule.result + if (!webhooks) return + webhooks.push("") + state.isDirty = true + }) + } + + updateWebhook({ + index, + webhookIndex, + value, + }: { + index: number + webhookIndex: number + value: string + }) { + immerSet((state) => { + const rule = state.rules[index] + if (!rule) return + const { webhooks } = rule.result + if (!webhooks) return + webhooks[webhookIndex] = value + state.isDirty = true + }) + } + + addRewriteRule(index: number) { + immerSet((state) => { + const rule = state.rules[index] + if (!rule) return + const { rewriteRules } = rule.result + if (!rewriteRules) return + rewriteRules.push({ from: "", to: "" }) + state.isDirty = true + }) + } + + updateRewriteRule({ + index, + rewriteRuleIndex, + key, + value, + }: { + index: number + rewriteRuleIndex: number + key: "from" | "to" + value: string + }) { + immerSet((state) => { + const rule = state.rules[index] + if (!rule) return + const { rewriteRules } = rule.result + if (!rewriteRules) return + const rewriteRule = rewriteRules[rewriteRuleIndex] + if (!rewriteRule) return + rewriteRule[key] = value + state.isDirty = true + }) + } +} + +export const actionSyncService = new ActionSyncService() +export const actionActions = new ActionActions() diff --git a/apps/mobile/src/store/action/types.ts b/apps/mobile/src/store/action/types.ts new file mode 100644 index 000000000..e732ead3c --- /dev/null +++ b/apps/mobile/src/store/action/types.ts @@ -0,0 +1,20 @@ +import type { HonoApiClient } from "@/src/morph/types" + +export type ActionFilterItem = Partial< + Exclude +> +export type ActionFilterGroup = ActionFilterItem[] +export type ActionFilter = ActionFilterGroup[] + +export type ActionRule = Omit & { + condition: ActionFilter + index: number +} +export type ActionId = Exclude +export type ActionRules = ActionRule[] + +export type ConditionIndex = { + ruleIndex: number + groupIndex: number + conditionIndex: number +}