diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index fef24f95..3f607057 100644 --- a/.github/workflows/build-and-push-bskyweb-aws.yaml +++ b/.github/workflows/build-and-push-bskyweb-aws.yaml @@ -3,8 +3,8 @@ on: push: branches: - main - - traffic-reduction - - respect-optout-for-embeds + - 3p-moderators + env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg new file mode 100644 index 00000000..f40546f7 --- /dev/null +++ b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/bars3_stroke2_corner0_rounded.svg b/assets/icons/bars3_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..cbcb531a --- /dev/null +++ b/assets/icons/bars3_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/chevronBottom_stroke2_corner0_rounded.svg b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..705c1c51 --- /dev/null +++ b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/chevronTop_stroke2_corner0_rounded.svg b/assets/icons/chevronTop_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..da94ba91 --- /dev/null +++ b/assets/icons/chevronTop_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/circleBanSign_stroke2_corner0_rounded.svg b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..73251477 --- /dev/null +++ b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/group3_stroke2_corner0_rounded.svg b/assets/icons/group3_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..2a8f43a8 --- /dev/null +++ b/assets/icons/group3_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/heart2_filled_stroke2_corner0_rounded.svg b/assets/icons/heart2_filled_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..1dfefb4c --- /dev/null +++ b/assets/icons/heart2_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/heart2_stroke2_corner0_rounded.svg b/assets/icons/heart2_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..5b3da8e0 --- /dev/null +++ b/assets/icons/heart2_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/person_stroke2_corner0_rounded.svg b/assets/icons/person_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..a23ad760 --- /dev/null +++ b/assets/icons/person_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..aed3d9e7 --- /dev/null +++ b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/settingsGear2_stroke2_corner0_rounded.svg b/assets/icons/settingsGear2_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..de8a5791 --- /dev/null +++ b/assets/icons/settingsGear2_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/shield_stroke2_corner0_rounded.svg b/assets/icons/shield_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..c4ef98e5 --- /dev/null +++ b/assets/icons/shield_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg b/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..1407a1d6 --- /dev/null +++ b/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg b/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..3fa7e5d3 --- /dev/null +++ b/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg new file mode 100644 index 00000000..aa564044 --- /dev/null +++ b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index e159d780..54a3925c 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -188,6 +188,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/threads", server.WebGeneric) e.GET("/settings/external-embeds", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) + e.GET("/sys/debug-mod", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) e.GET("/support", server.WebGeneric) e.GET("/support/privacy", server.WebGeneric) @@ -203,6 +204,7 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric) e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric) e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric) + e.GET("/profile/:handleOrDID/labeler/liked-by", server.WebGeneric) // profile RSS feed (DID not handle) e.GET("/profile/:ident/rss", server.WebProfileRSS) diff --git a/package.json b/package.json index 89620182..30df6695 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "update-extensions": "scripts/updateExtensions.sh" }, "dependencies": { - "@atproto/api": "^0.10.5", + "@atproto/api": "^0.11.2", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", @@ -76,7 +76,9 @@ "@segment/sovran-react-native": "^0.4.5", "@sentry/react-native": "5.5.0", "@tamagui/focus-scope": "^1.84.1", + "@tanstack/query-async-storage-persister": "^5.25.0", "@tanstack/react-query": "^5.8.1", + "@tanstack/react-query-persist-client": "^5.25.0", "@tiptap/core": "^2.0.0-beta.220", "@tiptap/extension-document": "^2.0.0-beta.220", "@tiptap/extension-hard-break": "^2.0.3", diff --git a/src/App.native.tsx b/src/App.native.tsx index eff8ab09..e825ffa0 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -5,7 +5,7 @@ import React, {useState, useEffect} from 'react' import {RootSiblingParent} from 'react-native-root-siblings' import * as SplashScreen from 'expo-splash-screen' import {GestureHandlerRootView} from 'react-native-gesture-handler' -import {QueryClientProvider} from '@tanstack/react-query' +import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client' import { SafeAreaProvider, initialWindowMetrics, @@ -22,7 +22,11 @@ import {s} from 'lib/styles' import {Shell} from 'view/shell' import * as notifications from 'lib/notifications/notifications' import * as Toast from 'view/com/util/Toast' -import {queryClient} from 'lib/react-query' +import { + queryClient, + asyncStoragePersister, + dehydrateOptions, +} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ModalStateProvider} from 'state/modals' @@ -33,6 +37,7 @@ import {Provider as InvitesStateProvider} from 'state/invites' import {Provider as PrefsStateProvider} from 'state/preferences' import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' +import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import I18nProvider from './locale/i18nProvider' import { Provider as SessionProvider, @@ -79,21 +84,23 @@ function InnerApp() { // Resets the entire tree below when it changes: key={currentAccount?.did}> - - - - - {/* All components should be within this provider */} - - - - - - - - - - + + + + + + {/* All components should be within this provider */} + + + + + + + + + + + @@ -118,7 +125,9 @@ function App() { * that is set up in the InnerApp component above. */ return ( - + @@ -140,7 +149,7 @@ function App() { - + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index eb2e4259..f47f763d 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,7 +1,7 @@ import 'lib/sentry' // must be near top import React, {useState, useEffect} from 'react' -import {QueryClientProvider} from '@tanstack/react-query' +import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client' import {SafeAreaProvider} from 'react-native-safe-area-context' import {RootSiblingParent} from 'react-native-root-siblings' @@ -13,7 +13,11 @@ import {init as initPersistedState} from '#/state/persisted' import {Shell} from 'view/shell/index' import {ToastContainer} from 'view/com/util/Toast.web' import {ThemeProvider} from 'lib/ThemeContext' -import {queryClient} from 'lib/react-query' +import { + queryClient, + asyncStoragePersister, + dehydrateOptions, +} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ModalStateProvider} from 'state/modals' import {Provider as DialogStateProvider} from 'state/dialogs' @@ -23,6 +27,7 @@ import {Provider as InvitesStateProvider} from 'state/invites' import {Provider as PrefsStateProvider} from 'state/preferences' import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' +import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import I18nProvider from './locale/i18nProvider' import { Provider as SessionProvider, @@ -56,21 +61,23 @@ function InnerApp() { // Resets the entire tree below when it changes: key={currentAccount?.did}> - - - - - {/* All components should be within this provider */} - - - - - - - - - - + + + + + + {/* All components should be within this provider */} + + + + + + + + + + + @@ -93,7 +100,9 @@ function App() { * that is set up in the InnerApp component above. */ return ( - + @@ -115,7 +124,7 @@ function App() { - + ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 77706ce3..3d6a15c4 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -46,7 +46,7 @@ import {SearchScreen} from './view/screens/Search' import {FeedsScreen} from './view/screens/Feeds' import {NotificationsScreen} from './view/screens/Notifications' import {ListsScreen} from './view/screens/Lists' -import {ModerationScreen} from './view/screens/Moderation' +import {ModerationScreen} from '#/screens/Moderation' import {ModerationModlistsScreen} from './view/screens/ModerationModlists' import {NotFoundScreen} from './view/screens/NotFound' import {SettingsScreen} from './view/screens/Settings' @@ -61,6 +61,7 @@ import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy' import {Storybook} from './view/screens/Storybook' +import {DebugModScreen} from './view/screens/DebugMod' import {LogScreen} from './view/screens/Log' import {SupportScreen} from './view/screens/Support' import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' @@ -78,6 +79,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack import {msg} from '@lingui/macro' import {i18n, MessageDescriptor} from '@lingui/core' import HashtagScreen from '#/screens/Hashtag' +import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig' const navigationRef = createNavigationContainerRef() @@ -198,11 +200,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => ProfileFeedLikedByScreen} options={{title: title(msg`Liked by`)}} /> + ProfileLabelerLikedByScreen} + options={{title: title(msg`Liked by`)}} + /> Storybook} options={{title: title(msg`Storybook`), requireAuth: true}} /> + DebugModScreen} + options={{title: title(msg`Moderation states`), requireAuth: true}} + /> LogScreen} diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 5088e3aa..0b473ba9 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -50,6 +50,9 @@ export const atoms = { h_full: { height: '100%', }, + h_full_vh: web({ + height: '100vh', + }), /* * Border radius @@ -243,6 +246,9 @@ export const atoms = { font_normal: { fontWeight: tokens.fontWeight.normal, }, + font_semibold: { + fontWeight: '500', + }, font_bold: { fontWeight: tokens.fontWeight.semibold, }, @@ -528,6 +534,10 @@ export const atoms = { /* * Margin */ + mx_auto: { + marginLeft: 'auto', + marginRight: 'auto', + }, m_2xs: { margin: tokens.space._2xs, }, diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index b1468f46..4045c831 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -12,6 +12,9 @@ export const dimScale = generateScale(12, 100) export const color = { trueBlack: '#000000', + temp_purple: 'rgb(105 0 255)', + temp_purple_dark: 'rgb(83 0 202)', + gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`, gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`, gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`, diff --git a/src/components/Button.tsx b/src/components/Button.tsx index d3bf73cc..0e22944a 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({ ]}> + {children} ) diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 3a7f7334..038f6295 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 00000000..dc14aa72 --- /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 ( + 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 00000000..6d061351 --- /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) { + return ( + + {children} + + ) +} + +export function Avatar({avatar}: {avatar?: string}) { + return +} + +export function Title({value}: {value: string}) { + return {value} +} + +export function Description({value, handle}: {value?: string; handle: string}) { + return value ? ( + + + + ) : ( + + By {sanitizeHandle(handle, '@')} + + ) +} + +export function LikeCount({count}: {count: number}) { + const t = useTheme() + return ( + + + Liked by {count} {pluralize(count, 'user')} + + + ) +} + +export function Content({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + + return ( + + {children} + + + + ) +} + +/** + * The canonical view for a labeling service. Use this or compose your own. + */ +export function Default({ + labeler, + style, +}: LabelingServiceProps & ViewStyleProp) { + return ( + + + + + <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 00000000..bd121363 --- /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 00000000..94a3f27e --- /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 00e6a56f..7d0e8333 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 bb0d2479..8a889c15 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 f4b03f68..60b23420 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/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx new file mode 100644 index 00000000..8ae0b52e --- /dev/null +++ b/src/components/ReportDialog/SelectReportOptionView.tsx @@ -0,0 +1,183 @@ +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} 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 +}) { + 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]}> + <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 00000000..99af64a2 --- /dev/null +++ b/src/components/ReportDialog/SubmitView.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 {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, + selectedReportOption, + goBack, + onSubmitComplete, +}: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + 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[]>( + labelers?.map(labeler => labeler.creator.did) || [], + ) + 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 00000000..30c9aff8 --- /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 00000000..b35727c7 --- /dev/null +++ b/src/components/ReportDialog/index.tsx @@ -0,0 +1,73 @@ +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 {SelectReportOptionView} from './SelectReportOptionView' +import {SubmitView} from './SubmitView' +import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' + +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) + const [selectedReportOption, setSelectedReportOption] = React.useState< + ReportOption | undefined + >() + + 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> + ) : selectedReportOption ? ( + <SubmitView + {...props} + labelers={labelers} + selectedReportOption={selectedReportOption} + goBack={() => setSelectedReportOption(undefined)} + onSubmitComplete={() => props.control.close()} + /> + ) : ( + <SelectReportOptionView + {...props} + labelers={labelers} + onSelectReportOption={setSelectedReportOption} + /> + )} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts new file mode 100644 index 00000000..0c8a1e07 --- /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 849a3f42..13e4e7d6 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 8245bd01..b2f5c907 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 5268e7f4..f8b3ad1b 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/Context.tsx b/src/components/dialogs/Context.tsx index d86c90a9..87bd5c2e 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 dcdaccea..46f319ad 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 a83f92a2..7a4b5ac9 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 7e1bd70b..9cdaaaa9 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 00000000..6c7e2ede --- /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 00000000..b27b719a --- /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 00000000..7b1415a4 --- /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 b1a9deea..a04e6e00 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 00000000..543985d4 --- /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 00000000..980b7413 --- /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 similarity index 100% rename from src/components/icons/Group3.tsx rename to src/components/icons/Group.tsx diff --git a/src/components/icons/RaisingHand.tsx b/src/components/icons/RaisingHand.tsx new file mode 100644 index 00000000..cd023cb7 --- /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 00000000..5038d5c2 --- /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 00000000..7701e26e --- /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 00000000..425599cb --- /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 00000000..1e8f36d3 --- /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 00000000..7633cb9f --- /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 00000000..099769fa --- /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 00000000..6eddbc7c --- /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 00000000..da490cb4 --- /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 00000000..f1455048 --- /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 00000000..0bfe6967 --- /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/view/com/util/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx similarity index 54% rename from src/view/com/util/moderation/PostHider.tsx rename to src/components/moderation/PostHider.tsx index ede62e98..464ee207 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/components/moderation/PostHider.tsx @@ -1,45 +1,50 @@ import React, {ComponentProps} from 'react' import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native' import {ModerationUI} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {Link} from '../Link' -import {Text} from '../text/Text' -import {addStyle} from 'lib/styles' -import {describeModerationCause} from 'lib/moderation' -import {ShieldExclamation} from 'lib/icons' import {useLingui} from '@lingui/react' import {Trans, msg} from '@lingui/macro' -import {useModalControls} from '#/state/modals' + +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> - moderation: ModerationUI + modui: ModerationUI } export function PostHider({ testID, href, - moderation, + modui, style, children, iconSize, iconStyles, ...props }: Props) { - const pal = usePalette('default') + const t = useTheme() const {_} = useLingui() const [override, setOverride] = React.useState(false) - const {openModal} = useModalControls() + const control = useModerationDetailsDialogControl() + const blur = modui.blurs[0] + const desc = useModerationCauseDescription(blur) - if (!moderation.blur) { + if (!blur) { return ( <Link testID={testID} style={style} href={href} - noFeedback accessible={false} {...props}> {children} @@ -47,12 +52,10 @@ export function PostHider({ ) } - const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '') - const desc = describeModerationCause(moderation.cause, 'content') return !override ? ( <Pressable onPress={() => { - if (!moderation.noOverride) { + if (!modui.noOverride) { setOverride(v => !v) } }} @@ -62,49 +65,45 @@ export function PostHider({ } accessibilityLabel="" style={[ - styles.description, + a.flex_row, + a.align_center, + a.gap_sm, + a.py_md, + { + paddingLeft: 6, + paddingRight: 18, + }, override ? {paddingBottom: 0} : undefined, - pal.view, + t.atoms.bg, ]}> + <ModerationDetailsDialog control={control} modcause={blur} /> <Pressable onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) + control.open() }} accessibilityRole="button" accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint=""> <View style={[ - pal.viewLight, + t.atoms.bg_contrast_25, + a.align_center, + a.justify_center, { width: iconSize, height: iconSize, borderRadius: iconSize, - alignItems: 'center', - justifyContent: 'center', }, iconStyles, ]}> - {isMute ? ( - <FontAwesomeIcon - icon={['far', 'eye-slash']} - size={14} - color={pal.colors.textLight} - /> - ) : ( - <ShieldExclamation size={14} style={pal.textLight} /> - )} + <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} /> </View> </Pressable> - <Text type="sm" style={[{flex: 1}, pal.textLight]} numberOfLines={1}> + <Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}> {desc.name} </Text> - {!moderation.noOverride && ( - <Text type="sm" style={[styles.showBtn, pal.link]}> + {!modui.noOverride && ( + <Text style={[{color: t.palette.primary_500}]}> {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} </Text> )} @@ -114,26 +113,14 @@ export function PostHider({ testID={testID} style={addStyle(style, styles.child)} href={href} - noFeedback> + accessible={false} + {...props}> {children} </Link> ) } const styles = StyleSheet.create({ - description: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingVertical: 10, - paddingLeft: 6, - paddingRight: 18, - marginTop: 1, - }, - showBtn: { - marginLeft: 'auto', - alignSelf: 'center', - }, child: { borderWidth: 0, borderTopWidth: 0, diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx new file mode 100644 index 00000000..dfc2aa55 --- /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 00000000..71ca85a9 --- /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> + ) +} diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts deleted file mode 100644 index 45566281..00000000 --- a/src/lib/__tests__/moderatePost_wrapped.test.ts +++ /dev/null @@ -1,692 +0,0 @@ -import {describe, it, expect} from '@jest/globals' -import {RichText} from '@atproto/api' - -import {hasMutedWord} from '../moderatePost_wrapped' - -describe(`hasMutedWord`, () => { - describe(`tags`, () => { - it(`match: outline tag`, () => { - const rt = new RichText({ - text: `This is a post #inlineTag`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'outlineTag', targets: ['tag']}], - text: rt.text, - facets: rt.facets, - outlineTags: ['outlineTag'], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: inline tag`, () => { - const rt = new RichText({ - text: `This is a post #inlineTag`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'inlineTag', targets: ['tag']}], - text: rt.text, - facets: rt.facets, - outlineTags: ['outlineTag'], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: content target matches inline tag`, () => { - const rt = new RichText({ - text: `This is a post #inlineTag`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'inlineTag', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: ['outlineTag'], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`no match: only tag targets`, () => { - const rt = new RichText({ - text: `This is a post`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'inlineTag', targets: ['tag']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(false) - }) - }) - - describe(`early exits`, () => { - it(`match: single character 希`, () => { - /** - * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c - */ - const rt = new RichText({ - text: `改善希望です`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: '希', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`no match: long muted word, short post`, () => { - const rt = new RichText({ - text: `hey`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'politics', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(false) - }) - - it(`match: exact text`, () => { - const rt = new RichText({ - text: `javascript`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'javascript', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - - describe(`general content`, () => { - it(`match: word within post`, () => { - const rt = new RichText({ - text: `This is a post about javascript`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'javascript', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`no match: partial word`, () => { - const rt = new RichText({ - text: `Use your brain, Eric`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'ai', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(false) - }) - - it(`match: multiline`, () => { - const rt = new RichText({ - text: `Use your\n\tbrain, Eric`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: 'brain', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: :)`, () => { - const rt = new RichText({ - text: `So happy :)`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: `:)`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - - describe(`punctuation semi-fuzzy`, () => { - describe(`yay!`, () => { - const rt = new RichText({ - text: `We're federating, yay!`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: yay!`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'yay!', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: yay`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'yay', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - - describe(`y!ppee!!`, () => { - const rt = new RichText({ - text: `We're federating, y!ppee!!`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: y!ppee`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'y!ppee', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - // single exclamation point, source has double - it(`no match: y!ppee!`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'y!ppee!', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - - describe(`Why so S@assy?`, () => { - const rt = new RichText({ - text: `Why so S@assy?`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: S@assy`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'S@assy', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: s@assy`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 's@assy', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - - describe(`New York Times`, () => { - const rt = new RichText({ - text: `New York Times`, - }) - rt.detectFacetsWithoutResolution() - - // case insensitive - it(`match: new york times`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'new york times', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - - describe(`!command`, () => { - const rt = new RichText({ - text: `Idk maybe a bot !command`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: !command`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `!command`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: command`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `command`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`no match: !command`, () => { - const rt = new RichText({ - text: `Idk maybe a bot command`, - }) - rt.detectFacetsWithoutResolution() - - const match = hasMutedWord({ - mutedWords: [{value: `!command`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(false) - }) - }) - - describe(`e/acc`, () => { - const rt = new RichText({ - text: `I'm e/acc pilled`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: e/acc`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `e/acc`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: acc`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `acc`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - - describe(`super-bad`, () => { - const rt = new RichText({ - text: `I'm super-bad`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: super-bad`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `super-bad`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: super`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `super`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: super bad`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `super bad`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: superbad`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `superbad`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(false) - }) - }) - - describe(`idk_what_this_would_be`, () => { - const rt = new RichText({ - text: `Weird post with idk_what_this_would_be`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: idk what this would be`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `idk what this would be`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`no match: idk what this would be for`, () => { - // extra word - const match = hasMutedWord({ - mutedWords: [ - {value: `idk what this would be for`, targets: ['content']}, - ], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(false) - }) - - it(`match: idk`, () => { - // extra word - const match = hasMutedWord({ - mutedWords: [{value: `idk`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: idkwhatthiswouldbe`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(false) - }) - }) - - describe(`parentheses`, () => { - const rt = new RichText({ - text: `Post with context(iykyk)`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: context(iykyk)`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `context(iykyk)`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: context`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `context`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: iykyk`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `iykyk`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: (iykyk)`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `(iykyk)`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - - describe(`🦋`, () => { - const rt = new RichText({ - text: `Post with 🦋`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: 🦋`, () => { - const match = hasMutedWord({ - mutedWords: [{value: `🦋`, targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - }) - - describe(`phrases`, () => { - describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => { - const rt = new RichText({ - text: `I like turtles, or how I learned to stop worrying and love the internet.`, - }) - rt.detectFacetsWithoutResolution() - - it(`match: stop worrying`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'stop worrying', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`match: turtles, or how`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'turtles, or how', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - }) - - describe(`languages without spaces`, () => { - // I love turtles, or how I learned to stop worrying and love the internet - describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => { - const rt = new RichText({ - text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, - }) - rt.detectFacetsWithoutResolution() - - // internet - it(`match: インターネット`, () => { - const match = hasMutedWord({ - mutedWords: [{value: 'インターネット', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - languages: ['ja'], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - }) - }) - - describe(`doesn't mute own post`, () => { - it(`does mute if it isn't own post`, () => { - const rt = new RichText({ - text: `Mute words!`, - }) - - const match = hasMutedWord({ - mutedWords: [{value: 'words', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: false, - }) - - expect(match).toBe(true) - }) - - it(`doesn't mute own post when muted word is in text`, () => { - const rt = new RichText({ - text: `Mute words!`, - }) - - const match = hasMutedWord({ - mutedWords: [{value: 'words', targets: ['content']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: true, - }) - - expect(match).toBe(false) - }) - - it(`doesn't mute own post when muted word is in tags`, () => { - const rt = new RichText({ - text: `Mute #words!`, - }) - - const match = hasMutedWord({ - mutedWords: [{value: 'words', targets: ['tags']}], - text: rt.text, - facets: rt.facets, - outlineTags: [], - isOwnPost: true, - }) - - expect(match).toBe(false) - }) - }) -}) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e8684439..f5a72669 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -35,6 +35,10 @@ export const MAX_GRAPHEME_LENGTH = 300 // but increasing limit per user feedback export const MAX_ALT_TEXT = 1000 +export function IS_TEST_USER(handle?: string) { + return handle && handle?.endsWith('.test') +} + export function IS_PROD_SERVICE(url?: string) { return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE } diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts index 9f6fa9c0..0ce01368 100644 --- a/src/lib/moderatePost_wrapped.ts +++ b/src/lib/moderatePost_wrapped.ts @@ -1,380 +1,30 @@ -import { - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - moderatePost, - AppBskyActorDefs, - AppBskyFeedPost, - AppBskyRichtextFacet, - AppBskyEmbedImages, - AppBskyEmbedExternal, -} from '@atproto/api' +import {moderatePost, BSKY_LABELER_DID} from '@atproto/api' type ModeratePost = typeof moderatePost -type Options = Parameters<ModeratePost>[1] & { - hiddenPosts?: string[] - mutedWords?: AppBskyActorDefs.MutedWord[] -} - -const REGEX = { - LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu, - ESCAPE: /[[\]{}()*+?.\\^$|\s]/g, - SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g, - WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g, -} - -/** - * List of 2-letter lang codes for languages that either don't use spaces, or - * don't use spaces in a way conducive to word-based filtering. - * - * For these, we use a simple `String.includes` to check for a match. - */ -const LANGUAGE_EXCEPTIONS = [ - 'ja', // Japanese - 'zh', // Chinese - 'ko', // Korean - 'th', // Thai - 'vi', // Vietnamese -] - -export function hasMutedWord({ - mutedWords, - text, - facets, - outlineTags, - languages, - isOwnPost, -}: { - mutedWords: AppBskyActorDefs.MutedWord[] - text: string - facets?: AppBskyRichtextFacet.Main[] - outlineTags?: string[] - languages?: string[] - isOwnPost: boolean -}) { - if (isOwnPost) return false - - const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '') - const tags = ([] as string[]) - .concat(outlineTags || []) - .concat( - facets - ?.filter(facet => { - return facet.features.find(feature => - AppBskyRichtextFacet.isTag(feature), - ) - }) - .map(t => t.features[0].tag as string) || [], - ) - .map(t => t.toLowerCase()) - - for (const mute of mutedWords) { - const mutedWord = mute.value.toLowerCase() - const postText = text.toLowerCase() - - // `content` applies to tags as well - if (tags.includes(mutedWord)) return true - // rest of the checks are for `content` only - if (!mute.targets.includes('content')) continue - // single character or other exception, has to use includes - if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord)) - return true - // too long - if (mutedWord.length > postText.length) continue - // exact match - if (mutedWord === postText) return true - // any muted phrase with space or punctuation - if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord)) - return true - - // check individual character groups - const words = postText.split(REGEX.WORD_BOUNDARY) - for (const word of words) { - if (word === mutedWord) return true - - // compare word without leading/trailing punctuation, but allow internal - // punctuation (such as `s@ssy`) - const wordTrimmedPunctuation = word.replace( - REGEX.LEADING_TRAILING_PUNCTUATION, - '', - ) - - if (mutedWord === wordTrimmedPunctuation) return true - if (mutedWord.length > wordTrimmedPunctuation.length) continue - - // handle hyphenated, slash separated words, etc - if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) { - // check against full normalized phrase - const wordNormalizedSeparators = wordTrimmedPunctuation.replace( - REGEX.SEPARATORS, - ' ', - ) - const mutedWordNormalizedSeparators = mutedWord.replace( - REGEX.SEPARATORS, - ' ', - ) - // hyphenated (or other sep) to spaced words - if (wordNormalizedSeparators === mutedWordNormalizedSeparators) - return true - - /* Disabled for now e.g. `super-cool` to `supercool` - const wordNormalizedCompressed = wordNormalizedSeparators.replace( - REGEX.WORD_BOUNDARY, - '', - ) - const mutedWordNormalizedCompressed = - mutedWordNormalizedSeparators.replace(/\s+?/g, '') - // hyphenated (or other sep) to non-hyphenated contiguous word - if (mutedWordNormalizedCompressed === wordNormalizedCompressed) - return true - */ - - // then individual parts of separated phrases/words - const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS) - for (const wp of wordParts) { - // still retain internal punctuation - if (wp === mutedWord) return true - } - } - } - } - - return false -} +type Options = Parameters<ModeratePost>[1] export function moderatePost_wrapped( subject: Parameters<ModeratePost>[0], opts: Options, ) { - const {hiddenPosts = [], mutedWords = [], ...options} = opts - const moderations = moderatePost(subject, options) - const isOwnPost = subject.author.did === opts.userDid + // HACK + // temporarily translate 'gore' into 'graphic-media' during the transition period + // can remove this in a few months + // -prf + translateOldLabels(subject) - if (hiddenPosts.includes(subject.uri)) { - moderations.content.filter = true - moderations.content.blur = true - if (!moderations.content.cause) { - moderations.content.cause = { - // @ts-ignore Temporary extension to the moderation system -prf - type: 'post-hidden', - source: {type: 'user'}, - priority: 1, - } - } - } - - if (AppBskyFeedPost.isRecord(subject.record)) { - let muted = hasMutedWord({ - mutedWords, - text: subject.record.text, - facets: subject.record.facets || [], - outlineTags: subject.record.tags || [], - languages: subject.record.langs, - isOwnPost, - }) - - if ( - subject.record.embed && - AppBskyEmbedImages.isMain(subject.record.embed) - ) { - for (const image of subject.record.embed.images) { - muted = - muted || - hasMutedWord({ - mutedWords, - text: image.alt, - facets: [], - outlineTags: [], - languages: subject.record.langs, - isOwnPost, - }) - } - } - - if (muted) { - moderations.content.filter = true - moderations.content.blur = true - if (!moderations.content.cause) { - moderations.content.cause = { - // @ts-ignore Temporary extension to the moderation system -prf - type: 'muted-word', - source: {type: 'user'}, - priority: 1, - } - } - } - } - - if (subject.embed) { - let embedHidden = false - let embedMuted = false - let externalMuted = false - - if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { - embedHidden = hiddenPosts.includes(subject.embed.record.uri) - } - if ( - AppBskyEmbedRecordWithMedia.isView(subject.embed) && - AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) - ) { - embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) - } - - if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { - if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { - const embeddedPost = subject.embed.record.value - - embedMuted = - embedMuted || - hasMutedWord({ - mutedWords, - text: embeddedPost.text, - facets: embeddedPost.facets, - outlineTags: embeddedPost.tags, - languages: embeddedPost.langs, - isOwnPost, - }) - - if (AppBskyEmbedImages.isMain(embeddedPost.embed)) { - for (const image of embeddedPost.embed.images) { - embedMuted = - embedMuted || - hasMutedWord({ - mutedWords, - text: image.alt, - facets: [], - outlineTags: [], - languages: embeddedPost.langs, - isOwnPost, - }) - } - } - - if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) { - const {external} = embeddedPost.embed - - embedMuted = - embedMuted || - hasMutedWord({ - mutedWords, - text: external.title + ' ' + external.description, - facets: [], - outlineTags: [], - languages: [], - isOwnPost, - }) - } - - if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) { - if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) { - const {external} = embeddedPost.embed.media - - embedMuted = - embedMuted || - hasMutedWord({ - mutedWords, - text: external.title + ' ' + external.description, - facets: [], - outlineTags: [], - languages: [], - isOwnPost, - }) - } - - if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) { - for (const image of embeddedPost.embed.media.images) { - embedMuted = - embedMuted || - hasMutedWord({ - mutedWords, - text: image.alt, - facets: [], - outlineTags: [], - languages: AppBskyFeedPost.isRecord(embeddedPost.record) - ? embeddedPost.langs - : [], - isOwnPost, - }) - } - } - } - } - } - - if (AppBskyEmbedExternal.isView(subject.embed)) { - const {external} = subject.embed - - externalMuted = - externalMuted || - hasMutedWord({ - mutedWords, - text: external.title + ' ' + external.description, - facets: [], - outlineTags: [], - languages: [], - isOwnPost, - }) - } - - if ( - AppBskyEmbedRecordWithMedia.isView(subject.embed) && - AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) - ) { - if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) { - const post = subject.embed.record.record.value - embedMuted = - embedMuted || - hasMutedWord({ - mutedWords, - text: post.text, - facets: post.facets, - outlineTags: post.tags, - languages: post.langs, - isOwnPost, - }) - } - - if (AppBskyEmbedImages.isView(subject.embed.media)) { - for (const image of subject.embed.media.images) { - embedMuted = - embedMuted || - hasMutedWord({ - mutedWords, - text: image.alt, - facets: [], - outlineTags: [], - languages: AppBskyFeedPost.isRecord(subject.record) - ? subject.record.langs - : [], - isOwnPost, - }) - } - } - } - - if (embedHidden) { - moderations.embed.filter = true - moderations.embed.blur = true - if (!moderations.embed.cause) { - moderations.embed.cause = { - // @ts-ignore Temporary extension to the moderation system -prf - type: 'post-hidden', - source: {type: 'user'}, - priority: 1, - } - } - } else if (externalMuted || embedMuted) { - moderations.content.filter = true - moderations.content.blur = true - if (!moderations.content.cause) { - moderations.content.cause = { - // @ts-ignore Temporary extension to the moderation system -prf - type: 'muted-word', - source: {type: 'user'}, - priority: 1, - } - } - } - } - - return moderations + return moderatePost(subject, opts) +} + +function translateOldLabels(subject: Parameters<ModeratePost>[0]) { + if (subject.labels) { + for (const label of subject.labels) { + if ( + label.val === 'gore' && + (!label.src || label.src === BSKY_LABELER_DID) + ) { + label.val = 'graphic-media' + } + } + } } diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index b6ebb47a..4105c2c2 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -1,144 +1,20 @@ -import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api' +import { + ModerationCause, + ModerationUI, + InterpretedLabelValueDefinition, + LABELS, + AppBskyLabelerDefs, + BskyAgent, + ModerationOpts, +} from '@atproto/api' -export interface ModerationCauseDescription { - name: string - description: string -} - -export function describeModerationCause( - cause: ModerationCause | undefined, - context: 'account' | 'content', -): ModerationCauseDescription { - if (!cause) { - return { - name: 'Content Warning', - description: - 'Moderator has chosen to set a general warning on the content.', - } - } - if (cause.type === 'blocking') { - if (cause.source.type === 'list') { - return { - name: `User Blocked by "${cause.source.list.name}"`, - description: - 'You have blocked this user. You cannot view their content.', - } - } else { - return { - name: 'User Blocked', - description: - 'You have blocked this user. You cannot view their content.', - } - } - } - if (cause.type === 'blocked-by') { - return { - name: 'User Blocking You', - description: 'This user has blocked you. You cannot view their content.', - } - } - if (cause.type === 'block-other') { - return { - name: 'Content Not Available', - description: - 'This content is not available because one of the users involved has blocked the other.', - } - } - if (cause.type === 'muted') { - if (cause.source.type === 'list') { - return { - name: - context === 'account' - ? `Muted by "${cause.source.list.name}"` - : `Post by muted user ("${cause.source.list.name}")`, - description: 'You have muted this user', - } - } else { - return { - name: context === 'account' ? 'Muted User' : 'Post by muted user', - description: 'You have muted this user', - } - } - } - // @ts-ignore Temporary extension to the moderation system -prf - if (cause.type === 'post-hidden') { - return { - name: 'Post Hidden by You', - description: 'You have hidden this post', - } - } - // @ts-ignore Temporary extension to the moderation system -prf - if (cause.type === 'muted-word') { - return { - name: 'Post hidden by muted word', - description: `You've chosen to hide a word or tag within this post.`, - } - } - return cause.labelDef.strings[context].en -} - -export function getProfileModerationCauses( - moderation: ProfileModeration, -): ModerationCause[] { - /* - Gather everything on profile and account that blurs or alerts - */ - return [ - moderation.decisions.profile.cause, - ...moderation.decisions.profile.additionalCauses, - moderation.decisions.account.cause, - ...moderation.decisions.account.additionalCauses, - ].filter(cause => { - if (!cause) { - return false - } - if (cause?.type === 'label') { - if ( - cause.labelDef.onwarn === 'blur' || - cause.labelDef.onwarn === 'alert' - ) { - return true - } else { - return false - } - } - return true - }) as ModerationCause[] -} - -export function isPostMediaBlurred( - decisions: PostModeration['decisions'], -): boolean { - return decisions.post.blurMedia -} - -export function isQuoteBlurred( - decisions: PostModeration['decisions'], -): boolean { - return ( - decisions.quote?.blur || - decisions.quote?.blurMedia || - decisions.quote?.filter || - decisions.quotedAccount?.blur || - decisions.quotedAccount?.filter || - false - ) -} - -export function isCauseALabelOnUri( - cause: ModerationCause | undefined, - uri: string, -): boolean { - if (cause?.type !== 'label') { - return false - } - return cause.label.uri === uri -} +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' export function getModerationCauseKey(cause: ModerationCause): string { const source = cause.source.type === 'labeler' - ? cause.source.labeler.did + ? cause.source.did : cause.source.type === 'list' ? cause.source.list.uri : 'user' @@ -147,3 +23,59 @@ export function getModerationCauseKey(cause: ModerationCause): string { } return `${cause.type}:${source}` } + +export function isJustAMute(modui: ModerationUI): boolean { + return modui.filters.length === 1 && modui.filters[0].type === 'muted' +} + +export function getLabelingServiceTitle({ + displayName, + handle, +}: { + displayName?: string + handle: string +}) { + return displayName + ? sanitizeDisplayName(displayName) + : sanitizeHandle(handle, '@') +} + +export function lookupLabelValueDefinition( + labelValue: string, + customDefs: InterpretedLabelValueDefinition[] | undefined, +): InterpretedLabelValueDefinition | undefined { + let def + if (!labelValue.startsWith('!') && customDefs) { + def = customDefs.find(d => d.identifier === labelValue) + } + if (!def) { + def = LABELS[labelValue as keyof typeof LABELS] + } + return def +} + +export function isAppLabeler( + labeler: + | string + | AppBskyLabelerDefs.LabelerView + | AppBskyLabelerDefs.LabelerViewDetailed, +): boolean { + if (typeof labeler === 'string') { + return BskyAgent.appLabelers.includes(labeler) + } + return BskyAgent.appLabelers.includes(labeler.creator.did) +} + +export function isLabelerSubscribed( + labeler: + | string + | AppBskyLabelerDefs.LabelerView + | AppBskyLabelerDefs.LabelerViewDetailed, + modOpts: ModerationOpts, +) { + labeler = typeof labeler === 'string' ? labeler : labeler.creator.did + if (isAppLabeler(labeler)) { + return true + } + return modOpts.prefs.labelers.find(l => l.did === labeler) +} diff --git a/src/lib/moderation/useGlobalLabelStrings.ts b/src/lib/moderation/useGlobalLabelStrings.ts new file mode 100644 index 00000000..1c5a4823 --- /dev/null +++ b/src/lib/moderation/useGlobalLabelStrings.ts @@ -0,0 +1,52 @@ +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMemo} from 'react' + +export type GlobalLabelStrings = Record< + string, + { + name: string + description: string + } +> + +export function useGlobalLabelStrings(): GlobalLabelStrings { + const {_} = useLingui() + return useMemo( + () => ({ + '!hide': { + name: _(msg`Content Blocked`), + description: _(msg`This content has been hidden by the moderators.`), + }, + '!warn': { + name: _(msg`Content Warning`), + description: _( + msg`This content has received a general warning from moderators.`, + ), + }, + '!no-unauthenticated': { + name: _(msg`Sign-in Required`), + description: _( + msg`This user has requested that their content only be shown to signed-in users.`, + ), + }, + porn: { + name: _(msg`Pornography`), + description: _(msg`Explicit sexual images.`), + }, + sexual: { + name: _(msg`Sexually Suggestive`), + description: _(msg`Does not include nudity.`), + }, + nudity: { + name: _(msg`Non-sexual Nudity`), + description: _(msg`E.g. artistic nudes.`), + }, + 'graphic-media': { + name: _(msg`Graphic Media`), + description: _(msg`Explicit or potentially disturbing media.`), + }, + }), + [_], + ) +} diff --git a/src/lib/moderation/useLabelBehaviorDescription.ts b/src/lib/moderation/useLabelBehaviorDescription.ts new file mode 100644 index 00000000..0250c1bc --- /dev/null +++ b/src/lib/moderation/useLabelBehaviorDescription.ts @@ -0,0 +1,70 @@ +import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +export function useLabelBehaviorDescription( + labelValueDef: InterpretedLabelValueDefinition, + pref: LabelPreference, +) { + const {_} = useLingui() + if (pref === 'ignore') { + return _(msg`Off`) + } + if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') { + if (pref === 'hide') { + return _(msg`Hide`) + } + return _(msg`Warn`) + } else if (labelValueDef.severity === 'alert') { + if (pref === 'hide') { + return _(msg`Hide`) + } + return _(msg`Warn`) + } else if (labelValueDef.severity === 'inform') { + if (pref === 'hide') { + return _(msg`Hide`) + } + return _(msg`Show badge`) + } else { + if (pref === 'hide') { + return _(msg`Hide`) + } + return _(msg`Disabled`) + } +} + +export function useLabelLongBehaviorDescription( + labelValueDef: InterpretedLabelValueDefinition, + pref: LabelPreference, +) { + const {_} = useLingui() + if (pref === 'ignore') { + return _(msg`Disabled`) + } + if (labelValueDef.blurs === 'content') { + if (pref === 'hide') { + return _(msg`Warn content and filter from feeds`) + } + return _(msg`Warn content`) + } else if (labelValueDef.blurs === 'media') { + if (pref === 'hide') { + return _(msg`Blur images and filter from feeds`) + } + return _(msg`Blur images`) + } else if (labelValueDef.severity === 'alert') { + if (pref === 'hide') { + return _(msg`Show warning and filter from feeds`) + } + return _(msg`Show warning`) + } else if (labelValueDef.severity === 'inform') { + if (pref === 'hide') { + return _(msg`Show badge and filter from feeds`) + } + return _(msg`Show badge`) + } else { + if (pref === 'hide') { + return _(msg`Filter from feeds`) + } + return _(msg`Disabled`) + } +} diff --git a/src/lib/moderation/useLabelInfo.ts b/src/lib/moderation/useLabelInfo.ts new file mode 100644 index 00000000..b1cffe1e --- /dev/null +++ b/src/lib/moderation/useLabelInfo.ts @@ -0,0 +1,100 @@ +import { + ComAtprotoLabelDefs, + AppBskyLabelerDefs, + LABELS, + interpretLabelValueDefinition, + InterpretedLabelValueDefinition, +} from '@atproto/api' +import {useLingui} from '@lingui/react' +import * as bcp47Match from 'bcp-47-match' + +import { + GlobalLabelStrings, + useGlobalLabelStrings, +} from '#/lib/moderation/useGlobalLabelStrings' +import {useLabelDefinitions} from '#/state/preferences' + +export interface LabelInfo { + label: ComAtprotoLabelDefs.Label + def: InterpretedLabelValueDefinition + strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings + labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined +} + +export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo { + const {i18n} = useLingui() + const {labelDefs, labelers} = useLabelDefinitions() + const globalLabelStrings = useGlobalLabelStrings() + const def = getDefinition(labelDefs, label) + return { + label, + def, + strings: getLabelStrings(i18n.locale, globalLabelStrings, def), + labeler: labelers.find(labeler => label.src === labeler.creator.did), + } +} + +export function getDefinition( + labelDefs: Record<string, InterpretedLabelValueDefinition[]>, + label: ComAtprotoLabelDefs.Label, +): InterpretedLabelValueDefinition { + // check local definitions + const customDef = + !label.val.startsWith('!') && + labelDefs[label.src]?.find( + def => def.identifier === label.val && def.definedBy === label.src, + ) + if (customDef) { + return customDef + } + + // check global definitions + const globalDef = LABELS[label.val as keyof typeof LABELS] + if (globalDef) { + return globalDef + } + + // fallback to a noop definition + return interpretLabelValueDefinition( + { + identifier: label.val, + severity: 'none', + blurs: 'none', + defaultSetting: 'ignore', + locales: [], + }, + label.src, + ) +} + +export function getLabelStrings( + locale: string, + globalLabelStrings: GlobalLabelStrings, + def: InterpretedLabelValueDefinition, +): ComAtprotoLabelDefs.LabelValueDefinitionStrings { + if (!def.definedBy) { + // global definition, look up strings + if (def.identifier in globalLabelStrings) { + return globalLabelStrings[ + def.identifier + ] as ComAtprotoLabelDefs.LabelValueDefinitionStrings + } + } else { + // try to find locale match in the definition's strings + const localeMatch = def.locales.find( + strings => bcp47Match.basicFilter(locale, strings.lang).length > 0, + ) + if (localeMatch) { + return localeMatch + } + // fall back to the zero item if no match + if (def.locales[0]) { + return def.locales[0] + } + } + return { + lang: locale, + name: def.identifier, + description: `Labeled "${def.identifier}"`, + } +} diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts new file mode 100644 index 00000000..46771e95 --- /dev/null +++ b/src/lib/moderation/useModerationCauseDescription.ts @@ -0,0 +1,146 @@ +import React from 'react' +import { + BSKY_LABELER_DID, + ModerationCause, + ModerationCauseSource, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {getDefinition, getLabelStrings} from './useLabelInfo' +import {useLabelDefinitions} from '#/state/preferences' +import {useGlobalLabelStrings} from './useGlobalLabelStrings' + +import {Props as SVGIconProps} from '#/components/icons/common' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' + +export interface ModerationCauseDescription { + icon: React.ComponentType<SVGIconProps> + name: string + description: string + source?: string + sourceType?: ModerationCauseSource['type'] +} + +export function useModerationCauseDescription( + cause: ModerationCause | undefined, +): ModerationCauseDescription { + const {_, i18n} = useLingui() + const {labelDefs, labelers} = useLabelDefinitions() + const globalLabelStrings = useGlobalLabelStrings() + + return React.useMemo(() => { + if (!cause) { + return { + icon: Warning, + name: _(msg`Content Warning`), + description: _( + msg`Moderator has chosen to set a general warning on the content.`, + ), + } + } + if (cause.type === 'blocking') { + if (cause.source.type === 'list') { + return { + icon: CircleBanSign, + name: _(msg`User Blocked by "${cause.source.list.name}"`), + description: _( + msg`You have blocked this user. You cannot view their content.`, + ), + } + } else { + return { + icon: CircleBanSign, + name: _(msg`User Blocked`), + description: _( + msg`You have blocked this user. You cannot view their content.`, + ), + } + } + } + if (cause.type === 'blocked-by') { + return { + icon: CircleBanSign, + name: _(msg`User Blocking You`), + description: _( + msg`This user has blocked you. You cannot view their content.`, + ), + } + } + if (cause.type === 'block-other') { + return { + icon: CircleBanSign, + name: _(msg`Content Not Available`), + description: _( + msg`This content is not available because one of the users involved has blocked the other.`, + ), + } + } + if (cause.type === 'muted') { + if (cause.source.type === 'list') { + return { + icon: EyeSlash, + name: _(msg`Muted by "${cause.source.list.name}"`), + description: _(msg`You have muted this user`), + } + } else { + return { + icon: EyeSlash, + name: _(msg`Account Muted`), + description: _(msg`You have muted this account.`), + } + } + } + if (cause.type === 'mute-word') { + return { + icon: EyeSlash, + name: _(msg`Post Hidden by Muted Word`), + description: _( + msg`You've chosen to hide a word or tag within this post.`, + ), + } + } + if (cause.type === 'hidden') { + return { + icon: EyeSlash, + name: _(msg`Post Hidden by You`), + description: _(msg`You have hidden this post`), + } + } + if (cause.type === 'label') { + const def = cause.labelDef || getDefinition(labelDefs, cause.label) + const strings = getLabelStrings(i18n.locale, globalLabelStrings, def) + const labeler = labelers.find(l => l.creator.did === cause.label.src) + let source = + labeler?.creator.displayName || + (labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined) + if (!source) { + if (cause.label.src === BSKY_LABELER_DID) { + source = 'Bluesky Moderation' + } else { + source = cause.label.src + } + } + return { + icon: + def.identifier === '!no-unauthenticated' + ? EyeSlash + : def.severity === 'alert' + ? Warning + : CircleInfo, + name: strings.name, + description: strings.description, + source, + sourceType: cause.source.type, + } + } + // should never happen + return { + icon: CircleInfo, + name: '', + description: ``, + } + }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale]) +} diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts new file mode 100644 index 00000000..e0017059 --- /dev/null +++ b/src/lib/moderation/useReportOptions.ts @@ -0,0 +1,94 @@ +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMemo} from 'react' +import {ComAtprotoModerationDefs} from '@atproto/api' + +export interface ReportOption { + reason: string + title: string + description: string +} + +interface ReportOptions { + account: ReportOption[] + post: ReportOption[] + list: ReportOption[] + feedgen: ReportOption[] + other: ReportOption[] +} + +export function useReportOptions(): ReportOptions { + const {_} = useLingui() + return useMemo(() => { + const other = { + reason: ComAtprotoModerationDefs.REASONOTHER, + title: _(msg`Other`), + description: _(msg`An issue not included in these options`), + } + const common = [ + { + reason: ComAtprotoModerationDefs.REASONRUDE, + title: _(msg`Anti-Social Behavior`), + description: _(msg`Harassment, trolling, or intolerance`), + }, + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Illegal and Urgent`), + description: _(msg`Glaring violations of law or terms of service`), + }, + other, + ] + return { + account: [ + { + reason: ComAtprotoModerationDefs.REASONMISLEADING, + title: _(msg`Misleading Account`), + description: _( + msg`Impersonation or false claims about identity or affiliation`, + ), + }, + { + reason: ComAtprotoModerationDefs.REASONSPAM, + title: _(msg`Frequently Posts Unwanted Content`), + description: _(msg`Spam; excessive mentions or replies`), + }, + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + other, + ], + post: [ + { + reason: ComAtprotoModerationDefs.REASONSPAM, + title: _(msg`Spam`), + description: _(msg`Excessive mentions or replies`), + }, + { + reason: ComAtprotoModerationDefs.REASONSEXUAL, + title: _(msg`Unwanted Sexual Content`), + description: _(msg`Nudity or pornography not labeled as such`), + }, + ...common, + ], + list: [ + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + ...common, + ], + feedgen: [ + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + ...common, + ], + other: common, + } + }, [_]) +} diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index 7fe3fe7a..d6cd3c54 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -1,7 +1,14 @@ import {AppState, AppStateStatus} from 'react-native' import {QueryClient, focusManager} from '@tanstack/react-query' +import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client' + import {isNative} from '#/platform/detection' +// any query keys in this array will be persisted to AsyncStorage +const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info'] + focusManager.setEventListener(onFocus => { if (isNative) { const subscription = AppState.addEventListener( @@ -48,3 +55,16 @@ export const queryClient = new QueryClient({ }, }, }) + +export const asyncStoragePersister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: 'queryCache', +}) + +export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] = + { + shouldDehydrateMutation: (_: any) => false, + shouldDehydrateQuery: query => { + return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0])) + }, + } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 6756a62a..95af2f23 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -21,7 +21,9 @@ export type CommonNavigatorParams = { PostRepostedBy: {name: string; rkey: string} ProfileFeed: {name: string; rkey: string} ProfileFeedLikedBy: {name: string; rkey: string} + ProfileLabelerLikedBy: {name: string} Debug: undefined + DebugMod: undefined Log: undefined Support: undefined PrivacyPolicy: undefined diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts index 75383dd4..e0f23fa2 100644 --- a/src/lib/strings/display-names.ts +++ b/src/lib/strings/display-names.ts @@ -1,5 +1,4 @@ import {ModerationUI} from '@atproto/api' -import {describeModerationCause} from '../moderation' // \u2705 = ✅ // \u2713 = ✓ @@ -14,7 +13,7 @@ export function sanitizeDisplayName( moderation?: ModerationUI, ): string { if (moderation?.blur) { - return `⚠${describeModerationCause(moderation.cause, 'account').name}` + return '' } if (typeof str === 'string') { return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim() diff --git a/src/lib/themes.ts b/src/lib/themes.ts index bd75aabe..6fada40a 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -9,7 +9,7 @@ export const defaultTheme: Theme = { palette: { default: { background: lightPalette.white, - backgroundLight: lightPalette.contrast_50, + backgroundLight: lightPalette.contrast_25, text: lightPalette.black, textLight: lightPalette.contrast_700, textInverted: lightPalette.white, diff --git a/src/routes.ts b/src/routes.ts index 5c263fd6..f6f37294 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -21,7 +21,9 @@ export const router = new Router({ PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', ProfileFeed: '/profile/:name/feed/:rkey', ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', + ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by', Debug: '/sys/debug', + DebugMod: '/sys/debug-mod', Log: '/sys/log', AppPasswords: '/settings/app-passwords', PreferencesFollowingFeed: '/settings/following-feed', diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx new file mode 100644 index 00000000..26fa9ec7 --- /dev/null +++ b/src/screens/Moderation/index.tsx @@ -0,0 +1,560 @@ +import React from 'react' +import {View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {ComAtprotoLabelDefs} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {LABELS} from '@atproto/api' +import {useSafeAreaFrame} from 'react-native-safe-area-context' + +import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' +import {CenteredView} from '#/view/com/util/Views' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {useAnalytics} from 'lib/analytics/analytics' +import {useSetMinimalShellMode} from '#/state/shell' +import {useSession} from '#/state/session' +import { + useProfileQuery, + useProfileUpdateMutation, +} from '#/state/queries/profile' +import {ScrollView} from '#/view/com/util/Views' + +import { + UsePreferencesQueryResponse, + useMyLabelersQuery, + usePreferencesQuery, + usePreferencesSetAdultContentMutation, +} from '#/state/queries/preferences' + +import {getLabelingServiceTitle} from '#/lib/moderation' +import {logger} from '#/logger' +import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf' +import {Divider} from '#/components/Divider' +import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' +import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' +import {Text} from '#/components/Typography' +import * as Toggle from '#/components/forms/Toggle' +import {InlineLink, Link} from '#/components/Link' +import {Button, ButtonText} from '#/components/Button' +import {Loader} from '#/components/Loader' +import * as LabelingService from '#/components/LabelingServiceCard' +import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {Props as SVGIconProps} from '#/components/icons/common' +import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' +import * as Dialog from '#/components/Dialog' + +function ErrorState({error}: {error: string}) { + const t = useTheme() + return ( + <View style={[a.p_xl]}> + <Text + style={[ + a.text_md, + a.leading_normal, + a.pb_md, + t.atoms.text_contrast_medium, + ]}> + <Trans> + Hmmmm, it seems we're having trouble loading this data. See below for + more details. If this issue persists, please contact us. + </Trans> + </Text> + <View + style={[ + a.relative, + a.py_md, + a.px_lg, + a.rounded_md, + a.mb_2xl, + t.atoms.bg_contrast_25, + ]}> + <Text style={[a.text_md, a.leading_normal]}>{error}</Text> + </View> + </View> + ) +} + +export function ModerationScreen( + _props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>, +) { + const t = useTheme() + const {_} = useLingui() + const { + isLoading: isPreferencesLoading, + error: preferencesError, + data: preferences, + } = usePreferencesQuery() + const {gtMobile} = useBreakpoints() + const {height} = useSafeAreaFrame() + + const isLoading = isPreferencesLoading + const error = preferencesError + + return ( + <CenteredView + testID="moderationScreen" + style={[ + t.atoms.border_contrast_low, + t.atoms.bg, + {minHeight: height}, + ...(gtMobile ? [a.border_l, a.border_r] : []), + ]}> + <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> + + {isLoading ? ( + <View style={[a.w_full, a.align_center, a.pt_2xl]}> + <Loader size="xl" fill={t.atoms.text.color} /> + </View> + ) : error || !preferences ? ( + <ErrorState + error={ + preferencesError?.toString() || + _(msg`Something went wrong, please try again.`) + } + /> + ) : ( + <ModerationScreenInner preferences={preferences} /> + )} + </CenteredView> + ) +} + +function SubItem({ + title, + icon: Icon, + style, +}: ViewStyleProp & { + title: string + icon: React.ComponentType<SVGIconProps> +}) { + const t = useTheme() + return ( + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_between, + a.p_lg, + a.gap_sm, + style, + ]}> + <View style={[a.flex_row, a.align_center, a.gap_md]}> + <Icon size="md" style={[t.atoms.text_contrast_medium]} /> + <Text style={[a.text_sm, a.font_bold]}>{title}</Text> + </View> + <ChevronRight + size="sm" + style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]} + /> + </View> + ) +} + +export function ModerationScreenInner({ + preferences, +}: { + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const t = useTheme() + const setMinimalShellMode = useSetMinimalShellMode() + const {screen} = useAnalytics() + const {gtMobile} = useBreakpoints() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const birthdateDialogControl = Dialog.useDialogControl() + const { + isLoading: isLabelersLoading, + data: labelers, + error: labelersError, + } = useMyLabelersQuery() + + useFocusEffect( + React.useCallback(() => { + screen('Moderation') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) + + const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} = + usePreferencesSetAdultContentMutation() + const adultContentEnabled = !!( + (optimisticAdultContent && optimisticAdultContent.enabled) || + (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled) + ) + const ageNotSet = !preferences.userAge + const isUnderage = (preferences.userAge || 0) < 18 + + const onToggleAdultContentEnabled = React.useCallback( + async (selected: boolean) => { + try { + await setAdultContentPref({ + enabled: selected, + }) + } catch (e: any) { + logger.error(`Failed to set adult content pref`, { + message: e.message, + }) + } + }, + [setAdultContentPref], + ) + + return ( + <View> + <ScrollView + contentContainerStyle={[ + a.border_0, + a.pt_2xl, + a.px_lg, + gtMobile && a.px_2xl, + ]}> + <Text + style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}> + <Trans>Moderation tools</Trans> + </Text> + + <View + style={[ + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + <Button + testID="mutedWordsBtn" + label={_(msg`Open muted words and tags settings`)} + onPress={() => mutedWordsDialogControl.open()}> + {state => ( + <SubItem + title={_(msg`Muted words & tags`)} + icon={Filter} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Button> + <Divider /> + <Link testID="moderationlistsBtn" to="/moderation/modlists"> + {state => ( + <SubItem + title={_(msg`Moderation lists`)} + icon={Group} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Link> + <Divider /> + <Link testID="mutedAccountsBtn" to="/moderation/muted-accounts"> + {state => ( + <SubItem + title={_(msg`Muted accounts`)} + icon={Person} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Link> + <Divider /> + <Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts"> + {state => ( + <SubItem + title={_(msg`Blocked accounts`)} + icon={CircleBanSign} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Link> + </View> + + <Text + style={[ + a.pt_2xl, + a.pb_md, + a.text_md, + a.font_bold, + t.atoms.text_contrast_high, + ]}> + <Trans>Content filters</Trans> + </Text> + + <View style={[a.gap_md]}> + {ageNotSet && ( + <> + <Button + label={_(msg`Confirm your birthdate`)} + size="small" + variant="solid" + color="secondary" + onPress={() => { + birthdateDialogControl.open() + }} + style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}> + <ButtonText> + <Trans>Confirm your age:</Trans> + </ButtonText> + <ButtonText> + <Trans>Set birthdate</Trans> + </ButtonText> + </Button> + + <BirthDateSettingsDialog + control={birthdateDialogControl} + preferences={preferences} + /> + </> + )} + <View + style={[ + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + {!ageNotSet && !isUnderage && ( + <> + <View + style={[ + a.py_lg, + a.px_lg, + a.flex_row, + a.align_center, + a.justify_between, + ]}> + <Text style={[a.font_semibold, t.atoms.text_contrast_high]}> + <Trans>Enable adult content</Trans> + </Text> + <Toggle.Item + label={_(msg`Toggle to enable or disable adult content`)} + name="adultContent" + value={adultContentEnabled} + onChange={onToggleAdultContentEnabled}> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Text style={[t.atoms.text_contrast_medium]}> + {adultContentEnabled ? ( + <Trans>Enabled</Trans> + ) : ( + <Trans>Disabled</Trans> + )} + </Text> + <Toggle.Switch /> + </View> + </Toggle.Item> + </View> + <Divider /> + </> + )} + {!isUnderage && adultContentEnabled && ( + <> + <GlobalModerationLabelPref labelValueDefinition={LABELS.porn} /> + <Divider /> + <GlobalModerationLabelPref + labelValueDefinition={LABELS.sexual} + /> + <Divider /> + <GlobalModerationLabelPref + labelValueDefinition={LABELS['graphic-media']} + /> + <Divider /> + </> + )} + <GlobalModerationLabelPref labelValueDefinition={LABELS.nudity} /> + </View> + </View> + + <Text + style={[ + a.text_md, + a.font_bold, + a.pt_2xl, + a.pb_md, + t.atoms.text_contrast_high, + ]}> + <Trans>Advanced</Trans> + </Text> + + {isLabelersLoading ? ( + <Loader /> + ) : labelersError || !labelers ? ( + <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}> + <Text> + <Trans> + We were unable to load your configured labelers at this time. + </Trans> + </Text> + </View> + ) : ( + <View style={[a.rounded_sm, t.atoms.bg_contrast_25]}> + {labelers.map((labeler, i) => { + return ( + <React.Fragment key={labeler.creator.did}> + {i !== 0 && <Divider />} + <LabelingService.Link labeler={labeler}> + {state => ( + <LabelingService.Outer + style={[ + i === 0 && { + borderTopLeftRadius: a.rounded_sm.borderRadius, + borderTopRightRadius: a.rounded_sm.borderRadius, + }, + i === labelers.length - 1 && { + borderBottomLeftRadius: a.rounded_sm.borderRadius, + borderBottomRightRadius: a.rounded_sm.borderRadius, + }, + (state.hovered || state.pressed) && [ + t.atoms.bg_contrast_50, + ], + ]}> + <LabelingService.Avatar /> + <LabelingService.Content> + <LabelingService.Title + value={getLabelingServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + })} + /> + <LabelingService.Description + value={labeler.creator.description} + handle={labeler.creator.handle} + /> + </LabelingService.Content> + </LabelingService.Outer> + )} + </LabelingService.Link> + </React.Fragment> + ) + })} + </View> + )} + + <Text + style={[ + a.text_md, + a.font_bold, + a.pt_2xl, + a.pb_md, + t.atoms.text_contrast_high, + ]}> + <Trans>Logged-out visibility</Trans> + </Text> + + <PwiOptOut /> + + <View style={{height: 200}} /> + </ScrollView> + </View> + ) +} + +function PwiOptOut() { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + const updateProfile = useProfileUpdateMutation() + + const isOptedOut = + profile?.labels?.some(l => l.val === '!no-unauthenticated') || false + const canToggle = profile && !updateProfile.isPending + + const onToggleOptOut = React.useCallback(() => { + if (!profile) { + return + } + let wasAdded = false + updateProfile.mutate({ + profile, + updates: existing => { + // create labels attr if needed + existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) + ? existing.labels + : { + $type: 'com.atproto.label.defs#selfLabels', + values: [], + } + + // toggle the label + const hasLabel = existing.labels.values.some( + l => l.val === '!no-unauthenticated', + ) + if (hasLabel) { + wasAdded = false + existing.labels.values = existing.labels.values.filter( + l => l.val !== '!no-unauthenticated', + ) + } else { + wasAdded = true + existing.labels.values.push({val: '!no-unauthenticated'}) + } + + // delete if no longer needed + if (existing.labels.values.length === 0) { + delete existing.labels + } + return existing + }, + checkCommitted: res => { + const exists = !!res.data.labels?.some( + l => l.val === '!no-unauthenticated', + ) + return exists === wasAdded + }, + }) + }, [updateProfile, profile]) + + return ( + <View style={[a.pt_sm]}> + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}> + <Toggle.Item + disabled={!canToggle} + value={isOptedOut} + onChange={onToggleOptOut} + name="logged_out_visibility" + label={_( + msg`Discourage apps from showing my account to logged-out users`, + )}> + <Toggle.Switch /> + <Toggle.Label style={[a.text_md]}> + <Trans> + Discourage apps from showing my account to logged-out users + </Trans> + </Toggle.Label> + </Toggle.Item> + + {updateProfile.isPending && <Loader />} + </View> + + <View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}> + <Text style={[a.leading_snug, t.atoms.text_contrast_high]}> + <Trans> + Bluesky will not show your profile and posts to logged-out users. + Other apps may not honor this request. This does not make your + account private. + </Trans> + </Text> + <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> + <Trans> + Note: Bluesky is an open and public network. This setting only + limits the visibility of your content on the Bluesky app and + website, and other apps may not respect this setting. Your content + may still be shown to logged-out users by other apps and websites. + </Trans> + </Text> + + <InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"> + <Trans>Learn more about what is public on Bluesky.</Trans> + </InlineLink> + </View> + </View> + ) +} diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx index 360025c0..0bff5343 100644 --- a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx +++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx @@ -56,7 +56,9 @@ export function AdultContentEnabledPref({ try { mutate({ - enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), + enabled: !( + variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled + ), }) } catch (e) { Toast.show( @@ -75,7 +77,10 @@ export function AdultContentEnabledPref({ <Toggle.Item name={_(msg`Enable adult content in your feeds`)} label={_(msg`Enable adult content in your feeds`)} - value={variables?.enabled ?? preferences?.adultContentEnabled} + value={ + variables?.enabled ?? + preferences?.moderationPrefs.adultContentEnabled + } onChange={onToggleAdultContent}> <View style={[ diff --git a/src/screens/Onboarding/StepModeration/ModerationOption.tsx b/src/screens/Onboarding/StepModeration/ModerationOption.tsx index c61b520b..ac02a874 100644 --- a/src/screens/Onboarding/StepModeration/ModerationOption.tsx +++ b/src/screens/Onboarding/StepModeration/ModerationOption.tsx @@ -1,40 +1,51 @@ import React from 'react' import {View} from 'react-native' -import {LabelPreference} from '@atproto/api' +import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api' import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' -import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' import { - CONFIGURABLE_LABEL_GROUPS, - ConfigurableLabelGroup, usePreferencesQuery, usePreferencesSetContentLabelMutation, } from '#/state/queries/preferences' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' import * as ToggleButton from '#/components/forms/ToggleButton' +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' export function ModerationOption({ - labelGroup, - isMounted, + labelValueDefinition, + disabled, }: { - labelGroup: ConfigurableLabelGroup - isMounted: React.MutableRefObject<boolean> + labelValueDefinition: InterpretedLabelValueDefinition + disabled?: boolean }) { const {_} = useLingui() const t = useTheme() - const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup] const {data: preferences} = usePreferencesQuery() const {mutate, variables} = usePreferencesSetContentLabelMutation() + const label = labelValueDefinition.identifier const visibility = - variables?.visibility ?? preferences?.contentLabels?.[labelGroup] + variables?.visibility ?? preferences?.moderationPrefs.labels?.[label] + + const allLabelStrings = useGlobalLabelStrings() + const labelStrings = + labelValueDefinition.identifier in allLabelStrings + ? allLabelStrings[labelValueDefinition.identifier] + : { + name: labelValueDefinition.identifier, + description: `Labeled "${labelValueDefinition.identifier}"`, + } const onChange = React.useCallback( (vis: string[]) => { - mutate({labelGroup, visibility: vis[0] as LabelPreference}) + mutate({ + label, + visibility: vis[0] as LabelPreference, + labelerDid: undefined, + }) }, - [mutate, labelGroup], + [mutate, label], ) const labels = { @@ -44,7 +55,7 @@ export function ModerationOption({ } return ( - <Animated.View + <View style={[ a.flex_row, a.justify_between, @@ -52,33 +63,37 @@ export function ModerationOption({ a.py_xs, a.px_xs, a.align_center, - ]} - layout={Layout.easing(Easing.ease).duration(200)} - entering={isMounted.current ? FadeIn : undefined}> - <View style={[a.gap_xs, {width: '50%'}]}> - <Text style={[a.font_bold]}>{groupInfo.title}</Text> + ]}> + <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]}> - {groupInfo.subtitle} + {labelStrings.description} </Text> </View> - <View style={[a.justify_center, {minHeight: 35}]}> - <ToggleButton.Group - label={_( - msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`, - )} - values={[visibility ?? 'hide']} - onChange={onChange}> - <ToggleButton.Button name="hide" label={labels.hide}> - {labels.hide} - </ToggleButton.Button> - <ToggleButton.Button name="warn" label={labels.warn}> - {labels.warn} - </ToggleButton.Button> - <ToggleButton.Button name="ignore" label={labels.show}> - {labels.show} - </ToggleButton.Button> - </ToggleButton.Group> + <View style={[a.justify_center, {minHeight: 40}]}> + {disabled ? ( + <Text style={[a.font_bold]}> + <Trans>Hide</Trans> + </Text> + ) : ( + <ToggleButton.Group + label={_( + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, + )} + values={[visibility ?? 'hide']} + onChange={onChange}> + <ToggleButton.Button name="ignore" label={labels.show}> + {labels.show} + </ToggleButton.Button> + <ToggleButton.Button name="warn" label={labels.warn}> + {labels.warn} + </ToggleButton.Button> + <ToggleButton.Button name="hide" label={labels.hide}> + {labels.hide} + </ToggleButton.Button> + </ToggleButton.Group> + )} </View> - </Animated.View> + </View> ) } diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx index 543a5b15..9b52f9f4 100644 --- a/src/screens/Onboarding/StepModeration/index.tsx +++ b/src/screens/Onboarding/StepModeration/index.tsx @@ -2,15 +2,10 @@ import React from 'react' import {View} from 'react-native' import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' -import Animated, {Easing, Layout} from 'react-native-reanimated' +import {LABELS} from '@atproto/api' import {atoms as a} from '#/alf' -import { - configurableAdultLabelGroups, - configurableOtherLabelGroups, - usePreferencesSetAdultContentMutation, -} from 'state/queries/preferences' -import {Divider} from '#/components/Divider' +import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' @@ -28,14 +23,6 @@ import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/Adult import {Context} from '#/screens/Onboarding/state' import {IconCircle} from '#/components/IconCircle' -function AnimatedDivider() { - return ( - <Animated.View layout={Layout.easing(Easing.ease).duration(200)}> - <Divider /> - </Animated.View> - ) -} - export function StepModeration() { const {_} = useLingui() const {track} = useAnalytics() @@ -52,7 +39,7 @@ export function StepModeration() { const adultContentEnabled = !!( (variables && variables.enabled) || - (!variables && preferences?.adultContentEnabled) + (!variables && preferences?.moderationPrefs.adultContentEnabled) ) const onContinue = React.useCallback(() => { @@ -86,22 +73,19 @@ export function StepModeration() { <AdultContentEnabledPref mutate={mutate} variables={variables} /> <View style={[a.gap_sm, a.w_full]}> - {adultContentEnabled && - configurableAdultLabelGroups.map((g, index) => ( - <React.Fragment key={index}> - {index === 0 && <AnimatedDivider />} - <ModerationOption labelGroup={g} isMounted={isMounted} /> - <AnimatedDivider /> - </React.Fragment> - ))} - - {configurableOtherLabelGroups.map((g, index) => ( - <React.Fragment key={index}> - {!adultContentEnabled && index === 0 && <AnimatedDivider />} - <ModerationOption labelGroup={g} isMounted={isMounted} /> - <AnimatedDivider /> - </React.Fragment> - ))} + <ModerationOption + labelValueDefinition={LABELS.porn} + disabled={!adultContentEnabled} + /> + <ModerationOption + labelValueDefinition={LABELS.sexual} + disabled={!adultContentEnabled} + /> + <ModerationOption + labelValueDefinition={LABELS['graphic-media']} + disabled={!adultContentEnabled} + /> + <ModerationOption labelValueDefinition={LABELS.nudity} /> </View> </> )} diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx index 06700589..7e4ea1f8 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx @@ -88,7 +88,7 @@ export function SuggestedAccountCard({ <UserAvatar size={48} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={[a.flex_1]}> diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index 14faddc1..bdf94d82 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -76,7 +76,7 @@ export function StepSuggestedAccounts() { return aggregateInterestItems( state.interestsStepResults.selectedInterests, state.interestsStepResults.apiResponse.suggestedAccountDids, - state.interestsStepResults.apiResponse.suggestedAccountDids.default, + state.interestsStepResults.apiResponse.suggestedAccountDids.default || [], ) }, [state.interestsStepResults]) const moderationOpts = useModerationOpts() diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx index 636565e3..089363c2 100644 --- a/src/screens/Onboarding/StepTopicalFeeds.tsx +++ b/src/screens/Onboarding/StepTopicalFeeds.tsx @@ -21,7 +21,7 @@ import { import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' import {aggregateInterestItems} from '#/screens/Onboarding/util' import {IconCircle} from '#/components/IconCircle' -import {IS_PROD_SERVICE} from 'lib/constants' +import {IS_TEST_USER} from 'lib/constants' import {useSession} from 'state/session' export function StepTopicalFeeds() { @@ -32,14 +32,14 @@ export function StepTopicalFeeds() { const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([]) const [saving, setSaving] = React.useState(false) const suggestedFeedUris = React.useMemo(() => { - if (!IS_PROD_SERVICE(currentAccount?.service)) return [] + if (IS_TEST_USER(currentAccount?.handle)) return [] return aggregateInterestItems( state.interestsStepResults.selectedInterests, state.interestsStepResults.apiResponse.suggestedFeedUris, - state.interestsStepResults.apiResponse.suggestedFeedUris.default, + state.interestsStepResults.apiResponse.suggestedFeedUris.default || [], ).slice(0, 10) }, [ - currentAccount?.service, + currentAccount?.handle, state.interestsStepResults.apiResponse.suggestedFeedUris, state.interestsStepResults.selectedInterests, ]) diff --git a/src/screens/Profile/ErrorState.tsx b/src/screens/Profile/ErrorState.tsx new file mode 100644 index 00000000..2ec2cf59 --- /dev/null +++ b/src/screens/Profile/ErrorState.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import {View} from 'react-native' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import {Button, ButtonText} from '#/components/Button' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {NavigationProp} from '#/lib/routes/types' + +export function ErrorState({error}: {error: string}) { + const t = useTheme() + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + return ( + <View style={[a.px_xl]}> + <CircleInfo width={48} style={[t.atoms.text_contrast_low]} /> + + <Text style={[a.text_xl, a.font_bold, a.pb_md, a.pt_xl]}> + <Trans>Hmmmm, we couldn't load that moderation service.</Trans> + </Text> + <Text + style={[ + a.text_md, + a.leading_normal, + a.pb_md, + t.atoms.text_contrast_medium, + ]}> + <Trans> + This moderation service is unavailable. See below for more details. If + this issue persists, contact us. + </Trans> + </Text> + <View + style={[ + a.relative, + a.py_md, + a.px_lg, + a.rounded_md, + a.mb_2xl, + t.atoms.bg_contrast_25, + ]}> + <Text style={[a.text_md, a.leading_normal]}>{error}</Text> + </View> + + <View style={{flexDirection: 'row'}}> + <Button + size="small" + color="secondary" + variant="solid" + label={_(msg`Go Back`)} + accessibilityHint="Return to previous page" + onPress={onPressBack}> + <ButtonText> + <Trans>Go Back</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx new file mode 100644 index 00000000..b6d88db7 --- /dev/null +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' +import {sanitizeHandle} from 'lib/strings/handles' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {Shadow} from '#/state/cache/types' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function ProfileHeaderDisplayName({ + profile, + moderation, +}: { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> + moderation: ModerationDecision +}) { + const t = useTheme() + return ( + <View pointerEvents="none"> + <Text + testID="profileHeaderDisplayName" + style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + </Text> + </View> + ) +} diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx new file mode 100644 index 00000000..fd1cbe53 --- /dev/null +++ b/src/screens/Profile/Header/Handle.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {isInvalidHandle} from 'lib/strings/handles' +import {Shadow} from '#/state/cache/types' +import {Trans} from '@lingui/macro' + +import {atoms as a, useTheme, web} from '#/alf' +import {Text} from '#/components/Typography' + +export function ProfileHeaderHandle({ + profile, +}: { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> +}) { + const t = useTheme() + const invalidHandle = isInvalidHandle(profile.handle) + const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy + return ( + <View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none"> + {profile.viewer?.followedBy && !blockHide ? ( + <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}> + <Text style={[t.atoms.text, a.text_sm]}> + <Trans>Follows you</Trans> + </Text> + </View> + ) : undefined} + <Text + style={[ + invalidHandle + ? [ + a.border, + a.text_xs, + a.px_sm, + a.py_xs, + a.rounded_xs, + {borderColor: t.palette.contrast_200}, + ] + : [a.text_md, t.atoms.text_contrast_medium], + web({wordBreak: 'break-all'}), + ]}> + {invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`} + </Text> + </View> + ) +} diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx new file mode 100644 index 00000000..d9a8a01a --- /dev/null +++ b/src/screens/Profile/Header/Metrics.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {Shadow} from '#/state/cache/types' +import {pluralize} from '#/lib/strings/helpers' +import {makeProfileLink} from 'lib/routes/links' +import {formatCount} from 'view/com/util/numeric/format' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {InlineLink} from '#/components/Link' + +export function ProfileHeaderMetrics({ + profile, +}: { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> +}) { + const t = useTheme() + const {_} = useLingui() + const following = formatCount(profile.followsCount || 0) + const followers = formatCount(profile.followersCount || 0) + const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') + + return ( + <View + style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]} + pointerEvents="box-none"> + <InlineLink + testID="profileHeaderFollowersButton" + style={[a.flex_row, t.atoms.text]} + to={makeProfileLink(profile, 'followers')} + label={`${followers} ${pluralizedFollowers}`}> + <Text style={[a.font_bold, a.text_md]}>{followers} </Text> + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> + {pluralizedFollowers} + </Text> + </InlineLink> + <InlineLink + testID="profileHeaderFollowsButton" + style={[a.flex_row, t.atoms.text]} + to={makeProfileLink(profile, 'follows')} + label={_(msg`${following} following`)}> + <Trans> + <Text style={[a.font_bold, a.text_md]}>{following} </Text> + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> + following + </Text> + </Trans> + </InlineLink> + <Text style={[a.font_bold, t.atoms.text, a.text_md]}> + {formatCount(profile.postsCount || 0)}{' '} + <Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}> + {pluralize(profile.postsCount || 0, 'post')} + </Text> + </Text> + </View> + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx new file mode 100644 index 00000000..6722ed09 --- /dev/null +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -0,0 +1,329 @@ +import React, {memo, useMemo} from 'react' +import {View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyLabelerDefs, + ModerationOpts, + moderateProfile, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {RichText} from '#/components/RichText' +import {useModalControls} from '#/state/modals' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useAnalytics} from 'lib/analytics/analytics' +import {useSession} from '#/state/session' +import {Shadow} from '#/state/cache/types' +import {useProfileShadow} from 'state/cache/profile-shadow' +import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' +import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' +import {logger} from '#/logger' +import {Haptics} from '#/lib/haptics' +import {pluralize} from '#/lib/strings/helpers' +import {isAppLabeler} from '#/lib/moderation' + +import {atoms as a, useTheme, tokens} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import * as Toast from '#/view/com/util/Toast' +import {ProfileHeaderShell} from './Shell' +import {ProfileMenu} from '#/view/com/profile/ProfileMenu' +import {ProfileHeaderDisplayName} from './DisplayName' +import {ProfileHeaderHandle} from './Handle' +import {ProfileHeaderMetrics} from './Metrics' +import { + Heart2_Stroke2_Corner0_Rounded as Heart, + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, +} from '#/components/icons/Heart2' +import {DialogOuterProps} from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' +import {Link} from '#/components/Link' + +interface Props { + profile: AppBskyActorDefs.ProfileViewDetailed + labeler: AppBskyLabelerDefs.LabelerViewDetailed + descriptionRT: RichTextAPI | null + moderationOpts: ModerationOpts + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeaderLabeler = ({ + profile: profileUnshadowed, + labeler, + descriptionRT, + moderationOpts, + hideBackButton = false, + isPlaceholderProfile, +}: Props): React.ReactNode => { + const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = + useProfileShadow(profileUnshadowed) + const t = useTheme() + const {_} = useLingui() + const {currentAccount, hasSession} = useSession() + const {openModal} = useModalControls() + const {track} = useAnalytics() + const cantSubscribePrompt = Prompt.usePromptControl() + const isSelf = currentAccount?.did === profile.did + + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) + const {data: preferences} = usePreferencesQuery() + const {mutateAsync: toggleSubscription, variables} = + useLabelerSubscriptionMutation() + const isSubscribed = + variables?.subscribe ?? + preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) + const canSubscribe = + isSubscribed || + (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false) + const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeMod, isPending: isUnlikePending} = + useUnlikeMutation() + const [likeUri, setLikeUri] = React.useState<string>( + labeler.viewer?.like || '', + ) + const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0) + + const onToggleLiked = React.useCallback(async () => { + if (!labeler) { + return + } + try { + Haptics.default() + + if (likeUri) { + await unlikeMod({uri: likeUri}) + track('CustomFeed:Unlike') + setLikeCount(c => c - 1) + setLikeUri('') + } else { + const res = await likeMod({uri: labeler.uri, cid: labeler.cid}) + track('CustomFeed:Like') + setLikeCount(c => c + 1) + setLikeUri(res.uri) + } + } catch (e: any) { + Toast.show( + _( + msg`There was an an issue contacting the server, please check your internet connection and try again.`, + ), + ) + logger.error(`Failed to toggle labeler like`, {message: e.message}) + } + }, [labeler, likeUri, likeMod, unlikeMod, track, _]) + + const onPressEditProfile = React.useCallback(() => { + track('ProfileHeader:EditProfileButtonClicked') + openModal({ + name: 'edit-profile', + profile, + }) + }, [track, openModal, profile]) + + const onPressSubscribe = React.useCallback(async () => { + if (!canSubscribe) { + cantSubscribePrompt.open() + return + } + try { + await toggleSubscription({ + did: profile.did, + subscribe: !isSubscribed, + }) + } catch (e: any) { + // setSubscriptionError(e.message) + logger.error(`Failed to subscribe to labeler`, {message: e.message}) + } + }, [ + toggleSubscription, + isSubscribed, + profile, + canSubscribe, + cantSubscribePrompt, + ]) + + const isMe = React.useMemo( + () => currentAccount?.did === profile.did, + [currentAccount, profile], + ) + + return ( + <ProfileHeaderShell + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + isPlaceholderProfile={isPlaceholderProfile}> + <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none"> + <View + style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]} + pointerEvents="box-none"> + {isMe ? ( + <Button + testID="profileHeaderEditProfileButton" + size="small" + color="secondary" + variant="solid" + onPress={onPressEditProfile} + label={_(msg`Edit profile`)} + style={a.rounded_full}> + <ButtonText> + <Trans>Edit Profile</Trans> + </ButtonText> + </Button> + ) : !isAppLabeler(profile.did) ? ( + <> + <Button + testID="toggleSubscribeBtn" + label={ + isSubscribed + ? _(msg`Unsubscribe from this labeler`) + : _(msg`Subscribe to this labeler`) + } + disabled={!hasSession} + onPress={onPressSubscribe}> + {state => ( + <View + style={[ + { + paddingVertical: 12, + backgroundColor: + isSubscribed || !canSubscribe + ? state.hovered || state.pressed + ? t.palette.contrast_50 + : t.palette.contrast_25 + : state.hovered || state.pressed + ? tokens.color.temp_purple_dark + : tokens.color.temp_purple, + }, + a.px_lg, + a.rounded_sm, + a.gap_sm, + ]}> + <Text + style={[ + { + color: canSubscribe + ? isSubscribed + ? t.palette.contrast_700 + : t.palette.white + : t.palette.contrast_400, + }, + a.font_bold, + a.text_center, + ]}> + {isSubscribed ? ( + <Trans>Unsubscribe</Trans> + ) : ( + <Trans>Subscribe to Labeler</Trans> + )} + </Text> + </View> + )} + </Button> + </> + ) : null} + <ProfileMenu profile={profile} /> + </View> + <View style={[a.flex_col, a.gap_xs, a.pb_md]}> + <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> + <ProfileHeaderHandle profile={profile} /> + </View> + {!isPlaceholderProfile && ( + <> + {isSelf && <ProfileHeaderMetrics profile={profile} />} + {descriptionRT && !moderation.ui('profileView').blur ? ( + <View pointerEvents="auto"> + <RichText + testID="profileHeaderDescription" + style={[a.text_md]} + numberOfLines={15} + value={descriptionRT} + /> + </View> + ) : undefined} + {!isAppLabeler(profile.did) && ( + <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}> + <Button + testID="toggleLikeBtn" + size="small" + color="secondary" + variant="solid" + shape="round" + label={_(msg`Like this feed`)} + disabled={!hasSession || isLikePending || isUnlikePending} + onPress={onToggleLiked}> + {likeUri ? ( + <HeartFilled fill={t.palette.negative_400} /> + ) : ( + <Heart fill={t.atoms.text_contrast_medium.color} /> + )} + </Button> + + {typeof likeCount === 'number' && ( + <Link + to={{ + screen: 'ProfileLabelerLikedBy', + params: { + name: labeler.creator.handle || labeler.creator.did, + }, + }} + size="tiny" + label={_( + msg`Liked by ${likeCount} ${pluralize( + likeCount, + 'user', + )}`, + )}> + {({hovered, focused, pressed}) => ( + <Text + style={[ + a.font_bold, + a.text_sm, + t.atoms.text_contrast_medium, + (hovered || focused || pressed) && + t.atoms.text_contrast_high, + ]}> + <Trans> + Liked by {likeCount} {pluralize(likeCount, 'user')} + </Trans> + </Text> + )} + </Link> + )} + </View> + )} + </> + )} + </View> + <CantSubscribePrompt control={cantSubscribePrompt} /> + </ProfileHeaderShell> + ) +} +ProfileHeaderLabeler = memo(ProfileHeaderLabeler) +export {ProfileHeaderLabeler} + +function CantSubscribePrompt({ + control, +}: { + control: DialogOuterProps['control'] +}) { + return ( + <Prompt.Outer control={control}> + <Prompt.Title>Unable to subscribe</Prompt.Title> + <Prompt.Description> + <Trans> + We're sorry! You can only subscribe to ten labelers, and you've + reached your limit of ten. + </Trans> + </Prompt.Description> + <Prompt.Actions> + <Prompt.Action onPress={control.close}>OK</Prompt.Action> + </Prompt.Actions> + </Prompt.Outer> + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx new file mode 100644 index 00000000..8b903824 --- /dev/null +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -0,0 +1,286 @@ +import React, {memo, useMemo} from 'react' +import {View} from 'react-native' +import { + AppBskyActorDefs, + ModerationOpts, + moderateProfile, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' + +import {useModalControls} from '#/state/modals' +import {useAnalytics} from 'lib/analytics/analytics' +import {useSession, useRequireAuth} from '#/state/session' +import {Shadow} from '#/state/cache/types' +import {useProfileShadow} from 'state/cache/profile-shadow' +import { + useProfileFollowMutationQueue, + useProfileBlockMutationQueue, +} from '#/state/queries/profile' +import {logger} from '#/logger' +import {sanitizeDisplayName} from 'lib/strings/display-names' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText, ButtonIcon} from '#/components/Button' +import * as Toast from '#/view/com/util/Toast' +import {ProfileHeaderShell} from './Shell' +import {ProfileMenu} from '#/view/com/profile/ProfileMenu' +import {ProfileHeaderDisplayName} from './DisplayName' +import {ProfileHeaderHandle} from './Handle' +import {ProfileHeaderMetrics} from './Metrics' +import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows' +import {RichText} from '#/components/RichText' +import * as Prompt from '#/components/Prompt' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' + +interface Props { + profile: AppBskyActorDefs.ProfileViewDetailed + descriptionRT: RichTextAPI | null + moderationOpts: ModerationOpts + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeaderStandard = ({ + profile: profileUnshadowed, + descriptionRT, + moderationOpts, + hideBackButton = false, + isPlaceholderProfile, +}: Props): React.ReactNode => { + const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = + useProfileShadow(profileUnshadowed) + const t = useTheme() + const {currentAccount, hasSession} = useSession() + const {_} = useLingui() + const {openModal} = useModalControls() + const {track} = useAnalytics() + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) + const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'ProfileHeader', + ) + const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + const unblockPromptControl = Prompt.usePromptControl() + const requireAuth = useRequireAuth() + + const onPressEditProfile = React.useCallback(() => { + track('ProfileHeader:EditProfileButtonClicked') + openModal({ + name: 'edit-profile', + profile, + }) + }, [track, openModal, profile]) + + const onPressFollow = () => { + requireAuth(async () => { + try { + track('ProfileHeader:FollowButtonClicked') + await queueFollow() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + )}`, + ), + ) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }) + } + + const onPressUnfollow = () => { + requireAuth(async () => { + try { + track('ProfileHeader:UnfollowButtonClicked') + await queueUnfollow() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + )}`, + ), + ) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }) + } + + const unblockAccount = React.useCallback(async () => { + track('ProfileHeader:UnblockAccountButtonClicked') + try { + await queueUnblock() + Toast.show(_(msg`Account unblocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }, [_, queueUnblock, track]) + + const isMe = React.useMemo( + () => currentAccount?.did === profile.did, + [currentAccount, profile], + ) + + return ( + <ProfileHeaderShell + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + isPlaceholderProfile={isPlaceholderProfile}> + <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none"> + <View + style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]} + pointerEvents="box-none"> + {isMe ? ( + <Button + testID="profileHeaderEditProfileButton" + size="small" + color="secondary" + variant="solid" + onPress={onPressEditProfile} + label={_(msg`Edit profile`)} + style={a.rounded_full}> + <ButtonText> + <Trans>Edit Profile</Trans> + </ButtonText> + </Button> + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( + <Button + testID="unblockBtn" + size="small" + color="secondary" + variant="solid" + label={_(msg`Unblock`)} + disabled={!hasSession} + onPress={() => unblockPromptControl.open()} + style={a.rounded_full}> + <ButtonText> + <Trans context="action">Unblock</Trans> + </ButtonText> + </Button> + ) + ) : !profile.viewer?.blockedBy ? ( + <> + {hasSession && ( + <Button + testID="suggestedFollowsBtn" + size="small" + color={showSuggestedFollows ? 'primary' : 'secondary'} + variant="solid" + shape="round" + onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} + label={_(msg`Show follows similar to ${profile.handle}`)}> + <FontAwesomeIcon + icon="user-plus" + style={ + showSuggestedFollows + ? {color: t.palette.white} + : t.atoms.text + } + size={14} + /> + </Button> + )} + + <Button + testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} + size="small" + color={profile.viewer?.following ? 'secondary' : 'primary'} + variant="solid" + label={ + profile.viewer?.following + ? _(msg`Unfollow ${profile.handle}`) + : _(msg`Follow ${profile.handle}`) + } + disabled={!hasSession} + onPress={ + profile.viewer?.following ? onPressUnfollow : onPressFollow + } + style={[a.rounded_full, a.gap_xs]}> + <ButtonIcon + position="left" + icon={profile.viewer?.following ? Check : Plus} + /> + <ButtonText> + {profile.viewer?.following ? ( + <Trans>Following</Trans> + ) : ( + <Trans>Follow</Trans> + )} + </ButtonText> + </Button> + </> + ) : null} + <ProfileMenu profile={profile} /> + </View> + <View style={[a.flex_col, a.gap_xs, a.pb_sm]}> + <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> + <ProfileHeaderHandle profile={profile} /> + </View> + {!isPlaceholderProfile && ( + <> + <ProfileHeaderMetrics profile={profile} /> + {descriptionRT && !moderation.ui('profileView').blur ? ( + <View pointerEvents="auto"> + <RichText + testID="profileHeaderDescription" + style={[a.text_md]} + numberOfLines={15} + value={descriptionRT} + /> + </View> + ) : undefined} + </> + )} + </View> + {showSuggestedFollows && ( + <ProfileHeaderSuggestedFollows + actorDid={profile.did} + requestDismiss={() => { + if (showSuggestedFollows) { + setShowSuggestedFollows(false) + } else { + track('ProfileHeader:SuggestedFollowsOpened') + setShowSuggestedFollows(true) + } + }} + /> + )} + <Prompt.Basic + control={unblockPromptControl} + title={_(msg`Unblock Account?`)} + description={_( + msg`The account will be able to interact with you after unblocking.`, + )} + onConfirm={unblockAccount} + confirmButtonCta={ + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) + } + confirmButtonColor="negative" + /> + </ProfileHeaderShell> + ) +} +ProfileHeaderStandard = memo(ProfileHeaderStandard) +export {ProfileHeaderStandard} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx new file mode 100644 index 00000000..1348b394 --- /dev/null +++ b/src/screens/Profile/Header/Shell.tsx @@ -0,0 +1,164 @@ +import React, {memo} from 'react' +import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' +import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NavigationProp} from 'lib/routes/types' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {BACK_HITSLOP} from 'lib/constants' +import {useSession} from '#/state/session' +import {Shadow} from '#/state/cache/types' +import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' + +import {atoms as a, useTheme} from '#/alf' +import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' +import {BlurView} from 'view/com/util/BlurView' +import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {UserBanner} from 'view/com/util/UserBanner' +import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' + +interface Props { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> + moderation: ModerationDecision + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeaderShell = ({ + children, + profile, + moderation, + hideBackButton = false, + isPlaceholderProfile, +}: React.PropsWithChildren<Props>): React.ReactNode => { + const t = useTheme() + const {currentAccount} = useSession() + const {_} = useLingui() + const {openLightbox} = useLightboxControls() + const navigation = useNavigation<NavigationProp>() + const {isDesktop} = useWebMediaQueries() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + const onPressAvi = React.useCallback(() => { + const modui = moderation.ui('avatar') + if (profile.avatar && !(modui.blur && modui.noOverride)) { + openLightbox(new ProfileImageLightbox(profile)) + } + }, [openLightbox, profile, moderation]) + + const isMe = React.useMemo( + () => currentAccount?.did === profile.did, + [currentAccount, profile], + ) + + return ( + <View style={t.atoms.bg} pointerEvents="box-none"> + <View pointerEvents="none"> + {isPlaceholderProfile ? ( + <LoadingPlaceholder + width="100%" + height={150} + style={{borderRadius: 0}} + /> + ) : ( + <UserBanner + type={profile.associated?.labeler ? 'labeler' : 'default'} + banner={profile.banner} + moderation={moderation.ui('banner')} + /> + )} + </View> + + {children} + + <View style={[a.px_lg, a.pb_sm]} pointerEvents="box-none"> + <ProfileHeaderAlerts moderation={moderation} /> + {isMe && ( + <LabelsOnMe details={{did: profile.did}} labels={profile.labels} /> + )} + </View> + + {!isDesktop && !hideBackButton && ( + <TouchableWithoutFeedback + testID="profileHeaderBackBtn" + onPress={onPressBack} + hitSlop={BACK_HITSLOP} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint=""> + <View style={styles.backBtnWrapper}> + <BlurView style={styles.backBtn} blurType="dark"> + <FontAwesomeIcon size={18} icon="angle-left" color="white" /> + </BlurView> + </View> + </TouchableWithoutFeedback> + )} + <TouchableWithoutFeedback + testID="profileHeaderAviButton" + onPress={onPressAvi} + accessibilityRole="image" + accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)} + accessibilityHint=""> + <View + style={[ + t.atoms.bg, + {borderColor: t.atoms.bg.backgroundColor}, + styles.avi, + profile.associated?.labeler && styles.aviLabeler, + ]}> + <UserAvatar + type={profile.associated?.labeler ? 'labeler' : 'user'} + size={90} + avatar={profile.avatar} + moderation={moderation.ui('avatar')} + /> + </View> + </TouchableWithoutFeedback> + </View> + ) +} +ProfileHeaderShell = memo(ProfileHeaderShell) +export {ProfileHeaderShell} + +const styles = StyleSheet.create({ + backBtnWrapper: { + position: 'absolute', + top: 10, + left: 10, + width: 30, + height: 30, + overflow: 'hidden', + borderRadius: 15, + // @ts-ignore web only + cursor: 'pointer', + }, + backBtn: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + avi: { + position: 'absolute', + top: 110, + left: 10, + width: 94, + height: 94, + borderRadius: 47, + borderWidth: 2, + }, + aviLabeler: { + borderRadius: 12, + }, +}) diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx new file mode 100644 index 00000000..1280dd8b --- /dev/null +++ b/src/screens/Profile/Header/index.tsx @@ -0,0 +1,78 @@ +import React, {memo} from 'react' +import {StyleSheet, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyLabelerDefs, + ModerationOpts, + RichText as RichTextAPI, +} from '@atproto/api' +import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {usePalette} from 'lib/hooks/usePalette' + +import {ProfileHeaderStandard} from './ProfileHeaderStandard' +import {ProfileHeaderLabeler} from './ProfileHeaderLabeler' + +let ProfileHeaderLoading = (_props: {}): React.ReactNode => { + const pal = usePalette('default') + return ( + <View style={pal.view}> + <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} /> + <View + style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> + <LoadingPlaceholder width={80} height={80} style={styles.br40} /> + </View> + <View style={styles.content}> + <View style={[styles.buttonsLine]}> + <LoadingPlaceholder width={167} height={31} style={styles.br50} /> + </View> + </View> + </View> + ) +} +ProfileHeaderLoading = memo(ProfileHeaderLoading) +export {ProfileHeaderLoading} + +interface Props { + profile: AppBskyActorDefs.ProfileViewDetailed + labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined + descriptionRT: RichTextAPI | null + moderationOpts: ModerationOpts + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeader = (props: Props): React.ReactNode => { + if (props.profile.associated?.labeler) { + if (!props.labeler) { + return <ProfileHeaderLoading /> + } + return <ProfileHeaderLabeler {...props} labeler={props.labeler} /> + } + return <ProfileHeaderStandard {...props} /> +} +ProfileHeader = memo(ProfileHeader) +export {ProfileHeader} + +const styles = StyleSheet.create({ + avi: { + position: 'absolute', + top: 110, + left: 10, + width: 84, + height: 84, + borderRadius: 42, + borderWidth: 2, + }, + content: { + paddingTop: 8, + paddingHorizontal: 14, + paddingBottom: 4, + }, + buttonsLine: { + flexDirection: 'row', + marginLeft: 'auto', + marginBottom: 12, + }, + br40: {borderRadius: 40}, + br50: {borderRadius: 50}, +}) diff --git a/src/screens/Profile/ProfileLabelerLikedBy.tsx b/src/screens/Profile/ProfileLabelerLikedBy.tsx new file mode 100644 index 00000000..1d216752 --- /dev/null +++ b/src/screens/Profile/ProfileLabelerLikedBy.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {LikedByList} from '#/components/LikedByList' +import {useSetMinimalShellMode} from '#/state/shell' +import {makeRecordUri} from '#/lib/strings/url-helpers' + +import {atoms as a, useBreakpoints} from '#/alf' + +export function ProfileLabelerLikedByScreen({ + route, +}: NativeStackScreenProps<CommonNavigatorParams, 'ProfileLabelerLikedBy'>) { + const setMinimalShellMode = useSetMinimalShellMode() + const {name: handleOrDid} = route.params + const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self') + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + return ( + <View + style={[ + a.mx_auto, + a.w_full, + a.h_full_vh, + gtMobile && [ + { + maxWidth: 600, + }, + ], + ]}> + <ViewHeader title={_(msg`Liked By`)} /> + <LikedByList uri={uri} /> + </View> + ) +} diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx new file mode 100644 index 00000000..0a5e2208 --- /dev/null +++ b/src/screens/Profile/Sections/Feed.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {ListRef} from 'view/com/util/List' +import {Feed} from 'view/com/posts/Feed' +import {EmptyState} from 'view/com/util/EmptyState' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {useQueryClient} from '@tanstack/react-query' +import {truncateAndInvalidate} from '#/state/queries/util' +import {Text} from '#/view/com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {isNative} from '#/platform/detection' +import {SectionRef} from './types' + +interface FeedSectionProps { + feed: FeedDescriptor + headerHeight: number + isFocused: boolean + scrollElRef: ListRef + ignoreFilterFor?: string +} +export const ProfileFeedSection = React.forwardRef< + SectionRef, + FeedSectionProps +>(function FeedSectionImpl( + {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, + ref, +) { + const {_} = useLingui() + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) + const [isScrolledDown, setIsScrolledDown] = React.useState(false) + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerHeight, + }) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = React.useCallback(() => { + return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> + }, [_]) + + return ( + <View> + <Feed + testID="postsFeed" + enabled={isFocused} + feed={feed} + scrollElRef={scrollElRef} + onHasNew={setHasNew} + onScrolledDownChange={setIsScrolledDown} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} + renderEndOfFeed={ProfileEndOfFeed} + ignoreFilterFor={ignoreFilterFor} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onScrollToTop} + label={_(msg`Load new posts`)} + showIndicator={hasNew} + /> + )} + </View> + ) +}) + +function ProfileEndOfFeed() { + const pal = usePalette('default') + + return ( + <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}> + <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}> + <Trans>End of feed</Trans> + </Text> + </View> + ) +} diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx new file mode 100644 index 00000000..07beb952 --- /dev/null +++ b/src/screens/Profile/Sections/Labels.tsx @@ -0,0 +1,233 @@ +import React from 'react' +import {View} from 'react-native' +import { + AppBskyLabelerDefs, + ModerationOpts, + interpretLabelValueDefinitions, + InterpretedLabelValueDefinition, +} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSafeAreaFrame} from 'react-native-safe-area-context' + +import {useScrollHandlers} from '#/lib/ScrollContext' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' +import {ListRef} from '#/view/com/util/List' +import {SectionRef} from './types' +import {isNative} from '#/platform/detection' + +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import {Loader} from '#/components/Loader' +import {Divider} from '#/components/Divider' +import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {ErrorState} from '../ErrorState' +import {ModerationLabelPref} from '#/components/moderation/ModerationLabelPref' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' + +interface LabelsSectionProps { + isLabelerLoading: boolean + labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined + labelerError: Error | null + moderationOpts: ModerationOpts + scrollElRef: ListRef + headerHeight: number +} +export const ProfileLabelsSection = React.forwardRef< + SectionRef, + LabelsSectionProps +>(function LabelsSectionImpl( + { + isLabelerLoading, + labelerInfo, + labelerError, + moderationOpts, + scrollElRef, + headerHeight, + }, + ref, +) { + const t = useTheme() + const {_} = useLingui() + const {height: minHeight} = useSafeAreaFrame() + + const onScrollToTop = React.useCallback(() => { + // @ts-ignore TODO fix this + scrollElRef.current?.scrollTo({ + animated: isNative, + x: 0, + y: -headerHeight, + }) + }, [scrollElRef, headerHeight]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + return ( + <CenteredView> + <View + style={[ + a.border_l, + a.border_r, + a.border_t, + t.atoms.border_contrast_low, + { + minHeight, + }, + ]}> + {isLabelerLoading ? ( + <View style={[a.w_full, a.align_center]}> + <Loader size="xl" /> + </View> + ) : labelerError || !labelerInfo ? ( + <ErrorState + error={ + labelerError?.toString() || + _(msg`Something went wrong, please try again.`) + } + /> + ) : ( + <ProfileLabelsSectionInner + moderationOpts={moderationOpts} + labelerInfo={labelerInfo} + scrollElRef={scrollElRef} + headerHeight={headerHeight} + /> + )} + </View> + </CenteredView> + ) +}) + +export function ProfileLabelsSectionInner({ + moderationOpts, + labelerInfo, + scrollElRef, + headerHeight, +}: { + moderationOpts: ModerationOpts + labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed + scrollElRef: ListRef + headerHeight: number +}) { + const t = useTheme() + const contextScrollHandlers = useScrollHandlers() + + const scrollHandler = useAnimatedScrollHandler({ + onBeginDrag(e, ctx) { + contextScrollHandlers.onBeginDrag?.(e, ctx) + }, + onEndDrag(e, ctx) { + contextScrollHandlers.onEndDrag?.(e, ctx) + }, + onScroll(e, ctx) { + contextScrollHandlers.onScroll?.(e, ctx) + }, + }) + + const {labelValues} = labelerInfo.policies + const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts) + const labelDefs = React.useMemo(() => { + const customDefs = interpretLabelValueDefinitions(labelerInfo) + return labelValues + .map(val => lookupLabelValueDefinition(val, customDefs)) + .filter( + def => def && def?.configurable, + ) as InterpretedLabelValueDefinition[] + }, [labelerInfo, labelValues]) + + return ( + <ScrollView + // @ts-ignore TODO fix this + ref={scrollElRef} + scrollEventThrottle={1} + contentContainerStyle={{ + paddingTop: headerHeight, + borderWidth: 0, + }} + contentOffset={{x: 0, y: headerHeight * -1}} + onScroll={scrollHandler}> + <View + style={[ + a.pt_xl, + a.px_lg, + isNative && a.border_t, + t.atoms.border_contrast_low, + ]}> + <View> + <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> + <Trans> + Labels are annotations on users and content. They can be used to + hide, warn, and categorize the network. + </Trans> + </Text> + {labelerInfo.creator.viewer?.blocking ? ( + <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}> + <CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} /> + <Text + style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> + <Trans> + Blocking does not prevent this labeler from placing labels on + your account. + </Trans> + </Text> + </View> + ) : null} + {labelValues.length === 0 ? ( + <Text + style={[ + a.pt_xl, + t.atoms.text_contrast_high, + a.leading_snug, + a.text_sm, + ]}> + <Trans> + This labeler hasn't declared what labels it publishes, and may + not be active. + </Trans> + </Text> + ) : !isSubscribed ? ( + <Text + style={[ + a.pt_xl, + t.atoms.text_contrast_high, + a.leading_snug, + a.text_sm, + ]}> + <Trans> + Subscribe to @{labelerInfo.creator.handle} to use these labels: + </Trans> + </Text> + ) : null} + </View> + {labelDefs.length > 0 && ( + <View + style={[ + a.mt_xl, + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + {labelDefs.map((labelDef, i) => { + return ( + <React.Fragment key={labelDef.identifier}> + {i !== 0 && <Divider />} + <ModerationLabelPref + disabled={isSubscribed ? undefined : true} + labelValueDefinition={labelDef} + labelerDid={labelerInfo.creator.did} + /> + </React.Fragment> + ) + })} + </View> + )} + + <View style={{height: 400}} /> + </View> + </ScrollView> + ) +} diff --git a/src/screens/Profile/Sections/types.ts b/src/screens/Profile/Sections/types.ts new file mode 100644 index 00000000..a7f77d64 --- /dev/null +++ b/src/screens/Profile/Sections/types.ts @@ -0,0 +1,3 @@ +export interface SectionRef { + scrollToTop: () => void +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index db5be0b8..524dcb1b 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' +import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '#/state/models/media/image' @@ -14,32 +14,6 @@ export interface EditProfileModal { onUpdate?: () => void } -export interface ModerationDetailsModal { - name: 'moderation-details' - context: 'account' | 'content' - moderation: ModerationUI -} - -export type ReportModal = { - name: 'report' -} & ( - | { - uri: string - cid: string - } - | {did: string} -) - -export type AppealLabelModal = { - name: 'appeal-label' -} & ( - | { - uri: string - cid: string - } - | {did: string} -) - export interface CreateOrEditListModal { name: 'create-or-edit-list' purpose?: string @@ -123,10 +97,6 @@ export interface AddAppPasswordModal { name: 'add-app-password' } -export interface ContentFilteringSettingsModal { - name: 'content-filtering-settings' -} - export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } @@ -181,15 +151,9 @@ export type Modal = | SwitchAccountModal // Curation - | ContentFilteringSettingsModal | ContentLanguagesSettingsModal | PostLanguagesSettingsModal - // Moderation - | ModerationDetailsModal - | ReportModal - | AppealLabelModal - // Lists | CreateOrEditListModal | UserAddRemoveListsModal diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index a442b763..cf1d9015 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -15,6 +15,7 @@ export { useSetExternalEmbedPref, } from './external-embeds-prefs' export * from './hidden-posts' +export {useLabelDefinitions} from './label-defs' export function Provider({children}: React.PropsWithChildren<{}>) { return ( diff --git a/src/state/preferences/label-defs.tsx b/src/state/preferences/label-defs.tsx new file mode 100644 index 00000000..d60f8ccb --- /dev/null +++ b/src/state/preferences/label-defs.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api' +import {useLabelDefinitionsQuery} from '../queries/preferences' + +interface StateContext { + labelDefs: Record<string, InterpretedLabelValueDefinition[]> + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] +} + +const stateContext = React.createContext<StateContext>({ + labelDefs: {}, + labelers: [], +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const {labelDefs, labelers} = useLabelDefinitionsQuery() + + const state = {labelDefs, labelers} + + return <stateContext.Provider value={state}>{children}</stateContext.Provider> +} + +export function useLabelDefinitions() { + return React.useContext(stateContext) +} diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 3159ad7a..f14b3d65 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -6,17 +6,14 @@ import {logger} from '#/logger' import {getAgent} from '#/state/session' import {useMyFollowsQuery} from '#/state/queries/my-follows' import {STALE} from '#/state/queries' -import { - DEFAULT_LOGGED_OUT_PREFERENCES, - getModerationOpts, - useModerationOpts, -} from './preferences' +import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences' import {isInvalidHandle} from '#/lib/strings/handles' +import {isJustAMute} from '#/lib/moderation' -const DEFAULT_MOD_OPTS = getModerationOpts({ - userDid: '', - preferences: DEFAULT_LOGGED_OUT_PREFERENCES, -}) +const DEFAULT_MOD_OPTS = { + userDid: undefined, + prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, +} export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix] @@ -114,8 +111,8 @@ function computeSuggestions( } } return items.filter(profile => { - const mod = moderateProfile(profile, moderationOpts) - return !mod.account.filter && mod.account.cause?.type !== 'muted' + const modui = moderateProfile(profile, moderationOpts).ui('profileList') + return !modui.filter || isJustAMute(modui) }) } diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts new file mode 100644 index 00000000..c405a6b5 --- /dev/null +++ b/src/state/queries/labeler.ts @@ -0,0 +1,89 @@ +import {z} from 'zod' +import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' +import {AppBskyLabelerDefs} from '@atproto/api' + +import {getAgent} from '#/state/session' +import {preferencesQueryKey} from '#/state/queries/preferences' +import {STALE, PUBLIC_BSKY_AGENT} from '#/state/queries' + +export const labelerInfoQueryKey = (did: string) => ['labeler-info', did] +export const labelersInfoQueryKey = (dids: string[]) => [ + 'labelers-info', + dids.sort(), +] +export const labelersDetailedInfoQueryKey = (dids: string[]) => [ + 'labelers-detailed-info', + dids, +] + +export function useLabelerInfoQuery({ + did, + enabled, +}: { + did?: string + enabled?: boolean +}) { + return useQuery({ + enabled: !!did && enabled !== false, + queryKey: labelerInfoQueryKey(did as string), + queryFn: async () => { + const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({ + dids: [did as string], + detailed: true, + }) + return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed + }, + }) +} + +export function useLabelersInfoQuery({dids}: {dids: string[]}) { + return useQuery({ + enabled: !!dids.length, + queryKey: labelersInfoQueryKey(dids), + queryFn: async () => { + const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({dids}) + return res.data.views as AppBskyLabelerDefs.LabelerView[] + }, + }) +} + +export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) { + return useQuery({ + enabled: !!dids.length, + queryKey: labelersDetailedInfoQueryKey(dids), + gcTime: 1000 * 60 * 60 * 6, // 6 hours + staleTime: STALE.MINUTES.ONE, + queryFn: async () => { + const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({ + dids, + detailed: true, + }) + return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[] + }, + }) +} + +export function useLabelerSubscriptionMutation() { + const queryClient = useQueryClient() + + return useMutation({ + async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) { + // TODO + z.object({ + did: z.string(), + subscribe: z.boolean(), + }).parse({did, subscribe}) + + if (subscribe) { + await getAgent().addLabeler(did) + } else { + await getAgent().removeLabeler(did) + } + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 626d3e91..97fc57dc 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -1,14 +1,13 @@ import { AppBskyNotificationListNotifications, ModerationOpts, - moderateProfile, + moderateNotification, AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedRepost, AppBskyFeedLike, AppBskyEmbedRecord, } from '@atproto/api' -import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import chunk from 'lodash.chunk' import {QueryClient} from '@tanstack/react-query' import {getAgent} from '../../session' @@ -88,37 +87,20 @@ export async function fetchPage({ // internal methods // = -// TODO this should be in the sdk as moderateNotification -prf -function shouldFilterNotif( +export function shouldFilterNotif( notif: AppBskyNotificationListNotifications.Notification, moderationOpts: ModerationOpts | undefined, ): boolean { if (!moderationOpts) { return false } - const profile = moderateProfile(notif.author, moderationOpts) - if ( - profile.account.filter || - profile.profile.filter || - notif.author.viewer?.muted - ) { - return true + if (notif.author.viewer?.following) { + return false } - if ( - notif.type === 'reply' || - notif.type === 'quote' || - notif.type === 'mention' - ) { - // NOTE: the notification overlaps the post enough for this to work - const post = moderatePost(notif, moderationOpts) - if (post.content.filter) { - return true - } - } - return false + return moderateNotification(notif, moderationOpts).ui('contentList').filter } -function groupNotifications( +export function groupNotifications( notifs: AppBskyNotificationListNotifications.Notification[], ): FeedNotification[] { const groupedNotifs: FeedNotification[] = [] diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index c295ffcb..0e6eef52 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -3,8 +3,8 @@ import {AppState} from 'react-native' import { AppBskyFeedDefs, AppBskyFeedPost, + ModerationDecision, AtUri, - PostModeration, } from '@atproto/api' import { useInfiniteQuery, @@ -29,7 +29,6 @@ import {STALE} from '#/state/queries' import {precacheFeedPostProfiles} from './profile' import {getAgent} from '#/state/session' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' -import {getModerationOpts} from '#/state/queries/preferences/moderation' import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {embedViewRecordToPostView, getEmbeddedPost} from './util' import {useModerationOpts} from './preferences' @@ -69,7 +68,7 @@ export interface FeedPostSliceItem { post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource - moderation: PostModeration + moderation: ModerationDecision } export interface FeedPostSlice { @@ -250,9 +249,17 @@ export function usePostFeedQuery( // apply moderation filter for (let i = 0; i < slice.items.length; i++) { + const ignoreFilter = + slice.items[i].post.author.did === ignoreFilterFor + if (ignoreFilter) { + // remove mutes to avoid confused UIs + moderations[i].causes = moderations[i].causes.filter( + cause => cause.type !== 'muted', + ) + } if ( - moderations[i]?.content.filter && - slice.items[i].post.author.did !== ignoreFilterFor + !ignoreFilter && + moderations[i]?.ui('contentList').filter ) { return undefined } @@ -435,13 +442,12 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { let somePostsPassModeration = false for (const item of feed) { - const moderationOpts = getModerationOpts({ - userDid: '', - preferences: DEFAULT_LOGGED_OUT_PREFERENCES, + const moderation = moderatePost(item.post, { + userDid: undefined, + prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, }) - const moderation = moderatePost(item.post, moderationOpts) - if (!moderation.content.filter) { + if (!moderation.ui('contentList').filter) { // we have a sfw post somePostsPassModeration = true } diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts index 2cde07f2..a0498ada 100644 --- a/src/state/queries/post-liked-by.ts +++ b/src/state/queries/post-liked-by.ts @@ -12,9 +12,9 @@ const PAGE_SIZE = 30 type RQPageParam = string | undefined // TODO refactor invalidate on mutate? -export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri] +export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri] -export function usePostLikedByQuery(resolvedUri: string | undefined) { +export function useLikedByQuery(resolvedUri: string | undefined) { return useInfiniteQuery< AppBskyFeedGetLikes.OutputSchema, Error, diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 53c9e482..4cb4d1e9 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -29,26 +29,20 @@ export const DEFAULT_PROD_FEEDS = { export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { birthDate: new Date('2022-11-17'), // TODO(pwi) - adultContentEnabled: false, feeds: { saved: [], pinned: [], unpinned: [], }, - // labels are undefined until set by user - contentLabels: { - nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw, - nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity, - suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive, - gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore, - hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate, - spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam, - impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation, + moderationPrefs: { + adultContentEnabled: false, + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, + labelers: [], + mutedWords: [], + hiddenPosts: [], }, feedViewPrefs: DEFAULT_HOME_FEED_PREFS, threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, userAge: 13, // TODO(pwi) interests: {tags: []}, - mutedWords: [], - hiddenPosts: [], } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 37ef10ae..cfc5c5bb 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,29 +1,27 @@ -import {useMemo} from 'react' +import {useMemo, createContext, useContext} from 'react' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' import { LabelPreference, BskyFeedViewPreference, + ModerationOpts, AppBskyActorDefs, } from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {getAge} from '#/lib/strings/time' -import {useSession, getAgent} from '#/state/session' -import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' +import {getAgent, useSession} from '#/state/session' import { - ConfigurableLabelGroup, UsePreferencesQueryResponse, ThreadViewPreferences, } from '#/state/queries/preferences/types' -import {temp__migrateLabelPref} from '#/state/queries/preferences/util' import { DEFAULT_HOME_FEED_PREFS, DEFAULT_THREAD_VIEW_PREFS, DEFAULT_LOGGED_OUT_PREFERENCES, } from '#/state/queries/preferences/const' -import {getModerationOpts} from '#/state/queries/preferences/moderation' import {STALE} from '#/state/queries' -import {useHiddenPosts} from '#/state/preferences/hidden-posts' +import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' +import {saveLabelers} from '#/state/session/agent-config' export * from '#/state/queries/preferences/types' export * from '#/state/queries/preferences/moderation' @@ -44,6 +42,13 @@ export function usePreferencesQuery() { return DEFAULT_LOGGED_OUT_PREFERENCES } else { const res = await agent.getPreferences() + + // save to local storage to ensure there are labels on initial requests + saveLabelers( + agent.session.did, + res.moderationPrefs.labelers.map(l => l.did), + ) + const preferences: UsePreferencesQueryResponse = { ...res, feeds: { @@ -54,32 +59,6 @@ export function usePreferencesQuery() { return !res.feeds.pinned?.includes(f) }) || [], }, - // labels are undefined until set by user - contentLabels: { - nsfw: temp__migrateLabelPref( - res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw, - ), - nudity: temp__migrateLabelPref( - res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity, - ), - suggestive: temp__migrateLabelPref( - res.contentLabels?.suggestive || - DEFAULT_LABEL_PREFERENCES.suggestive, - ), - gore: temp__migrateLabelPref( - res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore, - ), - hate: temp__migrateLabelPref( - res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate, - ), - spam: temp__migrateLabelPref( - res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam, - ), - impersonation: temp__migrateLabelPref( - res.contentLabels?.impersonation || - DEFAULT_LABEL_PREFERENCES.impersonation, - ), - }, feedViewPrefs: { ...DEFAULT_HOME_FEED_PREFS, ...(res.feedViewPrefs.home || {}), @@ -96,25 +75,30 @@ export function usePreferencesQuery() { }) } +// used in the moderation state devtool +export const moderationOptsOverrideContext = createContext< + ModerationOpts | undefined +>(undefined) + export function useModerationOpts() { + const override = useContext(moderationOptsOverrideContext) const {currentAccount} = useSession() const prefs = usePreferencesQuery() - const hiddenPosts = useHiddenPosts() - const opts = useMemo(() => { + const {labelDefs} = useLabelDefinitions() + const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs + const opts = useMemo<ModerationOpts | undefined>(() => { + if (override) { + return override + } if (!prefs.data) { return } - const moderationOpts = getModerationOpts({ - userDid: currentAccount?.did || '', - preferences: prefs.data, - }) - return { - ...moderationOpts, - hiddenPosts, - mutedWords: prefs.data.mutedWords || [], + userDid: currentAccount?.did, + prefs: {...prefs.data.moderationPrefs, hiddenPosts: hiddenPosts || []}, + labelDefs, } - }, [currentAccount?.did, prefs.data, hiddenPosts]) + }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts]) return opts } @@ -138,10 +122,32 @@ export function usePreferencesSetContentLabelMutation() { return useMutation< void, unknown, - {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference} + {label: string; visibility: LabelPreference; labelerDid: string | undefined} >({ - mutationFn: async ({labelGroup, visibility}) => { - await getAgent().setContentLabelPref(labelGroup, visibility) + mutationFn: async ({label, visibility, labelerDid}) => { + await getAgent().setContentLabelPref(label, visibility, labelerDid) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSetContentLabelMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + label, + visibility, + labelerDid, + }: { + label: string + visibility: LabelPreference + labelerDid?: string + }) => { + await getAgent().setContentLabelPref(label, visibility, labelerDid) // triggers a refetch await queryClient.invalidateQueries({ queryKey: preferencesQueryKey, diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts index cdae5293..9cd183e8 100644 --- a/src/state/queries/preferences/moderation.ts +++ b/src/state/queries/preferences/moderation.ts @@ -1,181 +1,53 @@ +import React from 'react' import { - LabelPreference, - ComAtprotoLabelDefs, - ModerationOpts, + DEFAULT_LABEL_SETTINGS, + BskyAgent, + interpretLabelValueDefinitions, } from '@atproto/api' -import { - LabelGroup, - ConfigurableLabelGroup, - UsePreferencesQueryResponse, -} from '#/state/queries/preferences/types' - -export type Label = ComAtprotoLabelDefs.Label - -export type LabelGroupConfig = { - id: LabelGroup - title: string - isAdultImagery?: boolean - subtitle?: string - warning: string - values: string[] -} - -export const DEFAULT_LABEL_PREFERENCES: Record< - ConfigurableLabelGroup, - LabelPreference -> = { - nsfw: 'hide', - nudity: 'warn', - suggestive: 'warn', - gore: 'warn', - hate: 'hide', - spam: 'hide', - impersonation: 'hide', -} +import {usePreferencesQuery} from './index' +import {useLabelersDetailedInfoQuery} from '../labeler' /** * More strict than our default settings for logged in users. - * - * TODO(pwi) */ -export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record< - ConfigurableLabelGroup, - LabelPreference -> = { - nsfw: 'hide', - nudity: 'hide', - suggestive: 'hide', - gore: 'hide', - hate: 'hide', - spam: 'hide', - impersonation: 'hide', +export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS = + Object.fromEntries( + Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']), + ) + +export function useMyLabelersQuery() { + const prefs = usePreferencesQuery() + const dids = Array.from( + new Set( + BskyAgent.appLabelers.concat( + prefs.data?.moderationPrefs.labelers.map(l => l.did) || [], + ), + ), + ) + const labelers = useLabelersDetailedInfoQuery({dids}) + const isLoading = prefs.isLoading || labelers.isLoading + const error = prefs.error || labelers.error + return React.useMemo(() => { + return { + isLoading, + error, + data: labelers.data, + } + }, [labelers, isLoading, error]) } -export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = { - id: 'illegal', - title: 'Illegal Content', - warning: 'Illegal Content', - values: ['csam', 'dmca-violation', 'nudity-nonconsensual'], -} - -export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = { - id: 'always-filter', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!filter'], -} - -export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = { - id: 'always-warn', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!warn', 'account-security'], -} - -export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = { - id: 'unknown', - title: 'Unknown Label', - warning: 'Content Warning', - values: [], -} - -export const CONFIGURABLE_LABEL_GROUPS: Record< - ConfigurableLabelGroup, - LabelGroupConfig -> = { - nsfw: { - id: 'nsfw', - title: 'Explicit Sexual Images', - subtitle: 'i.e. pornography', - warning: 'Sexually Explicit', - values: ['porn', 'nsfl'], - isAdultImagery: true, - }, - nudity: { - id: 'nudity', - title: 'Other Nudity', - subtitle: 'Including non-sexual and artistic', - warning: 'Nudity', - values: ['nudity'], - isAdultImagery: true, - }, - suggestive: { - id: 'suggestive', - title: 'Sexually Suggestive', - subtitle: 'Does not include nudity', - warning: 'Sexually Suggestive', - values: ['sexual'], - isAdultImagery: true, - }, - gore: { - id: 'gore', - title: 'Violent / Bloody', - subtitle: 'Gore, self-harm, torture', - warning: 'Violence', - values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'], - isAdultImagery: true, - }, - hate: { - id: 'hate', - title: 'Hate Group Iconography', - subtitle: 'Images of terror groups, articles covering events, etc.', - warning: 'Hate Groups', - values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'], - }, - spam: { - id: 'spam', - title: 'Spam', - subtitle: 'Excessive unwanted interactions', - warning: 'Spam', - values: ['spam'], - }, - impersonation: { - id: 'impersonation', - title: 'Impersonation', - subtitle: 'Accounts falsely claiming to be people or orgs', - warning: 'Impersonation', - values: ['impersonation'], - }, -} - -export function getModerationOpts({ - userDid, - preferences, -}: { - userDid: string - preferences: UsePreferencesQueryResponse -}): ModerationOpts { - return { - userDid: userDid, - adultContentEnabled: preferences.adultContentEnabled, - labels: { - porn: preferences.contentLabels.nsfw, - sexual: preferences.contentLabels.suggestive, - nudity: preferences.contentLabels.nudity, - nsfl: preferences.contentLabels.gore, - corpse: preferences.contentLabels.gore, - gore: preferences.contentLabels.gore, - torture: preferences.contentLabels.gore, - 'self-harm': preferences.contentLabels.gore, - 'intolerant-race': preferences.contentLabels.hate, - 'intolerant-gender': preferences.contentLabels.hate, - 'intolerant-sexual-orientation': preferences.contentLabels.hate, - 'intolerant-religion': preferences.contentLabels.hate, - intolerant: preferences.contentLabels.hate, - 'icon-intolerant': preferences.contentLabels.hate, - spam: preferences.contentLabels.spam, - impersonation: preferences.contentLabels.impersonation, - scam: 'warn', - }, - labelers: [ - { - labeler: { - did: '', - displayName: 'Bluesky Social', - }, - labels: {}, - }, - ], - } +export function useLabelDefinitionsQuery() { + const labelers = useMyLabelersQuery() + return React.useMemo(() => { + return { + labelDefs: Object.fromEntries( + (labelers.data || []).map(labeler => [ + labeler.creator.did, + interpretLabelValueDefinitions(labeler), + ]), + ), + labelers: labelers.data || [], + } + }, [labelers]) } diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts index 45c9eed7..96da16f1 100644 --- a/src/state/queries/preferences/types.ts +++ b/src/state/queries/preferences/types.ts @@ -1,46 +1,13 @@ import { BskyPreferences, - LabelPreference, BskyThreadViewPreference, BskyFeedViewPreference, } from '@atproto/api' -export const configurableAdultLabelGroups = [ - 'nsfw', - 'nudity', - 'suggestive', - 'gore', -] as const - -export const configurableOtherLabelGroups = [ - 'hate', - 'spam', - 'impersonation', -] as const - -export const configurableLabelGroups = [ - ...configurableAdultLabelGroups, - ...configurableOtherLabelGroups, -] as const -export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number] - -export type LabelGroup = - | ConfigurableLabelGroup - | 'illegal' - | 'always-filter' - | 'always-warn' - | 'unknown' - export type UsePreferencesQueryResponse = Omit< BskyPreferences, 'contentLabels' | 'feedViewPrefs' | 'feeds' > & { - /* - * Content labels previously included 'show', which has been deprecated in - * favor of 'ignore'. The API can return legacy data from the database, and - * we clean up the data in `usePreferencesQuery`. - */ - contentLabels: Record<ConfigurableLabelGroup, LabelPreference> feedViewPrefs: BskyFeedViewPreference & { lab_mergeFeedEnabled?: boolean } diff --git a/src/state/queries/preferences/util.ts b/src/state/queries/preferences/util.ts deleted file mode 100644 index 7b8160c2..00000000 --- a/src/state/queries/preferences/util.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {LabelPreference} from '@atproto/api' - -/** - * Content labels previously included 'show', which has been deprecated in - * favor of 'ignore'. The API can return legacy data from the database, and - * we clean up the data in `usePreferencesQuery`. - * - * @deprecated - */ -export function temp__migrateLabelPref( - pref: LabelPreference | 'show', -): LabelPreference { - // @ts-ignore - if (pref === 'show') return 'ignore' - return pref -} diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts deleted file mode 100644 index 8fc32c33..00000000 --- a/src/state/queries/profile-extra-info.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {useQuery} from '@tanstack/react-query' - -import {getAgent} from '#/state/session' -import {STALE} from '#/state/queries' - -// TODO refactor invalidate on mutate? -export const RQKEY = (did: string) => ['profile-extra-info', did] - -/** - * Fetches some additional information for the profile screen which - * is not available in the API's ProfileView - */ -export function useProfileExtraInfoQuery(did: string) { - return useQuery({ - staleTime: STALE.MINUTES.ONE, - queryKey: RQKEY(did), - async queryFn() { - const [listsRes, feedsRes] = await Promise.all([ - getAgent().app.bsky.graph.getLists({ - actor: did, - limit: 1, - }), - getAgent().app.bsky.feed.getActorFeeds({ - actor: did, - limit: 1, - }), - ]) - return { - hasLists: listsRes.data.lists.length > 0, - hasFeedgens: feedsRes.data.feeds.length > 0, - } - }, - }) -} diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 932226b7..45b3ebb6 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -46,7 +46,8 @@ export function useSuggestedFollowsQuery() { res.data.actors = res.data.actors .filter( - actor => !moderateProfile(actor, moderationOpts!).account.filter, + actor => + !moderateProfile(actor, moderationOpts!).ui('profileList').filter, ) .filter(actor => { const viewer = actor.viewer diff --git a/src/state/session/agent-config.ts b/src/state/session/agent-config.ts new file mode 100644 index 00000000..3ee2718a --- /dev/null +++ b/src/state/session/agent-config.ts @@ -0,0 +1,12 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const PREFIX = 'agent-labelers' + +export async function saveLabelers(did: string, value: string[]) { + await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value)) +} + +export async function readLabelers(did: string): Promise<string[] | undefined> { + const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`) + return rawData ? JSON.parse(rawData) : undefined +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 46628318..6b147483 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,8 +1,15 @@ import React from 'react' -import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' +import { + BskyAgent, + AtpPersistSessionHandler, + BSKY_LABELER_DID, +} from '@atproto/api' import {useQueryClient} from '@tanstack/react-query' import {jwtDecode} from 'jwt-decode' +import {IS_DEV} from '#/env' +import {IS_TEST_USER} from '#/lib/constants' +import {isWeb} from '#/platform/detection' import {networkRetry} from '#/lib/async/retry' import {logger} from '#/logger' import * as persisted from '#/state/persisted' @@ -12,6 +19,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {track} from '#/lib/analytics/analytics' import {hasProp} from '#/lib/type-guards' +import {readLabelers} from './agent-config' let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT @@ -255,6 +263,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { deactivated, } + await configureModeration(agent, account) + agent.setPersistSessionHandler( createPersistSessionHandler( account, @@ -298,6 +308,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { deactivated: isSessionDeactivated(agent.session.accessJwt), } + await configureModeration(agent, account) + agent.setPersistSessionHandler( createPersistSessionHandler( account, @@ -309,6 +321,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) __globalAgent = agent + // @ts-ignore + if (IS_DEV && isWeb) window.agent = agent queryClient.clear() upsertAccount(account) @@ -348,6 +362,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { {networkErrorCallback: clearCurrentAccount}, ), }) + // @ts-ignore + if (IS_DEV && isWeb) window.agent = agent + await configureModeration(agent, account) let canReusePrevSession = false try { @@ -643,6 +660,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } +async function configureModeration(agent: BskyAgent, account: SessionAccount) { + if (IS_TEST_USER(account.handle)) { + const did = ( + await agent + .resolveHandle({handle: 'mod-authority.test'}) + .catch(_ => undefined) + )?.data.did + if (did) { + console.warn('USING TEST ENV MODERATION') + BskyAgent.configure({appLabelers: [did]}) + } + } else { + BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) + const labelerDids = await readLabelers(account.did).catch(_ => {}) + if (labelerDids) { + agent.configureLabelersHeader( + labelerDids.filter(did => did !== BSKY_LABELER_DID), + ) + } + } +} + export function useSession() { return React.useContext(StateContext) } diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index c9dbfbea..a09e8fba 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -2,7 +2,7 @@ import React from 'react' import { AppBskyEmbedRecord, AppBskyRichtextFacet, - PostModeration, + ModerationDecision, } from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -16,7 +16,7 @@ export interface ComposerOptsPostRef { avatar?: string } embed?: AppBskyEmbedRecord.ViewRecord['embed'] - moderation?: PostModeration + moderation?: ModerationDecision } export interface ComposerOptsQuote { uri: string diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts index 7a727ec0..840084dc 100644 --- a/src/view/com/auth/create/state.ts +++ b/src/view/com/auth/create/state.ts @@ -12,7 +12,7 @@ import {createFullHandle, validateHandle} from '#/lib/strings/handles' import {cleanError} from '#/lib/strings/errors' import {useOnboardingDispatch} from '#/state/shell/onboarding' import {useSessionApi} from '#/state/session' -import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants' +import {DEFAULT_SERVICE, IS_TEST_USER} from '#/lib/constants' import { DEFAULT_PROD_FEEDS, usePreferencesSetBirthDateMutation, @@ -147,7 +147,7 @@ export function useSubmitCreateAccount( : undefined, }) setBirthDate({birthDate: uiState.birthDate}) - if (IS_PROD_SERVICE(uiState.serviceUrl)) { + if (!IS_TEST_USER(uiState.handle)) { setSavedFeeds(DEFAULT_PROD_FEEDS) } } catch (e: any) { diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 5f81a4d6..434b10c2 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -1,6 +1,6 @@ import React from 'react' import {View, StyleSheet, ActivityIndicator} from 'react-native' -import {ProfileModeration, AppBskyActorDefs} from '@atproto/api' +import {ModerationDecision, AppBskyActorDefs} from '@atproto/api' import {Button} from '#/view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -18,7 +18,7 @@ import {logger} from '#/logger' type Props = { profile: AppBskyActorDefs.ProfileViewBasic - moderation: ProfileModeration + moderation: ModerationDecision onFollowStateChange: (props: { did: string following: boolean @@ -62,7 +62,7 @@ function ProfileCard({ moderation, }: { profile: Shadow<AppBskyActorDefs.ProfileViewBasic> - moderation: ProfileModeration + moderation: ModerationDecision onFollowStateChange: (props: { did: string following: boolean @@ -113,7 +113,7 @@ function ProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> @@ -124,7 +124,7 @@ function ProfileCard({ lineHeight={1.2}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text type="xl" style={[pal.textLight]} numberOfLines={1}> diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 97f8e519..0a2692d0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -39,7 +39,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useExternalLinkFetch} from './useExternalLinkFetch' import {isWeb, isNative, isAndroid, isIOS} from 'platform/detection' -import QuoteEmbed from '../util/post-embeds/QuoteEmbed' +import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed' import {GalleryModel} from 'state/models/media/gallery' import {Gallery} from './photos/Gallery' import {MAX_GRAPHEME_LENGTH} from 'lib/constants' diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 39a1473a..4832bca0 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -15,7 +15,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {UserAvatar} from 'view/com/util/UserAvatar' import {Text} from 'view/com/util/text/Text' -import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed' +import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed' export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { const pal = usePalette('default') @@ -86,7 +86,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { <UserAvatar avatar={replyTo.author.avatar} size={50} - moderation={replyTo.moderation?.avatar} + moderation={replyTo.moderation?.ui('avatar')} /> <View style={styles.replyToPost}> <Text type="xl-medium" style={[pal.text]}> @@ -103,7 +103,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { {replyTo.text} </Text> </View> - {images && !replyTo.moderation?.embed.blur && ( + {images && !replyTo.moderation?.ui('contentMedia').blur && ( <ComposerReplyToImages images={images} showFull={showFull} /> )} </View> diff --git a/src/view/com/modals/AppealLabel.tsx b/src/view/com/modals/AppealLabel.tsx deleted file mode 100644 index b0aaaf62..00000000 --- a/src/view/com/modals/AppealLabel.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, {useState} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {ComAtprotoModerationDefs} from '@atproto/api' -import {ScrollView, TextInput} from './util' -import {Text} from '../util/text/Text' -import {s, colors} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {CharProgress} from '../composer/char-progress/CharProgress' -import {getAgent} from '#/state/session' -import * as Toast from '../util/Toast' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' - -export const snapPoints = ['40%'] - -type ReportComponentProps = - | { - uri: string - cid: string - } - | { - did: string - } - -export function Component(props: ReportComponentProps) { - const pal = usePalette('default') - const [details, setDetails] = useState<string>('') - const {_} = useLingui() - const {closeModal} = useModalControls() - const {isMobile} = useWebMediaQueries() - const isAccountReport = 'did' in props - - const submit = async () => { - try { - const $type = !isAccountReport - ? 'com.atproto.repo.strongRef' - : 'com.atproto.admin.defs#repoRef' - await getAgent().createModerationReport({ - reasonType: ComAtprotoModerationDefs.REASONAPPEAL, - subject: { - $type, - ...props, - }, - reason: details, - }) - Toast.show(_(msg`We'll look into your appeal promptly.`)) - } finally { - closeModal() - } - } - - return ( - <View - style={[ - pal.view, - s.flex1, - isMobile ? {paddingHorizontal: 12} : undefined, - ]} - testID="appealLabelModal"> - <Text - type="2xl-bold" - style={[pal.text, s.textCenter, {paddingBottom: 8}]}> - <Trans>Appeal Content Warning</Trans> - </Text> - <ScrollView> - <View style={[pal.btn, styles.detailsInputContainer]}> - <TextInput - accessibilityLabel={_(msg`Text input field`)} - accessibilityHint={_( - msg`Please tell us why you think this content warning was incorrectly applied!`, - )} - placeholder={_( - msg`Please tell us why you think this content warning was incorrectly applied!`, - )} - placeholderTextColor={pal.textLight.color} - value={details} - onChangeText={setDetails} - autoFocus={true} - numberOfLines={3} - multiline={true} - textAlignVertical="top" - maxLength={300} - style={[styles.detailsInput, pal.text]} - /> - <View style={styles.detailsInputBottomBar}> - <View style={styles.charCounter}> - <CharProgress count={details?.length || 0} /> - </View> - </View> - </View> - <TouchableOpacity - testID="confirmBtn" - onPress={submit} - style={styles.btn} - accessibilityRole="button" - accessibilityLabel={_(msg`Confirm`)} - accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}> - <Trans>Submit</Trans> - </Text> - </TouchableOpacity> - </ScrollView> - </View> - ) -} - -const styles = StyleSheet.create({ - detailsInputContainer: { - borderRadius: 8, - marginBottom: 8, - }, - detailsInput: { - paddingHorizontal: 12, - paddingTop: 12, - paddingBottom: 12, - borderRadius: 8, - minHeight: 100, - fontSize: 16, - }, - detailsInputBottomBar: { - alignSelf: 'flex-end', - }, - charCounter: { - flexDirection: 'row', - alignItems: 'center', - paddingRight: 10, - paddingBottom: 8, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - backgroundColor: colors.blue3, - }, -}) diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx deleted file mode 100644 index 3c7edcf0..00000000 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import React from 'react' -import {LabelPreference} from '@atproto/api' -import {StyleSheet, Pressable, View, Linking} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import {ScrollView} from './util' -import {s, colors, gradients} from 'lib/styles' -import {Text} from '../util/text/Text' -import {TextLink} from '../util/Link' -import {ToggleButton} from '../util/forms/ToggleButton' -import {Button} from '../util/forms/Button' -import {usePalette} from 'lib/hooks/usePalette' -import {isIOS} from 'platform/detection' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import * as Toast from '../util/Toast' -import {logger} from '#/logger' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import { - usePreferencesQuery, - usePreferencesSetContentLabelMutation, - usePreferencesSetAdultContentMutation, - ConfigurableLabelGroup, - CONFIGURABLE_LABEL_GROUPS, - UsePreferencesQueryResponse, -} from '#/state/queries/preferences' -import {useDialogControl} from '#/components/Dialog' -import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' - -export const snapPoints = ['90%'] - -export function Component({}: {}) { - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') - const {_} = useLingui() - const {closeModal} = useModalControls() - const {data: preferences} = usePreferencesQuery() - - const onPressDone = React.useCallback(() => { - closeModal() - }, [closeModal]) - - return ( - <View testID="contentFilteringModal" style={[pal.view, styles.container]}> - <Text style={[pal.text, styles.title]}> - <Trans>Content Filtering</Trans> - </Text> - - <ScrollView style={styles.scrollContainer}> - <AdultContentEnabledPref /> - <ContentLabelPref - preferences={preferences} - labelGroup="nsfw" - disabled={!preferences?.adultContentEnabled} - /> - <ContentLabelPref - preferences={preferences} - labelGroup="nudity" - disabled={!preferences?.adultContentEnabled} - /> - <ContentLabelPref - preferences={preferences} - labelGroup="suggestive" - disabled={!preferences?.adultContentEnabled} - /> - <ContentLabelPref - preferences={preferences} - labelGroup="gore" - disabled={!preferences?.adultContentEnabled} - /> - <ContentLabelPref preferences={preferences} labelGroup="hate" /> - <ContentLabelPref preferences={preferences} labelGroup="spam" /> - <ContentLabelPref - preferences={preferences} - labelGroup="impersonation" - /> - <View style={{height: isMobile ? 60 : 0}} /> - </ScrollView> - - <View - style={[ - styles.btnContainer, - isMobile && styles.btnContainerMobile, - pal.borderDark, - ]}> - <Pressable - testID="sendReportBtn" - onPress={onPressDone} - accessibilityRole="button" - accessibilityLabel={_(msg`Done`)} - accessibilityHint=""> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}> - <Trans>Done</Trans> - </Text> - </LinearGradient> - </Pressable> - </View> - </View> - ) -} - -function AdultContentEnabledPref() { - const pal = usePalette('default') - const {_} = useLingui() - const {data: preferences} = usePreferencesQuery() - const {mutate, variables} = usePreferencesSetAdultContentMutation() - const bithdayDialogControl = useDialogControl() - - const onSetAge = React.useCallback( - () => bithdayDialogControl.open(), - [bithdayDialogControl], - ) - - const onToggleAdultContent = React.useCallback(async () => { - if (isIOS) return - - try { - mutate({ - enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), - }) - } catch (e) { - Toast.show( - _(msg`There was an issue syncing your preferences with the server`), - ) - logger.error('Failed to update preferences with server', {message: e}) - } - }, [variables, preferences, mutate, _]) - - const onAdultContentLinkPress = React.useCallback(() => { - Linking.openURL('https://bsky.app/') - }, []) - - return ( - <View style={s.mb10}> - <BirthDateSettingsDialog - control={bithdayDialogControl} - preferences={preferences} - /> - {isIOS ? ( - preferences?.adultContentEnabled ? null : ( - <Text type="md" style={pal.textLight}> - <Trans> - Adult content can only be enabled via the Web at{' '} - <TextLink - style={pal.link} - href="" - text="bsky.app" - onPress={onAdultContentLinkPress} - /> - . - </Trans> - </Text> - ) - ) : typeof preferences?.birthDate === 'undefined' ? ( - <View style={[pal.viewLight, styles.agePrompt]}> - <Text type="md" style={[pal.text, {flex: 1}]}> - <Trans>Confirm your age to enable adult content.</Trans> - </Text> - <Button - type="primary" - label={_(msg({message: 'Set Age', context: 'action'}))} - onPress={onSetAge} - /> - </View> - ) : (preferences.userAge || 0) >= 18 ? ( - <ToggleButton - type="default-light" - label={_(msg`Enable Adult Content`)} - isSelected={variables?.enabled ?? preferences?.adultContentEnabled} - onPress={onToggleAdultContent} - style={styles.toggleBtn} - /> - ) : ( - <View style={[pal.viewLight, styles.agePrompt]}> - <Text type="md" style={[pal.text, {flex: 1}]}> - <Trans>You must be 18 or older to enable adult content.</Trans> - </Text> - <Button - type="primary" - label={_(msg({message: 'Set Age', context: 'action'}))} - onPress={onSetAge} - /> - </View> - )} - </View> - ) -} - -// TODO: Refactor this component to pass labels down to each tab -function ContentLabelPref({ - preferences, - labelGroup, - disabled, -}: { - preferences?: UsePreferencesQueryResponse - labelGroup: ConfigurableLabelGroup - disabled?: boolean -}) { - const pal = usePalette('default') - const visibility = preferences?.contentLabels?.[labelGroup] - const {mutate, variables} = usePreferencesSetContentLabelMutation() - - const onChange = React.useCallback( - (vis: LabelPreference) => { - mutate({labelGroup, visibility: vis}) - }, - [mutate, labelGroup], - ) - - return ( - <View style={[styles.contentLabelPref, pal.border]}> - <View style={s.flex1}> - <Text type="md-medium" style={[pal.text]}> - {CONFIGURABLE_LABEL_GROUPS[labelGroup].title} - </Text> - {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && ( - <Text type="sm" style={[pal.textLight]}> - {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle} - </Text> - )} - </View> - - {disabled || !visibility ? ( - <Text type="sm-bold" style={pal.textLight}> - <Trans context="action">Hide</Trans> - </Text> - ) : ( - <SelectGroup - current={variables?.visibility || visibility} - onChange={onChange} - labelGroup={labelGroup} - /> - )} - </View> - ) -} - -interface SelectGroupProps { - current: LabelPreference - onChange: (v: LabelPreference) => void - labelGroup: ConfigurableLabelGroup -} - -function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) { - const {_} = useLingui() - - return ( - <View style={styles.selectableBtns}> - <SelectableBtn - current={current} - value="hide" - label={_(msg`Hide`)} - left - onChange={onChange} - labelGroup={labelGroup} - /> - <SelectableBtn - current={current} - value="warn" - label={_(msg`Warn`)} - onChange={onChange} - labelGroup={labelGroup} - /> - <SelectableBtn - current={current} - value="ignore" - label={_(msg`Show`)} - right - onChange={onChange} - labelGroup={labelGroup} - /> - </View> - ) -} - -interface SelectableBtnProps { - current: string - value: LabelPreference - label: string - left?: boolean - right?: boolean - onChange: (v: LabelPreference) => void - labelGroup: ConfigurableLabelGroup -} - -function SelectableBtn({ - current, - value, - label, - left, - right, - onChange, - labelGroup, -}: SelectableBtnProps) { - const pal = usePalette('default') - const palPrimary = usePalette('inverted') - const {_} = useLingui() - - return ( - <Pressable - style={[ - styles.selectableBtn, - left && styles.selectableBtnLeft, - right && styles.selectableBtnRight, - pal.border, - current === value ? palPrimary.view : pal.view, - ]} - onPress={() => onChange(value)} - accessibilityRole="button" - accessibilityLabel={value} - accessibilityHint={_( - msg`Set ${value} for ${labelGroup} content moderation policy`, - )}> - <Text style={current === value ? palPrimary.text : pal.text}> - {label} - </Text> - </Pressable> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - fontSize: 24, - marginBottom: 12, - }, - description: { - paddingHorizontal: 2, - marginBottom: 10, - }, - scrollContainer: { - flex: 1, - paddingHorizontal: 10, - }, - btnContainer: { - paddingTop: 10, - paddingHorizontal: 10, - }, - btnContainerMobile: { - paddingBottom: 40, - borderTopWidth: 1, - }, - - agePrompt: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingLeft: 14, - paddingRight: 10, - paddingVertical: 8, - borderRadius: 8, - }, - - contentLabelPref: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingTop: 14, - paddingLeft: 4, - marginBottom: 14, - borderTopWidth: 1, - }, - - selectableBtns: { - flexDirection: 'row', - marginLeft: 10, - }, - selectableBtn: { - flexDirection: 'row', - justifyContent: 'center', - borderWidth: 1, - borderLeftWidth: 0, - paddingHorizontal: 10, - paddingVertical: 10, - }, - selectableBtnLeft: { - borderTopLeftRadius: 8, - borderBottomLeftRadius: 8, - borderLeftWidth: 1, - }, - selectableBtnRight: { - borderTopRightRadius: 8, - borderBottomRightRadius: 8, - }, - - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 14, - backgroundColor: colors.gray1, - }, - toggleBtn: { - paddingHorizontal: 0, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index e382e6fa..238cfc50 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -15,16 +15,12 @@ import * as UserAddRemoveListsModal from './UserAddRemoveLists' import * as ListAddUserModal from './ListAddRemoveUsers' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' -import * as ReportModal from './report/Modal' -import * as AppealLabelModal from './AppealLabel' import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' -import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' -import * as ModerationDetailsModal from './ModerationDetails' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as ChangePasswordModal from './ChangePassword' @@ -67,12 +63,6 @@ export function ModalsContainer() { if (activeModal?.name === 'edit-profile') { snapPoints = EditProfileModal.snapPoints element = <EditProfileModal.Component {...activeModal} /> - } else if (activeModal?.name === 'report') { - snapPoints = ReportModal.snapPoints - element = <ReportModal.Component {...activeModal} /> - } else if (activeModal?.name === 'appeal-label') { - snapPoints = AppealLabelModal.snapPoints - element = <AppealLabelModal.Component {...activeModal} /> } else if (activeModal?.name === 'create-or-edit-list') { snapPoints = CreateOrEditListModal.snapPoints element = <CreateOrEditListModal.Component {...activeModal} /> @@ -109,18 +99,12 @@ export function ModalsContainer() { } else if (activeModal?.name === 'add-app-password') { snapPoints = AddAppPassword.snapPoints element = <AddAppPassword.Component /> - } else if (activeModal?.name === 'content-filtering-settings') { - snapPoints = ContentFilteringSettingsModal.snapPoints - element = <ContentFilteringSettingsModal.Component /> } else if (activeModal?.name === 'content-languages-settings') { snapPoints = ContentLanguagesSettingsModal.snapPoints element = <ContentLanguagesSettingsModal.Component /> } else if (activeModal?.name === 'post-languages-settings') { snapPoints = PostLanguagesSettingsModal.snapPoints element = <PostLanguagesSettingsModal.Component /> - } else if (activeModal?.name === 'moderation-details') { - snapPoints = ModerationDetailsModal.snapPoints - element = <ModerationDetailsModal.Component {...activeModal} /> } else if (activeModal?.name === 'verify-email') { snapPoints = VerifyEmailModal.snapPoints element = <VerifyEmailModal.Component {...activeModal} /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 66ea2311..7e5d548a 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -8,8 +8,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {useModals, useModalControls} from '#/state/modals' import type {Modal as ModalIface} from '#/state/modals' import * as EditProfileModal from './EditProfile' -import * as ReportModal from './report/Modal' -import * as AppealLabelModal from './AppealLabel' import * as CreateOrEditListModal from './CreateOrEditList' import * as UserAddRemoveLists from './UserAddRemoveLists' import * as ListAddUserModal from './ListAddRemoveUsers' @@ -23,10 +21,8 @@ import * as EditImageModal from './EditImage' import * as ChangeHandleModal from './ChangeHandle' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' -import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' -import * as ModerationDetailsModal from './ModerationDetails' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as ChangePasswordModal from './ChangePassword' @@ -78,10 +74,6 @@ function Modal({modal}: {modal: ModalIface}) { let element if (modal.name === 'edit-profile') { element = <EditProfileModal.Component {...modal} /> - } else if (modal.name === 'report') { - element = <ReportModal.Component {...modal} /> - } else if (modal.name === 'appeal-label') { - element = <AppealLabelModal.Component {...modal} /> } else if (modal.name === 'create-or-edit-list') { element = <CreateOrEditListModal.Component {...modal} /> } else if (modal.name === 'user-add-remove-lists') { @@ -104,8 +96,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <InviteCodesModal.Component /> } else if (modal.name === 'add-app-password') { element = <AddAppPassword.Component /> - } else if (modal.name === 'content-filtering-settings') { - element = <ContentFilteringSettingsModal.Component /> } else if (modal.name === 'content-languages-settings') { element = <ContentLanguagesSettingsModal.Component /> } else if (modal.name === 'post-languages-settings') { @@ -114,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <AltTextImageModal.Component {...modal} /> } else if (modal.name === 'edit-image') { element = <EditImageModal.Component {...modal} /> - } else if (modal.name === 'moderation-details') { - element = <ModerationDetailsModal.Component {...modal} /> } else if (modal.name === 'verify-email') { element = <VerifyEmailModal.Component {...modal} /> } else if (modal.name === 'change-email') { diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx deleted file mode 100644 index f890d50d..00000000 --- a/src/view/com/modals/ModerationDetails.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {ModerationUI} from '@atproto/api' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {Text} from '../util/text/Text' -import {TextLink} from '../util/Link' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb} from 'platform/detection' -import {listUriToHref} from 'lib/strings/url-helpers' -import {Button} from '../util/forms/Button' -import {useModalControls} from '#/state/modals' -import {useLingui} from '@lingui/react' -import {Trans, msg} from '@lingui/macro' - -export const snapPoints = [300] - -export function Component({ - context, - moderation, -}: { - context: 'account' | 'content' - moderation: ModerationUI -}) { - const {closeModal} = useModalControls() - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') - const {_} = useLingui() - - let name - let description - if (!moderation.cause) { - name = _(msg`Content Warning`) - description = _( - msg`Moderator has chosen to set a general warning on the content.`, - ) - } else if (moderation.cause.type === 'blocking') { - if (moderation.cause.source.type === 'list') { - const list = moderation.cause.source.list - name = _(msg`User Blocked by List`) - description = ( - <Trans> - This user is included in the{' '} - <TextLink - type="2xl" - href={listUriToHref(list.uri)} - text={list.name} - style={pal.link} - />{' '} - 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 (moderation.cause.type === 'blocked-by') { - name = _(msg`User Blocks You`) - description = _( - msg`This user has blocked you. You cannot view their content.`, - ) - } else if (moderation.cause.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 (moderation.cause.type === 'muted') { - if (moderation.cause.source.type === 'list') { - const list = moderation.cause.source.list - name = _(msg`Account Muted by List`) - description = ( - <Trans> - This user is included in the{' '} - <TextLink - type="2xl" - href={listUriToHref(list.uri)} - text={list.name} - style={pal.link} - />{' '} - list which you have muted. - </Trans> - ) - } else { - name = _(msg`Account Muted`) - description = _(msg`You have muted this user.`) - } - } else { - name = moderation.cause.labelDef.strings[context].en.name - description = moderation.cause.labelDef.strings[context].en.description - } - - return ( - <View - testID="moderationDetailsModal" - style={[ - styles.container, - { - paddingHorizontal: isMobile ? 14 : 0, - }, - pal.view, - ]}> - <Text type="title-xl" style={[pal.text, styles.title]}> - {name} - </Text> - <Text type="2xl" style={[pal.text, styles.description]}> - {description} - </Text> - <View style={s.flex1} /> - <Button - type="primary" - style={styles.btn} - onPress={() => { - closeModal() - }}> - <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}> - Okay - </Text> - </Button> - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - marginBottom: 12, - }, - description: { - textAlign: 'center', - }, - btn: { - paddingVertical: 14, - marginTop: isWeb ? 40 : 0, - marginBottom: isWeb ? 0 : 40, - }, -}) diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx deleted file mode 100644 index 2bc86f75..00000000 --- a/src/view/com/modals/report/InputIssueDetails.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react' -import {View, TouchableOpacity, StyleSheet} from 'react-native' -import {TextInput} from '../util' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {CharProgress} from '../../composer/char-progress/CharProgress' -import {Text} from '../../util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {SendReportButton} from './SendReportButton' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -export function InputIssueDetails({ - details, - setDetails, - goBack, - submitReport, - isProcessing, -}: { - details: string | undefined - setDetails: (v: string) => void - goBack: () => void - submitReport: () => void - isProcessing: boolean -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {isMobile} = useWebMediaQueries() - - return ( - <View - style={{ - marginTop: isMobile ? 12 : 0, - }}> - <TouchableOpacity - testID="addDetailsBtn" - style={[s.mb10, styles.backBtn]} - onPress={goBack} - accessibilityRole="button" - accessibilityLabel={_(msg`Add details`)} - accessibilityHint="Add more details to your report"> - <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} /> - <Text style={[pal.text, s.f18, pal.link]}> - {' '} - <Trans>Back</Trans> - </Text> - </TouchableOpacity> - <View style={[pal.btn, styles.detailsInputContainer]}> - <TextInput - accessibilityLabel={_(msg`Text input field`)} - accessibilityHint="Enter a reason for reporting this post." - placeholder="Enter a reason or any other details here." - placeholderTextColor={pal.textLight.color} - value={details} - onChangeText={setDetails} - autoFocus={true} - numberOfLines={3} - multiline={true} - textAlignVertical="top" - maxLength={300} - style={[styles.detailsInput, pal.text]} - /> - <View style={styles.detailsInputBottomBar}> - <View style={styles.charCounter}> - <CharProgress count={details?.length || 0} /> - </View> - </View> - </View> - <SendReportButton onPress={submitReport} isProcessing={isProcessing} /> - </View> - ) -} - -const styles = StyleSheet.create({ - backBtn: { - flexDirection: 'row', - alignItems: 'center', - }, - detailsInputContainer: { - borderRadius: 8, - }, - detailsInput: { - paddingHorizontal: 12, - paddingTop: 12, - paddingBottom: 12, - borderRadius: 8, - minHeight: 100, - fontSize: 16, - }, - detailsInputBottomBar: { - alignSelf: 'flex-end', - }, - charCounter: { - flexDirection: 'row', - alignItems: 'center', - paddingRight: 10, - paddingBottom: 8, - }, -}) diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx deleted file mode 100644 index abbad9b4..00000000 --- a/src/view/com/modals/report/Modal.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, {useState, useMemo} from 'react' -import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' -import {AtUri} from '@atproto/api' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {Text} from '../../util/text/Text' -import * as Toast from '../../util/Toast' -import {ErrorMessage} from '../../util/error/ErrorMessage' -import {cleanError} from 'lib/strings/errors' -import {usePalette} from 'lib/hooks/usePalette' -import {SendReportButton} from './SendReportButton' -import {InputIssueDetails} from './InputIssueDetails' -import {ReportReasonOptions} from './ReasonOptions' -import {CollectionId} from './types' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {getAgent} from '#/state/session' - -const DMCA_LINK = 'https://bsky.social/about/support/copyright' - -export const snapPoints = [575] - -const CollectionNames = { - [CollectionId.FeedGenerator]: 'Feed', - [CollectionId.Profile]: 'Profile', - [CollectionId.List]: 'List', - [CollectionId.Post]: 'Post', -} - -type ReportComponentProps = - | { - uri: string - cid: string - } - | { - did: string - } - -export function Component(content: ReportComponentProps) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() - const [isProcessing, setIsProcessing] = useState(false) - const [showDetailsInput, setShowDetailsInput] = useState(false) - const [error, setError] = useState<string>('') - const [issue, setIssue] = useState<string>('') - const [details, setDetails] = useState<string>('') - const isAccountReport = 'did' in content - const subjectKey = isAccountReport ? content.did : content.uri - const atUri = useMemo( - () => (!isAccountReport ? new AtUri(subjectKey) : null), - [isAccountReport, subjectKey], - ) - - const submitReport = async () => { - setError('') - if (!issue) { - return - } - setIsProcessing(true) - try { - if (issue === '__copyright__') { - Linking.openURL(DMCA_LINK) - closeModal() - return - } - const $type = !isAccountReport - ? 'com.atproto.repo.strongRef' - : 'com.atproto.admin.defs#repoRef' - await getAgent().createModerationReport({ - reasonType: issue, - subject: { - $type, - ...content, - }, - reason: details, - }) - Toast.show("Thank you for your report! We'll look into it promptly.") - - closeModal() - return - } catch (e: any) { - setError(cleanError(e)) - setIsProcessing(false) - } - } - - const goBack = () => { - setShowDetailsInput(false) - } - - return ( - <ScrollView testID="reportModal" style={[s.flex1, pal.view]}> - <View - style={[ - styles.container, - isMobile && { - paddingBottom: 40, - }, - ]}> - {showDetailsInput ? ( - <InputIssueDetails - details={details} - setDetails={setDetails} - goBack={goBack} - submitReport={submitReport} - isProcessing={isProcessing} - /> - ) : ( - <SelectIssue - setShowDetailsInput={setShowDetailsInput} - error={error} - issue={issue} - setIssue={setIssue} - submitReport={submitReport} - isProcessing={isProcessing} - atUri={atUri} - /> - )} - </View> - </ScrollView> - ) -} - -// If no atUri is passed, that means the reporting collection is account -const getCollectionNameForReport = (atUri: AtUri | null) => { - if (!atUri) return 'Account' - // Generic fallback for any collection being reported - return CollectionNames[atUri.collection as CollectionId] || 'Content' -} - -const SelectIssue = ({ - error, - setShowDetailsInput, - issue, - setIssue, - submitReport, - isProcessing, - atUri, -}: { - error: string | undefined - setShowDetailsInput: (v: boolean) => void - issue: string | undefined - setIssue: (v: string) => void - submitReport: () => void - isProcessing: boolean - atUri: AtUri | null -}) => { - const pal = usePalette('default') - const {_} = useLingui() - const collectionName = getCollectionNameForReport(atUri) - const onSelectIssue = (v: string) => setIssue(v) - const goToDetails = () => { - if (issue === '__copyright__') { - Linking.openURL(DMCA_LINK) - return - } - setShowDetailsInput(true) - } - - return ( - <> - <Text style={[pal.text, styles.title]}> - <Trans>Report {collectionName}</Trans> - </Text> - <Text style={[pal.textLight, styles.description]}> - <Trans>What is the issue with this {collectionName}?</Trans> - </Text> - <View style={{marginBottom: 10}}> - <ReportReasonOptions - atUri={atUri} - selectedIssue={issue} - onSelectIssue={onSelectIssue} - /> - </View> - {error ? <ErrorMessage message={error} /> : undefined} - {/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */} - {issue || !atUri ? ( - <> - <SendReportButton - onPress={submitReport} - isProcessing={isProcessing} - /> - <TouchableOpacity - testID="addDetailsBtn" - style={styles.addDetailsBtn} - onPress={goToDetails} - accessibilityRole="button" - accessibilityLabel={_(msg`Add details`)} - accessibilityHint="Add more details to your report"> - <Text style={[s.f18, pal.link]}> - <Trans>Add details to report</Trans> - </Text> - </TouchableOpacity> - </> - ) : undefined} - </> - ) -} - -const styles = StyleSheet.create({ - container: { - paddingHorizontal: 10, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - fontSize: 24, - marginBottom: 12, - }, - description: { - textAlign: 'center', - fontSize: 17, - paddingHorizontal: 22, - marginBottom: 10, - }, - addDetailsBtn: { - padding: 14, - alignSelf: 'center', - }, -}) diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx deleted file mode 100644 index 23b49b66..00000000 --- a/src/view/com/modals/report/ReasonOptions.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import {View} from 'react-native' -import React, {useMemo} from 'react' -import {AtUri, ComAtprotoModerationDefs} from '@atproto/api' - -import {Text} from '../../util/text/Text' -import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette' -import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup' -import {CollectionId} from './types' - -type ReasonMap = Record<string, {title: string; description: string}> -const CommonReasons = { - [ComAtprotoModerationDefs.REASONRUDE]: { - title: 'Anti-Social Behavior', - description: 'Harassment, trolling, or intolerance', - }, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: 'Illegal and Urgent', - description: 'Glaring violations of law or terms of service', - }, - [ComAtprotoModerationDefs.REASONOTHER]: { - title: 'Other', - description: 'An issue not included in these options', - }, -} -const CollectionToReasonsMap: Record<string, ReasonMap> = { - [CollectionId.Post]: { - [ComAtprotoModerationDefs.REASONSPAM]: { - title: 'Spam', - description: 'Excessive mentions or replies', - }, - [ComAtprotoModerationDefs.REASONSEXUAL]: { - title: 'Unwanted Sexual Content', - description: 'Nudity or pornography not labeled as such', - }, - __copyright__: { - title: 'Copyright Violation', - description: 'Contains copyrighted material', - }, - ...CommonReasons, - }, - [CollectionId.List]: { - ...CommonReasons, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: 'Name or Description Violates Community Standards', - description: 'Terms used violate community standards', - }, - }, -} -const AccountReportReasons = { - [ComAtprotoModerationDefs.REASONMISLEADING]: { - title: 'Misleading Account', - description: 'Impersonation or false claims about identity or affiliation', - }, - [ComAtprotoModerationDefs.REASONSPAM]: { - title: 'Frequently Posts Unwanted Content', - description: 'Spam; excessive mentions or replies', - }, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: 'Name or Description Violates Community Standards', - description: 'Terms used violate community standards', - }, -} - -const Option = ({ - pal, - title, - description, -}: { - pal: UsePaletteValue - description: string - title: string -}) => { - return ( - <View> - <Text style={pal.text} type="md-bold"> - {title} - </Text> - <Text style={pal.textLight}>{description}</Text> - </View> - ) -} - -// This is mostly just content copy without almost any logic -// so this may grow over time and it makes sense to split it up into its own file -// to keep it separate from the actual reporting modal logic -const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) => - useMemo(() => { - let items: ReasonMap = {...CommonReasons} - // If no atUri is passed, that means the reporting collection is account - if (!atUri) { - items = {...AccountReportReasons} - } - - if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) { - items = {...CollectionToReasonsMap[atUri.collection]} - } - - return Object.entries(items).map(([key, {title, description}]) => ({ - key, - label: <Option pal={pal} title={title} description={description} />, - })) - }, [pal, atUri]) - -export const ReportReasonOptions = ({ - atUri, - selectedIssue, - onSelectIssue, -}: { - atUri: AtUri | null - selectedIssue?: string - onSelectIssue: (key: string) => void -}) => { - const pal = usePalette('default') - const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri) - return ( - <RadioGroup - items={ITEMS} - onSelect={onSelectIssue} - testID="reportReasonRadios" - initialSelection={selectedIssue} - /> - ) -} diff --git a/src/view/com/modals/report/SendReportButton.tsx b/src/view/com/modals/report/SendReportButton.tsx deleted file mode 100644 index 40c239bf..00000000 --- a/src/view/com/modals/report/SendReportButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import LinearGradient from 'react-native-linear-gradient' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {Text} from '../../util/text/Text' -import {s, gradients, colors} from 'lib/styles' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -export function SendReportButton({ - onPress, - isProcessing, -}: { - onPress: () => void - isProcessing: boolean -}) { - const {_} = useLingui() - // loading state - // = - if (isProcessing) { - return ( - <View style={[styles.btn, s.mt10]}> - <ActivityIndicator /> - </View> - ) - } - return ( - <TouchableOpacity - testID="sendReportBtn" - style={s.mt10} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={_(msg`Report post`)} - accessibilityHint={`Reports post with reason and details`}> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}> - <Trans>Send Report</Trans> - </Text> - </LinearGradient> - </TouchableOpacity> - ) -} - -const styles = StyleSheet.create({ - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 14, - backgroundColor: colors.gray1, - }, -}) diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts deleted file mode 100644 index ca947ecb..00000000 --- a/src/view/com/modals/report/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: ATM, @atproto/api does not export ids but it does have these listed at @atproto/api/client/lexicons -// once we start exporting the ids from the @atproto/ap package, replace these hardcoded ones -export enum CollectionId { - FeedGenerator = 'app.bsky.feed.generator', - Profile = 'app.bsky.actor.profile', - List = 'app.bsky.graph.list', - Post = 'app.bsky.feed.post', -} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index a4687026..b1655479 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -11,7 +11,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, ModerationOpts, - ProfileModeration, + ModerationDecision, moderateProfile, AppBskyEmbedRecordWithMedia, } from '@atproto/api' @@ -54,7 +54,7 @@ interface Author { handle: string displayName?: string avatar?: string - moderation: ProfileModeration + moderation: ModerationDecision } let FeedItem = ({ @@ -336,7 +336,7 @@ function CondensedAuthorsList({ did={authors[0].did} handle={authors[0].handle} avatar={authors[0].avatar} - moderation={authors[0].moderation.avatar} + moderation={authors[0].moderation.ui('avatar')} /> </View> ) @@ -354,7 +354,7 @@ function CondensedAuthorsList({ <UserAvatar size={35} avatar={author.avatar} - moderation={author.moderation.avatar} + moderation={author.moderation.ui('avatar')} /> </View> ))} @@ -412,7 +412,7 @@ function ExpandedAuthorsList({ <UserAvatar size={35} avatar={author.avatar} - moderation={author.moderation.avatar} + moderation={author.moderation.ui('avatar')} /> </View> <View style={s.flex1}> diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 55463dc1..0760ed7f 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -8,7 +8,7 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {logger} from '#/logger' import {LoadingScreen} from '../util/LoadingScreen' import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {usePostLikedByQuery} from '#/state/queries/post-liked-by' +import {useLikedByQuery} from '#/state/queries/post-liked-by' import {cleanError} from '#/lib/strings/errors' export function PostLikedBy({uri}: {uri: string}) { @@ -28,7 +28,7 @@ export function PostLikedBy({uri}: {uri: string}) { isError, error, refetch, - } = usePostLikedByQuery(resolvedUri?.uri) + } = useLikedByQuery(resolvedUri?.uri) const likes = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.likes) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index a7ee42a9..bac7018c 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -106,11 +106,12 @@ export function PostThread({ ? moderatePost(rootPost, moderationOpts) : undefined - const cause = mod?.content.cause - - return cause - ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated' - : false + return !!mod + ?.ui('contentList') + .blurs.find( + cause => + cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated', + ) }, [rootPost, moderationOpts]) useSetTitle( diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index cd746f9a..c073b55a 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -5,7 +5,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, RichText as RichTextAPI, - PostModeration, + ModerationDecision, } from '@atproto/api' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' @@ -19,14 +19,14 @@ import {niceDate} from 'lib/strings/time' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {countLines, pluralize} from 'lib/strings/helpers' -import {isEmbedByEmbedder} from 'lib/embeds' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/post-ctrls/PostCtrls' -import {PostHider} from '../util/moderation/PostHider' -import {ContentHider} from '../util/moderation/ContentHider' -import {PostAlerts} from '../util/moderation/PostAlerts' +import {PostHider} from '../../../components/moderation/PostHider' +import {ContentHider} from '../../../components/moderation/ContentHider' +import {PostAlerts} from '../../../components/moderation/PostAlerts' +import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' import {formatCount} from '../util/numeric/format' @@ -147,7 +147,7 @@ let PostThreadItemLoaded = ({ post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record richText: RichTextAPI - moderation: PostModeration + moderation: ModerationDecision treeView: boolean depth: number prevPost: ThreadPost | undefined @@ -175,7 +175,6 @@ let PostThreadItemLoaded = ({ const itemTitle = _(msg`Post by ${post.author.handle}`) const authorHref = makeProfileLink(post.author) const authorTitle = post.author.handle - const isAuthorMuted = post.author.viewer?.muted const likesHref = React.useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') @@ -256,7 +255,7 @@ let PostThreadItemLoaded = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> @@ -271,35 +270,12 @@ let PostThreadItemLoaded = ({ {sanitizeDisplayName( post.author.displayName || sanitizeHandle(post.author.handle), + moderation.ui('displayName'), )} </Text> </Link> </View> <View style={styles.meta}> - {isAuthorMuted && ( - <View - style={[ - pal.viewLight, - { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - borderRadius: 6, - paddingHorizontal: 6, - paddingVertical: 2, - marginRight: 4, - }, - ]}> - <FontAwesomeIcon - icon={['far', 'eye-slash']} - size={12} - color={pal.colors.textLight} - /> - <Text type="sm-medium" style={pal.textLight}> - Muted - </Text> - </View> - )} <Link style={s.flex1} href={authorHref} title={authorTitle}> <Text type="md" style={[pal.textLight]} numberOfLines={1}> {sanitizeHandle(post.author.handle, '@')} @@ -312,15 +288,16 @@ let PostThreadItemLoaded = ({ )} </View> <View style={[s.pl10, s.pr10, s.pb10]}> + <LabelsOnMyPost post={post} /> <ContentHider - moderation={moderation.content} + modui={moderation.ui('contentView')} ignoreMute style={styles.contentHider} childContainerStyle={styles.contentHiderChild}> <PostAlerts - moderation={moderation.content} + modui={moderation.ui('contentView')} includeMute - style={styles.alert} + style={[a.pt_2xs, a.pb_sm]} /> {richText?.text ? ( <View @@ -338,18 +315,9 @@ let PostThreadItemLoaded = ({ </View> ) : undefined} {post.embed && ( - <ContentHider - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} - ignoreQuoteDecisions - style={s.mb10}> - <PostEmbeds - embed={post.embed} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - /> - </ContentHider> + <View style={[a.pb_sm]}> + <PostEmbeds embed={post.embed} moderation={moderation} /> + </View> )} </ContentHider> <ExpandedPostDetails @@ -432,7 +400,8 @@ let PostThreadItemLoaded = ({ <PostHider testID={`postThreadItem-by-${post.author.handle}`} href={postHref} - moderation={moderation.content} + style={[pal.view]} + modui={moderation.ui('contentList')} iconSize={isThreadedChild ? 26 : 38} iconStyles={ isThreadedChild @@ -482,7 +451,7 @@ let PostThreadItemLoaded = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> {showChildReplyLine && ( @@ -508,19 +477,21 @@ let PostThreadItemLoaded = ({ }> <PostMeta author={post.author} + moderation={moderation} authorHasWarning={!!post.author.labels?.length} timestamp={post.indexedAt} postHref={postHref} showAvatar={isThreadedChild} - avatarModeration={moderation.avatar} + avatarModeration={moderation.ui('avatar')} avatarSize={28} displayNameType="md-bold" displayNameStyle={isThreadedChild && s.ml2} style={isThreadedChild && s.mb2} /> + <LabelsOnMyPost post={post} /> <PostAlerts - moderation={moderation.content} - style={styles.alert} + modui={moderation.ui('contentList')} + style={[a.pt_xs, a.pb_sm]} /> {richText?.text ? ( <View style={styles.postTextContainer}> @@ -542,18 +513,9 @@ let PostThreadItemLoaded = ({ /> ) : undefined} {post.embed && ( - <ContentHider - style={styles.contentHider} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} - ignoreQuoteDecisions> - <PostEmbeds - embed={post.embed} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - /> - </ContentHider> + <View style={[a.pb_xs]}> + <PostEmbeds embed={post.embed} moderation={moderation} /> + </View> )} <PostCtrls post={post} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 7e53eb27..c7bd4ba2 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -4,7 +4,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AtUri, - PostModeration, + ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' @@ -14,8 +14,9 @@ import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/post-ctrls/PostCtrls' -import {ContentHider} from '../util/moderation/ContentHider' -import {PostAlerts} from '../util/moderation/PostAlerts' +import {ContentHider} from '../../../components/moderation/ContentHider' +import {PostAlerts} from '../../../components/moderation/PostAlerts' +import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {Text} from '../util/text/Text' import {RichText} from '#/components/RichText' import {PreviewableUserAvatar} from '../util/UserAvatar' @@ -93,7 +94,7 @@ function PostInner({ post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record richText: RichTextAPI - moderation: PostModeration + moderation: ModerationDecision showReplyLine?: boolean style?: StyleProp<ViewStyle> }) { @@ -142,12 +143,13 @@ function PostInner({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> <PostMeta author={post.author} + moderation={moderation} authorHasWarning={!!post.author.labels?.length} timestamp={post.indexedAt} postHref={itemHref} @@ -176,11 +178,15 @@ function PostInner({ </Text> </View> )} + <LabelsOnMyPost post={post} /> <ContentHider - moderation={moderation.content} + modui={moderation.ui('contentView')} style={styles.contentHider} childContainerStyle={styles.contentHiderChild}> - <PostAlerts moderation={moderation.content} style={styles.alert} /> + <PostAlerts + modui={moderation.ui('contentView')} + style={[a.py_xs]} + /> {richText.text ? ( <View style={styles.postTextContainer}> <RichText @@ -202,17 +208,7 @@ function PostInner({ /> ) : undefined} {post.embed ? ( - <ContentHider - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - ignoreQuoteDecisions - style={styles.contentHider}> - <PostEmbeds - embed={post.embed} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - /> - </ContentHider> + <PostEmbeds embed={post.embed} moderation={moderation} /> ) : null} </ContentHider> <PostCtrls diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index f3911da6..0706ddb9 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -4,7 +4,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AtUri, - PostModeration, + ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import { @@ -18,8 +18,9 @@ import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostEmbeds} from '../util/post-embeds' -import {ContentHider} from '../util/moderation/ContentHider' -import {PostAlerts} from '../util/moderation/PostAlerts' +import {ContentHider} from '#/components/moderation/ContentHider' +import {PostAlerts} from '../../../components/moderation/PostAlerts' +import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {RichText} from '#/components/RichText' import {PreviewableUserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' @@ -27,13 +28,11 @@ import {usePalette} from 'lib/hooks/usePalette' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' -import {isEmbedByEmbedder} from 'lib/embeds' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' import {useComposerControls} from '#/state/shell/composer' import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' import {FeedNameText} from '../util/FeedInfoText' -import {useSession} from '#/state/session' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {atoms as a} from '#/alf' @@ -50,7 +49,7 @@ export function FeedItem({ post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined - moderation: PostModeration + moderation: ModerationDecision isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean @@ -100,7 +99,7 @@ let FeedItemInner = ({ record: AppBskyFeedPost.Record reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined richText: RichTextAPI - moderation: PostModeration + moderation: ModerationDecision isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean @@ -108,14 +107,10 @@ let FeedItemInner = ({ const {openComposer} = useComposerControls() const pal = usePalette('default') const {_} = useLingui() - const {currentAccount} = useSession() const href = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey) }, [post.uri, post.author]) - const isModeratedPost = - moderation.decisions.post.cause?.type === 'label' && - moderation.decisions.post.cause.label.src !== currentAccount?.did const replyAuthorDid = useMemo(() => { if (!record?.reply) { @@ -148,7 +143,7 @@ let FeedItemInner = ({ borderColor: pal.colors.border, paddingBottom: isThreadLastChild || (!isThreadChild && !isThreadParent) - ? 6 + ? 8 : undefined, }, isThreadChild ? styles.outerSmallTop : undefined, @@ -229,6 +224,7 @@ let FeedItemInner = ({ numberOfLines={1} text={sanitizeDisplayName( reason.by.displayName || sanitizeHandle(reason.by.handle), + moderation.ui('displayName'), )} href={makeProfileLink(reason.by)} /> @@ -246,7 +242,7 @@ let FeedItemInner = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> {isThreadParent && ( <View @@ -264,6 +260,7 @@ let FeedItemInner = ({ <View style={styles.layoutContent}> <PostMeta author={post.author} + moderation={moderation} authorHasWarning={!!post.author.labels?.length} timestamp={post.indexedAt} postHref={href} @@ -295,6 +292,7 @@ let FeedItemInner = ({ </Text> </View> )} + <LabelsOnMyPost post={post} /> <PostContent moderation={moderation} richText={richText} @@ -306,9 +304,6 @@ let FeedItemInner = ({ record={record} richText={richText} onPressReply={onPressReply} - showAppealLabelItem={ - post.author.did === currentAccount?.did && isModeratedPost - } logContext="FeedItem" /> </View> @@ -324,7 +319,7 @@ let PostContent = ({ postEmbed, postAuthor, }: { - moderation: PostModeration + moderation: ModerationDecision richText: RichTextAPI postEmbed: AppBskyFeedDefs.PostView['embed'] postAuthor: AppBskyFeedDefs.PostView['author'] @@ -342,10 +337,10 @@ let PostContent = ({ return ( <ContentHider testID="contentHider-post" - moderation={moderation.content} + modui={moderation.ui('contentList')} ignoreMute childContainerStyle={styles.contentHiderChild}> - <PostAlerts moderation={moderation.content} style={styles.alert} /> + <PostAlerts modui={moderation.ui('contentList')} style={[a.py_xs]} /> {richText.text ? ( <View style={styles.postTextContainer}> <RichText @@ -367,19 +362,9 @@ let PostContent = ({ /> ) : undefined} {postEmbed ? ( - <ContentHider - testID="contentHider-embed" - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - ignoreMute={isEmbedByEmbedder(postEmbed, postAuthor.did)} - ignoreQuoteDecisions - style={styles.embed}> - <PostEmbeds - embed={postEmbed} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - /> - </ContentHider> + <View style={[a.pb_sm]}> + <PostEmbeds embed={postEmbed} moderation={moderation} /> + </View> ) : null} </ContentHider> ) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 019e6c10..d909bda8 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -3,7 +3,8 @@ import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import { AppBskyActorDefs, moderateProfile, - ProfileModeration, + ModerationCause, + ModerationDecision, } from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' @@ -14,16 +15,13 @@ import {FollowButton} from './FollowButton' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' -import { - describeModerationCause, - getProfileModerationCauses, - getModerationCauseKey, -} from 'lib/moderation' +import {getModerationCauseKey, isJustAMute} from 'lib/moderation' import {Shadow} from '#/state/cache/types' import {useModerationOpts} from '#/state/queries/preferences' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import {Trans} from '@lingui/macro' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' export function ProfileCard({ testID, @@ -33,6 +31,7 @@ export function ProfileCard({ noBorder, followers, renderButton, + onPress, style, }: { testID?: string @@ -44,6 +43,7 @@ export function ProfileCard({ renderButton?: ( profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, ) => React.ReactNode + onPress?: () => void style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -53,11 +53,8 @@ export function ProfileCard({ return null } const moderation = moderateProfile(profile, moderationOpts) - if ( - !noModFilter && - moderation.account.filter && - moderation.account.cause?.type !== 'muted' - ) { + const modui = moderation.ui('profileList') + if (!noModFilter && modui.filter && !isJustAMute(modui)) { return null } @@ -73,6 +70,7 @@ export function ProfileCard({ ]} href={makeProfileLink(profile)} title={profile.handle} + onBeforePress={onPress} asAnchor anchorNoUnderline> <View style={styles.layout}> @@ -80,7 +78,7 @@ export function ProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> @@ -91,7 +89,7 @@ export function ProfileCard({ lineHeight={1.2}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> @@ -119,17 +117,17 @@ export function ProfileCard({ ) } -function ProfileCardPills({ +export function ProfileCardPills({ followedBy, moderation, }: { followedBy: boolean - moderation: ProfileModeration + moderation: ModerationDecision }) { const pal = usePalette('default') - const causes = getProfileModerationCauses(moderation) - if (!followedBy && !causes.length) { + const modui = moderation.ui('profileList') + if (!followedBy && !modui.inform && !modui.alert) { return null } @@ -142,19 +140,41 @@ function ProfileCardPills({ </Text> </View> )} - {causes.map(cause => { - const desc = describeModerationCause(cause, 'account') - return ( - <View - style={[s.mt5, pal.btn, styles.pill]} - key={getModerationCauseKey(cause)}> - <Text type="xs" style={pal.text}> - {cause?.type === 'label' ? '⚠' : ''} - {desc.name} - </Text> - </View> - ) - })} + {modui.alerts.map(alert => ( + <ProfileCardPillModerationCause + key={getModerationCauseKey(alert)} + cause={alert} + severity="alert" + /> + ))} + {modui.informs.map(inform => ( + <ProfileCardPillModerationCause + key={getModerationCauseKey(inform)} + cause={inform} + severity="inform" + /> + ))} + </View> + ) +} + +function ProfileCardPillModerationCause({ + cause, + severity, +}: { + cause: ModerationCause + severity: 'alert' | 'inform' +}) { + const pal = usePalette('default') + const {name} = useModerationCauseDescription(cause) + return ( + <View + style={[s.mt5, pal.btn, styles.pill]} + key={getModerationCauseKey(cause)}> + <Text type="xs" style={pal.text}> + {severity === 'alert' ? '⚠ ' : ''} + {name} + </Text> </View> ) } @@ -177,7 +197,7 @@ function FollowersList({ f, mod: moderateProfile(f, moderationOpts), })) - .filter(({mod}) => !mod.account.filter) + .filter(({mod}) => !mod.ui('profileList').filter) }, [followers, moderationOpts]) if (!followersWithMods?.length) { @@ -199,7 +219,11 @@ function FollowersList({ {followersWithMods.slice(0, 3).map(({f, mod}) => ( <View key={f.did} style={styles.followedByAviContainer}> <View style={[styles.followedByAvi, pal.view]}> - <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} /> + <UserAvatar + avatar={f.avatar} + size={32} + moderation={mod.ui('avatar')} + /> </View> </View> ))} @@ -212,11 +236,13 @@ export function ProfileCardWithFollowBtn({ noBg, noBorder, followers, + onPress, }: { profile: AppBskyActorDefs.ProfileViewBasic noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined + onPress?: () => void }) { const {currentAccount} = useSession() const isMe = profile.did === currentAccount?.did @@ -234,6 +260,7 @@ export function ProfileCardWithFollowBtn({ <FollowButton profile={profileShadow} logContext="ProfileCard" /> ) } + onPress={onPress} /> ) } diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx deleted file mode 100644 index 17dc5ce1..00000000 --- a/src/view/com/profile/ProfileHeader.tsx +++ /dev/null @@ -1,598 +0,0 @@ -import React, {memo, useMemo} from 'react' -import { - StyleSheet, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useNavigation} from '@react-navigation/native' -import { - AppBskyActorDefs, - ModerationOpts, - moderateProfile, - RichText as RichTextAPI, -} from '@atproto/api' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {NavigationProp} from 'lib/routes/types' -import {isNative} from 'platform/detection' -import {BlurView} from '../util/BlurView' -import * as Toast from '../util/Toast' -import {LoadingPlaceholder} from '../util/LoadingPlaceholder' -import {Text} from '../util/text/Text' -import {ThemedText} from '../util/text/ThemedText' -import {RichText} from '#/components/RichText' -import {UserAvatar} from '../util/UserAvatar' -import {UserBanner} from '../util/UserBanner' -import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' -import {formatCount} from '../util/numeric/format' -import {Link} from '../util/Link' -import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' -import {useModalControls} from '#/state/modals' -import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' -import { - useProfileBlockMutationQueue, - useProfileFollowMutationQueue, -} from '#/state/queries/profile' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {BACK_HITSLOP} from 'lib/constants' -import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' -import {makeProfileLink} from 'lib/routes/links' -import {pluralize} from 'lib/strings/helpers' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {s, colors} from 'lib/styles' -import {logger} from '#/logger' -import {useSession} from '#/state/session' -import {Shadow} from '#/state/cache/types' -import {useRequireAuth} from '#/state/session' -import {LabelInfo} from '../util/moderation/LabelInfo' -import {useProfileShadow} from 'state/cache/profile-shadow' -import {atoms as a} from '#/alf' -import {ProfileMenu} from 'view/com/profile/ProfileMenu' -import * as Prompt from '#/components/Prompt' - -let ProfileHeaderLoading = (_props: {}): React.ReactNode => { - const pal = usePalette('default') - return ( - <View style={pal.view}> - <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} /> - <View - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <LoadingPlaceholder width={80} height={80} style={styles.br40} /> - </View> - <View style={styles.content}> - <View style={[styles.buttonsLine]}> - <LoadingPlaceholder width={167} height={31} style={styles.br50} /> - </View> - </View> - </View> - ) -} -ProfileHeaderLoading = memo(ProfileHeaderLoading) -export {ProfileHeaderLoading} - -interface Props { - profile: AppBskyActorDefs.ProfileViewDetailed - descriptionRT: RichTextAPI | null - moderationOpts: ModerationOpts - hideBackButton?: boolean - isPlaceholderProfile?: boolean -} - -let ProfileHeader = ({ - profile: profileUnshadowed, - descriptionRT, - moderationOpts, - hideBackButton = false, - isPlaceholderProfile, -}: Props): React.ReactNode => { - const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = - useProfileShadow(profileUnshadowed) - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const {currentAccount, hasSession} = useSession() - const requireAuth = useRequireAuth() - const {_} = useLingui() - const {openModal} = useModalControls() - const {openLightbox} = useLightboxControls() - const navigation = useNavigation<NavigationProp>() - const {track} = useAnalytics() - const invalidHandle = isInvalidHandle(profile.handle) - const {isDesktop} = useWebMediaQueries() - const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( - profile, - 'ProfileHeader', - ) - const [__, queueUnblock] = useProfileBlockMutationQueue(profile) - const unblockPromptControl = Prompt.usePromptControl() - const moderation = useMemo( - () => moderateProfile(profile, moderationOpts), - [profile, moderationOpts], - ) - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - const onPressAvi = React.useCallback(() => { - if ( - profile.avatar && - !(moderation.avatar.blur && moderation.avatar.noOverride) - ) { - openLightbox(new ProfileImageLightbox(profile)) - } - }, [openLightbox, profile, moderation]) - - const onPressFollow = () => { - requireAuth(async () => { - try { - track('ProfileHeader:FollowButtonClicked') - await queueFollow() - Toast.show( - _( - msg`Following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, - ), - ) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to follow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }) - } - - const onPressUnfollow = () => { - requireAuth(async () => { - try { - track('ProfileHeader:UnfollowButtonClicked') - await queueUnfollow() - Toast.show( - _( - msg`No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, - ), - ) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unfollow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }) - } - - const onPressEditProfile = React.useCallback(() => { - track('ProfileHeader:EditProfileButtonClicked') - openModal({ - name: 'edit-profile', - profile, - }) - }, [track, openModal, profile]) - - const unblockAccount = React.useCallback(async () => { - track('ProfileHeader:UnblockAccountButtonClicked') - try { - await queueUnblock() - Toast.show(_(msg`Account unblocked`)) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unblock account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }, [_, queueUnblock, track]) - - const isMe = React.useMemo( - () => currentAccount?.did === profile.did, - [currentAccount, profile], - ) - - const blockHide = - !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) - const following = formatCount(profile.followsCount || 0) - const followers = formatCount(profile.followersCount || 0) - const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') - - return ( - <View style={[pal.view]} pointerEvents="box-none"> - <View pointerEvents="none"> - {isPlaceholderProfile ? ( - <LoadingPlaceholder - width="100%" - height={150} - style={{borderRadius: 0}} - /> - ) : ( - <UserBanner banner={profile.banner} moderation={moderation.avatar} /> - )} - </View> - <View style={styles.content} pointerEvents="box-none"> - <View style={[styles.buttonsLine]} pointerEvents="box-none"> - {isMe ? ( - <TouchableOpacity - testID="profileHeaderEditProfileButton" - onPress={onPressEditProfile} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel={_(msg`Edit profile`)} - accessibilityHint={_( - msg`Opens editor for profile display name, avatar, background image, and description`, - )}> - <Text type="button" style={pal.text}> - <Trans>Edit Profile</Trans> - </Text> - </TouchableOpacity> - ) : profile.viewer?.blocking ? ( - profile.viewer?.blockingByList ? null : ( - <TouchableOpacity - testID="unblockBtn" - onPress={() => unblockPromptControl.open()} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel={_(msg`Unblock`)} - accessibilityHint=""> - <Text type="button" style={[pal.text, s.bold]}> - <Trans context="action">Unblock</Trans> - </Text> - </TouchableOpacity> - ) - ) : !profile.viewer?.blockedBy ? ( - <> - {hasSession && ( - <TouchableOpacity - testID="suggestedFollowsBtn" - onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} - style={[ - styles.btn, - styles.mainBtn, - pal.btn, - { - paddingHorizontal: 10, - backgroundColor: showSuggestedFollows - ? pal.colors.text - : pal.colors.backgroundLight, - }, - ]} - accessibilityRole="button" - accessibilityLabel={_( - msg`Show follows similar to ${profile.handle}`, - )} - accessibilityHint={_( - msg`Shows a list of users similar to this user.`, - )}> - <FontAwesomeIcon - icon="user-plus" - style={[ - pal.text, - { - color: showSuggestedFollows - ? pal.textInverted.color - : pal.text.color, - }, - ]} - size={14} - /> - </TouchableOpacity> - )} - - {profile.viewer?.following ? ( - <TouchableOpacity - testID="unfollowBtn" - onPress={onPressUnfollow} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel={_(msg`Unfollow ${profile.handle}`)} - accessibilityHint={_( - msg`Hides posts from ${profile.handle} in your feed`, - )}> - <FontAwesomeIcon - icon="check" - style={[pal.text, s.mr5]} - size={14} - /> - <Text type="button" style={pal.text}> - <Trans>Following</Trans> - </Text> - </TouchableOpacity> - ) : ( - <TouchableOpacity - testID="followBtn" - onPress={onPressFollow} - style={[styles.btn, styles.mainBtn, palInverted.view]} - accessibilityRole="button" - accessibilityLabel={_(msg`Follow ${profile.handle}`)} - accessibilityHint={_( - msg`Shows posts from ${profile.handle} in your feed`, - )}> - <FontAwesomeIcon - icon="plus" - style={[palInverted.text, s.mr5]} - /> - <Text type="button" style={[palInverted.text, s.bold]}> - <Trans>Follow</Trans> - </Text> - </TouchableOpacity> - )} - </> - ) : null} - <ProfileMenu profile={profile} /> - </View> - <View pointerEvents="none"> - <Text - testID="profileHeaderDisplayName" - type="title-2xl" - style={[pal.text, styles.title]}> - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, - )} - </Text> - </View> - <View style={styles.handleLine} pointerEvents="none"> - {profile.viewer?.followedBy && !blockHide ? ( - <View style={[styles.pill, pal.btn, s.mr5]}> - <Text type="xs" style={[pal.text]}> - <Trans>Follows you</Trans> - </Text> - </View> - ) : undefined} - <ThemedText - type={invalidHandle ? 'xs' : 'md'} - fg={invalidHandle ? 'error' : 'light'} - border={invalidHandle ? 'error' : undefined} - style={[ - invalidHandle ? styles.invalidHandle : undefined, - styles.handle, - ]}> - {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`} - </ThemedText> - </View> - {!isPlaceholderProfile && !blockHide && ( - <> - <View style={styles.metricsLine} pointerEvents="box-none"> - <Link - testID="profileHeaderFollowersButton" - style={[s.flexRow, s.mr10]} - href={makeProfileLink(profile, 'followers')} - onPressOut={() => - track(`ProfileHeader:FollowersButtonClicked`, { - handle: profile.handle, - }) - } - asAnchor - accessibilityLabel={`${followers} ${pluralizedFollowers}`} - accessibilityHint={_(msg`Opens followers list`)}> - <Text type="md" style={[s.bold, pal.text]}> - {followers}{' '} - </Text> - <Text type="md" style={[pal.textLight]}> - {pluralizedFollowers} - </Text> - </Link> - <Link - testID="profileHeaderFollowsButton" - style={[s.flexRow, s.mr10]} - href={makeProfileLink(profile, 'follows')} - onPressOut={() => - track(`ProfileHeader:FollowsButtonClicked`, { - handle: profile.handle, - }) - } - asAnchor - accessibilityLabel={_(msg`${following} following`)} - accessibilityHint={_(msg`Opens following list`)}> - <Trans> - <Text type="md" style={[s.bold, pal.text]}> - {following}{' '} - </Text> - <Text type="md" style={[pal.textLight]}> - following - </Text> - </Trans> - </Link> - <Text type="md" style={[s.bold, pal.text]}> - {formatCount(profile.postsCount || 0)}{' '} - <Text type="md" style={[pal.textLight]}> - {pluralize(profile.postsCount || 0, 'post')} - </Text> - </Text> - </View> - {descriptionRT && !moderation.profile.blur ? ( - <View pointerEvents="auto" style={[styles.description]}> - <RichText - testID="profileHeaderDescription" - style={[a.text_md]} - numberOfLines={15} - value={descriptionRT} - /> - </View> - ) : undefined} - </> - )} - <ProfileHeaderAlerts moderation={moderation} /> - {isMe && ( - <LabelInfo details={{did: profile.did}} labels={profile.labels} /> - )} - </View> - - {showSuggestedFollows && ( - <ProfileHeaderSuggestedFollows - actorDid={profile.did} - requestDismiss={() => { - if (showSuggestedFollows) { - setShowSuggestedFollows(false) - } else { - track('ProfileHeader:SuggestedFollowsOpened') - setShowSuggestedFollows(true) - } - }} - /> - )} - - {!isDesktop && !hideBackButton && ( - <TouchableWithoutFeedback - testID="profileHeaderBackBtn" - onPress={onPressBack} - hitSlop={BACK_HITSLOP} - accessibilityRole="button" - accessibilityLabel={_(msg`Back`)} - accessibilityHint=""> - <View style={styles.backBtnWrapper}> - <BlurView style={styles.backBtn} blurType="dark"> - <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> - </BlurView> - </View> - </TouchableWithoutFeedback> - )} - <TouchableWithoutFeedback - testID="profileHeaderAviButton" - onPress={onPressAvi} - accessibilityRole="image" - accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)} - accessibilityHint=""> - <View - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <UserAvatar - size={80} - avatar={profile.avatar} - moderation={moderation.avatar} - /> - </View> - </TouchableWithoutFeedback> - <Prompt.Basic - control={unblockPromptControl} - title={_(msg`Unblock Account?`)} - description={_( - msg`The account will be able to interact with you after unblocking.`, - )} - onConfirm={unblockAccount} - confirmButtonCta={ - profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) - } - confirmButtonColor="negative" - /> - </View> - ) -} -ProfileHeader = memo(ProfileHeader) -export {ProfileHeader} - -const styles = StyleSheet.create({ - banner: { - width: '100%', - height: 120, - }, - backBtnWrapper: { - position: 'absolute', - top: 10, - left: 10, - width: 30, - height: 30, - overflow: 'hidden', - borderRadius: 15, - // @ts-ignore web only - cursor: 'pointer', - }, - backBtn: { - width: 30, - height: 30, - borderRadius: 15, - alignItems: 'center', - justifyContent: 'center', - }, - avi: { - position: 'absolute', - top: 110, - left: 10, - width: 84, - height: 84, - borderRadius: 42, - borderWidth: 2, - }, - content: { - paddingTop: 8, - paddingHorizontal: 14, - paddingBottom: 4, - }, - - buttonsLine: { - flexDirection: 'row', - marginLeft: 'auto', - marginBottom: 12, - }, - primaryBtn: { - backgroundColor: colors.blue3, - paddingHorizontal: 24, - paddingVertical: 6, - }, - mainBtn: { - paddingHorizontal: 24, - }, - secondaryBtn: { - paddingHorizontal: 14, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - }, - title: {lineHeight: 38}, - - // Word wrapping appears fine on - // mobile but overflows on desktop - handle: isNative - ? {} - : { - // @ts-ignore web only -prf - wordBreak: 'break-all', - }, - invalidHandle: { - borderWidth: 1, - borderRadius: 4, - paddingHorizontal: 4, - }, - - handleLine: { - flexDirection: 'row', - marginBottom: 8, - }, - - metricsLine: { - flexDirection: 'row', - marginBottom: 8, - }, - - description: { - marginBottom: 8, - }, - - detailLine: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 5, - }, - - pill: { - borderRadius: 4, - paddingHorizontal: 6, - paddingVertical: 2, - }, - - br40: {borderRadius: 40}, - br50: {borderRadius: 50}, -}) diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index 8f2c8949..947d6e9c 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -217,7 +217,7 @@ function SuggestedFollow({ <UserAvatar size={60} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> <View style={{width: '100%', paddingVertical: 12}}> @@ -227,7 +227,7 @@ function SuggestedFollow({ numberOfLines={1}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 0baa4f39..cb0b1d97 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -17,6 +17,7 @@ import {toShareUrl} from 'lib/strings/url-helpers' import {makeProfileLink} from 'lib/routes/links' import {useAnalytics} from 'lib/analytics/analytics' import {useModalControls} from 'state/modals' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import { RQKEY as profileQueryKey, useProfileBlockMutationQueue, @@ -31,6 +32,7 @@ import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {logger} from '#/logger' import {Shadow} from 'state/cache/types' import * as Prompt from '#/components/Prompt' @@ -47,12 +49,17 @@ let ProfileMenu = ({ const pal = usePalette('default') const {track} = useAnalytics() const {openModal} = useModalControls() + const reportDialogControl = useReportDialogControl() const queryClient = useQueryClient() const isSelf = currentAccount?.did === profile.did + const isFollowing = profile.viewer?.following + const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy + const isFollowingBlockedAccount = isFollowing && isBlocked + const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) - const [, queueUnfollow] = useProfileFollowMutationQueue( + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, 'ProfileMenu', ) @@ -139,6 +146,19 @@ let ProfileMenu = ({ } }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock]) + const onPressFollowAccount = React.useCallback(async () => { + track('ProfileHeader:FollowButtonClicked') + try { + await queueFollow() + Toast.show(_(msg`Account followed`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }, [_, queueFollow, track]) + const onPressUnfollowAccount = React.useCallback(async () => { track('ProfileHeader:UnfollowButtonClicked') try { @@ -154,11 +174,8 @@ let ProfileMenu = ({ const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') - openModal({ - name: 'report', - did: profile.did, - }) - }, [track, openModal, profile]) + reportDialogControl.open() + }, [track, reportDialogControl]) return ( <EventStopper onKeyDown={false}> @@ -175,10 +192,9 @@ let ProfileMenu = ({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 7, + paddingVertical: 10, borderRadius: 50, - marginLeft: 6, - paddingHorizontal: 14, + paddingHorizontal: 16, }, pal.btn, ]}> @@ -210,10 +226,38 @@ let ProfileMenu = ({ <Menu.ItemIcon icon={Share} /> </Menu.Item> </Menu.Group> + {hasSession && ( <> <Menu.Divider /> <Menu.Group> + {!isSelf && ( + <> + {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && ( + <Menu.Item + testID="profileHeaderDropdownFollowBtn" + label={ + isFollowing + ? _(msg`Unfollow Account`) + : _(msg`Follow Account`) + } + onPress={ + isFollowing + ? onPressUnfollowAccount + : onPressFollowAccount + }> + <Menu.ItemText> + {isFollowing ? ( + <Trans>Unfollow Account</Trans> + ) : ( + <Trans>Follow Account</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} /> + </Menu.Item> + )} + </> + )} <Menu.Item testID="profileHeaderDropdownListAddRemoveBtn" label={_(msg`Add to Lists`)} @@ -225,18 +269,6 @@ let ProfileMenu = ({ </Menu.Item> {!isSelf && ( <> - {profile.viewer?.following && - (profile.viewer.blocking || profile.viewer.blockedBy) && ( - <Menu.Item - testID="profileHeaderDropdownUnfollowBtn" - label={_(msg`Unfollow Account`)} - onPress={onPressUnfollowAccount}> - <Menu.ItemText> - <Trans>Unfollow Account</Trans> - </Menu.ItemText> - <Menu.ItemIcon icon={UserMinus} /> - </Menu.Item> - )} {!profile.viewer?.blocking && !profile.viewer?.mutedByList && ( <Menu.Item @@ -299,6 +331,11 @@ let ProfileMenu = ({ </Menu.Outer> </Menu.Root> + <ReportDialog + control={reportDialogControl} + params={{type: 'account', did: profile.did}} + /> + <Prompt.Basic control={blockPromptControl} title={ @@ -311,6 +348,10 @@ let ProfileMenu = ({ ? _( msg`The account will be able to interact with you after unblocking.`, ) + : profile.associated?.labeler + ? _( + msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`, + ) : _( msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, ) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 7468111b..b6c512b0 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -47,6 +47,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> { anchorNoUnderline?: boolean navigationAction?: 'push' | 'replace' | 'navigate' onPointerEnter?: () => void + onBeforePress?: () => void } export const Link = memo(function Link({ @@ -60,6 +61,7 @@ export const Link = memo(function Link({ accessible, anchorNoUnderline, navigationAction, + onBeforePress, ...props }: Props) { const t = useTheme() @@ -70,6 +72,7 @@ export const Link = memo(function Link({ const onPress = React.useCallback( (e?: Event) => { + onBeforePress?.() if (typeof href === 'string') { return onPressInner( closeModal, @@ -81,7 +84,7 @@ export const Link = memo(function Link({ ) } }, - [closeModal, navigation, navigationAction, href, openLink], + [closeModal, navigation, navigationAction, href, openLink, onBeforePress], ) if (noFeedback) { @@ -262,6 +265,7 @@ interface TextLinkOnWebOnlyProps extends TextProps { accessibilityHint?: string title?: string navigationAction?: 'push' | 'replace' | 'navigate' + disableMismatchWarning?: boolean onPointerEnter?: () => void } export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ @@ -273,6 +277,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ numberOfLines, lineHeight, navigationAction, + disableMismatchWarning, ...props }: TextLinkOnWebOnlyProps) { if (isWeb) { @@ -287,6 +292,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ lineHeight={lineHeight} title={props.title} navigationAction={navigationAction} + disableMismatchWarning={disableMismatchWarning} {...props} /> ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 3795dcf1..53dc20e7 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -11,7 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {isAndroid, isWeb} from 'platform/detection' import {TimeElapsed} from './TimeElapsed' import {makeProfileLink} from 'lib/routes/links' -import {ModerationUI} from '@atproto/api' +import {ModerationDecision, ModerationUI} from '@atproto/api' import {usePrefetchProfileQuery} from '#/state/queries/profile' interface PostMetaOpts { @@ -21,6 +21,7 @@ interface PostMetaOpts { handle: string displayName?: string | undefined } + moderation: ModerationDecision | undefined authorHasWarning: boolean postHref: string timestamp: string @@ -55,9 +56,14 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { style={[pal.text, opts.displayNameStyle]} numberOfLines={1} lineHeight={1.2} + disableMismatchWarning text={ <> - {sanitizeDisplayName(displayName)}  + {sanitizeDisplayName( + displayName, + opts.moderation?.ui('displayName'), + )} +   <Text type="md" numberOfLines={1} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 41323739..39bc7230 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -24,9 +24,9 @@ import { } from '#/components/icons/Camera' import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {useTheme} from '#/alf' +import {useTheme, tokens} from '#/alf' -export type UserAvatarType = 'user' | 'algo' | 'list' +export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' interface BaseUserAvatarProps { type?: UserAvatarType @@ -101,6 +101,29 @@ let DefaultAvatar = ({ </Svg> ) } + if (type === 'labeler') { + return ( + <Svg + testID="userAvatarFallback" + width={size} + height={size} + viewBox="0 0 32 32" + fill="none" + stroke="none"> + <Path + d="M28 0H4C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H28C30.2091 32 32 30.2091 32 28V4C32 1.79086 30.2091 0 28 0Z" + fill={tokens.color.temp_purple} + /> + <Path + d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z" + stroke="white" + strokeWidth="2" + strokeLinecap="square" + strokeLinejoin="round" + /> + </Svg> + ) + } return ( <Svg testID="userAvatarFallback" @@ -134,7 +157,7 @@ let UserAvatar = ({ const backgroundColor = pal.colors.backgroundLight const aviStyle = useMemo(() => { - if (type === 'algo' || type === 'list') { + if (type === 'algo' || type === 'list' || type === 'labeler') { return { width: size, height: size, diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index a5ddfee8..4fb3726c 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -7,7 +7,7 @@ import {msg, Trans} from '@lingui/macro' import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' -import {useTheme as useAlfTheme} from '#/alf' +import {useTheme as useAlfTheme, tokens} from '#/alf' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import { usePhotoLibraryPermission, @@ -26,10 +26,12 @@ import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/ico import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' export function UserBanner({ + type, banner, moderation, onSelectNewBanner, }: { + type?: 'labeler' | 'default' banner?: string | null moderation?: ModerationUI onSelectNewBanner?: (img: RNImage | null) => void @@ -167,7 +169,10 @@ export function UserBanner({ ) : ( <View testID="userBannerFallback" - style={[styles.bannerImage, styles.defaultBanner]} + style={[ + styles.bannerImage, + type === 'labeler' ? styles.labelerBanner : styles.defaultBanner, + ]} /> ) } @@ -191,4 +196,7 @@ const styles = StyleSheet.create({ defaultBanner: { backgroundColor: '#0070ff', }, + labelerBanner: { + backgroundColor: tokens.color.temp_purple, + }, }) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 8fc3d9ea..70fbb907 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -16,7 +16,6 @@ import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' import {useDialogControl} from '#/components/Dialog' import * as Prompt from '#/components/Prompt' -import {useModalControls} from '#/state/modals' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams} from '#/lib/routes/types' import {getCurrentRoute} from 'lib/routes/helpers' @@ -33,6 +32,7 @@ import {useSession} from '#/state/session' import {isWeb} from '#/platform/detection' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {atoms as a, useTheme as useAlf} from '#/alf' import * as Menu from '#/components/Menu' @@ -45,7 +45,6 @@ import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/ import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' let PostDropdownBtn = ({ testID, @@ -55,7 +54,6 @@ let PostDropdownBtn = ({ record, richText, style, - showAppealLabelItem, hitSlop, }: { testID: string @@ -65,7 +63,6 @@ let PostDropdownBtn = ({ record: AppBskyFeedPost.Record richText: RichTextAPI style?: StyleProp<ViewStyle> - showAppealLabelItem?: boolean hitSlop?: PressableProps['hitSlop'] }): React.ReactNode => { const {hasSession, currentAccount} = useSession() @@ -73,7 +70,6 @@ let PostDropdownBtn = ({ const alf = useAlf() const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl - const {openModal} = useModalControls() const langPrefs = useLanguagePrefs() const mutedThreads = useMutedThreads() const toggleThreadMute = useToggleThreadMute() @@ -83,6 +79,7 @@ let PostDropdownBtn = ({ const openLink = useOpenLink() const navigation = useNavigation() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const reportDialogControl = useReportDialogControl() const deletePromptControl = useDialogControl() const hidePromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl() @@ -293,13 +290,7 @@ let PostDropdownBtn = ({ <Menu.Item testID="postDropdownReportBtn" label={_(msg`Report post`)} - onPress={() => { - openModal({ - name: 'report', - uri: postUri, - cid: postCid, - }) - }}> + onPress={() => reportDialogControl.open()}> <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> <Menu.ItemIcon icon={Warning} position="right" /> </Menu.Item> @@ -314,28 +305,6 @@ let PostDropdownBtn = ({ <Menu.ItemIcon icon={Trash} position="right" /> </Menu.Item> )} - - {showAppealLabelItem && ( - <> - <Menu.Divider /> - - <Menu.Item - testID="postDropdownAppealBtn" - label={_(msg`Appeal content warning`)} - onPress={() => { - openModal({ - name: 'appeal-label', - uri: postUri, - cid: postCid, - }) - }}> - <Menu.ItemText> - {_(msg`Appeal content warning`)} - </Menu.ItemText> - <Menu.ItemIcon icon={CircleInfo} position="right" /> - </Menu.Item> - </> - )} </Menu.Group> </Menu.Outer> </Menu.Root> @@ -359,6 +328,15 @@ let PostDropdownBtn = ({ confirmButtonCta={_(msg`Hide`)} /> + <ReportDialog + control={reportDialogControl} + params={{ + type: 'post', + uri: postUri, + cid: postCid, + }} + /> + <Prompt.Basic control={loggedOutWarningPromptControl} title={_(msg`Note about sharing`)} diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx deleted file mode 100644 index cd254529..00000000 --- a/src/view/com/util/moderation/ContentHider.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {ModerationUI, PostModeration} from '@atproto/api' -import {Text} from '../text/Text' -import {ShieldExclamation} from 'lib/icons' -import {describeModerationCause} from 'lib/moderation' -import {useLingui} from '@lingui/react' -import {msg, Trans} from '@lingui/macro' -import {useModalControls} from '#/state/modals' -import {isPostMediaBlurred} from 'lib/moderation' - -export function ContentHider({ - testID, - moderation, - moderationDecisions, - ignoreMute, - ignoreQuoteDecisions, - style, - childContainerStyle, - children, -}: React.PropsWithChildren<{ - testID?: string - moderation: ModerationUI - moderationDecisions?: PostModeration['decisions'] - ignoreMute?: boolean - ignoreQuoteDecisions?: boolean - style?: StyleProp<ViewStyle> - childContainerStyle?: StyleProp<ViewStyle> -}>) { - const pal = usePalette('default') - const {_} = useLingui() - const [override, setOverride] = React.useState(false) - const {openModal} = useModalControls() - - if ( - !moderation.blur || - (ignoreMute && moderation.cause?.type === 'muted') || - shouldIgnoreQuote(moderationDecisions, ignoreQuoteDecisions) - ) { - return ( - <View testID={testID} style={[styles.outer, style]}> - {children} - </View> - ) - } - - const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '') - const desc = describeModerationCause(moderation.cause, 'content') - return ( - <View testID={testID} style={[styles.outer, style]}> - <Pressable - onPress={() => { - if (!moderation.noOverride) { - setOverride(v => !v) - } else { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) - } - }} - accessibilityRole="button" - accessibilityHint={ - override ? _(msg`Hide the content`) : _(msg`Show the content`) - } - accessibilityLabel="" - style={[ - styles.cover, - moderation.noOverride - ? {borderWidth: 1, borderColor: pal.colors.borderDark} - : pal.viewLight, - ]}> - <Pressable - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint=""> - {isMute ? ( - <FontAwesomeIcon - icon={['far', 'eye-slash']} - size={18} - color={pal.colors.textLight} - /> - ) : ( - <ShieldExclamation size={18} style={pal.textLight} /> - )} - </Pressable> - <Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}> - {desc.name} - </Text> - <View style={styles.showBtn}> - <Text type="lg" style={pal.link}> - {moderation.noOverride ? ( - <Trans>Learn more</Trans> - ) : override ? ( - <Trans>Hide</Trans> - ) : ( - <Trans>Show</Trans> - )} - </Text> - </View> - </Pressable> - {override && <View style={childContainerStyle}>{children}</View>} - </View> - ) -} - -function shouldIgnoreQuote( - decisions: PostModeration['decisions'] | undefined, - ignore: boolean | undefined, -): boolean { - if (!decisions || !ignore) { - return false - } - return !isPostMediaBlurred(decisions) -} - -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/view/com/util/moderation/LabelInfo.tsx b/src/view/com/util/moderation/LabelInfo.tsx deleted file mode 100644 index 97033875..00000000 --- a/src/view/com/util/moderation/LabelInfo.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import {ComAtprotoLabelDefs} from '@atproto/api' -import {Text} from '../text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' - -export function LabelInfo({ - details, - labels, - style, -}: { - details: {did: string} | {uri: string; cid: string} - labels: ComAtprotoLabelDefs.Label[] | undefined - style?: StyleProp<ViewStyle> -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {openModal} = useModalControls() - - if (!labels) { - return null - } - labels = labels.filter(l => !l.val.startsWith('!')) - if (!labels.length) { - return null - } - - return ( - <View - style={[ - pal.viewLight, - { - flexDirection: 'row', - flexWrap: 'wrap', - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 8, - }, - style, - ]}> - <Text type="sm" style={pal.text}> - <Trans> - A content warning has been applied to this{' '} - {'did' in details ? 'account' : 'post'}. - </Trans>{' '} - </Text> - <Pressable - accessibilityRole="button" - accessibilityLabel={_(msg`Appeal this decision`)} - accessibilityHint="" - onPress={() => openModal({name: 'appeal-label', ...details})}> - <Text type="sm" style={pal.link}> - <Trans>Appeal this decision.</Trans> - </Text> - </Pressable> - </View> - ) -} diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx deleted file mode 100644 index bc5bf9b3..00000000 --- a/src/view/com/util/moderation/PostAlerts.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native' -import {ModerationUI} from '@atproto/api' -import {Text} from '../text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {ShieldExclamation} from 'lib/icons' -import {describeModerationCause} from 'lib/moderation' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' - -export function PostAlerts({ - moderation, - style, -}: { - moderation: ModerationUI - includeMute?: boolean - style?: StyleProp<ViewStyle> -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {openModal} = useModalControls() - - const shouldAlert = !!moderation.cause && moderation.alert - if (!shouldAlert) { - return null - } - - const desc = describeModerationCause(moderation.cause, 'content') - return ( - <Pressable - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint="" - style={[styles.container, pal.viewLight, style]}> - <ShieldExclamation style={pal.text} size={16} /> - <Text type="lg" style={[pal.text]}> - {desc.name}{' '} - <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> - <Trans>Learn More</Trans> - </Text> - </Text> - </Pressable> - ) -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingVertical: 8, - paddingLeft: 14, - paddingHorizontal: 16, - borderRadius: 8, - }, - learnMoreBtn: { - marginLeft: 'auto', - }, -}) diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx deleted file mode 100644 index 0f07b679..00000000 --- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {ProfileModeration} from '@atproto/api' -import {Text} from '../text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {ShieldExclamation} from 'lib/icons' -import { - describeModerationCause, - getProfileModerationCauses, -} from 'lib/moderation' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useModalControls} from '#/state/modals' - -export function ProfileHeaderAlerts({ - moderation, - style, -}: { - moderation: ProfileModeration - style?: StyleProp<ViewStyle> -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {openModal} = useModalControls() - - const causes = getProfileModerationCauses(moderation) - if (!causes.length) { - return null - } - - return ( - <View style={styles.grid}> - {causes.map(cause => { - const isMute = cause.type === 'muted' - const desc = describeModerationCause(cause, 'account') - return ( - <Pressable - testID="profileHeaderAlert" - key={desc.name} - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation: {cause}, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint="" - style={[styles.container, pal.viewLight, style]}> - {isMute ? ( - <FontAwesomeIcon - icon={['far', 'eye-slash']} - size={14} - color={pal.colors.textLight} - /> - ) : ( - <ShieldExclamation style={pal.text} size={18} /> - )} - <Text type="sm" style={[{flex: 1}, pal.text]}> - {desc.name} - </Text> - <Text type="sm" style={[pal.link, styles.learnMoreBtn]}> - <Trans>Learn More</Trans> - </Text> - </Pressable> - ) - })} - </View> - ) -} - -const styles = StyleSheet.create({ - grid: { - gap: 4, - }, - container: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 8, - }, - learnMoreBtn: { - marginLeft: 'auto', - }, -}) diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx deleted file mode 100644 index 86f0cbf7..00000000 --- a/src/view/com/util/moderation/ScreenHider.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react' -import { - TouchableWithoutFeedback, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {useNavigation} from '@react-navigation/native' -import {ModerationUI} from '@atproto/api' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {Text} from '../text/Text' -import {Button} from '../forms/Button' -import {describeModerationCause} from 'lib/moderation' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {s} from '#/lib/styles' -import {CenteredView} from '../Views' - -export function ScreenHider({ - testID, - screenDescription, - moderation, - style, - containerStyle, - children, -}: React.PropsWithChildren<{ - testID?: string - screenDescription: string - moderation: ModerationUI - style?: StyleProp<ViewStyle> - containerStyle?: StyleProp<ViewStyle> -}>) { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const {_} = useLingui() - const [override, setOverride] = React.useState(false) - const navigation = useNavigation<NavigationProp>() - const {isMobile} = useWebMediaQueries() - const {openModal} = useModalControls() - - if (!moderation.blur || override) { - return ( - <View testID={testID} style={style}> - {children} - </View> - ) - } - - const isNoPwi = - moderation.cause?.type === 'label' && - moderation.cause?.labelDef.id === '!no-unauthenticated' - const desc = describeModerationCause(moderation.cause, 'account') - return ( - <CenteredView - style={[styles.container, pal.view, containerStyle]} - sideBorders> - <View style={styles.iconContainer}> - <View style={[styles.icon, palInverted.view]}> - <FontAwesomeIcon - icon={isNoPwi ? ['far', 'eye-slash'] : 'exclamation'} - style={pal.textInverted as FontAwesomeIconStyle} - size={24} - /> - </View> - </View> - <Text type="title-2xl" style={[styles.title, pal.text]}> - {isNoPwi ? ( - <Trans>Sign-in Required</Trans> - ) : ( - <Trans>Content Warning</Trans> - )} - </Text> - <Text type="2xl" style={[styles.description, pal.textLight]}> - {isNoPwi ? ( - <Trans> - This account has requested that users sign in to view their profile. - </Trans> - ) : ( - <> - <Trans>This {screenDescription} has been flagged:</Trans> - <Text type="2xl-medium" style={[pal.text, s.ml5]}> - {desc.name}. - </Text> - <TouchableWithoutFeedback - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'account', - moderation, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint=""> - <Text type="2xl" style={pal.link}> - <Trans>Learn More</Trans> - </Text> - </TouchableWithoutFeedback> - </> - )}{' '} - </Text> - {isMobile && <View style={styles.spacer} />} - <View style={styles.btnContainer}> - <Button - type="inverted" - onPress={() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }} - style={styles.btn}> - <Text type="button-lg" style={pal.textInverted}> - <Trans>Go back</Trans> - </Text> - </Button> - {!moderation.noOverride && ( - <Button - type="default" - onPress={() => setOverride(v => !v)} - style={styles.btn}> - <Text type="button-lg" style={pal.text}> - <Trans>Show anyway</Trans> - </Text> - </Button> - )} - </View> - </CenteredView> - ) -} - -const styles = StyleSheet.create({ - spacer: { - flex: 1, - }, - container: { - flex: 1, - paddingTop: 100, - paddingBottom: 150, - }, - iconContainer: { - alignItems: 'center', - marginBottom: 10, - }, - icon: { - borderRadius: 25, - width: 50, - height: 50, - alignItems: 'center', - justifyContent: 'center', - }, - title: { - textAlign: 'center', - marginBottom: 10, - }, - description: { - marginBottom: 10, - paddingHorizontal: 20, - textAlign: 'center', - }, - btnContainer: { - flexDirection: 'row', - justifyContent: 'center', - marginVertical: 10, - gap: 10, - }, - btn: { - paddingHorizontal: 20, - paddingVertical: 14, - }, -}) diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index c96954a1..3fa347a6 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -41,7 +41,6 @@ let PostCtrls = ({ post, record, richText, - showAppealLabelItem, style, onPressReply, logContext, @@ -50,7 +49,6 @@ let PostCtrls = ({ post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record richText: RichTextAPI - showAppealLabelItem?: boolean style?: StyleProp<ViewStyle> onPressReply: () => void logContext: 'FeedItem' | 'PostThreadItem' | 'Post' @@ -232,7 +230,6 @@ let PostCtrls = ({ postUri={post.uri} record={record} richText={richText} - showAppealLabelItem={showAppealLabelItem} style={styles.btnPad} hitSlop={big ? HITSLOP_20 : HITSLOP_10} /> diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 35b09126..2b1c3e61 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -1,13 +1,15 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import { + AppBskyFeedDefs, AppBskyEmbedRecord, AppBskyFeedPost, AppBskyEmbedImages, AppBskyEmbedRecordWithMedia, - ModerationUI, AppBskyEmbedExternal, RichText as RichTextAPI, + moderatePost, + ModerationDecision, } from '@atproto/api' import {AtUri} from '@atproto/api' import {PostMeta} from '../PostMeta' @@ -16,20 +18,20 @@ import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ComposerOptsQuote} from 'state/shell/composer' import {PostEmbeds} from '.' -import {PostAlerts} from '../moderation/PostAlerts' +import {PostAlerts} from '../../../../components/moderation/PostAlerts' import {makeProfileLink} from 'lib/routes/links' import {InfoCircleIcon} from 'lib/icons' import {Trans} from '@lingui/macro' +import {useModerationOpts} from '#/state/queries/preferences' +import {ContentHider} from '../../../../components/moderation/ContentHider' import {RichText} from '#/components/RichText' import {atoms as a} from '#/alf' export function MaybeQuoteEmbed({ embed, - moderation, style, }: { embed: AppBskyEmbedRecord.View - moderation: ModerationUI style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -39,17 +41,9 @@ export function MaybeQuoteEmbed({ AppBskyFeedPost.validateRecord(embed.record.value).success ) { return ( - <QuoteEmbed - quote={{ - author: embed.record.author, - cid: embed.record.cid, - uri: embed.record.uri, - indexedAt: embed.record.indexedAt, - text: embed.record.value.text, - facets: embed.record.value.facets, - embeds: embed.record.embeds, - }} - moderation={moderation} + <QuoteEmbedModerated + viewRecord={embed.record} + postRecord={embed.record.value} style={style} /> ) @@ -75,19 +69,49 @@ export function MaybeQuoteEmbed({ return null } +function QuoteEmbedModerated({ + viewRecord, + postRecord, + style, +}: { + viewRecord: AppBskyEmbedRecord.ViewRecord + postRecord: AppBskyFeedPost.Record + style?: StyleProp<ViewStyle> +}) { + const moderationOpts = useModerationOpts() + const moderation = React.useMemo(() => { + return moderationOpts + ? moderatePost(viewRecordToPostView(viewRecord), moderationOpts) + : undefined + }, [viewRecord, moderationOpts]) + + const quote = { + author: viewRecord.author, + cid: viewRecord.cid, + uri: viewRecord.uri, + indexedAt: viewRecord.indexedAt, + text: postRecord.text, + facets: postRecord.facets, + embeds: viewRecord.embeds, + } + + return <QuoteEmbed quote={quote} moderation={moderation} style={style} /> +} + export function QuoteEmbed({ quote, moderation, style, }: { quote: ComposerOptsQuote - moderation?: ModerationUI + moderation?: ModerationDecision style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') const itemUrip = new AtUri(quote.uri) const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) const itemTitle = `Post by ${quote.author.handle}` + const richText = React.useMemo( () => quote.text.trim() @@ -95,6 +119,7 @@ export function QuoteEmbed({ : undefined, [quote.text, quote.facets], ) + const embed = React.useMemo(() => { const e = quote.embeds?.[0] @@ -108,40 +133,52 @@ export function QuoteEmbed({ return e.media } }, [quote.embeds]) + return ( - <Link - style={[styles.container, pal.borderDark, style]} - hoverStyle={{borderColor: pal.colors.borderLinkHover}} - href={itemHref} - title={itemTitle}> - <View pointerEvents="none"> - <PostMeta - author={quote.author} - showAvatar - authorHasWarning={false} - postHref={itemHref} - timestamp={quote.indexedAt} - /> - </View> - {moderation ? ( - <PostAlerts moderation={moderation} style={styles.alert} /> - ) : null} - {richText ? ( - <RichText - enableTags - value={richText} - style={[a.text_md]} - numberOfLines={20} - disableLinks - authorHandle={quote.author.handle} - /> - ) : null} - {embed && <PostEmbeds embed={embed} moderation={{}} />} - </Link> + <ContentHider modui={moderation?.ui('contentList')}> + <Link + style={[styles.container, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} + href={itemHref} + title={itemTitle}> + <View pointerEvents="none"> + <PostMeta + author={quote.author} + moderation={moderation} + showAvatar + authorHasWarning={false} + postHref={itemHref} + timestamp={quote.indexedAt} + /> + </View> + {moderation ? ( + <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} /> + ) : null} + {richText ? ( + <RichText + value={richText} + style={[a.text_md]} + numberOfLines={20} + disableLinks + /> + ) : null} + {embed && <PostEmbeds embed={embed} moderation={moderation} />} + </Link> + </ContentHider> ) } -export default QuoteEmbed +function viewRecordToPostView( + viewRecord: AppBskyEmbedRecord.ViewRecord, +): AppBskyFeedDefs.PostView { + const {value, embeds, ...rest} = viewRecord + return { + ...rest, + $type: 'app.bsky.feed.defs#postView', + record: value, + embed: embeds?.[0], + } +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 7e235bab..47091fbb 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -15,8 +15,7 @@ import { AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyGraphDefs, - ModerationUI, - PostModeration, + ModerationDecision, } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' @@ -26,9 +25,8 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' import {ListEmbed} from './ListEmbed' -import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {ContentHider} from '../moderation/ContentHider' +import {ContentHider} from '../../../../components/moderation/ContentHider' import {isNative} from '#/platform/detection' import {shareUrl} from '#/lib/sharing' @@ -42,12 +40,10 @@ type Embed = export function PostEmbeds({ embed, moderation, - moderationDecisions, style, }: { embed?: Embed - moderation: ModerationUI - moderationDecisions?: PostModeration['decisions'] + moderation?: ModerationDecision style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -66,18 +62,10 @@ export function PostEmbeds({ // quote post with media // = if (AppBskyEmbedRecordWithMedia.isView(embed)) { - const isModOnQuote = - (AppBskyEmbedRecord.isViewRecord(embed.record.record) && - isCauseALabelOnUri(moderation.cause, embed.record.record.uri)) || - (moderationDecisions && isQuoteBlurred(moderationDecisions)) - const mediaModeration = isModOnQuote ? {} : moderation - const quoteModeration = isModOnQuote ? moderation : {} return ( <View style={style}> - <PostEmbeds embed={embed.media} moderation={mediaModeration} /> - <ContentHider moderation={quoteModeration}> - <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> - </ContentHider> + <PostEmbeds embed={embed.media} moderation={moderation} /> + <MaybeQuoteEmbed embed={embed.record} /> </View> ) } @@ -86,6 +74,7 @@ export function PostEmbeds({ // custom feed embed (i.e. generator view) // = if (AppBskyFeedDefs.isGeneratorView(embed.record)) { + // TODO moderation return ( <FeedSourceCard feedUri={embed.record.uri} @@ -97,16 +86,13 @@ export function PostEmbeds({ // list embed if (AppBskyGraphDefs.isListView(embed.record)) { + // TODO moderation return <ListEmbed item={embed.record} /> } // quote post // = - return ( - <ContentHider moderation={moderation}> - <MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} /> - </ContentHider> - ) + return <MaybeQuoteEmbed embed={embed} style={style} /> } // image embed @@ -132,35 +118,41 @@ export function PostEmbeds({ if (images.length === 1) { const {alt, thumb, aspectRatio} = images[0] return ( - <View style={[styles.imagesContainer, style]}> - <AutoSizedImage - alt={alt} - uri={thumb} - dimensionsHint={aspectRatio} - onPress={() => _openLightbox(0)} - onPressIn={() => onPressIn(0)} - style={[styles.singleImage]}> - {alt === '' ? null : ( - <View style={styles.altContainer}> - <Text style={styles.alt} accessible={false}> - ALT - </Text> - </View> - )} - </AutoSizedImage> - </View> + <ContentHider modui={moderation?.ui('contentMedia')}> + <View style={[styles.imagesContainer, style]}> + <AutoSizedImage + alt={alt} + uri={thumb} + dimensionsHint={aspectRatio} + onPress={() => _openLightbox(0)} + onPressIn={() => onPressIn(0)} + style={[styles.singleImage]}> + {alt === '' ? null : ( + <View style={styles.altContainer}> + <Text style={styles.alt} accessible={false}> + ALT + </Text> + </View> + )} + </AutoSizedImage> + </View> + </ContentHider> ) } return ( - <View style={[styles.imagesContainer, style]}> - <ImageLayoutGrid - images={embed.images} - onPress={_openLightbox} - onPressIn={onPressIn} - style={embed.images.length === 1 ? [styles.singleImage] : undefined} - /> - </View> + <ContentHider modui={moderation?.ui('contentMedia')}> + <View style={[styles.imagesContainer, style]}> + <ImageLayoutGrid + images={embed.images} + onPress={_openLightbox} + onPressIn={onPressIn} + style={ + embed.images.length === 1 ? [styles.singleImage] : undefined + } + /> + </View> + </ContentHider> ) } } @@ -171,15 +163,17 @@ export function PostEmbeds({ const link = embed.external return ( - <Link - asAnchor - anchorNoUnderline - href={link.uri} - style={[styles.extOuter, pal.view, pal.borderDark, style]} - hoverStyle={{borderColor: pal.colors.borderLinkHover}} - onLongPress={onShareExternal}> - <ExternalLinkEmbed link={link} /> - </Link> + <ContentHider modui={moderation?.ui('contentMedia')}> + <Link + asAnchor + anchorNoUnderline + href={link.uri} + style={[styles.extOuter, pal.view, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} + onLongPress={onShareExternal}> + <ExternalLinkEmbed link={link} /> + </Link> + </ContentHider> ) } diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx new file mode 100644 index 00000000..64f2376a --- /dev/null +++ b/src/view/screens/DebugMod.tsx @@ -0,0 +1,923 @@ +import React from 'react' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {View} from 'react-native' +import { + LABELS, + mock, + moderatePost, + moderateProfile, + ModerationOpts, + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyFeedPost, + LabelPreference, + ModerationDecision, + ModerationBehavior, + RichText, + ComAtprotoLabelDefs, + interpretLabelValueDefinition, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {moderationOptsOverrideContext} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {FeedNotification} from '#/state/queries/notifications/types' +import { + groupNotifications, + shouldFilterNotif, +} from '#/state/queries/notifications/util' + +import {atoms as a, useTheme} from '#/alf' +import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {H1, H3, P, Text} from '#/components/Typography' +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import * as Toggle from '#/components/forms/Toggle' +import * as ToggleButton from '#/components/forms/ToggleButton' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom, + ChevronTop_Stroke2_Corner0_Rounded as ChevronTop, +} from '#/components/icons/Chevron' +import {ScreenHider} from '../../components/moderation/ScreenHider' +import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard' +import {ProfileCard} from '../com/profile/ProfileCard' +import {FeedItem} from '../com/posts/FeedItem' +import {FeedItem as NotifFeedItem} from '../com/notifications/FeedItem' +import {PostThreadItem} from '../com/post-thread/PostThreadItem' +import {Divider} from '#/components/Divider' + +const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys( + LABELS, +) as (keyof typeof LABELS)[] + +export const DebugModScreen = ({}: NativeStackScreenProps< + CommonNavigatorParams, + 'DebugMod' +>) => { + const t = useTheme() + const [scenario, setScenario] = React.useState<string[]>(['label']) + const [scenarioSwitches, setScenarioSwitches] = React.useState<string[]>([]) + const [label, setLabel] = React.useState<string[]>([LABEL_VALUES[0]]) + const [target, setTarget] = React.useState<string[]>(['account']) + const [visibility, setVisiblity] = React.useState<string[]>(['warn']) + const [customLabelDef, setCustomLabelDef] = + React.useState<ComAtprotoLabelDefs.LabelValueDefinition>({ + identifier: 'custom', + blurs: 'content', + severity: 'alert', + defaultSetting: 'warn', + locales: [ + { + lang: 'en', + name: 'Custom label', + description: 'A custom label created in this test environment', + }, + ], + }) + const [view, setView] = React.useState<string[]>(['post']) + const labelStrings = useGlobalLabelStrings() + const {currentAccount} = useSession() + + const isTargetMe = + scenario[0] === 'label' && scenarioSwitches.includes('targetMe') + const isSelfLabel = + scenario[0] === 'label' && scenarioSwitches.includes('selfLabel') + const noAdult = + scenario[0] === 'label' && scenarioSwitches.includes('noAdult') + const isLoggedOut = + scenario[0] === 'label' && scenarioSwitches.includes('loggedOut') + const isFollowing = scenarioSwitches.includes('following') + + const did = + isTargetMe && currentAccount ? currentAccount.did : 'did:web:bob.test' + + const profile = React.useMemo(() => { + const mockedProfile = mock.profileViewBasic({ + handle: `bob.test`, + displayName: 'Bob Robertson', + description: 'User with this as their bio', + labels: + scenario[0] === 'label' && target[0] === 'account' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/`, + }), + ] + : scenario[0] === 'label' && target[0] === 'profile' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.actor.profile/self`, + }), + ] + : undefined, + viewer: mock.actorViewerState({ + following: isFollowing + ? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234` + : undefined, + muted: scenario[0] === 'mute', + mutedByList: undefined, + blockedBy: undefined, + blocking: + scenario[0] === 'block' + ? `at://did:web:alice.test/app.bsky.actor.block/fake` + : undefined, + blockingByList: undefined, + }), + }) + mockedProfile.did = did + mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png' + mockedProfile.banner = + 'https://bsky.social/about/images/social-card-default-gradient.png' + return mockedProfile + }, [scenario, target, label, isSelfLabel, did, isFollowing, currentAccount]) + + const post = React.useMemo(() => { + return mock.postView({ + record: mock.post({ + text: "This is the body of the post. It's where the text goes. You get the idea.", + }), + author: profile, + labels: + scenario[0] === 'label' && target[0] === 'post' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + embed: + target[0] === 'embed' + ? mock.embedRecordView({ + record: mock.post({ + text: 'Embed', + }), + labels: + scenario[0] === 'label' && target[0] === 'embed' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + author: profile, + }) + : { + $type: 'app.bsky.embed.images#view', + images: [ + { + thumb: + 'https://bsky.social/about/images/social-card-default-gradient.png', + fullsize: + 'https://bsky.social/about/images/social-card-default-gradient.png', + alt: '', + }, + ], + }, + }) + }, [scenario, label, target, profile, isSelfLabel, did]) + + const replyNotif = React.useMemo(() => { + const notif = mock.replyNotification({ + record: mock.post({ + text: "This is the body of the post. It's where the text goes. You get the idea.", + reply: { + parent: { + uri: `at://${did}/app.bsky.feed.post/fake-parent`, + cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq', + }, + root: { + uri: `at://${did}/app.bsky.feed.post/fake-parent`, + cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq', + }, + }, + }), + author: profile, + labels: + scenario[0] === 'label' && target[0] === 'post' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + }) + const [item] = groupNotifications([notif]) + item.subject = mock.postView({ + record: notif.record as AppBskyFeedPost.Record, + author: profile, + labels: notif.labels, + }) + return item + }, [scenario, label, target, profile, isSelfLabel, did]) + + const followNotif = React.useMemo(() => { + const notif = mock.followNotification({ + author: profile, + subjectDid: currentAccount?.did || '', + }) + const [item] = groupNotifications([notif]) + return item + }, [profile, currentAccount]) + + const modOpts = React.useMemo(() => { + return { + userDid: isLoggedOut ? '' : isTargetMe ? did : 'did:web:alice.test', + prefs: { + adultContentEnabled: !noAdult, + labels: { + [label[0]]: visibility[0] as LabelPreference, + }, + labelers: [ + { + did: 'did:plc:fake-labeler', + labels: {[label[0]]: visibility[0] as LabelPreference}, + }, + ], + mutedWords: [], + hiddenPosts: [], + }, + labelDefs: { + 'did:plc:fake-labeler': [ + interpretLabelValueDefinition(customLabelDef, 'did:plc:fake-labeler'), + ], + }, + } + }, [label, visibility, noAdult, isLoggedOut, isTargetMe, did, customLabelDef]) + + const profileModeration = React.useMemo(() => { + return moderateProfile(profile, modOpts) + }, [profile, modOpts]) + const postModeration = React.useMemo(() => { + return moderatePost(post, modOpts) + }, [post, modOpts]) + + return ( + <moderationOptsOverrideContext.Provider value={modOpts}> + <ScrollView> + <CenteredView style={[t.atoms.bg, a.px_lg, a.py_lg]}> + <H1 style={[a.text_5xl, a.font_bold, a.pb_lg]}>Moderation states</H1> + + <Heading title="" subtitle="Scenario" /> + <ToggleButton.Group + label="Scenario" + values={scenario} + onChange={setScenario}> + <ToggleButton.Button name="label" label="Label"> + Label + </ToggleButton.Button> + <ToggleButton.Button name="block" label="Block"> + Block + </ToggleButton.Button> + <ToggleButton.Button name="mute" label="Mute"> + Mute + </ToggleButton.Button> + </ToggleButton.Group> + + {scenario[0] === 'label' && ( + <> + <View + style={[ + a.border, + a.rounded_sm, + a.mt_lg, + a.mb_lg, + a.p_lg, + t.atoms.border_contrast_medium, + ]}> + <Toggle.Group + label="Toggle" + type="radio" + values={label} + onChange={setLabel}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + {LABEL_VALUES.map(labelValue => { + let targetFixed = target[0] + if ( + targetFixed !== 'account' && + targetFixed !== 'profile' + ) { + targetFixed = 'content' + } + const disabled = + isSelfLabel && + LABELS[labelValue].flags.includes('no-self') + return ( + <Toggle.Item + key={labelValue} + name={labelValue} + label={labelStrings[labelValue].name} + disabled={disabled} + style={disabled ? {opacity: 0.5} : undefined}> + <Toggle.Radio /> + <Toggle.Label>{labelValue}</Toggle.Label> + </Toggle.Item> + ) + })} + <Toggle.Item + name="custom" + label="Custom label" + disabled={isSelfLabel} + style={isSelfLabel ? {opacity: 0.5} : undefined}> + <Toggle.Radio /> + <Toggle.Label>Custom label</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + {label[0] === 'custom' ? ( + <CustomLabelForm + def={customLabelDef} + setDef={setCustomLabelDef} + /> + ) : ( + <> + <View style={{height: 10}} /> + <Divider /> + </> + )} + + <View style={{height: 10}} /> + + <SmallToggler label="Advanced"> + <Toggle.Group + label="Toggle" + type="checkbox" + values={scenarioSwitches} + onChange={setScenarioSwitches}> + <View style={[a.gap_md, a.flex_row, a.flex_wrap, a.pt_md]}> + <Toggle.Item name="targetMe" label="Target is me"> + <Toggle.Checkbox /> + <Toggle.Label>Target is me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="following" label="Following target"> + <Toggle.Checkbox /> + <Toggle.Label>Following target</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="selfLabel" label="Self label"> + <Toggle.Checkbox /> + <Toggle.Label>Self label</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="noAdult" label="Adult disabled"> + <Toggle.Checkbox /> + <Toggle.Label>Adult disabled</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="loggedOut" label="Logged out"> + <Toggle.Checkbox /> + <Toggle.Label>Logged out</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + {LABELS[label[0] as keyof typeof LABELS]?.configurable !== + false && ( + <View style={[a.mt_md]}> + <Text + style={[a.font_bold, a.text_xs, t.atoms.text, a.pb_sm]}> + Preference + </Text> + <Toggle.Group + label="Preference" + type="radio" + values={visibility} + onChange={setVisiblity}> + <View + style={[ + a.flex_row, + a.gap_md, + a.flex_wrap, + a.align_center, + ]}> + <Toggle.Item name="hide" label="Hide"> + <Toggle.Radio /> + <Toggle.Label>Hide</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="warn" label="Warn"> + <Toggle.Radio /> + <Toggle.Label>Warn</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="ignore" label="Ignore"> + <Toggle.Radio /> + <Toggle.Label>Ignore</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + )} + </SmallToggler> + </View> + + <View style={[a.flex_row, a.flex_wrap, a.gap_md]}> + <View> + <Text + style={[ + a.font_bold, + a.text_xs, + t.atoms.text, + a.pl_md, + a.pb_xs, + ]}> + Target + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Target" + type="radio" + values={target} + onChange={setTarget}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + <Toggle.Item name="account" label="Account"> + <Toggle.Radio /> + <Toggle.Label>Account</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="profile" label="Profile"> + <Toggle.Radio /> + <Toggle.Label>Profile</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="post" label="Post"> + <Toggle.Radio /> + <Toggle.Label>Post</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="embed" label="Embed"> + <Toggle.Radio /> + <Toggle.Label>Embed</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + </View> + </> + )} + + <Spacer /> + + <Heading title="" subtitle="Results" /> + + <ToggleButton.Group label="Results" values={view} onChange={setView}> + <ToggleButton.Button name="post" label="Post"> + Post + </ToggleButton.Button> + <ToggleButton.Button name="notifications" label="Notifications"> + Notifications + </ToggleButton.Button> + <ToggleButton.Button name="account" label="Account"> + Account + </ToggleButton.Button> + <ToggleButton.Button name="data" label="Data"> + Data + </ToggleButton.Button> + </ToggleButton.Group> + + <View + style={[ + a.border, + a.rounded_sm, + a.mt_lg, + a.p_md, + t.atoms.border_contrast_medium, + ]}> + {view[0] === 'post' && ( + <> + <Heading title="Post" subtitle="in feed" /> + <MockPostFeedItem post={post} moderation={postModeration} /> + + <Heading title="Post" subtitle="viewed directly" /> + <MockPostThreadItem post={post} moderation={postModeration} /> + + <Heading title="Post" subtitle="reply in thread" /> + <MockPostThreadItem + post={post} + moderation={postModeration} + reply + /> + </> + )} + + {view[0] === 'notifications' && ( + <> + <Heading title="Notification" subtitle="quote or reply" /> + <MockNotifItem notif={replyNotif} moderationOpts={modOpts} /> + <View style={{height: 20}} /> + <Heading title="Notification" subtitle="follow or like" /> + <MockNotifItem notif={followNotif} moderationOpts={modOpts} /> + </> + )} + + {view[0] === 'account' && ( + <> + <Heading title="Account" subtitle="in listing" /> + <MockAccountCard + profile={profile} + moderation={profileModeration} + /> + + <Heading title="Account" subtitle="viewing directly" /> + <MockAccountScreen + profile={profile} + moderation={profileModeration} + moderationOpts={modOpts} + /> + </> + )} + + {view[0] === 'data' && ( + <> + <ModerationUIView + label="Profile Moderation UI" + mod={profileModeration} + /> + <ModerationUIView + label="Post Moderation UI" + mod={postModeration} + /> + <DataView + label={label[0]} + data={LABELS[label[0] as keyof typeof LABELS]} + /> + <DataView + label="Profile Moderation Data" + data={profileModeration} + /> + <DataView label="Post Moderation Data" data={postModeration} /> + </> + )} + </View> + + <View style={{height: 400}} /> + </CenteredView> + </ScrollView> + </moderationOptsOverrideContext.Provider> + ) +} + +function Heading({title, subtitle}: {title: string; subtitle?: string}) { + const t = useTheme() + return ( + <H3 style={[a.text_3xl, a.font_bold, a.pb_md]}> + {title}{' '} + {!!subtitle && ( + <H3 style={[t.atoms.text_contrast_medium, a.text_lg]}>{subtitle}</H3> + )} + </H3> + ) +} + +function CustomLabelForm({ + def, + setDef, +}: { + def: ComAtprotoLabelDefs.LabelValueDefinition + setDef: React.Dispatch< + React.SetStateAction<ComAtprotoLabelDefs.LabelValueDefinition> + > +}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_row, + a.flex_wrap, + a.gap_md, + t.atoms.bg_contrast_25, + a.rounded_md, + a.p_md, + a.mt_md, + ]}> + <View> + <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}> + Blurs + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Blurs" + type="radio" + values={[def.blurs]} + onChange={values => setDef(v => ({...v, blurs: values[0]}))}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + <Toggle.Item name="content" label="Content"> + <Toggle.Radio /> + <Toggle.Label>Content</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="media" label="Media"> + <Toggle.Radio /> + <Toggle.Label>Media</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="none" label="None"> + <Toggle.Radio /> + <Toggle.Label>None</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + <View> + <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}> + Severity + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Severity" + type="radio" + values={[def.severity]} + onChange={values => setDef(v => ({...v, severity: values[0]}))}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap, a.align_center]}> + <Toggle.Item name="alert" label="Alert"> + <Toggle.Radio /> + <Toggle.Label>Alert</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="inform" label="Inform"> + <Toggle.Radio /> + <Toggle.Label>Inform</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="none" label="None"> + <Toggle.Radio /> + <Toggle.Label>None</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + </View> + ) +} + +function Toggler({label, children}: React.PropsWithChildren<{label: string}>) { + const t = useTheme() + const [show, setShow] = React.useState(false) + return ( + <View style={a.mb_md}> + <View + style={[ + t.atoms.border_contrast_medium, + a.border, + a.rounded_sm, + a.p_xs, + ]}> + <Button + variant="solid" + color="secondary" + label="Toggle visibility" + size="small" + onPress={() => setShow(!show)}> + <ButtonText>{label}</ButtonText> + <ButtonIcon + icon={show ? ChevronTop : ChevronBottom} + position="right" + /> + </Button> + {show && children} + </View> + </View> + ) +} + +function SmallToggler({ + label, + children, +}: React.PropsWithChildren<{label: string}>) { + const [show, setShow] = React.useState(false) + return ( + <View> + <View style={[a.flex_row]}> + <Button + variant="ghost" + color="secondary" + label="Toggle visibility" + size="tiny" + onPress={() => setShow(!show)}> + <ButtonText>{label}</ButtonText> + <ButtonIcon + icon={show ? ChevronTop : ChevronBottom} + position="right" + /> + </Button> + </View> + {show && children} + </View> + ) +} + +function DataView({label, data}: {label: string; data: any}) { + return ( + <Toggler label={label}> + <Text style={[{fontFamily: 'monospace'}, a.p_md]}> + {JSON.stringify(data, null, 2)} + </Text> + </Toggler> + ) +} + +function ModerationUIView({ + mod, + label, +}: { + mod: ModerationDecision + label: string +}) { + return ( + <Toggler label={label}> + <View style={a.p_lg}> + {[ + 'profileList', + 'profileView', + 'avatar', + 'banner', + 'displayName', + 'contentList', + 'contentView', + 'contentMedia', + ].map(key => { + const ui = mod.ui(key as keyof ModerationBehavior) + return ( + <View key={key} style={[a.flex_row, a.gap_md]}> + <Text style={[a.font_bold, {width: 100}]}>{key}</Text> + <Flag v={ui.filter} label="Filter" /> + <Flag v={ui.blur} label="Blur" /> + <Flag v={ui.alert} label="Alert" /> + <Flag v={ui.inform} label="Inform" /> + <Flag v={ui.noOverride} label="No-override" /> + </View> + ) + })} + </View> + </Toggler> + ) +} + +function Spacer() { + return <View style={{height: 30}} /> +} + +function MockPostFeedItem({ + post, + moderation, +}: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision +}) { + const t = useTheme() + if (moderation.ui('contentList').filter) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}> + Filtered from the feed + </P> + ) + } + return ( + <FeedItem + post={post} + record={post.record as AppBskyFeedPost.Record} + moderation={moderation} + reason={undefined} + /> + ) +} + +function MockPostThreadItem({ + post, + reply, +}: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision + reply?: boolean +}) { + return ( + <PostThreadItem + // @ts-ignore + post={post} + record={post.record as AppBskyFeedPost.Record} + depth={reply ? 1 : 0} + isHighlightedPost={!reply} + treeView={false} + prevPost={undefined} + nextPost={undefined} + hasPrecedingItem={false} + onPostReply={() => {}} + /> + ) +} + +function MockNotifItem({ + notif, + moderationOpts, +}: { + notif: FeedNotification + moderationOpts: ModerationOpts +}) { + const t = useTheme() + if (shouldFilterNotif(notif.notification, moderationOpts)) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md]}> + Filtered from the feed + </P> + ) + } + return <NotifFeedItem item={notif} moderationOpts={moderationOpts} /> +} + +function MockAccountCard({ + profile, + moderation, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ModerationDecision +}) { + const t = useTheme() + + if (moderation.ui('profileList').filter) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}> + Filtered from the listing + </P> + ) + } + + return <ProfileCard profile={profile} /> +} + +function MockAccountScreen({ + profile, + moderation, + moderationOpts, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ModerationDecision + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const {_} = useLingui() + return ( + <View style={[t.atoms.border_contrast_medium, a.border, a.mb_md]}> + <ScreenHider + style={{}} + screenDescription={_(msg`profile`)} + modui={moderation.ui('profileView')}> + <ProfileHeaderStandard + // @ts-ignore ProfileViewBasic is close enough -prf + profile={profile} + moderationOpts={moderationOpts} + descriptionRT={new RichText({text: profile.description as string})} + /> + </ScreenHider> + </View> + ) +} + +function Flag({v, label}: {v: boolean | undefined; label: string}) { + const t = useTheme() + return ( + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <View + style={[ + a.justify_center, + a.align_center, + a.rounded_xs, + a.border, + t.atoms.border_contrast_medium, + { + backgroundColor: t.palette.contrast_25, + width: 14, + height: 14, + }, + ]}> + {v && <Check size="xs" fill={t.palette.contrast_900} />} + </View> + <P style={a.text_xs}>{label}</P> + </View> + ) +} diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx deleted file mode 100644 index 928766c3..00000000 --- a/src/view/screens/Moderation.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {ComAtprotoLabelDefs} from '@atproto/api' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {s} from 'lib/styles' -import {CenteredView} from '../com/util/Views' -import {ViewHeader} from '../com/util/ViewHeader' -import {Link, TextLink} from '../com/util/Link' -import {Text} from '../com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useSetMinimalShellMode} from '#/state/shell' -import {useModalControls} from '#/state/modals' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {ToggleButton} from '../com/util/forms/ToggleButton' -import {useSession} from '#/state/session' -import { - useProfileQuery, - useProfileUpdateMutation, -} from '#/state/queries/profile' -import {ScrollView} from '../com/util/Views' -import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' - -type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> -export function ModerationScreen({}: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() - const {openModal} = useModalControls() - const {mutedWordsDialogControl} = useGlobalDialogsControlContext() - - useFocusEffect( - React.useCallback(() => { - screen('Moderation') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) - - const onPressContentFiltering = React.useCallback(() => { - track('Moderation:ContentfilteringButtonClicked') - openModal({name: 'content-filtering-settings'}) - }, [track, openModal]) - - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, - ]} - testID="moderationScreen"> - <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> - <ScrollView contentContainerStyle={[styles.noBorder]}> - <View style={styles.spacer} /> - <TouchableOpacity - testID="contentFilteringBtn" - style={[styles.linkCard, pal.view]} - onPress={onPressContentFiltering} - accessibilityRole="tab" - accessibilityHint="" - accessibilityLabel={_(msg`Open content filtering settings`)}> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="eye" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Content filtering</Trans> - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="mutedWordsBtn" - style={[styles.linkCard, pal.view]} - onPress={() => mutedWordsDialogControl.open()} - accessibilityRole="tab" - accessibilityHint="" - accessibilityLabel={_(msg`Open muted words settings`)}> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="filter" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Muted words & tags</Trans> - </Text> - </TouchableOpacity> - <Link - testID="moderationlistsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/modlists"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="users-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Moderation lists</Trans> - </Text> - </Link> - <Link - testID="mutedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/muted-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="user-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Muted accounts</Trans> - </Text> - </Link> - <Link - testID="blockedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/blocked-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="ban" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Blocked accounts</Trans> - </Text> - </Link> - <Text - type="xl-bold" - style={[ - pal.text, - { - paddingHorizontal: 18, - paddingTop: 18, - paddingBottom: 6, - }, - ]}> - <Trans>Logged-out visibility</Trans> - </Text> - <PwiOptOut /> - </ScrollView> - </CenteredView> - ) -} - -function PwiOptOut() { - const pal = usePalette('default') - const {_} = useLingui() - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const updateProfile = useProfileUpdateMutation() - - const isOptedOut = - profile?.labels?.some(l => l.val === '!no-unauthenticated') || false - const canToggle = profile && !updateProfile.isPending - - const onToggleOptOut = React.useCallback(() => { - if (!profile) { - return - } - let wasAdded = false - updateProfile.mutate({ - profile, - updates: existing => { - // create labels attr if needed - existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) - ? existing.labels - : { - $type: 'com.atproto.label.defs#selfLabels', - values: [], - } - - // toggle the label - const hasLabel = existing.labels.values.some( - l => l.val === '!no-unauthenticated', - ) - if (hasLabel) { - wasAdded = false - existing.labels.values = existing.labels.values.filter( - l => l.val !== '!no-unauthenticated', - ) - } else { - wasAdded = true - existing.labels.values.push({val: '!no-unauthenticated'}) - } - - // delete if no longer needed - if (existing.labels.values.length === 0) { - delete existing.labels - } - return existing - }, - checkCommitted: res => { - const exists = !!res.data.labels?.some( - l => l.val === '!no-unauthenticated', - ) - return exists === wasAdded - }, - }) - }, [updateProfile, profile]) - - return ( - <View style={[pal.view, styles.toggleCard]}> - <View - style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}> - <ToggleButton - type="default-light" - label={_( - msg`Discourage apps from showing my account to logged-out users`, - )} - labelType="lg" - isSelected={isOptedOut} - onPress={canToggle ? onToggleOptOut : undefined} - style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]} - /> - {updateProfile.isPending && <ActivityIndicator />} - </View> - <View - style={{ - flexDirection: 'column', - gap: 10, - paddingLeft: 66, - paddingRight: 12, - paddingBottom: 10, - marginBottom: 64, - }}> - <Text style={pal.textLight}> - <Trans> - Bluesky will not show your profile and posts to logged-out users. - Other apps may not honor this request. This does not make your - account private. - </Trans> - </Text> - <Text style={[pal.textLight, {fontWeight: '500'}]}> - <Trans> - Note: Bluesky is an open and public network. This setting only - limits the visibility of your content on the Bluesky app and - website, and other apps may not respect this setting. Your content - may still be shown to logged-out users by other apps and websites. - </Trans> - </Text> - <TextLink - style={pal.link} - href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy" - text={_(msg`Learn more about what is public on Bluesky.`)} - /> - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - spacer: { - height: 6, - }, - linkCard: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 18, - marginBottom: 1, - }, - toggleCard: { - paddingVertical: 8, - paddingTop: 2, - paddingHorizontal: 6, - marginBottom: 1, - }, - iconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 40, - height: 40, - borderRadius: 30, - marginRight: 12, - }, - noBorder: { - borderBottomWidth: 0, - borderRightWidth: 0, - borderLeftWidth: 0, - borderTopWidth: 0, - }, -}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index b30b4491..d5a46c5c 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from 'react' -import {StyleSheet, View} from 'react-native' +import {StyleSheet} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import { AppBskyActorDefs, @@ -7,48 +7,39 @@ import { ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {CenteredView} from '../com/util/Views' import {ListRef} from '../com/util/List' -import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {Feed} from 'view/com/posts/Feed' +import {ScreenHider} from '#/components/moderation/ScreenHider' import {ProfileLists} from '../com/lists/ProfileLists' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' -import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ErrorScreen} from '../com/util/error/ErrorScreen' -import {EmptyState} from '../com/util/EmptyState' import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' -import { - FeedDescriptor, - resetProfilePostsQueries, -} from '#/state/queries/post-feed' +import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useProfileQuery} from '#/state/queries/profile' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession, getAgent} from '#/state/session' import {useModerationOpts} from '#/state/queries/preferences' -import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {useLabelerInfoQuery} from '#/state/queries/labeler' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {cleanError} from '#/lib/strings/errors' -import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' -import {useQueryClient} from '@tanstack/react-query' import {useComposerControls} from '#/state/shell/composer' import {listenSoftReset} from '#/state/events' -import {truncateAndInvalidate} from '#/state/queries/util' -import {Text} from '#/view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {isNative} from '#/platform/detection' import {isInvalidHandle} from '#/lib/strings/handles' +import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' +import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' +import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' + interface SectionRef { scrollToTop: () => void } @@ -148,16 +139,24 @@ function ProfileScreenLoaded({ const setMinimalShellMode = useSetMinimalShellMode() const {openComposer} = useComposerControls() const {screen, track} = useAnalytics() + const { + data: labelerInfo, + error: labelerError, + isLoading: isLabelerLoading, + } = useLabelerInfoQuery({ + did: profile.did, + enabled: !!profile.associated?.labeler, + }) const [currentPage, setCurrentPage] = React.useState(0) const {_} = useLingui() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const extraInfoQuery = useProfileExtraInfoQuery(profile.did) const postsSectionRef = React.useRef<SectionRef>(null) const repliesSectionRef = React.useRef<SectionRef>(null) const mediaSectionRef = React.useRef<SectionRef>(null) const likesSectionRef = React.useRef<SectionRef>(null) const feedsSectionRef = React.useRef<SectionRef>(null) const listsSectionRef = React.useRef<SectionRef>(null) + const labelsSectionRef = React.useRef<SectionRef>(null) useSetTitle(combinedDisplayName(profile)) @@ -171,44 +170,75 @@ function ProfileScreenLoaded({ ) const isMe = profile.did === currentAccount?.did + const hasLabeler = !!profile.associated?.labeler + const showFiltersTab = hasLabeler + const showPostsTab = true const showRepliesTab = hasSession + const showMediaTab = !hasLabeler const showLikesTab = isMe - const showFeedsTab = hasSession && (isMe || extraInfoQuery.data?.hasFeedgens) - const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists) + const showFeedsTab = + hasSession && (isMe || (profile.associated?.feedgens || 0) > 0) + const showListsTab = + hasSession && (isMe || (profile.associated?.lists || 0) > 0) + const sectionTitles = useMemo<string[]>(() => { return [ - _(msg`Posts`), + showFiltersTab ? _(msg`Labels`) : undefined, + showListsTab && hasLabeler ? _(msg`Lists`) : undefined, + showPostsTab ? _(msg`Posts`) : undefined, showRepliesTab ? _(msg`Replies`) : undefined, - _(msg`Media`), + showMediaTab ? _(msg`Media`) : undefined, showLikesTab ? _(msg`Likes`) : undefined, showFeedsTab ? _(msg`Feeds`) : undefined, - showListsTab ? _(msg`Lists`) : undefined, + showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, ].filter(Boolean) as string[] - }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab, _]) + }, [ + showPostsTab, + showRepliesTab, + showMediaTab, + showLikesTab, + showFeedsTab, + showListsTab, + showFiltersTab, + hasLabeler, + _, + ]) let nextIndex = 0 - const postsIndex = nextIndex++ + let filtersIndex: number | null = null + let postsIndex: number | null = null let repliesIndex: number | null = null + let mediaIndex: number | null = null + let likesIndex: number | null = null + let feedsIndex: number | null = null + let listsIndex: number | null = null + if (showFiltersTab) { + filtersIndex = nextIndex++ + } + if (showPostsTab) { + postsIndex = nextIndex++ + } if (showRepliesTab) { repliesIndex = nextIndex++ } - const mediaIndex = nextIndex++ - let likesIndex: number | null = null + if (showMediaTab) { + mediaIndex = nextIndex++ + } if (showLikesTab) { likesIndex = nextIndex++ } - let feedsIndex: number | null = null if (showFeedsTab) { feedsIndex = nextIndex++ } - let listsIndex: number | null = null if (showListsTab) { listsIndex = nextIndex++ } const scrollSectionToTop = React.useCallback( (index: number) => { - if (index === postsIndex) { + if (index === filtersIndex) { + labelsSectionRef.current?.scrollToTop() + } else if (index === postsIndex) { postsSectionRef.current?.scrollToTop() } else if (index === repliesIndex) { repliesSectionRef.current?.scrollToTop() @@ -222,7 +252,15 @@ function ProfileScreenLoaded({ listsSectionRef.current?.scrollToTop() } }, - [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex], + [ + filtersIndex, + postsIndex, + repliesIndex, + mediaIndex, + likesIndex, + feedsIndex, + listsIndex, + ], ) useFocusEffect( @@ -278,6 +316,7 @@ function ProfileScreenLoaded({ return ( <ProfileHeader profile={profile} + labeler={labelerInfo} descriptionRT={hasDescription ? descriptionRT : null} moderationOpts={moderationOpts} hideBackButton={hideBackButton} @@ -286,6 +325,7 @@ function ProfileScreenLoaded({ ) }, [ profile, + labelerInfo, descriptionRT, hasDescription, moderationOpts, @@ -297,8 +337,8 @@ function ProfileScreenLoaded({ <ScreenHider testID="profileView" style={styles.container} - screenDescription="profile" - moderation={moderation.account}> + screenDescription={_(msg`profile`)} + modui={moderation.ui('profileView')}> <PagerWithHeader testID="profilePager" isHeaderReady={!showPlaceholder} @@ -306,19 +346,45 @@ function ProfileScreenLoaded({ onPageSelected={onPageSelected} onCurrentPageSelected={onCurrentPageSelected} renderHeader={renderHeader}> - {({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection - ref={postsSectionRef} - feed={`author|${profile.did}|posts_and_author_threads`} - headerHeight={headerHeight} - isFocused={isFocused} - scrollElRef={scrollElRef as ListRef} - ignoreFilterFor={profile.did} - /> - )} + {showFiltersTab + ? ({headerHeight, scrollElRef}) => ( + <ProfileLabelsSection + ref={labelsSectionRef} + labelerInfo={labelerInfo} + labelerError={labelerError} + isLabelerLoading={isLabelerLoading} + moderationOpts={moderationOpts} + scrollElRef={scrollElRef as ListRef} + headerHeight={headerHeight} + /> + ) + : null} + {showListsTab && !!profile.associated?.labeler + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileLists + ref={listsSectionRef} + did={profile.did} + scrollElRef={scrollElRef as ListRef} + headerOffset={headerHeight} + enabled={isFocused} + /> + ) + : null} + {showPostsTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedSection + ref={postsSectionRef} + feed={`author|${profile.did}|posts_and_author_threads`} + headerHeight={headerHeight} + isFocused={isFocused} + scrollElRef={scrollElRef as ListRef} + ignoreFilterFor={profile.did} + /> + ) + : null} {showRepliesTab ? ({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection + <ProfileFeedSection ref={repliesSectionRef} feed={`author|${profile.did}|posts_with_replies`} headerHeight={headerHeight} @@ -328,19 +394,21 @@ function ProfileScreenLoaded({ /> ) : null} - {({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection - ref={mediaSectionRef} - feed={`author|${profile.did}|posts_with_media`} - headerHeight={headerHeight} - isFocused={isFocused} - scrollElRef={scrollElRef as ListRef} - ignoreFilterFor={profile.did} - /> - )} + {showMediaTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedSection + ref={mediaSectionRef} + feed={`author|${profile.did}|posts_with_media`} + headerHeight={headerHeight} + isFocused={isFocused} + scrollElRef={scrollElRef as ListRef} + ignoreFilterFor={profile.did} + /> + ) + : null} {showLikesTab ? ({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection + <ProfileFeedSection ref={likesSectionRef} feed={`likes|${profile.did}`} headerHeight={headerHeight} @@ -361,7 +429,7 @@ function ProfileScreenLoaded({ /> ) : null} - {showListsTab + {showListsTab && !profile.associated?.labeler ? ({headerHeight, isFocused, scrollElRef}) => ( <ProfileLists ref={listsSectionRef} @@ -387,77 +455,6 @@ function ProfileScreenLoaded({ ) } -interface FeedSectionProps { - feed: FeedDescriptor - headerHeight: number - isFocused: boolean - scrollElRef: ListRef - ignoreFilterFor?: string -} -const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( - function FeedSectionImpl( - {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, - ref, - ) { - const {_} = useLingui() - const queryClient = useQueryClient() - const [hasNew, setHasNew] = React.useState(false) - const [isScrolledDown, setIsScrolledDown] = React.useState(false) - - const onScrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({ - animated: isNative, - offset: -headerHeight, - }) - truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) - setHasNew(false) - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) - React.useImperativeHandle(ref, () => ({ - scrollToTop: onScrollToTop, - })) - - const renderPostsEmpty = React.useCallback(() => { - return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> - }, [_]) - - return ( - <View> - <Feed - testID="postsFeed" - enabled={isFocused} - feed={feed} - scrollElRef={scrollElRef} - onHasNew={setHasNew} - onScrolledDownChange={setIsScrolledDown} - renderEmptyState={renderPostsEmpty} - headerOffset={headerHeight} - renderEndOfFeed={ProfileEndOfFeed} - ignoreFilterFor={ignoreFilterFor} - /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onScrollToTop} - label={_(msg`Load new posts`)} - showIndicator={hasNew} - /> - )} - </View> - ) - }, -) - -function ProfileEndOfFeed() { - const pal = usePalette('default') - - return ( - <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}> - <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}> - <Trans>End of feed</Trans> - </Text> - </View> - ) -} - function useRichText(text: string): [RichTextAPI, boolean] { const [prevText, setPrevText] = React.useState(text) const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text})) diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index b3a7328c..416bbc30 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -35,7 +35,7 @@ import {ComposeIcon2} from 'lib/icons' import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import { @@ -155,7 +155,7 @@ export function ProfileFeedScreenInner({ const {_} = useLingui() const t = useTheme() const {hasSession, currentAccount} = useSession() - const {openModal} = useModalControls() + const reportDialogControl = useReportDialogControl() const {openComposer} = useComposerControls() const {track} = useAnalytics() const feedSectionRef = React.useRef<SectionRef>(null) @@ -253,13 +253,8 @@ export function ProfileFeedScreenInner({ }, [feedInfo, track]) const onPressReport = React.useCallback(() => { - if (!feedInfo) return - openModal({ - name: 'report', - uri: feedInfo.uri, - cid: feedInfo.cid, - }) - }, [openModal, feedInfo]) + reportDialogControl.open() + }, [reportDialogControl]) const onCurrentPageSelected = React.useCallback( (index: number) => { @@ -400,6 +395,14 @@ export function ProfileFeedScreenInner({ return ( <View style={s.hContentRegion}> + <ReportDialog + control={reportDialogControl} + params={{ + type: 'feedgen', + uri: feedInfo.uri, + cid: feedInfo.cid, + }} + /> <PagerWithHeader items={SECTION_TITLES} isHeaderReady={true} diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 95046e5b..35152126 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -39,6 +39,7 @@ import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' import {useModalControls} from '#/state/modals' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import { useListQuery, @@ -236,6 +237,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const {currentAccount} = useSession() + const reportDialogControl = useReportDialogControl() const {openModal} = useModalControls() const listMuteMutation = useListMuteMutation() const listBlockMutation = useListBlockMutation() @@ -370,12 +372,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { ]) const onPressReport = useCallback(() => { - openModal({ - name: 'report', - uri: list.uri, - cid: list.cid, - }) - }, [openModal, list]) + reportDialogControl.open() + }, [reportDialogControl]) const onPressShare = useCallback(() => { const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) @@ -550,6 +548,14 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { isOwner={list.creator.did === currentAccount?.did} creator={list.creator} avatarType="list"> + <ReportDialog + control={reportDialogControl} + params={{ + type: 'list', + uri: list.uri, + cid: list.cid, + }} + /> {isCurateList || isPinned ? ( <Button testID={isPinned ? 'unpinBtn' : 'pinBtn'} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 3b5e190c..1b96a09a 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -267,6 +267,10 @@ export function SettingsScreen({}: Props) { navigation.navigate('Debug') }, [navigation]) + const onPressDebugModeration = React.useCallback(() => { + navigation.navigate('DebugMod') + }, [navigation]) + const onPressSavedFeeds = React.useCallback(() => { navigation.navigate('SavedFeeds') }, [navigation]) @@ -821,6 +825,16 @@ export function SettingsScreen({}: Props) { <Trans>Storybook</Trans> </Text> </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressDebugModeration} + accessibilityRole="button" + accessibilityLabel={_(msg`Open storybook page`)} + accessibilityHint={_(msg`Opens the storybook page`)}> + <Text type="lg" style={pal.text}> + <Trans>Debug Moderation</Trans> + </Text> + </TouchableOpacity> <TouchableOpacity style={[pal.view, styles.linkCardNoIcon]} onPress={onPressResetPreferences} diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx index 320db13f..ad2fff3f 100644 --- a/src/view/screens/Storybook/Buttons.tsx +++ b/src/view/screens/Storybook/Buttons.tsx @@ -129,6 +129,15 @@ export function Buttons() { <ButtonIcon icon={Globe} position="left" /> <ButtonText>Link out</ButtonText> </Button> + + <Button + variant="gradient" + color="gradient_sky" + size="tiny" + label="Link out"> + <ButtonIcon icon={Globe} position="left" /> + <ButtonText>Link out</ButtonText> + </Button> </View> <View style={[a.flex_row, a.gap_md, a.align_start]}> @@ -148,6 +157,14 @@ export function Buttons() { label="Link out"> <ButtonIcon icon={ChevronLeft} /> </Button> + <Button + variant="gradient" + color="gradient_sunset" + size="tiny" + shape="round" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> <Button variant="outline" color="primary" @@ -164,6 +181,14 @@ export function Buttons() { label="Link out"> <ButtonIcon icon={ChevronLeft} /> </Button> + <Button + variant="ghost" + color="primary" + size="tiny" + shape="round" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> </View> <View style={[a.flex_row, a.gap_md, a.align_start]}> @@ -183,6 +208,14 @@ export function Buttons() { label="Link out"> <ButtonIcon icon={ChevronLeft} /> </Button> + <Button + variant="gradient" + color="gradient_sunset" + size="tiny" + shape="square" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> <Button variant="outline" color="primary" @@ -199,6 +232,14 @@ export function Buttons() { label="Link out"> <ButtonIcon icon={ChevronLeft} /> </Button> + <Button + variant="ghost" + color="primary" + size="tiny" + shape="square" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> </View> </View> ) diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index e43d756d..3a2e2f36 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -67,6 +67,7 @@ export function Storybook() { </Button> </View> + <Dialogs /> <ThemeProvider theme="light"> <Theming /> </ThemeProvider> diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 4a948373..8933324e 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -11,7 +11,7 @@ import {useNavigation, StackActions} from '@react-navigation/native' import { AppBskyActorDefs, moderateProfile, - ProfileModeration, + ModerationDecision, } from '@atproto/api' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -86,7 +86,7 @@ export function SearchProfileCard({ moderation, }: { profile: AppBskyActorDefs.ProfileViewBasic - moderation: ProfileModeration + moderation: ModerationDecision }) { const pal = usePalette('default') @@ -111,7 +111,7 @@ export function SearchProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> <View style={{flex: 1}}> <Text @@ -121,7 +121,7 @@ export function SearchProfileCard({ lineHeight={1.2}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 76a7f8fb..f2918309 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -101,8 +101,8 @@ function ShellInner() { <Composer winHeight={winDim.height} /> <ModalsContainer /> <MutedWordsDialog /> - <PortalOutlet /> <Lightbox /> + <PortalOutlet /> </> ) } diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 24024e6e..02993ac4 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -45,8 +45,9 @@ function ShellInner() { <Composer winHeight={0} /> <ModalsContainer /> <MutedWordsDialog /> - <PortalOutlet /> <Lightbox /> + <PortalOutlet /> + {!isDesktop && isDrawerOpen && ( <TouchableOpacity onPress={() => setDrawerOpen(false)} diff --git a/yarn.lock b/yarn.lock index 28d82102..83dee81c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,15 +34,15 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.10.5": - version "0.10.5" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.5.tgz#e778e2843d08690df8df81f24028a7578e9b3cb4" - integrity sha512-GYdST5sPKU2JnPmm8x3KqjOSlDiYXrp4GkW7bpQTVLPabnUNq5NLN6HJEoJABjjOAsaLF12rBoV+JpRb1UjNsQ== +"@atproto/api@^0.11.2": + version "0.11.2" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.11.2.tgz#33432cdaeff674d8b21a28601542884bbe1473b2" + integrity sha512-VYpavKaEfuXhce9mP/+boZ7BNglnKSReFCRNXhFpa1KnAvWFHGsf+2jP69skEEnZ1XY4/lQhXdDzZdUIqXn2aA== dependencies: - "@atproto/common-web" "^0.2.3" - "@atproto/lexicon" "^0.3.2" - "@atproto/syntax" "^0.2.0" - "@atproto/xrpc" "^0.4.2" + "@atproto/common-web" "^0.2.4" + "@atproto/lexicon" "^0.3.3" + "@atproto/syntax" "^0.2.1" + "@atproto/xrpc" "^0.4.3" multiformats "^9.9.0" tlds "^1.234.0" typed-emitter "^2.1.0" @@ -144,6 +144,16 @@ uint8arrays "3.0.0" zod "^3.21.4" +"@atproto/common-web@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.4.tgz#a77e0a7f8f025115b3db4f7c530b934b10ab3d82" + integrity sha512-6+DOhQcTklFmeiSkZRx6iFeqi4OFtGl4yEDGATk00q4tEcPoPvyOBtYHN6+G9lrfJIfx5RfmggamvXlJv1PxxA== + dependencies: + graphemer "^1.4.0" + multiformats "^9.9.0" + uint8arrays "3.0.0" + zod "^3.21.4" + "@atproto/common@0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" @@ -245,13 +255,13 @@ multiformats "^9.9.0" zod "^3.21.4" -"@atproto/lexicon@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.3.2.tgz#0085a3acd3a77867b8efe188297a1bbacc55ce5c" - integrity sha512-kmGCkrRwpWIqmn/KO4BZwUf8Nmfndk3XvFC06V0ygCWc42g6+t4QP/6ywNW4PgqfZY0Q5aW4EuDfD7KjAFkFtQ== +"@atproto/lexicon@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.3.3.tgz#e242bf8b024661b3312a8da5b84fe2cd627a6ab1" + integrity sha512-6FOjdc3V05JKrtkhjfhHMS7f/4hMJOeHNtoE3Na7iFMpzBz0Lw5sw8kIFKY8pc8IG79qGcFgELyHLsljZYX+5A== dependencies: - "@atproto/common-web" "^0.2.3" - "@atproto/syntax" "^0.2.0" + "@atproto/common-web" "^0.2.4" + "@atproto/syntax" "^0.2.1" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" zod "^3.21.4" @@ -351,12 +361,12 @@ dependencies: "@atproto/common-web" "^0.2.3" -"@atproto/syntax@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.2.0.tgz#4bab724c02e11f8943b8ec101251082cf55067e9" - integrity sha512-K+9jl6mtxC9ytlR7msSiP9jVNqtdxEBSt0kOfsC924lqGwuD8nlUAMi1GSMgAZJGg/Rd+0MKXh789heTdeL3HQ== +"@atproto/syntax@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.2.1.tgz#3abcb050c122e7ce80fc25b49cd7d86c63dc6c2e" + integrity sha512-ImOuiICtB5h78j90hAYOfTYzr5q5Wut0irNdELiogA3i74a8EXThe+j6Tj8snanYggrShbu5c6BDc1tVj477Yw== dependencies: - "@atproto/common-web" "^0.2.3" + "@atproto/common-web" "^0.2.4" "@atproto/xrpc-server@^0.4.2": version "0.4.2" @@ -383,12 +393,12 @@ "@atproto/lexicon" "^0.3.1" zod "^3.21.4" -"@atproto/xrpc@^0.4.2": - version "0.4.2" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.4.2.tgz#57812e0624be597b85f21471acf336513f35ccda" - integrity sha512-x4x2QB4nWmLjIpz2Ue9n/QVbVyJkk6tQMhvmDQaVFF89E3FcVI4rxF4uhzSxaLpbNtyVQBNEEmNHOr5EJLeHVA== +"@atproto/xrpc@^0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.4.3.tgz#19d85cb7f343369363e664917945dec1f89fde97" + integrity sha512-0rn3abHORG0T93mci8WW97Cpg2ClU2aCtTq5rxdCPRsl9P4tyP+8F4snbkrIaMbVO05Rd9D9gFwtWs5Z473pCQ== dependencies: - "@atproto/lexicon" "^0.3.2" + "@atproto/lexicon" "^0.3.3" zod "^3.21.4" "@aws-crypto/crc32@3.0.0": @@ -7244,11 +7254,37 @@ dependencies: "@tamagui/constants" "1.84.1" +"@tanstack/query-async-storage-persister@^5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.25.0.tgz#0e8a2a781b8e32a81a5d02a688d6fcdfd055235b" + integrity sha512-58UTp1CuLr2mehsJRMOd8IZPtYGHFeL+uHnHyRd1kmbwo7wDaa8HXstiBdTRq5KokxIXy9FiFbA06LtKuOiwMQ== + dependencies: + "@tanstack/query-persist-client-core" "5.25.0" + +"@tanstack/query-core@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.25.0.tgz#e08ed0a9fad34c8005d1a282e57280031ac50cdc" + integrity sha512-vlobHP64HTuSE68lWF1mEhwSRC5Q7gaT+a/m9S+ItuN+ruSOxe1rFnR9j0ACWQ314BPhBEVKfBQ6mHL0OWfdbQ== + "@tanstack/query-core@5.8.1": version "5.8.1" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996" integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA== +"@tanstack/query-persist-client-core@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.25.0.tgz#52fa634a8067d7b965854a532a33077fd4df0eff" + integrity sha512-sEUsEZ/XWkOosO45CDBI5nj5woCS+DUd9Dk8pGpU8MkeH0EVd3p4N5CdbjNhrreyy5Krf3rpNaiRN9ygLX/rWA== + dependencies: + "@tanstack/query-core" "5.25.0" + +"@tanstack/react-query-persist-client@^5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.25.0.tgz#ecbd1362cd6fd94e723d54f5af477d0812852dab" + integrity sha512-j1+GyFj4UQGWuiFZoDUVJZS+wxqKd9SGvPlyHG619zzYNN+QQu4B5uvvHc6U8MroM377EOBOuLKK3W6qsAdahQ== + dependencies: + "@tanstack/query-persist-client-core" "5.25.0" + "@tanstack/react-query@^5.8.1": version "5.8.1" resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.8.1.tgz#22a122016e23a39acd90341954a895980ec21ade"