Repurpose report post modal and re-use for list reporting (#1070)

*  Repupose report post modal and re-use for list reporting

*  Allow reporting a feed generator

*  ♻️ Refactor report modal into one shared component for reporting different collections

*  Adjust report option selector in tests

*  Add test for list reporting

* ♻️  Refactor reason options and add options for list and feedgen

* 🧹 Cleanup remaining todo

* Fix to mutelist react keys

* Fix regression from rebase

* Improve customfeed mobile header

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Foysal Ahamed 2023-08-15 23:32:06 +02:00 committed by GitHub
parent a5762c2d7d
commit abbc6543f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 354 additions and 363 deletions

View File

@ -56,12 +56,12 @@ describe('Home screen', () => {
.atIndex(0) .atIndex(0)
.tap() .tap()
await element(by.text('Report post')).tap() await element(by.text('Report post')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible() await expect(element(by.id('reportModal'))).toBeVisible()
await element( await element(
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'), by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap() ).tap()
await element(by.id('sendReportBtn')).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 () => { it('Can swipe between feeds', async () => {

View File

@ -138,4 +138,33 @@ describe('Mute lists', () => {
await element(by.id('saveBtn')).tap() await element(by.id('saveBtn')).tap()
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() 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()
})
}) })

View File

@ -125,12 +125,12 @@ describe('Profile screen', () => {
it('Can report another user', async () => { it('Can report another user', async () => {
await element(by.id('profileHeaderDropdownBtn')).tap() await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Report Account')).tap() await element(by.text('Report Account')).tap()
await expect(element(by.id('reportAccountModal'))).toBeVisible() await expect(element(by.id('reportModal'))).toBeVisible()
await element( await element(
by.id('reportAccountRadios-com.atproto.moderation.defs#reasonSpam'), by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap() ).tap()
await element(by.id('sendReportBtn')).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 () => { it('Can like posts', async () => {
@ -173,11 +173,11 @@ describe('Profile screen', () => {
const posts = by.id('feedItem-by-bob.test') const posts = by.id('feedItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap() await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
await element(by.text('Report post')).tap() await element(by.text('Report post')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible() await expect(element(by.id('reportModal'))).toBeVisible()
await element( await element(
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'), by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap() ).tap()
await element(by.id('sendReportBtn')).tap() await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).not.toBeVisible() await expect(element(by.id('reportModal'))).not.toBeVisible()
}) })
}) })

View File

@ -105,23 +105,23 @@ describe('Thread screen', () => {
const post = by.id('postThreadItem-by-bob.test') const post = by.id('postThreadItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap() await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
await element(by.text('Report post')).tap() await element(by.text('Report post')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible() await expect(element(by.id('reportModal'))).toBeVisible()
await element( await element(
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'), by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap() ).tap()
await element(by.id('sendReportBtn')).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 () => { it('Can report a reply post', async () => {
const post = by.id('postThreadItem-by-carla.test') const post = by.id('postThreadItem-by-carla.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap() await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
await element(by.text('Report post')).tap() await element(by.text('Report post')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible() await expect(element(by.id('reportModal'))).toBeVisible()
await element( await element(
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'), by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap() ).tap()
await element(by.id('sendReportBtn')).tap() await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).not.toBeVisible() await expect(element(by.id('reportModal'))).not.toBeVisible()
}) })
}) })

View File

@ -306,7 +306,7 @@ export class ListModel {
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.list = res.data.list this.list = res.data.list
this.items = this.items.concat( this.items = this.items.concat(
res.data.items.map(item => ({...item, _reactKey: item.subject})), res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
) )
} }
} }

View File

@ -48,16 +48,15 @@ export interface ModerationDetailsModal {
moderation: ModerationUI moderation: ModerationUI
} }
export interface ReportPostModal { export type ReportModal = {
name: 'report-post' name: 'report'
postUri: string } & (
postCid: string | {
} uri: string
cid: string
export interface ReportAccountModal { }
name: 'report-account' | {did: string}
did: string )
}
export interface CreateOrEditMuteListModal { export interface CreateOrEditMuteListModal {
name: 'create-or-edit-mute-list' name: 'create-or-edit-mute-list'
@ -159,8 +158,7 @@ export type Modal =
// Moderation // Moderation
| ModerationDetailsModal | ModerationDetailsModal
| ReportAccountModal | ReportModal
| ReportPostModal
| CreateOrEditMuteListModal | CreateOrEditMuteListModal
| ListAddRemoveUserModal | ListAddRemoveUserModal

View File

@ -11,6 +11,7 @@ export const ListActions = ({
isOwner, isOwner,
onPressDeleteList, onPressDeleteList,
onPressShareList, onPressShareList,
onPressReportList,
reversed = false, // Default value of reversed is false reversed = false, // Default value of reversed is false
}: { }: {
isOwner: boolean isOwner: boolean
@ -19,6 +20,7 @@ export const ListActions = ({
onPressEditList?: () => void onPressEditList?: () => void
onPressDeleteList?: () => void onPressDeleteList?: () => void
onPressShareList?: () => void onPressShareList?: () => void
onPressReportList?: () => void
reversed?: boolean // New optional prop reversed?: boolean // New optional prop
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
@ -64,6 +66,17 @@ export const ListActions = ({
onPress={onPressShareList}> onPress={onPressShareList}>
<FontAwesomeIcon icon={'share'} style={[pal.text]} /> <FontAwesomeIcon icon={'share'} style={[pal.text]} />
</Button>, </Button>,
!isOwner && (
<Button
key="reportListBtn"
testID="reportListBtn"
type="default"
accessibilityLabel="Report list"
accessibilityHint=""
onPress={onPressReportList}>
<FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} />
</Button>
),
] ]
// If reversed is true, reverse the array to reverse the order of the buttons // If reversed is true, reverse the array to reverse the order of the buttons

View File

@ -45,6 +45,7 @@ export const ListItems = observer(
onPressEditList, onPressEditList,
onPressDeleteList, onPressDeleteList,
onPressShareList, onPressShareList,
onPressReportList,
renderEmptyState, renderEmptyState,
testID, testID,
headerOffset = 0, headerOffset = 0,
@ -57,6 +58,7 @@ export const ListItems = observer(
onPressEditList: () => void onPressEditList: () => void
onPressDeleteList: () => void onPressDeleteList: () => void
onPressShareList: () => void onPressShareList: () => void
onPressReportList: () => void
renderEmptyState?: () => JSX.Element renderEmptyState?: () => JSX.Element
testID?: string testID?: string
headerOffset?: number headerOffset?: number
@ -169,6 +171,7 @@ export const ListItems = observer(
onPressEditList={onPressEditList} onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList} onPressDeleteList={onPressDeleteList}
onPressShareList={onPressShareList} onPressShareList={onPressShareList}
onPressReportList={onPressReportList}
/> />
) : null ) : null
} else if (item === ERROR_ITEM) { } else if (item === ERROR_ITEM) {
@ -208,6 +211,7 @@ export const ListItems = observer(
onPressEditList, onPressEditList,
onPressDeleteList, onPressDeleteList,
onPressShareList, onPressShareList,
onPressReportList,
onPressTryAgain, onPressTryAgain,
onPressRetryLoadMore, onPressRetryLoadMore,
], ],
@ -267,6 +271,7 @@ const ListHeader = observer(
onPressEditList, onPressEditList,
onPressDeleteList, onPressDeleteList,
onPressShareList, onPressShareList,
onPressReportList,
}: { }: {
list: AppBskyGraphDefs.ListView list: AppBskyGraphDefs.ListView
isOwner: boolean isOwner: boolean
@ -274,6 +279,7 @@ const ListHeader = observer(
onPressEditList: () => void onPressEditList: () => void
onPressDeleteList: () => void onPressDeleteList: () => void
onPressShareList: () => void onPressShareList: () => void
onPressReportList: () => void
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
@ -319,6 +325,7 @@ const ListHeader = observer(
onPressEditList={onPressEditList} onPressEditList={onPressEditList}
onToggleSubscribed={onToggleSubscribed} onToggleSubscribed={onToggleSubscribed}
onPressShareList={onPressShareList} onPressShareList={onPressShareList}
onPressReportList={onPressReportList}
/> />
)} )}
</View> </View>

View File

@ -13,14 +13,13 @@ import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile' import * as EditProfileModal from './EditProfile'
import * as ProfilePreviewModal from './ProfilePreview' import * as ProfilePreviewModal from './ProfilePreview'
import * as ServerInputModal from './ServerInput' import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './report/ReportPost'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel' import * as SelfLabelModal from './SelfLabel'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as AltImageModal from './AltImage' import * as AltImageModal from './AltImage'
import * as EditImageModal 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 DeleteAccountModal from './DeleteAccount'
import * as ChangeHandleModal from './ChangeHandle' import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist' import * as WaitlistModal from './Waitlist'
@ -87,12 +86,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'server-input') { } else if (activeModal?.name === 'server-input') {
snapPoints = ServerInputModal.snapPoints snapPoints = ServerInputModal.snapPoints
element = <ServerInputModal.Component {...activeModal} /> element = <ServerInputModal.Component {...activeModal} />
} else if (activeModal?.name === 'report-post') { } else if (activeModal?.name === 'report') {
snapPoints = ReportPostModal.snapPoints snapPoints = ReportModal.snapPoints
element = <ReportPostModal.Component {...activeModal} /> element = <ReportModal.Component {...activeModal} />
} else if (activeModal?.name === 'report-account') {
snapPoints = ReportAccountModal.snapPoints
element = <ReportAccountModal.Component {...activeModal} />
} else if (activeModal?.name === 'create-or-edit-mute-list') { } else if (activeModal?.name === 'create-or-edit-mute-list') {
snapPoints = CreateOrEditMuteListModal.snapPoints snapPoints = CreateOrEditMuteListModal.snapPoints
element = <CreateOrEditMuteListModal.Component {...activeModal} /> element = <CreateOrEditMuteListModal.Component {...activeModal} />

View File

@ -10,8 +10,7 @@ import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile' import * as EditProfileModal from './EditProfile'
import * as ProfilePreviewModal from './ProfilePreview' import * as ProfilePreviewModal from './ProfilePreview'
import * as ServerInputModal from './ServerInput' import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './report/ReportPost' import * as ReportModal from './report/Modal'
import * as ReportAccountModal from './report/ReportAccount'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
@ -76,10 +75,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ProfilePreviewModal.Component {...modal} /> element = <ProfilePreviewModal.Component {...modal} />
} else if (modal.name === 'server-input') { } else if (modal.name === 'server-input') {
element = <ServerInputModal.Component {...modal} /> element = <ServerInputModal.Component {...modal} />
} else if (modal.name === 'report-post') { } else if (modal.name === 'report') {
element = <ReportPostModal.Component {...modal} /> element = <ReportModal.Component {...modal} />
} else if (modal.name === 'report-account') {
element = <ReportAccountModal.Component {...modal} />
} else if (modal.name === 'create-or-edit-mute-list') { } else if (modal.name === 'create-or-edit-mute-list') {
element = <CreateOrEditMuteListModal.Component {...modal} /> element = <CreateOrEditMuteListModal.Component {...modal} />
} else if (modal.name === 'list-add-remove-user') { } else if (modal.name === 'list-add-remove-user') {

View File

@ -1,10 +1,9 @@
import React, {useState, useMemo} from 'react' import React, {useState, useMemo} from 'react'
import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler' import {ScrollView} from 'react-native-gesture-handler'
import {ComAtprotoModerationDefs} from '@atproto/api' import {AtUri} from '@atproto/api'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup'
import {Text} from '../../util/text/Text' import {Text} from '../../util/text/Text'
import * as Toast from '../../util/Toast' import * as Toast from '../../util/Toast'
import {ErrorMessage} from '../../util/error/ErrorMessage' import {ErrorMessage} from '../../util/error/ErrorMessage'
@ -12,25 +11,43 @@ import {cleanError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {SendReportButton} from './SendReportButton' import {SendReportButton} from './SendReportButton'
import {InputIssueDetails} from './InputIssueDetails' import {InputIssueDetails} from './InputIssueDetails'
import {ReportReasonOptions} from './ReasonOptions'
import {CollectionId} from './types'
const DMCA_LINK = 'https://bsky.app/support/copyright' const DMCA_LINK = 'https://bsky.app/support/copyright'
export const snapPoints = [575] export const snapPoints = [575]
export function Component({ const CollectionNames = {
postUri, [CollectionId.FeedGenerator]: 'Feed',
postCid, [CollectionId.Profile]: 'Profile',
}: { [CollectionId.List]: 'List',
postUri: string [CollectionId.Post]: 'Post',
postCid: string }
}) {
type ReportComponentProps =
| {
uri: string
cid: string
}
| {
did: string
}
export function Component(content: ReportComponentProps) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [showTextInput, setShowTextInput] = useState(false) const [showDetailsInput, setShowDetailsInput] = useState(false)
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [issue, setIssue] = useState<string>() const [issue, setIssue] = useState<string>()
const [details, setDetails] = 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 () => { const submitReport = async () => {
setError('') setError('')
@ -43,12 +60,14 @@ export function Component({
Linking.openURL(DMCA_LINK) Linking.openURL(DMCA_LINK)
return return
} }
const $type = !isAccountReport
? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef'
await store.agent.createModerationReport({ await store.agent.createModerationReport({
reasonType: issue, reasonType: issue,
subject: { subject: {
$type: 'com.atproto.repo.strongRef', $type,
uri: postUri, ...content,
cid: postCid,
}, },
reason: details, reason: details,
}) })
@ -63,13 +82,13 @@ export function Component({
} }
const goBack = () => { const goBack = () => {
setShowTextInput(false) setShowDetailsInput(false)
} }
return ( return (
<ScrollView testID="reportPostModal" style={[s.flex1, pal.view]}> <ScrollView testID="reportModal" style={[s.flex1, pal.view]}>
<View style={styles.container}> <View style={styles.container}>
{showTextInput ? ( {showDetailsInput ? (
<InputIssueDetails <InputIssueDetails
details={details} details={details}
setDetails={setDetails} setDetails={setDetails}
@ -79,12 +98,13 @@ export function Component({
/> />
) : ( ) : (
<SelectIssue <SelectIssue
setShowTextInput={setShowTextInput} setShowDetailsInput={setShowDetailsInput}
error={error} error={error}
issue={issue} issue={issue}
setIssue={setIssue} setIssue={setIssue}
submitReport={submitReport} submitReport={submitReport}
isProcessing={isProcessing} isProcessing={isProcessing}
atUri={atUri}
/> />
)} )}
</View> </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 = ({ const SelectIssue = ({
error, error,
setShowTextInput, setShowDetailsInput,
issue, issue,
setIssue, setIssue,
submitReport, submitReport,
isProcessing, isProcessing,
atUri,
}: { }: {
error: string | undefined error: string | undefined
setShowTextInput: (v: boolean) => void setShowDetailsInput: (v: boolean) => void
issue: string | undefined issue: string | undefined
setIssue: (v: string) => void setIssue: (v: string) => void
submitReport: () => void submitReport: () => void
isProcessing: boolean isProcessing: boolean
atUri: AtUri | null
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
const ITEMS: RadioGroupItem[] = useMemo( const collectionName = getCollectionNameForReport(atUri)
() => [
{
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 onSelectIssue = (v: string) => setIssue(v) const onSelectIssue = (v: string) => setIssue(v)
const goToDetails = () => { const goToDetails = () => {
if (issue === '__copyright__') { if (issue === '__copyright__') {
Linking.openURL(DMCA_LINK) Linking.openURL(DMCA_LINK)
return return
} }
setShowTextInput(true) setShowDetailsInput(true)
} }
return ( 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]}> <Text style={[pal.textLight, styles.description]}>
What is the issue with this post? What is the issue with this {collectionName}?
</Text> </Text>
<RadioGroup <ReportReasonOptions
testID="reportPostRadios" atUri={atUri}
items={ITEMS} selectedIssue={issue}
onSelect={onSelectIssue} onSelectIssue={onSelectIssue}
/> />
{error ? ( {error ? (
<View style={s.mt10}> <View style={s.mt10}>
<ErrorMessage message={error} /> <ErrorMessage message={error} />
</View> </View>
) : undefined} ) : 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 <SendReportButton
onPress={submitReport} onPress={submitReport}

View File

@ -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}
/>
)
}

View File

@ -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,
},
})

View File

@ -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',
}

View File

@ -245,7 +245,7 @@ const ProfileHeaderLoaded = observer(
const onPressReportAccount = React.useCallback(() => { const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked') track('ProfileHeader:ReportAccountButtonClicked')
store.shell.openModal({ store.shell.openModal({
name: 'report-account', name: 'report',
did: view.did, did: view.did,
}) })
}, [track, store, view]) }, [track, store, view])

View File

@ -102,9 +102,9 @@ export function PostDropdownBtn({
label: 'Report post', label: 'Report post',
onPress() { onPress() {
store.shell.openModal({ store.shell.openModal({
name: 'report-post', name: 'report',
postUri: itemUri, uri: itemUri,
postCid: itemCid, cid: itemCid,
}) })
}, },
testID: 'postDropdownReportBtn', testID: 'postDropdownReportBtn',

View File

@ -89,6 +89,7 @@ import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
import {faList} from '@fortawesome/free-solid-svg-icons/faList'
export function setup() { export function setup() {
library.add( library.add(
@ -181,5 +182,6 @@ export function setup() {
faXmark, faXmark,
faPlay, faPlay,
faPause, faPause,
faList,
) )
} }

View File

@ -188,6 +188,15 @@ export const CustomFeedScreenInner = observer(
track('CustomFeed:Share') track('CustomFeed:Share')
}, [handleOrDid, rkey, track]) }, [handleOrDid, rkey, track])
const onPressReport = React.useCallback(() => {
if (!currentFeed) return
store.shell.openModal({
name: 'report',
uri: currentFeed.uri,
cid: currentFeed.data.cid,
})
}, [store, currentFeed])
const onScrollToTop = React.useCallback(() => { const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
resetMainScroll() resetMainScroll()
@ -200,15 +209,37 @@ export const CustomFeedScreenInner = observer(
const dropdownItems: DropdownItem[] = React.useMemo(() => { const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [ let items: DropdownItem[] = [
{ {
testID: 'feedHeaderDropdownRemoveBtn', testID: 'feedHeaderDropdownToggleSavedBtn',
label: 'Remove from my feeds', label: currentFeed?.isSaved
? 'Remove from my feeds'
: 'Add to my feeds',
onPress: onToggleSaved, onPress: onToggleSaved,
icon: { icon: currentFeed?.isSaved
? {
ios: { ios: {
name: 'trash', name: 'trash',
}, },
android: 'ic_delete', android: 'ic_delete',
web: 'trash', web: 'trash',
}
: {
ios: {
name: 'plus',
},
android: '',
web: 'plus',
},
},
{
testID: 'feedHeaderDropdownReportBtn',
label: 'Report feed',
onPress: onPressReport,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
}, },
}, },
{ {
@ -225,7 +256,7 @@ export const CustomFeedScreenInner = observer(
}, },
] ]
return items return items
}, [onToggleSaved, onPressShare]) }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare])
const renderHeaderBtns = React.useCallback(() => { const renderHeaderBtns = React.useCallback(() => {
return ( return (
@ -258,12 +289,7 @@ export const CustomFeedScreenInner = observer(
/> />
</Button> </Button>
) : undefined} ) : undefined}
{currentFeed?.isSaved ? ( {!currentFeed?.isSaved ? (
<NativeDropdown
testID="feedHeaderDropdownBtn"
items={dropdownItems}
/>
) : (
<Button <Button
type="default-light" type="default-light"
onPress={onToggleSaved} onPress={onToggleSaved}
@ -275,7 +301,21 @@ export const CustomFeedScreenInner = observer(
Add to My Feeds Add to My Feeds
</Text> </Text>
</Button> </Button>
)} ) : null}
<NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}>
<View
style={{
paddingLeft: currentFeed?.isSaved ? 12 : 6,
paddingRight: 12,
paddingVertical: 8,
}}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={pal.colors.textLight}
/>
</View>
</NativeDropdown>
</View> </View>
) )
}, [ }, [
@ -370,6 +410,17 @@ export const CustomFeedScreenInner = observer(
color={pal.colors.icon} color={pal.colors.icon}
/> />
</Button> </Button>
<Button
type="default"
accessibilityLabel="Report this feed"
accessibilityHint=""
onPress={onPressReport}>
<FontAwesomeIcon
icon="circle-exclamation"
size={18}
color={pal.colors.icon}
/>
</Button>
</View> </View>
)} )}
</View> </View>
@ -419,6 +470,7 @@ export const CustomFeedScreenInner = observer(
onToggleLiked, onToggleLiked,
onPressShare, onPressShare,
handleOrDid, handleOrDid,
onPressReport,
rkey, rkey,
isPinned, isPinned,
onTogglePinned, onTogglePinned,

View File

@ -86,6 +86,15 @@ export const ProfileListScreen = withAuthRequired(
}) })
}, [store, list, navigation]) }, [store, list, navigation])
const onPressReportList = React.useCallback(() => {
if (!list.list) return
store.shell.openModal({
name: 'report',
uri: list.uri,
cid: list.list.cid,
})
}, [store, list])
const onPressShareList = React.useCallback(() => { const onPressShareList = React.useCallback(() => {
const url = toShareUrl(`/profile/${name}/lists/${rkey}`) const url = toShareUrl(`/profile/${name}/lists/${rkey}`)
shareUrl(url) shareUrl(url)
@ -104,6 +113,7 @@ export const ProfileListScreen = withAuthRequired(
onPressEditList={onPressEditList} onPressEditList={onPressEditList}
onToggleSubscribed={onToggleSubscribed} onToggleSubscribed={onToggleSubscribed}
onPressShareList={onPressShareList} onPressShareList={onPressShareList}
onPressReportList={onPressReportList}
reversed={true} reversed={true}
/> />
) )
@ -114,6 +124,7 @@ export const ProfileListScreen = withAuthRequired(
onPressEditList, onPressEditList,
onPressShareList, onPressShareList,
onToggleSubscribed, onToggleSubscribed,
onPressReportList,
]) ])
return ( return (
@ -132,6 +143,7 @@ export const ProfileListScreen = withAuthRequired(
onToggleSubscribed={onToggleSubscribed} onToggleSubscribed={onToggleSubscribed}
onPressEditList={onPressEditList} onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList} onPressDeleteList={onPressDeleteList}
onPressReportList={onPressReportList}
onPressShareList={onPressShareList} onPressShareList={onPressShareList}
style={[s.flex1]} style={[s.flex1]}
/> />