diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index fff3a4d8..6d73cd30 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -111,6 +111,12 @@ export const atoms = { flex_row: { flexDirection: 'row', }, + flex_col_reverse: { + flexDirection: 'column-reverse', + }, + flex_row_reverse: { + flexDirection: 'row-reverse', + }, flex_wrap: { flexWrap: 'wrap', }, diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index f0e7b7e8..5f6edeac 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -75,6 +75,7 @@ export function Outer({ control, onClose, nativeOptions, + testID, }: React.PropsWithChildren) { const t = useTheme() const sheet = React.useRef(null) @@ -145,7 +146,8 @@ export function Outer({ accessibilityViewIsModal // Android importantForAccessibility="yes" - style={[a.absolute, a.inset_0]}> + style={[a.absolute, a.inset_0]} + testID={testID}> } webOptions?: {} + testID?: string } type DialogInnerPropsBase = React.PropsWithChildren & T diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 3b245c44..9ffa9e7d 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -1,11 +1,11 @@ import React from 'react' -import {View, PressableProps} from 'react-native' +import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useTheme, atoms as a, useBreakpoints} from '#/alf' import {Text} from '#/components/Typography' -import {Button} from '#/components/Button' +import {Button, ButtonColor, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -22,8 +22,10 @@ const Context = React.createContext<{ export function Outer({ children, control, + testID, }: React.PropsWithChildren<{ control: Dialog.DialogOuterProps['control'] + testID?: string }>) { const {gtMobile} = useBreakpoints() const titleId = React.useId() @@ -35,7 +37,7 @@ export function Outer({ ) return ( - + @@ -80,7 +82,9 @@ export function Actions({children}: React.PropsWithChildren<{}>) { a.w_full, a.gap_sm, a.justify_end, - gtMobile ? [a.flex_row] : [a.flex_col, a.pt_md, a.pb_4xl], + gtMobile + ? [a.flex_row, a.flex_row_reverse, a.justify_start] + : [a.flex_col, a.pt_md, a.pb_4xl], ]}> {children} @@ -89,18 +93,29 @@ export function Actions({children}: React.PropsWithChildren<{}>) { export function Cancel({ children, -}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) { + cta, +}: React.PropsWithChildren<{ + /** + * Optional i18n string, used in lieu of `children` for simple buttons. If + * undefined (and `children` is undefined), it will default to "Cancel". + */ + cta?: string +}>) { const {_} = useLingui() const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() + const onPress = React.useCallback(() => { + close() + }, [close]) + return ( ) } @@ -108,22 +123,70 @@ export function Cancel({ export function Action({ children, onPress, -}: React.PropsWithChildren<{onPress?: () => void}>) { + color = 'primary', + cta, + testID, +}: React.PropsWithChildren<{ + onPress: () => void + color?: ButtonColor + /** + * Optional i18n string, used in lieu of `children` for simple buttons. If + * undefined (and `children` is undefined), it will default to "Confirm". + */ + cta?: string + testID?: string +}>) { const {_} = useLingui() const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() const handleOnPress = React.useCallback(() => { close() - onPress?.() + onPress() }, [close, onPress]) + return ( ) } + +export function Basic({ + control, + title, + description, + cancelButtonCta, + confirmButtonCta, + onConfirm, + confirmButtonColor, +}: React.PropsWithChildren<{ + control: Dialog.DialogOuterProps['control'] + title: string + description: string + cancelButtonCta?: string + confirmButtonCta?: string + onConfirm: () => void + confirmButtonColor?: ButtonColor +}>) { + return ( + + {title} + {description} + + + + + + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 5f23a788..dcdaccea 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -277,29 +277,16 @@ function MutedWordRow({ return ( <> - - - Are you sure? - - - - This will delete {word.value} from your muted words. You can always - add it back later. - - - - - - Nevermind - - - - - Remove - - - - + { - openModal({ - name: 'confirm', - title: t`Update Available`, - message: t`A new version of the app is available. Please update to continue using the app.`, - onPressConfirm: async () => { - Updates.reloadAsync().catch(err => { - throw err - }) - }, - }) - }, [openModal]) const checkForUpdate = useCallback(async () => { logger.debug('useOTAUpdate: Checking for update...') try { @@ -32,32 +16,26 @@ export function useOTAUpdate() { } // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch. await Updates.fetchUpdateAsync() - // show a popup modal - showUpdatePopup() } catch (e) { logger.error('useOTAUpdate: Error while checking for update', { message: e, }) } - }, [showUpdatePopup]) - const updateEventListener = useCallback( - (event: Updates.UpdateEvent) => { - logger.debug('useOTAUpdate: Listening for update...') - if (event.type === Updates.UpdateEventType.ERROR) { - logger.error('useOTAUpdate: Error while listening for update', { - message: event.message, - }) - } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { - // Handle no update available - // do nothing - } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { - // Handle update available - // open modal, ask for user confirmation, and reload the app - showUpdatePopup() - } - }, - [showUpdatePopup], - ) + }, []) + const updateEventListener = useCallback((event: Updates.UpdateEvent) => { + logger.debug('useOTAUpdate: Listening for update...') + if (event.type === Updates.UpdateEventType.ERROR) { + logger.error('useOTAUpdate: Error while listening for update', { + message: event.message, + }) + } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { + // Handle no update available + // do nothing + } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { + // Handle update available + // open modal, ask for user confirmation, and reload the app + } + }, []) useEffect(() => { // ADD EVENT LISTENERS diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 691add00..a18f6c87 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,6 +1,5 @@ import React from 'react' import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' -import {StyleProp, ViewStyle} from 'react-native' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '#/state/models/media/image' @@ -9,17 +8,6 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {EmbedPlayerSource} from '#/lib/strings/embed-player' import {ThreadgateSetting} from '../queries/threadgate' -export interface ConfirmModal { - name: 'confirm' - title: string - message: string | (() => JSX.Element) - onPressConfirm: () => void | Promise - onPressCancel?: () => void | Promise - confirmBtnText?: string - confirmBtnStyle?: StyleProp - cancelBtnText?: string -} - export interface EditProfileModal { name: 'edit-profile' profile: AppBskyActorDefs.ProfileViewDetailed @@ -225,7 +213,6 @@ export type Modal = | InviteCodesModal // Generic - | ConfirmModal | LinkWarningModal | EmbedConsentModal | InAppBrowserConsentModal diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2855d423..ef965b27 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -49,7 +49,7 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage' import {insertMentionAt} from 'lib/strings/mention-manip' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useModals, useModalControls} from '#/state/modals' +import {useModals} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { useLanguagePrefs, @@ -63,6 +63,8 @@ import {emitPostCreated} from '#/state/events' import {ThreadgateSetting} from '#/state/queries/threadgate' import {logger} from '#/logger' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' +import * as Prompt from '#/components/Prompt' +import {useDialogStateControlContext} from 'state/dialogs' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -76,8 +78,7 @@ export const ComposePost = observer(function ComposePost({ }: Props) { const {currentAccount} = useSession() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) - const {isModalActive, activeModals} = useModals() - const {openModal, closeModal} = useModalControls() + const {isModalActive} = useModals() const {closeComposer} = useComposerControls() const {track} = useAnalytics() const pal = usePalette('default') @@ -87,6 +88,9 @@ export const ComposePost = observer(function ComposePost({ const langPrefs = useLanguagePrefs() const setLangPrefs = useLanguagePrefsApi() const textInput = useRef(null) + const discardPromptControl = Prompt.usePromptControl() + const {closeAllDialogs} = useDialogStateControlContext() + const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') @@ -134,27 +138,21 @@ export const ComposePost = observer(function ComposePost({ const onPressCancel = useCallback(() => { if (graphemeLength > 0 || !gallery.isEmpty) { - if (activeModals.some(modal => modal.name === 'confirm')) { - closeModal() - } + closeAllDialogs() if (Keyboard) { Keyboard.dismiss() } - openModal({ - name: 'confirm', - title: _(msg`Discard draft`), - onPressConfirm: onClose, - onPressCancel: () => { - closeModal() - }, - message: _(msg`Are you sure you'd like to discard this draft?`), - confirmBtnText: _(msg`Discard`), - confirmBtnStyle: {backgroundColor: colors.red4}, - }) + discardPromptControl.open() } else { onClose() } - }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _]) + }, [ + graphemeLength, + gallery.isEmpty, + closeAllDialogs, + discardPromptControl, + onClose, + ]) // android back button useEffect(() => { if (!isAndroid) { @@ -488,6 +486,15 @@ export const ComposePost = observer(function ComposePost({ + + ) }) diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 4af62f6f..9300b415 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -11,7 +11,6 @@ import {AtUri} from '@atproto/api' import * as Toast from 'view/com/util/Toast' import {sanitizeHandle} from 'lib/strings/handles' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import { @@ -24,6 +23,7 @@ import { import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {useTheme} from '#/alf' +import * as Prompt from '#/components/Prompt' import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' export function FeedSourceCard({ @@ -85,8 +85,8 @@ export function FeedSourceCardLoaded({ const t = useTheme() const pal = usePalette('default') const {_} = useLingui() + const removePromptControl = Prompt.usePromptControl() const navigation = useNavigationDeduped() - const {openModal} = useModalControls() const {isPending: isSavePending, mutateAsync: saveFeed} = useSaveFeedMutation() @@ -96,40 +96,45 @@ export function FeedSourceCardLoaded({ const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || '')) + const onSave = React.useCallback(async () => { + if (!feed) return + + try { + if (pinOnSave) { + await pinFeed({uri: feed.uri}) + } else { + await saveFeed({uri: feed.uri}) + } + Toast.show(_(msg`Added to my feeds`)) + } catch (e) { + Toast.show(_(msg`There was an issue contacting your server`)) + logger.error('Failed to save feed', {message: e}) + } + }, [_, feed, pinFeed, pinOnSave, saveFeed]) + + const onUnsave = React.useCallback(async () => { + if (!feed) return + + try { + await removeFeed({uri: feed.uri}) + // await item.unsave() + Toast.show(_(msg`Removed from my feeds`)) + } catch (e) { + Toast.show(_(msg`There was an issue contacting your server`)) + logger.error('Failed to unsave feed', {message: e}) + } + }, [_, feed, removeFeed]) + const onToggleSaved = React.useCallback(async () => { // Only feeds can be un/saved, lists are handled elsewhere if (feed?.type !== 'feed') return if (isSaved) { - openModal({ - name: 'confirm', - title: _(msg`Remove from my feeds`), - message: _(msg`Remove ${feed?.displayName} from my feeds?`), - onPressConfirm: async () => { - try { - await removeFeed({uri: feed.uri}) - // await item.unsave() - Toast.show(_(msg`Removed from my feeds`)) - } catch (e) { - Toast.show(_(msg`There was an issue contacting your server`)) - logger.error('Failed to unsave feed', {message: e}) - } - }, - }) + removePromptControl.open() } else { - try { - if (pinOnSave) { - await pinFeed({uri: feed.uri}) - } else { - await saveFeed({uri: feed.uri}) - } - Toast.show(_(msg`Added to my feeds`)) - } catch (e) { - Toast.show(_(msg`There was an issue contacting your server`)) - logger.error('Failed to save feed', {message: e}) - } + await onSave() } - }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) + }, [feed?.type, isSaved, removePromptControl, onSave]) /* * LOAD STATE @@ -167,25 +172,7 @@ export function FeedSourceCardLoaded({ accessibilityRole="button" accessibilityLabel={_(msg`Remove from my feeds`)} accessibilityHint="" - onPress={() => { - openModal({ - name: 'confirm', - title: _(msg`Remove from my feeds`), - message: _(msg`Remove this feed from my feeds?`), - onPressConfirm: async () => { - try { - await removeFeed({uri: feedUri}) - // await item.unsave() - Toast.show(_(msg`Removed from my feeds`)) - } catch (e) { - Toast.show( - _(msg`There was an issue contacting your server`), - ) - logger.error('Failed to unsave feed', {message: e}) - } - }, - }) - }} + onPress={onToggleSaved} hitSlop={15} style={styles.btn}> { - if (feed.type === 'feed') { - navigation.push('ProfileFeed', { - name: feed.creatorDid, - rkey: new AtUri(feed.uri).rkey, - }) - } else if (feed.type === 'list') { - navigation.push('ProfileList', { - name: feed.creatorDid, - rkey: new AtUri(feed.uri).rkey, - }) - } - }} - key={feed.uri}> - - - - - - - {feed.displayName} - - - {feed.type === 'feed' ? ( - Feed by {sanitizeHandle(feed.creatorHandle, '@')} - ) : ( - List by {sanitizeHandle(feed.creatorHandle, '@')} - )} - - - - {showSaveBtn && feed.type === 'feed' && ( - - - {isSaved ? ( - - ) : ( - - )} - + <> + { + if (feed.type === 'feed') { + navigation.push('ProfileFeed', { + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, + }) + } else if (feed.type === 'list') { + navigation.push('ProfileList', { + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, + }) + } + }} + key={feed.uri}> + + + + + + {feed.displayName} + + + {feed.type === 'feed' ? ( + Feed by {sanitizeHandle(feed.creatorHandle, '@')} + ) : ( + List by {sanitizeHandle(feed.creatorHandle, '@')} + )} + + + + {showSaveBtn && feed.type === 'feed' && ( + + + {isSaved ? ( + + ) : ( + + )} + + + )} + + + {showDescription && feed.description ? ( + + ) : null} + + {showLikes && feed.type === 'feed' ? ( + + + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} + + + ) : null} + + + - - {showDescription && feed.description ? ( - - ) : null} - - {showLikes && feed.type === 'feed' ? ( - - - Liked by {feed.likeCount || 0}{' '} - {pluralize(feed.likeCount || 0, 'user')} - - - ) : null} - + onConfirm={onUnsave} + confirmButtonCta={_(msg`Remove`)} + confirmButtonColor="negative" + /> + ) } diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx deleted file mode 100644 index 307897fb..00000000 --- a/src/view/com/modals/Confirm.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React, {useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {Text} from '../util/text/Text' -import {s, colors} from 'lib/styles' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {cleanError} from 'lib/strings/errors' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb} from 'platform/detection' -import {useLingui} from '@lingui/react' -import {Trans, msg} from '@lingui/macro' -import type {ConfirmModal} from '#/state/modals' -import {useModalControls} from '#/state/modals' - -export const snapPoints = ['50%'] - -export function Component({ - title, - message, - onPressConfirm, - onPressCancel, - confirmBtnText, - confirmBtnStyle, - cancelBtnText, -}: ConfirmModal) { - const pal = usePalette('default') - const {_} = useLingui() - const {closeModal} = useModalControls() - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState('') - const onPress = async () => { - setError('') - setIsProcessing(true) - try { - await onPressConfirm() - closeModal() - return - } catch (e: any) { - setError(cleanError(e)) - setIsProcessing(false) - } - } - return ( - - - {title} - - {typeof message === 'string' ? ( - - {message} - - ) : ( - message() - )} - {error ? ( - - - - ) : undefined} - - {isProcessing ? ( - - - - ) : ( - - - {confirmBtnText ?? Confirm} - - - )} - {onPressCancel === undefined ? null : ( - - - {cancelBtnText ?? Cancel} - - - )} - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 10, - paddingBottom: isWeb ? 0 : 60, - }, - title: { - textAlign: 'center', - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 22, - marginBottom: 10, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - marginTop: 22, - marginHorizontal: 44, - backgroundColor: colors.blue3, - }, - btnCancel: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - marginHorizontal: 20, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 100444ff..e03879c1 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -6,7 +6,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' import {useModals, useModalControls} from '#/state/modals' -import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' @@ -66,10 +65,7 @@ export function ModalsContainer() { let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element - if (activeModal?.name === 'confirm') { - snapPoints = ConfirmModal.snapPoints - element = - } else if (activeModal?.name === 'edit-profile') { + if (activeModal?.name === 'edit-profile') { snapPoints = EditProfileModal.snapPoints element = } else if (activeModal?.name === 'report') { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 0ced894a..d72b7e48 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -7,7 +7,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {useModals, useModalControls} from '#/state/modals' import type {Modal as ModalIface} from '#/state/modals' -import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ReportModal from './report/Modal' import * as AppealLabelModal from './AppealLabel' @@ -78,9 +77,7 @@ function Modal({modal}: {modal: ModalIface}) { } let element - if (modal.name === 'confirm') { - element = - } else if (modal.name === 'edit-profile') { + if (modal.name === 'edit-profile') { element = } else if (modal.name === 'report') { element = diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 6d99c32f..c52090f9 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -9,13 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' import {msg as msgLingui, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {FeedDescriptor} from '#/state/queries/post-feed' import {EmptyState} from '../util/EmptyState' import {cleanError} from '#/lib/strings/errors' import {useRemoveFeedMutation} from '#/state/queries/preferences' +import * as Prompt from '#/components/Prompt' export enum KnownError { Block = 'Block', @@ -118,35 +118,29 @@ function FeedgenErrorMessage({ ) const [_, uri] = feedDesc.split('|') const [ownerDid] = safeParseFeedgenUri(uri) - const {openModal, closeModal} = useModalControls() + const removePromptControl = Prompt.usePromptControl() const {mutateAsync: removeFeed} = useRemoveFeedMutation() const onViewProfile = React.useCallback(() => { navigation.navigate('Profile', {name: ownerDid}) }, [navigation, ownerDid]) + const onPressRemoveFeed = React.useCallback(() => { + removePromptControl.open() + }, [removePromptControl]) + const onRemoveFeed = React.useCallback(async () => { - openModal({ - name: 'confirm', - title: _l(msgLingui`Remove feed`), - message: _l(msgLingui`Remove this feed from your saved feeds?`), - async onPressConfirm() { - try { - await removeFeed({uri}) - } catch (err) { - Toast.show( - _l( - msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`, - ), - ) - logger.error('Failed to remove feed', {message: err}) - } - }, - onPressCancel() { - closeModal() - }, - }) - }, [openModal, closeModal, uri, removeFeed, _l]) + try { + await removeFeed({uri}) + } catch (err) { + Toast.show( + _l( + msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`, + ), + ) + logger.error('Failed to remove feed', {message: err}) + } + }, [uri, removeFeed, _l]) const cta = React.useMemo(() => { switch (knownError) { @@ -179,27 +173,38 @@ function FeedgenErrorMessage({ }, [knownError, onViewProfile, onRemoveFeed, _l]) return ( - - {msg} + <> + + {msg} - {rawError?.message && ( - - Message from server: {rawError.message} - - )} + {rawError?.message && ( + + Message from server: {rawError.message} + + )} - {cta} - + {cta} + + + + ) } diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index a11fe837..75e06eb9 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -52,6 +52,7 @@ import {LabelInfo} from '../util/moderation/LabelInfo' import {useProfileShadow} from 'state/cache/profile-shadow' import {atoms as a} from '#/alf' import {ProfileMenu} from 'view/com/profile/ProfileMenu' +import * as Prompt from '#/components/Prompt' let ProfileHeaderLoading = (_props: {}): React.ReactNode => { const pal = usePalette('default') @@ -104,6 +105,7 @@ let ProfileHeader = ({ const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [__, queueUnblock] = useProfileBlockMutationQueue(profile) + const unblockPromptControl = Prompt.usePromptControl() const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], @@ -176,27 +178,18 @@ let ProfileHeader = ({ }) }, [track, openModal, profile]) - const onPressUnblockAccount = React.useCallback(() => { + const unblockAccount = React.useCallback(async () => { track('ProfileHeader:UnblockAccountButtonClicked') - openModal({ - name: 'confirm', - title: _(msg`Unblock Account`), - message: _( - msg`The account will be able to interact with you after unblocking.`, - ), - onPressConfirm: async () => { - try { - await queueUnblock() - Toast.show(_(msg`Account unblocked`)) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unblock account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }, - }) - }, [_, openModal, queueUnblock, track]) + try { + await queueUnblock() + Toast.show(_(msg`Account unblocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }, [_, queueUnblock, track]) const isMe = React.useMemo( () => currentAccount?.did === profile.did, @@ -242,7 +235,7 @@ let ProfileHeader = ({ profile.viewer?.blockingByList ? null : ( unblockPromptControl.open()} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" accessibilityLabel={_(msg`Unblock`)} @@ -475,6 +468,18 @@ let ProfileHeader = ({ /> + ) } diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index d79e1891..ef102daf 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -33,6 +33,7 @@ import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Per import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' import {logger} from '#/logger' import {Shadow} from 'state/cache/types' +import * as Prompt from '#/components/Prompt' let ProfileMenu = ({ profile, @@ -53,6 +54,8 @@ let ProfileMenu = ({ const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const [, queueUnfollow] = useProfileFollowMutationQueue(profile) + const blockPromptControl = Prompt.usePromptControl() + const invalidateProfileQuery = React.useCallback(() => { queryClient.invalidateQueries({ queryKey: profileQueryKey(profile.did), @@ -102,49 +105,31 @@ let ProfileMenu = ({ } }, [profile.viewer?.muted, track, queueUnmute, _, queueMute]) - const onPressBlockAccount = React.useCallback(async () => { + const blockAccount = React.useCallback(async () => { if (profile.viewer?.blocking) { track('ProfileHeader:UnblockAccountButtonClicked') - openModal({ - name: 'confirm', - title: _(msg`Unblock Account`), - message: _( - msg`The account will be able to interact with you after unblocking.`, - ), - onPressConfirm: async () => { - try { - await queueUnblock() - Toast.show(_(msg`Account unblocked`)) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unblock account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }, - }) + try { + await queueUnblock() + Toast.show(_(msg`Account unblocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } } else { track('ProfileHeader:BlockAccountButtonClicked') - openModal({ - name: 'confirm', - title: _(msg`Block Account`), - message: _( - msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, - ), - onPressConfirm: async () => { - try { - await queueBlock() - Toast.show(_(msg`Account blocked`)) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to block account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }, - }) + try { + await queueBlock() + Toast.show(_(msg`Account blocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } } - }, [profile.viewer?.blocking, track, openModal, _, queueUnblock, queueBlock]) + }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock]) const onPressUnfollowAccount = React.useCallback(async () => { track('ProfileHeader:UnfollowButtonClicked') @@ -268,7 +253,7 @@ let ProfileMenu = ({ ? _(msg`Unblock Account`) : _(msg`Block Account`) } - onPress={onPressBlockAccount}> + onPress={() => blockPromptControl.open()}> {profile.viewer?.blocking ? ( Unblock Account @@ -299,6 +284,29 @@ let ProfileMenu = ({ )} + + ) } diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 3c1a736f..84a047c4 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -14,6 +14,8 @@ import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' +import {useDialogControl} from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' import {useModalControls} from '#/state/modals' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams} from '#/lib/routes/types' @@ -81,6 +83,8 @@ let PostDropdownBtn = ({ const openLink = useOpenLink() const navigation = useNavigation() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const deletePromptControl = useDialogControl() + const hidePromptControl = useDialogControl() const rootUri = record.reply?.root?.uri || postUri const isThreadMuted = mutedThreads.includes(rootUri) @@ -257,16 +261,7 @@ let PostDropdownBtn = ({ { - openModal({ - name: 'confirm', - title: _(msg`Hide this post?`), - message: _( - msg`This will hide this post from your feeds.`, - ), - onPressConfirm: onHidePost, - }) - }}> + onPress={hidePromptControl.open}> {_(msg`Hide post`)} @@ -298,14 +293,7 @@ let PostDropdownBtn = ({ { - openModal({ - name: 'confirm', - title: _(msg`Delete this post?`), - message: _(msg`Are you sure? This cannot be undone.`), - onPressConfirm: onDeletePost, - }) - }}> + onPress={deletePromptControl.open}> {_(msg`Delete post`)} @@ -335,6 +323,25 @@ let PostDropdownBtn = ({ + + + + + + ) } diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 9e98757e..95046e5b 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -61,6 +61,8 @@ import {logger} from '#/logger' import {useAnalytics} from '#/lib/analytics/analytics' import {listenSoftReset} from '#/state/events' import {atoms as a, useTheme} from '#/alf' +import * as Prompt from '#/components/Prompt' +import {useDialogControl} from '#/components/Dialog' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -234,7 +236,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const {_} = useLingui() const navigation = useNavigation() const {currentAccount} = useSession() - const {openModal, closeModal} = useModalControls() + const {openModal} = useModalControls() const listMuteMutation = useListMuteMutation() const listBlockMutation = useListBlockMutation() const listDeleteMutation = useListDeleteMutation() @@ -251,6 +253,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() const {track} = useAnalytics() + const deleteListPromptControl = useDialogControl() + const subscribeMutePromptControl = useDialogControl() + const subscribeBlockPromptControl = useDialogControl() + const isPinned = preferences?.feeds?.pinned?.includes(list.uri) const isSaved = preferences?.feeds?.saved?.includes(list.uri) @@ -269,32 +275,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } }, [list.uri, isPinned, pinFeed, unpinFeed, _]) - const onSubscribeMute = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Mute these accounts?`), - message: _( - msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, - ), - confirmBtnText: _(msg`Mute this List`), - async onPressConfirm() { - try { - await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) - Toast.show(_(msg`List muted`)) - track('Lists:Mute') - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, - onPressCancel() { - closeModal() - }, - }) - }, [openModal, closeModal, list, listMuteMutation, track, _]) + const onSubscribeMute = useCallback(async () => { + try { + await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) + Toast.show(_(msg`List muted`)) + track('Lists:Mute') + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + }, [list, listMuteMutation, track, _]) const onUnsubscribeMute = useCallback(async () => { try { @@ -310,32 +303,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } }, [list, listMuteMutation, track, _]) - const onSubscribeBlock = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Block these accounts?`), - message: _( - msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, - ), - confirmBtnText: _(msg`Block this List`), - async onPressConfirm() { - try { - await listBlockMutation.mutateAsync({uri: list.uri, block: true}) - Toast.show(_(msg`List blocked`)) - track('Lists:Block') - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, - onPressCancel() { - closeModal() - }, - }) - }, [openModal, closeModal, list, listBlockMutation, track, _]) + const onSubscribeBlock = useCallback(async () => { + try { + await listBlockMutation.mutateAsync({uri: list.uri, block: true}) + Toast.show(_(msg`List blocked`)) + track('Lists:Block') + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + }, [list, listBlockMutation, track, _]) const onUnsubscribeBlock = useCallback(async () => { try { @@ -358,34 +338,26 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { }) }, [openModal, list]) - const onPressDelete = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Delete List`), - message: _(msg`Are you sure?`), - async onPressConfirm() { - await listDeleteMutation.mutateAsync({uri: list.uri}) + const onPressDelete = useCallback(async () => { + await listDeleteMutation.mutateAsync({uri: list.uri}) - if (isSaved || isPinned) { - const {saved, pinned} = preferences!.feeds + if (isSaved || isPinned) { + const {saved, pinned} = preferences!.feeds - setSavedFeeds({ - saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, - pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, - }) - } + setSavedFeeds({ + saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, + pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, + }) + } - Toast.show(_(msg`List deleted`)) - track('Lists:Delete') - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, - }) + Toast.show(_(msg`List deleted`)) + track('Lists:Delete') + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } }, [ - openModal, list, listDeleteMutation, navigation, @@ -443,7 +415,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownDeleteBtn', label: _(msg`Delete List`), - onPress: onPressDelete, + onPress: deleteListPromptControl.open, icon: { ios: { name: 'trash', @@ -489,7 +461,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownMuteBtn', label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`), - onPress: isMuting ? onUnsubscribeMute : onSubscribeMute, + onPress: isMuting + ? onUnsubscribeMute + : subscribeMutePromptControl.open, icon: { ios: { name: isMuting ? 'eye' : 'eye.slash', @@ -504,7 +478,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownBlockBtn', label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`), - onPress: isBlocking ? onUnsubscribeBlock : onSubscribeBlock, + onPress: isBlocking + ? onUnsubscribeBlock + : subscribeBlockPromptControl.open, icon: { ios: { name: 'person.fill.xmark', @@ -517,24 +493,24 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } return items }, [ - isOwner, - onPressShare, - onPressEdit, - onPressDelete, - onPressReport, _, + onPressShare, + isOwner, isModList, isPinned, - unpinFeed, - isPending, - list.uri, isCurateList, - isMuting, + onPressEdit, + deleteListPromptControl.open, + onPressReport, + isPending, + unpinFeed, + list.uri, isBlocking, + isMuting, onUnsubscribeMute, - onSubscribeMute, + subscribeMutePromptControl.open, onUnsubscribeBlock, - onSubscribeBlock, + subscribeBlockPromptControl.open, ]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { @@ -542,7 +518,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { { testID: 'subscribeDropdownMuteBtn', label: _(msg`Mute accounts`), - onPress: onSubscribeMute, + onPress: subscribeMutePromptControl.open, icon: { ios: { name: 'speaker.slash', @@ -554,7 +530,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { { testID: 'subscribeDropdownBlockBtn', label: _(msg`Block accounts`), - onPress: onSubscribeBlock, + onPress: subscribeBlockPromptControl.open, icon: { ios: { name: 'person.fill.xmark', @@ -564,7 +540,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { }, }, ] - }, [onSubscribeMute, onSubscribeBlock, _]) + }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open]) return ( + + + + + + ) } diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index 09be124d..c2eaf19a 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -68,7 +68,7 @@ export function Dialogs() { Cancel - Confirm + {}}>Confirm