diff options
Diffstat (limited to 'src/components')
46 files changed, 2740 insertions, 48 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx index d3bf73cc3..0e22944a3 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -15,6 +15,7 @@ import LinearGradient from 'react-native-linear-gradient' import {useTheme, atoms as a, tokens, android, flatten} from '#/alf' import {Props as SVGIconProps} from '#/components/icons/common' +import {normalizeTextStyles} from '#/components/Typography' export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' export type ButtonColor = @@ -139,7 +140,7 @@ export function Button({ })) }, [setState]) - const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => { + const {baseStyles, hoverStyles} = React.useMemo(() => { const baseStyles: ViewStyle[] = [] const hoverStyles: ViewStyle[] = [] const light = t.name === 'light' @@ -191,14 +192,14 @@ export function Button({ if (variant === 'solid') { if (!disabled) { baseStyles.push({ - backgroundColor: t.palette.contrast_50, + backgroundColor: t.palette.contrast_25, }) hoverStyles.push({ - backgroundColor: t.palette.contrast_100, + backgroundColor: t.palette.contrast_50, }) } else { baseStyles.push({ - backgroundColor: t.palette.contrast_200, + backgroundColor: t.palette.contrast_100, }) } } else if (variant === 'outline') { @@ -308,12 +309,6 @@ export function Button({ return { baseStyles, hoverStyles, - focusStyles: [ - ...hoverStyles, - { - outline: 0, - } as ViewStyle, - ], } }, [t, variant, color, size, shape, disabled]) @@ -376,10 +371,8 @@ export function Button({ a.flex_row, a.align_center, a.justify_center, - a.justify_center, flattenedBaseStyles, ...(state.hovered || state.pressed ? hoverStyles : []), - ...(state.focused ? focusStyles : []), flatten(style), ]} onPressIn={onPressIn} @@ -398,7 +391,7 @@ export function Button({ ]}> <LinearGradient colors={ - state.hovered || state.pressed || state.focused + state.hovered || state.pressed ? gradientHoverColors : gradientColors } @@ -527,7 +520,14 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) { const textStyles = useSharedButtonTextStyles() return ( - <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}> + <Text + {...rest} + style={normalizeTextStyles([ + a.font_bold, + a.text_center, + textStyles, + style, + ])}> {children} </Text> ) diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 5f6edeac7..0da2919c5 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -23,6 +23,7 @@ import { DialogInnerProps, } from '#/components/Dialog/types' import {Context} from '#/components/Dialog/context' +import {isNative} from 'platform/detection' export {useDialogControl, useDialogContext} from '#/components/Dialog/context' export * from '#/components/Dialog/types' @@ -221,7 +222,8 @@ export function ScrollableInner({children, style}: DialogInnerProps) { borderTopRightRadius: 40, }, flatten(style), - ]}> + ]} + contentContainerStyle={isNative ? a.pb_4xl : undefined}> {children} <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> </BottomSheetScrollView> diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 3a7f73342..038f6295a 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -99,7 +99,7 @@ export function Outer({ style={[ web(a.fixed), a.inset_0, - {opacity: 0.5, backgroundColor: t.palette.black}, + {opacity: 0.8, backgroundColor: t.palette.black}, ]} /> )} diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx new file mode 100644 index 000000000..dc14aa72b --- /dev/null +++ b/src/components/GradientFill.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import LinearGradient from 'react-native-linear-gradient' + +import {atoms as a, tokens} from '#/alf' + +export function GradientFill({ + gradient, +}: { + gradient: + | typeof tokens.gradients.sky + | typeof tokens.gradients.midnight + | typeof tokens.gradients.sunrise + | typeof tokens.gradients.sunset + | typeof tokens.gradients.bonfire + | typeof tokens.gradients.summer + | typeof tokens.gradients.nordic +}) { + return ( + <LinearGradient + colors={gradient.values.map(c => c[1])} + locations={gradient.values.map(c => c[0])} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[a.absolute, a.inset_0]} + /> + ) +} diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx new file mode 100644 index 000000000..6d0613511 --- /dev/null +++ b/src/components/LabelingServiceCard/index.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyLabelerDefs} from '@atproto/api' + +import {getLabelingServiceTitle} from '#/lib/moderation' +import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Text} from '#/components/Typography' +import {useLabelerInfoQuery} from '#/state/queries/labeler' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {RichText} from '#/components/RichText' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {sanitizeHandle} from '#/lib/strings/handles' +import {pluralize} from '#/lib/strings/helpers' + +type LabelingServiceProps = { + labeler: AppBskyLabelerDefs.LabelerViewDetailed +} + +export function Outer({ + children, + style, +}: React.PropsWithChildren<ViewStyleProp>) { + return ( + <View + style={[ + a.flex_row, + a.gap_md, + a.w_full, + a.p_lg, + a.pr_md, + a.overflow_hidden, + style, + ]}> + {children} + </View> + ) +} + +export function Avatar({avatar}: {avatar?: string}) { + return <UserAvatar type="labeler" size={40} avatar={avatar} /> +} + +export function Title({value}: {value: string}) { + return <Text style={[a.text_md, a.font_bold]}>{value}</Text> +} + +export function Description({value, handle}: {value?: string; handle: string}) { + return value ? ( + <Text numberOfLines={2}> + <RichText value={value} style={[]} /> + </Text> + ) : ( + <Text> + <Trans>By {sanitizeHandle(handle, '@')}</Trans> + </Text> + ) +} + +export function LikeCount({count}: {count: number}) { + const t = useTheme() + return ( + <Text + style={[ + a.mt_sm, + a.text_sm, + t.atoms.text_contrast_medium, + {fontWeight: '500'}, + ]}> + <Trans> + Liked by {count} {pluralize(count, 'user')} + </Trans> + </Text> + ) +} + +export function Content({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + + return ( + <View + style={[ + a.flex_1, + a.flex_row, + a.gap_md, + a.align_center, + a.justify_between, + ]}> + <View style={[a.gap_xs, a.flex_1]}>{children}</View> + + <ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} /> + </View> + ) +} + +/** + * The canonical view for a labeling service. Use this or compose your own. + */ +export function Default({ + labeler, + style, +}: LabelingServiceProps & ViewStyleProp) { + return ( + <Outer style={style}> + <Avatar /> + <Content> + <Title + value={getLabelingServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + })} + /> + <Description + value={labeler.creator.description} + handle={labeler.creator.handle} + /> + {labeler.likeCount ? <LikeCount count={labeler.likeCount} /> : null} + </Content> + </Outer> + ) +} + +export function Link({ + children, + labeler, +}: LabelingServiceProps & Pick<LinkProps, 'children'>) { + const {_} = useLingui() + + return ( + <InternalLink + to={{ + screen: 'Profile', + params: { + name: labeler.creator.handle, + }, + }} + label={_( + msg`View the labeling service provided by @${labeler.creator.handle}`, + )}> + {children} + </InternalLink> + ) +} + +// TODO not finished yet +export function DefaultSkeleton() { + return ( + <View> + <Text>Loading</Text> + </View> + ) +} + +export function Loader({ + did, + loading: LoadingComponent = DefaultSkeleton, + error: ErrorComponent, + component: Component, +}: { + did: string + loading?: React.ComponentType<{}> + error?: React.ComponentType<{error: string}> + component: React.ComponentType<{ + labeler: AppBskyLabelerDefs.LabelerViewDetailed + }> +}) { + const {isLoading, data, error} = useLabelerInfoQuery({did}) + + return isLoading ? ( + LoadingComponent ? ( + <LoadingComponent /> + ) : null + ) : error || !data ? ( + ErrorComponent ? ( + <ErrorComponent error={error?.message || 'Unknown error'} /> + ) : null + ) : ( + <Component labeler={data} /> + ) +} diff --git a/src/components/LikedByList.tsx b/src/components/LikedByList.tsx new file mode 100644 index 000000000..bd1213639 --- /dev/null +++ b/src/components/LikedByList.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {logger} from '#/logger' +import {List} from '#/view/com/util/List' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {ListFooter} from '#/components/Lists' + +import {atoms as a, useTheme} from '#/alf' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function LikedByList({uri}: {uri: string}) { + const t = useTheme() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data: resolvedUri, + error: resolveError, + isFetching: isFetchingResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching, + isFetched, + isRefetching, + hasNextPage, + fetchNextPage, + isError, + error: likedByError, + refetch, + } = useLikedByQuery(resolvedUri?.uri) + const likes = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.likes) + } + return [] + }, [data]) + const initialNumToRender = useInitialNumToRender() + const error = resolveError || likedByError + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh likes', {message: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more likes', {message: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => { + return ( + <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> + ) + }, []) + + if (isFetchingResolvedUri || !isFetched) { + return ( + <View style={[a.w_full, a.align_center, a.p_lg]}> + <Loader size="xl" /> + </View> + ) + } + + return likes.length ? ( + <List + data={likes} + keyExtractor={item => item.actor.did} + refreshing={isPTRing} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={3} + renderItem={renderItem} + initialNumToRender={initialNumToRender} + ListFooterComponent={() => ( + <ListFooter + isFetching={isFetching && !isRefetching} + isError={isError} + error={error ? error.toString() : undefined} + onRetry={fetchNextPage} + /> + )} + /> + ) : ( + <View style={[a.p_lg]}> + <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + Nobody has liked this yet. Maybe you should be the first! + </Trans> + </Text> + </View> + </View> + ) +} diff --git a/src/components/LikesDialog.tsx b/src/components/LikesDialog.tsx new file mode 100644 index 000000000..94a3f27e2 --- /dev/null +++ b/src/components/LikesDialog.tsx @@ -0,0 +1,131 @@ +import React, {useMemo, useCallback} from 'react' +import {ActivityIndicator, FlatList, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' + +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {Loader} from '#/components/Loader' + +interface LikesDialogProps { + control: Dialog.DialogOuterProps['control'] + uri: string +} + +export function LikesDialog(props: LikesDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + + <LikesDialogInner {...props} /> + </Dialog.Outer> + ) +} + +export function LikesDialogInner({control, uri}: LikesDialogProps) { + const {_} = useLingui() + const t = useTheme() + + const { + data: resolvedUri, + error: resolveError, + isFetched: hasFetchedResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching: isFetchingLikedBy, + isFetched: hasFetchedLikedBy, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error: likedByError, + } = useLikedByQuery(resolvedUri?.uri) + + const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy + const likes = useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.likes) + } + return [] + }, [data]) + + const onEndReached = useCallback(async () => { + if (isFetchingLikedBy || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more likes', {message: err}) + } + }, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage]) + + const renderItem = useCallback( + ({item}: {item: GetLikes.Like}) => { + return ( + <ProfileCardWithFollowBtn + key={item.actor.did} + profile={item.actor} + onPress={() => control.close()} + /> + ) + }, + [control], + ) + + return ( + <Dialog.Inner label={_(msg`Users that have liked this content or profile`)}> + <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}> + <Trans>Liked by</Trans> + </Text> + + {isLoading ? ( + <View style={{minHeight: 300}}> + <Loader size="xl" /> + </View> + ) : resolveError || likedByError || !data ? ( + <ErrorMessage message={cleanError(resolveError || likedByError)} /> + ) : likes.length === 0 ? ( + <View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}> + <Text style={[a.text_center]}> + <Trans> + Nobody has liked this yet. Maybe you should be the first! + </Trans> + </Text> + </View> + ) : ( + <FlatList + data={likes} + keyExtractor={item => item.actor.did} + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + ListFooterComponent={ + <ListFooterComponent isFetching={isFetchingNextPage} /> + } + /> + )} + + <Dialog.Close /> + </Dialog.Inner> + ) +} + +function ListFooterComponent({isFetching}: {isFetching: boolean}) { + if (isFetching) { + return ( + <View style={a.pt_lg}> + <ActivityIndicator /> + </View> + ) + } + return null +} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 00e6a56f4..7d0e83332 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -251,7 +251,7 @@ export function InlineLink({ onIn: onPressIn, onOut: onPressOut, } = useInteractionState() - const flattenedStyle = flatten(style) + const flattenedStyle = flatten(style) || {} return ( <Text diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index bb0d24797..8a889c15e 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -33,7 +33,7 @@ export function ListFooter({ a.border_t, a.pb_lg, t.atoms.border_contrast_low, - {height: 100}, + {height: 180}, ]}> {isFetching ? ( <Loader size="xl" /> diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index f4b03f680..60b234203 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -223,7 +223,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) { style={flatten([ a.flex_row, a.align_center, - a.gap_sm, + a.gap_lg, a.py_sm, a.rounded_xs, {minHeight: 32, paddingHorizontal: 10}, diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 1b9348d9c..b81b20707 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -3,7 +3,6 @@ import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {isNative} from '#/platform/detection' import {useTheme, atoms as a, useBreakpoints} from '#/alf' import {Text} from '#/components/Typography' import {Button, ButtonColor, ButtonText} from '#/components/Button' @@ -86,7 +85,6 @@ export function Actions({children}: React.PropsWithChildren<{}>) { gtMobile ? [a.flex_row, a.flex_row_reverse, a.justify_start] : [a.flex_col], - isNative && [a.pb_4xl], ]}> {children} </View> diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx new file mode 100644 index 000000000..300114fc4 --- /dev/null +++ b/src/components/ReportDialog/SelectLabelerView.tsx @@ -0,0 +1,115 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyLabelerDefs} from '@atproto/api' + +export {useDialogControl as useReportDialogControl} from '#/components/Dialog' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {Button, useButtonContext} from '#/components/Button' +import {Divider} from '#/components/Divider' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' + +import {ReportDialogProps} from './types' + +export function SelectLabelerView({ + ...props +}: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + onSelectLabeler: (v: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <View style={[a.gap_lg]}> + <View style={[a.justify_center, a.gap_sm]}> + <Text style={[a.text_2xl, a.font_bold]}> + <Trans>Select moderation service</Trans> + </Text> + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + <Trans>Who do you want to send this report to?</Trans> + </Text> + </View> + + <Divider /> + + <View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}> + {props.labelers.map(labeler => { + return ( + <Button + key={labeler.creator.did} + label={_(msg`Send report to ${labeler.creator.displayName}`)} + onPress={() => props.onSelectLabeler(labeler.creator.did)}> + <LabelerButton + title={labeler.creator.displayName || labeler.creator.handle} + description={labeler.creator.description || ''} + /> + </Button> + ) + })} + </View> + </View> + ) +} + +function LabelerButton({ + title, + description, +}: { + title: string + description: string +}) { + const t = useTheme() + const {hovered, pressed} = useButtonContext() + const interacted = hovered || pressed + + const styles = React.useMemo(() => { + return { + interacted: { + backgroundColor: t.palette.contrast_50, + }, + } + }, [t]) + + return ( + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_between, + a.p_md, + a.rounded_md, + {paddingRight: 70}, + interacted && styles.interacted, + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> + {title} + </Text> + <Text style={[a.leading_tight, {maxWidth: 400}]} numberOfLines={3}> + {description} + </Text> + </View> + + <View + style={[ + a.absolute, + a.inset_0, + a.justify_center, + a.pr_md, + {left: 'auto'}, + ]}> + <ChevronRight + size="md" + fill={ + hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color + } + /> + </View> + </View> + ) +} diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx new file mode 100644 index 000000000..037a62fce --- /dev/null +++ b/src/components/ReportDialog/SelectReportOptionView.tsx @@ -0,0 +1,199 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyLabelerDefs} from '@atproto/api' + +import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions' +import {DMCA_LINK} from '#/components/ReportDialog/const' +import {Link} from '#/components/Link' +export {useDialogControl as useReportDialogControl} from '#/components/Dialog' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import { + Button, + ButtonIcon, + ButtonText, + useButtonContext, +} from '#/components/Button' +import {Divider} from '#/components/Divider' +import { + ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, + ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, +} from '#/components/icons/Chevron' +import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' + +import {ReportDialogProps} from './types' + +export function SelectReportOptionView({ + ...props +}: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + onSelectReportOption: (reportOption: ReportOption) => void + goBack: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const allReportOptions = useReportOptions() + const reportOptions = allReportOptions[props.params.type] + + const i18n = React.useMemo(() => { + let title = _(msg`Report this content`) + let description = _(msg`Why should this content be reviewed?`) + + if (props.params.type === 'account') { + title = _(msg`Report this user`) + description = _(msg`Why should this user be reviewed?`) + } else if (props.params.type === 'post') { + title = _(msg`Report this post`) + description = _(msg`Why should this post be reviewed?`) + } else if (props.params.type === 'list') { + title = _(msg`Report this list`) + description = _(msg`Why should this list be reviewed?`) + } else if (props.params.type === 'feedgen') { + title = _(msg`Report this feed`) + description = _(msg`Why should this feed be reviewed?`) + } + + return { + title, + description, + } + }, [_, props.params.type]) + + return ( + <View style={[a.gap_lg]}> + {props.labelers?.length > 1 ? ( + <Button + size="small" + variant="solid" + color="secondary" + shape="round" + label={_(msg`Go back to previous step`)} + onPress={props.goBack}> + <ButtonIcon icon={ChevronLeft} /> + </Button> + ) : null} + + <View style={[a.justify_center, a.gap_sm]}> + <Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text> + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + {i18n.description} + </Text> + </View> + + <Divider /> + + <View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}> + {reportOptions.map(reportOption => { + return ( + <Button + key={reportOption.reason} + label={_(msg`Create report for ${reportOption.title}`)} + onPress={() => props.onSelectReportOption(reportOption)}> + <ReportOptionButton + title={reportOption.title} + description={reportOption.description} + /> + </Button> + ) + })} + + {(props.params.type === 'post' || props.params.type === 'account') && ( + <View style={[a.pt_md, a.px_md]}> + <View + style={[ + a.flex_row, + a.align_center, + a.justify_between, + a.gap_lg, + a.p_md, + a.pl_lg, + a.rounded_md, + t.atoms.bg_contrast_900, + ]}> + <Text + style={[ + a.flex_1, + t.atoms.text_inverted, + a.italic, + a.leading_snug, + ]}> + <Trans>Need to report a copyright violation?</Trans> + </Text> + <Link + to={DMCA_LINK} + label={_(msg`View details for reporting a copyright violation`)} + size="small" + variant="solid" + color="secondary"> + <ButtonText> + <Trans>View details</Trans> + </ButtonText> + <ButtonIcon position="right" icon={SquareArrowTopRight} /> + </Link> + </View> + </View> + )} + </View> + </View> + ) +} + +function ReportOptionButton({ + title, + description, +}: { + title: string + description: string +}) { + const t = useTheme() + const {hovered, pressed} = useButtonContext() + const interacted = hovered || pressed + + const styles = React.useMemo(() => { + return { + interacted: { + backgroundColor: t.palette.contrast_50, + }, + } + }, [t]) + + return ( + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_between, + a.p_md, + a.rounded_md, + {paddingRight: 70}, + interacted && styles.interacted, + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> + {title} + </Text> + <Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text> + </View> + + <View + style={[ + a.absolute, + a.inset_0, + a.justify_center, + a.pr_md, + {left: 'auto'}, + ]}> + <ChevronRight + size="md" + fill={ + hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color + } + /> + </View> + </View> + ) +} diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx new file mode 100644 index 000000000..d47211c81 --- /dev/null +++ b/src/components/ReportDialog/SubmitView.tsx @@ -0,0 +1,264 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyLabelerDefs} from '@atproto/api' + +import {getLabelingServiceTitle} from '#/lib/moderation' +import {ReportOption} from '#/lib/moderation/useReportOptions' + +import {atoms as a, useTheme, native} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import * as Toggle from '#/components/forms/Toggle' +import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' +import {Loader} from '#/components/Loader' +import * as Toast from '#/view/com/util/Toast' + +import {ReportDialogProps} from './types' +import {getAgent} from '#/state/session' + +export function SubmitView({ + params, + labelers, + selectedLabeler, + selectedReportOption, + goBack, + onSubmitComplete, +}: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + selectedLabeler: string + selectedReportOption: ReportOption + goBack: () => void + onSubmitComplete: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const [details, setDetails] = React.useState<string>('') + const [submitting, setSubmitting] = React.useState<boolean>(false) + const [selectedServices, setSelectedServices] = React.useState<string[]>([ + selectedLabeler, + ]) + const [error, setError] = React.useState('') + + const submit = React.useCallback(async () => { + setSubmitting(true) + setError('') + + const $type = + params.type === 'account' + ? 'com.atproto.admin.defs#repoRef' + : 'com.atproto.repo.strongRef' + const report = { + reasonType: selectedReportOption.reason, + subject: { + $type, + ...params, + }, + reason: details, + } + const results = await Promise.all( + selectedServices.map(did => + getAgent() + .withProxy('atproto_labeler', did) + .createModerationReport(report) + .then( + _ => true, + _ => false, + ), + ), + ) + + setSubmitting(false) + + if (results.includes(true)) { + Toast.show(_(msg`Thank you. Your report has been sent.`)) + onSubmitComplete() + } else { + setError( + _( + msg`There was an issue sending your report. Please check your internet connection.`, + ), + ) + } + }, [ + _, + params, + details, + selectedReportOption, + selectedServices, + onSubmitComplete, + setError, + ]) + + return ( + <View style={[a.gap_2xl]}> + <Button + size="small" + variant="solid" + color="secondary" + shape="round" + label={_(msg`Go back to previous step`)} + onPress={goBack}> + <ButtonIcon icon={ChevronLeft} /> + </Button> + + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_between, + a.gap_lg, + a.p_md, + a.rounded_md, + a.border, + t.atoms.border_contrast_low, + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.text_md, a.font_bold]}> + {selectedReportOption.title} + </Text> + <Text style={[a.leading_tight, {maxWidth: 400}]}> + {selectedReportOption.description} + </Text> + </View> + + <Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} /> + </View> + + <View style={[a.gap_md]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Select the moderation service(s) to report to</Trans> + </Text> + + <Toggle.Group + label="Select mod services" + values={selectedServices} + onChange={setSelectedServices}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + {labelers.map(labeler => { + const title = getLabelingServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + }) + return ( + <Toggle.Item + key={labeler.creator.did} + name={labeler.creator.did} + label={title}> + <LabelerToggle title={title} /> + </Toggle.Item> + ) + })} + </View> + </Toggle.Group> + </View> + <View style={[a.gap_md]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Optionally provide additional information below:</Trans> + </Text> + + <View style={[a.relative, a.w_full]}> + <Dialog.Input + multiline + value={details} + onChangeText={setDetails} + label="Text field" + style={{paddingRight: 60}} + numberOfLines={6} + /> + + <View + style={[ + a.absolute, + a.flex_row, + a.align_center, + a.pr_md, + a.pb_sm, + { + bottom: 0, + right: 0, + }, + ]}> + <CharProgress count={details?.length || 0} /> + </View> + </View> + </View> + + <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}> + {!selectedServices.length || + (error && ( + <Text + style={[ + a.flex_1, + a.italic, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {error ? ( + error + ) : ( + <Trans>You must select at least one labeler for a report</Trans> + )} + </Text> + ))} + + <Button + size="large" + variant="solid" + color="negative" + label={_(msg`Send report`)} + onPress={submit} + disabled={!selectedServices.length}> + <ButtonText> + <Trans>Send report</Trans> + </ButtonText> + {submitting && <ButtonIcon icon={Loader} />} + </Button> + </View> + </View> + ) +} + +function LabelerToggle({title}: {title: string}) { + const t = useTheme() + const ctx = Toggle.useItemContext() + + return ( + <View + style={[ + a.flex_row, + a.align_center, + a.gap_md, + a.p_md, + a.pr_lg, + a.rounded_sm, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ctx.selected && [t.atoms.bg_contrast_50], + ]}> + <Toggle.Checkbox /> + <View + style={[ + a.flex_row, + a.align_center, + a.justify_between, + a.gap_lg, + a.z_10, + ]}> + <Text + style={[ + native({marginTop: 2}), + t.atoms.text_contrast_medium, + ctx.selected && t.atoms.text, + ]}> + {title} + </Text> + </View> + </View> + ) +} diff --git a/src/components/ReportDialog/const.ts b/src/components/ReportDialog/const.ts new file mode 100644 index 000000000..30c9aff88 --- /dev/null +++ b/src/components/ReportDialog/const.ts @@ -0,0 +1 @@ +export const DMCA_LINK = 'https://bsky.social/about/support/copyright' diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx new file mode 100644 index 000000000..f01ff3f3b --- /dev/null +++ b/src/components/ReportDialog/index.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import {View, Pressable} from 'react-native' +import {Trans} from '@lingui/macro' + +import {useMyLabelersQuery} from '#/state/queries/preferences' +import {ReportOption} from '#/lib/moderation/useReportOptions' +export {useDialogControl as useReportDialogControl} from '#/components/Dialog' + +import {atoms as a} from '#/alf' +import {Loader} from '#/components/Loader' +import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' + +import {ReportDialogProps} from './types' +import {SelectLabelerView} from './SelectLabelerView' +import {SelectReportOptionView} from './SelectReportOptionView' +import {SubmitView} from './SubmitView' +import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' +import {AppBskyLabelerDefs} from '@atproto/api' + +export function ReportDialog(props: ReportDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + + <ReportDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function ReportDialogInner(props: ReportDialogProps) { + const { + isLoading: isLabelerLoading, + data: labelers, + error, + } = useMyLabelersQuery() + const isLoading = useDelayedLoading(500, isLabelerLoading) + + return ( + <Dialog.ScrollableInner label="Report Dialog"> + {isLoading ? ( + <View style={[a.align_center, {height: 100}]}> + <Loader size="xl" /> + {/* Here to capture focus for a hot sec to prevent flash */} + <Pressable accessible={false} /> + </View> + ) : error || !labelers ? ( + <View> + <Text style={[a.text_md]}> + <Trans>Something went wrong, please try again.</Trans> + </Text> + </View> + ) : ( + <ReportDialogLoaded labelers={labelers} {...props} /> + )} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +function ReportDialogLoaded( + props: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + }, +) { + const [selectedLabeler, setSelectedLabeler] = React.useState< + string | undefined + >(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined) + const [selectedReportOption, setSelectedReportOption] = React.useState< + ReportOption | undefined + >() + + if (selectedReportOption && selectedLabeler) { + return ( + <SubmitView + {...props} + selectedLabeler={selectedLabeler} + selectedReportOption={selectedReportOption} + goBack={() => setSelectedReportOption(undefined)} + onSubmitComplete={() => props.control.close()} + /> + ) + } + if (selectedLabeler) { + return ( + <SelectReportOptionView + {...props} + goBack={() => setSelectedLabeler(undefined)} + onSelectReportOption={setSelectedReportOption} + /> + ) + } + return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} /> +} diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts new file mode 100644 index 000000000..0c8a1e077 --- /dev/null +++ b/src/components/ReportDialog/types.ts @@ -0,0 +1,15 @@ +import * as Dialog from '#/components/Dialog' + +export type ReportDialogProps = { + control: Dialog.DialogOuterProps['control'] + params: + | { + type: 'post' | 'list' | 'feedgen' | 'other' + uri: string + cid: string + } + | { + type: 'account' + did: string + } +} diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 849a3f42d..13e4e7d6b 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -59,7 +59,7 @@ export function TagMenu({ const displayTag = '#' + tag const isMuted = Boolean( - (preferences?.mutedWords?.find( + (preferences?.moderationPrefs.mutedWords?.find( m => m.value === tag && m.targets.includes('tag'), ) ?? optimisticUpsert?.find( diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index 8245bd019..b2f5c9075 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -50,7 +50,7 @@ export function TagMenu({ const {mutateAsync: removeMutedWord, variables: optimisticRemove} = useRemoveMutedWordMutation() const isMuted = Boolean( - (preferences?.mutedWords?.find( + (preferences?.moderationPrefs.mutedWords?.find( m => m.value === tag && m.targets.includes('tag'), ) ?? optimisticUpsert?.find( diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index 5268e7f46..f8b3ad1bd 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -1,5 +1,10 @@ import React from 'react' -import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native' +import { + Text as RNText, + StyleProp, + TextStyle, + TextProps as RNTextProps, +} from 'react-native' import {UITextView} from 'react-native-ui-text-view' import {useTheme, atoms, web, flatten} from '#/alf' @@ -34,7 +39,7 @@ export function leading< * If the `lineHeight` value is > 2, we assume it's an absolute value and * returns it as-is. */ -function normalizeTextStyles(styles: TextStyle[]) { +export function normalizeTextStyles(styles: StyleProp<TextStyle>) { const s = flatten(styles) // should always be defined on these components const fontSize = s.fontSize || atoms.text_md.fontSize diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx new file mode 100644 index 000000000..62c95c78d --- /dev/null +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' + +import * as Dialog from '#/components/Dialog' +import {Text} from '../Typography' +import {DateInput} from '#/view/com/util/forms/DateInput' +import {logger} from '#/logger' +import { + usePreferencesSetBirthDateMutation, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {Button, ButtonText} from '../Button' +import {atoms as a, useTheme} from '#/alf' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {cleanError} from '#/lib/strings/errors' +import {ActivityIndicator, View} from 'react-native' +import {isIOS, isWeb} from '#/platform/detection' + +export function BirthDateSettingsDialog({ + control, + preferences, +}: { + control: Dialog.DialogControlProps + preferences: UsePreferencesQueryResponse | undefined +}) { + const {_} = useLingui() + const {isPending, isError, error, mutateAsync} = + usePreferencesSetBirthDateMutation() + + return ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + <Dialog.ScrollableInner label={_(msg`My Birthday`)}> + {preferences && !isPending ? ( + <BirthdayInner + control={control} + preferences={preferences} + isError={isError} + error={error} + setBirthDate={mutateAsync} + /> + ) : ( + <ActivityIndicator size="large" style={a.my_5xl} /> + )} + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +function BirthdayInner({ + control, + preferences, + isError, + error, + setBirthDate, +}: { + control: Dialog.DialogControlProps + preferences: UsePreferencesQueryResponse + isError: boolean + error: unknown + setBirthDate: (args: {birthDate: Date}) => Promise<unknown> +}) { + const {_} = useLingui() + const [date, setDate] = React.useState(preferences.birthDate || new Date()) + const t = useTheme() + + const hasChanged = date !== preferences.birthDate + + const onSave = React.useCallback(async () => { + try { + // skip if date is the same + if (hasChanged) { + await setBirthDate({birthDate: date}) + } + control.close() + } catch (e) { + logger.error(`setBirthDate failed`, {message: e}) + } + }, [date, setBirthDate, control, hasChanged]) + + return ( + <View style={a.gap_lg} testID="birthDateSettingsDialog"> + <View style={[a.gap_sm]}> + <Text style={[a.text_2xl, a.font_bold]}> + <Trans>My Birthday</Trans> + </Text> + <Text style={t.atoms.text_contrast_medium}> + <Trans>This information is not shared with other users.</Trans> + </Text> + </View> + <View style={isIOS && [a.w_full, a.align_center]}> + <DateInput + handleAsUTC + testID="birthdayInput" + value={date} + onChange={setDate} + buttonType="default-light" + buttonStyle={[a.rounded_sm]} + buttonLabelType="lg" + accessibilityLabel={_(msg`Birthday`)} + accessibilityHint={_(msg`Enter your birth date`)} + accessibilityLabelledBy="birthDate" + /> + </View> + {isError ? ( + <ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} /> + ) : undefined} + + <View style={isWeb && [a.flex_row, a.justify_end]}> + <Button + label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)} + size={isWeb ? 'small' : 'medium'} + onPress={onSave} + variant="solid" + color="primary"> + <ButtonText> + {hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>} + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index d86c90a92..87bd5c2ed 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -18,7 +18,7 @@ export function useGlobalDialogsControlContext() { export function Provider({children}: React.PropsWithChildren<{}>) { const mutedWordsDialogControl = Dialog.useDialogControl() - const ctx = React.useMemo( + const ctx = React.useMemo<ControlsContext>( () => ({mutedWordsDialogControl}), [mutedWordsDialogControl], ) diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index dcdaccea3..46f319adf 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -233,8 +233,8 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { </Trans> </Text> </View> - ) : preferences.mutedWords.length ? ( - [...preferences.mutedWords] + ) : preferences.moderationPrefs.mutedWords.length ? ( + [...preferences.moderationPrefs.mutedWords] .reverse() .map((word, i) => ( <MutedWordRow diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index a83f92a2a..7a4b5ac95 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -2,7 +2,14 @@ import React from 'react' import {Pressable, View, ViewStyle} from 'react-native' import {HITSLOP_10} from 'lib/constants' -import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' +import { + useTheme, + atoms as a, + native, + flatten, + ViewStyleProp, + TextStyleProp, +} from '#/alf' import {Text} from '#/components/Typography' import {useInteractionState} from '#/components/hooks/useInteractionState' import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' @@ -220,20 +227,17 @@ export function Item({ onPressOut={onPressOut} onFocus={onFocus} onBlur={onBlur} - style={[ - a.flex_row, - a.align_center, - a.gap_sm, - focused ? web({outline: 'none'}) : {}, - flatten(style), - ]}> + style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}> {typeof children === 'function' ? children(state) : children} </Pressable> </ItemContext.Provider> ) } -export function Label({children}: React.PropsWithChildren<{}>) { +export function Label({ + children, + style, +}: React.PropsWithChildren<TextStyleProp>) { const t = useTheme() const {disabled} = useItemContext() return ( @@ -242,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) { a.font_bold, { userSelect: 'none', - color: disabled ? t.palette.contrast_400 : t.palette.contrast_600, + color: disabled + ? t.atoms.text_contrast_low.color + : t.atoms.text_contrast_high.color, }, native({ paddingTop: 3, }), + flatten(style), ]}> {children} </Text> @@ -257,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) { export function createSharedToggleStyles({ theme: t, hovered, - focused, selected, disabled, isInvalid, @@ -280,7 +286,7 @@ export function createSharedToggleStyles({ borderColor: t.palette.primary_500, }) - if (hovered || focused) { + if (hovered) { baseHover.push({ backgroundColor: t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800, @@ -289,7 +295,7 @@ export function createSharedToggleStyles({ }) } } else { - if (hovered || focused) { + if (hovered) { baseHover.push({ backgroundColor: t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100, @@ -306,7 +312,7 @@ export function createSharedToggleStyles({ t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800, }) - if (hovered || focused) { + if (hovered) { baseHover.push({ backgroundColor: t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, @@ -353,7 +359,7 @@ export function Checkbox() { width: 20, }, baseStyles, - hovered || focused ? baseHoverStyles : {}, + hovered ? baseHoverStyles : {}, ]}> {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null} </View> @@ -385,7 +391,7 @@ export function Switch() { width: 30, }, baseStyles, - hovered || focused ? baseHoverStyles : {}, + hovered ? baseHoverStyles : {}, ]}> <View style={[ @@ -437,7 +443,7 @@ export function Radio() { width: 20, }, baseStyles, - hovered || focused ? baseHoverStyles : {}, + hovered ? baseHoverStyles : {}, ]}> {selected ? ( <View diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 7e1bd70b9..9cdaaaa9d 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -8,7 +8,9 @@ import * as Toggle from '#/components/forms/Toggle' export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> & AccessibilityProps & - React.PropsWithChildren<{testID?: string}> + React.PropsWithChildren<{ + testID?: string + }> export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & { multiple?: boolean @@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) { native({ paddingBottom: 10, }), - a.px_sm, + a.px_md, t.atoms.bg, t.atoms.border_contrast_low, baseStyles, activeStyles, - (state.hovered || state.focused || state.pressed) && hoverStyles, + (state.hovered || state.pressed) && hoverStyles, ]}> {typeof children === 'string' ? ( <Text diff --git a/src/components/hooks/useDelayedLoading.ts b/src/components/hooks/useDelayedLoading.ts new file mode 100644 index 000000000..6c7e2ede0 --- /dev/null +++ b/src/components/hooks/useDelayedLoading.ts @@ -0,0 +1,15 @@ +import React from 'react' + +export function useDelayedLoading(delay: number, initialState: boolean = true) { + const [isLoading, setIsLoading] = React.useState(initialState) + + React.useEffect(() => { + let timeout: NodeJS.Timeout + // on initial load, show a loading spinner for a hot sec to prevent flash + if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay) + + return () => timeout && clearTimeout(timeout) + }, [isLoading, delay]) + + return isLoading +} diff --git a/src/components/icons/ArrowTriangle.tsx b/src/components/icons/ArrowTriangle.tsx new file mode 100644 index 000000000..b27b719ae --- /dev/null +++ b/src/components/icons/ArrowTriangle.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({ + path: 'M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z', +}) diff --git a/src/components/icons/Bars.tsx b/src/components/icons/Bars.tsx new file mode 100644 index 000000000..7b1415a4b --- /dev/null +++ b/src/components/icons/Bars.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx index b1a9deea0..a04e6e009 100644 --- a/src/components/icons/Chevron.tsx +++ b/src/components/icons/Chevron.tsx @@ -7,3 +7,11 @@ export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z', }) + +export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z', +}) + +export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/CircleBanSign.tsx b/src/components/icons/CircleBanSign.tsx new file mode 100644 index 000000000..543985d43 --- /dev/null +++ b/src/components/icons/CircleBanSign.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z', +}) diff --git a/src/components/icons/Gear.tsx b/src/components/icons/Gear.tsx new file mode 100644 index 000000000..980b7413b --- /dev/null +++ b/src/components/icons/Gear.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', +}) diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group.tsx index 9e5ab8893..9e5ab8893 100644 --- a/src/components/icons/Group3.tsx +++ b/src/components/icons/Group.tsx diff --git a/src/components/icons/RaisingHand.tsx b/src/components/icons/RaisingHand.tsx new file mode 100644 index 000000000..cd023cb7e --- /dev/null +++ b/src/components/icons/RaisingHand.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z', +}) diff --git a/src/components/icons/Shield.tsx b/src/components/icons/Shield.tsx new file mode 100644 index 000000000..5038d5c24 --- /dev/null +++ b/src/components/icons/Shield.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z', +}) diff --git a/src/components/icons/SquareArrowTopRight.tsx b/src/components/icons/SquareArrowTopRight.tsx new file mode 100644 index 000000000..7701e26e5 --- /dev/null +++ b/src/components/icons/SquareArrowTopRight.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const SquareArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z', +}) diff --git a/src/components/icons/SquareBehindSquare4.tsx b/src/components/icons/SquareBehindSquare4.tsx new file mode 100644 index 000000000..425599cbc --- /dev/null +++ b/src/components/icons/SquareBehindSquare4.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z', +}) diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx new file mode 100644 index 000000000..1e8f36d31 --- /dev/null +++ b/src/components/moderation/ContentHider.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {isJustAMute} from '#/lib/moderation' +import {sanitizeDisplayName} from '#/lib/strings/display-names' + +import {atoms as a, useTheme, useBreakpoints, web} from '#/alf' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ContentHider({ + testID, + modui, + ignoreMute, + style, + childContainerStyle, + children, +}: React.PropsWithChildren<{ + testID?: string + modui: ModerationUI | undefined + ignoreMute?: boolean + style?: StyleProp<ViewStyle> + childContainerStyle?: StyleProp<ViewStyle> +}>) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const [override, setOverride] = React.useState(false) + const control = useModerationDetailsDialogControl() + + const blur = modui?.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur || (ignoreMute && isJustAMute(modui))) { + return ( + <View testID={testID} style={[styles.outer, style]}> + {children} + </View> + ) + } + + return ( + <View testID={testID} style={[a.overflow_hidden, style]}> + <ModerationDetailsDialog control={control} modcause={blur} /> + + <Button + onPress={() => { + if (!modui.noOverride) { + setOverride(v => !v) + } else { + control.open() + } + }} + label={desc.name} + accessibilityHint={ + modui.noOverride + ? _(msg`Learn more about the moderation applied to this content.`) + : override + ? _(msg`Hide the content`) + : _(msg`Show the content`) + }> + {state => ( + <View + style={[ + a.flex_row, + a.w_full, + a.justify_start, + a.align_center, + a.py_md, + a.px_lg, + a.gap_xs, + a.rounded_sm, + t.atoms.bg_contrast_25, + gtMobile && [a.gap_sm, a.py_lg, a.mt_xs, a.px_xl], + (state.hovered || state.pressed) && t.atoms.bg_contrast_50, + ]}> + <desc.icon + size="md" + fill={t.atoms.text_contrast_medium.color} + style={{marginLeft: -2}} + /> + <Text + style={[ + a.flex_1, + a.text_left, + a.font_bold, + a.leading_snug, + gtMobile && [a.font_semibold], + t.atoms.text_contrast_medium, + web({ + marginBottom: 1, + }), + ]}> + {desc.name} + </Text> + {!modui.noOverride && ( + <Text + style={[ + a.font_bold, + a.leading_snug, + gtMobile && [a.font_semibold], + t.atoms.text_contrast_high, + web({ + marginBottom: 1, + }), + ]}> + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} + </Text> + )} + </View> + )} + </Button> + + {desc.source && blur.type === 'label' && !override && ( + <Button + onPress={() => { + control.open() + }} + label={_( + msg`Learn more about the moderation applied to this content.`, + )} + style={[a.pt_sm]}> + {state => ( + <Text + style={[ + a.flex_1, + a.text_sm, + a.font_normal, + a.leading_snug, + t.atoms.text_contrast_medium, + a.text_left, + ]}> + {desc.sourceType === 'user' ? ( + <Trans>Labeled by the author.</Trans> + ) : ( + <Trans>Labeled by {sanitizeDisplayName(desc.source!)}.</Trans> + )}{' '} + <Text + style={[ + {color: t.palette.primary_500}, + a.text_sm, + state.hovered && [web({textDecoration: 'underline'})], + ]}> + <Trans>Learn more.</Trans> + </Text> + </Text> + )} + </Button> + )} + + {override && <View style={childContainerStyle}>{children}</View>} + </View> + ) +} + +const styles = StyleSheet.create({ + outer: { + overflow: 'hidden', + }, + cover: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderRadius: 8, + marginTop: 4, + paddingVertical: 14, + paddingLeft: 14, + paddingRight: 18, + }, + showBtn: { + marginLeft: 'auto', + alignSelf: 'center', + }, +}) diff --git a/src/components/moderation/GlobalModerationLabelPref.tsx b/src/components/moderation/GlobalModerationLabelPref.tsx new file mode 100644 index 000000000..7633cb9f2 --- /dev/null +++ b/src/components/moderation/GlobalModerationLabelPref.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import {View} from 'react-native' +import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import { + usePreferencesQuery, + usePreferencesSetContentLabelMutation, +} from '#/state/queries/preferences' + +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import * as ToggleButton from '#/components/forms/ToggleButton' + +export function GlobalModerationLabelPref({ + labelValueDefinition, + disabled, +}: { + labelValueDefinition: InterpretedLabelValueDefinition + disabled?: boolean +}) { + const {_} = useLingui() + const t = useTheme() + + const {identifier} = labelValueDefinition + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetContentLabelMutation() + const savedPref = preferences?.moderationPrefs.labels[identifier] + const pref = variables?.visibility ?? savedPref ?? 'warn' + + const allLabelStrings = useGlobalLabelStrings() + const labelStrings = + labelValueDefinition.identifier in allLabelStrings + ? allLabelStrings[labelValueDefinition.identifier] + : { + name: labelValueDefinition.identifier, + description: `Labeled "${labelValueDefinition.identifier}"`, + } + + const labelOptions = { + hide: _(msg`Hide`), + warn: _(msg`Warn`), + ignore: _(msg`Show`), + } + + return ( + <View + style={[ + a.flex_row, + a.justify_between, + a.gap_sm, + a.py_md, + a.pl_lg, + a.pr_md, + a.align_center, + ]}> + <View style={[a.gap_xs, a.flex_1]}> + <Text style={[a.font_bold]}>{labelStrings.name}</Text> + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> + {labelStrings.description} + </Text> + </View> + <View style={[a.justify_center, {minHeight: 35}]}> + {!disabled && ( + <ToggleButton.Group + label={_( + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, + )} + values={[pref]} + onChange={newPref => + mutate({ + label: identifier, + visibility: newPref[0] as LabelPreference, + labelerDid: undefined, + }) + }> + <ToggleButton.Button name="ignore" label={labelOptions.ignore}> + {labelOptions.ignore} + </ToggleButton.Button> + <ToggleButton.Button name="warn" label={labelOptions.warn}> + {labelOptions.warn} + </ToggleButton.Button> + <ToggleButton.Button name="hide" label={labelOptions.hide}> + {labelOptions.hide} + </ToggleButton.Button> + </ToggleButton.Group> + )} + </View> + </View> + ) +} diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx new file mode 100644 index 000000000..099769fa7 --- /dev/null +++ b/src/components/moderation/LabelsOnMe.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import { + LabelsOnMeDialog, + useLabelsOnMeDialogControl, +} from '#/components/moderation/LabelsOnMeDialog' + +export function LabelsOnMe({ + details, + labels, + size, + style, +}: { + details: {did: string} | {uri: string; cid: string} + labels: ComAtprotoLabelDefs.Label[] | undefined + size?: ButtonSize + style?: StyleProp<ViewStyle> +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const isAccount = 'did' in details + const control = useLabelsOnMeDialogControl() + + if (!labels || !currentAccount) { + return null + } + labels = labels.filter( + l => !l.val.startsWith('!') && l.src !== currentAccount.did, + ) + if (!labels.length) { + return null + } + + const labelTarget = isAccount ? _(msg`account`) : _(msg`content`) + return ( + <View style={[a.flex_row, style]}> + <LabelsOnMeDialog control={control} subject={details} labels={labels} /> + + <Button + variant="solid" + color="secondary" + size={size || 'small'} + label={_(msg`View information about these labels`)} + onPress={() => { + control.open() + }}> + <ButtonIcon position="left" icon={CircleInfo} /> + <ButtonText style={[a.leading_snug]}> + {labels.length}{' '} + {labels.length === 1 ? ( + <Trans>label has been placed on this {labelTarget}</Trans> + ) : ( + <Trans>labels have been placed on this {labelTarget}</Trans> + )} + </ButtonText> + </Button> + </View> + ) +} + +export function LabelsOnMyPost({ + post, + style, +}: { + post: AppBskyFeedDefs.PostView + style?: StyleProp<ViewStyle> +}) { + const {currentAccount} = useSession() + if (post.author.did !== currentAccount?.did) { + return null + } + return ( + <LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} /> + ) +} diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx new file mode 100644 index 000000000..6eddbc7ce --- /dev/null +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -0,0 +1,262 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api' + +import {useLabelInfo} from '#/lib/moderation/useLabelInfo' +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeHandle} from '#/lib/strings/handles' +import {getAgent} from '#/state/session' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {Button, ButtonText} from '#/components/Button' +import {InlineLink} from '#/components/Link' +import * as Toast from '#/view/com/util/Toast' +import {Divider} from '../Divider' + +export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog' + +type Subject = + | { + uri: string + cid: string + } + | { + did: string + } + +export interface LabelsOnMeDialogProps { + control: Dialog.DialogOuterProps['control'] + subject: Subject + labels: ComAtprotoLabelDefs.Label[] +} + +export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { + const {_} = useLingui() + const [appealingLabel, setAppealingLabel] = React.useState< + ComAtprotoLabelDefs.Label | undefined + >(undefined) + const {subject, labels} = props + const isAccount = 'did' in subject + + return ( + <Dialog.ScrollableInner + label={ + isAccount + ? _(msg`The following labels were applied to your account.`) + : _(msg`The following labels were applied to your content.`) + }> + {appealingLabel ? ( + <AppealForm + label={appealingLabel} + subject={subject} + control={props.control} + onPressBack={() => setAppealingLabel(undefined)} + /> + ) : ( + <> + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> + {isAccount ? ( + <Trans>Labels on your account</Trans> + ) : ( + <Trans>Labels on your content</Trans> + )} + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + You may appeal these labels if you feel they were placed in error. + </Trans> + </Text> + + <View style={[a.py_lg, a.gap_md]}> + {labels.map(label => ( + <Label + key={`${label.val}-${label.src}`} + label={label} + control={props.control} + onPressAppeal={label => setAppealingLabel(label)} + /> + ))} + </View> + </> + )} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + + <LabelsOnMeDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function Label({ + label, + control, + onPressAppeal, +}: { + label: ComAtprotoLabelDefs.Label + control: Dialog.DialogOuterProps['control'] + onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void +}) { + const t = useTheme() + const {_} = useLingui() + const {labeler, strings} = useLabelInfo(label) + return ( + <View + style={[ + a.border, + t.atoms.border_contrast_low, + a.rounded_sm, + a.overflow_hidden, + ]}> + <View style={[a.p_md, a.gap_sm, a.flex_row]}> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.font_bold, a.text_md]}>{strings.name}</Text> + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> + {strings.description} + </Text> + </View> + <View> + <Button + variant="solid" + color="secondary" + size="small" + label={_(msg`Appeal`)} + onPress={() => onPressAppeal(label)}> + <ButtonText> + <Trans>Appeal</Trans> + </ButtonText> + </Button> + </View> + </View> + + <Divider /> + + <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Source:</Trans>{' '} + <InlineLink + to={makeProfileLink( + labeler ? labeler.creator : {did: label.src, handle: ''}, + )} + onPress={() => control.close()}> + {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} + </InlineLink> + </Text> + </View> + </View> + ) +} + +function AppealForm({ + label, + subject, + control, + onPressBack, +}: { + label: ComAtprotoLabelDefs.Label + subject: Subject + control: Dialog.DialogOuterProps['control'] + onPressBack: () => void +}) { + const {_} = useLingui() + const {labeler, strings} = useLabelInfo(label) + const {gtMobile} = useBreakpoints() + const [details, setDetails] = React.useState('') + const isAccountReport = 'did' in subject + + const onSubmit = async () => { + try { + const $type = !isAccountReport + ? 'com.atproto.repo.strongRef' + : 'com.atproto.admin.defs#repoRef' + await getAgent() + .withProxy('atproto_labeler', label.src) + .createModerationReport({ + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, + subject: { + $type, + ...subject, + }, + reason: details, + }) + Toast.show(_(msg`Appeal submitted.`)) + } finally { + control.close() + } + } + + return ( + <> + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> + <Trans>Appeal "{strings.name}" label</Trans> + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + This appeal will be sent to{' '} + <InlineLink + to={makeProfileLink( + labeler ? labeler.creator : {did: label.src, handle: ''}, + )} + onPress={() => control.close()} + style={[a.text_md, a.leading_snug]}> + {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} + </InlineLink> + . + </Trans> + </Text> + <View style={[a.my_md]}> + <Dialog.Input + label={_(msg`Text input field`)} + placeholder={_( + msg`Please explain why you think this label was incorrectly applied by ${ + labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src + }`, + )} + value={details} + onChangeText={setDetails} + autoFocus={true} + numberOfLines={3} + multiline + maxLength={300} + /> + </View> + + <View + style={ + gtMobile + ? [a.flex_row, a.justify_between] + : [{flexDirection: 'column-reverse'}, a.gap_sm] + }> + <Button + testID="backBtn" + variant="solid" + color="secondary" + size="medium" + onPress={onPressBack} + label={_(msg`Back`)}> + {_(msg`Back`)} + </Button> + <Button + testID="submitBtn" + variant="solid" + color="primary" + size="medium" + onPress={onSubmit} + label={_(msg`Submit`)}> + {_(msg`Submit`)} + </Button> + </View> + </> + ) +} diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx new file mode 100644 index 000000000..da490cb43 --- /dev/null +++ b/src/components/moderation/ModerationDetailsDialog.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {ModerationCause} from '@atproto/api' + +import {listUriToHref} from '#/lib/strings/url-helpers' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {makeProfileLink} from '#/lib/routes/links' + +import {isNative} from '#/platform/detection' +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {InlineLink} from '#/components/Link' +import {Divider} from '#/components/Divider' + +export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog' + +export interface ModerationDetailsDialogProps { + control: Dialog.DialogOuterProps['control'] + modcause: ModerationCause +} + +export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + <ModerationDetailsDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function ModerationDetailsDialogInner({ + modcause, + control, +}: ModerationDetailsDialogProps & { + control: Dialog.DialogOuterProps['control'] +}) { + const t = useTheme() + const {_} = useLingui() + const desc = useModerationCauseDescription(modcause) + + let name + let description + if (!modcause) { + name = _(msg`Content Warning`) + description = _( + msg`Moderator has chosen to set a general warning on the content.`, + ) + } else if (modcause.type === 'blocking') { + if (modcause.source.type === 'list') { + const list = modcause.source.list + name = _(msg`User Blocked by List`) + description = ( + <Trans> + This user is included in the{' '} + <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}> + {list.name} + </InlineLink>{' '} + list which you have blocked. + </Trans> + ) + } else { + name = _(msg`User Blocked`) + description = _( + msg`You have blocked this user. You cannot view their content.`, + ) + } + } else if (modcause.type === 'blocked-by') { + name = _(msg`User Blocks You`) + description = _( + msg`This user has blocked you. You cannot view their content.`, + ) + } else if (modcause.type === 'block-other') { + name = _(msg`Content Not Available`) + description = _( + msg`This content is not available because one of the users involved has blocked the other.`, + ) + } else if (modcause.type === 'muted') { + if (modcause.source.type === 'list') { + const list = modcause.source.list + name = _(msg`Account Muted by List`) + description = ( + <Trans> + This user is included in the{' '} + <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}> + {list.name} + </InlineLink>{' '} + list which you have muted. + </Trans> + ) + } else { + name = _(msg`Account Muted`) + description = _(msg`You have muted this account.`) + } + } else if (modcause.type === 'mute-word') { + name = _(msg`Post Hidden by Muted Word`) + description = _(msg`You've chosen to hide a word or tag within this post.`) + } else if (modcause.type === 'hidden') { + name = _(msg`Post Hidden by You`) + description = _(msg`You have hidden this post.`) + } else if (modcause.type === 'label') { + name = desc.name + description = desc.description + } else { + // should never happen + name = '' + description = '' + } + + return ( + <Dialog.ScrollableInner label={_(msg`Moderation details`)}> + <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> + {name} + </Text> + <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}> + {description} + </Text> + + {modcause.type === 'label' && ( + <> + <Divider /> + <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> + <Trans> + This label was applied by{' '} + {modcause.source.type === 'user' ? ( + <Trans>the author</Trans> + ) : ( + <InlineLink + to={makeProfileLink({did: modcause.label.src, handle: ''})} + onPress={() => control.close()} + style={a.text_md}> + {desc.source} + </InlineLink> + )} + . + </Trans> + </Text> + </> + )} + + {isNative && <View style={{height: 40}} />} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} diff --git a/src/components/moderation/ModerationLabelPref.tsx b/src/components/moderation/ModerationLabelPref.tsx new file mode 100644 index 000000000..f14550488 --- /dev/null +++ b/src/components/moderation/ModerationLabelPref.tsx @@ -0,0 +1,154 @@ +import React from 'react' +import {View} from 'react-native' +import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' +import { + usePreferencesQuery, + usePreferencesSetContentLabelMutation, +} from '#/state/queries/preferences' +import {getLabelStrings} from '#/lib/moderation/useLabelInfo' + +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import {InlineLink} from '#/components/Link' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' +import * as ToggleButton from '#/components/forms/ToggleButton' + +export function ModerationLabelPref({ + labelValueDefinition, + labelerDid, + disabled, +}: { + labelValueDefinition: InterpretedLabelValueDefinition + labelerDid: string | undefined + disabled?: boolean +}) { + const {_, i18n} = useLingui() + const t = useTheme() + + const isGlobalLabel = !labelValueDefinition.definedBy + const {identifier} = labelValueDefinition + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetContentLabelMutation() + const savedPref = + labelerDid && !isGlobalLabel + ? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid) + ?.labels[identifier] + : preferences?.moderationPrefs.labels[identifier] + const pref = + variables?.visibility ?? + savedPref ?? + labelValueDefinition.defaultSetting ?? + 'warn' + + // does the 'warn' setting make sense for this label? + const canWarn = !( + labelValueDefinition.blurs === 'none' && + labelValueDefinition.severity === 'none' + ) + // is this label adult only? + const adultOnly = labelValueDefinition.flags.includes('adult') + // is this label disabled because it's adult only? + const adultDisabled = + adultOnly && !preferences?.moderationPrefs.adultContentEnabled + // are there any reasons we cant configure this label here? + const cantConfigure = isGlobalLabel || adultDisabled + + // adjust the pref based on whether warn is available + let prefAdjusted = pref + if (adultDisabled) { + prefAdjusted = 'hide' + } else if (!canWarn && pref === 'warn') { + prefAdjusted = 'ignore' + } + + // grab localized descriptions of the label and its settings + const currentPrefLabel = useLabelBehaviorDescription( + labelValueDefinition, + prefAdjusted, + ) + const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide') + const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn') + const ignoreLabel = useLabelBehaviorDescription( + labelValueDefinition, + 'ignore', + ) + const globalLabelStrings = useGlobalLabelStrings() + const labelStrings = getLabelStrings( + i18n.locale, + globalLabelStrings, + labelValueDefinition, + ) + + return ( + <View style={[a.flex_row, a.gap_sm, a.px_lg, a.py_lg, a.justify_between]}> + <View style={[a.gap_xs, a.flex_1]}> + <Text style={[a.font_bold]}>{labelStrings.name}</Text> + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> + {labelStrings.description} + </Text> + + {cantConfigure && ( + <View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}> + <CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} /> + + <Text + style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}> + {adultDisabled ? ( + <Trans>Adult content is disabled.</Trans> + ) : isGlobalLabel ? ( + <Trans> + Configured in{' '} + <InlineLink to="/moderation" style={a.text_sm}> + moderation settings + </InlineLink> + . + </Trans> + ) : null} + </Text> + </View> + )} + </View> + {disabled ? ( + <></> + ) : cantConfigure ? ( + <View style={[{minHeight: 35}, a.px_sm, a.py_md]}> + <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> + {currentPrefLabel} + </Text> + </View> + ) : ( + <View style={[{minHeight: 35}]}> + <ToggleButton.Group + label={_( + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, + )} + values={[prefAdjusted]} + onChange={newPref => + mutate({ + label: identifier, + visibility: newPref[0] as LabelPreference, + labelerDid, + }) + }> + <ToggleButton.Button name="ignore" label={ignoreLabel}> + {ignoreLabel} + </ToggleButton.Button> + {canWarn && ( + <ToggleButton.Button name="warn" label={warnLabel}> + {warnLabel} + </ToggleButton.Button> + )} + <ToggleButton.Button name="hide" label={hideLabel}> + {hideLabel} + </ToggleButton.Button> + </ToggleButton.Group> + </View> + )} + </View> + ) +} diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx new file mode 100644 index 000000000..0bfe69678 --- /dev/null +++ b/src/components/moderation/PostAlerts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {ModerationUI, ModerationCause} from '@atproto/api' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {getModerationCauseKey} from '#/lib/moderation' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function PostAlerts({ + modui, + style, +}: { + modui: ModerationUI + includeMute?: boolean + style?: StyleProp<ViewStyle> +}) { + if (!modui.alert && !modui.inform) { + return null + } + + return ( + <View style={[a.flex_col, a.gap_xs, style]}> + <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> + {modui.alerts.map(cause => ( + <PostLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + {modui.informs.map(cause => ( + <PostLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + </View> + </View> + ) +} + +function PostLabel({cause}: {cause: ModerationCause}) { + const control = useModerationDetailsDialogControl() + const desc = useModerationCauseDescription(cause) + + return ( + <> + <Button + label={desc.name} + variant="solid" + color="secondary" + size="small" + shape="default" + onPress={() => { + control.open() + }} + style={[a.px_sm, a.py_xs, a.gap_xs]}> + <ButtonIcon icon={desc.icon} position="left" /> + <ButtonText style={[a.text_left, a.leading_snug]}> + {desc.name} + </ButtonText> + </Button> + + <ModerationDetailsDialog control={control} modcause={cause} /> + </> + ) +} diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx new file mode 100644 index 000000000..464ee2077 --- /dev/null +++ b/src/components/moderation/PostHider.tsx @@ -0,0 +1,129 @@ +import React, {ComponentProps} from 'react' +import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {addStyle} from 'lib/styles' + +import {useTheme, atoms as a} from '#/alf' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' +import {Text} from '#/components/Typography' +// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up +import {Link} from '#/view/com/util/Link' + +interface Props extends ComponentProps<typeof Link> { + iconSize: number + iconStyles: StyleProp<ViewStyle> + modui: ModerationUI +} + +export function PostHider({ + testID, + href, + modui, + style, + children, + iconSize, + iconStyles, + ...props +}: Props) { + const t = useTheme() + const {_} = useLingui() + const [override, setOverride] = React.useState(false) + const control = useModerationDetailsDialogControl() + const blur = modui.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur) { + return ( + <Link + testID={testID} + style={style} + href={href} + accessible={false} + {...props}> + {children} + </Link> + ) + } + + return !override ? ( + <Pressable + onPress={() => { + if (!modui.noOverride) { + setOverride(v => !v) + } + }} + accessibilityRole="button" + accessibilityHint={ + override ? _(msg`Hide the content`) : _(msg`Show the content`) + } + accessibilityLabel="" + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + a.py_md, + { + paddingLeft: 6, + paddingRight: 18, + }, + override ? {paddingBottom: 0} : undefined, + t.atoms.bg, + ]}> + <ModerationDetailsDialog control={control} modcause={blur} /> + <Pressable + onPress={() => { + control.open() + }} + accessibilityRole="button" + accessibilityLabel={_(msg`Learn more about this warning`)} + accessibilityHint=""> + <View + style={[ + t.atoms.bg_contrast_25, + a.align_center, + a.justify_center, + { + width: iconSize, + height: iconSize, + borderRadius: iconSize, + }, + iconStyles, + ]}> + <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} /> + </View> + </Pressable> + <Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}> + {desc.name} + </Text> + {!modui.noOverride && ( + <Text style={[{color: t.palette.primary_500}]}> + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} + </Text> + )} + </Pressable> + ) : ( + <Link + testID={testID} + style={addStyle(style, styles.child)} + href={href} + accessible={false} + {...props}> + {children} + </Link> + ) +} + +const styles = StyleSheet.create({ + child: { + borderWidth: 0, + borderTopWidth: 0, + borderRadius: 8, + }, +}) diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx new file mode 100644 index 000000000..dfc2aa557 --- /dev/null +++ b/src/components/moderation/ProfileHeaderAlerts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {ModerationCause, ModerationDecision} from '@atproto/api' + +import {getModerationCauseKey} from 'lib/moderation' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ProfileHeaderAlerts({ + moderation, + style, +}: { + moderation: ModerationDecision + style?: StyleProp<ViewStyle> +}) { + const modui = moderation.ui('profileView') + if (!modui.alert && !modui.inform) { + return null + } + + return ( + <View style={[a.flex_col, a.gap_xs, style]}> + <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> + {modui.alerts.map(cause => ( + <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + {modui.informs.map(cause => ( + <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + </View> + </View> + ) +} + +function ProfileLabel({cause}: {cause: ModerationCause}) { + const control = useModerationDetailsDialogControl() + const desc = useModerationCauseDescription(cause) + + return ( + <> + <Button + label={desc.name} + variant="solid" + color="secondary" + size="small" + shape="default" + onPress={() => { + control.open() + }} + style={[a.px_sm, a.py_xs, a.gap_xs]}> + <ButtonIcon icon={desc.icon} position="left" /> + <ButtonText style={[a.text_left, a.leading_snug]}> + {desc.name} + </ButtonText> + </Button> + + <ModerationDetailsDialog control={control} modcause={cause} /> + </> + ) +} diff --git a/src/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx new file mode 100644 index 000000000..71ca85a92 --- /dev/null +++ b/src/components/moderation/ScreenHider.tsx @@ -0,0 +1,171 @@ +import React from 'react' +import { + TouchableWithoutFeedback, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import {ModerationUI} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {NavigationProp} from 'lib/routes/types' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' + +import {useTheme, atoms as a} from '#/alf' +import {CenteredView} from '#/view/com/util/Views' +import {Text} from '#/components/Typography' +import {Button, ButtonText} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ScreenHider({ + testID, + screenDescription, + modui, + style, + containerStyle, + children, +}: React.PropsWithChildren<{ + testID?: string + screenDescription: string + modui: ModerationUI + style?: StyleProp<ViewStyle> + containerStyle?: StyleProp<ViewStyle> +}>) { + const t = useTheme() + const {_} = useLingui() + const [override, setOverride] = React.useState(false) + const navigation = useNavigation<NavigationProp>() + const {isMobile} = useWebMediaQueries() + const control = useModerationDetailsDialogControl() + const blur = modui.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur || override) { + return ( + <View testID={testID} style={style}> + {children} + </View> + ) + } + + const isNoPwi = !!modui.blurs.find( + cause => + cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated', + ) + return ( + <CenteredView + style={[ + a.flex_1, + { + paddingTop: 100, + paddingBottom: 150, + }, + t.atoms.bg, + containerStyle, + ]} + sideBorders> + <View style={[a.align_center, a.mb_md]}> + <View + style={[ + t.atoms.bg_contrast_975, + a.align_center, + a.justify_center, + { + borderRadius: 25, + width: 50, + height: 50, + }, + ]}> + <desc.icon width={24} fill={t.atoms.bg.backgroundColor} /> + </View> + </View> + <Text + style={[ + a.text_4xl, + a.font_semibold, + a.text_center, + a.mb_md, + t.atoms.text, + ]}> + {isNoPwi ? ( + <Trans>Sign-in Required</Trans> + ) : ( + <Trans>Content Warning</Trans> + )} + </Text> + <Text + style={[ + a.text_lg, + a.mb_md, + a.px_lg, + a.text_center, + t.atoms.text_contrast_medium, + ]}> + {isNoPwi ? ( + <Trans> + This account has requested that users sign in to view their profile. + </Trans> + ) : ( + <> + <Trans>This {screenDescription} has been flagged:</Trans> + <Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}> + {desc.name}.{' '} + </Text> + <TouchableWithoutFeedback + onPress={() => { + control.open() + }} + accessibilityRole="button" + accessibilityLabel={_(msg`Learn more about this warning`)} + accessibilityHint=""> + <Text style={[a.text_lg, {color: t.palette.primary_500}]}> + <Trans>Learn More</Trans> + </Text> + </TouchableWithoutFeedback> + + <ModerationDetailsDialog control={control} modcause={blur} /> + </> + )}{' '} + </Text> + {isMobile && <View style={a.flex_1} />} + <View style={[a.flex_row, a.justify_center, a.my_md, a.gap_md]}> + <Button + variant="solid" + color="primary" + size="large" + style={[a.rounded_full]} + label={_(msg`Go back`)} + onPress={() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }}> + <ButtonText> + <Trans>Go back</Trans> + </ButtonText> + </Button> + {!modui.noOverride && ( + <Button + variant="solid" + color="secondary" + size="large" + style={[a.rounded_full]} + label={_(msg`Show anyway`)} + onPress={() => setOverride(v => !v)}> + <ButtonText> + <Trans>Show anyway</Trans> + </ButtonText> + </Button> + )} + </View> + </CenteredView> + ) +} |