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
}
},
"src/Navigation.tsx": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/Splash.android.tsx": {
"typescript/no-floating-promises": {
"count": 1
@@ -790,20 +785,6 @@
"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": {
"typescript/no-explicit-any": {
"count": 1
+7 -1
View File
@@ -87,6 +87,7 @@ import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
@@ -178,6 +179,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => ModerationScreen}
options={{title: title(msg`Moderation`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationInbox"
getComponent={() => ModerationInboxScreen}
options={{title: title(msg`Moderation inbox`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
@@ -919,7 +925,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
// chat-removed-from-group, chat-join-request-rejected: the convo is
// no longer accessible to the recipient, so just open the list.
// @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',
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
ModerationInboxEnable = 'moderation_inbox:enable',
// values
TrendingDiscoverValues = 'trending_discover:values',
+1
View File
@@ -23,6 +23,7 @@ export type CommonNavigatorParams = {
NotFound: undefined
Lists: undefined
Moderation: undefined
ModerationInbox: undefined
ModerationModlists: undefined
ModerationMutedAccounts: 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',
// moderation
Moderation: '/moderation',
ModerationInbox: '/moderation/inbox',
ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts',
+91 -39
View File
@@ -1,9 +1,7 @@
import {Fragment, useCallback} from 'react'
import {Linking, View} from 'react-native'
import {LABELS} from '@bsky/sdk/moderation'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {getLabelingServiceTitle, isAppLabeler} from '#/lib/moderation'
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 {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
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 * as LabelingService from '#/components/LabelingServiceCard'
import * as Layout from '#/components/Layout'
@@ -45,8 +44,11 @@ import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
const UNREAD_NOTIFICATION_CAP = 100
function ErrorState({error}: {error: string}) {
const t = useTheme()
return (
@@ -81,7 +83,7 @@ function ErrorState({error}: {error: string}) {
export function ModerationScreen(
_props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
) {
const {_} = useLingui()
const {t: l} = useLingui()
const {
isLoading: isPreferencesLoading,
error: preferencesError,
@@ -109,7 +111,7 @@ export function ModerationScreen(
<ErrorState
error={
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({
title,
icon: Icon,
badge,
style,
}: ViewStyleProp & {
title: string
icon: React.ComponentType<SVGIconProps>
badge?: number
}) {
const t = useTheme()
const {t: l, i18n} = useLingui()
return (
<View
style={[
@@ -144,10 +149,38 @@ function SubItem({
<Icon size="md" style={[t.atoms.text_contrast_medium]} />
<Text style={[a.text_sm, a.font_semi_bold]}>{title}</Text>
</View>
<ChevronRight
size="sm"
style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]}
/>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
{badge ? (
<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>
)
}
@@ -157,10 +190,14 @@ export function ModerationScreenInner({
}: {
preferences: UsePreferencesQueryResponse
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const ax = useAnalytics()
const isModerationInboxEnabled = ax.features.enabled(
ax.features.ModerationInboxEnable,
)
const {
isLoading: isLabelersLoading,
data: labelers,
@@ -184,10 +221,11 @@ export function ModerationScreenInner({
const handleCleanup = async () => {
try {
await removeLabelers({dids: unavailableDids})
Toast.show(_(msg`Removed unavailable services`), {
Toast.show(l`Removed unavailable services`, {
type: 'success',
})
} catch (e: any) {
} catch (error) {
const e = error as Error
logger.error('Failed to remove unavailable labelers', {
safeMessage: e.message,
})
@@ -214,7 +252,8 @@ export function ModerationScreenInner({
await setAdultContentPref({
enabled: selected,
})
} catch (e: any) {
} catch (error) {
const e = error as Error
logger.error(`Failed to set adult content pref`, {
message: e.message,
})
@@ -234,7 +273,7 @@ export function ModerationScreenInner({
your{' '}
<InlineLinkText
to="/settings/account"
label={_(msg`Go to account settings`)}>
label={l`Go to account settings`}>
account settings
</InlineLinkText>
.
@@ -242,7 +281,25 @@ export function ModerationScreenInner({
</Admonition.Admonition>
</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
style={[
a.text_md,
@@ -252,7 +309,6 @@ export function ModerationScreenInner({
]}>
<Trans>Moderation tools</Trans>
</Text>
<View
style={[
a.w_full,
@@ -261,12 +317,12 @@ export function ModerationScreenInner({
t.atoms.bg_contrast_25,
]}>
<Link
label={_(msg`View your default post interaction settings`)}
label={l`View your default post interaction settings`}
testID="interactionSettingsBtn"
to="/moderation/interaction-settings">
{state => (
<SubItem
title={_(msg`Interaction settings`)}
title={l`Interaction settings`}
icon={EditBig}
style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -277,11 +333,11 @@ export function ModerationScreenInner({
<Divider />
<Button
testID="mutedWordsBtn"
label={_(msg`Open muted words and tags settings`)}
label={l`Open muted words and tags settings`}
onPress={() => mutedWordsDialogControl.open()}>
{state => (
<SubItem
title={_(msg`Muted words & tags`)}
title={l`Muted words & tags`}
icon={Filter}
style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -291,12 +347,12 @@ export function ModerationScreenInner({
</Button>
<Divider />
<Link
label={_(msg`View your moderation lists`)}
label={l`View your moderation lists`}
testID="moderationlistsBtn"
to="/moderation/modlists">
{state => (
<SubItem
title={_(msg`Moderation lists`)}
title={l`Moderation lists`}
icon={Group}
style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -306,12 +362,12 @@ export function ModerationScreenInner({
</Link>
<Divider />
<Link
label={_(msg`View your muted accounts`)}
label={l`View your muted accounts`}
testID="mutedAccountsBtn"
to="/moderation/muted-accounts">
{state => (
<SubItem
title={_(msg`Muted accounts`)}
title={l`Muted accounts`}
icon={Person}
style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -321,12 +377,12 @@ export function ModerationScreenInner({
</Link>
<Divider />
<Link
label={_(msg`View your blocked accounts`)}
label={l`View your blocked accounts`}
testID="blockedAccountsBtn"
to="/moderation/blocked-accounts">
{state => (
<SubItem
title={_(msg`Blocked accounts`)}
title={l`Blocked accounts`}
icon={CircleBanSign}
style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -336,12 +392,12 @@ export function ModerationScreenInner({
</Link>
<Divider />
<Link
label={_(msg`Manage verification settings`)}
label={l`Manage verification settings`}
testID="verificationSettingsBtn"
to="/moderation/verification-settings">
{state => (
<SubItem
title={_(msg`Verification settings`)}
title={l`Verification settings`}
icon={CircleCheck}
style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
@@ -350,7 +406,6 @@ export function ModerationScreenInner({
)}
</Link>
</View>
<Text
style={[
a.pt_2xl,
@@ -361,11 +416,9 @@ export function ModerationScreenInner({
]}>
<Trans>Content filters</Trans>
</Text>
<AgeAssuranceAdmonition style={[a.pb_md]}>
{aaCopy.notice}
</AgeAssuranceAdmonition>
<View style={[a.gap_md]}>
<View
style={[
@@ -389,11 +442,13 @@ export function ModerationScreenInner({
<Trans>Enable adult content</Trans>
</Text>
<Toggle.Item
label={_(msg`Toggle to enable or disable adult content`)}
label={l`Toggle to enable or disable adult content`}
disabled={adultContentUIDisabled}
name="adultContent"
value={adultContentEnabled}
onChange={onToggleAdultContentEnabled}>
onChange={(selected: boolean) =>
void onToggleAdultContentEnabled(selected)
}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text style={[t.atoms.text_contrast_medium]}>
{adultContentEnabled ? (
@@ -412,11 +467,11 @@ export function ModerationScreenInner({
<Trans>
Adult content can only be enabled via the Web at{' '}
<InlineLinkText
label={_(msg`The Bluesky web application`)}
label={l`The Bluesky web application`}
to=""
onPress={evt => {
evt.preventDefault()
Linking.openURL('https://bsky.app/')
void Linking.openURL('https://bsky.app/')
return false
}}>
bsky.app
@@ -445,7 +500,6 @@ export function ModerationScreenInner({
)}
</View>
</View>
<Text
style={[
a.text_md,
@@ -456,7 +510,6 @@ export function ModerationScreenInner({
]}>
<Trans>Advanced</Trans>
</Text>
{unavailableDids.length > 0 && (
<Admonition.Outer type="tip" style={[a.mb_md]}>
<Admonition.Row>
@@ -470,7 +523,7 @@ export function ModerationScreenInner({
</Admonition.Content>
<Admonition.Button
color="primary_subtle"
label={_(msg`Remove unavailable moderation services`)}
label={l`Remove unavailable moderation services`}
onPress={handleCleanup}
disabled={isRemovingLabelers}>
<ButtonText>
@@ -481,7 +534,6 @@ export function ModerationScreenInner({
</Admonition.Row>
</Admonition.Outer>
)}
{isLabelersLoading ? (
<View style={[a.w_full, a.align_center, a.p_lg]}>
<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>
)
}