diff options
Diffstat (limited to 'src/view/com/modals')
-rw-r--r-- | src/view/com/modals/ChangeHandle.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/InviteCodes.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/ListAddRemoveUser.tsx | 65 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 42 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 15 | ||||
-rw-r--r-- | src/view/com/modals/ModerationDetails.tsx | 105 | ||||
-rw-r--r-- | src/view/com/modals/ProfilePreview.tsx | 91 | ||||
-rw-r--r-- | src/view/com/modals/SelfLabel.tsx | 191 | ||||
-rw-r--r-- | src/view/com/modals/report/Modal.tsx (renamed from src/view/com/modals/report/ReportPost.tsx) | 165 | ||||
-rw-r--r-- | src/view/com/modals/report/ReasonOptions.tsx | 123 | ||||
-rw-r--r-- | src/view/com/modals/report/ReportAccount.tsx | 197 | ||||
-rw-r--r-- | src/view/com/modals/report/types.ts | 8 |
12 files changed, 617 insertions, 395 deletions
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index a6010906c..0b9707622 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -493,7 +493,9 @@ function CustomHandleForm({ <ActivityIndicator color="white" /> ) : ( <Text type="xl-medium" style={[s.white, s.textCenter]}> - {canSave ? `Update to ${handle}` : 'Verify DNS Record'} + {canSave + ? `Update to ${handle}` + : `Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`} </Text> )} </Button> diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index b3fe9dd3f..d46579f09 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -53,11 +53,7 @@ export function Component({}: {}) { Invite a Friend </Text> <Text type="lg" style={[styles.description, pal.text]}> - Send these invites to your friends so they can create an account. Each - code works once! - </Text> - <Text type="sm" style={[styles.description, pal.textLight]}> - (You'll receive one invite code every two weeks.) + Each code works once. You'll receive more invite codes periodically. </Text> <ScrollView style={[styles.scrollContainer, pal.border]}> {store.me.invites.map((invite, i) => ( diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx index 0f001f911..bfb7e4dc0 100644 --- a/src/view/com/modals/ListAddRemoveUser.tsx +++ b/src/view/com/modals/ListAddRemoveUser.tsx @@ -1,6 +1,6 @@ import React, {useCallback} from 'react' import {observer} from 'mobx-react-lite' -import {Pressable, StyleSheet, View} from 'react-native' +import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import { FontAwesomeIcon, @@ -42,6 +42,7 @@ export const Component = observer( string[] >([]) const [selected, setSelected] = React.useState<string[]>([]) + const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) const listsList: ListsListModel = React.useMemo( () => new ListsListModel(store, store.me.did), @@ -58,12 +59,13 @@ export const Component = observer( const ids = memberships.memberships.map(m => m.value.list) setOriginalSelections(ids) setSelected(ids) + setMembershipsLoaded(true) }, err => { store.log.error('Failed to fetch memberships', {err}) }, ) - }, [memberships, listsList, store, setSelected]) + }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) const onPressCancel = useCallback(() => { store.shell.closeModal() @@ -107,11 +109,16 @@ export const Component = observer( return ( <Pressable testID={`toggleBtn-${list.name}`} - style={[styles.listItem, pal.border]} + style={[ + styles.listItem, + pal.border, + {opacity: membershipsLoaded ? 1 : 0.5}, + ]} accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ list.name }`} accessibilityHint="" + disabled={!membershipsLoaded} onPress={() => onToggleSelected(list.uri)}> <View style={styles.listItemAvi}> <UserAvatar size={40} avatar={list.avatar} /> @@ -132,23 +139,33 @@ export const Component = observer( : sanitizeHandle(list.creator.handle, '@')} </Text> </View> - <View - style={ - isSelected - ? [styles.checkbox, palPrimary.border, palPrimary.view] - : [styles.checkbox, pal.borderDark] - }> - {isSelected && ( - <FontAwesomeIcon - icon="check" - style={palInverted.text as FontAwesomeIconStyle} - /> - )} - </View> + {membershipsLoaded && ( + <View + style={ + isSelected + ? [styles.checkbox, palPrimary.border, palPrimary.view] + : [styles.checkbox, pal.borderDark] + }> + {isSelected && ( + <FontAwesomeIcon + icon="check" + style={palInverted.text as FontAwesomeIconStyle} + /> + )} + </View> + )} </Pressable> ) }, - [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did], + [ + pal, + palPrimary, + palInverted, + onToggleSelected, + selected, + store.me.did, + membershipsLoaded, + ], ) const renderEmptyState = React.useCallback(() => { @@ -200,6 +217,12 @@ export const Component = observer( label="Save Changes" /> )} + + {(listsList.isLoading || !membershipsLoaded) && ( + <View style={styles.loadingContainer}> + <ActivityIndicator /> + </View> + )} </View> </View> ) @@ -221,6 +244,7 @@ const styles = StyleSheet.create({ borderTopWidth: 1, }, btns: { + position: 'relative', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', @@ -263,4 +287,11 @@ const styles = StyleSheet.create({ borderRadius: 6, marginRight: 8, }, + loadingContainer: { + position: 'absolute', + top: 10, + right: 0, + bottom: 0, + justifyContent: 'center', + }, }) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 525df7ba1..efd06412d 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -6,18 +6,20 @@ import BottomSheet from '@gorhom/bottom-sheet' import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' +import {navigate} from '../../../Navigation' +import once from 'lodash.once' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' -import * as ReportPostModal from './report/ReportPost' import * as RepostModal from './Repost' +import * as SelfLabelModal from './SelfLabel' import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' -import * as ReportAccountModal from './report/ReportAccount' +import * as ReportModal from './report/Modal' import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' @@ -28,6 +30,7 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as OnboardingModal from './OnboardingModal' +import * as ModerationDetailsModal from './ModerationDetails' const DEFAULT_SNAPPOINTS = ['90%'] @@ -35,9 +38,25 @@ export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() const bottomSheetRef = useRef<BottomSheet>(null) const pal = usePalette('default') + + const activeModal = + store.shell.activeModals[store.shell.activeModals.length - 1] + + const navigateOnce = once(navigate) + + const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => { + if (activeModal?.name === 'profile-preview' && toIndex === 1) { + // begin loading the profile screen behind the scenes + navigateOnce('Profile', {name: activeModal.did}) + } + } const onBottomSheetChange = (snapPoint: number) => { if (snapPoint === -1) { store.shell.closeModal() + } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) { + // ensure we navigate to Profile and close the modal + navigateOnce('Profile', {name: activeModal.did}) + store.shell.closeModal() } } const onClose = () => { @@ -45,9 +64,6 @@ export const ModalsContainer = observer(function ModalsContainer() { store.shell.closeModal() } - const activeModal = - store.shell.activeModals[store.shell.activeModals.length - 1] - useEffect(() => { if (store.shell.isModalActive) { bottomSheetRef.current?.expand() @@ -70,12 +86,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'server-input') { snapPoints = ServerInputModal.snapPoints element = <ServerInputModal.Component {...activeModal} /> - } else if (activeModal?.name === 'report-post') { - snapPoints = ReportPostModal.snapPoints - element = <ReportPostModal.Component {...activeModal} /> - } else if (activeModal?.name === 'report-account') { - snapPoints = ReportAccountModal.snapPoints - element = <ReportAccountModal.Component {...activeModal} /> + } else if (activeModal?.name === 'report') { + snapPoints = ReportModal.snapPoints + element = <ReportModal.Component {...activeModal} /> } else if (activeModal?.name === 'create-or-edit-mute-list') { snapPoints = CreateOrEditMuteListModal.snapPoints element = <CreateOrEditMuteListModal.Component {...activeModal} /> @@ -88,6 +101,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'repost') { snapPoints = RepostModal.snapPoints element = <RepostModal.Component {...activeModal} /> + } else if (activeModal?.name === 'self-label') { + snapPoints = SelfLabelModal.snapPoints + element = <SelfLabelModal.Component {...activeModal} /> } else if (activeModal?.name === 'alt-text-image') { snapPoints = AltImageModal.snapPoints element = <AltImageModal.Component {...activeModal} /> @@ -121,6 +137,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'onboarding') { snapPoints = OnboardingModal.snapPoints element = <OnboardingModal.Component /> + } else if (activeModal?.name === 'moderation-details') { + snapPoints = ModerationDetailsModal.snapPoints + element = <ModerationDetailsModal.Component {...activeModal} /> } else { return null } @@ -146,6 +165,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } handleIndicatorStyle={{backgroundColor: pal.text.color}} handleStyle={[styles.handle, pal.view]} + onAnimate={onBottomSheetAnimate} onChange={onBottomSheetChange}> {element} </BottomSheet> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 39cdbd868..0e28b1618 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -10,12 +10,12 @@ import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' -import * as ReportPostModal from './report/ReportPost' -import * as ReportAccountModal from './report/ReportAccount' +import * as ReportModal from './report/Modal' import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' +import * as SelfLabelModal from './SelfLabel' import * as CropImageModal from './crop-image/CropImage.web' import * as AltTextImageModal from './AltImage' import * as EditImageModal from './EditImage' @@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as OnboardingModal from './OnboardingModal' +import * as ModerationDetailsModal from './ModerationDetails' import * as PreferencesHomeFeed from './PreferencesHomeFeed' @@ -74,10 +75,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <ProfilePreviewModal.Component {...modal} /> } else if (modal.name === 'server-input') { element = <ServerInputModal.Component {...modal} /> - } else if (modal.name === 'report-post') { - element = <ReportPostModal.Component {...modal} /> - } else if (modal.name === 'report-account') { - element = <ReportAccountModal.Component {...modal} /> + } else if (modal.name === 'report') { + element = <ReportModal.Component {...modal} /> } else if (modal.name === 'create-or-edit-mute-list') { element = <CreateOrEditMuteListModal.Component {...modal} /> } else if (modal.name === 'list-add-remove-user') { @@ -88,6 +87,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <DeleteAccountModal.Component /> } else if (modal.name === 'repost') { element = <RepostModal.Component {...modal} /> + } else if (modal.name === 'self-label') { + element = <SelfLabelModal.Component {...modal} /> } else if (modal.name === 'change-handle') { element = <ChangeHandleModal.Component {...modal} /> } else if (modal.name === 'waitlist') { @@ -110,6 +111,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <PreferencesHomeFeed.Component /> } else if (modal.name === 'onboarding') { element = <OnboardingModal.Component /> + } else if (modal.name === 'moderation-details') { + element = <ModerationDetailsModal.Component {...modal} /> } else { return null } diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx new file mode 100644 index 000000000..b0e68e61b --- /dev/null +++ b/src/view/com/modals/ModerationDetails.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {listUriToHref} from 'lib/strings/url-helpers' +import {Button} from '../util/forms/Button' + +export const snapPoints = [300] + +export function Component({ + context, + moderation, +}: { + context: 'account' | 'content' + moderation: ModerationUI +}) { + const store = useStores() + const pal = usePalette('default') + + let name + let description + if (!moderation.cause) { + name = 'Content Warning' + description = + 'Moderator has chosen to set a general warning on the content.' + } else if (moderation.cause.type === 'blocking') { + name = 'User Blocked' + description = 'You have blocked this user. You cannot view their content.' + } else if (moderation.cause.type === 'blocked-by') { + name = 'User Blocks You' + description = 'This user has blocked you. You cannot view their content.' + } else if (moderation.cause.type === 'block-other') { + name = 'Content Not Available' + description = + 'This content is not available because one of the users involved has blocked the other.' + } else if (moderation.cause.type === 'muted') { + if (moderation.cause.source.type === 'list') { + const list = moderation.cause.source.list + name = <>Account Muted by List</> + description = ( + <> + This user is included the{' '} + <TextLink + type="2xl" + href={listUriToHref(list.uri)} + text={list.name} + style={pal.link} + />{' '} + list which you have muted. + </> + ) + } else { + name = 'Account Muted' + description = 'You have muted this user.' + } + } else { + name = moderation.cause.labelDef.strings[context].en.name + description = moderation.cause.labelDef.strings[context].en.description + } + + return ( + <View testID="moderationDetailsModal" style={[styles.container, pal.view]}> + <Text type="title-xl" style={[pal.text, styles.title]}> + {name} + </Text> + <Text type="2xl" style={[pal.text, styles.description]}> + {description} + </Text> + <View style={s.flex1} /> + <Button + type="primary" + style={styles.btn} + onPress={() => store.shell.closeModal()}> + <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}> + Okay + </Text> + </Button> + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: isDesktopWeb ? 0 : 14, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + marginBottom: 12, + }, + description: { + textAlign: 'center', + }, + btn: { + paddingVertical: 14, + marginTop: isDesktopWeb ? 40 : 0, + marginBottom: isDesktopWeb ? 0 : 40, + }, +}) diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index d3267644b..4efe81225 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -1,63 +1,56 @@ -import React, {useState, useEffect, useCallback} from 'react' -import {StyleSheet, View} from 'react-native' +import React, {useState, useEffect} from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {useNavigation, StackActions} from '@react-navigation/native' -import {Text} from '../util/text/Text' +import {ThemedText} from '../util/text/ThemedText' import {useStores} from 'state/index' import {ProfileModel} from 'state/models/content/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {ProfileHeader} from '../profile/ProfileHeader' -import {Button} from '../util/forms/Button' -import {NavigationProp} from 'lib/routes/types' +import {InfoCircleIcon} from 'lib/icons' +import {useNavigationState} from '@react-navigation/native' +import {isIOS} from 'platform/detection' +import {s} from 'lib/styles' -export const snapPoints = [560] +export const snapPoints = [520, '100%'] export const Component = observer(({did}: {did: string}) => { const store = useStores() const pal = usePalette('default') - const palInverted = usePalette('inverted') - const navigation = useNavigation<NavigationProp>() const [model] = useState(new ProfileModel(store, {actor: did})) const {screen} = useAnalytics() + // track the navigator state to detect if a page-load occurred + const navState = useNavigationState(s => s) + const [initNavState] = useState(navState) + const isLoading = initNavState !== navState + useEffect(() => { screen('Profile:Preview') model.setup() }, [model, screen]) - const onPressViewProfile = useCallback(() => { - navigation.dispatch(StackActions.push('Profile', {name: model.handle})) - store.shell.closeModal() - }, [navigation, store, model]) - return ( - <View style={pal.view}> - <View style={styles.headerWrapper}> + <View style={[pal.view, s.flex1]}> + <View + style={[ + styles.headerWrapper, + isLoading && isIOS && styles.headerPositionAdjust, + ]}> <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} /> </View> - <View style={[styles.buttonsContainer, pal.view]}> - <View style={styles.buttons}> - <Button - type="inverted" - style={[styles.button, styles.buttonWide]} - onPress={onPressViewProfile} - accessibilityLabel="View profile" - accessibilityHint=""> - <Text type="button-lg" style={palInverted.text}> - View Profile - </Text> - </Button> - <Button - type="default" - style={styles.button} - onPress={() => store.shell.closeModal()} - accessibilityLabel="Close this preview" - accessibilityHint=""> - <Text type="button-lg" style={pal.text}> - Close - </Text> - </Button> + <View style={[styles.hintWrapper, pal.view]}> + <View style={styles.hint}> + {isLoading ? ( + <ActivityIndicator /> + ) : ( + <> + <InfoCircleIcon size={21} style={pal.textLight} /> + <ThemedText type="xl" fg="light"> + Swipe up to see more + </ThemedText> + </> + )} </View> </View> </View> @@ -68,22 +61,18 @@ const styles = StyleSheet.create({ headerWrapper: { height: 440, }, - buttonsContainer: { - height: 120, + headerPositionAdjust: { + // HACK align the header for the profilescreen transition -prf + paddingTop: 23, }, - buttons: { - flexDirection: 'row', - gap: 8, - paddingHorizontal: 14, - paddingTop: 16, + hintWrapper: { + height: 80, }, - button: { - flex: 2, + hint: { flexDirection: 'row', justifyContent: 'center', - paddingVertical: 12, - }, - buttonWide: { - flex: 3, + gap: 8, + paddingHorizontal: 14, + borderRadius: 6, }, }) diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx new file mode 100644 index 000000000..42863fd33 --- /dev/null +++ b/src/view/com/modals/SelfLabel.tsx @@ -0,0 +1,191 @@ +import React, {useState} from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {Text} from '../util/text/Text' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {Button} from '../util/forms/Button' +import {SelectableBtn} from '../util/forms/SelectableBtn' +import {ScrollView} from 'view/com/modals/util' + +const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn'] + +export const snapPoints = ['50%'] + +export const Component = observer(function Component({ + labels, + hasMedia, + onChange, +}: { + labels: string[] + hasMedia: boolean + onChange: (labels: string[]) => void +}) { + const pal = usePalette('default') + const store = useStores() + const [selected, setSelected] = useState(labels) + + const toggleAdultLabel = (label: string) => { + const hadLabel = selected.includes(label) + const stripped = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l)) + const final = !hadLabel ? stripped.concat([label]) : stripped + setSelected(final) + onChange(final) + } + + const removeAdultLabel = () => { + const final = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l)) + setSelected(final) + onChange(final) + } + + const hasAdultSelection = + selected.includes('sexual') || + selected.includes('nudity') || + selected.includes('porn') + return ( + <View testID="selfLabelModal" style={[pal.view, styles.container]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + Add a content warning + </Text> + </View> + + <ScrollView> + <View style={[styles.section, pal.border, {borderBottomWidth: 1}]}> + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingBottom: 8, + }}> + <Text type="title" style={pal.text}> + Adult Content + </Text> + {hasAdultSelection ? ( + <Button + type="default-light" + onPress={removeAdultLabel} + style={{paddingTop: 0, paddingBottom: 0, paddingRight: 0}}> + <Text type="md" style={pal.link}> + Remove + </Text> + </Button> + ) : null} + </View> + {hasMedia ? ( + <> + <View style={s.flexRow}> + <SelectableBtn + testID="sexualLabelBtn" + selected={selected.includes('sexual')} + left + label="Suggestive" + onSelect={() => toggleAdultLabel('sexual')} + accessibilityHint="" + style={s.flex1} + /> + <SelectableBtn + testID="nudityLabelBtn" + selected={selected.includes('nudity')} + label="Nudity" + onSelect={() => toggleAdultLabel('nudity')} + accessibilityHint="" + style={s.flex1} + /> + <SelectableBtn + testID="pornLabelBtn" + selected={selected.includes('porn')} + label="Porn" + right + onSelect={() => toggleAdultLabel('porn')} + accessibilityHint="" + style={s.flex1} + /> + </View> + + <Text style={[pal.text, styles.adultExplainer]}> + {selected.includes('sexual') ? ( + <>Pictures meant for adults.</> + ) : selected.includes('nudity') ? ( + <>Artistic or non-erotic nudity.</> + ) : selected.includes('porn') ? ( + <>Sexual activity or erotic nudity.</> + ) : ( + <>If none are selected, suitable for all ages.</> + )} + </Text> + </> + ) : ( + <View> + <Text style={[pal.textLight]}> + <Text type="md-bold" style={[pal.textLight]}> + Not Applicable + </Text> + . This warning is only available for posts with media attached. + </Text> + </View> + )} + </View> + </ScrollView> + + <View style={[styles.btnContainer, pal.borderDark]}> + <TouchableOpacity + testID="confirmBtn" + onPress={() => { + store.shell.closeModal() + }} + style={styles.btn} + accessibilityRole="button" + accessibilityLabel="Confirm" + accessibilityHint=""> + <Text style={[s.white, s.bold, s.f18]}>Done</Text> + </TouchableOpacity> + </View> + </View> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 40, + }, + titleSection: { + paddingTop: isDesktopWeb ? 0 : 4, + paddingBottom: isDesktopWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + description: { + textAlign: 'center', + paddingHorizontal: 32, + }, + section: { + borderTopWidth: 1, + paddingVertical: 20, + paddingHorizontal: isDesktopWeb ? 0 : 20, + }, + adultExplainer: { + paddingLeft: 5, + paddingTop: 10, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + paddingHorizontal: 20, + }, +}) diff --git a/src/view/com/modals/report/ReportPost.tsx b/src/view/com/modals/report/Modal.tsx index 34ec8c2f2..f386b110d 100644 --- a/src/view/com/modals/report/ReportPost.tsx +++ b/src/view/com/modals/report/Modal.tsx @@ -1,10 +1,9 @@ import React, {useState, useMemo} from 'react' import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' import {ScrollView} from 'react-native-gesture-handler' -import {ComAtprotoModerationDefs} from '@atproto/api' +import {AtUri} from '@atproto/api' import {useStores} from 'state/index' import {s} from 'lib/styles' -import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup' import {Text} from '../../util/text/Text' import * as Toast from '../../util/Toast' import {ErrorMessage} from '../../util/error/ErrorMessage' @@ -12,25 +11,43 @@ import {cleanError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {SendReportButton} from './SendReportButton' import {InputIssueDetails} from './InputIssueDetails' +import {ReportReasonOptions} from './ReasonOptions' +import {CollectionId} from './types' const DMCA_LINK = 'https://bsky.app/support/copyright' export const snapPoints = [575] -export function Component({ - postUri, - postCid, -}: { - postUri: string - postCid: string -}) { +const CollectionNames = { + [CollectionId.FeedGenerator]: 'Feed', + [CollectionId.Profile]: 'Profile', + [CollectionId.List]: 'List', + [CollectionId.Post]: 'Post', +} + +type ReportComponentProps = + | { + uri: string + cid: string + } + | { + did: string + } + +export function Component(content: ReportComponentProps) { const store = useStores() const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState(false) - const [showTextInput, setShowTextInput] = useState(false) + const [showDetailsInput, setShowDetailsInput] = useState(false) const [error, setError] = useState<string>() const [issue, setIssue] = useState<string>() const [details, setDetails] = useState<string>() + const isAccountReport = 'did' in content + const subjectKey = isAccountReport ? content.did : content.uri + const atUri = useMemo( + () => (!isAccountReport ? new AtUri(subjectKey) : null), + [isAccountReport, subjectKey], + ) const submitReport = async () => { setError('') @@ -43,12 +60,14 @@ export function Component({ Linking.openURL(DMCA_LINK) return } + const $type = !isAccountReport + ? 'com.atproto.repo.strongRef' + : 'com.atproto.admin.defs#repoRef' await store.agent.createModerationReport({ reasonType: issue, subject: { - $type: 'com.atproto.repo.strongRef', - uri: postUri, - cid: postCid, + $type, + ...content, }, reason: details, }) @@ -63,13 +82,13 @@ export function Component({ } const goBack = () => { - setShowTextInput(false) + setShowDetailsInput(false) } return ( - <ScrollView testID="reportPostModal" style={[s.flex1, pal.view]}> + <ScrollView testID="reportModal" style={[s.flex1, pal.view]}> <View style={styles.container}> - {showTextInput ? ( + {showDetailsInput ? ( <InputIssueDetails details={details} setDetails={setDetails} @@ -79,12 +98,13 @@ export function Component({ /> ) : ( <SelectIssue - setShowTextInput={setShowTextInput} + setShowDetailsInput={setShowDetailsInput} error={error} issue={issue} setIssue={setIssue} submitReport={submitReport} isProcessing={isProcessing} + atUri={atUri} /> )} </View> @@ -92,128 +112,59 @@ export function Component({ ) } +// If no atUri is passed, that means the reporting collection is account +const getCollectionNameForReport = (atUri: AtUri | null) => { + if (!atUri) return 'Account' + // Generic fallback for any collection being reported + return CollectionNames[atUri.collection as CollectionId] || 'Content' +} + const SelectIssue = ({ error, - setShowTextInput, + setShowDetailsInput, issue, setIssue, submitReport, isProcessing, + atUri, }: { error: string | undefined - setShowTextInput: (v: boolean) => void + setShowDetailsInput: (v: boolean) => void issue: string | undefined setIssue: (v: string) => void submitReport: () => void isProcessing: boolean + atUri: AtUri | null }) => { const pal = usePalette('default') - const ITEMS: RadioGroupItem[] = useMemo( - () => [ - { - key: ComAtprotoModerationDefs.REASONSPAM, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Spam - </Text> - <Text style={pal.textLight}>Excessive mentions or replies</Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONSEXUAL, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Unwanted Sexual Content - </Text> - <Text style={pal.textLight}> - Nudity or pornography not labeled as such - </Text> - </View> - ), - }, - { - key: '__copyright__', - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Copyright Violation - </Text> - <Text style={pal.textLight}>Contains copyrighted material</Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONRUDE, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Anti-Social Behavior - </Text> - <Text style={pal.textLight}> - Harassment, trolling, or intolerance - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONVIOLATION, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Illegal and Urgent - </Text> - <Text style={pal.textLight}> - Glaring violations of law or terms of service - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONOTHER, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Other - </Text> - <Text style={pal.textLight}> - An issue not included in these options - </Text> - </View> - ), - }, - ], - [pal], - ) - + const collectionName = getCollectionNameForReport(atUri) const onSelectIssue = (v: string) => setIssue(v) const goToDetails = () => { if (issue === '__copyright__') { Linking.openURL(DMCA_LINK) return } - setShowTextInput(true) + setShowDetailsInput(true) } return ( <> - <Text style={[pal.text, styles.title]}>Report post</Text> + <Text style={[pal.text, styles.title]}>Report {collectionName}</Text> <Text style={[pal.textLight, styles.description]}> - What is the issue with this post? + What is the issue with this {collectionName}? </Text> - <RadioGroup - testID="reportPostRadios" - items={ITEMS} - onSelect={onSelectIssue} + <ReportReasonOptions + atUri={atUri} + selectedIssue={issue} + onSelectIssue={onSelectIssue} /> {error ? ( <View style={s.mt10}> <ErrorMessage message={error} /> </View> ) : undefined} - {issue ? ( + {/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */} + {issue || !atUri ? ( <> <SendReportButton onPress={submitReport} diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx new file mode 100644 index 000000000..23b49b664 --- /dev/null +++ b/src/view/com/modals/report/ReasonOptions.tsx @@ -0,0 +1,123 @@ +import {View} from 'react-native' +import React, {useMemo} from 'react' +import {AtUri, ComAtprotoModerationDefs} from '@atproto/api' + +import {Text} from '../../util/text/Text' +import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette' +import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup' +import {CollectionId} from './types' + +type ReasonMap = Record<string, {title: string; description: string}> +const CommonReasons = { + [ComAtprotoModerationDefs.REASONRUDE]: { + title: 'Anti-Social Behavior', + description: 'Harassment, trolling, or intolerance', + }, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Illegal and Urgent', + description: 'Glaring violations of law or terms of service', + }, + [ComAtprotoModerationDefs.REASONOTHER]: { + title: 'Other', + description: 'An issue not included in these options', + }, +} +const CollectionToReasonsMap: Record<string, ReasonMap> = { + [CollectionId.Post]: { + [ComAtprotoModerationDefs.REASONSPAM]: { + title: 'Spam', + description: 'Excessive mentions or replies', + }, + [ComAtprotoModerationDefs.REASONSEXUAL]: { + title: 'Unwanted Sexual Content', + description: 'Nudity or pornography not labeled as such', + }, + __copyright__: { + title: 'Copyright Violation', + description: 'Contains copyrighted material', + }, + ...CommonReasons, + }, + [CollectionId.List]: { + ...CommonReasons, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Name or Description Violates Community Standards', + description: 'Terms used violate community standards', + }, + }, +} +const AccountReportReasons = { + [ComAtprotoModerationDefs.REASONMISLEADING]: { + title: 'Misleading Account', + description: 'Impersonation or false claims about identity or affiliation', + }, + [ComAtprotoModerationDefs.REASONSPAM]: { + title: 'Frequently Posts Unwanted Content', + description: 'Spam; excessive mentions or replies', + }, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Name or Description Violates Community Standards', + description: 'Terms used violate community standards', + }, +} + +const Option = ({ + pal, + title, + description, +}: { + pal: UsePaletteValue + description: string + title: string +}) => { + return ( + <View> + <Text style={pal.text} type="md-bold"> + {title} + </Text> + <Text style={pal.textLight}>{description}</Text> + </View> + ) +} + +// This is mostly just content copy without almost any logic +// so this may grow over time and it makes sense to split it up into its own file +// to keep it separate from the actual reporting modal logic +const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) => + useMemo(() => { + let items: ReasonMap = {...CommonReasons} + // If no atUri is passed, that means the reporting collection is account + if (!atUri) { + items = {...AccountReportReasons} + } + + if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) { + items = {...CollectionToReasonsMap[atUri.collection]} + } + + return Object.entries(items).map(([key, {title, description}]) => ({ + key, + label: <Option pal={pal} title={title} description={description} />, + })) + }, [pal, atUri]) + +export const ReportReasonOptions = ({ + atUri, + selectedIssue, + onSelectIssue, +}: { + atUri: AtUri | null + selectedIssue?: string + onSelectIssue: (key: string) => void +}) => { + const pal = usePalette('default') + const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri) + return ( + <RadioGroup + items={ITEMS} + onSelect={onSelectIssue} + testID="reportReasonRadios" + initialSelection={selectedIssue} + /> + ) +} diff --git a/src/view/com/modals/report/ReportAccount.tsx b/src/view/com/modals/report/ReportAccount.tsx deleted file mode 100644 index b53c54caa..000000000 --- a/src/view/com/modals/report/ReportAccount.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React, {useState, useMemo} from 'react' -import {TouchableOpacity, StyleSheet, View} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' -import {ComAtprotoModerationDefs} from '@atproto/api' -import {useStores} from 'state/index' -import {s} from 'lib/styles' -import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup' -import {Text} from '../../util/text/Text' -import * as Toast from '../../util/Toast' -import {ErrorMessage} from '../../util/error/ErrorMessage' -import {cleanError} from 'lib/strings/errors' -import {usePalette} from 'lib/hooks/usePalette' -import {isDesktopWeb} from 'platform/detection' -import {SendReportButton} from './SendReportButton' -import {InputIssueDetails} from './InputIssueDetails' - -export const snapPoints = [500] - -export function Component({did}: {did: string}) { - const store = useStores() - const pal = usePalette('default') - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState<string>() - const [issue, setIssue] = useState<string>() - const onSelectIssue = (v: string) => setIssue(v) - const [details, setDetails] = useState<string>() - const [showDetailsInput, setShowDetailsInput] = useState(false) - - const onPress = async () => { - setError('') - if (!issue) { - return - } - setIsProcessing(true) - try { - await store.agent.com.atproto.moderation.createReport({ - reasonType: issue, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did, - }, - reason: details, - }) - Toast.show("Thank you for your report! We'll look into it promptly.") - store.shell.closeModal() - return - } catch (e: any) { - setError(cleanError(e)) - setIsProcessing(false) - } - } - const goBack = () => { - setShowDetailsInput(false) - } - const goToDetails = () => { - setShowDetailsInput(true) - } - - return ( - <ScrollView - testID="reportAccountModal" - style={[styles.container, pal.view]}> - {showDetailsInput ? ( - <InputIssueDetails - submitReport={onPress} - setDetails={setDetails} - details={details} - isProcessing={isProcessing} - goBack={goBack} - /> - ) : ( - <SelectIssue - onPress={onPress} - onSelectIssue={onSelectIssue} - error={error} - isProcessing={isProcessing} - goToDetails={goToDetails} - /> - )} - </ScrollView> - ) -} - -const SelectIssue = ({ - onPress, - onSelectIssue, - error, - isProcessing, - goToDetails, -}: { - onPress: () => void - onSelectIssue: (v: string) => void - error: string | undefined - isProcessing: boolean - goToDetails: () => void -}) => { - const pal = usePalette('default') - const ITEMS: RadioGroupItem[] = useMemo( - () => [ - { - key: ComAtprotoModerationDefs.REASONMISLEADING, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Misleading Account - </Text> - <Text style={pal.textLight}> - Impersonation or false claims about identity or affiliation - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONSPAM, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Frequently Posts Unwanted Content - </Text> - <Text style={pal.textLight}> - Spam; excessive mentions or replies - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONVIOLATION, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Name or Description Violates Community Standards - </Text> - <Text style={pal.textLight}> - Terms used violate community standards - </Text> - </View> - ), - }, - ], - [pal], - ) - return ( - <> - <Text type="title-xl" style={[pal.text, styles.title]}> - Report Account - </Text> - <Text type="xl" style={[pal.text, styles.description]}> - What is the issue with this account? - </Text> - <RadioGroup - testID="reportAccountRadios" - items={ITEMS} - onSelect={onSelectIssue} - /> - <Text type="sm" style={[pal.text, styles.description, s.pt10]}> - For other issues, please report specific posts. - </Text> - {error ? ( - <View style={s.mt10}> - <ErrorMessage message={error} /> - </View> - ) : undefined} - <SendReportButton onPress={onPress} isProcessing={isProcessing} /> - <TouchableOpacity - testID="addDetailsBtn" - style={styles.addDetailsBtn} - onPress={goToDetails} - accessibilityRole="button" - accessibilityLabel="Add details" - accessibilityHint="Add more details to your report"> - <Text style={[s.f18, pal.link]}>Add details to report</Text> - </TouchableOpacity> - </> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingHorizontal: isDesktopWeb ? 0 : 10, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 22, - marginBottom: 10, - }, - addDetailsBtn: { - padding: 14, - alignSelf: 'center', - marginBottom: 40, - }, -}) diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts new file mode 100644 index 000000000..ca947ecbd --- /dev/null +++ b/src/view/com/modals/report/types.ts @@ -0,0 +1,8 @@ +// TODO: ATM, @atproto/api does not export ids but it does have these listed at @atproto/api/client/lexicons +// once we start exporting the ids from the @atproto/ap package, replace these hardcoded ones +export enum CollectionId { + FeedGenerator = 'app.bsky.feed.generator', + Profile = 'app.bsky.actor.profile', + List = 'app.bsky.graph.list', + Post = 'app.bsky.feed.post', +} |