diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/Toast/Toast.tsx | 269 | ||||
-rw-r--r-- | src/components/Toast/index.tsx | 57 | ||||
-rw-r--r-- | src/components/Toast/index.web.tsx | 42 | ||||
-rw-r--r-- | src/components/Toast/sonner/index.ts | 3 | ||||
-rw-r--r-- | src/components/Toast/sonner/index.web.ts | 3 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 39 | ||||
-rw-r--r-- | src/view/screens/Storybook/Toasts.tsx | 89 |
7 files changed, 413 insertions, 89 deletions
diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx index 4d782597d..53d5e5115 100644 --- a/src/components/Toast/Toast.tsx +++ b/src/components/Toast/Toast.tsx @@ -1,21 +1,32 @@ import {createContext, useContext, useMemo} from 'react' -import {View} from 'react-native' +import {type GestureResponderEvent, View} from 'react-native' import {atoms as a, select, useAlf, useTheme} from '#/alf' +import { + Button, + type ButtonProps, + type UninheritableButtonProps, +} from '#/components/Button' +import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' +import {type Props as SVGIconProps} from '#/components/icons/common' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {dismiss} from '#/components/Toast/sonner' import {type ToastType} from '#/components/Toast/types' -import {Text} from '#/components/Typography' -import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '../icons/CircleCheck' +import {Text as BaseText} from '#/components/Typography' -type ContextType = { +type ToastConfigContextType = { + id: string +} + +type ToastThemeContextType = { type: ToastType } export type ToastComponentProps = { type?: ToastType - content: React.ReactNode + content: string } export const ICONS = { @@ -26,81 +37,239 @@ export const ICONS = { info: CircleInfo, } -const Context = createContext<ContextType>({ +const ToastConfigContext = createContext<ToastConfigContextType>({ + id: '', +}) +ToastConfigContext.displayName = 'ToastConfigContext' + +export function ToastConfigProvider({ + children, + id, +}: { + children: React.ReactNode + id: string +}) { + return ( + <ToastConfigContext.Provider value={useMemo(() => ({id}), [id])}> + {children} + </ToastConfigContext.Provider> + ) +} + +const ToastThemeContext = createContext<ToastThemeContextType>({ type: 'default', }) -Context.displayName = 'ToastContext' +ToastThemeContext.displayName = 'ToastThemeContext' -export function Toast({type = 'default', content}: ToastComponentProps) { - const {fonts} = useAlf() +export function Default({type = 'default', content}: ToastComponentProps) { + return ( + <Outer type={type}> + <Icon /> + <Text>{content}</Text> + </Outer> + ) +} + +export function Outer({ + children, + type = 'default', +}: { + children: React.ReactNode + type?: ToastType +}) { const t = useTheme() const styles = useToastStyles({type}) - const Icon = ICONS[type] - /** - * Vibes-based number, adjusts `top` of `View` that wraps the text to - * compensate for different type sizes and keep the first line of text - * aligned with the icon. - esb - */ - const fontScaleCompensation = useMemo( - () => parseInt(fonts.scale) * -1 * 0.65, - [fonts.scale], - ) return ( - <Context.Provider value={useMemo(() => ({type}), [type])}> + <ToastThemeContext.Provider value={useMemo(() => ({type}), [type])}> <View style={[ a.flex_1, - a.py_lg, - a.pl_xl, - a.pr_2xl, + a.p_lg, a.rounded_md, a.border, a.flex_row, a.gap_sm, t.atoms.shadow_sm, { + paddingVertical: 14, // 16 seems too big backgroundColor: styles.backgroundColor, borderColor: styles.borderColor, }, ]}> - <Icon size="md" fill={styles.iconColor} /> - - <View - style={[ - a.flex_1, - { - top: fontScaleCompensation, - }, - ]}> - {typeof content === 'string' ? ( - <ToastText>{content}</ToastText> - ) : ( - content - )} - </View> + {children} </View> - </Context.Provider> + </ToastThemeContext.Provider> ) } -export function ToastText({children}: {children: React.ReactNode}) { - const {type} = useContext(Context) +export function Icon({icon}: {icon?: React.ComponentType<SVGIconProps>}) { + const {type} = useContext(ToastThemeContext) + const styles = useToastStyles({type}) + const IconComponent = icon || ICONS[type] + return <IconComponent size="md" fill={styles.iconColor} /> +} + +export function Text({children}: {children: React.ReactNode}) { + const {type} = useContext(ToastThemeContext) const {textColor} = useToastStyles({type}) + const {fontScaleCompensation} = useToastFontScaleCompensation() return ( - <Text - selectable={false} + <View style={[ - a.text_md, - a.font_medium, - a.leading_snug, - a.pointer_events_none, + a.flex_1, + a.pr_lg, { - color: textColor, + top: fontScaleCompensation, }, ]}> - {children} - </Text> + <BaseText + selectable={false} + style={[ + a.text_md, + a.font_medium, + a.leading_snug, + a.pointer_events_none, + { + color: textColor, + }, + ]}> + {children} + </BaseText> + </View> + ) +} + +export function Action( + props: Omit<ButtonProps, UninheritableButtonProps | 'children'> & { + children: string + }, +) { + const t = useTheme() + const {fontScaleCompensation} = useToastFontScaleCompensation() + const {type} = useContext(ToastThemeContext) + const {id} = useContext(ToastConfigContext) + const styles = useMemo(() => { + const base = { + base: { + textColor: t.palette.contrast_600, + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, + }, + interacted: { + textColor: t.atoms.text.color, + backgroundColor: t.atoms.bg_contrast_50.backgroundColor, + }, + } + return { + default: base, + success: { + base: { + textColor: select(t.name, { + light: t.palette.primary_800, + dim: t.palette.primary_900, + dark: t.palette.primary_900, + }), + backgroundColor: t.palette.primary_25, + }, + interacted: { + textColor: select(t.name, { + light: t.palette.primary_900, + dim: t.palette.primary_975, + dark: t.palette.primary_975, + }), + backgroundColor: t.palette.primary_50, + }, + }, + error: { + base: { + textColor: select(t.name, { + light: t.palette.negative_700, + dim: t.palette.negative_900, + dark: t.palette.negative_900, + }), + backgroundColor: t.palette.negative_25, + }, + interacted: { + textColor: select(t.name, { + light: t.palette.negative_900, + dim: t.palette.negative_975, + dark: t.palette.negative_975, + }), + backgroundColor: t.palette.negative_50, + }, + }, + warning: base, + info: base, + }[type] + }, [t, type]) + + const onPress = (e: GestureResponderEvent) => { + console.log('Toast Action pressed, dismissing toast', id) + dismiss(id) + props.onPress?.(e) + } + + return ( + <View style={{top: fontScaleCompensation}}> + <Button {...props} onPress={onPress}> + {s => { + const interacted = s.pressed || s.hovered || s.focused + return ( + <> + <View + style={[ + a.absolute, + a.curve_continuous, + { + // tiny button styles + top: -5, + bottom: -5, + left: -9, + right: -9, + borderRadius: 6, + backgroundColor: interacted + ? styles.interacted.backgroundColor + : styles.base.backgroundColor, + }, + ]} + /> + <BaseText + style={[ + a.text_md, + a.font_medium, + a.leading_snug, + { + color: interacted + ? styles.interacted.textColor + : styles.base.textColor, + }, + ]}> + {props.children} + </BaseText> + </> + ) + }} + </Button> + </View> + ) +} + +/** + * Vibes-based number, provides t `top` value to wrap the text to compensate + * for different type sizes and keep the first line of text aligned with the + * icon. - esb + */ +function useToastFontScaleCompensation() { + const {fonts} = useAlf() + const fontScaleCompensation = useMemo( + () => parseInt(fonts.scale) * -1 * 0.65, + [fonts.scale], + ) + return useMemo( + () => ({ + fontScaleCompensation, + }), + [fontScaleCompensation], ) } diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index 286d414a1..16d62afcd 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -1,15 +1,21 @@ +import React from 'react' import {View} from 'react-native' +import {nanoid} from 'nanoid/non-secure' import {toast as sonner, Toaster} from 'sonner-native' import {atoms as a} from '#/alf' import {DURATION} from '#/components/Toast/const' import { - Toast as BaseToast, + Default as DefaultToast, + Outer as BaseOuter, type ToastComponentProps, + ToastConfigProvider, } from '#/components/Toast/Toast' import {type BaseToastOptions} from '#/components/Toast/types' export {DURATION} from '#/components/Toast/const' +export {Action, Icon, Text} from '#/components/Toast/Toast' +export {type ToastType} from '#/components/Toast/types' /** * Toasts are rendered in a global outlet, which is placed at the top of the @@ -22,10 +28,24 @@ export function ToastOutlet() { /** * The toast UI component */ -export function Toast({type, content}: ToastComponentProps) { +export function Default({type, content}: ToastComponentProps) { return ( <View style={[a.px_xl, a.w_full]}> - <BaseToast content={content} type={type} /> + <DefaultToast content={content} type={type} /> + </View> + ) +} + +export function Outer({ + children, + type = 'default', +}: { + children: React.ReactNode + type?: ToastComponentProps['type'] +}) { + return ( + <View style={[a.px_xl, a.w_full]}> + <BaseOuter type={type}>{children}</BaseOuter> </View> ) } @@ -42,8 +62,31 @@ export function show( content: React.ReactNode, {type, ...options}: BaseToastOptions = {}, ) { - sonner.custom(<Toast content={content} type={type} />, { - ...options, - duration: options?.duration ?? DURATION, - }) + const id = nanoid() + + if (typeof content === 'string') { + sonner.custom( + <ToastConfigProvider id={id}> + <DefaultToast content={content} type={type} /> + </ToastConfigProvider>, + { + ...options, + id, + duration: options?.duration ?? DURATION, + }, + ) + } else if (React.isValidElement(content)) { + sonner.custom( + <ToastConfigProvider id={id}>{content}</ToastConfigProvider>, + { + ...options, + id, + duration: options?.duration ?? DURATION, + }, + ) + } else { + throw new Error( + `Toast can be a string or a React element, got ${typeof content}`, + ) + } } diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx index 857ed7b39..c4db20dca 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -1,10 +1,19 @@ +import React from 'react' +import {nanoid} from 'nanoid/non-secure' import {toast as sonner, Toaster} from 'sonner' import {atoms as a} from '#/alf' import {DURATION} from '#/components/Toast/const' -import {Toast} from '#/components/Toast/Toast' +import { + Default as DefaultToast, + ToastConfigProvider, +} from '#/components/Toast/Toast' import {type BaseToastOptions} from '#/components/Toast/types' +export {DURATION} from '#/components/Toast/const' +export * from '#/components/Toast/Toast' +export {type ToastType} from '#/components/Toast/types' + /** * Toasts are rendered in a global outlet, which is placed at the top of the * component tree. @@ -32,9 +41,30 @@ export function show( content: React.ReactNode, {type, ...options}: BaseToastOptions = {}, ) { - sonner(<Toast content={content} type={type} />, { - unstyled: true, // required on web - ...options, - duration: options?.duration ?? DURATION, - }) + const id = nanoid() + + if (typeof content === 'string') { + sonner( + <ToastConfigProvider id={id}> + <DefaultToast content={content} type={type} /> + </ToastConfigProvider>, + { + ...options, + unstyled: true, // required on web + id, + duration: options?.duration ?? DURATION, + }, + ) + } else if (React.isValidElement(content)) { + sonner(<ToastConfigProvider id={id}>{content}</ToastConfigProvider>, { + ...options, + unstyled: true, // required on web + id, + duration: options?.duration ?? DURATION, + }) + } else { + throw new Error( + `Toast can be a string or a React element, got ${typeof content}`, + ) + } } diff --git a/src/components/Toast/sonner/index.ts b/src/components/Toast/sonner/index.ts new file mode 100644 index 000000000..35f8552c7 --- /dev/null +++ b/src/components/Toast/sonner/index.ts @@ -0,0 +1,3 @@ +import {toast} from 'sonner-native' + +export const dismiss = toast.dismiss diff --git a/src/components/Toast/sonner/index.web.ts b/src/components/Toast/sonner/index.web.ts new file mode 100644 index 000000000..12c4741d6 --- /dev/null +++ b/src/components/Toast/sonner/index.web.ts @@ -0,0 +1,3 @@ +import {toast} from 'sonner' + +export const dismiss = toast.dismiss diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index d0dbdfaba..7d4eb8ca7 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -46,12 +46,14 @@ import { AppBskyFeedDefs, type AppBskyFeedGetPostThread, AppBskyUnspeccedDefs, + AtUri, type BskyAgent, type RichText, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' @@ -70,6 +72,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {mimeToExt} from '#/lib/media/video/util' +import {type NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {colors} from '#/lib/styles' @@ -123,12 +126,13 @@ import {Text} from '#/view/com/util/text/Text' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' import * as Prompt from '#/components/Prompt' -import * as toast from '#/components/Toast' +import * as Toast from '#/components/Toast' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { @@ -188,6 +192,7 @@ export const ComposePost = ({ const {closeAllDialogs} = useDialogStateControlContext() const {closeAllModals} = useModalControls() const {data: preferences} = usePreferencesQuery() + const navigation = useNavigation<NavigationProp>() const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isPublishing, setIsPublishing] = useState(false) @@ -521,12 +526,27 @@ export const ComposePost = ({ onPostSuccess?.(postSuccessData) } onClose() - toast.show( - thread.posts.length > 1 - ? _(msg`Your posts have been published`) - : replyTo - ? _(msg`Your reply has been published`) - : _(msg`Your post has been published`), + Toast.show( + <Toast.Outer type="success"> + <Toast.Icon icon={CircleCheckIcon} /> + <Toast.Text> + {thread.posts.length > 1 + ? _(msg`Your posts were sent`) + : replyTo + ? _(msg`Your reply was sent`) + : _(msg`Your post was sent`)} + </Toast.Text> + {postUri && ( + <Toast.Action + label={_(msg`View post`)} + onPress={() => { + const {host: name, rkey} = new AtUri(postUri) + navigation.navigate('PostThread', {name, rkey}) + }}> + View + </Toast.Action> + )} + </Toast.Outer>, {type: 'success'}, ) }, [ @@ -543,6 +563,7 @@ export const ComposePost = ({ replyTo, setLangPrefs, queryClient, + navigation, ]) // Preserves the referential identity passed to each post item. @@ -826,7 +847,7 @@ let ComposerPost = React.memo(function ComposerPost({ if (isNative) return // web only const [mimeType] = uri.slice('data:'.length).split(';') if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { - toast.show(_(msg`Unsupported video type: ${mimeType}`), { + Toast.show(_(msg`Unsupported video type: ${mimeType}`), { type: 'error', }) return @@ -1362,7 +1383,7 @@ function ComposerFooter({ } errors.map(error => { - toast.show(error, { + Toast.show(error, { type: 'warning', }) }) diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx index 98d5b05e3..319f88e21 100644 --- a/src/view/screens/Storybook/Toasts.tsx +++ b/src/view/screens/Storybook/Toasts.tsx @@ -2,74 +2,129 @@ import {Pressable, View} from 'react-native' import {show as deprecatedShow} from '#/view/com/util/Toast' import {atoms as a} from '#/alf' -import * as toast from '#/components/Toast' -import {Toast} from '#/components/Toast/Toast' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import * as Toast from '#/components/Toast' +import {Default} from '#/components/Toast/Toast' import {H1} from '#/components/Typography' +function ToastWithAction({type = 'default'}: {type?: Toast.ToastType}) { + return ( + <Toast.Outer type={type}> + <Toast.Icon icon={GlobeIcon} /> + <Toast.Text>This toast has an action button</Toast.Text> + <Toast.Action + label="Action" + onPress={() => console.log('Action clicked!')}> + Action + </Toast.Action> + </Toast.Outer> + ) +} + +function LongToastWithAction({type = 'default'}: {type?: Toast.ToastType}) { + return ( + <Toast.Outer type={type}> + <Toast.Icon icon={GlobeIcon} /> + <Toast.Text> + This is a longer message to test how the toast handles multiple lines of + text content. + </Toast.Text> + <Toast.Action + label="Action" + onPress={() => console.log('Action clicked!')}> + Action + </Toast.Action> + </Toast.Outer> + ) +} + export function Toasts() { return ( <View style={[a.gap_md]}> <H1>Toast Examples</H1> <View style={[a.gap_md]}> + <View style={[a.gap_md, {marginHorizontal: a.px_xl.paddingLeft * -1}]}> + <Pressable + accessibilityRole="button" + onPress={() => Toast.show(<ToastWithAction />)}> + <ToastWithAction /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => Toast.show(<LongToastWithAction />)}> + <LongToastWithAction /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => Toast.show(<ToastWithAction type="success" />)}> + <ToastWithAction type="success" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => Toast.show(<ToastWithAction type="error" />)}> + <ToastWithAction type="error" /> + </Pressable> + </View> + <Pressable accessibilityRole="button" - onPress={() => toast.show(`Hey I'm a toast!`)}> - <Toast content="Hey I'm a toast!" /> + onPress={() => Toast.show(`Hey I'm a toast!`)}> + <Default content="Hey I'm a toast!" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`This toast will disappear after 6 seconds`, { + Toast.show(`This toast will disappear after 6 seconds`, { duration: 6e3, }) }> - <Toast content="This toast will disappear after 6 seconds" /> + <Default content="This toast will disappear after 6 seconds" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show( + Toast.show( `This is a longer message to test how the toast handles multiple lines of text content.`, ) }> - <Toast content="This is a longer message to test how the toast handles multiple lines of text content." /> + <Default content="This is a longer message to test how the toast handles multiple lines of text content." /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`Success! Yayyyyyyy :)`, { + Toast.show(`Success! Yayyyyyyy :)`, { type: 'success', }) }> - <Toast content="Success! Yayyyyyyy :)" type="success" /> + <Default content="Success! Yayyyyyyy :)" type="success" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`I'm providing info!`, { + Toast.show(`I'm providing info!`, { type: 'info', }) }> - <Toast content="I'm providing info!" type="info" /> + <Default content="I'm providing info!" type="info" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`This is a warning toast`, { + Toast.show(`This is a warning toast`, { type: 'warning', }) }> - <Toast content="This is a warning toast" type="warning" /> + <Default content="This is a warning toast" type="warning" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`This is an error toast :(`, { + Toast.show(`This is an error toast :(`, { type: 'error', }) }> - <Toast content="This is an error toast :(" type="error" /> + <Default content="This is an error toast :(" type="error" /> </Pressable> <Pressable @@ -80,7 +135,7 @@ export function Toasts() { 'exclamation-circle', ) }> - <Toast + <Default content="This is a test of the deprecated API" type="warning" /> |