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