feat: implement imperative modal (#4041)

* feat: implement ImperativeModalProvider with modal context and controls

* fix: make onClose prop optional for bottom modal

* chore: add ImperativeModalProvider to context providers

* feat: wrap BottomModal content in KeyboardAvoidingView for better keyboard handling

* chore: add polyfill for Promise.withResolvers

* fix: wrap ImperativeModal in a View and update onClose to accept modal ID

* feat: implement ImperativeModal with context and modal control hooks

* refactor: redesign imperative modal with root-siblings

* fix: wrap BottomModal in a View to resolve rendering issues on Android

* refactor: replace Alert.prompt with modalPrompt for category creation and renaming
This commit is contained in:
Whitewater 2025-07-03 01:20:41 +08:00 committed by GitHub
parent e91b2bfa6a
commit e9e67de25f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 352 additions and 64 deletions

View File

@ -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 (
<Modal
visible={internalVisible}
transparent={true}
animationType="none"
onRequestClose={hideModal}
statusBarTranslucent
navigationBarTranslucent
>
<Animated.View className="absolute inset-0 bg-black" style={backdropStyle}>
<Pressable
className="flex-1"
onPress={handleBackdropPress}
android_ripple={{ color: "white" }}
/>
</Animated.View>
<Animated.View
className={cn(
"bg-system-background mt-auto flex-1 overflow-hidden rounded-t-2xl",
className,
)}
style={modalContentStyle}
// Wrap in a View to avoid rendering issues with Modal on Android
<View>
<Modal
visible={internalVisible}
transparent={true}
animationType="none"
onRequestClose={hideModal}
statusBarTranslucent
navigationBarTranslucent
>
{children}
</Animated.View>
</Modal>
<KeyboardAvoidingView className="flex-1" behavior="padding">
<Animated.View className="absolute inset-0 bg-black" style={backdropStyle}>
<Pressable
className="flex-1"
onPress={handleBackdropPress}
android_ripple={{ color: "white" }}
/>
</Animated.View>
<Animated.View
className={cn(
"bg-system-background mt-auto flex-1 overflow-hidden rounded-t-2xl",
className,
)}
style={modalContentStyle}
>
{children}
</Animated.View>
</KeyboardAvoidingView>
</Modal>
</View>
)
}

View File

@ -0,0 +1,2 @@
export * from "./modal"
export * as ModalTemplate from "./templates"

View File

