feat(mobile): action page (#3019)
This commit is contained in:
parent
8966a534d5
commit
94d3eb6dfb
|
|
@ -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 (
|
||||
<TouchableOpacity hitSlop={5} className="p-2" onPress={onPress}>
|
||||
<TouchableOpacity
|
||||
hitSlop={5}
|
||||
className={cn("p-2", className)}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<GroupedInsetListBaseCell className="flex-1">
|
||||
<GroupedInsetListBaseCell className="bg-secondary-system-grouped-background flex-1">
|
||||
<View className={cn("flex-1", leftClassName)}>
|
||||
<Text className="text-label">{label}</Text>
|
||||
{!!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 (
|
||||
<Pressable onPress={onPress} disabled={disabled}>
|
||||
{({ pressed }) => (
|
||||
<GroupedInsetListBaseCell
|
||||
className={cn(pressed ? "bg-system-fill" : undefined, disabled && "opacity-40")}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-label">{label}</Text>
|
||||
{!!description && (
|
||||
<Text className="text-secondary-label text-sm leading-tight">{description}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="ml-4 size-[18px]">
|
||||
{selected && <CheckFilledIcon height={18} width={18} color={accentColor} />}
|
||||
</View>
|
||||
</GroupedInsetListBaseCell>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export const GroupedInsetListActionCell: FC<{
|
||||
label: string
|
||||
description?: string
|
||||
onPress: () => void
|
||||
onPress?: () => void
|
||||
disabled?: boolean
|
||||
}> = ({ label, description, onPress, disabled }) => {
|
||||
const rightIconColor = useColor("tertiaryLabel")
|
||||
return (
|
||||
<Pressable onPress={onPress} disabled={disabled}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
className="bg-secondary-system-grouped-background"
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<GroupedInsetListBaseCell
|
||||
className={cn(pressed ? "bg-system-fill" : undefined, disabled && "opacity-40")}
|
||||
|
|
@ -194,7 +228,7 @@ export const GroupedInsetListActionCell: FC<{
|
|||
|
||||
export const GroupedInsetButtonCell: FC<{
|
||||
label: string
|
||||
onPress: () => void
|
||||
onPress?: () => void
|
||||
disabled?: boolean
|
||||
style?: "destructive" | "primary"
|
||||
}> = ({ label, onPress, disabled, style = "primary" }) => {
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
|
|||
navigation.navigate("Actions")
|
||||
},
|
||||
iconBackgroundColor: "#059669",
|
||||
todo: true,
|
||||
anonymous: false,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<GroupedInsetListCell
|
||||
label="Translate into"
|
||||
leftClassName="flex-none"
|
||||
rightClassName="flex-1 flex-row items-center gap-4 justify-end"
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Text className="text-label">{currentTranslation?.label || "Select"}</Text>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{translationOptions.map((translation) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={translation.value === currentTranslation?.value}
|
||||
key={translation.value}
|
||||
onSelect={() => {
|
||||
actionActions.patchRule(rule.index, { result: { translation: translation.value } })
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{translation.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</GroupedInsetListCell>
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
|
|
@ -6,3 +6,5 @@ import type { SettingsStackParamList } from "./types"
|
|||
export const useSettingsNavigation = () => {
|
||||
return useNavigation<NativeStackNavigationProp<SettingsStackParamList>>()
|
||||
}
|
||||
|
||||
export type SettingsNavigation = ReturnType<typeof useSettingsNavigation>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Actions Settings</Text>
|
||||
</View>
|
||||
<SafeNavigationScrollView nestedScrollEnabled className="bg-system-grouped-background">
|
||||
<NavigationBlurEffectHeader
|
||||
title="Actions"
|
||||
headerRight={useCallback(
|
||||
() => (
|
||||
<SaveRuleButton disabled={!isDirty} />
|
||||
),
|
||||
[isDirty],
|
||||
)}
|
||||
/>
|
||||
|
||||
<View className="mt-6">
|
||||
<GroupedInsetListCard>
|
||||
<GroupedInformationCell
|
||||
title="Actions"
|
||||
description="Action are collections of rules that you can automate to perform tasks on server or client side."
|
||||
icon={<Magic2CuteFiIcon height={40} width={40} color="#fff" />}
|
||||
iconBackgroundColor="#059669"
|
||||
/>
|
||||
</GroupedInsetListCard>
|
||||
</View>
|
||||
|
||||
<View className="mt-6">
|
||||
<GroupedInsetListCard>
|
||||
{rules.length > 0 ? (
|
||||
<SwipeableGroupProvider>
|
||||
<Animated.FlatList
|
||||
keyExtractor={keyExtractor}
|
||||
itemLayoutAnimation={LinearTransition}
|
||||
scrollEnabled={false}
|
||||
data={rules}
|
||||
renderItem={ListItemCell}
|
||||
ItemSeparatorComponent={ItemSeparatorComponent}
|
||||
/>
|
||||
</SwipeableGroupProvider>
|
||||
) : isLoading && rules.length === 0 ? (
|
||||
<View className="my-4">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : null}
|
||||
</GroupedInsetListCard>
|
||||
<NewRuleButton />
|
||||
</View>
|
||||
</SafeNavigationScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const NewRuleButton = () => {
|
||||
const label = useColor("label")
|
||||
return (
|
||||
<GroupedInsetListCard className="mt-6">
|
||||
<UINavigationHeaderActionButton
|
||||
onPress={() => {
|
||||
actionActions.addRule()
|
||||
}}
|
||||
className="flex-row items-center gap-3 py-4"
|
||||
>
|
||||
<AddCuteReIcon height={20} width={20} color={label} />
|
||||
<Text className="text-label text-lg">New Rule</Text>
|
||||
</UINavigationHeaderActionButton>
|
||||
</GroupedInsetListCard>
|
||||
)
|
||||
}
|
||||
|
||||
const SaveRuleButton = ({ disabled }: { disabled?: boolean }) => {
|
||||
const { mutate, isPending } = useUpdateActionsMutation()
|
||||
const label = useColor("label")
|
||||
return (
|
||||
<UINavigationHeaderActionButton onPress={mutate} disabled={disabled || isPending}>
|
||||
{isPending ? (
|
||||
<RotateableLoading size={20} color={withOpacity(label, 0.5)} />
|
||||
) : (
|
||||
<CheckLineIcon height={20} width={20} color={disabled ? withOpacity(label, 0.5) : label} />
|
||||
)}
|
||||
</UINavigationHeaderActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemSeparatorComponent = () => {
|
||||
return (
|
||||
<View
|
||||
className="bg-opaque-separator ml-24 h-px flex-1"
|
||||
collapsable={false}
|
||||
style={{ transform: [{ scaleY: 0.5 }] }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const keyExtractor = (item: ActionRule) => item.index.toString()
|
||||
const ListItemCell: ListRenderItem<ActionRule> = (props) => {
|
||||
return <ListItemCellImpl {...props} />
|
||||
}
|
||||
const ListItemCellImpl: ListRenderItem<ActionRule> = ({ item: rule }) => {
|
||||
const navigation = useSettingsNavigation()
|
||||
return (
|
||||
<SwipeableItem
|
||||
swipeRightToCallAction
|
||||
rightActions={[
|
||||
{
|
||||
label: "Delete",
|
||||
onPress: () => {
|
||||
actionActions.deleteRule(rule.index)
|
||||
},
|
||||
backgroundColor: "red",
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
onPress: () => {
|
||||
navigation.navigate("EditRule", { index: rule.index })
|
||||
},
|
||||
backgroundColor: "#0ea5e9",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ItemPressable
|
||||
className="flex-row justify-between p-4"
|
||||
onPress={() => navigation.navigate("EditRule", { index: rule.index })}
|
||||
>
|
||||
<Text className="text-label text-lg">{rule.name}</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
value={!rule.result.disabled}
|
||||
onValueChange={() => {
|
||||
actionActions.patchRule(rule.index, {
|
||||
result: {
|
||||
disabled: !rule.result.disabled,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ItemPressable>
|
||||
</SwipeableItem>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SettingsStackParamList, "EditCondition">
|
||||
}) {
|
||||
return (
|
||||
<SafeModalScrollView className="bg-system-grouped-background">
|
||||
<ModalHeader headerTitle="Edit Condition" />
|
||||
|
||||
<ConditionForm index={route.params} />
|
||||
</SafeModalScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<GroupedInsetListSectionHeader label="Condition" />
|
||||
<GroupedInsetListCard>
|
||||
<GroupedInsetListBaseCell className="flex flex-row justify-between">
|
||||
<Text className="text-label">Field</Text>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Text className="text-label">{currentField?.label || "Select"}</Text>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{filterFieldOptions.map((field) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={field.value === item.field}
|
||||
key={field.value}
|
||||
onSelect={() => {
|
||||
actionActions.pathCondition(index, {
|
||||
field: field.value as any,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{field.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</GroupedInsetListBaseCell>
|
||||
|
||||
<GroupedInsetListBaseCell className="flex flex-row justify-between">
|
||||
<Text className="text-label">Operator</Text>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Text className="text-label">{currentOperator?.label || "Select"}</Text>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{filterOperatorOptions
|
||||
.filter((operator) => operator.types.includes(currentField?.type ?? "text"))
|
||||
.map((operator) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={operator.value === item.operator}
|
||||
key={operator.value}
|
||||
onSelect={() => {
|
||||
actionActions.pathCondition(index, {
|
||||
operator: operator.value as any,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{operator.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</GroupedInsetListBaseCell>
|
||||
|
||||
<GroupedInsetListBaseCell className="flex flex-row justify-between">
|
||||
<Text className="text-label">Value</Text>
|
||||
{currentField?.type === "view" ? (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex-row items-center gap-1">
|
||||
{currentView?.icon({
|
||||
height: 17,
|
||||
width: 17,
|
||||
color: currentView?.activeColor,
|
||||
})}
|
||||
<Text className="text-label">{currentView?.name || "Select"}</Text>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{views.map((field) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={String(field.view) === item.value}
|
||||
key={String(field.view)}
|
||||
onSelect={() => {
|
||||
actionActions.pathCondition(index, {
|
||||
value: String(field.view),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{field.name}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
) : (
|
||||
<PlainTextField
|
||||
className="w-full flex-1 text-right"
|
||||
value={item.value}
|
||||
onChangeText={(value) => {
|
||||
actionActions.pathCondition(index, { value })
|
||||
}}
|
||||
hitSlop={10}
|
||||
selectionColor={accentColor}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
)}
|
||||
</GroupedInsetListBaseCell>
|
||||
</GroupedInsetListCard>
|
||||
{__DEV__ && (
|
||||
<View className="m-5">
|
||||
<Text className="text-label">{JSON.stringify(item)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<SettingsStackParamList, "EditRewriteRules">
|
||||
}) => {
|
||||
const { index } = route.params
|
||||
const rule = useActionRule(index)
|
||||
|
||||
return (
|
||||
<SafeModalScrollView className="bg-system-grouped-background">
|
||||
<ModalHeader headerTitle="Edit Rewrite Rules" />
|
||||
<GroupedInsetListSectionHeader label="Rewrite Rules" />
|
||||
{rule?.result.rewriteRules?.map((rewriteRule, rewriteRuleIndex) => (
|
||||
<GroupedInsetListCard key={rewriteRuleIndex} className="mb-4">
|
||||
<GroupedInsetListBaseCell className="flex-row">
|
||||
<Text>From</Text>
|
||||
<PlainTextField
|
||||
className="w-full flex-1 text-right"
|
||||
value={rewriteRule.from}
|
||||
onChangeText={(value) => {
|
||||
actionActions.updateRewriteRule({
|
||||
index,
|
||||
rewriteRuleIndex,
|
||||
key: "from",
|
||||
value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</GroupedInsetListBaseCell>
|
||||
<GroupedInsetListBaseCell className="flex-row">
|
||||
<Text>To</Text>
|
||||
<PlainTextField
|
||||
className="w-full flex-1 text-right"
|
||||
value={rewriteRule.to}
|
||||
onChangeText={(value) => {
|
||||
actionActions.updateRewriteRule({
|
||||
index,
|
||||
rewriteRuleIndex,
|
||||
key: "to",
|
||||
value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</GroupedInsetListBaseCell>
|
||||
</GroupedInsetListCard>
|
||||
))}
|
||||
<GroupedInsetListCard>
|
||||
<GroupedPlainButtonCell
|
||||
label="Add"
|
||||
onPress={() => {
|
||||
actionActions.addRewriteRule(index)
|
||||
}}
|
||||
/>
|
||||
</GroupedInsetListCard>
|
||||
{__DEV__ && <Text>{JSON.stringify(rule?.result.rewriteRules, null, 2)}</Text>}
|
||||
</SafeModalScrollView>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<SettingsStackParamList, "EditRule">
|
||||
}) => {
|
||||
const { index } = route.params
|
||||
const rule = useActionRule(index)
|
||||
|
||||
return (
|
||||
<SafeNavigationScrollView
|
||||
className="bg-system-grouped-background"
|
||||
contentContainerClassName="mt-6"
|
||||
>
|
||||
<NavigationBlurEffectHeader title={`Edit Rule - ${rule?.name}`} />
|
||||
<RuleImpl index={index} />
|
||||
</SafeNavigationScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const RuleImpl: React.FC<{ index: number }> = ({ index }) => {
|
||||
const rule = useActionRule(index)
|
||||
|
||||
if (!rule) {
|
||||
return <Text>No rule available</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="gap-6">
|
||||
<NameSection rule={rule} />
|
||||
<FilterSection rule={rule} />
|
||||
<ConditionSection filter={rule.condition as any} index={rule.index} />
|
||||
<ActionSection rule={rule} />
|
||||
{__DEV__ && (
|
||||
<View className="mx-6">
|
||||
<Text className="text-label">{JSON.stringify(rule, null, 2)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const NameSection: React.FC<{ rule: ActionRule }> = ({ rule }) => {
|
||||
return (
|
||||
<GroupedInsetListCard>
|
||||
<GroupedInsetListCell label="Name" leftClassName="flex-none" rightClassName="flex-1">
|
||||
<View className="flex-1">
|
||||
<PlainTextField
|
||||
className="text-secondary-label w-full flex-1 text-right"
|
||||
value={rule.name}
|
||||
hitSlop={10}
|
||||
selectionColor={accentColor}
|
||||
onChangeText={(text) => {
|
||||
actionActions.patchRule(rule.index, { name: text })
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</GroupedInsetListCell>
|
||||
</GroupedInsetListCard>
|
||||
)
|
||||
}
|
||||
|
||||
const FilterSection: React.FC<{ rule: ActionRule }> = ({ rule }) => {
|
||||
const hasCustomFilters = rule.condition.length > 0
|
||||
return (
|
||||
<View>
|
||||
<GroupedInsetListSectionHeader label="When feeds match..." />
|
||||
<GroupedInsetListCard>
|
||||
<GroupedInsetListActionCellRadio
|
||||
label="All"
|
||||
selected={!hasCustomFilters}
|
||||
onPress={() => {
|
||||
actionActions.toggleRuleFilter(rule.index)
|
||||
}}
|
||||
/>
|
||||
<GroupedInsetListActionCellRadio
|
||||
label="Custom filters"
|
||||
selected={hasCustomFilters}
|
||||
onPress={() => {
|
||||
actionActions.toggleRuleFilter(rule.index)
|
||||
}}
|
||||
/>
|
||||
</GroupedInsetListCard>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const ConditionSection: React.FC<{ filter: ActionFilter; index: number }> = ({ filter, index }) => {
|
||||
const navigation = useSettingsNavigation()
|
||||
|
||||
if (filter.length === 0) return null
|
||||
return (
|
||||
<View>
|
||||
<GroupedInsetListSectionHeader label="Conditions" />
|
||||
<GroupedInsetListCard>
|
||||
{filter.map((group, groupIndex) => {
|
||||
return (
|
||||
<Fragment key={groupIndex}>
|
||||
{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 (
|
||||
<Fragment
|
||||
key={`${groupIndex}-${itemIndex}-${item.field}-${item.operator}-${item.value}`}
|
||||
>
|
||||
<SwipeableItem
|
||||
swipeRightToCallAction
|
||||
rightActions={[
|
||||
{
|
||||
label: "Delete",
|
||||
onPress: () => {
|
||||
actionActions.deleteConditionItem({
|
||||
ruleIndex: index,
|
||||
groupIndex,
|
||||
conditionIndex: itemIndex,
|
||||
})
|
||||
},
|
||||
backgroundColor: "red",
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
onPress: () => {
|
||||
navigation.navigate("EditCondition", {
|
||||
ruleIndex: index,
|
||||
groupIndex,
|
||||
conditionIndex: itemIndex,
|
||||
})
|
||||
},
|
||||
backgroundColor: "#0ea5e9",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<GroupedInsetListActionCell
|
||||
label={
|
||||
[currentField?.label, currentOperator?.label, currentValue]
|
||||
.filter(Boolean)
|
||||
.join(" ") || "Unknown"
|
||||
}
|
||||
onPress={() => {
|
||||
navigation.navigate("EditCondition", {
|
||||
ruleIndex: index,
|
||||
groupIndex,
|
||||
conditionIndex: itemIndex,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</SwipeableItem>
|
||||
{itemIndex === group.length - 1 && (
|
||||
<GroupedPlainButtonCell
|
||||
label="And"
|
||||
textClassName="text-left"
|
||||
onPress={() => {
|
||||
actionActions.addConditionItem({ ruleIndex: index, groupIndex })
|
||||
setTimeout(() => {
|
||||
navigation.navigate("EditCondition", {
|
||||
ruleIndex: index,
|
||||
groupIndex,
|
||||
conditionIndex: group.length,
|
||||
})
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
<GroupedPlainButtonCell
|
||||
label="Or"
|
||||
onPress={() => {
|
||||
actionActions.addConditionGroup({ ruleIndex: index })
|
||||
}}
|
||||
/>
|
||||
</GroupedInsetListCard>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View>
|
||||
<GroupedInsetListSectionHeader label="Then do..." />
|
||||
<GroupedInsetListCard>
|
||||
{enabledActions.map((action) => (
|
||||
<SwipeableItem
|
||||
key={action.value}
|
||||
rightActions={[
|
||||
{
|
||||
label: "Delete",
|
||||
onPress: () => {
|
||||
actionActions.deleteRuleAction(rule.index, action.value)
|
||||
},
|
||||
backgroundColor: "red",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{action.component ? (
|
||||
<action.component rule={rule} />
|
||||
) : action.onNavigate ? (
|
||||
<GroupedInsetListActionCell
|
||||
label={action.label}
|
||||
onPress={() => action.onNavigate?.(navigation, rule.index)}
|
||||
/>
|
||||
) : (
|
||||
<GroupedInsetListCell
|
||||
label={action.label}
|
||||
leftClassName="flex-none"
|
||||
rightClassName="flex-1 flex-row justify-end"
|
||||
/>
|
||||
)}
|
||||
</SwipeableItem>
|
||||
))}
|
||||
{notEnabledActions.length > 0 && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<GroupedPlainButtonCell label="Add" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{notEnabledActions.map((action) => (
|
||||
<DropdownMenu.Item
|
||||
key={action.value}
|
||||
onSelect={() => {
|
||||
if (action.onEnable) {
|
||||
action.onEnable(rule.index)
|
||||
} else {
|
||||
actionActions.patchRule(rule.index, { result: { [action.value]: true } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{action.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
</GroupedInsetListCard>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<SettingsStackParamList, "EditWebhooks">
|
||||
}) => {
|
||||
const { index } = route.params
|
||||
const rule = useActionRule(index)
|
||||
|
||||
return (
|
||||
<SafeModalScrollView className="bg-system-grouped-background">
|
||||
<ModalHeader headerTitle="Edit Webhooks" />
|
||||
<GroupedInsetListSectionHeader label="Webhooks" />
|
||||
<GroupedInsetListCard>
|
||||
{rule?.result.webhooks?.map((webhook, webhookIndex) => (
|
||||
<GroupedInsetListBaseCell className="flex-row" key={webhookIndex}>
|
||||
<PlainTextField
|
||||
placeholder="https://"
|
||||
inputMode="url"
|
||||
value={webhook}
|
||||
onChangeText={(value) => {
|
||||
actionActions.updateWebhook({ index, webhookIndex, value })
|
||||
}}
|
||||
/>
|
||||
</GroupedInsetListBaseCell>
|
||||
))}
|
||||
<GroupedInsetButtonCell
|
||||
label="Add"
|
||||
onPress={() => {
|
||||
actionActions.addWebhook(index)
|
||||
}}
|
||||
/>
|
||||
</GroupedInsetListCard>
|
||||
{__DEV__ && <Text>{JSON.stringify(rule?.result.webhooks, null, 2)}</Text>}
|
||||
</SafeModalScrollView>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<any, any>) => {
|
|||
<Stack.Screen key="Privacy" name="Privacy" component={PrivacyScreen} />
|
||||
<Stack.Screen key="About" name="About" component={AboutScreen} />
|
||||
|
||||
{/* @ts-expect-error */}
|
||||
<Stack.Screen key="ManageList" name="ManageList" component={ManageListScreen} />
|
||||
<Stack.Screen
|
||||
key="ManageList"
|
||||
name="ManageList"
|
||||
/* @ts-expect-error */
|
||||
component={ManageListScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
key="EditRule"
|
||||
name="EditRule"
|
||||
/* @ts-expect-error */
|
||||
component={EditRuleScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
key="EditCondition"
|
||||
name="EditCondition"
|
||||
/* @ts-expect-error */
|
||||
component={EditConditionScreen}
|
||||
options={{
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
key="EditRewriteRules"
|
||||
name="EditRewriteRules"
|
||||
/* @ts-expect-error */
|
||||
component={EditRewriteRulesScreen}
|
||||
options={{
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
key="EditWebhooks"
|
||||
name="EditWebhooks"
|
||||
/* @ts-expect-error */
|
||||
component={EditWebhooksScreen}
|
||||
options={{
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen key="EditProfile" name="EditProfile" component={EditProfileScreen} />
|
||||
<Stack.Screen key="ResetPassword" name="ResetPassword" component={ResetPassword} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -15,5 +15,9 @@ export namespace HonoApiClient {
|
|||
export type List_List_Get = ExtractData<typeof apiClient.lists.list.$get>[number]
|
||||
export type Feed_Get = ExtractData<typeof apiClient.feeds.$get>
|
||||
|
||||
export type ActionRule = Exclude<
|
||||
ExtractData<typeof apiClient.actions.$get>["rules"],
|
||||
undefined | null
|
||||
>[number]
|
||||
export type ActionSettings = Exclude<Entry_Post[number]["settings"], undefined>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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<ActionStore>("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<ActionRule>) {
|
||||
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<ActionFilterItem>) {
|
||||
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<ConditionIndex, "conditionIndex">) {
|
||||
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<ConditionIndex, "conditionIndex" | "groupIndex">) {
|
||||
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()
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { HonoApiClient } from "@/src/morph/types"
|
||||
|
||||
export type ActionFilterItem = Partial<
|
||||
Exclude<HonoApiClient.ActionRule["condition"][number], { length: number }>
|
||||
>
|
||||
export type ActionFilterGroup = ActionFilterItem[]
|
||||
export type ActionFilter = ActionFilterGroup[]
|
||||
|
||||
export type ActionRule = Omit<HonoApiClient.ActionRule, "condition"> & {
|
||||
condition: ActionFilter
|
||||
index: number
|
||||
}
|
||||
export type ActionId = Exclude<keyof ActionRule["result"], "disabled">
|
||||
export type ActionRules = ActionRule[]
|
||||
|
||||
export type ConditionIndex = {
|
||||
ruleIndex: number
|
||||
groupIndex: number
|
||||
conditionIndex: number
|
||||
}
|
||||
Loading…
Reference in New Issue