Add self-labeling controls (#1141)
* Add self-label modal * Use the shield-exclamation icon consistently on post moderation * Wire up self-labeling * Bump @atproto/api@0.6.0 * Bump @atproto/dev-env@^0.2.3 * Add e2e test for self-labeling * Fix typeszio/stable
parent
48813a96d6
commit
03d152675e
|
@ -0,0 +1,34 @@
|
||||||
|
/* eslint-env detox/detox */
|
||||||
|
|
||||||
|
import {openApp, login, createServer, sleep} from '../util'
|
||||||
|
|
||||||
|
describe('Self-labeling', () => {
|
||||||
|
let service: string
|
||||||
|
beforeAll(async () => {
|
||||||
|
service = await createServer('?users')
|
||||||
|
await openApp({
|
||||||
|
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Login', async () => {
|
||||||
|
await login(service, 'alice', 'hunter2')
|
||||||
|
await element(by.id('homeScreenFeedTabs-Following')).tap()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Post an image with the porn label', async () => {
|
||||||
|
await element(by.id('composeFAB')).tap()
|
||||||
|
await element(by.id('composerTextInput')).typeText('Post with an image')
|
||||||
|
await element(by.id('openGalleryBtn')).tap()
|
||||||
|
await sleep(1e3)
|
||||||
|
await element(by.id('labelsBtn')).tap()
|
||||||
|
await element(by.id('pornLabelBtn')).tap()
|
||||||
|
await element(by.id('confirmBtn')).tap()
|
||||||
|
await element(by.id('composerPublishBtn')).tap()
|
||||||
|
await expect(element(by.id('composeFAB'))).toBeVisible()
|
||||||
|
const posts = by.id('feedItem-by-alice.test')
|
||||||
|
await expect(
|
||||||
|
element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0),
|
||||||
|
).toExist()
|
||||||
|
})
|
||||||
|
})
|
|
@ -24,7 +24,7 @@
|
||||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
|
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.5.4",
|
"@atproto/api": "^0.6.0",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@expo/html-elements": "^0.4.2",
|
"@expo/html-elements": "^0.4.2",
|
||||||
|
@ -146,7 +146,7 @@
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@atproto/dev-env": "^0.2.2",
|
"@atproto/dev-env": "^0.2.3",
|
||||||
"@atproto/pds": "^0.2.0-beta.2",
|
"@atproto/pds": "^0.2.0-beta.2",
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "^7.20.0",
|
||||||
"@babel/preset-env": "^7.20.0",
|
"@babel/preset-env": "^7.20.0",
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyRichtextFacet,
|
AppBskyRichtextFacet,
|
||||||
|
ComAtprotoLabelDefs,
|
||||||
ComAtprotoRepoUploadBlob,
|
ComAtprotoRepoUploadBlob,
|
||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
@ -77,6 +78,7 @@ interface PostOpts {
|
||||||
}
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ImageModel[]
|
||||||
|
labels?: string[]
|
||||||
knownHandles?: Set<string>
|
knownHandles?: Set<string>
|
||||||
onStateChange?: (state: string) => void
|
onStateChange?: (state: string) => void
|
||||||
langs?: string[]
|
langs?: string[]
|
||||||
|
@ -234,6 +236,15 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// set labels
|
||||||
|
let labels: ComAtprotoLabelDefs.SelfLabels | undefined
|
||||||
|
if (opts.labels?.length) {
|
||||||
|
labels = {
|
||||||
|
$type: 'com.atproto.label.defs#selfLabels',
|
||||||
|
values: opts.labels.map(val => ({val})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// add top 3 languages from user preferences if langs is provided
|
// add top 3 languages from user preferences if langs is provided
|
||||||
let langs = opts.langs
|
let langs = opts.langs
|
||||||
if (opts.langs) {
|
if (opts.langs) {
|
||||||
|
@ -248,6 +259,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||||
reply,
|
reply,
|
||||||
embed,
|
embed,
|
||||||
langs,
|
langs,
|
||||||
|
labels,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(`Failed to create post: ${e.toString()}`)
|
console.error(`Failed to create post: ${e.toString()}`)
|
||||||
|
|
|
@ -957,3 +957,41 @@ export function SatelliteDishIcon({
|
||||||
</Svg>
|
</Svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copyright (c) 2020 Refactoring UI Inc.
|
||||||
|
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||||
|
export function ShieldExclamation({
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
strokeWidth = 1.5,
|
||||||
|
}: {
|
||||||
|
style?: StyleProp<TextStyle>
|
||||||
|
size?: string | number
|
||||||
|
strokeWidth?: number
|
||||||
|
}) {
|
||||||
|
let color = 'currentColor'
|
||||||
|
if (
|
||||||
|
style &&
|
||||||
|
typeof style === 'object' &&
|
||||||
|
'color' in style &&
|
||||||
|
typeof style.color === 'string'
|
||||||
|
) {
|
||||||
|
color = style.color
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={strokeWidth || 1.5}
|
||||||
|
stroke={color}
|
||||||
|
style={style}>
|
||||||
|
<Path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -29,12 +29,7 @@ export function describeModerationCause(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cause.type === 'muted') {
|
if (cause.type === 'muted') {
|
||||||
if (cause.source.type === 'user') {
|
if (cause.source.type === 'list') {
|
||||||
return {
|
|
||||||
name: context === 'account' ? 'Muted User' : 'Post by muted user',
|
|
||||||
description: 'You have muted this user',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
return {
|
||||||
name:
|
name:
|
||||||
context === 'account'
|
context === 'account'
|
||||||
|
@ -42,6 +37,11 @@ export function describeModerationCause(
|
||||||
: `Post by muted user ("${cause.source.list.name}")`,
|
: `Post by muted user ("${cause.source.list.name}")`,
|
||||||
description: 'You have muted this user',
|
description: 'You have muted this user',
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: context === 'account' ? 'Muted User' : 'Post by muted user',
|
||||||
|
description: 'You have muted this user',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cause.labelDef.strings[context].en
|
return cause.labelDef.strings[context].en
|
||||||
|
|
|
@ -418,13 +418,7 @@ export class PreferencesModel {
|
||||||
return {
|
return {
|
||||||
userDid: this.rootStore.session.currentSession?.did || '',
|
userDid: this.rootStore.session.currentSession?.did || '',
|
||||||
adultContentEnabled: this.adultContentEnabled,
|
adultContentEnabled: this.adultContentEnabled,
|
||||||
labelerSettings: [
|
labels: {
|
||||||
{
|
|
||||||
labeler: {
|
|
||||||
did: '',
|
|
||||||
displayName: 'Bluesky Social',
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
// TEMP translate old settings until this UI can be migrated -prf
|
// TEMP translate old settings until this UI can be migrated -prf
|
||||||
porn: tempfixLabelPref(this.contentLabels.nsfw),
|
porn: tempfixLabelPref(this.contentLabels.nsfw),
|
||||||
sexual: tempfixLabelPref(this.contentLabels.suggestive),
|
sexual: tempfixLabelPref(this.contentLabels.suggestive),
|
||||||
|
@ -446,6 +440,13 @@ export class PreferencesModel {
|
||||||
impersonation: tempfixLabelPref(this.contentLabels.impersonation),
|
impersonation: tempfixLabelPref(this.contentLabels.impersonation),
|
||||||
scam: 'warn',
|
scam: 'warn',
|
||||||
},
|
},
|
||||||
|
labelers: [
|
||||||
|
{
|
||||||
|
labeler: {
|
||||||
|
did: '',
|
||||||
|
displayName: 'Bluesky Social',
|
||||||
|
},
|
||||||
|
labels: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,6 +100,12 @@ export interface RepostModal {
|
||||||
isReposted: boolean
|
isReposted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelfLabelModal {
|
||||||
|
name: 'self-label'
|
||||||
|
labels: string[]
|
||||||
|
onChange: (labels: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChangeHandleModal {
|
export interface ChangeHandleModal {
|
||||||
name: 'change-handle'
|
name: 'change-handle'
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
|
@ -164,6 +170,7 @@ export type Modal =
|
||||||
| EditImageModal
|
| EditImageModal
|
||||||
| ServerInputModal
|
| ServerInputModal
|
||||||
| RepostModal
|
| RepostModal
|
||||||
|
| SelfLabelModal
|
||||||
|
|
||||||
// Bluesky access
|
// Bluesky access
|
||||||
| WaitlistModal
|
| WaitlistModal
|
||||||
|
|
|
@ -41,6 +41,7 @@ import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
import {Gallery} from './photos/Gallery'
|
import {Gallery} from './photos/Gallery'
|
||||||
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
|
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
|
||||||
|
import {LabelsBtn} from './labels/LabelsBtn'
|
||||||
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
||||||
|
|
||||||
type Props = ComposerOpts & {
|
type Props = ComposerOpts & {
|
||||||
|
@ -67,6 +68,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
initQuote,
|
initQuote,
|
||||||
)
|
)
|
||||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||||
|
const [labels, setLabels] = useState<string[]>([])
|
||||||
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
||||||
const gallery = useMemo(() => new GalleryModel(store), [store])
|
const gallery = useMemo(() => new GalleryModel(store), [store])
|
||||||
|
|
||||||
|
@ -145,8 +147,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
[gallery, track],
|
[gallery, track],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressPublish = useCallback(
|
const onPressPublish = async (rt: RichText) => {
|
||||||
async (rt: RichText) => {
|
|
||||||
if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) {
|
if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -168,8 +169,9 @@ export const ComposePost = observer(function ComposePost({
|
||||||
rawText: rt.text,
|
rawText: rt.text,
|
||||||
replyTo: replyTo?.uri,
|
replyTo: replyTo?.uri,
|
||||||
images: gallery.images,
|
images: gallery.images,
|
||||||
quote: quote,
|
quote,
|
||||||
extLink: extLink,
|
extLink,
|
||||||
|
labels,
|
||||||
onStateChange: setProcessingState,
|
onStateChange: setProcessingState,
|
||||||
knownHandles: autocompleteView.knownHandles,
|
knownHandles: autocompleteView.knownHandles,
|
||||||
langs: store.preferences.postLanguages,
|
langs: store.preferences.postLanguages,
|
||||||
|
@ -197,23 +199,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
onPost?.()
|
onPost?.()
|
||||||
onClose()
|
onClose()
|
||||||
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
|
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
|
||||||
},
|
}
|
||||||
[
|
|
||||||
isProcessing,
|
|
||||||
setError,
|
|
||||||
setIsProcessing,
|
|
||||||
replyTo,
|
|
||||||
autocompleteView.knownHandles,
|
|
||||||
extLink,
|
|
||||||
onClose,
|
|
||||||
onPost,
|
|
||||||
quote,
|
|
||||||
setExtLink,
|
|
||||||
store,
|
|
||||||
track,
|
|
||||||
gallery,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const canPost = useMemo(
|
const canPost = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -246,6 +232,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
<Text style={[pal.link, s.f18]}>Cancel</Text>
|
<Text style={[pal.link, s.f18]}>Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
|
<LabelsBtn labels={labels} onChange={setLabels} />
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<View style={styles.postBtn}>
|
<View style={styles.postBtn}>
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
|
@ -407,7 +394,7 @@ const styles = StyleSheet.create({
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: isDesktopWeb ? 10 : undefined,
|
paddingTop: isDesktopWeb ? 10 : undefined,
|
||||||
paddingBottom: 10,
|
paddingBottom: isDesktopWeb ? 10 : 4,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
height: 55,
|
height: 55,
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet} from 'react-native'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {Button} from 'view/com/util/forms/Button'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {ShieldExclamation} from 'lib/icons'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
||||||
|
|
||||||
|
export const LabelsBtn = observer(function LabelsBtn({
|
||||||
|
labels,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
labels: string[]
|
||||||
|
onChange: (v: string[]) => void
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="default-light"
|
||||||
|
testID="labelsBtn"
|
||||||
|
style={styles.button}
|
||||||
|
accessibilityLabel="Content warnings"
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={() =>
|
||||||
|
store.shell.openModal({name: 'self-label', labels, onChange})
|
||||||
|
}>
|
||||||
|
<ShieldExclamation style={pal.link} size={26} />
|
||||||
|
{labels.length > 0 ? (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="check"
|
||||||
|
size={16}
|
||||||
|
style={pal.link as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
maxWidth: 100,
|
||||||
|
},
|
||||||
|
})
|
|
@ -15,6 +15,7 @@ import * as ProfilePreviewModal from './ProfilePreview'
|
||||||
import * as ServerInputModal from './ServerInput'
|
import * as ServerInputModal from './ServerInput'
|
||||||
import * as ReportPostModal from './report/ReportPost'
|
import * as ReportPostModal from './report/ReportPost'
|
||||||
import * as RepostModal from './Repost'
|
import * as RepostModal from './Repost'
|
||||||
|
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'
|
||||||
|
@ -104,6 +105,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'repost') {
|
} else if (activeModal?.name === 'repost') {
|
||||||
snapPoints = RepostModal.snapPoints
|
snapPoints = RepostModal.snapPoints
|
||||||
element = <RepostModal.Component {...activeModal} />
|
element = <RepostModal.Component {...activeModal} />
|
||||||
|
} else if (activeModal?.name === 'self-label') {
|
||||||
|
snapPoints = SelfLabelModal.snapPoints
|
||||||
|
element = <SelfLabelModal.Component {...activeModal} />
|
||||||
} else if (activeModal?.name === 'alt-text-image') {
|
} else if (activeModal?.name === 'alt-text-image') {
|
||||||
snapPoints = AltImageModal.snapPoints
|
snapPoints = AltImageModal.snapPoints
|
||||||
element = <AltImageModal.Component {...activeModal} />
|
element = <AltImageModal.Component {...activeModal} />
|
||||||
|
|
|
@ -16,6 +16,7 @@ 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'
|
||||||
import * as RepostModal from './Repost'
|
import * as RepostModal from './Repost'
|
||||||
|
import * as SelfLabelModal from './SelfLabel'
|
||||||
import * as CropImageModal from './crop-image/CropImage.web'
|
import * as CropImageModal from './crop-image/CropImage.web'
|
||||||
import * as AltTextImageModal from './AltImage'
|
import * as AltTextImageModal from './AltImage'
|
||||||
import * as EditImageModal from './EditImage'
|
import * as EditImageModal from './EditImage'
|
||||||
|
@ -89,6 +90,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <DeleteAccountModal.Component />
|
element = <DeleteAccountModal.Component />
|
||||||
} else if (modal.name === 'repost') {
|
} else if (modal.name === 'repost') {
|
||||||
element = <RepostModal.Component {...modal} />
|
element = <RepostModal.Component {...modal} />
|
||||||
|
} else if (modal.name === 'self-label') {
|
||||||
|
element = <SelfLabelModal.Component {...modal} />
|
||||||
} else if (modal.name === 'change-handle') {
|
} else if (modal.name === 'change-handle') {
|
||||||
element = <ChangeHandleModal.Component {...modal} />
|
element = <ChangeHandleModal.Component {...modal} />
|
||||||
} else if (modal.name === 'waitlist') {
|
} else if (modal.name === 'waitlist') {
|
||||||
|
|
|
@ -35,10 +35,7 @@ export function Component({
|
||||||
name = 'Account Blocks You'
|
name = 'Account Blocks You'
|
||||||
description = 'This user has blocked you. You cannot view their content.'
|
description = 'This user has blocked you. You cannot view their content.'
|
||||||
} else if (moderation.cause.type === 'muted') {
|
} else if (moderation.cause.type === 'muted') {
|
||||||
if (moderation.cause.source.type === 'user') {
|
if (moderation.cause.source.type === 'list') {
|
||||||
name = 'Account Muted'
|
|
||||||
description = 'You have muted this user.'
|
|
||||||
} else {
|
|
||||||
const list = moderation.cause.source.list
|
const list = moderation.cause.source.list
|
||||||
name = <>Account Muted by List</>
|
name = <>Account Muted by List</>
|
||||||
description = (
|
description = (
|
||||||
|
@ -53,6 +50,9 @@ export function Component({
|
||||||
list which you have muted.
|
list which you have muted.
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
name = 'Account Muted'
|
||||||
|
description = 'You have muted this user.'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
name = moderation.cause.labelDef.strings[context].en.name
|
name = moderation.cause.labelDef.strings[context].en.name
|
||||||
|
|
|
@ -0,0 +1,167 @@
|
||||||
|
import React, {useState} from 'react'
|
||||||
|
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {s, colors} from 'lib/styles'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
import {SelectableBtn} from '../util/forms/SelectableBtn'
|
||||||
|
import {ScrollView} from 'view/com/modals/util'
|
||||||
|
|
||||||
|
const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
|
||||||
|
|
||||||
|
export const snapPoints = ['50%']
|
||||||
|
|
||||||
|
export const Component = observer(function Component({
|
||||||
|
labels,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
labels: string[]
|
||||||
|
onChange: (labels: string[]) => void
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const [selected, setSelected] = useState(labels)
|
||||||
|
|
||||||
|
const toggleAdultContent = (label: string) => {
|
||||||
|
const hadLabel = selected.includes(label)
|
||||||
|
const stripped = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l))
|
||||||
|
const final = !hadLabel ? stripped.concat([label]) : stripped
|
||||||
|
setSelected(final)
|
||||||
|
onChange(final)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID="selfLabelModal" style={[pal.view, styles.container]}>
|
||||||
|
<View style={styles.titleSection}>
|
||||||
|
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||||
|
Add a content warning
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView>
|
||||||
|
<View style={[styles.section, pal.border, {borderBottomWidth: 1}]}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingBottom: 8,
|
||||||
|
}}>
|
||||||
|
<Text type="title" style={pal.text}>
|
||||||
|
Adult Content
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
{selected.includes('sexual') ? (
|
||||||
|
<>😏</>
|
||||||
|
) : selected.includes('nudity') ? (
|
||||||
|
<>🫣</>
|
||||||
|
) : selected.includes('porn') ? (
|
||||||
|
<>🥵</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={s.flexRow}>
|
||||||
|
<SelectableBtn
|
||||||
|
testID="sexualLabelBtn"
|
||||||
|
selected={selected.includes('sexual')}
|
||||||
|
left
|
||||||
|
label="Suggestive"
|
||||||
|
onSelect={() => toggleAdultContent('sexual')}
|
||||||
|
accessibilityHint=""
|
||||||
|
style={s.flex1}
|
||||||
|
/>
|
||||||
|
<SelectableBtn
|
||||||
|
testID="nudityLabelBtn"
|
||||||
|
selected={selected.includes('nudity')}
|
||||||
|
label="Nudity"
|
||||||
|
onSelect={() => toggleAdultContent('nudity')}
|
||||||
|
accessibilityHint=""
|
||||||
|
style={s.flex1}
|
||||||
|
/>
|
||||||
|
<SelectableBtn
|
||||||
|
testID="pornLabelBtn"
|
||||||
|
selected={selected.includes('porn')}
|
||||||
|
label="Porn"
|
||||||
|
right
|
||||||
|
onSelect={() => toggleAdultContent('porn')}
|
||||||
|
accessibilityHint=""
|
||||||
|
style={s.flex1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[pal.text, styles.adultExplainer]}>
|
||||||
|
{selected.includes('sexual') ? (
|
||||||
|
<>Pictures meant for adults.</>
|
||||||
|
) : selected.includes('nudity') ? (
|
||||||
|
<>Artistic or non-erotic nudity.</>
|
||||||
|
) : selected.includes('porn') ? (
|
||||||
|
<>Sexual activity or erotic nudity.</>
|
||||||
|
) : (
|
||||||
|
<>If none are selected, suitable for all ages.</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={[styles.btnContainer, pal.borderDark]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="confirmBtn"
|
||||||
|
onPress={() => {
|
||||||
|
store.shell.closeModal()
|
||||||
|
}}
|
||||||
|
style={styles.btn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Confirm"
|
||||||
|
accessibilityHint="">
|
||||||
|
<Text style={[s.white, s.bold, s.f18]}>Done</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: isDesktopWeb ? 0 : 40,
|
||||||
|
},
|
||||||
|
titleSection: {
|
||||||
|
paddingTop: isDesktopWeb ? 0 : 4,
|
||||||
|
paddingBottom: isDesktopWeb ? 14 : 10,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 5,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: isDesktopWeb ? 0 : 20,
|
||||||
|
},
|
||||||
|
adultExplainer: {
|
||||||
|
paddingLeft: 5,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 32,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: colors.blue3,
|
||||||
|
},
|
||||||
|
btnContainer: {
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
})
|
|
@ -271,6 +271,7 @@ export const FeedItem = observer(function ({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ContentHider
|
<ContentHider
|
||||||
|
testID="contentHider-post"
|
||||||
moderation={item.moderation.content}
|
moderation={item.moderation.content}
|
||||||
ignoreMute
|
ignoreMute
|
||||||
style={styles.contentHider}
|
style={styles.contentHider}
|
||||||
|
@ -291,6 +292,7 @@ export const FeedItem = observer(function ({
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{item.post.embed ? (
|
{item.post.embed ? (
|
||||||
<ContentHider
|
<ContentHider
|
||||||
|
testID="contentHider-embed"
|
||||||
moderation={item.moderation.embed}
|
moderation={item.moderation.embed}
|
||||||
style={styles.embed}>
|
style={styles.embed}>
|
||||||
<PostEmbeds
|
<PostEmbeds
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
|
||||||
interface SelectableBtnProps {
|
interface SelectableBtnProps {
|
||||||
|
testID?: string
|
||||||
selected: boolean
|
selected: boolean
|
||||||
label: string
|
label: string
|
||||||
left?: boolean
|
left?: boolean
|
||||||
|
@ -15,6 +16,7 @@ interface SelectableBtnProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectableBtn({
|
export function SelectableBtn({
|
||||||
|
testID,
|
||||||
selected,
|
selected,
|
||||||
label,
|
label,
|
||||||
left,
|
left,
|
||||||
|
@ -25,12 +27,15 @@ export function SelectableBtn({
|
||||||
}: SelectableBtnProps) {
|
}: SelectableBtnProps) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const palPrimary = usePalette('inverted')
|
const palPrimary = usePalette('inverted')
|
||||||
|
const needsWidthStyles = !style || !('width' in style || 'flex' in style)
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID={testID}
|
||||||
style={[
|
style={[
|
||||||
styles.selectableBtn,
|
styles.btn,
|
||||||
left && styles.selectableBtnLeft,
|
needsWidthStyles && styles.btnWidth,
|
||||||
right && styles.selectableBtnRight,
|
left && styles.btnLeft,
|
||||||
|
right && styles.btnRight,
|
||||||
pal.border,
|
pal.border,
|
||||||
selected ? palPrimary.view : pal.view,
|
selected ? palPrimary.view : pal.view,
|
||||||
style,
|
style,
|
||||||
|
@ -45,9 +50,7 @@ export function SelectableBtn({
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
selectableBtn: {
|
btn: {
|
||||||
flex: isDesktopWeb ? undefined : 1,
|
|
||||||
width: isDesktopWeb ? 100 : undefined,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
@ -55,12 +58,16 @@ const styles = StyleSheet.create({
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
selectableBtnLeft: {
|
btnWidth: {
|
||||||
|
flex: isDesktopWeb ? undefined : 1,
|
||||||
|
width: isDesktopWeb ? 100 : undefined,
|
||||||
|
},
|
||||||
|
btnLeft: {
|
||||||
borderTopLeftRadius: 8,
|
borderTopLeftRadius: 8,
|
||||||
borderBottomLeftRadius: 8,
|
borderBottomLeftRadius: 8,
|
||||||
borderLeftWidth: 1,
|
borderLeftWidth: 1,
|
||||||
},
|
},
|
||||||
selectableBtnRight: {
|
btnRight: {
|
||||||
borderTopRightRadius: 8,
|
borderTopRightRadius: 8,
|
||||||
borderBottomRightRadius: 8,
|
borderBottomRightRadius: 8,
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {ModerationUI} from '@atproto/api'
|
import {ModerationUI} from '@atproto/api'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {InfoCircleIcon} from 'lib/icons'
|
import {ShieldExclamation} from 'lib/icons'
|
||||||
import {describeModerationCause} from 'lib/moderation'
|
import {describeModerationCause} from 'lib/moderation'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
@ -58,7 +58,7 @@ export function ContentHider({
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Learn more about this warning"
|
accessibilityLabel="Learn more about this warning"
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<InfoCircleIcon size={18} style={pal.text} />
|
<ShieldExclamation size={18} style={pal.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text type="lg" style={pal.text}>
|
<Text type="lg" style={pal.text}>
|
||||||
{desc.name}
|
{desc.name}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native'
|
||||||
import {ModerationUI} from '@atproto/api'
|
import {ModerationUI} from '@atproto/api'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {InfoCircleIcon} from 'lib/icons'
|
import {ShieldExclamation} from 'lib/icons'
|
||||||
import {describeModerationCause} from 'lib/moderation'
|
import {describeModerationCause} from 'lib/moderation'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ export function PostAlerts({
|
||||||
accessibilityLabel="Learn more about this warning"
|
accessibilityLabel="Learn more about this warning"
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
style={[styles.container, pal.viewLight, style]}>
|
style={[styles.container, pal.viewLight, style]}>
|
||||||
<InfoCircleIcon style={pal.text} size={18} />
|
<ShieldExclamation style={pal.text} size={16} />
|
||||||
<Text type="lg" style={pal.text}>
|
<Text type="lg" style={pal.text}>
|
||||||
{desc.name}
|
{desc.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {Link} from '../Link'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {addStyle} from 'lib/styles'
|
import {addStyle} from 'lib/styles'
|
||||||
import {describeModerationCause} from 'lib/moderation'
|
import {describeModerationCause} from 'lib/moderation'
|
||||||
import {InfoCircleIcon} from 'lib/icons'
|
import {ShieldExclamation} from 'lib/icons'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ export function PostHider({
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Learn more about this warning"
|
accessibilityLabel="Learn more about this warning"
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<InfoCircleIcon size={18} style={pal.text} />
|
<ShieldExclamation size={18} style={pal.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text type="lg" style={pal.text}>
|
<Text type="lg" style={pal.text}>
|
||||||
{desc.name}
|
{desc.name}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||||
import {ProfileModeration} from '@atproto/api'
|
import {ProfileModeration} from '@atproto/api'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {InfoCircleIcon} from 'lib/icons'
|
import {ShieldExclamation} from 'lib/icons'
|
||||||
import {
|
import {
|
||||||
describeModerationCause,
|
describeModerationCause,
|
||||||
getProfileModerationCauses,
|
getProfileModerationCauses,
|
||||||
|
@ -44,7 +44,7 @@ export function ProfileHeaderAlerts({
|
||||||
accessibilityLabel="Learn more about this warning"
|
accessibilityLabel="Learn more about this warning"
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
style={[styles.container, pal.viewLight, style]}>
|
style={[styles.container, pal.viewLight, style]}>
|
||||||
<InfoCircleIcon style={pal.text} size={24} />
|
<ShieldExclamation style={pal.text} size={24} />
|
||||||
<Text type="lg" style={pal.text}>
|
<Text type="lg" style={pal.text}>
|
||||||
{desc.name}
|
{desc.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -72,6 +72,8 @@ import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSq
|
||||||
import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
|
import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
|
||||||
import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
|
import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
|
||||||
import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
|
import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
|
||||||
|
import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
|
||||||
|
import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
|
||||||
import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
|
import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
|
||||||
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
|
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
|
||||||
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
|
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
|
||||||
|
@ -162,6 +164,8 @@ export function setup() {
|
||||||
faShield,
|
faShield,
|
||||||
faSignal,
|
faSignal,
|
||||||
faSliders,
|
faSliders,
|
||||||
|
faSquare,
|
||||||
|
faSquareCheck,
|
||||||
faSquarePlus,
|
faSquarePlus,
|
||||||
faUser,
|
faUser,
|
||||||
faUsers,
|
faUsers,
|
||||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -40,10 +40,10 @@
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
typed-emitter "^2.1.0"
|
typed-emitter "^2.1.0"
|
||||||
|
|
||||||
"@atproto/api@^0.5.4":
|
"@atproto/api@^0.6.0":
|
||||||
version "0.5.4"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.5.4.tgz#81a0dc36d3fcae085092434218740b68ee28f816"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.0.tgz#c4eea08ee4d1be522928cd016d7de8061d86e573"
|
||||||
integrity sha512-e2M5d+w6PMEzunVWbeX4yD9pMXaP6FakQYOfj4gUz9tL05k94Qv1dcX8IFUHdWEunHijLGU2fU2SNC4jgiuLBA==
|
integrity sha512-GkWHoGZfNneHarAYkIPJD1GGgKiI7OwnCtKS+J4AmlVKYijGEzOYgg1fY6rluT6XPT5TlQZiHUWpMlpqAkQIkQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "*"
|
"@atproto/common-web" "*"
|
||||||
"@atproto/uri" "*"
|
"@atproto/uri" "*"
|
||||||
|
@ -157,10 +157,10 @@
|
||||||
one-webcrypto "^1.0.3"
|
one-webcrypto "^1.0.3"
|
||||||
uint8arrays "3.0.0"
|
uint8arrays "3.0.0"
|
||||||
|
|
||||||
"@atproto/dev-env@^0.2.2":
|
"@atproto/dev-env@^0.2.3":
|
||||||
version "0.2.2"
|
version "0.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.2.2.tgz#930cb63dc751b08fa0a1c69820e27587d7b38252"
|
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.2.3.tgz#17f7574a85f560dbd128dc3dd7620de14ff63483"
|
||||||
integrity sha512-94KruFi2C52YHiSmvJKjPp3414KV9ev06Sla4BebJxm1z3/DKGhmPGDEHVke4+KPZOTR3vQ2x1WKuX87mtCp7w==
|
integrity sha512-7Glr0NVWftXF8kvmVouNWhXX9DzTWpKhytQZkfPaIWf6jVRATgRSfts1QOet1lTx33DczqKBJb9xc0qDt4MqWw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/api" "*"
|
"@atproto/api" "*"
|
||||||
"@atproto/bsky" "*"
|
"@atproto/bsky" "*"
|
||||||
|
|
Loading…
Reference in New Issue