@ -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<Modal, "id" | "closeOnBackdropPress"> & {
/**
* 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<void>()
const abortController = modal.abortController || new AbortController()
const node = (
<BottomModal
id={modal.id || nanoid()}
visible={true}
{...modal}
onClose={() => {
abortController.abort()
siblings.destroy()
modal.onClose?.()
promise.resolve()
}}
>
{modal.content}
</BottomModal>
)
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 (
<View className="flex-1">
<Header
renderLeft={() => (
<HeaderSubmitTextButton
label={t("words.cancel", { ns: "common" })}
isValid={true}
onPress={() => {
abortController.abort()
}}
/>
)}
>
<HeaderText>{title}</HeaderText>
</Header>
<View className="flex flex-1 gap-4 p-4">
<Input
className="box-border w-full"
value={text}
onChangeText={setText}
placeholder={placeholder}
/>
<TouchableOpacity
className="bg-accent w-full rounded-xl px-6 py-3"
disabled={text.trim().length === 0}
onPress={() => {
onSave(text)
abortController.abort()
}}
>
<Text className="text-center text-base font-semibold text-white">
{t("words.save", { ns: "common" })}
</Text>
</TouchableOpacity>
</View>
</View>
)
}
export const modalPrompt = (
title: string,
message: string,
callback: (text: string) => void,
type?: undefined,
defaultValue?: string,
) => {
const abortController = new AbortController()
openModal({
abortController,
closeOnBackdropPress: false,
content: (
<PromptModal
title={title}
defaultValue={defaultValue}
placeholder={message}
abortController={abortController}
onSave={callback}
/>
),
})
}

View File

@ -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<ViewStyle>
onLayout?: (event: LayoutChangeEvent) => void
}) {
return (
<View
className={cn(
"border-non-opaque-separator relative min-h-[50px] w-full flex-row items-center justify-center rounded-t-md border-b py-2",
className,
)}
style={style}
onLayout={onLayout}
>
{renderLeft && <View className="absolute left-1">{renderLeft()}</View>}
{children}
{renderRight && <View className="absolute right-1">{renderRight()}</View>}
</View>
)
}
export function HeaderText({
children,
style,
}: {
children?: React.ReactNode
style?: StyleProp<TextStyle>
}) {
return (
<Text className="text-center text-lg font-bold" style={style}>
{children}
</Text>
)
}
export function Input({
value,
onChangeText,
placeholder,
className,
style,
wrapperClassName,
wrapperStyle,
...rest
}: {
value: string
onChangeText: (text: string) => void
placeholder?: string
className?: string
style?: StyleProp<TextStyle>
wrapperClassName?: string
wrapperStyle?: StyleProp<ViewStyle>
} & TextInputProps) {
return (
<View
className={cn(
"bg-tertiary-system-fill relative h-10 flex-row items-center rounded-lg px-3",
wrapperClassName,
)}
style={wrapperStyle}
>
<TextInput
className={cn("text-label w-full flex-1 p-0", className)}
clearButtonMode="always"
style={style}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
{...rest}
/>
</View>
)
}

View File

@ -1,4 +1,5 @@
import "./global.css"
import "./polyfill"
import {
apiClientSimpleContext,

View File

@ -201,31 +201,28 @@ const SelectableTextSheet: FC<{
}
return (
// Wrap in a View to avoid rendering issues with Modal on Android
<View>
<BottomModal visible={visible} onClose={onClose}>
<View className="p-4" style={{ paddingBottom: insets.bottom + 10 }}>
<View className="mb-4 flex-row items-center justify-between">
<TouchableOpacity
onPress={handleCopyAll}
className="rounded-full bg-zinc-100 p-2 active:opacity-80 dark:bg-zinc-800"
>
<CopyCuteReIcon width={18} height={18} color={textColor} />
</TouchableOpacity>
<Text className="text-label text-lg font-semibold">AI Summary</Text>
<TouchableOpacity
onPress={onClose}
className="rounded-full bg-zinc-100 p-2 active:opacity-80 dark:bg-zinc-800"
>
<CloseCuteReIcon width={18} height={18} color={textColor} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<SelectableText className="text-label text-base leading-6">{text}</SelectableText>
</ScrollView>
<BottomModal visible={visible} onClose={onClose}>
<View className="p-4" style={{ paddingBottom: insets.bottom + 10 }}>
<View className="mb-4 flex-row items-center justify-between">
<TouchableOpacity
onPress={handleCopyAll}
className="rounded-full bg-zinc-100 p-2 active:opacity-80 dark:bg-zinc-800"
>
<CopyCuteReIcon width={18} height={18} color={textColor} />
</TouchableOpacity>
<Text className="text-label text-lg font-semibold">AI Summary</Text>
<TouchableOpacity
onPress={onClose}
className="rounded-full bg-zinc-100 p-2 active:opacity-80 dark:bg-zinc-800"
>
<CloseCuteReIcon width={18} height={18} color={textColor} />
</TouchableOpacity>
</View>
</BottomModal>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<SelectableText className="text-label text-base leading-6">{text}</SelectableText>
</ScrollView>
</View>
</BottomModal>
)
}

View File

@ -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 = ({
<ContextMenu.Item
key="EditCategory"
onSelect={() => {
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,
)
}}
>

View File

@ -0,0 +1,2 @@
// import "core-js/proposals/promise-with-resolvers";
import "./promise-with-resolvers"

View File

@ -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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new this<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
}
export {}