feat(mobile): action page (#3019)

This commit is contained in:
Stephen Zhou 2025-03-13 10:34:35 +08:00 committed by GitHub
parent 8966a534d5
commit 94d3eb6dfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1358 additions and 13 deletions

View File

@ -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>
)

View File

@ -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" }) => {

View File

@ -97,7 +97,6 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
navigation.navigate("Actions")
},
iconBackgroundColor: "#059669",
todo: true,
anonymous: false,
},

View File

@ -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>
)
}

View File

@ -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",
},
]

View File

@ -6,3 +6,5 @@ import type { SettingsStackParamList } from "./types"
export const useSettingsNavigation = () => {
return useNavigation<NativeStackNavigationProp<SettingsStackParamList>>()
}
export type SettingsNavigation = ReturnType<typeof useSettingsNavigation>

View File

@ -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>
)
}

View File

@ -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>
)}
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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} />

View File

@ -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 }

View File

@ -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>
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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
}