Implement preference controls on labelers

This commit is contained in:
Paul Frazee
2024-03-04 18:14:02 -08:00
parent b26e280373
commit 19c0ae80eb
8 changed files with 440 additions and 97 deletions
@@ -0,0 +1,133 @@
import React from 'react'
import {Pressable} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {InterprettedLabelValueDefinition, LabelPreference} from '@atproto/api'
import {
useLabelBehaviorDescription,
useLabelLongBehaviorDescription,
} from '#/lib/moderation/useLabelBehaviorDescription'
import {useTheme, atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
import {ArrowTriangleBottom_Stroke2_Corner1_Rounded as ArrowTriangleBottom} from '../icons/ArrowTriangle'
import {Check_Stroke2_Corner0_Rounded as Check} from '../icons/Check'
export function PreferenceButton({
name,
pref,
labelValueDefinition,
onSelectPref,
}: {
name: string
pref: LabelPreference
labelValueDefinition: InterprettedLabelValueDefinition
onSelectPref: (pref: LabelPreference) => void
}) {
const {_} = useLingui()
const t = useTheme()
const control = Dialog.useDialogControl()
const settingDesc = useLabelBehaviorDescription(labelValueDefinition, pref)
const hideLabel = useLabelLongBehaviorDescription(
labelValueDefinition,
'hide',
)
const warnLabel = useLabelLongBehaviorDescription(
labelValueDefinition,
'warn',
)
const ignoreLabel = useLabelLongBehaviorDescription(
labelValueDefinition,
'ignore',
)
const canWarn = !(
labelValueDefinition.blurs === 'none' &&
labelValueDefinition.severity === 'none'
)
return (
<>
<Pressable
onPress={() => control.open()}
accessibilityLabel={settingDesc}
accessibilityHint=""
style={[
a.flex_row,
a.align_center,
a.justify_end,
a.gap_xs,
a.py_xs,
a.rounded_2xs,
]}>
<Text style={[{color: t.palette.primary_500}, a.font_semibold]}>
{settingDesc}
</Text>
<ArrowTriangleBottom width={8} fill={t.palette.primary_500} />
</Pressable>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner
label={_(msg`Settings: ${labelValueDefinition.identifier}`)}
style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, t.atoms.text]}>
{name}
</Text>
<Text style={[a.text_md, a.pb_sm, t.atoms.text_contrast_medium]}>
<Trans>Choose how this label should be handled.</Trans>
</Text>
<Button
label={hideLabel}
size="large"
variant="solid"
color={pref === 'hide' ? 'primary' : 'secondary'}
onPress={() => {
onSelectPref('hide')
control.close()
}}>
<ButtonText style={[a.flex_1, a.text_left]}>{hideLabel}</ButtonText>
{pref === 'hide' && <ButtonIcon icon={Check} position="right" />}
</Button>
{canWarn && (
<Button
label={warnLabel}
size="large"
variant="solid"
color={pref === 'warn' ? 'primary' : 'secondary'}
onPress={() => {
onSelectPref('warn')
control.close()
}}>
<ButtonText style={[a.flex_1, a.text_left]}>
{warnLabel}
</ButtonText>
{pref === 'warn' && <ButtonIcon icon={Check} position="right" />}
</Button>
)}
<Button
label={ignoreLabel}
size="large"
variant="solid"
color={pref === 'ignore' ? 'primary' : 'secondary'}
onPress={() => {
onSelectPref('ignore')
control.close()
}}>
<ButtonText style={[a.flex_1, a.text_left]}>
{ignoreLabel}
</ButtonText>
{pref === 'ignore' && <ButtonIcon icon={Check} position="right" />}
</Button>
</Dialog.Inner>
</Dialog.Outer>
</>
)
}
@@ -0,0 +1,102 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {InterprettedLabelValueDefinition, LabelPreference} from '@atproto/api'
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
import {
NativeDropdown,
DropdownItem,
} from '#/view/com/util/forms/NativeDropdown'
import {useTheme, atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {ArrowTriangleBottom_Stroke2_Corner1_Rounded as ArrowTriangleBottom} from '../icons/ArrowTriangle'
import {useInteractionState} from '#/components/hooks/useInteractionState'
const CHECK_ICON: DropdownItem['icon'] = {
web: ['fas', 'check'],
ios: {name: 'trash'}, //doesnt matter
android: '',
}
export function PreferenceButton({
pref,
labelValueDefinition,
onSelectPref,
}: {
pref: LabelPreference
labelValueDefinition: InterprettedLabelValueDefinition
onSelectPref: (pref: LabelPreference) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const settingDesc = useLabelBehaviorDescription(labelValueDefinition, pref)
const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide')
const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn')
const ignoreLabel = useLabelBehaviorDescription(
labelValueDefinition,
'ignore',
)
const canWarn = !(
labelValueDefinition.blurs === 'none' &&
labelValueDefinition.severity === 'none'
)
const dropdownItems: DropdownItem[] = []
dropdownItems.push({
icon: pref === 'hide' ? CHECK_ICON : undefined,
label: hideLabel,
onPress: () => onSelectPref('hide'),
})
if (canWarn) {
dropdownItems.push({
icon: pref === 'warn' ? CHECK_ICON : undefined,
label: warnLabel,
onPress: () => onSelectPref('warn'),
})
}
dropdownItems.push({
icon: pref === 'ignore' ? CHECK_ICON : undefined,
label: ignoreLabel,
onPress: () => onSelectPref('ignore'),
})
return (
<NativeDropdown
items={dropdownItems}
accessibilityLabel={_(msg`More post options`)}
accessibilityHint="">
<View
style={[
a.flex_row,
a.align_center,
a.justify_end,
a.gap_xs,
a.py_xs,
a.rounded_2xs,
hovered && {
// @ts-ignore
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
]}
// @ts-ignore
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}>
<Text style={[{color: t.palette.primary_500}, a.font_semibold]}>
{settingDesc}
</Text>
<ArrowTriangleBottom width={8} fill={t.palette.primary_500} />
</View>
</NativeDropdown>
)
}
+31 -21
View File
@@ -1,24 +1,32 @@
import React from 'react'
import {View} from 'react-native'
import {InterprettedLabelValueDefinition} from '@atproto/api'
import {InterprettedLabelValueDefinition, LabelPreference} from '@atproto/api'
import {useLabelStrings} from '#/lib/moderation/useLabelStrings'
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
import {
usePreferencesQuery,
usePreferencesSetContentLabelMutation,
} from '#/state/queries/preferences'
import {useTheme, atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
import {ArrowTriangleBottom_Stroke2_Corner1_Rounded as ArrowTriangleBottom} from '../icons/ArrowTriangle'
import {PreferenceButton} from './PreferenceButton'
export function ModerationLabelPref({
labelValueDefinition,
labelerDid,
disabled,
}: {
labelValueDefinition: InterprettedLabelValueDefinition
labelerDid: string | undefined
disabled?: boolean
}) {
const t = useTheme()
const allLabelStrings = useLabelStrings()
const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetContentLabelMutation()
const {identifier} = labelValueDefinition
const labelStrings = labelValueDefinition.locales[0] // TODO look up locale
? labelValueDefinition.locales[0]
: labelValueDefinition.identifier in allLabelStrings
@@ -27,9 +35,16 @@ export function ModerationLabelPref({
name: labelValueDefinition.identifier,
description: `Labeled "${labelValueDefinition.identifier}"`,
}
const settingDesc = useLabelBehaviorDescription(labelValueDefinition, 'hide')
// TODO add onChange behavior when mod prefs are updated
const savedPref = labelerDid
? preferences?.moderationPrefs.mods.find(m => m.did === labelerDid)?.labels[
identifier
]
: preferences?.moderationPrefs.labels[identifier]
const pref = variables?.visibility ?? savedPref ?? 'warn'
const onSelectPref = (newPref: LabelPreference) =>
mutate({label: identifier, visibility: newPref, labelerDid})
return (
<View style={[a.flex_row, a.justify_between, a.gap_lg, a.align_center]}>
@@ -39,21 +54,16 @@ export function ModerationLabelPref({
{labelStrings.description}
</Text>
</View>
{!disabled && (
<View
style={[
a.flex_row,
a.align_center,
a.justify_end,
a.gap_xs,
{width: 125},
]}>
<Text style={[{color: t.palette.primary_500}, a.font_semibold]}>
{settingDesc}
</Text>
<ArrowTriangleBottom width={8} fill={t.palette.primary_500} />
</View>
)}
<View style={[{width: 110}]}>
{!disabled && (
<PreferenceButton
name={labelStrings.name}
pref={pref}
labelValueDefinition={labelValueDefinition}
onSelectPref={onSelectPref}
/>
)}
</View>
</View>
)
}
@@ -12,22 +12,58 @@ export function useLabelBehaviorDescription(
}
if (labelValueDef.blurs === 'content') {
if (pref === 'hide') {
return _(msg`Hide content`)
return _(msg`Hide`)
}
return _(msg`Warn content`)
return _(msg`Warn`)
} else if (labelValueDef.blurs === 'media') {
if (pref === 'hide') {
return _(msg`Hide images`)
return _(msg`Hide`)
}
return _(msg`Warn images`)
return _(msg`Blur images`)
} else if (labelValueDef.severity === 'alert') {
if (pref === 'hide') {
return _(msg`Filter from feeds`)
return _(msg`Hide`)
}
return _(msg`Show warning`)
} else if (labelValueDef.severity === 'inform') {
if (pref === 'hide') {
return _(msg`Filter from feeds`)
return _(msg`Hide`)
}
return _(msg`Show badge`)
} else {
if (pref === 'hide') {
return _(msg`Hide`)
}
return _(msg`Disabled`)
}
}
export function useLabelLongBehaviorDescription(
labelValueDef: InterprettedLabelValueDefinition,
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 {
@@ -137,7 +137,7 @@ let ProfileHeaderLabeler = ({
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]}
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
pointerEvents="box-none">
{isMe ? (
<Button
+123 -60
View File
@@ -10,9 +10,13 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
import {logger} from '#/logger'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {runOnJS} from 'react-native-reanimated'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {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'
@@ -23,20 +27,44 @@ import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {ErrorState} from '../ErrorState'
import {ModerationLabelPref} from '#/components/ModerationLabelPref'
export function ProfileLabelsSection({
isLabelerLoading,
labelerInfo,
labelerError,
moderationOpts,
}: {
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(() => {
scrollElRef.current?.scrollTo({
animated: isNative,
x: 0,
y: -headerHeight,
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
return (
<CenteredView>
<View
@@ -64,22 +92,42 @@ export function ProfileLabelsSection({
<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 {_} = useLingui()
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 = moderationOpts.prefs.mods.find(
mod => mod.did === labelerInfo.creator.did,
@@ -93,66 +141,81 @@ export function ProfileLabelsSectionInner({
) as InterprettedLabelValueDefinition[]
}, [labelerInfo, labelValues])
console.log(headerHeight, scrollElRef.current)
return (
<ScrollView
ref={scrollElRef}
scrollEventThrottle={1}
contentContainerStyle={{
paddingTop: headerHeight,
borderWidth: 0,
paddingHorizontal: a.px_xl.paddingLeft,
}}>
<View style={[a.pt_xl]}>
<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>
{labelValues.length === 0 ? (
<Text
style={[
a.pt_xl,
t.atoms.text_contrast_high,
a.leading_snug,
a.text_sm,
]}>
}}
contentOffset={{x: 0, y: headerHeight * -1}}
onScroll={scrollHandler}>
<View
style={[
a.pt_xl,
a.px_xl,
isNative && a.border_t,
t.atoms.border_contrast_low,
]}>
<View>
<Text style={[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.
Labels are annotations on users and content. They can be used to
hide, warn, and categorize the network.
</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, t.atoms.bg_contrast_25, a.rounded_md, a.py_xs]}>
{labelDefs.map((labelDef, i) => {
return (
<React.Fragment key={labelDef.identifier}>
{i !== 0 && <Divider />}
<View style={[a.py_md, a.px_md]}>
<ModerationLabelPref
disabled={isSubscribed ? undefined : true}
labelValueDefinition={labelDef}
/>
</View>
</React.Fragment>
)
})}
{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, t.atoms.bg_contrast_25, a.rounded_md, a.py_xs]}>
{labelDefs.map((labelDef, i) => {
return (
<React.Fragment key={labelDef.identifier}>
{i !== 0 && <Divider />}
<View style={[a.py_md, a.px_md]}>
<ModerationLabelPref
disabled={isSubscribed ? undefined : true}
labelValueDefinition={labelDef}
labelerDid={labelerInfo.creator.did}
/>
</View>
</React.Fragment>
)
})}
</View>
)}
<View style={{height: 100}} />
<View style={{height: 400}} />
</View>
</ScrollView>
)
}
+3 -3
View File
@@ -115,10 +115,10 @@ 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,
+5 -6
View File
@@ -156,7 +156,7 @@ function ProfileScreenLoaded({
const likesSectionRef = React.useRef<SectionRef>(null)
const feedsSectionRef = React.useRef<SectionRef>(null)
const listsSectionRef = React.useRef<SectionRef>(null)
const filtersSectionRef = React.useRef<SectionRef>(null)
const labelsSectionRef = React.useRef<SectionRef>(null)
useSetTitle(combinedDisplayName(profile))
@@ -237,7 +237,7 @@ function ProfileScreenLoaded({
const scrollSectionToTop = React.useCallback(
(index: number) => {
if (index === filtersIndex) {
filtersSectionRef.current?.scrollToTop()
labelsSectionRef.current?.scrollToTop()
} else if (index === postsIndex) {
postsSectionRef.current?.scrollToTop()
} else if (index === repliesIndex) {
@@ -347,16 +347,15 @@ function ProfileScreenLoaded({
onCurrentPageSelected={onCurrentPageSelected}
renderHeader={renderHeader}>
{showFiltersTab
? ({headerHeight, isFocused, scrollElRef}) => (
? ({headerHeight, scrollElRef}) => (
<ProfileLabelsSection
// ref={moderationSectionRef}
ref={labelsSectionRef}
labelerInfo={labelerInfo}
labelerError={labelerError}
isLabelerLoading={isLabelerLoading}
moderationOpts={moderationOpts}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
enabled={isFocused}
headerHeight={headerHeight}
/>
)
: null}