Eric/preferences (#1873)

* Add initial preferences query, couple mutations

* Remove unused

* Clean up labels, migrate getModerationOpts

* Add birth date handling

* Migrate feed prefs

* Migrate thread view prefs

* Migrate homeFeed to use existing key name

* Fix up saved feeds in response, no impl yet

* Migrate saved feeds to new hooks

* Clean up more of preferences

* Fix PreferencesThreads load state

* Fix modal dismissal

* Small spacing fix

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
Eric Bailey 2023-11-12 13:31:11 -06:00 committed by GitHub
parent c8c308e31e
commit 05b728fffc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1339 additions and 914 deletions

View file

@ -19,6 +19,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOnboardingDispatch} from '#/state/shell'
import {useSessionApi} from '#/state/session'
import {
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
DEFAULT_PROD_FEEDS,
} from '#/state/queries/preferences'
import {IS_PROD} from '#/lib/constants'
import {Step1} from './Step1'
import {Step2} from './Step2'
@ -36,6 +42,8 @@ export const CreateAccount = observer(function CreateAccountImpl({
const {_} = useLingui()
const onboardingDispatch = useOnboardingDispatch()
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
React.useEffect(() => {
screen('CreateAccount')
@ -70,13 +78,26 @@ export const CreateAccount = observer(function CreateAccountImpl({
onboardingDispatch,
createAccount,
})
setBirthDate({birthDate: model.birthDate})
if (IS_PROD(model.serviceUrl)) {
setSavedFeeds(DEFAULT_PROD_FEEDS)
}
} catch {
// dont need to handle here
} finally {
track('Try Create Account')
}
}
}, [model, track, onboardingDispatch, createAccount])
}, [
model,
track,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
])
return (
<LoggedOutLayout

View file

@ -16,6 +16,151 @@ import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {
usePreferencesQuery,
useSaveFeedMutation,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {useFeedSourceInfoQuery} from '#/state/queries/feed'
export const NewFeedSourceCard = observer(function FeedSourceCardImpl({
feedUri,
style,
showSaveBtn = false,
showDescription = false,
showLikes = false,
}: {
feedUri: string
style?: StyleProp<ViewStyle>
showSaveBtn?: boolean
showDescription?: boolean
showLikes?: boolean
}) {
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
const {data: preferences} = usePreferencesQuery()
const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
const {isPending: isSavePending, mutateAsync: saveFeed} =
useSaveFeedMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
const isSaved = Boolean(preferences?.feeds?.saved?.includes(feedUri))
const onToggleSaved = React.useCallback(async () => {
// Only feeds can be un/saved, lists are handled elsewhere
if (info?.type !== 'feed') return
if (isSaved) {
openModal({
name: 'confirm',
title: 'Remove from my feeds',
message: `Remove ${info?.displayName} from my feeds?`,
onPressConfirm: async () => {
try {
await removeFeed({uri: feedUri})
// await item.unsave()
Toast.show('Removed from my feeds')
} catch (e) {
Toast.show('There was an issue contacting your server')
logger.error('Failed to unsave feed', {error: e})
}
},
})
} else {
try {
await saveFeed({uri: feedUri})
Toast.show('Added to my feeds')
} catch (e) {
Toast.show('There was an issue contacting your server')
logger.error('Failed to save feed', {error: e})
}
}
}, [isSaved, openModal, info, feedUri, removeFeed, saveFeed])
if (!info || !preferences) return null
return (
<Pressable
testID={`feed-${info.displayName}`}
accessibilityRole="button"
style={[styles.container, pal.border, style]}
onPress={() => {
if (info.type === 'feed') {
navigation.push('ProfileFeed', {
name: info.creatorDid,
rkey: new AtUri(info.uri).rkey,
})
} else if (info.type === 'list') {
navigation.push('ProfileList', {
name: info.creatorDid,
rkey: new AtUri(info.uri).rkey,
})
}
}}
key={info.uri}>
<View style={[styles.headerContainer]}>
<View style={[s.mr10]}>
<UserAvatar type="algo" size={36} avatar={info.avatar} />
</View>
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{info.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{info.type === 'feed' ? 'Feed' : 'List'} by{' '}
{sanitizeHandle(info.creatorHandle, '@')}
</Text>
</View>
{showSaveBtn && info.type === 'feed' && (
<View>
<Pressable
disabled={isSavePending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved ? 'Remove from my feeds' : 'Add to my feeds'
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
</View>
)}
</View>
{showDescription && info.description ? (
<RichText
style={[pal.textLight, styles.description]}
richText={info.description}
numberOfLines={3}
/>
) : null}
{showLikes && info.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
Liked by {info.likeCount || 0}{' '}
{pluralize(info.likeCount || 0, 'user')}
</Text>
) : null}
</Pressable>
)
})
export const FeedSourceCard = observer(function FeedSourceCardImpl({
item,

View file

@ -9,7 +9,6 @@ import {observer} from 'mobx-react-lite'
import {Text} from '../util/text/Text'
import {DateInput} from '../util/forms/DateInput'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
@ -18,33 +17,36 @@ import {cleanError} from 'lib/strings/errors'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {
usePreferencesQuery,
usePreferencesSetBirthDateMutation,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
import {logger} from '#/logger'
export const snapPoints = ['50%']
export const Component = observer(function Component({}: {}) {
function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
const pal = usePalette('default')
const store = useStores()
const {_} = useLingui()
const {closeModal} = useModalControls()
const [date, setDate] = useState<Date>(
store.preferences.birthDate || new Date(),
)
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const {
isPending,
isError,
error,
mutateAsync: setBirthDate,
} = usePreferencesSetBirthDateMutation()
const [date, setDate] = useState(preferences.birthDate || new Date())
const {closeModal} = useModalControls()
const onSave = async () => {
setError('')
setIsProcessing(true)
const onSave = React.useCallback(async () => {
try {
await store.preferences.setBirthDate(date)
await setBirthDate({birthDate: date})
closeModal()
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
logger.error(`setBirthDate failed`, {error: e})
}
}
}, [date, setBirthDate, closeModal])
return (
<View
@ -74,12 +76,12 @@ export const Component = observer(function Component({}: {}) {
/>
</View>
{error ? (
<ErrorMessage message={error} style={styles.error} />
{isError ? (
<ErrorMessage message={cleanError(error)} style={styles.error} />
) : undefined}
<View style={[styles.btnContainer, pal.borderDark]}>
{isProcessing ? (
{isPending ? (
<View style={styles.btn}>
<ActivityIndicator color="#fff" />
</View>
@ -99,6 +101,16 @@ export const Component = observer(function Component({}: {}) {
</View>
</View>
)
}
export const Component = observer(function Component({}: {}) {
const {data: preferences} = usePreferencesQuery()
return !preferences ? (
<ActivityIndicator />
) : (
<Inner preferences={preferences} />
)
})
const styles = StyleSheet.create({

View file

@ -1,17 +1,15 @@
import React from 'react'
import {BskyPreferences, LabelPreference} from '@atproto/api'
import {StyleSheet, Pressable, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {observer} from 'mobx-react-lite'
import {ScrollView} from './util'
import {useStores} from 'state/index'
import {LabelPreference} from 'state/models/ui/preferences'
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 {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
import {isIOS} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import * as Toast from '../util/Toast'
@ -19,20 +17,23 @@ 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,
} from '#/state/queries/preferences'
export const snapPoints = ['90%']
export const Component = observer(
function ContentFilteringSettingsImpl({}: {}) {
const store = useStores()
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
const {closeModal} = useModalControls()
React.useEffect(() => {
store.preferences.sync()
}, [store])
const {data: preferences} = usePreferencesQuery()
const onPressDone = React.useCallback(() => {
closeModal()
@ -43,29 +44,38 @@ export const Component = observer(
<Text style={[pal.text, styles.title]}>
<Trans>Content Filtering</Trans>
</Text>
<ScrollView style={styles.scrollContainer}>
<AdultContentEnabledPref />
<ContentLabelPref
group="nsfw"
disabled={!store.preferences.adultContentEnabled}
preferences={preferences}
labelGroup="nsfw"
disabled={!preferences?.adultContentEnabled}
/>
<ContentLabelPref
group="nudity"
disabled={!store.preferences.adultContentEnabled}
preferences={preferences}
labelGroup="nudity"
disabled={!preferences?.adultContentEnabled}
/>
<ContentLabelPref
group="suggestive"
disabled={!store.preferences.adultContentEnabled}
preferences={preferences}
labelGroup="suggestive"
disabled={!preferences?.adultContentEnabled}
/>
<ContentLabelPref
group="gore"
disabled={!store.preferences.adultContentEnabled}
preferences={preferences}
labelGroup="gore"
disabled={!preferences?.adultContentEnabled}
/>
<ContentLabelPref preferences={preferences} labelGroup="hate" />
<ContentLabelPref preferences={preferences} labelGroup="spam" />
<ContentLabelPref
preferences={preferences}
labelGroup="impersonation"
/>
<ContentLabelPref group="hate" />
<ContentLabelPref group="spam" />
<ContentLabelPref group="impersonation" />
<View style={{height: isMobile ? 60 : 0}} />
</ScrollView>
<View
style={[
styles.btnContainer,
@ -94,118 +104,114 @@ export const Component = observer(
},
)
const AdultContentEnabledPref = observer(
function AdultContentEnabledPrefImpl() {
const store = useStores()
const pal = usePalette('default')
const {openModal} = useModalControls()
function AdultContentEnabledPref() {
const pal = usePalette('default')
const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetAdultContentMutation()
const {openModal} = useModalControls()
const onSetAge = () => openModal({name: 'birth-date-settings'})
const onSetAge = React.useCallback(
() => openModal({name: 'birth-date-settings'}),
[openModal],
)
const onToggleAdultContent = async () => {
if (isIOS) {
return
}
try {
await store.preferences.setAdultContentEnabled(
!store.preferences.adultContentEnabled,
)
} catch (e) {
Toast.show(
'There was an issue syncing your preferences with the server',
)
logger.error('Failed to update preferences with server', {error: e})
}
const onToggleAdultContent = React.useCallback(async () => {
if (isIOS) return
try {
mutate({
enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
})
} catch (e) {
Toast.show('There was an issue syncing your preferences with the server')
logger.error('Failed to update preferences with server', {error: e})
}
}, [variables, preferences, mutate])
return (
<View style={s.mb10}>
{isIOS ? (
store.preferences.adultContentEnabled ? null : (
<Text type="md" style={pal.textLight}>
Adult content can only be enabled via the Web at{' '}
<TextLink
style={pal.link}
href="https://bsky.app"
text="bsky.app"
/>
.
</Text>
)
) : typeof store.preferences.birthDate === 'undefined' ? (
<View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}>
Confirm your age to enable adult content.
</Text>
<Button type="primary" label="Set Age" onPress={onSetAge} />
</View>
) : (store.preferences.userAge || 0) >= 18 ? (
<ToggleButton
type="default-light"
label="Enable Adult Content"
isSelected={store.preferences.adultContentEnabled}
onPress={onToggleAdultContent}
style={styles.toggleBtn}
/>
) : (
<View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}>
You must be 18 or older to enable adult content.
</Text>
<Button type="primary" label="Set Age" onPress={onSetAge} />
</View>
)}
</View>
)
},
)
return (
<View style={s.mb10}>
{isIOS ? (
preferences?.adultContentEnabled ? null : (
<Text type="md" style={pal.textLight}>
Adult content can only be enabled via the Web at{' '}
<TextLink
style={pal.link}
href="https://bsky.app"
text="bsky.app"
/>
.
</Text>
)
) : typeof preferences?.birthDate === 'undefined' ? (
<View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}>
Confirm your age to enable adult content.
</Text>
<Button type="primary" label="Set Age" onPress={onSetAge} />
</View>
) : (preferences.userAge || 0) >= 18 ? (
<ToggleButton
type="default-light"
label="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}]}>
You must be 18 or older to enable adult content.
</Text>
<Button type="primary" label="Set Age" onPress={onSetAge} />
</View>
)}
</View>
)
}
// TODO: Refactor this component to pass labels down to each tab
const ContentLabelPref = observer(function ContentLabelPrefImpl({
group,
preferences,
labelGroup,
disabled,
}: {
group: keyof typeof CONFIGURABLE_LABEL_GROUPS
preferences?: BskyPreferences
labelGroup: ConfigurableLabelGroup
disabled?: boolean
}) {
const store = useStores()
const pal = usePalette('default')
const visibility = preferences?.contentLabels?.[labelGroup]
const {mutate, variables} = usePreferencesSetContentLabelMutation()
const onChange = React.useCallback(
async (v: LabelPreference) => {
try {
await store.preferences.setContentLabelPref(group, v)
} catch (e) {
Toast.show(
'There was an issue syncing your preferences with the server',
)
logger.error('Failed to update preferences with server', {error: e})
}
(vis: LabelPreference) => {
mutate({labelGroup, visibility: vis})
},
[store, group],
[mutate, labelGroup],
)
return (
<View style={[styles.contentLabelPref, pal.border]}>
<View style={s.flex1}>
<Text type="md-medium" style={[pal.text]}>
{CONFIGURABLE_LABEL_GROUPS[group].title}
{CONFIGURABLE_LABEL_GROUPS[labelGroup].title}
</Text>
{typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && (
{typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && (
<Text type="sm" style={[pal.textLight]}>
{CONFIGURABLE_LABEL_GROUPS[group].subtitle}
{CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle}
</Text>
)}
</View>
{disabled ? (
{disabled || !visibility ? (
<Text type="sm-bold" style={pal.textLight}>
Hide
</Text>
) : (
<SelectGroup
current={store.preferences.contentLabels[group]}
current={variables?.visibility || visibility}
onChange={onChange}
group={group}
labelGroup={labelGroup}
/>
)}
</View>
@ -215,10 +221,10 @@ const ContentLabelPref = observer(function ContentLabelPrefImpl({
interface SelectGroupProps {
current: LabelPreference
onChange: (v: LabelPreference) => void
group: keyof typeof CONFIGURABLE_LABEL_GROUPS
labelGroup: ConfigurableLabelGroup
}
function SelectGroup({current, onChange, group}: SelectGroupProps) {
function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
return (
<View style={styles.selectableBtns}>
<SelectableBtn
@ -227,14 +233,14 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) {
label="Hide"
left
onChange={onChange}
group={group}
labelGroup={labelGroup}
/>
<SelectableBtn
current={current}
value="warn"
label="Warn"
onChange={onChange}
group={group}
labelGroup={labelGroup}
/>
<SelectableBtn
current={current}
@ -242,7 +248,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) {
label="Show"
right
onChange={onChange}
group={group}
labelGroup={labelGroup}
/>
</View>
)
@ -255,7 +261,7 @@ interface SelectableBtnProps {
left?: boolean
right?: boolean
onChange: (v: LabelPreference) => void
group: keyof typeof CONFIGURABLE_LABEL_GROUPS
labelGroup: ConfigurableLabelGroup
}
function SelectableBtn({
@ -265,7 +271,7 @@ function SelectableBtn({
left,
right,
onChange,
group,
labelGroup,
}: SelectableBtnProps) {
const pal = usePalette('default')
const palPrimary = usePalette('inverted')
@ -281,7 +287,7 @@ function SelectableBtn({
onPress={() => onChange(value)}
accessibilityRole="button"
accessibilityLabel={value}
accessibilityHint={`Set ${value} for ${group} content moderation policy`}>
accessibilityHint={`Set ${value} for ${labelGroup} content moderation policy`}>
<Text style={current === value ? palPrimary.text : pal.text}>
{label}
</Text>

View file

@ -1,10 +1,10 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {useStores} from 'state/index'
import {navigate} from '../../../Navigation'
import {useModalControls} from '#/state/modals'
import {useQueryClient} from '@tanstack/react-query'
import {useSessionApi} from '#/state/session'
import {useSetFeedViewPreferencesMutation} from '#/state/queries/preferences'
/**
* This utility component is only included in the test simulator
@ -15,10 +15,10 @@ import {useSessionApi} from '#/state/session'
const BTN = {height: 1, width: 1, backgroundColor: 'red'}
export function TestCtrls() {
const store = useStores()
const queryClient = useQueryClient()
const {logout, login} = useSessionApi()
const {openModal} = useModalControls()
const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
const onPressSignInAlice = async () => {
await login({
service: 'http://localhost:3000',
@ -79,7 +79,7 @@ export function TestCtrls() {
/>
<Pressable
testID="e2eToggleMergefeed"
onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()}
onPress={() => setFeedViewPref({lab_mergeFeedEnabled: true})}
accessibilityRole="button"
style={BTN}
/>