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:
parent
c7256cb331
commit
fce03fe977
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue