diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 6a1e1da0a0..cf9dfee1eb 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -48,25 +48,30 @@ export type VariantProps = { shape?: ButtonShape } -export type ButtonProps = React.PropsWithChildren< - Pick & - AccessibilityProps & - VariantProps & { - testID?: string - label: string - style?: StyleProp - } -> +export type ButtonState = { + hovered: boolean + focused: boolean + pressed: boolean + disabled: boolean +} + +export type ButtonProps = Pick< + PressableProps, + 'disabled' | 'onPress' | 'testID' +> & + AccessibilityProps & + VariantProps & { + testID?: string + label: string + style?: StyleProp + children: + | React.ReactNode + | string + | ((state: VariantProps & ButtonState) => React.ReactNode | string) + } export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} -const Context = React.createContext< - VariantProps & { - hovered: boolean - focused: boolean - pressed: boolean - disabled: boolean - } ->({ +const Context = React.createContext({ hovered: false, focused: false, pressed: false, @@ -402,6 +407,8 @@ export function Button({ {typeof children === 'string' ? ( {children} + ) : typeof children === 'function' ? ( + children(context) ) : ( children )} @@ -524,9 +531,11 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) { export function ButtonIcon({ icon: Comp, position, + size: iconSize, }: { icon: React.ComponentType position?: 'left' | 'right' + size?: SVGIconProps['size'] }) { const {size, disabled} = useButtonContext() const textStyles = useSharedButtonTextStyles() @@ -542,7 +551,9 @@ export function ButtonIcon({ }, ]}> diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 30fb4b61ec..94e0891819 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -10,6 +10,8 @@ import {Portal} from '#/components/Portal' import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types' import {Context} from '#/components/Dialog/context' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' export {useDialogControl, useDialogContext} from '#/components/Dialog/context' export * from '#/components/Dialog/types' @@ -171,25 +173,28 @@ export function Handle() { return null } -/** - * TODO(eric) unused rn - */ -// export function Close() { -// const {_} = useLingui() -// const t = useTheme() -// const {close} = useDialogContext() -// return ( -// -// -// -// ) -// } +export function Close() { + const {_} = useLingui() + const {close} = React.useContext(Context) + return ( + + + + ) +} diff --git a/src/components/ModerationServiceCard.tsx b/src/components/ModerationServiceCard.tsx deleted file mode 100644 index 07865a6846..0000000000 --- a/src/components/ModerationServiceCard.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import LinearGradient from 'react-native-linear-gradient' -import {AppBskyModerationDefs} from '@atproto/api' - -import {atoms as a, useTheme, tokens, web} from '#/alf' -import {Link, useLinkContext} from '#/components/Link' -import {Text} from '#/components/Typography' -import {RichText} from '#/components/RichText' -import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand' -import {useModServiceInfoQuery} from '#/state/queries/modservice' - -type ModerationServiceCardProps = { - modservice: AppBskyModerationDefs.ModServiceViewDetailed -} - -export function ModerationServiceCard({ - modservice, -}: ModerationServiceCardProps) { - const {_} = useLingui() - - return ( - - - - ) -} - -function Inner({modservice}: ModerationServiceCardProps) { - const t = useTheme() - const {hovered, pressed, focused} = useLinkContext() - - return ( - - c[1])} - locations={tokens.gradients.midnight.values.map(c => c[0])} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[a.absolute, a.inset_0]} - /> - - - - {/* TODO */} - {modservice.displayName || 'Mod service'} - - - {modservice.description ? ( - - ) : ( - - - Moderation service managed by @{modservice.creator.handle} - - - )} - - - - - ) -} - -export function ModerationServiceCardSkeleton() { - return ( - - Loading - - ) -} - -export function Loader({ - did, - loading: LoadingComponent = ModerationServiceCardSkeleton, - error: ErrorComponent, - component: Component, -}: { - did: string - loading?: React.ComponentType<{}> - error?: React.ComponentType<{error: string}> - component: React.ComponentType<{ - modservice: AppBskyModerationDefs.ModServiceViewDetailed - }> -}) { - const {isLoading, data, error} = useModServiceInfoQuery({did}) - - return isLoading ? ( - LoadingComponent ? ( - - ) : null - ) : error || !data ? ( - ErrorComponent ? ( - - ) : null - ) : ( - - ) -} diff --git a/src/components/ModerationServiceCard/Card.tsx b/src/components/ModerationServiceCard/Card.tsx new file mode 100644 index 0000000000..2dfff4c280 --- /dev/null +++ b/src/components/ModerationServiceCard/Card.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {atoms as a, useTheme, ViewStyleProp, flatten} from '#/alf' +import {Text} from '#/components/Typography' +import {RichText} from '#/components/RichText' +import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand' +import {UserAvatar} from '#/view/com/util/UserAvatar' + +export function Outer({ + children, + style, +}: React.PropsWithChildren) { + const t = useTheme() + + return ( + + + {children} + + + ) +} + +export function Avatar({avatar}: {avatar?: string}) { + return +} + +export function Content({ + title, + description, + handle, +}: { + title: string + description?: string + handle: string +}) { + const t = useTheme() + + return ( + + + {title} + + {description ? ( + + ) : ( + + Moderation service managed by @{handle} + + )} + + + + + ) +} diff --git a/src/components/ModerationServiceCard/index.tsx b/src/components/ModerationServiceCard/index.tsx new file mode 100644 index 0000000000..3806064980 --- /dev/null +++ b/src/components/ModerationServiceCard/index.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyModerationDefs} from '@atproto/api' + +import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Text} from '#/components/Typography' +import {useModServiceInfoQuery} from '#/state/queries/modservice' + +export * as Card from '#/components/ModerationServiceCard/Card' + +type ModerationServiceProps = { + modservice: AppBskyModerationDefs.ModServiceViewDetailed +} + +export function Link({ + children, + modservice, +}: ModerationServiceProps & Pick) { + const {_} = useLingui() + + return ( + + {children} + + ) +} + +export function ModerationServiceCardSkeleton() { + return ( + + Loading + + ) +} + +export function Loader({ + did, + loading: LoadingComponent = ModerationServiceCardSkeleton, + error: ErrorComponent, + component: Component, +}: { + did: string + loading?: React.ComponentType<{}> + error?: React.ComponentType<{error: string}> + component: React.ComponentType<{ + modservice: AppBskyModerationDefs.ModServiceViewDetailed + }> +}) { + const {isLoading, data, error} = useModServiceInfoQuery({did}) + + return isLoading ? ( + LoadingComponent ? ( + + ) : null + ) : error || !data ? ( + ErrorComponent ? ( + + ) : null + ) : ( + + ) +} diff --git a/src/components/dialogs/ReportDialog/index.tsx b/src/components/dialogs/ReportDialog/index.tsx index ab7eb8eaff..4868d552d6 100644 --- a/src/components/dialogs/ReportDialog/index.tsx +++ b/src/components/dialogs/ReportDialog/index.tsx @@ -2,7 +2,7 @@ import React from 'react' import {View, Dimensions} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {LABEL_GROUPS, LabelGroupDefinition} from '@atproto/api' +import {AppBskyModerationDefs, LabelGroupDefinition} from '@atproto/api' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {atoms as a, useTheme, tokens, native} from '#/alf' @@ -25,10 +25,16 @@ import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import * as Toggle from '#/components/forms/Toggle' import {GradientFill} from '#/components/GradientFill' -import * as TextField from '#/components/forms/TextField' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {Loader} from '#/components/Loader' import * as Toast from '#/view/com/util/Toast' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useModServicesDetailedInfoQuery} from '#/state/queries/modservice' +import { + getLabelGroupsFromLabels, + getModerationServiceTitle, + useConfigurableLabelGroups, +} from '#/lib/moderation' export type ReportDialogProps = | { @@ -41,7 +47,11 @@ export type ReportDialogProps = did: string } -function LabelGroupButton({labelGroup}: {labelGroup: string}) { +function LabelGroupButton({ + labelGroup, +}: { + labelGroup: LabelGroupDefinition['id'] +}) { const t = useTheme() const {hovered, focused, pressed} = useButtonContext() const labelGroupStrings = useLabelGroupStrings() @@ -143,22 +153,89 @@ function ModServiceToggle({title}: {title: string}) { ) } +function SubmitViewLoader({ + children, +}: { + children: (props: { + labelers: AppBskyModerationDefs.ModServiceViewDetailed[] + labelGroupToModServiceMap: Record + }) => React.ReactNode +}) { + const { + isLoading: isPreferencesLoading, + error: preferencesError, + data: preferences, + } = usePreferencesQuery() + const { + isLoading: isModServicesLoading, + data: modservices, + error: modservicesError, + } = useModServicesDetailedInfoQuery({ + dids: preferences ? preferences.moderationOpts.mods.map(m => m.did) : [], + }) + const labelGroupToModServiceMap = React.useMemo(() => { + if (!modservices) return {} + + const groups: Partial< + Record< + LabelGroupDefinition['id'], + AppBskyModerationDefs.ModServiceViewDetailed[] + > + > = {} + + for (const modservice of modservices) { + const labelGroups = getLabelGroupsFromLabels( + modservice.policies.labelValues, + ) + for (const group of labelGroups) { + const g = (groups[group.id] = groups[group.id] || []) + g.push(modservice) + } + } + + return groups + }, [modservices]) + + const isLoading = isPreferencesLoading || isModServicesLoading + const error = preferencesError || modservicesError + + return isLoading ? ( + + + + ) : error || !(preferences && modservices) ? null : ( // TODO + children({ + labelers: modservices, + // TODO mismatched types + // @ts-ignore + labelGroupToModServiceMap, + }) + ) +} + function SubmitView({ selectedLabelGroup, goBack, onSubmitComplete, + labelGroupToModServiceMap, }: { - selectedLabelGroup: string + selectedLabelGroup: LabelGroupDefinition['id'] goBack: () => void onSubmitComplete: () => void + labelers: AppBskyModerationDefs.ModServiceViewDetailed[] + labelGroupToModServiceMap: Record< + LabelGroupDefinition['id'], + AppBskyModerationDefs.ModServiceViewDetailed[] + > }) { const t = useTheme() const {_} = useLingui() const labelGroupStrings = useLabelGroupStrings() const groupInfoStrings = labelGroupStrings[selectedLabelGroup] - const [selectedServices, setSelectedServices] = React.useState([]) const [details, setDetails] = React.useState('') const [submitting, setSubmitting] = React.useState(false) + const [selectedServices, setSelectedServices] = React.useState([]) + const supportedLabelers = labelGroupToModServiceMap[selectedLabelGroup] const submit = React.useCallback(async () => { setSubmitting(true) @@ -208,22 +285,35 @@ function SubmitView({ Select the moderation service(s) to report to - - - - - - - - - - - + {supportedLabelers ? ( + + + {supportedLabelers.map(labeler => { + const title = getModerationServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + }) + return ( + + + + ) + })} + + + ) : ( + + + None of your subscribed labelers support this content type. + - + )} @@ -231,13 +321,14 @@ function SubmitView({ - @@ -284,7 +376,9 @@ export function ReportDialog({ const {_} = useLingui() const insets = useSafeAreaInsets() const control = Dialog.useDialogControl() - const [selectedLabelGroup, setSelectedLabelGroup] = React.useState('') + const [selectedLabelGroup, setSelectedLabelGroup] = React.useState< + LabelGroupDefinition['id'] | undefined + >() const labelGroupStrings = useLabelGroupStrings() // REQUIRED CLEANUP @@ -305,11 +399,7 @@ export function ReportDialog({ } }, [_, params.type]) - const groups = React.useMemo< - [keyof typeof LABEL_GROUPS, LabelGroupDefinition][] - >(() => { - return Object.entries(LABEL_GROUPS).filter(([, def]) => def.configurable) - }, []) + const groups = useConfigurableLabelGroups() return ( {selectedLabelGroup ? ( - setSelectedLabelGroup('')} - onSubmitComplete={control.close} - /> + + {props => ( + // TODO same types mismatch + // @ts-ignore + setSelectedLabelGroup(undefined)} + onSubmitComplete={control.close} + /> + )} + ) : ( @@ -342,14 +439,14 @@ export function ReportDialog({ - {groups.map(([name, def]) => { - const groupStrings = labelGroupStrings[name] + {groups.map(def => { + const groupStrings = labelGroupStrings[def.id] return ( ) })} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 3b6a8e879c..93f40ba5a4 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -149,6 +149,7 @@ export type InputProps = Omit & { value: string onChangeText: (value: string) => void isInvalid?: boolean + disabled?: boolean } export function createInput(Component: typeof TextInput) { diff --git a/src/components/icons/Times.tsx b/src/components/icons/Times.tsx new file mode 100644 index 0000000000..678ac3fcb3 --- /dev/null +++ b/src/components/icons/Times.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const TimesLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 7f82a6c9f5..62fb16d52b 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -1,4 +1,7 @@ +import React from 'react' import {ModerationCause, LABEL_GROUPS, LabelGroupDefinition} from '@atproto/api' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' export function getModerationCauseKey(cause: ModerationCause): string { const source = @@ -27,3 +30,23 @@ export function getLabelGroupsFromLabels(labels: string[]) { return Array.from(groups) } + +export function getConfigurableLabelGroups() { + return Object.values(LABEL_GROUPS).filter(group => group.configurable) +} + +export function useConfigurableLabelGroups() { + return React.useMemo(() => getConfigurableLabelGroups(), []) +} + +export function getModerationServiceTitle({ + displayName, + handle, +}: { + displayName?: string + handle: string +}) { + return displayName + ? sanitizeDisplayName(displayName) + : sanitizeHandle(handle, '@') +} diff --git a/src/screens/Moderation/SettingsDialog.tsx b/src/screens/Moderation/SettingsDialog.tsx new file mode 100644 index 0000000000..7c7b905585 --- /dev/null +++ b/src/screens/Moderation/SettingsDialog.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyModerationDefs} from '@atproto/api' +import {LabelGroupDefinition} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import * as Toggle from '#/components/forms/Toggle' +import {useLabelGroupStrings} from '#/lib/moderation/useLabelGroupStrings' +import * as Dialog from '#/components/Dialog' +import * as ModerationServiceCard from '#/components/ModerationServiceCard' +import {getModerationServiceTitle} from '#/lib/moderation' +import {UsePreferencesQueryResponse} from '#/state/queries/preferences' +import {useModServiceLabelGroupEnableMutation} from '#/state/queries/modservice' + +function LabelerToggle({ + labelGroup, + labeler, + preferences, +}: { + labelGroup: LabelGroupDefinition['id'] + labeler: AppBskyModerationDefs.ModServiceViewDetailed + preferences: UsePreferencesQueryResponse +}) { + const t = useTheme() + const {mutateAsync, variables, reset} = + useModServiceLabelGroupEnableMutation() + + const modservicePreferences = preferences.moderationOpts.mods.find( + ({did}) => did === labeler.creator.did, + ) + const enabled = + variables?.enabled ?? + !modservicePreferences?.disabledLabelGroups?.includes(labelGroup) + const title = getModerationServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + }) + + const onToggleEnabled = React.useCallback(async () => { + try { + await mutateAsync({ + // @ts-ignore TODO + did: modservicePreferences?.did, + group: labelGroup, + enabled: !enabled, + }) + reset() // Important: clears query `variables` + } catch (e: any) { + // TODO + console.error(e) + } + }, [mutateAsync, enabled, modservicePreferences, labelGroup, reset]) + return ( + + {ctx => ( + + + + + )} + + ) +} + +export type SettingsDialogProps = { + labelGroup: LabelGroupDefinition['id'] + modservices: AppBskyModerationDefs.ModServiceViewDetailed[] +} + +export function SettingsDialog({ + labelGroup, + modservices, + preferences, +}: SettingsDialogProps & { + preferences: UsePreferencesQueryResponse +}) { + const t = useTheme() + const labelGroupStrings = useLabelGroupStrings() + const groupInfoStrings = labelGroupStrings[labelGroup] + + // this is mounted on native + if (!groupInfoStrings) return null + + return ( + + + + + Configure enabled labelers + + + + + {groupInfoStrings.name} + + {groupInfoStrings.description} + + + + + + Select which labelers to use for this type of content: + + + + {modservices.map(modservice => { + return ( + + ) + })} + + + ) +} diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index 41813f6119..32b7cfb174 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -1,14 +1,10 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {ComAtprotoLabelDefs} from '@atproto/api' +import {ComAtprotoLabelDefs, LabelPreference} from '@atproto/api' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import { - LABEL_GROUPS, - LabelGroupDefinition, - DEFAULT_LABEL_GROUP_SETTINGS, -} from '@atproto/api' +import {LabelGroupDefinition, AppBskyModerationDefs} from '@atproto/api' import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' import {CenteredView} from '#/view/com/util/Views' @@ -22,12 +18,18 @@ import { } from '#/state/queries/profile' import {ScrollView} from '#/view/com/util/Views' +import { + UsePreferencesQueryResponse, + usePreferencesQuery, + useSetContentLabelMutation, +} from '#/state/queries/preferences' +import {useModServicesDetailedInfoQuery} from '#/state/queries/modservice' + import {useTheme, atoms as a, useBreakpoints} 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 {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Text} from '#/components/Typography' import * as Toggle from '#/components/forms/Toggle' import * as ToggleButton from '#/components/forms/ToggleButton' @@ -36,98 +38,71 @@ import {Loader} from '#/components/Loader' import {useLabelGroupStrings} from '#/lib/moderation/useLabelGroupStrings' import * as Dialog from '#/components/Dialog' import {Button} from '#/components/Button' +import { + getLabelGroupsFromLabels, + getModerationServiceTitle, + useConfigurableLabelGroups, +} from '#/lib/moderation' -function ModSettingsToggleItem({name}: {name: string}) { +import { + SettingsDialog, + SettingsDialogProps, +} from '#/screens/Moderation/SettingsDialog' + +export function ModerationScreen( + _props: NativeStackScreenProps, +) { const t = useTheme() - const ctx = Toggle.useItemContext() - return ( - - {name} - {ctx.selected && } + const { + isLoading: isPreferencesLoading, + // error: preferencesError, + data: preferences, + } = usePreferencesQuery() + + return isPreferencesLoading ? ( + + - ) + ) : preferences ? ( + + ) : // TODO + null } -function ModSettingsDialog({ - name, - onComplete, +function ModerationScreenIntermediate({ + preferences, }: { - name: string - onComplete: () => void + preferences: UsePreferencesQueryResponse }) { const t = useTheme() - const labelGroupStrings = useLabelGroupStrings() - const [selectedServices, setSelectedServices] = React.useState([ - 'bluesky', - ]) + const { + isLoading: isModServicesLoading, + data: modservices, + // error: modservicesError, + } = useModServicesDetailedInfoQuery({ + dids: preferences.moderationOpts.mods.map(m => m.did), + }) - const save = React.useCallback(() => { - onComplete() - }, [onComplete]) - - return ( - - - Configure moderation services - - - Select which moderation services' labels you'd like to use to filter - content matching {labelGroupStrings[name].name}. - - - - - - - - - - - - - - - - - - - - - ) + return isModServicesLoading ? ( + + + + ) : modservices ? ( + + ) : // TODO + null } -export function ModerationScreen({}: NativeStackScreenProps< - CommonNavigatorParams, - 'Moderation' ->) { +export function ModerationScreenInner({ + preferences, + modservices, +}: { + preferences: UsePreferencesQueryResponse + modservices: AppBskyModerationDefs.ModServiceViewDetailed[] +}) { const t = useTheme() const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() @@ -135,8 +110,13 @@ export function ModerationScreen({}: NativeStackScreenProps< const {gtMobile, gtTablet} = useBreakpoints() const labelGroupStrings = useLabelGroupStrings() const modSettingsDialogControl = Dialog.useDialogControl() - const [modSettingsDialogLabelGroup, setModSettingsDialogLabelGroup] = - React.useState(() => Object.keys(LABEL_GROUPS)[0]) + + const [settingsDialogProps, setSettingsDialogProps] = + React.useState({ + // @ts-ignore + labelGroup: '', + modservices: [], + }) useFocusEffect( React.useCallback(() => { @@ -145,20 +125,40 @@ export function ModerationScreen({}: NativeStackScreenProps< }, [screen, setMinimalShellMode]), ) - const groups = React.useMemo< - [keyof typeof LABEL_GROUPS, LabelGroupDefinition][] + const groups = useConfigurableLabelGroups() + + const didToModServiceMap = React.useMemo< + Record >(() => { - return Object.entries(LABEL_GROUPS).filter(([, def]) => def.configurable) - }, []) - const labelOptions = { - hide: _(msg`Hide`), - warn: _(msg`Warn`), - show: _(msg`Show`), - } + return modservices.reduce((acc, modservice) => { + return { + ...acc, + [modservice.creator.did]: modservice, + } + }, {}) + }, [modservices]) + const labelGroupToModServiceMap = React.useMemo(() => { + const groups: Partial> = {} + + for (const modservice of modservices) { + const labelGroups = getLabelGroupsFromLabels( + modservice.policies.labelValues, + ) + for (const group of labelGroups) { + const g = (groups[group.id] = groups[group.id] || []) + g.push(modservice.creator.did) + } + } + + return groups + }, [modservices]) const openModSettingsDialog = React.useCallback( - ({name}: {name: string}) => { - setModSettingsDialogLabelGroup(name) + ({labelGroup, modservices}: Omit) => { + setSettingsDialogProps({ + labelGroup, + modservices, + }) modSettingsDialogControl.open() }, [modSettingsDialogControl], @@ -175,11 +175,7 @@ export function ModerationScreen({}: NativeStackScreenProps< testID="moderationScreen"> - - modSettingsDialogControl.close()} - /> + @@ -255,114 +251,21 @@ export function ModerationScreen({}: NativeStackScreenProps< Content filtering settings - {groups.map(([name, def], i) => { - const groupStrings = labelGroupStrings[name] + {groups.map((def, i) => { + const groupStrings = labelGroupStrings[def.id] + const modDids = labelGroupToModServiceMap[def.id] || [] + const mods = modDids.map(did => didToModServiceMap[did]) return ( {i !== 0 && } - - - - - - {groupStrings.name} - - - {groupStrings.description} - - - - - {}}> - - {labelOptions.hide} - - - {labelOptions.warn} - - - {labelOptions.show} - - - - - - - - - + ) })} @@ -381,6 +284,168 @@ export function ModerationScreen({}: NativeStackScreenProps< ) } +function LabelGroup({ + labelGroup, + name, + description, + labelers: mods, + preferences, + openModSettingsDialog, +}: { + labelGroup: LabelGroupDefinition['id'] + name: string + description: string + labelers: AppBskyModerationDefs.ModServiceViewDetailed[] + preferences: UsePreferencesQueryResponse + openModSettingsDialog: (props: SettingsDialogProps) => void +}) { + const t = useTheme() + const {_} = useLingui() + const {mutateAsync: setContentLabelPref, variables: optimisticContentLabel} = + useSetContentLabelMutation() + + const onChangeVisibility = React.useCallback( + async (values: string[]) => { + try { + await setContentLabelPref({ + labelGroup, + visibility: values[0] as LabelPreference, + }) + } catch (e) { + console.error(e) + } + }, + [labelGroup, setContentLabelPref], + ) + + const value = + optimisticContentLabel?.visibility ?? + preferences.moderationOpts.labelGroups[labelGroup] + + const labelOptions = { + hide: _(msg`Hide`), + warn: _(msg`Warn`), + show: _(msg`Show`), + } + + return ( + + + + + {name} + + {description} + + + + + + {labelOptions.hide} + + + {labelOptions.warn} + + + {labelOptions.show} + + + + + + {!!mods.length && ( + + + + )} + + ) +} + function PwiOptOut() { const t = useTheme() const {_} = useLingui() diff --git a/src/state/queries/modservice.ts b/src/state/queries/modservice.ts index ea5cfc17a9..d79110a421 100644 --- a/src/state/queries/modservice.ts +++ b/src/state/queries/modservice.ts @@ -1,10 +1,19 @@ import {z} from 'zod' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' +import {AppBskyModerationDefs} from '@atproto/api' import {getAgent} from '#/state/session' import {preferencesQueryKey} from '#/state/queries/preferences' export const modServiceInfoQueryKey = (did: string) => ['mod-service-info', did] +export const modServicesInfoQueryKey = (dids: string[]) => [ + 'mod-services-info', + dids, +] +export const modServicesDetailedInfoQueryKey = (dids: string[]) => [ + 'mod-services-detailed-info', + dids, +] export function useModServiceInfoQuery({did}: {did: string}) { return useQuery({ @@ -16,6 +25,42 @@ export function useModServiceInfoQuery({did}: {did: string}) { }) } +export function useModServicesInfoQuery({dids}: {dids: string[]}) { + return useQuery({ + enabled: !!dids.length, + queryKey: modServicesInfoQueryKey(dids), + queryFn: async () => { + const res = await getAgent().app.bsky.moderation.getServices({dids}) + return res.data.views + }, + }) +} + +export function useModServicesDetailedInfoQuery({dids}: {dids: string[]}) { + return useQuery({ + queryKey: modServicesDetailedInfoQueryKey(dids), + queryFn: async () => { + const views: AppBskyModerationDefs.ModServiceViewDetailed[] = [] + + await Promise.all( + dids.map(did => { + return getAgent() + .app.bsky.moderation.getService({did}) + .then(res => { + views.push(res.data) + }) + .catch(e => { + console.error(e) + return null + }) + }), + ) + + return views + }, + }) +} + export function useModServiceSubscriptionMutation() { const queryClient = useQueryClient() @@ -52,9 +97,7 @@ export function useModServiceEnableMutation() { enabled: z.boolean(), }).parse({did, enabled}) await getAgent().setModServiceEnabled(did, enabled) - }, - onSuccess() { - queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: preferencesQueryKey, }) }, @@ -65,7 +108,15 @@ export function useModServiceLabelGroupEnableMutation() { const queryClient = useQueryClient() return useMutation({ - async mutationFn({did, group, enabled}: {did: string; group: string, enabled: boolean}) { + async mutationFn({ + did, + group, + enabled, + }: { + did: string + group: string + enabled: boolean + }) { // TODO z.object({ did: z.string(), diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 78506fe733..101713f80e 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -4,6 +4,7 @@ import { LabelPreference, BskyFeedViewPreference, ModerationOpts, + LabelGroupDefinition, } from '@atproto/api' import {track} from '#/lib/analytics/analytics' @@ -124,6 +125,26 @@ export function usePreferencesSetContentLabelMutation() { }) } +export function useSetContentLabelMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + labelGroup, + visibility, + }: { + labelGroup: LabelGroupDefinition['id'] + visibility: LabelPreference + }) => { + await getAgent().setContentLabelPref(labelGroup, visibility) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + export function usePreferencesSetAdultContentMutation() { const queryClient = useQueryClient() diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 41a3503a8d..4579df7e31 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -56,10 +56,10 @@ 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 { - Loader as ModServiceLoader, - ModerationServiceCard, -} from '#/components/ModerationServiceCard' +import * as ModerationServiceCard from '#/components/ModerationServiceCard' +import {getModerationServiceTitle} from '#/lib/moderation' + +import {useTheme} from '#/alf' let ProfileHeaderLoading = (_props: {}): React.ReactNode => { const pal = usePalette('default') @@ -96,6 +96,7 @@ let ProfileHeader = ({ hideBackButton = false, isPlaceholderProfile, }: Props): React.ReactNode => { + const t = useTheme() const profile: Shadow = useProfileShadow(profileUnshadowed) const pal = usePalette('default') @@ -629,7 +630,30 @@ let ProfileHeader = ({ )} - + ( + + {ctx => ( + + + + )} + + )} + /> {showSuggestedFollows && ( diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index 2d5495d706..0bfad1aab2 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -62,6 +62,7 @@ export function Forms() { value={value} onChangeText={setValue} label="Text field" + disabled />