feat(mobile): enhance dialog system with dynamic actions and improved header

- Refactor dialog library to support dynamic button actions
- Add support for custom header components and icons
- Improve AddFeedDialog layout and interaction
- Implement MarkAllAsReadDialog integration in home screen actions

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-02-24 23:05:17 +08:00
parent c7256cb331
commit fce03fe977
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
4 changed files with 212 additions and 43 deletions

View File

@ -1,14 +1,23 @@
import type { FC, ReactNode } from "react"
import { createContext, createElement, useContext } from "react"
import { cn } from "@follow/utils"
import type { Dispatch, FC, ReactElement, ReactNode, SetStateAction } from "react"
import {
cloneElement,
createContext,
createElement,
isValidElement,
useContext,
useEffect,
useMemo,
useState,
} from "react"
import { Text, TouchableOpacity, View } from "react-native"
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"
import RootSiblings from "react-native-root-siblings"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { getColor } from "react-native-uikit-colors"
import { useColor } from "react-native-uikit-colors"
import { FullWindowOverlay } from "../components/common/FullWindowOverlay"
import { Overlay } from "../components/ui/overlay/Overlay"
import { accentColor } from "../theme/colors"
export interface DialogProps<Ctx> {
title?: string
@ -18,6 +27,12 @@ export interface DialogProps<Ctx> {
onConfirm?: (ctx: Ctx & DialogContextType) => void
cancelText?: string
confirmText?: string
headerIcon?: ReactNode
HeaderComponent?: FC<{
title: string
onClose: () => void
}>
id: string
}
@ -28,6 +43,23 @@ const exiting = SlideOutUp.duration(200)
type DialogContextType = {
dismiss: () => void
}
const DialogDynamicButtonActionContext = createContext<{
onConfirm: (() => void) | null
onCancel: (() => void) | null
}>({
onConfirm: null,
onCancel: null,
})
const SetDialogDynamicButtonActionContext = createContext<{
setOnConfirm: Dispatch<SetStateAction<(() => void) | null>>
setOnCancel: Dispatch<SetStateAction<(() => void) | null>>
}>({
setOnConfirm: () => {},
setOnCancel: () => {},
})
const DialogContext = createContext<DialogContextType | null>(null)
export type DialogComponent<Ctx = unknown> = FC<DialogContextType & { ctx: Ctx }> &
Omit<DialogProps<Ctx>, "content">
@ -37,6 +69,41 @@ class DialogStatic {
}
private currentStackedDialogs = new Set<string>()
// Components
DialogConfirm: FC<{ onPress: () => void }> = ({ onPress }) => {
const { setOnConfirm } = useContext(SetDialogDynamicButtonActionContext)
useEffect(() => {
setOnConfirm(() => {
return onPress
})
}, [onPress, setOnConfirm])
return null
}
DialogCancel: FC<{ onPress: () => void }> = ({ onPress }) => {
const { setOnCancel } = useContext(SetDialogDynamicButtonActionContext)
const { dismiss } = useContext(DialogContext)!
useEffect(() => {
let timeout: NodeJS.Timeout
setOnCancel(() => {
return () => {
dismiss()
clearTimeout(timeout)
timeout = setTimeout(() => {
onPress()
}, 16)
}
})
return () => {
clearTimeout(timeout)
}
}, [onPress, setOnCancel, dismiss])
return null
}
show<Ctx>(propsOrComponent: DialogProps<Ctx> | DialogComponent<Ctx>) {
const isExist = this.currentStackedDialogs.has(propsOrComponent.id)
if (isExist) {
@ -68,6 +135,16 @@ class DialogStatic {
props.onClose?.(mergeCtx(ctx))
}, 16)
}
const Header = props.HeaderComponent ? (
createElement(props.HeaderComponent, {
title: props.title ?? "",
onClose: handleClose,
})
) : (
<DefaultHeader title={props.title} headerIcon={props.headerIcon} />
)
const siblings = new RootSiblings(
(
<FullWindowOverlay>
@ -78,32 +155,27 @@ class DialogStatic {
exiting={exiting}
>
<SafeInsetTop />
<DialogContext.Provider value={reactCtx}>{children}</DialogContext.Provider>
<DialogDynamicButtonActionProvider>
{Header}
<View className="px-6 py-4">
<DialogContext.Provider value={reactCtx}>{children}</DialogContext.Provider>
</View>
<View className="flex-row gap-4 px-6 pb-4">
<TouchableOpacity
className="bg-system-fill flex-1 items-center justify-center rounded-full px-6 py-3"
onPress={handleClose}
>
<Text className="text-label text-base font-medium">
{props.cancelText ?? "Cancel"}
</Text>
</TouchableOpacity>
<View className="flex-row gap-4 px-6 pb-4">
<DialogDynamicButtonAction
fallbackCaller={handleClose}
text={props.cancelText ?? "Cancel"}
type="cancel"
/>
<TouchableOpacity
className="flex-1 items-center justify-center rounded-full bg-accent px-6 py-3"
style={{
backgroundColor: props.variant === "destructive" ? getColor("red") : accentColor,
}}
onPress={() => {
props.onConfirm?.(mergeCtx(ctx))
}}
>
<Text className="text-label text-base font-semibold">
{props.confirmText ?? "Confirm"}
</Text>
</TouchableOpacity>
</View>
<DialogDynamicButtonAction
fallbackCaller={handleClose}
text={props.confirmText ?? "Confirm"}
type="confirm"
className={props.variant === "destructive" ? "bg-red" : "bg-accent"}
/>
</View>
</DialogDynamicButtonActionProvider>
</Animated.View>
</FullWindowOverlay>
),
@ -124,3 +196,65 @@ const SafeInsetTop = () => {
return <View style={{ height: insets.top }} />
}
export const Dialog = new DialogStatic()
const DefaultHeader = (props: { title?: string; headerIcon?: ReactNode }) => {
const label = useColor("label")
if (!props.title) return null
return (
<View className="flex-row items-center gap-2 px-6">
{isValidElement(props.headerIcon) &&
props.headerIcon &&
typeof props.headerIcon === "object" &&
cloneElement(props.headerIcon as ReactElement, {
color: label,
height: 20,
width: 20,
})}
<Text className="text-label text-lg font-semibold">{props.title}</Text>
</View>
)
}
const DialogDynamicButtonAction = (props: {
type: "confirm" | "cancel"
text: string
fallbackCaller: () => void
className?: string
textClassName?: string
}) => {
const { onConfirm, onCancel } = useContext(DialogDynamicButtonActionContext)
const caller =
{
confirm: onConfirm,
cancel: onCancel,
}[props.type] || props.fallbackCaller
return (
<TouchableOpacity
className={cn(
"bg-system-fill flex-1 items-center justify-center rounded-full px-6 py-3",
props.className,
)}
onPress={caller}
>
<Text className={cn("text-label text-base font-medium", props.textClassName)}>
{props.text}
</Text>
</TouchableOpacity>
)
}
const DialogDynamicButtonActionProvider = (props: { children: ReactNode }) => {
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null)
const [onCancel, setOnCancel] = useState<(() => void) | null>(null)
const ctx1 = useMemo(() => ({ onConfirm, onCancel }), [onConfirm, onCancel])
const ctx2 = useMemo(() => ({ setOnConfirm, setOnCancel }), [setOnConfirm, setOnCancel])
return (
<DialogDynamicButtonActionContext.Provider value={ctx1}>
<SetDialogDynamicButtonActionContext.Provider value={ctx2}>
{props.children}
</SetDialogDynamicButtonActionContext.Provider>
</DialogDynamicButtonActionContext.Provider>
)
}

View File

@ -26,22 +26,20 @@ export const AddFeedDialog: DialogComponent<{
return (
<View>
<View className="px-6 py-4">
<View className="flex-row items-center gap-2">
<LinkCuteReIcon color={label} height={20} width={20} />
<Text className="text-label text-base font-medium">Enter Feed URL or RSSHub URL</Text>
</View>
<TextInput
onChangeText={(text) => (ctx.url = text)}
autoFocus
enterKeyHint="done"
cursorColor={accentColor}
selectionColor={accentColor}
onSubmitEditing={handleAdd}
className="bg-system-background dark:bg-secondary-system-fill/30 text-text my-3 rounded-xl"
placeholder="https:// or rsshub://"
/>
<View className="flex-row items-center gap-2">
<LinkCuteReIcon color={label} height={20} width={20} />
<Text className="text-label text-base font-medium">Enter Feed URL or RSSHub URL</Text>
</View>
<TextInput
onChangeText={(text) => (ctx.url = text)}
autoFocus
enterKeyHint="done"
cursorColor={accentColor}
selectionColor={accentColor}
onSubmitEditing={handleAdd}
className="bg-system-background dark:bg-secondary-system-fill/30 text-text my-3 rounded-xl"
placeholder="https:// or rsshub://"
/>
</View>
)
}

View File

@ -0,0 +1,33 @@
import { Text, View } from "react-native"
import { CheckCircleCuteReIcon } from "@/src/icons/check_circle_cute_re"
import type { DialogComponent } from "@/src/lib/dialog"
import { Dialog } from "@/src/lib/dialog"
import { unreadSyncService } from "@/src/store/unread/store"
import { useSelectedView } from "../feed-drawer/atoms"
export const MarkAllAsReadDialog: DialogComponent = () => {
const selectedView = useSelectedView()
const ctx = Dialog.useDialogContext()
return (
<View>
<Text className="text-label">Do you want to mark all items as read?</Text>
<Dialog.DialogConfirm
onPress={() => {
ctx?.dismiss()
if (typeof selectedView === "number") {
unreadSyncService.markViewAsRead(selectedView)
}
}}
/>
</View>
)
}
MarkAllAsReadDialog.title = "Mark All as Read"
MarkAllAsReadDialog.id = "mark-all-as-read"
MarkAllAsReadDialog.title = "Mark All as Read"
MarkAllAsReadDialog.headerIcon = <CheckCircleCuteReIcon />

View File

@ -14,6 +14,7 @@ import { useWhoami } from "@/src/store/user/hooks"
import { accentColor, useColor } from "@/src/theme/colors"
import { AddFeedDialog } from "../dialogs/AddFeedDialog"
import { MarkAllAsReadDialog } from "../dialogs/MarkAllAsReadDialog"
const ActionGroup = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
return (
@ -40,6 +41,9 @@ export function HomeSharedRightAction(props: PropsWithChildren) {
<UIBarButton
label="Mark All as Read"
normalIcon={<CheckCircleCuteReIcon height={20} width={20} color={accentColor} />}
onPress={() => {
Dialog.show(MarkAllAsReadDialog)
}}
/>
</ActionGroup>
)