diff --git a/apps/mobile/src/components/ui/modal/BottomModal.tsx b/apps/mobile/src/components/ui/modal/BottomModal.tsx index c0831c479..891517801 100644 --- a/apps/mobile/src/components/ui/modal/BottomModal.tsx +++ b/apps/mobile/src/components/ui/modal/BottomModal.tsx @@ -1,7 +1,7 @@ import { cn } from "@follow/utils/utils" import type { ReactNode } from "react" import { useCallback, useEffect, useState } from "react" -import { Modal, Pressable } from "react-native" +import { KeyboardAvoidingView, Modal, Pressable, View } from "react-native" import Animated, { Easing, runOnJS, @@ -10,7 +10,8 @@ import Animated, { withTiming, } from "react-native-reanimated" -interface BottomModalProps { +export interface BottomModalProps { + // ref?: Ref<{ close: () => void }> /** * Whether the modal is visible */ @@ -19,7 +20,7 @@ interface BottomModalProps { /** * Function to call when the modal should be closed (backdrop press or programmatically) */ - onClose: () => void + onClose?: () => void /** * Content to render inside the modal @@ -82,6 +83,19 @@ export function BottomModal({ transform: [{ translateY: contentTranslateY.value }], })) + // useImperativeHandle(ref, () => ({ + // close: () => { + // if (internalVisible) { + // hideModal() + // } + // }, + // open: () => { + // if (!internalVisible) { + // showModal() + // } + // }, + // })) + const showModal = useCallback(() => { setInternalVisible(true) backdropAnim.value = withTiming(backdropOpacity, { @@ -109,7 +123,9 @@ export function BottomModal({ }, () => { runOnJS(setInternalVisible)(false) - runOnJS(onClose)() + if (onClose) { + runOnJS(onClose)() + } }, ) }, [backdropAnim, contentTranslateY, closeDuration, onClose]) @@ -122,9 +138,12 @@ export function BottomModal({ // Start animations when visibility changes useEffect(() => { + if (visible === internalVisible) { + return // No change, do nothing + } if (visible) { showModal() - } else if (internalVisible) { + } else { hideModal() } }, [visible, showModal, hideModal, internalVisible]) @@ -134,31 +153,36 @@ export function BottomModal({ } return ( - - - - - - + - {children} - - + + + + + + + {children} + + + + ) } diff --git a/apps/mobile/src/components/ui/modal/imperative-modal/index.tsx b/apps/mobile/src/components/ui/modal/imperative-modal/index.tsx new file mode 100644 index 000000000..b50a488de --- /dev/null +++ b/apps/mobile/src/components/ui/modal/imperative-modal/index.tsx @@ -0,0 +1,2 @@ +export * from "./modal" +export * as ModalTemplate from "./templates" diff --git a/apps/mobile/src/components/ui/modal/imperative-modal/modal.tsx b/apps/mobile/src/components/ui/modal/imperative-modal/modal.tsx new file mode 100644 index 000000000..ad8f4e8bd --- /dev/null +++ b/apps/mobile/src/components/ui/modal/imperative-modal/modal.tsx @@ -0,0 +1,143 @@ +import { nanoid } from "nanoid/non-secure" +import type { ReactNode } from "react" +import { cloneElement, useState } from "react" +import { useTranslation } from "react-i18next" +import { Text, TouchableOpacity, View } from "react-native" +import RootSiblings from "react-native-root-siblings" + +import { HeaderSubmitTextButton } from "@/src/components/layouts/header/HeaderElements" + +import type { BottomModalProps } from "../BottomModal" +import { BottomModal } from "../BottomModal" +import { Header, HeaderText, Input } from "./templates" + +export type Modal = { id: string; content: ReactNode } & Omit< + BottomModalProps, + "visible" | "children" +> + +export type ModalInput = Omit & { + /** + * Optional: Will be auto-generated with nanoid if not provided + */ + id?: string + /** + * Default is true + */ + closeOnBackdropPress?: false + /** + * Select template for the modal. + * + * @default 'plain' + */ + // type?: "plain" // | 'select' | 'confirm' | 'input' | 'custom' + abortController?: AbortController +} + +export const openModal = (modal: ModalInput) => { + const promise = Promise.withResolvers() + const abortController = modal.abortController || new AbortController() + + const node = ( + { + abortController.abort() + siblings.destroy() + modal.onClose?.() + promise.resolve() + }} + > + {modal.content} + + ) + const siblings = new RootSiblings(node) + + abortController.signal.addEventListener("abort", () => { + const newNode = cloneElement(node, { + visible: false, + }) + siblings.update(newNode) + }) + + return promise.promise +} + +const PromptModal = ({ + defaultValue, + title, + placeholder, + onSave, + abortController, +}: { + defaultValue?: string + title?: string + placeholder?: string + onSave: (newCategory: string) => void + abortController: AbortController +}) => { + const { t } = useTranslation() + const [text, setText] = useState(defaultValue ?? "") + return ( + +
( + { + abortController.abort() + }} + /> + )} + > + {title} +
+ + + { + onSave(text) + abortController.abort() + }} + > + + {t("words.save", { ns: "common" })} + + + +
+ ) +} + +export const modalPrompt = ( + title: string, + message: string, + callback: (text: string) => void, + type?: undefined, + defaultValue?: string, +) => { + const abortController = new AbortController() + openModal({ + abortController, + closeOnBackdropPress: false, + content: ( + + ), + }) +} diff --git a/apps/mobile/src/components/ui/modal/imperative-modal/templates.tsx b/apps/mobile/src/components/ui/modal/imperative-modal/templates.tsx new file mode 100644 index 000000000..ef35749d2 --- /dev/null +++ b/apps/mobile/src/components/ui/modal/imperative-modal/templates.tsx @@ -0,0 +1,93 @@ +import { cn } from "@follow/utils" +import type { + LayoutChangeEvent, + StyleProp, + TextInputProps, + TextStyle, + ViewStyle, +} from "react-native" +import { Text, TextInput, View } from "react-native" + +export function Header({ + renderLeft, + renderRight, + children, + className, + style, + onLayout, +}: { + renderLeft?: () => React.ReactNode + renderRight?: () => React.ReactNode + children?: React.ReactNode + className?: string + style?: StyleProp + onLayout?: (event: LayoutChangeEvent) => void +}) { + return ( + + {renderLeft && {renderLeft()}} + {children} + {renderRight && {renderRight()}} + + ) +} + +export function HeaderText({ + children, + style, +}: { + children?: React.ReactNode + style?: StyleProp +}) { + return ( + + {children} + + ) +} + +export function Input({ + value, + onChangeText, + placeholder, + className, + style, + wrapperClassName, + wrapperStyle, + ...rest +}: { + value: string + onChangeText: (text: string) => void + placeholder?: string + className?: string + style?: StyleProp + wrapperClassName?: string + wrapperStyle?: StyleProp +} & TextInputProps) { + return ( + + + + ) +} diff --git a/apps/mobile/src/main.tsx b/apps/mobile/src/main.tsx index d812e0b82..489d86f51 100644 --- a/apps/mobile/src/main.tsx +++ b/apps/mobile/src/main.tsx @@ -1,4 +1,5 @@ import "./global.css" +import "./polyfill" import { apiClientSimpleContext, diff --git a/apps/mobile/src/modules/ai/summary.tsx b/apps/mobile/src/modules/ai/summary.tsx index baedec092..681ef548f 100644 --- a/apps/mobile/src/modules/ai/summary.tsx +++ b/apps/mobile/src/modules/ai/summary.tsx @@ -201,31 +201,28 @@ const SelectableTextSheet: FC<{ } return ( - // Wrap in a View to avoid rendering issues with Modal on Android - - - - - - - - AI Summary - - - - - - {text} - + + + + + + + AI Summary + + + - - + + {text} + + + ) } diff --git a/apps/mobile/src/modules/context-menu/feeds.tsx b/apps/mobile/src/modules/context-menu/feeds.tsx index 387d2b0d1..95684ae35 100644 --- a/apps/mobile/src/modules/context-menu/feeds.tsx +++ b/apps/mobile/src/modules/context-menu/feeds.tsx @@ -17,9 +17,11 @@ import { Alert, FlatList, View } from "react-native" import { useFetchEntriesSettings } from "@/src/atoms/settings/general" import { ContextMenu } from "@/src/components/ui/context-menu" import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" +import { modalPrompt } from "@/src/components/ui/modal/imperative-modal" import { views } from "@/src/constants/views" import { useNavigation } from "@/src/lib/navigation/hooks" import type { Navigation } from "@/src/lib/navigation/Navigation" +import { isIOS } from "@/src/lib/platform" import { toast } from "@/src/lib/toast" import { FollowScreen } from "@/src/screens/(modal)/FollowScreen" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" @@ -158,7 +160,9 @@ const generateSubscriptionContextMenu = (navigation: Navigation, id: string) => // create new category const subscription = getSubscriptionById(id) if (!subscription) return - Alert.prompt("Create New Category", "Enter the name of the new category", (text) => { + const prompt = isIOS ? Alert.prompt : modalPrompt + + prompt("Create New Category", "Enter the name of the new category", (text) => { subscriptionSyncService.edit({ ...subscription, category: text, @@ -327,19 +331,25 @@ export const SubscriptionFeedCategoryContextMenu = ({ { - Alert.prompt( + const prompt = isIOS ? Alert.prompt : modalPrompt + + const handleRenameCategory = async (newCategory: string) => { + if (!newCategory) return + await subscriptionSyncService.renameCategory({ + lastCategory: category, + newCategory, + view: currentView, + }) + toast.success("Category renamed successfully") + } + prompt( t("operation.rename_category"), t("operation.enter_new_name_for_category", { category, }), - (newCategory) => { - if (!newCategory) return - subscriptionSyncService.renameCategory({ - lastCategory: category, - newCategory, - view: currentView, - }) - }, + handleRenameCategory, + undefined, + category, ) }} > diff --git a/apps/mobile/src/polyfill/index.ts b/apps/mobile/src/polyfill/index.ts new file mode 100644 index 000000000..43c905ec4 --- /dev/null +++ b/apps/mobile/src/polyfill/index.ts @@ -0,0 +1,2 @@ +// import "core-js/proposals/promise-with-resolvers"; +import "./promise-with-resolvers" diff --git a/apps/mobile/src/polyfill/promise-with-resolvers.ts b/apps/mobile/src/polyfill/promise-with-resolvers.ts new file mode 100644 index 000000000..23e463830 --- /dev/null +++ b/apps/mobile/src/polyfill/promise-with-resolvers.ts @@ -0,0 +1,16 @@ +// this polyfill can be removed once we drop support for Firefox v121 (after +// June 24 2025) Chrome v119 (after November 14, 2025). + +if (Promise.withResolvers === undefined) { + Promise.withResolvers = function withResolvers() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new this((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } + } +} + +export {}