diff --git a/apps/mobile/src/lib/dialog.tsx b/apps/mobile/src/lib/dialog.tsx index 118b9c49a..057d55112 100644 --- a/apps/mobile/src/lib/dialog.tsx +++ b/apps/mobile/src/lib/dialog.tsx @@ -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 { title?: string @@ -18,6 +27,12 @@ export interface DialogProps { 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 void) | null>> + setOnCancel: Dispatch void) | null>> +}>({ + setOnConfirm: () => {}, + setOnCancel: () => {}, +}) + const DialogContext = createContext(null) export type DialogComponent = FC & Omit, "content"> @@ -37,6 +69,41 @@ class DialogStatic { } private currentStackedDialogs = new Set() + + // 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(propsOrComponent: DialogProps | DialogComponent) { 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, + }) + ) : ( + + ) + const siblings = new RootSiblings( ( @@ -78,32 +155,27 @@ class DialogStatic { exiting={exiting} > - {children} + + {Header} + + {children} + - - - - {props.cancelText ?? "Cancel"} - - + + - { - props.onConfirm?.(mergeCtx(ctx)) - }} - > - - {props.confirmText ?? "Confirm"} - - - + + + ), @@ -124,3 +196,65 @@ const SafeInsetTop = () => { return } export const Dialog = new DialogStatic() + +const DefaultHeader = (props: { title?: string; headerIcon?: ReactNode }) => { + const label = useColor("label") + if (!props.title) return null + return ( + + {isValidElement(props.headerIcon) && + props.headerIcon && + typeof props.headerIcon === "object" && + cloneElement(props.headerIcon as ReactElement, { + color: label, + height: 20, + width: 20, + })} + {props.title} + + ) +} + +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 ( + + + {props.text} + + + ) +} + +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 ( + + + {props.children} + + + ) +} diff --git a/apps/mobile/src/modules/dialogs/AddFeedDialog.tsx b/apps/mobile/src/modules/dialogs/AddFeedDialog.tsx index 8837b0e43..e8875cea5 100644 --- a/apps/mobile/src/modules/dialogs/AddFeedDialog.tsx +++ b/apps/mobile/src/modules/dialogs/AddFeedDialog.tsx @@ -26,22 +26,20 @@ export const AddFeedDialog: DialogComponent<{ return ( - - - - Enter Feed URL or RSSHub URL - - (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://" - /> + + + Enter Feed URL or RSSHub URL + (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://" + /> ) } diff --git a/apps/mobile/src/modules/dialogs/MarkAllAsReadDialog.tsx b/apps/mobile/src/modules/dialogs/MarkAllAsReadDialog.tsx new file mode 100644 index 000000000..de723cf11 --- /dev/null +++ b/apps/mobile/src/modules/dialogs/MarkAllAsReadDialog.tsx @@ -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 ( + + Do you want to mark all items as read? + { + ctx?.dismiss() + + if (typeof selectedView === "number") { + unreadSyncService.markViewAsRead(selectedView) + } + }} + /> + + ) +} + +MarkAllAsReadDialog.title = "Mark All as Read" +MarkAllAsReadDialog.id = "mark-all-as-read" + +MarkAllAsReadDialog.title = "Mark All as Read" +MarkAllAsReadDialog.headerIcon = diff --git a/apps/mobile/src/modules/screen/action.tsx b/apps/mobile/src/modules/screen/action.tsx index 530a39617..8c37b9306 100644 --- a/apps/mobile/src/modules/screen/action.tsx +++ b/apps/mobile/src/modules/screen/action.tsx @@ -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) { } + onPress={() => { + Dialog.show(MarkAllAsReadDialog) + }} /> )