diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml index 23d1538fe5..a6b2d9c3e0 100644 --- a/.github/workflows/build-and-push-embedr-aws.yaml +++ b/.github/workflows/build-and-push-embedr-aws.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - echoprom_fix env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} diff --git a/bskyweb/cmd/embedr/server.go b/bskyweb/cmd/embedr/server.go index d8df4276f2..62427e70e7 100644 --- a/bskyweb/cmd/embedr/server.go +++ b/bskyweb/cmd/embedr/server.go @@ -154,7 +154,13 @@ func serve(cctx *cli.Context) error { RedirectCode: http.StatusFound, })) - e.Use(echoprometheus.NewMiddleware("")) + echoprom := echoprometheus.NewMiddlewareWithConfig( + echoprometheus.MiddlewareConfig{ + DoNotUseRequestPathFor404: true, + }, + ) + + e.Use(echoprom) // // configure routes diff --git a/src/App.web.tsx b/src/App.web.tsx index 04de8529ff..1f795cb3e0 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -50,7 +50,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import * as Toast from '#/view/com/util/Toast' -import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' @@ -61,6 +60,7 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo import {Provider as PortalProvider} from '#/components/Portal' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {ToastContainer} from '#/components/Toast' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' diff --git a/src/components/Button.tsx b/src/components/Button.tsx index a5198d1d92..fa4794bef4 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -19,6 +19,18 @@ import {atoms as a, flatten, select, useTheme} from '#/alf' import {type Props as SVGIconProps} from '#/components/icons/common' import {Text} from '#/components/Typography' +/** + * The `Button` component, and some extensions of it like `Link` are intended + * to be generic and therefore apply no styles by default. These `VariantProps` + * are what control the `Button`'s presentation, and are intended only use cases where the buttons appear as, well, buttons. + * + * If `Button` or an extension of it are used for other compound components, use this property to avoid misuse of these variant props further down the line. + * + * @example + * type MyComponentProps = Omit & {...} + */ +export type UninheritableButtonProps = 'variant' | 'color' | 'size' | 'shape' + export type ButtonVariant = 'solid' | 'outline' | 'ghost' export type ButtonColor = | 'primary' diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx new file mode 100644 index 0000000000..0dc9d4b079 --- /dev/null +++ b/src/components/Toast/Toast.tsx @@ -0,0 +1,205 @@ +import {createContext, useContext, useMemo} from 'react' +import {View} from 'react-native' + +import {atoms as a, select, useTheme} from '#/alf' +import {Check_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {type ToastType} from '#/components/Toast/types' +import {Text} from '#/components/Typography' + +type ContextType = { + type: ToastType +} + +export const ICONS = { + default: SuccessIcon, + success: SuccessIcon, + error: ErrorIcon, + warning: WarningIcon, + info: CircleInfo, +} + +const Context = createContext({ + type: 'default', +}) + +export function Toast({ + type, + content, +}: { + type: ToastType + content: React.ReactNode +}) { + const t = useTheme() + const styles = useToastStyles({type}) + const Icon = ICONS[type] + + return ( + ({type}), [type])}> + + + + + {typeof content === 'string' ? ( + {content} + ) : ( + content + )} + + + + ) +} + +export function ToastText({children}: {children: React.ReactNode}) { + const {type} = useContext(Context) + const {textColor} = useToastStyles({type}) + return ( + + {children} + + ) +} + +function useToastStyles({type}: {type: ToastType}) { + const t = useTheme() + return useMemo(() => { + return { + default: { + backgroundColor: select(t.name, { + light: t.atoms.bg_contrast_25.backgroundColor, + dim: t.atoms.bg_contrast_100.backgroundColor, + dark: t.atoms.bg_contrast_100.backgroundColor, + }), + borderColor: select(t.name, { + light: t.atoms.border_contrast_low.borderColor, + dim: t.atoms.border_contrast_high.borderColor, + dark: t.atoms.border_contrast_high.borderColor, + }), + iconColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + textColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + }, + success: { + backgroundColor: select(t.name, { + light: t.palette.primary_100, + dim: t.palette.primary_100, + dark: t.palette.primary_50, + }), + borderColor: select(t.name, { + light: t.palette.primary_500, + dim: t.palette.primary_500, + dark: t.palette.primary_500, + }), + iconColor: select(t.name, { + light: t.palette.primary_500, + dim: t.palette.primary_600, + dark: t.palette.primary_600, + }), + textColor: select(t.name, { + light: t.palette.primary_500, + dim: t.palette.primary_600, + dark: t.palette.primary_600, + }), + }, + error: { + backgroundColor: select(t.name, { + light: t.palette.negative_200, + dim: t.palette.negative_25, + dark: t.palette.negative_25, + }), + borderColor: select(t.name, { + light: t.palette.negative_300, + dim: t.palette.negative_300, + dark: t.palette.negative_300, + }), + iconColor: select(t.name, { + light: t.palette.negative_600, + dim: t.palette.negative_600, + dark: t.palette.negative_600, + }), + textColor: select(t.name, { + light: t.palette.negative_600, + dim: t.palette.negative_600, + dark: t.palette.negative_600, + }), + }, + warning: { + backgroundColor: select(t.name, { + light: t.atoms.bg_contrast_25.backgroundColor, + dim: t.atoms.bg_contrast_100.backgroundColor, + dark: t.atoms.bg_contrast_100.backgroundColor, + }), + borderColor: select(t.name, { + light: t.atoms.border_contrast_low.borderColor, + dim: t.atoms.border_contrast_high.borderColor, + dark: t.atoms.border_contrast_high.borderColor, + }), + iconColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + textColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + }, + info: { + backgroundColor: select(t.name, { + light: t.atoms.bg_contrast_25.backgroundColor, + dim: t.atoms.bg_contrast_100.backgroundColor, + dark: t.atoms.bg_contrast_100.backgroundColor, + }), + borderColor: select(t.name, { + light: t.atoms.border_contrast_low.borderColor, + dim: t.atoms.border_contrast_high.borderColor, + dark: t.atoms.border_contrast_high.borderColor, + }), + iconColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + textColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + }, + }[type] + }, [t, type]) +} diff --git a/src/components/Toast/const.ts b/src/components/Toast/const.ts new file mode 100644 index 0000000000..034d0a2fce --- /dev/null +++ b/src/components/Toast/const.ts @@ -0,0 +1 @@ +export const DEFAULT_TOAST_DURATION = 3000 diff --git a/src/components/Toast/index.e2e.tsx b/src/components/Toast/index.e2e.tsx new file mode 100644 index 0000000000..57daf5bf0d --- /dev/null +++ b/src/components/Toast/index.e2e.tsx @@ -0,0 +1,5 @@ +export function ToastContainer() { + return null +} + +export function show() {} diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx new file mode 100644 index 0000000000..131a796b3f --- /dev/null +++ b/src/components/Toast/index.tsx @@ -0,0 +1,197 @@ +import {useEffect, useMemo, useRef, useState} from 'react' +import {AccessibilityInfo} from 'react-native' +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler' +import Animated, { + Easing, + runOnJS, + SlideInUp, + SlideOutUp, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withDecay, + withSpring, +} from 'react-native-reanimated' +import RootSiblings from 'react-native-root-siblings' +import {useSafeAreaInsets} from 'react-native-safe-area-context' + +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {atoms as a} from '#/alf' +import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' +import {Toast} from '#/components/Toast/Toast' +import {type ToastApi, type ToastType} from '#/components/Toast/types' + +const TOAST_ANIMATION_DURATION = 300 + +export function ToastContainer() { + return null +} + +export const toast: ToastApi = { + show(props) { + if (process.env.NODE_ENV === 'test') { + return + } + + AccessibilityInfo.announceForAccessibility(props.a11yLabel) + + const item = new RootSiblings( + ( + item.destroy()} + /> + ), + ) + }, +} + +function AnimatedToast({ + type, + content, + a11yLabel, + duration, + destroy, +}: { + type: ToastType + content: React.ReactNode + a11yLabel: string + duration: number + destroy: () => void +}) { + const {top} = useSafeAreaInsets() + const isPanning = useSharedValue(false) + const dismissSwipeTranslateY = useSharedValue(0) + const [cardHeight, setCardHeight] = useState(0) + + // for the exit animation to work on iOS the animated component + // must not be the root component + // so we need to wrap it in a view and unmount the toast ahead of time + const [alive, setAlive] = useState(true) + + const hideAndDestroyImmediately = () => { + setAlive(false) + setTimeout(() => { + destroy() + }, 1e3) + } + + const destroyTimeoutRef = useRef>() + const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { + clearTimeout(destroyTimeoutRef.current) + destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, duration) + }) + const pauseDestroy = useNonReactiveCallback(() => { + clearTimeout(destroyTimeoutRef.current) + }) + + useEffect(() => { + hideAndDestroyAfterTimeout() + }, [hideAndDestroyAfterTimeout]) + + const panGesture = useMemo(() => { + return Gesture.Pan() + .activeOffsetY([-10, 10]) + .failOffsetX([-10, 10]) + .maxPointers(1) + .onStart(() => { + 'worklet' + if (!alive) return + isPanning.set(true) + runOnJS(pauseDestroy)() + }) + .onUpdate(e => { + 'worklet' + if (!alive) return + dismissSwipeTranslateY.value = e.translationY + }) + .onEnd(e => { + 'worklet' + if (!alive) return + runOnJS(hideAndDestroyAfterTimeout)() + isPanning.set(false) + if (e.velocityY < -100) { + if (dismissSwipeTranslateY.value === 0) { + // HACK: If the initial value is 0, withDecay() animation doesn't start. + // This is a bug in Reanimated, but for now we'll work around it like this. + dismissSwipeTranslateY.value = 1 + } + dismissSwipeTranslateY.value = withDecay({ + velocity: e.velocityY, + velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), + deceleration: 1, + }) + } else { + dismissSwipeTranslateY.value = withSpring(0, { + stiffness: 500, + damping: 50, + }) + } + }) + }, [ + dismissSwipeTranslateY, + isPanning, + alive, + hideAndDestroyAfterTimeout, + pauseDestroy, + ]) + + const topOffset = top + 10 + + useAnimatedReaction( + () => + !isPanning.get() && + dismissSwipeTranslateY.get() < -topOffset - cardHeight, + (isSwipedAway, prevIsSwipedAway) => { + 'worklet' + if (isSwipedAway && !prevIsSwipedAway) { + runOnJS(destroy)() + } + }, + ) + + const animatedStyle = useAnimatedStyle(() => { + const translation = dismissSwipeTranslateY.get() + return { + transform: [ + { + translateY: translation > 0 ? translation ** 0.7 : translation, + }, + ], + } + }) + + return ( + + {alive && ( + setCardHeight(evt.nativeEvent.layout.height)} + accessibilityRole="alert" + accessible={true} + accessibilityLabel={a11yLabel} + accessibilityHint="" + onAccessibilityEscape={hideAndDestroyImmediately} + style={[a.flex_1, animatedStyle]}> + + + + + )} + + ) +} diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx new file mode 100644 index 0000000000..f6ceda568e --- /dev/null +++ b/src/components/Toast/index.web.tsx @@ -0,0 +1,107 @@ +/* + * Note: relies on styles in #/styles.css + */ + +import {useEffect, useState} from 'react' +import {AccessibilityInfo, Pressable, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useBreakpoints} from '#/alf' +import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' +import {Toast} from '#/components/Toast/Toast' +import {type ToastApi, type ToastType} from '#/components/Toast/types' + +const TOAST_ANIMATION_STYLES = { + entering: { + animation: 'toastFadeIn 0.3s ease-out forwards', + }, + exiting: { + animation: 'toastFadeOut 0.2s ease-in forwards', + }, +} + +interface ActiveToast { + type: ToastType + content: React.ReactNode + a11yLabel: string +} +type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void +let globalSetActiveToast: GlobalSetActiveToast | undefined +let toastTimeout: NodeJS.Timeout | undefined +type ToastContainerProps = {} + +export const ToastContainer: React.FC = ({}) => { + const {_} = useLingui() + const {gtPhone} = useBreakpoints() + const [activeToast, setActiveToast] = useState() + const [isExiting, setIsExiting] = useState(false) + + useEffect(() => { + globalSetActiveToast = (t: ActiveToast | undefined) => { + if (!t && activeToast) { + setIsExiting(true) + setTimeout(() => { + setActiveToast(t) + setIsExiting(false) + }, 200) + } else { + if (t) { + AccessibilityInfo.announceForAccessibility(t.a11yLabel) + } + setActiveToast(t) + setIsExiting(false) + } + } + }, [activeToast]) + + return ( + <> + {activeToast && ( + + + setActiveToast(undefined)} + /> + + )} + + ) +} + +export const toast: ToastApi = { + show(props) { + if (toastTimeout) { + clearTimeout(toastTimeout) + } + + globalSetActiveToast?.({ + type: props.type, + content: props.content, + a11yLabel: props.a11yLabel, + }) + + toastTimeout = setTimeout(() => { + globalSetActiveToast?.(undefined) + }, props.duration || DEFAULT_TOAST_DURATION) + }, +} diff --git a/src/components/Toast/types.ts b/src/components/Toast/types.ts new file mode 100644 index 0000000000..9f1245fa22 --- /dev/null +++ b/src/components/Toast/types.ts @@ -0,0 +1,24 @@ +export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' + +export type ToastApi = { + show: (props: { + /** + * The type of toast to show. This determines the styling and icon used. + */ + type: ToastType + /** + * A string, `Text`, or `Span` components to render inside the toast. This + * allows additional formatting of the content, but should not be used for + * interactive elements link links or buttons. + */ + content: React.ReactNode | string + /** + * Accessibility label for the toast, used for screen readers. + */ + a11yLabel: string + /** + * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`. + */ + duration?: number + }) => void +} diff --git a/src/env/common.ts b/src/env/common.ts index e68e9fab80..5b902622bc 100644 --- a/src/env/common.ts +++ b/src/env/common.ts @@ -43,9 +43,10 @@ export const BUNDLE_IDENTIFIER: string = * for each build. This should only be used for StatSig reporting and shouldn't * be used to identify a specific bundle. */ -export const BUNDLE_DATE: number = !process.env.EXPO_PUBLIC_BUNDLE_DATE - ? 0 - : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE) +export const BUNDLE_DATE: number = + process.env.EXPO_PUBLIC_BUNDLE_DATE === undefined + ? 0 + : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE) /** * The log level for the app. diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 3a5480a5cd..91699294d3 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -1239,7 +1239,7 @@ msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:54 #: src/components/dms/MessageProfileButton.tsx:58 -#: src/screens/Messages/ChatList.tsx:362 +#: src/screens/Messages/ChatList.tsx:358 #: src/screens/Messages/Conversation.tsx:228 msgid "Before you can message another user, you must first verify your email." msgstr "" @@ -1663,11 +1663,11 @@ msgid "Chat muted" msgstr "" #: src/Navigation.tsx:558 -#: src/screens/Messages/components/InboxPreview.tsx:24 +#: src/screens/Messages/components/InboxPreview.tsx:22 msgid "Chat request inbox" msgstr "" -#: src/screens/Messages/components/InboxPreview.tsx:64 +#: src/screens/Messages/components/InboxPreview.tsx:62 #: src/screens/Messages/Inbox.tsx:56 #: src/screens/Messages/Inbox.tsx:98 msgid "Chat requests" @@ -1675,7 +1675,7 @@ msgstr "" #: src/components/dms/ConvoMenu.tsx:75 #: src/Navigation.tsx:553 -#: src/screens/Messages/ChatList.tsx:371 +#: src/screens/Messages/ChatList.tsx:367 msgid "Chat settings" msgstr "" @@ -1690,8 +1690,8 @@ msgid "Chat unmuted" msgstr "" #: src/screens/Messages/ChatList.tsx:76 -#: src/screens/Messages/ChatList.tsx:387 -#: src/screens/Messages/ChatList.tsx:411 +#: src/screens/Messages/ChatList.tsx:383 +#: src/screens/Messages/ChatList.tsx:407 msgid "Chats" msgstr "" @@ -3339,7 +3339,7 @@ msgstr "" msgid "Failed to delete starter pack" msgstr "" -#: src/screens/Messages/ChatList.tsx:274 +#: src/screens/Messages/ChatList.tsx:270 #: src/screens/Messages/Inbox.tsx:208 msgid "Failed to load conversations" msgstr "" @@ -5300,8 +5300,8 @@ msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorName} msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:67 -#: src/screens/Messages/ChatList.tsx:394 -#: src/screens/Messages/ChatList.tsx:401 +#: src/screens/Messages/ChatList.tsx:390 +#: src/screens/Messages/ChatList.tsx:397 msgid "New chat" msgstr "" @@ -5592,7 +5592,7 @@ msgstr "" msgid "Note: This post is only visible to logged-in users." msgstr "" -#: src/screens/Messages/ChatList.tsx:295 +#: src/screens/Messages/ChatList.tsx:291 msgid "Nothing here" msgstr "" @@ -5834,7 +5834,7 @@ msgstr "" msgid "Open system log" msgstr "" -#: src/view/com/util/forms/DropdownButton.tsx:162 +#: src/view/com/util/forms/DropdownButton.tsx:167 msgid "Opens {numItems} options" msgstr "" @@ -6688,7 +6688,7 @@ msgstr "" msgid "Reject chat request" msgstr "" -#: src/screens/Messages/ChatList.tsx:278 +#: src/screens/Messages/ChatList.tsx:274 #: src/screens/Messages/Inbox.tsx:212 msgid "Reload conversations" msgstr "" @@ -7158,7 +7158,7 @@ msgstr "" #: src/components/StarterPack/ProfileStarterPacks.tsx:346 #: src/screens/Login/LoginForm.tsx:323 #: src/screens/Login/LoginForm.tsx:330 -#: src/screens/Messages/ChatList.tsx:284 +#: src/screens/Messages/ChatList.tsx:280 #: src/screens/Messages/components/MessageListError.tsx:25 #: src/screens/Messages/Inbox.tsx:218 #: src/screens/Onboarding/StepInterests/index.tsx:217 @@ -7538,7 +7538,7 @@ msgstr "" msgid "Select your preferred notification channels" msgstr "" -#: src/view/com/util/forms/DropdownButton.tsx:297 +#: src/view/com/util/forms/DropdownButton.tsx:302 msgid "Selects option {0} of {numItems}" msgstr "" @@ -8933,7 +8933,7 @@ msgstr "" msgid "Today" msgstr "" -#: src/view/com/util/forms/DropdownButton.tsx:258 +#: src/view/com/util/forms/DropdownButton.tsx:263 msgid "Toggle dropdown" msgstr "" @@ -9517,7 +9517,7 @@ msgstr "" #: src/screens/Settings/AboutSettings.tsx:126 #: src/screens/Settings/AboutSettings.tsx:155 -msgid "Version {appVersion}" +msgid "Version {0}" msgstr "" #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:83 @@ -9901,7 +9901,7 @@ msgid "Who can verify?" msgstr "" #: src/screens/Home/NoFeedsPinned.tsx:79 -#: src/screens/Messages/ChatList.tsx:262 +#: src/screens/Messages/ChatList.tsx:258 #: src/screens/Messages/Inbox.tsx:197 msgid "Whoops!" msgstr "" @@ -10173,7 +10173,7 @@ msgstr "" msgid "You have muted this user" msgstr "" -#: src/screens/Messages/ChatList.tsx:305 +#: src/screens/Messages/ChatList.tsx:301 msgid "You have no conversations yet. Start one!" msgstr "" diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx index b626c93295..4c40684176 100644 --- a/src/screens/Search/SearchResults.tsx +++ b/src/screens/Search/SearchResults.tsx @@ -255,7 +255,7 @@ let SearchScreenPostResults = ({ Sign in @@ -263,7 +263,7 @@ let SearchScreenPostResults = ({ or create an account diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx index 6d17990478..5720849723 100644 --- a/src/screens/Settings/components/SettingsList.tsx +++ b/src/screens/Settings/components/SettingsList.tsx @@ -124,7 +124,7 @@ export function LinkItem({ contentContainerStyle, chevronColor, ...props -}: LinkProps & { +}: Omit & { contentContainerStyle?: StyleProp destructive?: boolean chevronColor?: string @@ -132,7 +132,7 @@ export function LinkItem({ const t = useTheme() return ( - + {args => ( & { contentContainerStyle?: StyleProp destructive?: boolean }) { diff --git a/src/style.css b/src/style.css index 35ffe0d3a2..4c5677fbf3 100644 --- a/src/style.css +++ b/src/style.css @@ -369,3 +369,23 @@ input[type='range'][orient='vertical']::-moz-range-thumb { transform: translateY(0); } } + +/* + * #/components/Toast/index.web.tsx + */ +@keyframes toastFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes toastFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} diff --git a/src/view/com/util/Toast.e2e.tsx b/src/view/com/util/Toast.e2e.tsx deleted file mode 100644 index c5582ff0a8..0000000000 --- a/src/view/com/util/Toast.e2e.tsx +++ /dev/null @@ -1 +0,0 @@ -export function show() {} diff --git a/src/view/com/util/Toast.style.tsx b/src/view/com/util/Toast.style.tsx deleted file mode 100644 index 3869e6890b..0000000000 --- a/src/view/com/util/Toast.style.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import {select, type Theme} from '#/alf' -import {Check_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' -import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' - -export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' - -export type LegacyToastType = - | 'xmark' - | 'exclamation-circle' - | 'check' - | 'clipboard-check' - | 'circle-exclamation' - -export const convertLegacyToastType = ( - type: ToastType | LegacyToastType, -): ToastType => { - switch (type) { - // these ones are fine - case 'default': - case 'success': - case 'error': - case 'warning': - case 'info': - return type - // legacy ones need conversion - case 'xmark': - return 'error' - case 'exclamation-circle': - return 'warning' - case 'check': - return 'success' - case 'clipboard-check': - return 'success' - case 'circle-exclamation': - return 'warning' - default: - return 'default' - } -} - -export const TOAST_ANIMATION_CONFIG = { - duration: 300, - damping: 15, - stiffness: 150, - mass: 0.8, - overshootClamping: false, - restSpeedThreshold: 0.01, - restDisplacementThreshold: 0.01, -} - -export const TOAST_TYPE_TO_ICON = { - default: SuccessIcon, - success: SuccessIcon, - error: ErrorIcon, - warning: WarningIcon, - info: CircleInfo, -} - -export const getToastTypeStyles = (t: Theme) => ({ - default: { - backgroundColor: select(t.name, { - light: t.atoms.bg_contrast_25.backgroundColor, - dim: t.atoms.bg_contrast_100.backgroundColor, - dark: t.atoms.bg_contrast_100.backgroundColor, - }), - borderColor: select(t.name, { - light: t.atoms.border_contrast_low.borderColor, - dim: t.atoms.border_contrast_high.borderColor, - dark: t.atoms.border_contrast_high.borderColor, - }), - iconColor: select(t.name, { - light: t.atoms.text_contrast_medium.color, - dim: t.atoms.text_contrast_medium.color, - dark: t.atoms.text_contrast_medium.color, - }), - textColor: select(t.name, { - light: t.atoms.text_contrast_medium.color, - dim: t.atoms.text_contrast_medium.color, - dark: t.atoms.text_contrast_medium.color, - }), - }, - success: { - backgroundColor: select(t.name, { - light: t.palette.primary_100, - dim: t.palette.primary_100, - dark: t.palette.primary_50, - }), - borderColor: select(t.name, { - light: t.palette.primary_500, - dim: t.palette.primary_500, - dark: t.palette.primary_500, - }), - iconColor: select(t.name, { - light: t.palette.primary_500, - dim: t.palette.primary_600, - dark: t.palette.primary_600, - }), - textColor: select(t.name, { - light: t.palette.primary_500, - dim: t.palette.primary_600, - dark: t.palette.primary_600, - }), - }, - error: { - backgroundColor: select(t.name, { - light: t.palette.negative_200, - dim: t.palette.negative_25, - dark: t.palette.negative_25, - }), - borderColor: select(t.name, { - light: t.palette.negative_300, - dim: t.palette.negative_300, - dark: t.palette.negative_300, - }), - iconColor: select(t.name, { - light: t.palette.negative_600, - dim: t.palette.negative_600, - dark: t.palette.negative_600, - }), - textColor: select(t.name, { - light: t.palette.negative_600, - dim: t.palette.negative_600, - dark: t.palette.negative_600, - }), - }, - warning: { - backgroundColor: select(t.name, { - light: t.atoms.bg_contrast_25.backgroundColor, - dim: t.atoms.bg_contrast_100.backgroundColor, - dark: t.atoms.bg_contrast_100.backgroundColor, - }), - borderColor: select(t.name, { - light: t.atoms.border_contrast_low.borderColor, - dim: t.atoms.border_contrast_high.borderColor, - dark: t.atoms.border_contrast_high.borderColor, - }), - iconColor: select(t.name, { - light: t.atoms.text_contrast_medium.color, - dim: t.atoms.text_contrast_medium.color, - dark: t.atoms.text_contrast_medium.color, - }), - textColor: select(t.name, { - light: t.atoms.text_contrast_medium.color, - dim: t.atoms.text_contrast_medium.color, - dark: t.atoms.text_contrast_medium.color, - }), - }, - info: { - backgroundColor: select(t.name, { - light: t.atoms.bg_contrast_25.backgroundColor, - dim: t.atoms.bg_contrast_100.backgroundColor, - dark: t.atoms.bg_contrast_100.backgroundColor, - }), - borderColor: select(t.name, { - light: t.atoms.border_contrast_low.borderColor, - dim: t.atoms.border_contrast_high.borderColor, - dark: t.atoms.border_contrast_high.borderColor, - }), - iconColor: select(t.name, { - light: t.atoms.text_contrast_medium.color, - dim: t.atoms.text_contrast_medium.color, - dark: t.atoms.text_contrast_medium.color, - }), - textColor: select(t.name, { - light: t.atoms.text_contrast_medium.color, - dim: t.atoms.text_contrast_medium.color, - dark: t.atoms.text_contrast_medium.color, - }), - }, -}) - -export const getToastWebAnimationStyles = () => ({ - entering: { - animation: 'toastFadeIn 0.3s ease-out forwards', - }, - exiting: { - animation: 'toastFadeOut 0.2s ease-in forwards', - }, -}) - -export const TOAST_WEB_KEYFRAMES = ` - @keyframes toastFadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - - @keyframes toastFadeOut { - from { - opacity: 1; - } - to { - opacity: 0; - } - } -` diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 54ef7042d2..37ec6acb53 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,234 +1,54 @@ -import {useEffect, useMemo, useRef, useState} from 'react' -import {AccessibilityInfo, View} from 'react-native' -import { - Gesture, - GestureDetector, - GestureHandlerRootView, -} from 'react-native-gesture-handler' -import Animated, { - FadeIn, - FadeOut, - runOnJS, - useAnimatedReaction, - useAnimatedStyle, - useSharedValue, - withDecay, - withSpring, -} from 'react-native-reanimated' -import RootSiblings from 'react-native-root-siblings' -import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {toast} from '#/components/Toast' +import {type ToastType} from '#/components/Toast/types' -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import { - convertLegacyToastType, - getToastTypeStyles, - type LegacyToastType, - TOAST_ANIMATION_CONFIG, - TOAST_TYPE_TO_ICON, - type ToastType, -} from '#/view/com/util/Toast.style' -import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' - -const TIMEOUT = 2e3 - -// Use type overloading to mark certain types as deprecated -sfn -// https://stackoverflow.com/a/78325851/13325987 -export function show(message: string, type?: ToastType): void /** - * @deprecated type is deprecated - use one of `'default' | 'success' | 'error' | 'warning' | 'info'` + * @deprecated use {@link ToastType} and {@link toast} instead + */ +export type LegacyToastType = + | 'xmark' + | 'exclamation-circle' + | 'check' + | 'clipboard-check' + | 'circle-exclamation' + +export const convertLegacyToastType = ( + type: ToastType | LegacyToastType, +): ToastType => { + switch (type) { + // these ones are fine + case 'default': + case 'success': + case 'error': + case 'warning': + case 'info': + return type + // legacy ones need conversion + case 'xmark': + return 'error' + case 'exclamation-circle': + return 'warning' + case 'check': + return 'success' + case 'clipboard-check': + return 'success' + case 'circle-exclamation': + return 'warning' + default: + return 'default' + } +} + +/** + * @deprecated use {@link toast} instead */ -export function show(message: string, type?: LegacyToastType): void export function show( message: string, type: ToastType | LegacyToastType = 'default', ): void { - if (process.env.NODE_ENV === 'test') { - return - } - - AccessibilityInfo.announceForAccessibility(message) - const item = new RootSiblings( - ( - item.destroy()} - /> - ), - ) -} - -function Toast({ - message, - type, - destroy, -}: { - message: string - type: ToastType - destroy: () => void -}) { - const t = useTheme() - const {top} = useSafeAreaInsets() - const isPanning = useSharedValue(false) - const dismissSwipeTranslateY = useSharedValue(0) - const [cardHeight, setCardHeight] = useState(0) - - const toastStyles = getToastTypeStyles(t) - const colors = toastStyles[type] - const IconComponent = TOAST_TYPE_TO_ICON[type] - - // for the exit animation to work on iOS the animated component - // must not be the root component - // so we need to wrap it in a view and unmount the toast ahead of time - const [alive, setAlive] = useState(true) - - const hideAndDestroyImmediately = () => { - setAlive(false) - setTimeout(() => { - destroy() - }, 1e3) - } - - const destroyTimeoutRef = useRef>() - const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { - clearTimeout(destroyTimeoutRef.current) - destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT) - }) - const pauseDestroy = useNonReactiveCallback(() => { - clearTimeout(destroyTimeoutRef.current) - }) - - useEffect(() => { - hideAndDestroyAfterTimeout() - }, [hideAndDestroyAfterTimeout]) - - const panGesture = useMemo(() => { - return Gesture.Pan() - .activeOffsetY([-10, 10]) - .failOffsetX([-10, 10]) - .maxPointers(1) - .onStart(() => { - 'worklet' - if (!alive) return - isPanning.set(true) - runOnJS(pauseDestroy)() - }) - .onUpdate(e => { - 'worklet' - if (!alive) return - dismissSwipeTranslateY.value = e.translationY - }) - .onEnd(e => { - 'worklet' - if (!alive) return - runOnJS(hideAndDestroyAfterTimeout)() - isPanning.set(false) - if (e.velocityY < -100) { - if (dismissSwipeTranslateY.value === 0) { - // HACK: If the initial value is 0, withDecay() animation doesn't start. - // This is a bug in Reanimated, but for now we'll work around it like this. - dismissSwipeTranslateY.value = 1 - } - dismissSwipeTranslateY.value = withDecay({ - velocity: e.velocityY, - velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), - deceleration: 1, - }) - } else { - dismissSwipeTranslateY.value = withSpring(0, { - stiffness: 500, - damping: 50, - }) - } - }) - }, [ - dismissSwipeTranslateY, - isPanning, - alive, - hideAndDestroyAfterTimeout, - pauseDestroy, - ]) - - const topOffset = top + 10 - - useAnimatedReaction( - () => - !isPanning.get() && - dismissSwipeTranslateY.get() < -topOffset - cardHeight, - (isSwipedAway, prevIsSwipedAway) => { - 'worklet' - if (isSwipedAway && !prevIsSwipedAway) { - runOnJS(destroy)() - } - }, - ) - - const animatedStyle = useAnimatedStyle(() => { - const translation = dismissSwipeTranslateY.get() - return { - transform: [ - { - translateY: translation > 0 ? translation ** 0.7 : translation, - }, - ], - } - }) - - return ( - - {alive && ( - setCardHeight(evt.nativeEvent.layout.height)} - accessibilityRole="alert" - accessible={true} - accessibilityLabel={message} - accessibilityHint="" - onAccessibilityEscape={hideAndDestroyImmediately} - style={[ - a.flex_1, - {backgroundColor: colors.backgroundColor}, - a.shadow_sm, - {borderColor: colors.borderColor, borderWidth: 1}, - a.rounded_sm, - animatedStyle, - ]}> - - - - - - - - {message} - - - - - - )} - - ) + const convertedType = convertLegacyToastType(type) + toast.show({ + type: convertedType, + content: message, + a11yLabel: message, + }) } diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx deleted file mode 100644 index 6b99b30bf3..0000000000 --- a/src/view/com/util/Toast.web.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Note: the dataSet properties are used to leverage custom CSS in public/index.html - */ - -import {useEffect, useState} from 'react' -import {Pressable, StyleSheet, Text, View} from 'react-native' - -import { - convertLegacyToastType, - getToastTypeStyles, - getToastWebAnimationStyles, - type LegacyToastType, - TOAST_TYPE_TO_ICON, - TOAST_WEB_KEYFRAMES, - type ToastType, -} from '#/view/com/util/Toast.style' -import {atoms as a, useTheme} from '#/alf' - -const DURATION = 3500 - -interface ActiveToast { - text: string - type: ToastType -} -type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void - -// globals -// = -let globalSetActiveToast: GlobalSetActiveToast | undefined -let toastTimeout: NodeJS.Timeout | undefined - -// components -// = -type ToastContainerProps = {} -export const ToastContainer: React.FC = ({}) => { - const [activeToast, setActiveToast] = useState() - const [isExiting, setIsExiting] = useState(false) - - useEffect(() => { - globalSetActiveToast = (t: ActiveToast | undefined) => { - if (!t && activeToast) { - setIsExiting(true) - setTimeout(() => { - setActiveToast(t) - setIsExiting(false) - }, 200) - } else { - setActiveToast(t) - setIsExiting(false) - } - } - }, [activeToast]) - - useEffect(() => { - const styleId = 'toast-animations' - if (!document.getElementById(styleId)) { - const style = document.createElement('style') - style.id = styleId - style.textContent = TOAST_WEB_KEYFRAMES - document.head.appendChild(style) - } - }, []) - - const t = useTheme() - - const toastTypeStyles = getToastTypeStyles(t) - const toastStyles = activeToast - ? toastTypeStyles[activeToast.type] - : toastTypeStyles.default - - const IconComponent = activeToast - ? TOAST_TYPE_TO_ICON[activeToast.type] - : TOAST_TYPE_TO_ICON.default - - const animationStyles = getToastWebAnimationStyles() - - return ( - <> - {activeToast && ( - - - - - - {activeToast.text} - - { - setActiveToast(undefined) - }} - /> - - )} - - ) -} - -// methods -// = - -export function show( - text: string, - type: ToastType | LegacyToastType = 'default', -) { - if (toastTimeout) { - clearTimeout(toastTimeout) - } - - globalSetActiveToast?.({text, type: convertLegacyToastType(type)}) - toastTimeout = setTimeout(() => { - globalSetActiveToast?.(undefined) - }, DURATION) -} - -const styles = StyleSheet.create({ - container: { - // @ts-ignore web only - position: 'fixed', - left: 20, - bottom: 20, - // @ts-ignore web only - width: 'calc(100% - 40px)', - maxWidth: 380, - padding: 20, - flexDirection: 'row', - alignItems: 'center', - borderRadius: 10, - borderWidth: 1, - }, - dismissBackdrop: { - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - right: 0, - }, - iconContainer: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - }, - icon: { - flexShrink: 0, - }, - text: { - marginLeft: 10, - }, -}) diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx index 4c17f1c332..8fc6f095f7 100644 --- a/src/view/screens/Storybook/Toasts.tsx +++ b/src/view/screens/Storybook/Toasts.tsx @@ -1,65 +1,11 @@ import {Pressable, View} from 'react-native' -import * as Toast from '#/view/com/util/Toast' -import { - getToastTypeStyles, - TOAST_TYPE_TO_ICON, - type ToastType, -} from '#/view/com/util/Toast.style' -import {atoms as a, useTheme} from '#/alf' -import {H1, Text} from '#/components/Typography' - -function ToastPreview({message, type}: {message: string; type: ToastType}) { - const t = useTheme() - const toastStyles = getToastTypeStyles(t) - const colors = toastStyles[type as keyof typeof toastStyles] - const IconComponent = - TOAST_TYPE_TO_ICON[type as keyof typeof TOAST_TYPE_TO_ICON] - - return ( - Toast.show(message, type)} - style={[ - {backgroundColor: colors.backgroundColor}, - a.shadow_sm, - {borderColor: colors.borderColor}, - a.rounded_sm, - a.border, - a.px_sm, - a.py_sm, - a.flex_row, - a.gap_sm, - a.align_center, - ]}> - - - - - - {message} - - - - ) -} +import {show as deprecatedShow} from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {toast} from '#/components/Toast' +import {Toast} from '#/components/Toast/Toast' +import {H1} from '#/components/Typography' export function Toasts() { return ( @@ -67,35 +13,103 @@ export function Toasts() {

Toast Examples

- - - - - - + toast.show({ + type: 'default', + content: 'Default toast', + a11yLabel: 'Default toast', + }) + }> + + + + toast.show({ + type: 'default', + content: 'Default toast, 6 seconds', + a11yLabel: 'Default toast, 6 seconds', + duration: 6e3, + }) + }> + + + + toast.show({ + type: 'default', + content: + 'This is a longer message to test how the toast handles multiple lines of text content.', + a11yLabel: + 'This is a longer message to test how the toast handles multiple lines of text content.', + }) + }> + - + + + toast.show({ + type: 'success', + content: 'Success toast', + a11yLabel: 'Success toast', + }) + }> + + + + toast.show({ + type: 'info', + content: 'Info toast', + a11yLabel: 'Info toast', + }) + }> + + + + toast.show({ + type: 'warning', + content: 'Warning toast', + a11yLabel: 'Warning toast', + }) + }> + + + + toast.show({ + type: 'error', + content: 'Error toast', + a11yLabel: 'Error toast', + }) + }> + + - - - - - - - - - - - - - - - + ) diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 0c179ba57d..1151d5a3ce 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -87,6 +87,8 @@ function StorybookInner() { + +