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>
)
}
+29 -19
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>
<View style={[{width: 110}]}>
{!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>
<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
+80 -17
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,14 +141,26 @@ 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]}>
}}
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>
Labels are annotations on users and content. They can be used to
@@ -116,8 +176,8 @@ export function ProfileLabelsSectionInner({
a.text_sm,
]}>
<Trans>
This labeler hasn't declared what labels it publishes, and may not
be active.
This labeler hasn't declared what labels it publishes, and may
not be active.
</Trans>
</Text>
) : !isSubscribed ? (
@@ -135,7 +195,8 @@ export function ProfileLabelsSectionInner({
) : null}
</View>
{labelDefs.length > 0 && (
<View style={[a.mt_xl, t.atoms.bg_contrast_25, a.rounded_md, a.py_xs]}>
<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}>
@@ -144,6 +205,7 @@ export function ProfileLabelsSectionInner({
<ModerationLabelPref
disabled={isSubscribed ? undefined : true}
labelValueDefinition={labelDef}
labelerDid={labelerInfo.creator.did}
/>
</View>
</React.Fragment>
@@ -152,7 +214,8 @@ export function ProfileLabelsSectionInner({
</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}