From e08bcd0228b89446731c89978da237cb7e30a7e2 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:02:21 -0700 Subject: [PATCH] Create moderation inbox reports list (#11570) --- .../ModerationInbox/components/FilterMenu.tsx | 66 ++++++ .../ModerationInbox/components/ReportRow.tsx | 73 +++++++ src/screens/ModerationInbox/index.tsx | 193 +++++++++++++++++- src/view/com/pager/TabBar.tsx | 41 +++- src/view/com/pager/TabBar.web.tsx | 13 +- 5 files changed, 367 insertions(+), 19 deletions(-) create mode 100644 src/screens/ModerationInbox/components/FilterMenu.tsx create mode 100644 src/screens/ModerationInbox/components/ReportRow.tsx diff --git a/src/screens/ModerationInbox/components/FilterMenu.tsx b/src/screens/ModerationInbox/components/FilterMenu.tsx new file mode 100644 index 0000000000..d94237494d --- /dev/null +++ b/src/screens/ModerationInbox/components/FilterMenu.tsx @@ -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> +}) { + const t = useTheme() + const {t: l} = useLingui() + + const labels: Record = { + 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 ( + + + {({props}) => ( + + {currentLabel} + + + )} + + + + {options.map(option => ( + setFilter(option.value)}> + {option.label} + + + ))} + + + + ) +} diff --git a/src/screens/ModerationInbox/components/ReportRow.tsx b/src/screens/ModerationInbox/components/ReportRow.tsx new file mode 100644 index 0000000000..8adf8b75f5 --- /dev/null +++ b/src/screens/ModerationInbox/components/ReportRow.tsx @@ -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 ( + + + + {subject} + + {action} + + {i18n.date(date, { + month: 'short', + day: 'numeric', + year: shouldShowYear ? 'numeric' : undefined, + })} + + + + {unread ? ( + + ) : undefined} + + + + ) +} diff --git a/src/screens/ModerationInbox/index.tsx b/src/screens/ModerationInbox/index.tsx index a8e1fcfa5c..dca78b206a 100644 --- a/src/screens/ModerationInbox/index.tsx +++ b/src/screens/ModerationInbox/index.tsx @@ -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 ( - - - - - Moderation inbox - - - - + ( + + + + + + Moderation inbox + + + + + + + )}> + + + ) } + +function YourReports() { + const t = useTheme() + const {t: l} = useLingui() + + const [filter, setFilter] = useState('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 ( + + + + {hasUnread ? ( + { + // TODO Handle this action. -dsb + })}> + Mark all as read + + ) : undefined} + + + + + + ) +} + +function YourAccount() { + const t = useTheme() + const {t: l} = useLingui() + + const [filter, setFilter] = useState('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 ( + + + + {hasUnread ? ( + { + // TODO Handle this action. -dsb + })}> + Mark all as read + + ) : undefined} + + + + + + ) +} diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 595c235f1d..4407de691c 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -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 @@ -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 ( ) })} @@ -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 ( - + 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, diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx index dbe76cb89f..3d3e94429c 100644 --- a/src/view/com/pager/TabBar.web.tsx +++ b/src/view/com/pager/TabBar.web.tsx @@ -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, + }, +})