diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 76b7bcc06b..cd6487cf10 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -210,6 +210,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/modservice", server.WebGeneric) // profile RSS feed (DID not handle) e.GET("/profile/:ident/rss", server.WebProfileRSS) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 35d8dff74c..d57d6a2167 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -56,6 +56,7 @@ import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' import {ProfileFeedScreen} from './view/screens/ProfileFeed' import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy' +import {ProfileModserviceScreen} from './view/screens/ProfileModservice' import {ProfileListScreen} from './view/screens/ProfileList' import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' @@ -197,6 +198,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => ProfileFeedLikedByScreen} options={{title: title(msg`Liked by`)}} /> + ProfileModserviceScreen} + options={{title: title(msg`Moderation service`)}} + /> Storybook} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 90ae758304..64020d96ea 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -21,6 +21,7 @@ export type CommonNavigatorParams = { PostRepostedBy: {name: string; rkey: string} ProfileFeed: {name: string; rkey: string} ProfileFeedLikedBy: {name: string; rkey: string} + ProfileModservice: {name: string} Debug: undefined Log: undefined Support: undefined diff --git a/src/routes.ts b/src/routes.ts index e58fddd429..7da64be018 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -21,6 +21,7 @@ export const router = new Router({ PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', ProfileFeed: '/profile/:name/feed/:rkey', ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', + ProfileModservice: '/profile/:name/modservice', Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', diff --git a/src/state/queries/modservice.ts b/src/state/queries/modservice.ts new file mode 100644 index 0000000000..5cf45254cd --- /dev/null +++ b/src/state/queries/modservice.ts @@ -0,0 +1,14 @@ +import {useQuery} from '@tanstack/react-query' +import {getAgent} from '../session' + +export const RQKEY = (did: string) => ['mod-service-info', did] + +export function useModServiceInfoQuery({did}: {did: string}) { + return useQuery({ + queryKey: RQKEY(did), + queryFn: async () => { + const res = await getAgent().app.bsky.moderation.getService({did}) + return res.data + }, + }) +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index b555a997b8..b6da8d3eaa 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -312,6 +312,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) __globalAgent = agent + window.agent = agent queryClient.clear() upsertAccount(account) @@ -351,6 +352,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { {networkErrorCallback: clearCurrentAccount}, ), }) + window.agent = agent let canReusePrevSession = false try { diff --git a/src/view/com/moderation/AdultContentPref.tsx b/src/view/com/moderation/AdultContentPref.tsx new file mode 100644 index 0000000000..a9be38b748 --- /dev/null +++ b/src/view/com/moderation/AdultContentPref.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {s} 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 * 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, + usePreferencesSetAdultContentMutation, +} from '#/state/queries/preferences' + +export function AdultContentEnabledPref() { + const pal = usePalette('default') + const {_} = useLingui() + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetAdultContentMutation() + const {openModal} = useModalControls() + + const onSetAge = React.useCallback( + () => openModal({name: 'birth-date-settings'}), + [openModal], + ) + + 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', {error: e}) + } + }, [variables, preferences, mutate, _]) + + return ( + + {isIOS ? ( + preferences?.adultContentEnabled ? null : ( + + + + Adult content can only be enabled via the Web at{' '} + + . + + + + ) + ) : (preferences?.userAge || 0) >= 18 ? ( + + + + ) : ( + + + You must be 18 or older to enable adult content. + + + )} + + ) +} + +const styles = StyleSheet.create({ + agePrompt: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + }, + toggleBtn: { + paddingHorizontal: 0, + backgroundColor: 'transparent', + }, +}) diff --git a/src/view/com/moderation/LabelGroupPref.tsx b/src/view/com/moderation/LabelGroupPref.tsx new file mode 100644 index 0000000000..799428e924 --- /dev/null +++ b/src/view/com/moderation/LabelGroupPref.tsx @@ -0,0 +1,178 @@ +import React from 'react' +import {LabelPreference} from '@atproto/api' +import {StyleSheet, Pressable, View} from 'react-native' +import {s} from 'lib/styles' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesSetContentLabelMutation, + ConfigurableLabelGroup, + CONFIGURABLE_LABEL_GROUPS, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' + +export function LabelGroupPref({ + 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 ( + + + + {CONFIGURABLE_LABEL_GROUPS[labelGroup].title} + + {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && ( + + {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle} + + )} + + + {disabled || !visibility ? ( + + Hide + + ) : ( + + )} + + ) +} + +interface SelectGroupProps { + current: LabelPreference + onChange: (v: LabelPreference) => void + labelGroup: ConfigurableLabelGroup +} + +function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) { + const {_} = useLingui() + + return ( + + + + + + ) +} + +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 ( + onChange(value)} + accessibilityRole="button" + accessibilityLabel={value} + accessibilityHint={_( + msg`Set ${value} for ${labelGroup} content moderation policy`, + )}> + + {label} + + + ) +} +const styles = StyleSheet.create({ + labelGroupPref: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 14, + paddingLeft: 14, + paddingRight: 10, + 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, + }, +}) diff --git a/src/view/com/moderation/ModServiceGuidelines.tsx b/src/view/com/moderation/ModServiceGuidelines.tsx new file mode 100644 index 0000000000..689145b914 --- /dev/null +++ b/src/view/com/moderation/ModServiceGuidelines.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import {View} from 'react-native' +import {Text} from '../util/text/Text' +import {usePalette} from '#/lib/hooks/usePalette' + +export function ModServiceGuidelines({}: {}) { + const pal = usePalette('default') + + return ( + + + Guidelines + + + + These rules will evolve over time as we continually work to cultivate + a healthy and thriving community. Do not: + + + 1. Praise or promote material from hate groups or U.S., Canadian, and + E.U. proscribed terror groups. + + + 2. Distribute child sexual abuse material + + + 3. Engage in human trafficking or sexual exploitation, including any + attempt to distribute, participate or normalize child sexual abuse + + + 4. Trade in illegal goods or substances + + + 5. Steal or distribute others’ private personal information without + their permission + + + 6. Hack or access systems that you aren’t authorized to access + + + 7. Scam or cheat others for financial gain h. Spam, phish, or + otherwise use technical means to disrupt the experience of others on + Bluesky Social + + + 8. Infringe other’s copyrights, trademarks and/or other intellectual + property + + + + ) +} diff --git a/src/view/com/moderation/ModServiceHeader.tsx b/src/view/com/moderation/ModServiceHeader.tsx new file mode 100644 index 0000000000..63f2872a1f --- /dev/null +++ b/src/view/com/moderation/ModServiceHeader.tsx @@ -0,0 +1,226 @@ +import React from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' +import {CenteredView} from '../util/Views' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink} from 'lib/routes/links' +import {NavigationProp} from 'lib/routes/types' +import {BACK_HITSLOP} from 'lib/constants' +import {isNative} from 'platform/detection' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' +import {useSetDrawerOpen} from '#/state/shell' +import {emitSoftReset} from '#/state/events' +import {AppBskyModerationDefs} from '@atproto/api' +import {HandIcon} from '#/lib/icons' +import {shareUrl} from 'lib/sharing' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {Button} from '../util/forms/Button' +import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' +import {useSession} from '#/state/session' +import {useModalControls} from '#/state/modals' + +export function ModServiceHeader({ + info, +}: { + info: AppBskyModerationDefs.ModServiceViewDetailed +}) { + const setDrawerOpen = useSetDrawerOpen() + const navigation = useNavigation() + const {_} = useLingui() + const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') + const canGoBack = navigation.canGoBack() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + const onPressMenu = React.useCallback(() => { + setDrawerOpen(true) + }, [setDrawerOpen]) + + return ( + + {isMobile && ( + + + {canGoBack ? ( + + ) : ( + + )} + + + + + )} + + + + + + + + Moderation service + + + + + + + + ) + } + + return resolvedDid ? ( + + ) : ( + + + + + + ) +} + +function ProfileModservicecreenIntermediate({modDid}: {modDid: string}) { + const {data: preferences} = usePreferencesQuery() + const {data: info} = useModServiceInfoQuery({did: modDid}) + + if (!preferences || !info) { + return ( + + + + + + ) + } + + return ( + + ) +} + +export function ProfileModserviceScreenInner({ + preferences, + modInfo, +}: { + preferences: UsePreferencesQueryResponse + modInfo: AppBskyModerationDefs.ModServiceViewDetailed +}) { + const {_} = useLingui() + const pal = usePalette('default') + const {hasSession} = useSession() + const {track} = useAnalytics() + const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeMod, isPending: isUnlikePending} = + useUnlikeMutation() + const [likeUri, setLikeUri] = React.useState( + modInfo.viewer?.like || '', + ) + + const isLiked = !!likeUri + const isSaved = false // TODO + // !removedFeed && + // (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri)) + const isEnabled = false // TODO + // !unpinnedFeed && + // (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri)) + + const descriptionRT = useMemo( + () => + modInfo.description + ? new RichTextAPI({ + text: modInfo.description, + facets: modInfo.descriptionFacets, + }) + : undefined, + [modInfo], + ) + + useSetTitle(modInfo.creator.displayName || modInfo.creator.handle) + + // event handlers + // + const onToggleLiked = React.useCallback(async () => { + try { + Haptics.default() + + if (isLiked && likeUri) { + await unlikeMod({uri: likeUri}) + track('CustomFeed:Unlike') + setLikeUri('') + } else { + const res = await likeMod({uri: modInfo.uri, cid: modInfo.cid}) + track('CustomFeed:Like') + setLikeUri(res.uri) + } + } catch (err) { + Toast.show( + _( + msg`There was an an issue contacting the server, please check your internet connection and try again.`, + ), + ) + logger.error('Failed up toggle like', {error: err}) + } + }, [likeUri, isLiked, modInfo, likeMod, unlikeMod, track, _]) + + // render + // = + + return ( + + + + + {descriptionRT ? ( + + ) : ( + + No description + + )} + + + Operated by{' '} + + . Handles reports of anti-social behavior, illegal content, + unwanted sexual content, and misinformation. + + + + + + {typeof modInfo.likeCount === 'number' && ( + + )} + + + + + + + + ) +} + +const styles = StyleSheet.create({ + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, + }, + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, +})