diff --git a/__e2e__/tests/home-screen.test.ts b/__e2e__/tests/home-screen.test.ts index d0eeb670..7bad3c19 100644 --- a/__e2e__/tests/home-screen.test.ts +++ b/__e2e__/tests/home-screen.test.ts @@ -56,12 +56,12 @@ describe('Home screen', () => { .atIndex(0) .tap() await element(by.text('Report post')).tap() - await expect(element(by.id('reportPostModal'))).toBeVisible() + await expect(element(by.id('reportModal'))).toBeVisible() await element( - by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'), + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'), ).tap() await element(by.id('sendReportBtn')).tap() - await expect(element(by.id('reportPostModal'))).not.toBeVisible() + await expect(element(by.id('reportModal'))).not.toBeVisible() }) it('Can swipe between feeds', async () => { diff --git a/__e2e__/tests/mute-lists.test.ts b/__e2e__/tests/mute-lists.test.ts index 9c6980bb..870e7ced 100644 --- a/__e2e__/tests/mute-lists.test.ts +++ b/__e2e__/tests/mute-lists.test.ts @@ -138,4 +138,33 @@ describe('Mute lists', () => { await element(by.id('saveBtn')).tap() await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() }) + + it('Can report a mute list', async () => { + await element(by.id('bottomBarHomeBtn')).tap() + // Last test leaves us in the list view so we are going back 1 screen to the lists list screen + await element(by.id('viewHeaderDrawerBtn')).tap() + // then to the moderation screen + await element(by.id('viewHeaderDrawerBtn')).tap() + // then to the home screen + await element(by.id('viewHeaderDrawerBtn')).tap() + // then open the drawer to go to settings + await element(by.id('viewHeaderDrawerBtn')).tap() + await element(by.id('menuItemButton-Settings')).tap() + await element(by.id('signOutBtn')).tap() + await expect(element(by.id('signInButton'))).toBeVisible() + await login(service, 'bob.test', 'hunter2') + await element(by.id('bottomBarSearchBtn')).tap() + await element(by.id('searchTextInput')).typeText('alice') + await element(by.id('searchAutoCompleteResult-alice.test')).tap() + await element(by.id('selector-3')).tap() + await element(by.id('list-Bad Ppl')).tap() + await element(by.id('reportListBtn')).tap() + await expect(element(by.id('reportModal'))).toBeVisible() + await expect(element(by.text('Report List'))).toBeVisible() + await element( + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'), + ).tap() + await element(by.id('sendReportBtn')).tap() + await expect(element(by.id('reportModal'))).not.toBeVisible() + }) }) diff --git a/__e2e__/tests/profile-screen.test.ts b/__e2e__/tests/profile-screen.test.ts index 59b7326b..da798009 100644 --- a/__e2e__/tests/profile-screen.test.ts +++ b/__e2e__/tests/profile-screen.test.ts @@ -125,12 +125,12 @@ describe('Profile screen', () => { it('Can report another user', async () => { await element(by.id('profileHeaderDropdownBtn')).tap() await element(by.text('Report Account')).tap() - await expect(element(by.id('reportAccountModal'))).toBeVisible() + await expect(element(by.id('reportModal'))).toBeVisible() await element( - by.id('reportAccountRadios-com.atproto.moderation.defs#reasonSpam'), + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'), ).tap() await element(by.id('sendReportBtn')).tap() - await expect(element(by.id('reportAccountModal'))).not.toBeVisible() + await expect(element(by.id('reportModal'))).not.toBeVisible() }) it('Can like posts', async () => { @@ -173,11 +173,11 @@ describe('Profile screen', () => { const posts = by.id('feedItem-by-bob.test') await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap() await element(by.text('Report post')).tap() - await expect(element(by.id('reportPostModal'))).toBeVisible() + await expect(element(by.id('reportModal'))).toBeVisible() await element( - by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'), + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'), ).tap() await element(by.id('sendReportBtn')).tap() - await expect(element(by.id('reportPostModal'))).not.toBeVisible() + await expect(element(by.id('reportModal'))).not.toBeVisible() }) }) diff --git a/__e2e__/tests/thread-screen.test.ts b/__e2e__/tests/thread-screen.test.ts index 081282a3..0964988e 100644 --- a/__e2e__/tests/thread-screen.test.ts +++ b/__e2e__/tests/thread-screen.test.ts @@ -105,23 +105,23 @@ describe('Thread screen', () => { const post = by.id('postThreadItem-by-bob.test') await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap() await element(by.text('Report post')).tap() - await expect(element(by.id('reportPostModal'))).toBeVisible() + await expect(element(by.id('reportModal'))).toBeVisible() await element( - by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'), + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'), ).tap() await element(by.id('sendReportBtn')).tap() - await expect(element(by.id('reportPostModal'))).not.toBeVisible() + await expect(element(by.id('reportModal'))).not.toBeVisible() }) it('Can report a reply post', async () => { const post = by.id('postThreadItem-by-carla.test') await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap() await element(by.text('Report post')).tap() - await expect(element(by.id('reportPostModal'))).toBeVisible() + await expect(element(by.id('reportModal'))).toBeVisible() await element( - by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'), + by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'), ).tap() await element(by.id('sendReportBtn')).tap() - await expect(element(by.id('reportPostModal'))).not.toBeVisible() + await expect(element(by.id('reportModal'))).not.toBeVisible() }) }) diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts index 2498cf58..5d4ffb4f 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -306,7 +306,7 @@ export class ListModel { this.hasMore = !!this.loadMoreCursor this.list = res.data.list this.items = this.items.concat( - res.data.items.map(item => ({...item, _reactKey: item.subject})), + res.data.items.map(item => ({...item, _reactKey: item.subject.did})), ) } } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 348fa489..e5fd5d42 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -48,16 +48,15 @@ export interface ModerationDetailsModal { moderation: ModerationUI } -export interface ReportPostModal { - name: 'report-post' - postUri: string - postCid: string -} - -export interface ReportAccountModal { - name: 'report-account' - did: string -} +export type ReportModal = { + name: 'report' +} & ( + | { + uri: string + cid: string + } + | {did: string} +) export interface CreateOrEditMuteListModal { name: 'create-or-edit-mute-list' @@ -159,8 +158,7 @@ export type Modal = // Moderation | ModerationDetailsModal - | ReportAccountModal - | ReportPostModal + | ReportModal | CreateOrEditMuteListModal | ListAddRemoveUserModal diff --git a/src/view/com/lists/ListActions.tsx b/src/view/com/lists/ListActions.tsx index ee5a2afc..35333819 100644 --- a/src/view/com/lists/ListActions.tsx +++ b/src/view/com/lists/ListActions.tsx @@ -11,6 +11,7 @@ export const ListActions = ({ isOwner, onPressDeleteList, onPressShareList, + onPressReportList, reversed = false, // Default value of reversed is false }: { isOwner: boolean @@ -19,6 +20,7 @@ export const ListActions = ({ onPressEditList?: () => void onPressDeleteList?: () => void onPressShareList?: () => void + onPressReportList?: () => void reversed?: boolean // New optional prop }) => { const pal = usePalette('default') @@ -64,6 +66,17 @@ export const ListActions = ({ onPress={onPressShareList}> , + !isOwner && ( + + ), ] // If reversed is true, reverse the array to reverse the order of the buttons diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx index 188518ea..94e22f35 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListItems.tsx @@ -45,6 +45,7 @@ export const ListItems = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, renderEmptyState, testID, headerOffset = 0, @@ -57,6 +58,7 @@ export const ListItems = observer( onPressEditList: () => void onPressDeleteList: () => void onPressShareList: () => void + onPressReportList: () => void renderEmptyState?: () => JSX.Element testID?: string headerOffset?: number @@ -169,6 +171,7 @@ export const ListItems = observer( onPressEditList={onPressEditList} onPressDeleteList={onPressDeleteList} onPressShareList={onPressShareList} + onPressReportList={onPressReportList} /> ) : null } else if (item === ERROR_ITEM) { @@ -208,6 +211,7 @@ export const ListItems = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, onPressTryAgain, onPressRetryLoadMore, ], @@ -267,6 +271,7 @@ const ListHeader = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, }: { list: AppBskyGraphDefs.ListView isOwner: boolean @@ -274,6 +279,7 @@ const ListHeader = observer( onPressEditList: () => void onPressDeleteList: () => void onPressShareList: () => void + onPressReportList: () => void }) => { const pal = usePalette('default') const store = useStores() @@ -319,6 +325,7 @@ const ListHeader = observer( onPressEditList={onPressEditList} onToggleSubscribed={onToggleSubscribed} onPressShareList={onPressShareList} + onPressReportList={onPressReportList} /> )} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index ce5dc40e..efd06412 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -13,14 +13,13 @@ 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' @@ -87,12 +86,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'server-input') { snapPoints = ServerInputModal.snapPoints element = - } else if (activeModal?.name === 'report-post') { - snapPoints = ReportPostModal.snapPoints - element = - } else if (activeModal?.name === 'report-account') { - snapPoints = ReportAccountModal.snapPoints - element = + } else if (activeModal?.name === 'report') { + snapPoints = ReportModal.snapPoints + element = } else if (activeModal?.name === 'create-or-edit-mute-list') { snapPoints = CreateOrEditMuteListModal.snapPoints element = diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 0f624782..0e28b161 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -10,8 +10,7 @@ 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' @@ -76,10 +75,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'server-input') { element = - } else if (modal.name === 'report-post') { - element = - } else if (modal.name === 'report-account') { - element = + } else if (modal.name === 'report') { + element = } else if (modal.name === 'create-or-edit-mute-list') { element = } else if (modal.name === 'list-add-remove-user') { diff --git a/src/view/com/modals/report/ReportPost.tsx b/src/view/com/modals/report/Modal.tsx similarity index 55% rename from src/view/com/modals/report/ReportPost.tsx rename to src/view/com/modals/report/Modal.tsx index 34ec8c2f..f386b110 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() const [issue, setIssue] = useState() const [details, setDetails] = useState() + 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 ( - + - {showTextInput ? ( + {showDetailsInput ? ( ) : ( )} @@ -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: ( - - - Spam - - Excessive mentions or replies - - ), - }, - { - key: ComAtprotoModerationDefs.REASONSEXUAL, - label: ( - - - Unwanted Sexual Content - - - Nudity or pornography not labeled as such - - - ), - }, - { - key: '__copyright__', - label: ( - - - Copyright Violation - - Contains copyrighted material - - ), - }, - { - key: ComAtprotoModerationDefs.REASONRUDE, - label: ( - - - Anti-Social Behavior - - - Harassment, trolling, or intolerance - - - ), - }, - { - key: ComAtprotoModerationDefs.REASONVIOLATION, - label: ( - - - Illegal and Urgent - - - Glaring violations of law or terms of service - - - ), - }, - { - key: ComAtprotoModerationDefs.REASONOTHER, - label: ( - - - Other - - - An issue not included in these options - - - ), - }, - ], - [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 ( <> - Report post + Report {collectionName} - What is the issue with this post? + What is the issue with this {collectionName}? - {error ? ( ) : 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 ? ( <> +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 = { + [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 ( + + + {title} + + {description} + + ) +} + +// 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: