Merge remote-tracking branch 'upstream/main' into patch-3

This commit is contained in:
Minseo Lee 2024-03-19 10:52:29 +09:00
commit ad43d594c9
174 changed files with 7262 additions and 5065 deletions

View file

@ -0,0 +1,923 @@
import React from 'react'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {View} from 'react-native'
import {
LABELS,
mock,
moderatePost,
moderateProfile,
ModerationOpts,
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
LabelPreference,
ModerationDecision,
ModerationBehavior,
RichText,
ComAtprotoLabelDefs,
interpretLabelValueDefinition,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {moderationOptsOverrideContext} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {FeedNotification} from '#/state/queries/notifications/types'
import {
groupNotifications,
shouldFilterNotif,
} from '#/state/queries/notifications/util'
import {atoms as a, useTheme} from '#/alf'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {H1, H3, P, Text} from '#/components/Typography'
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
import * as Toggle from '#/components/forms/Toggle'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom,
ChevronTop_Stroke2_Corner0_Rounded as ChevronTop,
} from '#/components/icons/Chevron'
import {ScreenHider} from '../../components/moderation/ScreenHider'
import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard'
import {ProfileCard} from '../com/profile/ProfileCard'
import {FeedItem} from '../com/posts/FeedItem'
import {FeedItem as NotifFeedItem} from '../com/notifications/FeedItem'
import {PostThreadItem} from '../com/post-thread/PostThreadItem'
import {Divider} from '#/components/Divider'
const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys(
LABELS,
) as (keyof typeof LABELS)[]
export const DebugModScreen = ({}: NativeStackScreenProps<
CommonNavigatorParams,
'DebugMod'
>) => {
const t = useTheme()
const [scenario, setScenario] = React.useState<string[]>(['label'])
const [scenarioSwitches, setScenarioSwitches] = React.useState<string[]>([])
const [label, setLabel] = React.useState<string[]>([LABEL_VALUES[0]])
const [target, setTarget] = React.useState<string[]>(['account'])
const [visibility, setVisiblity] = React.useState<string[]>(['warn'])
const [customLabelDef, setCustomLabelDef] =
React.useState<ComAtprotoLabelDefs.LabelValueDefinition>({
identifier: 'custom',
blurs: 'content',
severity: 'alert',
defaultSetting: 'warn',
locales: [
{
lang: 'en',
name: 'Custom label',
description: 'A custom label created in this test environment',
},
],
})
const [view, setView] = React.useState<string[]>(['post'])
const labelStrings = useGlobalLabelStrings()
const {currentAccount} = useSession()
const isTargetMe =
scenario[0] === 'label' && scenarioSwitches.includes('targetMe')
const isSelfLabel =
scenario[0] === 'label' && scenarioSwitches.includes('selfLabel')
const noAdult =
scenario[0] === 'label' && scenarioSwitches.includes('noAdult')
const isLoggedOut =
scenario[0] === 'label' && scenarioSwitches.includes('loggedOut')
const isFollowing = scenarioSwitches.includes('following')
const did =
isTargetMe && currentAccount ? currentAccount.did : 'did:web:bob.test'
const profile = React.useMemo(() => {
const mockedProfile = mock.profileViewBasic({
handle: `bob.test`,
displayName: 'Bob Robertson',
description: 'User with this as their bio',
labels:
scenario[0] === 'label' && target[0] === 'account'
? [
mock.label({
src: isSelfLabel ? did : undefined,
val: label[0],
uri: `at://${did}/`,
}),
]
: scenario[0] === 'label' && target[0] === 'profile'
? [
mock.label({
src: isSelfLabel ? did : undefined,
val: label[0],
uri: `at://${did}/app.bsky.actor.profile/self`,
}),
]
: undefined,
viewer: mock.actorViewerState({
following: isFollowing
? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234`
: undefined,
muted: scenario[0] === 'mute',
mutedByList: undefined,
blockedBy: undefined,
blocking:
scenario[0] === 'block'
? `at://did:web:alice.test/app.bsky.actor.block/fake`
: undefined,
blockingByList: undefined,
}),
})
mockedProfile.did = did
mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png'
mockedProfile.banner =
'https://bsky.social/about/images/social-card-default-gradient.png'
return mockedProfile
}, [scenario, target, label, isSelfLabel, did, isFollowing, currentAccount])
const post = React.useMemo(() => {
return mock.postView({
record: mock.post({
text: "This is the body of the post. It's where the text goes. You get the idea.",
}),
author: profile,
labels:
scenario[0] === 'label' && target[0] === 'post'
? [
mock.label({
src: isSelfLabel ? did : undefined,
val: label[0],
uri: `at://${did}/app.bsky.feed.post/fake`,
}),
]
: undefined,
embed:
target[0] === 'embed'
? mock.embedRecordView({
record: mock.post({
text: 'Embed',
}),
labels:
scenario[0] === 'label' && target[0] === 'embed'
? [
mock.label({
src: isSelfLabel ? did : undefined,
val: label[0],
uri: `at://${did}/app.bsky.feed.post/fake`,
}),
]
: undefined,
author: profile,
})
: {
$type: 'app.bsky.embed.images#view',
images: [
{
thumb:
'https://bsky.social/about/images/social-card-default-gradient.png',
fullsize:
'https://bsky.social/about/images/social-card-default-gradient.png',
alt: '',
},
],
},
})
}, [scenario, label, target, profile, isSelfLabel, did])
const replyNotif = React.useMemo(() => {
const notif = mock.replyNotification({
record: mock.post({
text: "This is the body of the post. It's where the text goes. You get the idea.",
reply: {
parent: {
uri: `at://${did}/app.bsky.feed.post/fake-parent`,
cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
},
root: {
uri: `at://${did}/app.bsky.feed.post/fake-parent`,
cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
},
},
}),
author: profile,
labels:
scenario[0] === 'label' && target[0] === 'post'
? [
mock.label({
src: isSelfLabel ? did : undefined,
val: label[0],
uri: `at://${did}/app.bsky.feed.post/fake`,
}),
]
: undefined,
})
const [item] = groupNotifications([notif])
item.subject = mock.postView({
record: notif.record as AppBskyFeedPost.Record,
author: profile,
labels: notif.labels,
})
return item
}, [scenario, label, target, profile, isSelfLabel, did])
const followNotif = React.useMemo(() => {
const notif = mock.followNotification({
author: profile,
subjectDid: currentAccount?.did || '',
})
const [item] = groupNotifications([notif])
return item
}, [profile, currentAccount])
const modOpts = React.useMemo(() => {
return {
userDid: isLoggedOut ? '' : isTargetMe ? did : 'did:web:alice.test',
prefs: {
adultContentEnabled: !noAdult,
labels: {
[label[0]]: visibility[0] as LabelPreference,
},
labelers: [
{
did: 'did:plc:fake-labeler',
labels: {[label[0]]: visibility[0] as LabelPreference},
},
],
mutedWords: [],
hiddenPosts: [],
},
labelDefs: {
'did:plc:fake-labeler': [
interpretLabelValueDefinition(customLabelDef, 'did:plc:fake-labeler'),
],
},
}
}, [label, visibility, noAdult, isLoggedOut, isTargetMe, did, customLabelDef])
const profileModeration = React.useMemo(() => {
return moderateProfile(profile, modOpts)
}, [profile, modOpts])
const postModeration = React.useMemo(() => {
return moderatePost(post, modOpts)
}, [post, modOpts])
return (
<moderationOptsOverrideContext.Provider value={modOpts}>
<ScrollView>
<CenteredView style={[t.atoms.bg, a.px_lg, a.py_lg]}>
<H1 style={[a.text_5xl, a.font_bold, a.pb_lg]}>Moderation states</H1>
<Heading title="" subtitle="Scenario" />
<ToggleButton.Group
label="Scenario"
values={scenario}
onChange={setScenario}>
<ToggleButton.Button name="label" label="Label">
Label
</ToggleButton.Button>
<ToggleButton.Button name="block" label="Block">
Block
</ToggleButton.Button>
<ToggleButton.Button name="mute" label="Mute">
Mute
</ToggleButton.Button>
</ToggleButton.Group>
{scenario[0] === 'label' && (
<>
<View
style={[
a.border,
a.rounded_sm,
a.mt_lg,
a.mb_lg,
a.p_lg,
t.atoms.border_contrast_medium,
]}>
<Toggle.Group
label="Toggle"
type="radio"
values={label}
onChange={setLabel}>
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
{LABEL_VALUES.map(labelValue => {
let targetFixed = target[0]
if (
targetFixed !== 'account' &&
targetFixed !== 'profile'
) {
targetFixed = 'content'
}
const disabled =
isSelfLabel &&
LABELS[labelValue].flags.includes('no-self')
return (
<Toggle.Item
key={labelValue}
name={labelValue}
label={labelStrings[labelValue].name}
disabled={disabled}
style={disabled ? {opacity: 0.5} : undefined}>
<Toggle.Radio />
<Toggle.Label>{labelValue}</Toggle.Label>
</Toggle.Item>
)
})}
<Toggle.Item
name="custom"
label="Custom label"
disabled={isSelfLabel}
style={isSelfLabel ? {opacity: 0.5} : undefined}>
<Toggle.Radio />
<Toggle.Label>Custom label</Toggle.Label>
</Toggle.Item>
</View>
</Toggle.Group>
{label[0] === 'custom' ? (
<CustomLabelForm
def={customLabelDef}
setDef={setCustomLabelDef}
/>
) : (
<>
<View style={{height: 10}} />
<Divider />
</>
)}
<View style={{height: 10}} />
<SmallToggler label="Advanced">
<Toggle.Group
label="Toggle"
type="checkbox"
values={scenarioSwitches}
onChange={setScenarioSwitches}>
<View style={[a.gap_md, a.flex_row, a.flex_wrap, a.pt_md]}>
<Toggle.Item name="targetMe" label="Target is me">
<Toggle.Checkbox />
<Toggle.Label>Target is me</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="following" label="Following target">
<Toggle.Checkbox />
<Toggle.Label>Following target</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="selfLabel" label="Self label">
<Toggle.Checkbox />
<Toggle.Label>Self label</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="noAdult" label="Adult disabled">
<Toggle.Checkbox />
<Toggle.Label>Adult disabled</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="loggedOut" label="Logged out">
<Toggle.Checkbox />
<Toggle.Label>Logged out</Toggle.Label>
</Toggle.Item>
</View>
</Toggle.Group>
{LABELS[label[0] as keyof typeof LABELS]?.configurable !==
false && (
<View style={[a.mt_md]}>
<Text
style={[a.font_bold, a.text_xs, t.atoms.text, a.pb_sm]}>
Preference
</Text>
<Toggle.Group
label="Preference"
type="radio"
values={visibility}
onChange={setVisiblity}>
<View
style={[
a.flex_row,
a.gap_md,
a.flex_wrap,
a.align_center,
]}>
<Toggle.Item name="hide" label="Hide">
<Toggle.Radio />
<Toggle.Label>Hide</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="warn" label="Warn">
<Toggle.Radio />
<Toggle.Label>Warn</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="ignore" label="Ignore">
<Toggle.Radio />
<Toggle.Label>Ignore</Toggle.Label>
</Toggle.Item>
</View>
</Toggle.Group>
</View>
)}
</SmallToggler>
</View>
<View style={[a.flex_row, a.flex_wrap, a.gap_md]}>
<View>
<Text
style={[
a.font_bold,
a.text_xs,
t.atoms.text,
a.pl_md,
a.pb_xs,
]}>
Target
</Text>
<View
style={[
a.border,
a.rounded_full,
a.px_md,
a.py_sm,
t.atoms.border_contrast_medium,
t.atoms.bg,
]}>
<Toggle.Group
label="Target"
type="radio"
values={target}
onChange={setTarget}>
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
<Toggle.Item name="account" label="Account">
<Toggle.Radio />
<Toggle.Label>Account</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="profile" label="Profile">
<Toggle.Radio />
<Toggle.Label>Profile</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="post" label="Post">
<Toggle.Radio />
<Toggle.Label>Post</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="embed" label="Embed">
<Toggle.Radio />
<Toggle.Label>Embed</Toggle.Label>
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</View>
</View>
</>
)}
<Spacer />
<Heading title="" subtitle="Results" />
<ToggleButton.Group label="Results" values={view} onChange={setView}>
<ToggleButton.Button name="post" label="Post">
Post
</ToggleButton.Button>
<ToggleButton.Button name="notifications" label="Notifications">
Notifications
</ToggleButton.Button>
<ToggleButton.Button name="account" label="Account">
Account
</ToggleButton.Button>
<ToggleButton.Button name="data" label="Data">
Data
</ToggleButton.Button>
</ToggleButton.Group>
<View
style={[
a.border,
a.rounded_sm,
a.mt_lg,
a.p_md,
t.atoms.border_contrast_medium,
]}>
{view[0] === 'post' && (
<>
<Heading title="Post" subtitle="in feed" />
<MockPostFeedItem post={post} moderation={postModeration} />
<Heading title="Post" subtitle="viewed directly" />
<MockPostThreadItem post={post} moderation={postModeration} />
<Heading title="Post" subtitle="reply in thread" />
<MockPostThreadItem
post={post}
moderation={postModeration}
reply
/>
</>
)}
{view[0] === 'notifications' && (
<>
<Heading title="Notification" subtitle="quote or reply" />
<MockNotifItem notif={replyNotif} moderationOpts={modOpts} />
<View style={{height: 20}} />
<Heading title="Notification" subtitle="follow or like" />
<MockNotifItem notif={followNotif} moderationOpts={modOpts} />
</>
)}
{view[0] === 'account' && (
<>
<Heading title="Account" subtitle="in listing" />
<MockAccountCard
profile={profile}
moderation={profileModeration}
/>
<Heading title="Account" subtitle="viewing directly" />
<MockAccountScreen
profile={profile}
moderation={profileModeration}
moderationOpts={modOpts}
/>
</>
)}
{view[0] === 'data' && (
<>
<ModerationUIView
label="Profile Moderation UI"
mod={profileModeration}
/>
<ModerationUIView
label="Post Moderation UI"
mod={postModeration}
/>
<DataView
label={label[0]}
data={LABELS[label[0] as keyof typeof LABELS]}
/>
<DataView
label="Profile Moderation Data"
data={profileModeration}
/>
<DataView label="Post Moderation Data" data={postModeration} />
</>
)}
</View>
<View style={{height: 400}} />
</CenteredView>
</ScrollView>
</moderationOptsOverrideContext.Provider>
)
}
function Heading({title, subtitle}: {title: string; subtitle?: string}) {
const t = useTheme()
return (
<H3 style={[a.text_3xl, a.font_bold, a.pb_md]}>
{title}{' '}
{!!subtitle && (
<H3 style={[t.atoms.text_contrast_medium, a.text_lg]}>{subtitle}</H3>
)}
</H3>
)
}
function CustomLabelForm({
def,
setDef,
}: {
def: ComAtprotoLabelDefs.LabelValueDefinition
setDef: React.Dispatch<
React.SetStateAction<ComAtprotoLabelDefs.LabelValueDefinition>
>
}) {
const t = useTheme()
return (
<View
style={[
a.flex_row,
a.flex_wrap,
a.gap_md,
t.atoms.bg_contrast_25,
a.rounded_md,
a.p_md,
a.mt_md,
]}>
<View>
<Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}>
Blurs
</Text>
<View
style={[
a.border,
a.rounded_full,
a.px_md,
a.py_sm,
t.atoms.border_contrast_medium,
t.atoms.bg,
]}>
<Toggle.Group
label="Blurs"
type="radio"
values={[def.blurs]}
onChange={values => setDef(v => ({...v, blurs: values[0]}))}>
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
<Toggle.Item name="content" label="Content">
<Toggle.Radio />
<Toggle.Label>Content</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="media" label="Media">
<Toggle.Radio />
<Toggle.Label>Media</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="none" label="None">
<Toggle.Radio />
<Toggle.Label>None</Toggle.Label>
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</View>
<View>
<Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}>
Severity
</Text>
<View
style={[
a.border,
a.rounded_full,
a.px_md,
a.py_sm,
t.atoms.border_contrast_medium,
t.atoms.bg,
]}>
<Toggle.Group
label="Severity"
type="radio"
values={[def.severity]}
onChange={values => setDef(v => ({...v, severity: values[0]}))}>
<View style={[a.flex_row, a.gap_md, a.flex_wrap, a.align_center]}>
<Toggle.Item name="alert" label="Alert">
<Toggle.Radio />
<Toggle.Label>Alert</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="inform" label="Inform">
<Toggle.Radio />
<Toggle.Label>Inform</Toggle.Label>
</Toggle.Item>
<Toggle.Item name="none" label="None">
<Toggle.Radio />
<Toggle.Label>None</Toggle.Label>
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</View>
</View>
)
}
function Toggler({label, children}: React.PropsWithChildren<{label: string}>) {
const t = useTheme()
const [show, setShow] = React.useState(false)
return (
<View style={a.mb_md}>
<View
style={[
t.atoms.border_contrast_medium,
a.border,
a.rounded_sm,
a.p_xs,
]}>
<Button
variant="solid"
color="secondary"
label="Toggle visibility"
size="small"
onPress={() => setShow(!show)}>
<ButtonText>{label}</ButtonText>
<ButtonIcon
icon={show ? ChevronTop : ChevronBottom}
position="right"
/>
</Button>
{show && children}
</View>
</View>
)
}
function SmallToggler({
label,
children,
}: React.PropsWithChildren<{label: string}>) {
const [show, setShow] = React.useState(false)
return (
<View>
<View style={[a.flex_row]}>
<Button
variant="ghost"
color="secondary"
label="Toggle visibility"
size="tiny"
onPress={() => setShow(!show)}>
<ButtonText>{label}</ButtonText>
<ButtonIcon
icon={show ? ChevronTop : ChevronBottom}
position="right"
/>
</Button>
</View>
{show && children}
</View>
)
}
function DataView({label, data}: {label: string; data: any}) {
return (
<Toggler label={label}>
<Text style={[{fontFamily: 'monospace'}, a.p_md]}>
{JSON.stringify(data, null, 2)}
</Text>
</Toggler>
)
}
function ModerationUIView({
mod,
label,
}: {
mod: ModerationDecision
label: string
}) {
return (
<Toggler label={label}>
<View style={a.p_lg}>
{[
'profileList',
'profileView',
'avatar',
'banner',
'displayName',
'contentList',
'contentView',
'contentMedia',
].map(key => {
const ui = mod.ui(key as keyof ModerationBehavior)
return (
<View key={key} style={[a.flex_row, a.gap_md]}>
<Text style={[a.font_bold, {width: 100}]}>{key}</Text>
<Flag v={ui.filter} label="Filter" />
<Flag v={ui.blur} label="Blur" />
<Flag v={ui.alert} label="Alert" />
<Flag v={ui.inform} label="Inform" />
<Flag v={ui.noOverride} label="No-override" />
</View>
)
})}
</View>
</Toggler>
)
}
function Spacer() {
return <View style={{height: 30}} />
}
function MockPostFeedItem({
post,
moderation,
}: {
post: AppBskyFeedDefs.PostView
moderation: ModerationDecision
}) {
const t = useTheme()
if (moderation.ui('contentList').filter) {
return (
<P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}>
Filtered from the feed
</P>
)
}
return (
<FeedItem
post={post}
record={post.record as AppBskyFeedPost.Record}
moderation={moderation}
reason={undefined}
/>
)
}
function MockPostThreadItem({
post,
reply,
}: {
post: AppBskyFeedDefs.PostView
moderation: ModerationDecision
reply?: boolean
}) {
return (
<PostThreadItem
// @ts-ignore
post={post}
record={post.record as AppBskyFeedPost.Record}
depth={reply ? 1 : 0}
isHighlightedPost={!reply}
treeView={false}
prevPost={undefined}
nextPost={undefined}
hasPrecedingItem={false}
onPostReply={() => {}}
/>
)
}
function MockNotifItem({
notif,
moderationOpts,
}: {
notif: FeedNotification
moderationOpts: ModerationOpts
}) {
const t = useTheme()
if (shouldFilterNotif(notif.notification, moderationOpts)) {
return (
<P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md]}>
Filtered from the feed
</P>
)
}
return <NotifFeedItem item={notif} moderationOpts={moderationOpts} />
}
function MockAccountCard({
profile,
moderation,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
moderation: ModerationDecision
}) {
const t = useTheme()
if (moderation.ui('profileList').filter) {
return (
<P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}>
Filtered from the listing
</P>
)
}
return <ProfileCard profile={profile} />
}
function MockAccountScreen({
profile,
moderation,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
moderation: ModerationDecision
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const {_} = useLingui()
return (
<View style={[t.atoms.border_contrast_medium, a.border, a.mb_md]}>
<ScreenHider
style={{}}
screenDescription={_(msg`profile`)}
modui={moderation.ui('profileView')}>
<ProfileHeaderStandard
// @ts-ignore ProfileViewBasic is close enough -prf
profile={profile}
moderationOpts={moderationOpts}
descriptionRT={new RichText({text: profile.description as string})}
/>
</ScreenHider>
</View>
)
}
function Flag({v, label}: {v: boolean | undefined; label: string}) {
const t = useTheme()
return (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<View
style={[
a.justify_center,
a.align_center,
a.rounded_xs,
a.border,
t.atoms.border_contrast_medium,
{
backgroundColor: t.palette.contrast_25,
width: 14,
height: 14,
},
]}>
{v && <Check size="xs" fill={t.palette.contrast_900} />}
</View>
<P style={a.text_xs}>{label}</P>
</View>
)
}

View file

@ -1,306 +0,0 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {s} from 'lib/styles'
import {CenteredView} from '../com/util/Views'
import {ViewHeader} from '../com/util/ViewHeader'
import {Link, TextLink} from '../com/util/Link'
import {Text} from '../com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ToggleButton} from '../com/util/forms/ToggleButton'
import {useSession} from '#/state/session'
import {
useProfileQuery,
useProfileUpdateMutation,
} from '#/state/queries/profile'
import {ScrollView} from '../com/util/Views'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
export function ModerationScreen({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics()
const {isTabletOrDesktop} = useWebMediaQueries()
const {openModal} = useModalControls()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
useFocusEffect(
React.useCallback(() => {
screen('Moderation')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
const onPressContentFiltering = React.useCallback(() => {
track('Moderation:ContentfilteringButtonClicked')
openModal({name: 'content-filtering-settings'})
}, [track, openModal])
return (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
]}
testID="moderationScreen">
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
<ScrollView contentContainerStyle={[styles.noBorder]}>
<View style={styles.spacer} />
<TouchableOpacity
testID="contentFilteringBtn"
style={[styles.linkCard, pal.view]}
onPress={onPressContentFiltering}
accessibilityRole="tab"
accessibilityLabel={_(msg`Content filtering`)}
accessibilityHint={_(
msg`Opens modal for content filtering settings`,
)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="eye"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Content filtering</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="mutedWordsBtn"
style={[styles.linkCard, pal.view]}
onPress={() => mutedWordsDialogControl.open()}
accessibilityRole="tab"
accessibilityLabel={_(msg`Muted words & tags`)}
accessibilityHint={_(msg`Open modal for muted words settings`)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="filter"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Muted words & tags</Trans>
</Text>
</TouchableOpacity>
<Link
testID="moderationlistsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/modlists">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="users-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Moderation lists</Trans>
</Text>
</Link>
<Link
testID="mutedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/muted-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="user-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Muted accounts</Trans>
</Text>
</Link>
<Link
testID="blockedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/blocked-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="ban"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Blocked accounts</Trans>
</Text>
</Link>
<Text
type="xl-bold"
style={[
pal.text,
{
paddingHorizontal: 18,
paddingTop: 18,
paddingBottom: 6,
},
]}>
<Trans>Logged-out visibility</Trans>
</Text>
<PwiOptOut />
</ScrollView>
</CenteredView>
)
}
function PwiOptOut() {
const pal = usePalette('default')
const {_} = useLingui()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const updateProfile = useProfileUpdateMutation()
const isOptedOut =
profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
const canToggle = profile && !updateProfile.isPending
const onToggleOptOut = React.useCallback(() => {
if (!profile) {
return
}
let wasAdded = false
updateProfile.mutate({
profile,
updates: existing => {
// create labels attr if needed
existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
? existing.labels
: {
$type: 'com.atproto.label.defs#selfLabels',
values: [],
}
// toggle the label
const hasLabel = existing.labels.values.some(
l => l.val === '!no-unauthenticated',
)
if (hasLabel) {
wasAdded = false
existing.labels.values = existing.labels.values.filter(
l => l.val !== '!no-unauthenticated',
)
} else {
wasAdded = true
existing.labels.values.push({val: '!no-unauthenticated'})
}
// delete if no longer needed
if (existing.labels.values.length === 0) {
delete existing.labels
}
return existing
},
checkCommitted: res => {
const exists = !!res.data.labels?.some(
l => l.val === '!no-unauthenticated',
)
return exists === wasAdded
},
})
}, [updateProfile, profile])
return (
<View style={[pal.view, styles.toggleCard]}>
<View
style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}>
<ToggleButton
type="default-light"
label={_(
msg`Discourage apps from showing my account to logged-out users`,
)}
labelType="lg"
isSelected={isOptedOut}
onPress={canToggle ? onToggleOptOut : undefined}
style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]}
/>
{updateProfile.isPending && <ActivityIndicator />}
</View>
<View
style={{
flexDirection: 'column',
gap: 10,
paddingLeft: 66,
paddingRight: 12,
paddingBottom: 10,
marginBottom: 64,
}}>
<Text style={pal.textLight}>
<Trans>
Bluesky will not show your profile and posts to logged-out users.
Other apps may not honor this request. This does not make your
account private.
</Trans>
</Text>
<Text style={[pal.textLight, {fontWeight: '500'}]}>
<Trans>
Note: Bluesky is an open and public network. This setting only
limits the visibility of your content on the Bluesky app and
website, and other apps may not respect this setting. Your content
may still be shown to logged-out users by other apps and websites.
</Trans>
</Text>
<TextLink
style={pal.link}
href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"
text={_(msg`Learn more about what is public on Bluesky.`)}
/>
</View>
</View>
)
}
const styles = StyleSheet.create({
desktopContainer: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
spacer: {
height: 6,
},
linkCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
marginBottom: 1,
},
toggleCard: {
paddingVertical: 8,
paddingTop: 2,
paddingHorizontal: 6,
marginBottom: 1,
},
iconContainer: {
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 30,
marginRight: 12,
},
noBorder: {
borderBottomWidth: 0,
borderRightWidth: 0,
borderLeftWidth: 0,
borderTopWidth: 0,
},
})

View file

@ -1,5 +1,5 @@
import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {StyleSheet} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {
AppBskyActorDefs,
@ -7,48 +7,39 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {CenteredView} from '../com/util/Views'
import {ListRef} from '../com/util/List'
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
import {Feed} from 'view/com/posts/Feed'
import {ScreenHider} from '#/components/moderation/ScreenHider'
import {ProfileLists} from '../com/lists/ProfileLists'
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {EmptyState} from '../com/util/EmptyState'
import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {ComposeIcon2} from 'lib/icons'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {combinedDisplayName} from 'lib/strings/display-names'
import {
FeedDescriptor,
resetProfilePostsQueries,
} from '#/state/queries/post-feed'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useProfileQuery} from '#/state/queries/profile'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession, getAgent} from '#/state/session'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {cleanError} from '#/lib/strings/errors'
import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
import {useQueryClient} from '@tanstack/react-query'
import {useComposerControls} from '#/state/shell/composer'
import {listenSoftReset} from '#/state/events'
import {truncateAndInvalidate} from '#/state/queries/util'
import {Text} from '#/view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isNative} from '#/platform/detection'
import {isInvalidHandle} from '#/lib/strings/handles'
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
interface SectionRef {
scrollToTop: () => void
}
@ -148,16 +139,24 @@ function ProfileScreenLoaded({
const setMinimalShellMode = useSetMinimalShellMode()
const {openComposer} = useComposerControls()
const {screen, track} = useAnalytics()
const {
data: labelerInfo,
error: labelerError,
isLoading: isLabelerLoading,
} = useLabelerInfoQuery({
did: profile.did,
enabled: !!profile.associated?.labeler,
})
const [currentPage, setCurrentPage] = React.useState(0)
const {_} = useLingui()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const extraInfoQuery = useProfileExtraInfoQuery(profile.did)
const postsSectionRef = React.useRef<SectionRef>(null)
const repliesSectionRef = React.useRef<SectionRef>(null)
const mediaSectionRef = React.useRef<SectionRef>(null)
const likesSectionRef = React.useRef<SectionRef>(null)
const feedsSectionRef = React.useRef<SectionRef>(null)
const listsSectionRef = React.useRef<SectionRef>(null)
const labelsSectionRef = React.useRef<SectionRef>(null)
useSetTitle(combinedDisplayName(profile))
@ -171,44 +170,75 @@ function ProfileScreenLoaded({
)
const isMe = profile.did === currentAccount?.did
const hasLabeler = !!profile.associated?.labeler
const showFiltersTab = hasLabeler
const showPostsTab = true
const showRepliesTab = hasSession
const showMediaTab = !hasLabeler
const showLikesTab = isMe
const showFeedsTab = hasSession && (isMe || extraInfoQuery.data?.hasFeedgens)
const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists)
const showFeedsTab =
hasSession && (isMe || (profile.associated?.feedgens || 0) > 0)
const showListsTab =
hasSession && (isMe || (profile.associated?.lists || 0) > 0)
const sectionTitles = useMemo<string[]>(() => {
return [
_(msg`Posts`),
showFiltersTab ? _(msg`Labels`) : undefined,
showListsTab && hasLabeler ? _(msg`Lists`) : undefined,
showPostsTab ? _(msg`Posts`) : undefined,
showRepliesTab ? _(msg`Replies`) : undefined,
_(msg`Media`),
showMediaTab ? _(msg`Media`) : undefined,
showLikesTab ? _(msg`Likes`) : undefined,
showFeedsTab ? _(msg`Feeds`) : undefined,
showListsTab ? _(msg`Lists`) : undefined,
showListsTab && !hasLabeler ? _(msg`Lists`) : undefined,
].filter(Boolean) as string[]
}, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab, _])
}, [
showPostsTab,
showRepliesTab,
showMediaTab,
showLikesTab,
showFeedsTab,
showListsTab,
showFiltersTab,
hasLabeler,
_,
])
let nextIndex = 0
const postsIndex = nextIndex++
let filtersIndex: number | null = null
let postsIndex: number | null = null
let repliesIndex: number | null = null
let mediaIndex: number | null = null
let likesIndex: number | null = null
let feedsIndex: number | null = null
let listsIndex: number | null = null
if (showFiltersTab) {
filtersIndex = nextIndex++
}
if (showPostsTab) {
postsIndex = nextIndex++
}
if (showRepliesTab) {
repliesIndex = nextIndex++
}
const mediaIndex = nextIndex++
let likesIndex: number | null = null
if (showMediaTab) {
mediaIndex = nextIndex++
}
if (showLikesTab) {
likesIndex = nextIndex++
}
let feedsIndex: number | null = null
if (showFeedsTab) {
feedsIndex = nextIndex++
}
let listsIndex: number | null = null
if (showListsTab) {
listsIndex = nextIndex++
}
const scrollSectionToTop = React.useCallback(
(index: number) => {
if (index === postsIndex) {
if (index === filtersIndex) {
labelsSectionRef.current?.scrollToTop()
} else if (index === postsIndex) {
postsSectionRef.current?.scrollToTop()
} else if (index === repliesIndex) {
repliesSectionRef.current?.scrollToTop()
@ -222,7 +252,15 @@ function ProfileScreenLoaded({
listsSectionRef.current?.scrollToTop()
}
},
[postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex],
[
filtersIndex,
postsIndex,
repliesIndex,
mediaIndex,
likesIndex,
feedsIndex,
listsIndex,
],
)
useFocusEffect(
@ -278,6 +316,7 @@ function ProfileScreenLoaded({
return (
<ProfileHeader
profile={profile}
labeler={labelerInfo}
descriptionRT={hasDescription ? descriptionRT : null}
moderationOpts={moderationOpts}
hideBackButton={hideBackButton}
@ -286,6 +325,7 @@ function ProfileScreenLoaded({
)
}, [
profile,
labelerInfo,
descriptionRT,
hasDescription,
moderationOpts,
@ -297,8 +337,8 @@ function ProfileScreenLoaded({
<ScreenHider
testID="profileView"
style={styles.container}
screenDescription="profile"
moderation={moderation.account}>
screenDescription={_(msg`profile`)}
modui={moderation.ui('profileView')}>
<PagerWithHeader
testID="profilePager"
isHeaderReady={!showPlaceholder}
@ -306,19 +346,45 @@ function ProfileScreenLoaded({
onPageSelected={onPageSelected}
onCurrentPageSelected={onCurrentPageSelected}
renderHeader={renderHeader}>
{({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
ref={postsSectionRef}
feed={`author|${profile.did}|posts_and_author_threads`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)}
{showFiltersTab
? ({headerHeight, scrollElRef}) => (
<ProfileLabelsSection
ref={labelsSectionRef}
labelerInfo={labelerInfo}
labelerError={labelerError}
isLabelerLoading={isLabelerLoading}
moderationOpts={moderationOpts}
scrollElRef={scrollElRef as ListRef}
headerHeight={headerHeight}
/>
)
: null}
{showListsTab && !!profile.associated?.labeler
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileLists
ref={listsSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
enabled={isFocused}
/>
)
: null}
{showPostsTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileFeedSection
ref={postsSectionRef}
feed={`author|${profile.did}|posts_and_author_threads`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)
: null}
{showRepliesTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
<ProfileFeedSection
ref={repliesSectionRef}
feed={`author|${profile.did}|posts_with_replies`}
headerHeight={headerHeight}
@ -328,19 +394,21 @@ function ProfileScreenLoaded({
/>
)
: null}
{({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
ref={mediaSectionRef}
feed={`author|${profile.did}|posts_with_media`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)}
{showMediaTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileFeedSection
ref={mediaSectionRef}
feed={`author|${profile.did}|posts_with_media`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)
: null}
{showLikesTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
<ProfileFeedSection
ref={likesSectionRef}
feed={`likes|${profile.did}`}
headerHeight={headerHeight}
@ -361,7 +429,7 @@ function ProfileScreenLoaded({
/>
)
: null}
{showListsTab
{showListsTab && !profile.associated?.labeler
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileLists
ref={listsSectionRef}
@ -387,77 +455,6 @@ function ProfileScreenLoaded({
)
}
interface FeedSectionProps {
feed: FeedDescriptor
headerHeight: number
isFocused: boolean
scrollElRef: ListRef
ignoreFilterFor?: string
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
ref,
) {
const {_} = useLingui()
const queryClient = useQueryClient()
const [hasNew, setHasNew] = React.useState(false)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
offset: -headerHeight,
})
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderPostsEmpty = React.useCallback(() => {
return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
}, [_])
return (
<View>
<Feed
testID="postsFeed"
enabled={isFocused}
feed={feed}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
/>
)}
</View>
)
},
)
function ProfileEndOfFeed() {
const pal = usePalette('default')
return (
<View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
<Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
<Trans>End of feed</Trans>
</Text>
</View>
)
}
function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))

View file

@ -35,7 +35,7 @@ import {ComposeIcon2} from 'lib/icons'
import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {
@ -155,7 +155,7 @@ export function ProfileFeedScreenInner({
const {_} = useLingui()
const t = useTheme()
const {hasSession, currentAccount} = useSession()
const {openModal} = useModalControls()
const reportDialogControl = useReportDialogControl()
const {openComposer} = useComposerControls()
const {track} = useAnalytics()
const feedSectionRef = React.useRef<SectionRef>(null)
@ -253,13 +253,8 @@ export function ProfileFeedScreenInner({
}, [feedInfo, track])
const onPressReport = React.useCallback(() => {
if (!feedInfo) return
openModal({
name: 'report',
uri: feedInfo.uri,
cid: feedInfo.cid,
})
}, [openModal, feedInfo])
reportDialogControl.open()
}, [reportDialogControl])
const onCurrentPageSelected = React.useCallback(
(index: number) => {
@ -400,6 +395,14 @@ export function ProfileFeedScreenInner({
return (
<View style={s.hContentRegion}>
<ReportDialog
control={reportDialogControl}
params={{
type: 'feedgen',
uri: feedInfo.uri,
cid: feedInfo.cid,
}}
/>
<PagerWithHeader
items={SECTION_TITLES}
isHeaderReady={true}

View file

@ -39,6 +39,7 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {
useListQuery,
@ -236,6 +237,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {currentAccount} = useSession()
const reportDialogControl = useReportDialogControl()
const {openModal} = useModalControls()
const listMuteMutation = useListMuteMutation()
const listBlockMutation = useListBlockMutation()
@ -370,12 +372,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
])
const onPressReport = useCallback(() => {
openModal({
name: 'report',
uri: list.uri,
cid: list.cid,
})
}, [openModal, list])
reportDialogControl.open()
}, [reportDialogControl])
const onPressShare = useCallback(() => {
const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`)
@ -550,6 +548,14 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
isOwner={list.creator.did === currentAccount?.did}
creator={list.creator}
avatarType="list">
<ReportDialog
control={reportDialogControl}
params={{
type: 'list',
uri: list.uri,
cid: list.cid,
}}
/>
{isCurateList || isPinned ? (
<Button
testID={isPinned ? 'unpinBtn' : 'pinBtn'}

View file

@ -267,6 +267,10 @@ export function SettingsScreen({}: Props) {
navigation.navigate('Debug')
}, [navigation])
const onPressDebugModeration = React.useCallback(() => {
navigation.navigate('DebugMod')
}, [navigation])
const onPressSavedFeeds = React.useCallback(() => {
navigation.navigate('SavedFeeds')
}, [navigation])
@ -826,6 +830,16 @@ export function SettingsScreen({}: Props) {
<Trans>Storybook</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[pal.view, styles.linkCardNoIcon]}
onPress={onPressDebugModeration}
accessibilityRole="button"
accessibilityLabel={_(msg`Open storybook page`)}
accessibilityHint={_(msg`Opens the storybook page`)}>
<Text type="lg" style={pal.text}>
<Trans>Debug Moderation</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[pal.view, styles.linkCardNoIcon]}
onPress={onPressResetPreferences}

View file

@ -129,6 +129,15 @@ export function Buttons() {
<ButtonIcon icon={Globe} position="left" />
<ButtonText>Link out</ButtonText>
</Button>
<Button
variant="gradient"
color="gradient_sky"
size="tiny"
label="Link out">
<ButtonIcon icon={Globe} position="left" />
<ButtonText>Link out</ButtonText>
</Button>
</View>
<View style={[a.flex_row, a.gap_md, a.align_start]}>
@ -148,6 +157,14 @@ export function Buttons() {
label="Link out">
<ButtonIcon icon={ChevronLeft} />
</Button>
<Button
variant="gradient"
color="gradient_sunset"
size="tiny"
shape="round"
label="Link out">
<ButtonIcon icon={ChevronLeft} />
</Button>
<Button
variant="outline"
color="primary"
@ -164,6 +181,14 @@ export function Buttons() {
label="Link out">
<ButtonIcon icon={ChevronLeft} />
</Button>
<Button
variant="ghost"
color="primary"
size="tiny"
shape="round"
label="Link out">
<ButtonIcon icon={ChevronLeft} />
</Button>
</View>
<View style={[a.flex_row, a.gap_md, a.align_start]}>
@ -183,6 +208,14 @@ export function Buttons() {
label="Link out">
<ButtonIcon icon={ChevronLeft} />
</Button>
<Button
variant="gradient"
color="gradient_sunset"
size="tiny"
shape="square"
label="Link out">
<ButtonIcon icon={ChevronLeft} />
</Button>
<Button
variant="outline"
color="primary"
@ -199,6 +232,14 @@ export function Buttons() {
label="Link out">
<ButtonIcon icon={ChevronLeft} />
</Button>
<Button
variant="ghost"
color="primary"
size="tiny"
shape="square"
label="Link out">
<ButtonIcon icon={ChevronLeft} />
</Button>
</View>
</View>
)

View file

@ -67,6 +67,7 @@ export function Storybook() {
</Button>
</View>
<Dialogs />
<ThemeProvider theme="light">
<Theming />
</ThemeProvider>