✨ 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
parent
a5762c2d7d
commit
abbc6543f4
|
@ -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 () => {
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}>
|
||||
<FontAwesomeIcon icon={'share'} style={[pal.text]} />
|
||||
</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
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
@ -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 = <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} />
|
||||
|
|
|
@ -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 = <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') {
|
||||
|
|
|
@ -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}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
}
|
|
@ -245,7 +245,7 @@ const ProfileHeaderLoaded = observer(
|
|||
const onPressReportAccount = React.useCallback(() => {
|
||||
track('ProfileHeader:ReportAccountButtonClicked')
|
||||
store.shell.openModal({
|
||||
name: 'report-account',
|
||||
name: 'report',
|
||||
did: view.did,
|
||||
})
|
||||
}, [track, store, view])
|
||||
|
|
|
@ -102,9 +102,9 @@ export function PostDropdownBtn({
|
|||
label: 'Report post',
|
||||
onPress() {
|
||||
store.shell.openModal({
|
||||
name: 'report-post',
|
||||
postUri: itemUri,
|
||||
postCid: itemCid,
|
||||
name: 'report',
|
||||
uri: itemUri,
|
||||
cid: itemCid,
|
||||
})
|
||||
},
|
||||
testID: 'postDropdownReportBtn',
|
||||
|
|
|
@ -89,6 +89,7 @@ import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
|||
import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
|
||||
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
|
||||
import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
|
||||
import {faList} from '@fortawesome/free-solid-svg-icons/faList'
|
||||
|
||||
export function setup() {
|
||||
library.add(
|
||||
|
@ -181,5 +182,6 @@ export function setup() {
|
|||
faXmark,
|
||||
faPlay,
|
||||
faPause,
|
||||
faList,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -188,6 +188,15 @@ export const CustomFeedScreenInner = observer(
|
|||
track('CustomFeed:Share')
|
||||
}, [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(() => {
|
||||
scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
|
||||
resetMainScroll()
|
||||
|
@ -200,15 +209,37 @@ export const CustomFeedScreenInner = observer(
|
|||
const dropdownItems: DropdownItem[] = React.useMemo(() => {
|
||||
let items: DropdownItem[] = [
|
||||
{
|
||||
testID: 'feedHeaderDropdownRemoveBtn',
|
||||
label: 'Remove from my feeds',
|
||||
testID: 'feedHeaderDropdownToggleSavedBtn',
|
||||
label: currentFeed?.isSaved
|
||||
? 'Remove from my feeds'
|
||||
: 'Add to my feeds',
|
||||
onPress: onToggleSaved,
|
||||
icon: {
|
||||
icon: currentFeed?.isSaved
|
||||
? {
|
||||
ios: {
|
||||
name: 'trash',
|
||||
},
|
||||
android: 'ic_delete',
|
||||
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
|
||||
}, [onToggleSaved, onPressShare])
|
||||
}, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare])
|
||||
|
||||
const renderHeaderBtns = React.useCallback(() => {
|
||||
return (
|
||||
|
@ -258,12 +289,7 @@ export const CustomFeedScreenInner = observer(
|
|||
/>
|
||||
</Button>
|
||||
) : undefined}
|
||||
{currentFeed?.isSaved ? (
|
||||
<NativeDropdown
|
||||
testID="feedHeaderDropdownBtn"
|
||||
items={dropdownItems}
|
||||
/>
|
||||
) : (
|
||||
{!currentFeed?.isSaved ? (
|
||||
<Button
|
||||
type="default-light"
|
||||
onPress={onToggleSaved}
|
||||
|
@ -275,7 +301,21 @@ export const CustomFeedScreenInner = observer(
|
|||
Add to My Feeds
|
||||
</Text>
|
||||
</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>
|
||||
)
|
||||
}, [
|
||||
|
@ -370,6 +410,17 @@ export const CustomFeedScreenInner = observer(
|
|||
color={pal.colors.icon}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
accessibilityLabel="Report this feed"
|
||||
accessibilityHint=""
|
||||
onPress={onPressReport}>
|
||||
<FontAwesomeIcon
|
||||
icon="circle-exclamation"
|
||||
size={18}
|
||||
color={pal.colors.icon}
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
@ -419,6 +470,7 @@ export const CustomFeedScreenInner = observer(
|
|||
onToggleLiked,
|
||||
onPressShare,
|
||||
handleOrDid,
|
||||
onPressReport,
|
||||
rkey,
|
||||
isPinned,
|
||||
onTogglePinned,
|
||||
|
|
|
@ -86,6 +86,15 @@ export const ProfileListScreen = withAuthRequired(
|
|||
})
|
||||
}, [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 url = toShareUrl(`/profile/${name}/lists/${rkey}`)
|
||||
shareUrl(url)
|
||||
|
@ -104,6 +113,7 @@ export const ProfileListScreen = withAuthRequired(
|
|||
onPressEditList={onPressEditList}
|
||||
onToggleSubscribed={onToggleSubscribed}
|
||||
onPressShareList={onPressShareList}
|
||||
onPressReportList={onPressReportList}
|
||||
reversed={true}
|
||||
/>
|
||||
)
|
||||
|
@ -114,6 +124,7 @@ export const ProfileListScreen = withAuthRequired(
|
|||
onPressEditList,
|
||||
onPressShareList,
|
||||
onToggleSubscribed,
|
||||
onPressReportList,
|
||||
])
|
||||
|
||||
return (
|
||||
|
@ -132,6 +143,7 @@ export const ProfileListScreen = withAuthRequired(
|
|||
onToggleSubscribed={onToggleSubscribed}
|
||||
onPressEditList={onPressEditList}
|
||||
onPressDeleteList={onPressDeleteList}
|
||||
onPressReportList={onPressReportList}
|
||||
onPressShareList={onPressShareList}
|
||||
style={[s.flex1]}
|
||||
/>
|
||||
|
|
Loading…
Reference in New Issue