Create moderation inbox reports list (#11570)

This commit is contained in:
DS Boyce
2026-09-02 10:02:21 -07:00
committed by GitHub
parent 87329397a4
commit e08bcd0228
5 changed files with 367 additions and 19 deletions
@@ -0,0 +1,66 @@
import {Pressable} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
type ReportFilter = 'all' | 'pending' | 'resolved' | 'unread'
export function FilterMenu({
filter,
setFilter,
}: {
filter: ReportFilter
setFilter: React.Dispatch<React.SetStateAction<ReportFilter>>
}) {
const t = useTheme()
const {t: l} = useLingui()
const labels: Record<ReportFilter, string> = {
all: l({context: 'moderation-report-filter', message: 'All'}),
pending: l({
context: 'moderation-report-filter',
message: 'Under review',
}),
resolved: l({context: 'moderation-report-filter', message: 'Resolved'}),
unread: l({context: 'moderation-report-filter', message: 'Unread'}),
}
const options: {value: ReportFilter; label: string}[] = [
{value: 'all', label: labels.all},
{value: 'pending', label: labels.pending},
{value: 'resolved', label: labels.resolved},
{value: 'unread', label: labels.unread},
]
const currentLabel = labels[filter]
return (
<Menu.Root>
<Menu.Trigger
label={l`Filter moderation reports (currently: ${currentLabel})`}>
{({props}) => (
<Pressable
{...props}
style={[a.flex_row, a.align_center, a.justify_center, a.gap_xs]}>
<Text style={[a.text_md, a.font_medium]}>{currentLabel}</Text>
<ChevronDownIcon size="xs" style={[t.atoms.text]} />
</Pressable>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
{options.map(option => (
<Menu.Item
key={option.value}
label={option.label}
onPress={() => setFilter(option.value)}>
<Menu.ItemText>{option.label}</Menu.ItemText>
<Menu.ItemRadio selected={filter === option.value} />
</Menu.Item>
))}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
@@ -0,0 +1,73 @@
import {View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {isBefore, subYears} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Text} from '#/components/Typography'
export function ReportRow({
subject,
action,
date,
unread,
}: {
subject: string
action: string
date: Date
unread?: boolean
}) {
const t = useTheme()
const {i18n} = useLingui()
const shouldShowYear = isBefore(date, subYears(new Date(), 1))
return (
<View
style={[
a.flex_row,
a.w_full,
a.align_center,
a.justify_between,
a.gap_sm,
a.p_lg,
{
backgroundColor: unread ? t.palette.primary_25 : undefined,
},
]}>
<View style={[a.flex_1, a.gap_2xs, {minWidth: 0}]}>
<Text
emoji
style={[a.text_md, unread ? a.font_semi_bold : a.font_medium]}>
{subject}
</Text>
<Text style={[a.text_sm, t.atoms.text_contrast_high]}>{action}</Text>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{i18n.date(date, {
month: 'short',
day: 'numeric',
year: shouldShowYear ? 'numeric' : undefined,
})}
</Text>
</View>
<View
style={[
a.flex_row,
a.flex_shrink_0,
a.align_center,
a.justify_center,
a.gap_sm,
]}>
{unread ? (
<View
style={[
a.rounded_full,
{height: 8, width: 8, backgroundColor: t.palette.primary_500},
]}
/>
) : undefined}
<ChevronRightIcon size="md" style={[t.atoms.text_contrast_medium]} />
</View>
</View>
)
}
+183 -10
View File
@@ -1,11 +1,24 @@
import {Trans} from '@lingui/react/macro'
import {useState} from 'react'
import {View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {NotFoundScreen} from '#/view/screens/NotFound'
import {atoms as a, useTheme} from '#/alf'
import * as Layout from '#/components/Layout'
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {useAnalytics} from '#/analytics'
import {FilterMenu} from './components/FilterMenu'
import {ReportRow} from './components/ReportRow'
type ReportFilter = 'all' | 'pending' | 'resolved' | 'unread'
export function ModerationInboxScreen() {
const {t: l} = useLingui()
const ax = useAnalytics()
const isEnabled = ax.features.enabled(ax.features.ModerationInboxEnable)
if (!isEnabled) {
@@ -14,15 +27,175 @@ export function ModerationInboxScreen() {
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>
<Pager
testID="moderationInboxPager"
renderTabBar={props => (
<Layout.Center>
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content align="left">
<Layout.Header.TitleText>
<Trans>Moderation inbox</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<TabBar
testID="moderationInboxTabs"
items={[l`Your reports`, l`Your account`]}
align="left"
{...props}
/>
</Layout.Center>
)}>
<YourReports />
<YourAccount />
</Pager>
</Layout.Screen>
)
}
function YourReports() {
const t = useTheme()
const {t: l} = useLingui()
const [filter, setFilter] = useState<ReportFilter>('all')
const hasUnread = true // TODO This is hard-coded atm. -dsb
// TODO Placeholders. - dsb
const account = '@deleteme01.bsky.social'
const list = 'The Worst Posters'
return (
<Layout.Center>
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.px_lg,
a.py_sm,
a.border_b,
t.atoms.border_contrast_low,
{minHeight: 48},
]}>
<FilterMenu filter={filter} setFilter={setFilter} />
{hasUnread ? (
<SimpleInlineLinkText
label={l`Mark all reports as read`}
style={[a.text_md, t.atoms.text]}
{...createStaticClick(() => {
// TODO Handle this action. -dsb
})}>
<Trans>Mark all as read</Trans>
</SimpleInlineLinkText>
) : undefined}
</View>
<ReportRow
subject={l({
context: 'moderation-report-subject',
message: `Post by ${account}`,
})}
action={l`Awaiting review`}
date={new Date()}
unread
/>
<ReportRow
subject={l({
context: 'moderation-report-subject',
message: `List “${list}`,
})}
action={l`No action taken`}
date={new Date()}
unread
/>
<ReportRow
subject={l({
context: 'moderation-report-subject',
message: `Direct message from ${account}`,
})}
action={l`Message deleted`}
date={new Date()}
/>
</Layout.Center>
)
}
function YourAccount() {
const t = useTheme()
const {t: l} = useLingui()
const [filter, setFilter] = useState<ReportFilter>('all')
const hasUnread = true // TODO This is hard-coded atm. -dsb
// TODO Placeholders. - dsb
const guideline = l({
context: 'moderation-report-guideline',
message: 'Harassment',
})
const label = l({
context: 'moderation-report-label',
message: 'Graphic media',
})
const duration = plural(72, {
one: '# hour',
other: '# hours',
})
return (
<Layout.Center>
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.px_lg,
a.py_sm,
a.border_b,
t.atoms.border_contrast_low,
{minHeight: 48},
]}>
<FilterMenu filter={filter} setFilter={setFilter} />
{hasUnread ? (
<SimpleInlineLinkText
label={l`Mark all actions as read`}
style={[a.text_md, t.atoms.text]}
{...createStaticClick(() => {
// TODO Handle this action. -dsb
})}>
<Trans>Mark all as read</Trans>
</SimpleInlineLinkText>
) : undefined}
</View>
<ReportRow
subject={l({
context: 'moderation-report-action',
message: `Your post was removed`,
})}
action={l`Violates community guideline: ${guideline}`}
date={new Date()}
unread
/>
<ReportRow
subject={l({
context: 'moderation-report-action',
message: `A label was added to your post`,
})}
action={l`${label}”  shown behind a warning`}
date={new Date()}
/>
<ReportRow
subject={l({
context: 'moderation-report-action',
message: `A label was added to your post`,
})}
action={l`Ban evasion  ${duration}, now expired`}
date={new Date()}
/>
</Layout.Center>
)
}
+34 -7
View File
@@ -25,6 +25,7 @@ export interface TabBarProps {
testID?: string
selectedPage: number
items: string[]
align?: 'center' | 'left'
onSelect?: (index: number) => void
onPressSelected?: (index: number) => void
dragProgress: SharedValue<number>
@@ -42,6 +43,7 @@ export function TabBar({
testID,
selectedPage,
items,
align = 'center',
onSelect,
onPressSelected,
dragProgress,
@@ -77,10 +79,13 @@ export function TabBar({
'worklet'
const layout = layouts.get()[index]
const availableSize = containerSize.get() - 2 * CONTENT_PADDING
const maxOffset = Math.max(0, contentSize.get() - availableSize)
if (!layout) {
// Should not happen, but fall back to equal sizes.
const offsetPerPage = contentSize.get() - availableSize
return (index / (itemsLength - 1)) * offsetPerPage
return itemsLength > 1 ? (index / (itemsLength - 1)) * maxOffset : 0
}
if (maxOffset === 0) {
return 0
}
const freeSpace = availableSize - layout.width
const accumulatingOffset = interpolate(
@@ -91,7 +96,7 @@ export function TabBar({
[0, freeSpace],
'clamp',
)
return layout.x - accumulatingOffset
return Math.min(Math.max(layout.x - accumulatingOffset, 0), maxOffset)
},
[itemsLength, contentSize, containerSize, layouts],
)
@@ -338,7 +343,7 @@ export function TabBar({
onLayout={e => {
contentSize.set(e.nativeEvent.layout.width)
}}
style={{flexDirection: 'row', flexGrow: 1}}>
style={[styles.items, align === 'center' && styles.itemsCentered]}>
{items.map((item, i) => {
return (
<TabBarItem
@@ -350,6 +355,7 @@ export function TabBar({
onPressItem={onPressItem}
onItemLayout={onItemLayout}
onTextLayout={onTextLayout}
align={align}
/>
)
})}
@@ -382,6 +388,7 @@ function TabBarItem({
onPressItem,
onItemLayout,
onTextLayout,
align,
}: {
index: number
testID: string | undefined
@@ -390,6 +397,7 @@ function TabBarItem({
onPressItem: (index: number) => void
onItemLayout: (index: number, layout: {x: number; width: number}) => void
onTextLayout: (index: number, layout: {width: number}) => void
align: 'center' | 'left'
}) {
const t = useTheme()
const style = useAnimatedStyle(() => {
@@ -421,10 +429,15 @@ function TabBarItem({
)
return (
<View onLayout={handleLayout} style={{flexGrow: 1}}>
<View
onLayout={handleLayout}
style={[
styles.itemContainer,
align === 'center' && styles.itemContainerCentered,
]}>
<PressableWithHover
testID={`${testID}-selector-${index}`}
style={styles.item}
style={[styles.item, align === 'center' && styles.itemCentered]}
hoverStyle={t.atoms.bg_contrast_25}
onPress={() => onPressItem(index)}
accessibilityRole="tab">
@@ -448,12 +461,26 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent',
paddingHorizontal: CONTENT_PADDING,
},
item: {
items: {
flexDirection: 'row',
},
itemsCentered: {
flexGrow: 1,
},
itemContainer: {
flexShrink: 0,
},
itemContainerCentered: {
flexGrow: 1,
},
item: {
paddingTop: 10,
paddingHorizontal: ITEM_PADDING,
justifyContent: 'center',
},
itemCentered: {
flexGrow: 1,
},
itemInner: {
alignItems: 'center',
flexGrow: 1,
+11 -2
View File
@@ -11,6 +11,7 @@ export interface TabBarProps {
testID?: string
selectedPage: number
items: string[]
align?: 'center' | 'left'
indicatorColor?: string
backgroundColor?: string
@@ -34,6 +35,7 @@ export function TabBar({
testID,
selectedPage,
items,
align = 'center',
onSelect,
onPressSelected,
}: TabBarProps) {
@@ -87,7 +89,7 @@ export function TabBar({
behavior: 'smooth',
})
}
}, [scrollElRef, selectedPage, styles])
}, [scrollElRef, selectedPage, styles, align])
const onPressItem = useCallback(
(index: number) => {
@@ -119,7 +121,7 @@ export function TabBar({
ref={node => {
itemRefs.current[i] = node as any
}}
style={styles.item}
style={[styles.item, align === 'left' && leftAlignedStyles.item]}
hoverStyle={t.atoms.bg_contrast_25}
onPress={() => onPressItem(i)}
accessibilityRole="tab">
@@ -239,3 +241,10 @@ const mobileStyles = StyleSheet.create({
borderBottomWidth: StyleSheet.hairlineWidth,
},
})
const leftAlignedStyles = StyleSheet.create({
item: {
flexGrow: 0,
flexShrink: 0,
},
})