Create moderation inbox menu item and feature gate (#11552)

This commit is contained in:
DS Boyce
2026-08-25 13:31:13 -07:00
committed by GitHub
parent f968c9e721
commit 5118748cb1
8 changed files with 420 additions and 340 deletions
-19
View File
@@ -60,11 +60,6 @@
"count": 1 "count": 1
} }
}, },
"src/Navigation.tsx": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/Splash.android.tsx": { "src/Splash.android.tsx": {
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 1 "count": 1
@@ -790,20 +785,6 @@
"count": 2 "count": 2
} }
}, },
"src/screens/Moderation/index.tsx": {
"typescript/no-explicit-any": {
"count": 2
},
"typescript/no-floating-promises": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 1
},
"typescript/no-unsafe-member-access": {
"count": 2
}
},
"src/screens/ModerationInteractionSettings/index.tsx": { "src/screens/ModerationInteractionSettings/index.tsx": {
"typescript/no-explicit-any": { "typescript/no-explicit-any": {
"count": 1 "count": 1
+7 -1
View File
@@ -87,6 +87,7 @@ import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings' import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList' import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
@@ -178,6 +179,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => ModerationScreen} getComponent={() => ModerationScreen}
options={{title: title(msg`Moderation`), requireAuth: true}} options={{title: title(msg`Moderation`), requireAuth: true}}
/> />
<Stack.Screen
name="ModerationInbox"
getComponent={() => ModerationInboxScreen}
options={{title: title(msg`Moderation inbox`), requireAuth: true}}
/>
<Stack.Screen <Stack.Screen
name="ModerationModlists" name="ModerationModlists"
getComponent={() => ModerationModlistsScreen} getComponent={() => ModerationModlistsScreen}
@@ -919,7 +925,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
// chat-removed-from-group, chat-join-request-rejected: the convo is // chat-removed-from-group, chat-join-request-rejected: the convo is
// no longer accessible to the recipient, so just open the list. // no longer accessible to the recipient, so just open the list.
// @ts-expect-error nested navigators aren't typed -sfn // @ts-expect-error nested navigators aren't typed -sfn
navigate('MessagesTab', {screen: 'Messages'}) void navigate('MessagesTab', {screen: 'Messages'})
} }
}, },
) )
+1
View File
@@ -25,6 +25,7 @@ export enum Features {
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable', OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable', CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable', ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
ModerationInboxEnable = 'moderation_inbox:enable',
// values // values
TrendingDiscoverValues = 'trending_discover:values', TrendingDiscoverValues = 'trending_discover:values',
+1
View File
@@ -23,6 +23,7 @@ export type CommonNavigatorParams = {
NotFound: undefined NotFound: undefined
Lists: undefined Lists: undefined
Moderation: undefined Moderation: undefined
ModerationInbox: undefined
ModerationModlists: undefined ModerationModlists: undefined
ModerationMutedAccounts: undefined ModerationMutedAccounts: undefined
ModerationBlockedAccounts: undefined ModerationBlockedAccounts: undefined
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@ export const router = new Router<AllNavigatableRoutes>({
Lists: '/lists', Lists: '/lists',
// moderation // moderation
Moderation: '/moderation', Moderation: '/moderation',
ModerationInbox: '/moderation/inbox',
ModerationModlists: '/moderation/modlists', ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts', ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts', ModerationBlockedAccounts: '/moderation/blocked-accounts',
+91 -39
View File
@@ -1,9 +1,7 @@
import {Fragment, useCallback} from 'react' import {Fragment, useCallback} from 'react'
import {Linking, View} from 'react-native' import {Linking, View} from 'react-native'
import {LABELS} from '@bsky/sdk/moderation' import {LABELS} from '@bsky/sdk/moderation'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {getLabelingServiceTitle, isAppLabeler} from '#/lib/moderation' import {getLabelingServiceTitle, isAppLabeler} from '#/lib/moderation'
import { import {
@@ -35,6 +33,7 @@ import {type Props as SVGIconProps} from '#/components/icons/common'
import {EditBig_Stroke2_Corner2_Rounded as EditBig} from '#/components/icons/EditBig' import {EditBig_Stroke2_Corner2_Rounded as EditBig} from '#/components/icons/EditBig'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {Inbox_Stroke2_Corner2_Rounded as Inbox} from '#/components/icons/Inbox'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import * as LabelingService from '#/components/LabelingServiceCard' import * as LabelingService from '#/components/LabelingServiceCard'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
@@ -45,8 +44,11 @@ import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env' import {IS_IOS} from '#/env'
const UNREAD_NOTIFICATION_CAP = 100
function ErrorState({error}: {error: string}) { function ErrorState({error}: {error: string}) {
const t = useTheme() const t = useTheme()
return ( return (
@@ -81,7 +83,7 @@ function ErrorState({error}: {error: string}) {
export function ModerationScreen( export function ModerationScreen(
_props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>, _props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
) { ) {
const {_} = useLingui() const {t: l} = useLingui()
const { const {
isLoading: isPreferencesLoading, isLoading: isPreferencesLoading,
error: preferencesError, error: preferencesError,
@@ -109,7 +111,7 @@ export function ModerationScreen(
<ErrorState <ErrorState
error={ error={
preferencesError?.toString() || preferencesError?.toString() ||
_(msg`Something went wrong, please try again.`) l`Something went wrong, please try again.`
} }
/> />
) : ( ) : (
@@ -123,12 +125,15 @@ export function ModerationScreen(
function SubItem({ function SubItem({
title, title,
icon: Icon, icon: Icon,
badge,
style, style,
}: ViewStyleProp & { }: ViewStyleProp & {
title: string title: string
icon: React.ComponentType<SVGIconProps> icon: React.ComponentType<SVGIconProps>
badge?: number
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l, i18n} = useLingui()
return ( return (
<View <View
style={[ style={[
@@ -144,10 +149,38 @@ function SubItem({
<Icon size="md" style={[t.atoms.text_contrast_medium]} /> <Icon size="md" style={[t.atoms.text_contrast_medium]} />
<Text style={[a.text_sm, a.font_semi_bold]}>{title}</Text> <Text style={[a.text_sm, a.font_semi_bold]}>{title}</Text>
</View> </View>
<ChevronRight <View style={[a.flex_row, a.align_center, a.gap_md]}>
size="sm" {badge ? (
style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]} <View
/> style={[
a.rounded_lg,
a.align_center,
a.justify_center,
a.px_sm,
{
minWidth: 24,
height: 24,
backgroundColor: t.palette.primary_500,
},
]}>
<Text
style={[
a.text_xs,
a.font_semi_bold,
{color: t.palette.white, fontVariant: ['tabular-nums']},
]}>
{badge >= UNREAD_NOTIFICATION_CAP
? l({
message: `${i18n.number(UNREAD_NOTIFICATION_CAP - 1)}+`,
comment:
'Displayed when the number of notifications exceeds the cap – for example, 99+ notifications',
})
: i18n.number(badge)}
</Text>
</View>
) : null}
<ChevronRight size="sm" style={[t.atoms.text_contrast_low]} />
</View>
</View> </View>
) )
} }
@@ -157,10 +190,14 @@ export function ModerationScreenInner({
}: { }: {
preferences: UsePreferencesQueryResponse preferences: UsePreferencesQueryResponse
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const ax = useAnalytics()
const isModerationInboxEnabled = ax.features.enabled(
ax.features.ModerationInboxEnable,
)
const { const {
isLoading: isLabelersLoading, isLoading: isLabelersLoading,
data: labelers, data: labelers,
@@ -184,10 +221,11 @@ export function ModerationScreenInner({
const handleCleanup = async () => { const handleCleanup = async () => {
try { try {
await removeLabelers({dids: unavailableDids}) await removeLabelers({dids: unavailableDids})
Toast.show(_(msg`Removed unavailable services`), { Toast.show(l`Removed unavailable services`, {
type: 'success', type: 'success',
}) })
} catch (e: any) { } catch (error) {
const e = error as Error
logger.error('Failed to remove unavailable labelers', { logger.error('Failed to remove unavailable labelers', {
safeMessage: e.message, safeMessage: e.message,
}) })
@@ -214,7 +252,8 @@ export function ModerationScreenInner({
await setAdultContentPref({ await setAdultContentPref({
enabled: selected, enabled: selected,
}) })
} catch (e: any) { } catch (error) {
const e = error as Error
logger.error(`Failed to set adult content pref`, { logger.error(`Failed to set adult content pref`, {
message: e.message, message: e.message,
}) })
@@ -234,7 +273,7 @@ export function ModerationScreenInner({
your{' '} your{' '}
<InlineLinkText <InlineLinkText
to="/settings/account" to="/settings/account"
label={_(msg`Go to account settings`)}> label={l`Go to account settings`}>
account settings account settings
</InlineLinkText> </InlineLinkText>
. .
@@ -242,7 +281,25 @@ export function ModerationScreenInner({
</Admonition.Admonition> </Admonition.Admonition>
</View> </View>
)} )}
{isModerationInboxEnabled && (
<Link
label={l`View your moderation inbox`}
testID="moderationInboxBtn"
to="/moderation/inbox"
style={[a.mb_2xl, a.rounded_md, a.overflow_hidden]}>
{state => (
<SubItem
title={l`Moderation inbox`}
icon={Inbox}
badge={3} // TODO This is a placeholder value. -dsb
style={[
t.atoms.bg_contrast_25,
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
]}
/>
)}
</Link>
)}
<Text <Text
style={[ style={[
a.text_md, a.text_md,
@@ -252,7 +309,6 @@ export function ModerationScreenInner({
]}> ]}>
<Trans>Moderation tools</Trans> <Trans>Moderation tools</Trans>
</Text> </Text>
<View <View
style={[ style={[
a.w_full, a.w_full,
@@ -261,12 +317,12 @@ export function ModerationScreenInner({
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
]}> ]}>
<Link <Link
label={_(msg`View your default post interaction settings`)} label={l`View your default post interaction settings`}
testID="interactionSettingsBtn" testID="interactionSettingsBtn"
to="/moderation/interaction-settings"> to="/moderation/interaction-settings">
{state => ( {state => (
<SubItem <SubItem
title={_(msg`Interaction settings`)} title={l`Interaction settings`}
icon={EditBig} icon={EditBig}
style={[ style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50], (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -277,11 +333,11 @@ export function ModerationScreenInner({
<Divider /> <Divider />
<Button <Button
testID="mutedWordsBtn" testID="mutedWordsBtn"
label={_(msg`Open muted words and tags settings`)} label={l`Open muted words and tags settings`}
onPress={() => mutedWordsDialogControl.open()}> onPress={() => mutedWordsDialogControl.open()}>
{state => ( {state => (
<SubItem <SubItem
title={_(msg`Muted words & tags`)} title={l`Muted words & tags`}
icon={Filter} icon={Filter}
style={[ style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50], (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -291,12 +347,12 @@ export function ModerationScreenInner({
</Button> </Button>
<Divider /> <Divider />
<Link <Link
label={_(msg`View your moderation lists`)} label={l`View your moderation lists`}
testID="moderationlistsBtn" testID="moderationlistsBtn"
to="/moderation/modlists"> to="/moderation/modlists">
{state => ( {state => (
<SubItem <SubItem
title={_(msg`Moderation lists`)} title={l`Moderation lists`}
icon={Group} icon={Group}
style={[ style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50], (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -306,12 +362,12 @@ export function ModerationScreenInner({
</Link> </Link>
<Divider /> <Divider />
<Link <Link
label={_(msg`View your muted accounts`)} label={l`View your muted accounts`}
testID="mutedAccountsBtn" testID="mutedAccountsBtn"
to="/moderation/muted-accounts"> to="/moderation/muted-accounts">
{state => ( {state => (
<SubItem <SubItem
title={_(msg`Muted accounts`)} title={l`Muted accounts`}
icon={Person} icon={Person}
style={[ style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50], (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -321,12 +377,12 @@ export function ModerationScreenInner({
</Link> </Link>
<Divider /> <Divider />
<Link <Link
label={_(msg`View your blocked accounts`)} label={l`View your blocked accounts`}
testID="blockedAccountsBtn" testID="blockedAccountsBtn"
to="/moderation/blocked-accounts"> to="/moderation/blocked-accounts">
{state => ( {state => (
<SubItem <SubItem
title={_(msg`Blocked accounts`)} title={l`Blocked accounts`}
icon={CircleBanSign} icon={CircleBanSign}
style={[ style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50], (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -336,12 +392,12 @@ export function ModerationScreenInner({
</Link> </Link>
<Divider /> <Divider />
<Link <Link
label={_(msg`Manage verification settings`)} label={l`Manage verification settings`}
testID="verificationSettingsBtn" testID="verificationSettingsBtn"
to="/moderation/verification-settings"> to="/moderation/verification-settings">
{state => ( {state => (
<SubItem <SubItem
title={_(msg`Verification settings`)} title={l`Verification settings`}
icon={CircleCheck} icon={CircleCheck}
style={[ style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50], (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -350,7 +406,6 @@ export function ModerationScreenInner({
)} )}
</Link> </Link>
</View> </View>
<Text <Text
style={[ style={[
a.pt_2xl, a.pt_2xl,
@@ -361,11 +416,9 @@ export function ModerationScreenInner({
]}> ]}>
<Trans>Content filters</Trans> <Trans>Content filters</Trans>
</Text> </Text>
<AgeAssuranceAdmonition style={[a.pb_md]}> <AgeAssuranceAdmonition style={[a.pb_md]}>
{aaCopy.notice} {aaCopy.notice}
</AgeAssuranceAdmonition> </AgeAssuranceAdmonition>
<View style={[a.gap_md]}> <View style={[a.gap_md]}>
<View <View
style={[ style={[
@@ -389,11 +442,13 @@ export function ModerationScreenInner({
<Trans>Enable adult content</Trans> <Trans>Enable adult content</Trans>
</Text> </Text>
<Toggle.Item <Toggle.Item
label={_(msg`Toggle to enable or disable adult content`)} label={l`Toggle to enable or disable adult content`}
disabled={adultContentUIDisabled} disabled={adultContentUIDisabled}
name="adultContent" name="adultContent"
value={adultContentEnabled} value={adultContentEnabled}
onChange={onToggleAdultContentEnabled}> onChange={(selected: boolean) =>
void onToggleAdultContentEnabled(selected)
}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}> <View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text style={[t.atoms.text_contrast_medium]}> <Text style={[t.atoms.text_contrast_medium]}>
{adultContentEnabled ? ( {adultContentEnabled ? (
@@ -412,11 +467,11 @@ export function ModerationScreenInner({
<Trans> <Trans>
Adult content can only be enabled via the Web at{' '} Adult content can only be enabled via the Web at{' '}
<InlineLinkText <InlineLinkText
label={_(msg`The Bluesky web application`)} label={l`The Bluesky web application`}
to="" to=""
onPress={evt => { onPress={evt => {
evt.preventDefault() evt.preventDefault()
Linking.openURL('https://bsky.app/') void Linking.openURL('https://bsky.app/')
return false return false
}}> }}>
bsky.app bsky.app
@@ -445,7 +500,6 @@ export function ModerationScreenInner({
)} )}
</View> </View>
</View> </View>
<Text <Text
style={[ style={[
a.text_md, a.text_md,
@@ -456,7 +510,6 @@ export function ModerationScreenInner({
]}> ]}>
<Trans>Advanced</Trans> <Trans>Advanced</Trans>
</Text> </Text>
{unavailableDids.length > 0 && ( {unavailableDids.length > 0 && (
<Admonition.Outer type="tip" style={[a.mb_md]}> <Admonition.Outer type="tip" style={[a.mb_md]}>
<Admonition.Row> <Admonition.Row>
@@ -470,7 +523,7 @@ export function ModerationScreenInner({
</Admonition.Content> </Admonition.Content>
<Admonition.Button <Admonition.Button
color="primary_subtle" color="primary_subtle"
label={_(msg`Remove unavailable moderation services`)} label={l`Remove unavailable moderation services`}
onPress={handleCleanup} onPress={handleCleanup}
disabled={isRemovingLabelers}> disabled={isRemovingLabelers}>
<ButtonText> <ButtonText>
@@ -481,7 +534,6 @@ export function ModerationScreenInner({
</Admonition.Row> </Admonition.Row>
</Admonition.Outer> </Admonition.Outer>
)} )}
{isLabelersLoading ? ( {isLabelersLoading ? (
<View style={[a.w_full, a.align_center, a.p_lg]}> <View style={[a.w_full, a.align_center, a.p_lg]}>
<Loader size="xl" /> <Loader size="xl" />
+28
View File
@@ -0,0 +1,28 @@
import {Trans} from '@lingui/react/macro'
import {NotFoundScreen} from '#/view/screens/NotFound'
import * as Layout from '#/components/Layout'
import {useAnalytics} from '#/analytics'
export function ModerationInboxScreen() {
const ax = useAnalytics()
const isEnabled = ax.features.enabled(ax.features.ModerationInboxEnable)
if (!isEnabled) {
return <NotFoundScreen />
}
return (
<Layout.Screen testID="moderationInboxScreen">
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Moderation inbox</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
</Layout.Screen>
)
}