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:
parent
e91b2bfa6a
commit
e9e67de25f
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./modal"
|
||||
export * as ModalTemplate from "./templates"
|
||||
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import "./global.css"
|
||||
import "./polyfill"
|
||||
|
||||
import {
|
||||
apiClientSimpleContext,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
// import "core-js/proposals/promise-with-resolvers";
|
||||
import "./promise-with-resolvers"
|
||||
|
|
@ -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 {}
|
||||
Loading…
Reference in New Issue