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}
/>

View file

@ -4,7 +4,6 @@ import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Slider} from '@miblanchard/react-native-slider'
import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@ -16,21 +15,31 @@ import {CenteredView} from 'view/com/util/Views'
import debounce from 'lodash.debounce'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
usePreferencesQuery,
useSetFeedViewPreferencesMutation,
} from '#/state/queries/preferences'
function RepliesThresholdInput({enabled}: {enabled: boolean}) {
const store = useStores()
function RepliesThresholdInput({
enabled,
initialValue,
}: {
enabled: boolean
initialValue: number
}) {
const pal = usePalette('default')
const [value, setValue] = useState(
store.preferences.homeFeed.hideRepliesByLikeCount,
)
const [value, setValue] = useState(initialValue)
const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
const save = React.useMemo(
() =>
debounce(
threshold =>
store.preferences.setHomeFeedHideRepliesByLikeCount(threshold),
setFeedViewPref({
hideRepliesByLikeCount: threshold,
}),
500,
), // debouce for 500ms
[store],
[setFeedViewPref],
)
return (
@ -67,9 +76,15 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
navigation,
}: Props) {
const pal = usePalette('default')
const store = useStores()
const {_} = useLingui()
const {isTabletOrDesktop} = useWebMediaQueries()
const {data: preferences} = usePreferencesQuery()
const {mutate: setFeedViewPref, variables} =
useSetFeedViewPreferencesMutation()
const showReplies = !(
variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies
)
return (
<CenteredView
@ -105,17 +120,20 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
<ToggleButton
testID="toggleRepliesBtn"
type="default-light"
label={store.preferences.homeFeed.hideReplies ? 'No' : 'Yes'}
isSelected={!store.preferences.homeFeed.hideReplies}
onPress={store.preferences.toggleHomeFeedHideReplies}
label={showReplies ? 'Yes' : 'No'}
isSelected={showReplies}
onPress={() =>
setFeedViewPref({
hideReplies: !(
variables?.hideReplies ??
preferences?.feedViewPrefs?.hideReplies
),
})
}
/>
</View>
<View
style={[
pal.viewLight,
styles.card,
store.preferences.homeFeed.hideReplies && styles.dimmed,
]}>
style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<Trans>Reply Filters</Trans>
</Text>
@ -128,10 +146,19 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
<ToggleButton
type="default-light"
label="Followed users only"
isSelected={store.preferences.homeFeed.hideRepliesByUnfollowed}
isSelected={Boolean(
variables?.hideRepliesByUnfollowed ??
preferences?.feedViewPrefs?.hideRepliesByUnfollowed,
)}
onPress={
!store.preferences.homeFeed.hideReplies
? store.preferences.toggleHomeFeedHideRepliesByUnfollowed
showReplies
? () =>
setFeedViewPref({
hideRepliesByUnfollowed: !(
variables?.hideRepliesByUnfollowed ??
preferences?.feedViewPrefs?.hideRepliesByUnfollowed
),
})
: undefined
}
style={[s.mb10]}
@ -142,9 +169,12 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
feed.
</Trans>
</Text>
<RepliesThresholdInput
enabled={!store.preferences.homeFeed.hideReplies}
/>
{preferences && (
<RepliesThresholdInput
enabled={showReplies}
initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount}
/>
)}
</View>
<View style={[pal.viewLight, styles.card]}>
@ -158,9 +188,26 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
</Text>
<ToggleButton
type="default-light"
label={store.preferences.homeFeed.hideReposts ? 'No' : 'Yes'}
isSelected={!store.preferences.homeFeed.hideReposts}
onPress={store.preferences.toggleHomeFeedHideReposts}
label={
variables?.hideReposts ??
preferences?.feedViewPrefs?.hideReposts
? 'No'
: 'Yes'
}
isSelected={
!(
variables?.hideReposts ??
preferences?.feedViewPrefs?.hideReposts
)
}
onPress={() =>
setFeedViewPref({
hideReposts: !(
variables?.hideReposts ??
preferences?.feedViewPrefs?.hideReposts
),
})
}
/>
</View>
@ -176,9 +223,26 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
</Text>
<ToggleButton
type="default-light"
label={store.preferences.homeFeed.hideQuotePosts ? 'No' : 'Yes'}
isSelected={!store.preferences.homeFeed.hideQuotePosts}
onPress={store.preferences.toggleHomeFeedHideQuotePosts}
label={
variables?.hideQuotePosts ??
preferences?.feedViewPrefs?.hideQuotePosts
? 'No'
: 'Yes'
}
isSelected={
!(
variables?.hideQuotePosts ??
preferences?.feedViewPrefs?.hideQuotePosts
)
}
onPress={() =>
setFeedViewPref({
hideQuotePosts: !(
variables?.hideQuotePosts ??
preferences?.feedViewPrefs?.hideQuotePosts
),
})
}
/>
</View>
@ -196,10 +260,25 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
<ToggleButton
type="default-light"
label={
store.preferences.homeFeed.lab_mergeFeedEnabled ? 'Yes' : 'No'
variables?.lab_mergeFeedEnabled ??
preferences?.feedViewPrefs?.lab_mergeFeedEnabled
? 'Yes'
: 'No'
}
isSelected={
!!(
variables?.lab_mergeFeedEnabled ??
preferences?.feedViewPrefs?.lab_mergeFeedEnabled
)
}
onPress={() =>
setFeedViewPref({
lab_mergeFeedEnabled: !(
variables?.lab_mergeFeedEnabled ??
preferences?.feedViewPrefs?.lab_mergeFeedEnabled
),
})
}
isSelected={!!store.preferences.homeFeed.lab_mergeFeedEnabled}
onPress={store.preferences.toggleHomeFeedMergeFeedEnabled}
/>
</View>
</View>

View file

@ -1,9 +1,14 @@
import React from 'react'
import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {
ActivityIndicator,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@ -14,15 +19,30 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
usePreferencesQuery,
useSetThreadViewPreferencesMutation,
} from '#/state/queries/preferences'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
export const PreferencesThreads = observer(function PreferencesThreadsImpl({
navigation,
}: Props) {
const pal = usePalette('default')
const store = useStores()
const {_} = useLingui()
const {isTabletOrDesktop} = useWebMediaQueries()
const {data: preferences} = usePreferencesQuery()
const {mutate: setThreadViewPrefs, variables} =
useSetThreadViewPreferencesMutation()
const prioritizeFollowedUsers = Boolean(
variables?.prioritizeFollowedUsers ??
preferences?.threadViewPrefs?.prioritizeFollowedUsers,
)
const treeViewEnabled = Boolean(
variables?.lab_treeViewEnabled ??
preferences?.threadViewPrefs?.lab_treeViewEnabled,
)
return (
<CenteredView
@ -44,71 +64,79 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({
</Text>
</View>
<ScrollView>
<View style={styles.cardsContainer}>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<Trans>Sort Replies</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>Sort replies to the same post by:</Trans>
</Text>
<View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}>
<RadioGroup
{preferences ? (
<ScrollView>
<View style={styles.cardsContainer}>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<Trans>Sort Replies</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>Sort replies to the same post by:</Trans>
</Text>
<View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}>
<RadioGroup
type="default-light"
items={[
{key: 'oldest', label: 'Oldest replies first'},
{key: 'newest', label: 'Newest replies first'},
{key: 'most-likes', label: 'Most-liked replies first'},
{key: 'random', label: 'Random (aka "Poster\'s Roulette")'},
]}
onSelect={key => setThreadViewPrefs({sort: key})}
initialSelection={preferences?.threadViewPrefs?.sort}
/>
</View>
</View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<Trans>Prioritize Your Follows</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Show replies by people you follow before all other replies.
</Trans>
</Text>
<ToggleButton
type="default-light"
items={[
{key: 'oldest', label: 'Oldest replies first'},
{key: 'newest', label: 'Newest replies first'},
{key: 'most-likes', label: 'Most-liked replies first'},
{key: 'random', label: 'Random (aka "Poster\'s Roulette")'},
]}
onSelect={store.preferences.setThreadSort}
initialSelection={store.preferences.thread.sort}
label={prioritizeFollowedUsers ? 'Yes' : 'No'}
isSelected={prioritizeFollowedUsers}
onPress={() =>
setThreadViewPrefs({
prioritizeFollowedUsers: !prioritizeFollowedUsers,
})
}
/>
</View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<FontAwesomeIcon icon="flask" color={pal.colors.text} />{' '}
<Trans>Threaded Mode</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Set this setting to "Yes" to show replies in a threaded view.
This is an experimental feature.
</Trans>
</Text>
<ToggleButton
type="default-light"
label={treeViewEnabled ? 'Yes' : 'No'}
isSelected={treeViewEnabled}
onPress={() =>
setThreadViewPrefs({
lab_treeViewEnabled: !treeViewEnabled,
})
}
/>
</View>
</View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<Trans>Prioritize Your Follows</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Show replies by people you follow before all other replies.
</Trans>
</Text>
<ToggleButton
type="default-light"
label={
store.preferences.thread.prioritizeFollowedUsers ? 'Yes' : 'No'
}
isSelected={store.preferences.thread.prioritizeFollowedUsers}
onPress={store.preferences.togglePrioritizedFollowedUsers}
/>
</View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<FontAwesomeIcon icon="flask" color={pal.colors.text} />{' '}
<Trans>Threaded Mode</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Set this setting to "Yes" to show replies in a threaded view.
This is an experimental feature.
</Trans>
</Text>
<ToggleButton
type="default-light"
label={
store.preferences.thread.lab_treeViewEnabled ? 'Yes' : 'No'
}
isSelected={!!store.preferences.thread.lab_treeViewEnabled}
onPress={store.preferences.toggleThreadTreeViewEnabled}
/>
</View>
</View>
</ScrollView>
</ScrollView>
) : (
<ActivityIndicator />
)}
<View
style={[

View file

@ -1,4 +1,4 @@
import React, {useCallback, useMemo} from 'react'
import React from 'react'
import {
StyleSheet,
View,
@ -8,26 +8,34 @@ import {
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {track} from '#/lib/analytics/analytics'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {CommonNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SavedFeedsModel} from 'state/models/ui/saved-feeds'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {ScrollView, CenteredView} from 'view/com/util/Views'
import {Text} from 'view/com/util/text/Text'
import {s, colors} from 'lib/styles'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as Toast from 'view/com/util/Toast'
import {Haptics} from 'lib/haptics'
import {TextLink} from 'view/com/util/Link'
import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell'
import {
usePreferencesQuery,
usePinFeedMutation,
useUnpinFeedMutation,
useSetSaveFeedsMutation,
usePreferencesQueryKey,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
const HITSLOP_TOP = {
top: 20,
@ -43,150 +51,178 @@ const HITSLOP_BOTTOM = {
}
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
export const SavedFeeds = withAuthRequired(
observer(function SavedFeedsImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const setMinimalShellMode = useSetMinimalShellMode()
export const SavedFeeds = withAuthRequired(function SavedFeedsImpl({}: Props) {
const pal = usePalette('default')
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const setMinimalShellMode = useSetMinimalShellMode()
const {data: preferences} = usePreferencesQuery()
const savedFeeds = useMemo(() => {
const model = new SavedFeedsModel(store)
model.refresh()
return model
}, [store])
useFocusEffect(
useCallback(() => {
screen('SavedFeeds')
setMinimalShellMode(false)
savedFeeds.refresh()
}, [screen, setMinimalShellMode, savedFeeds]),
)
useFocusEffect(
React.useCallback(() => {
screen('SavedFeeds')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
return (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isTabletOrDesktop && styles.desktopContainer,
]}>
<ViewHeader title="Edit My Feeds" showOnDesktop showBorder />
<ScrollView style={s.flex1}>
<View style={[pal.text, pal.border, styles.title]}>
<Text type="title" style={pal.text}>
Pinned Feeds
</Text>
</View>
{savedFeeds.hasLoaded ? (
!savedFeeds.pinned.length ? (
<View
style={[
pal.border,
isMobile && s.flex1,
pal.viewLight,
styles.empty,
]}>
<Text type="lg" style={[pal.text]}>
You don't have any pinned feeds.
</Text>
</View>
) : (
savedFeeds.pinned.map(feed => (
<ListItem
key={feed._reactKey}
savedFeeds={savedFeeds}
item={feed}
/>
))
)
return (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isTabletOrDesktop && styles.desktopContainer,
]}>
<ViewHeader title="Edit My Feeds" showOnDesktop showBorder />
<ScrollView style={s.flex1}>
<View style={[pal.text, pal.border, styles.title]}>
<Text type="title" style={pal.text}>
Pinned Feeds
</Text>
</View>
{preferences?.feeds ? (
!preferences.feeds.pinned.length ? (
<View
style={[
pal.border,
isMobile && s.flex1,
pal.viewLight,
styles.empty,
]}>
<Text type="lg" style={[pal.text]}>
You don't have any pinned feeds.
</Text>
</View>
) : (
<ActivityIndicator style={{marginTop: 20}} />
)}
<View style={[pal.text, pal.border, styles.title]}>
<Text type="title" style={pal.text}>
Saved Feeds
</Text>
</View>
{savedFeeds.hasLoaded ? (
!savedFeeds.unpinned.length ? (
<View
style={[
pal.border,
isMobile && s.flex1,
pal.viewLight,
styles.empty,
]}>
<Text type="lg" style={[pal.text]}>
You don't have any saved feeds.
</Text>
</View>
) : (
savedFeeds.unpinned.map(feed => (
<ListItem
key={feed._reactKey}
savedFeeds={savedFeeds}
item={feed}
/>
))
)
preferences?.feeds?.pinned?.map(uri => (
<ListItem key={uri} feedUri={uri} isPinned />
))
)
) : (
<ActivityIndicator style={{marginTop: 20}} />
)}
<View style={[pal.text, pal.border, styles.title]}>
<Text type="title" style={pal.text}>
Saved Feeds
</Text>
</View>
{preferences?.feeds ? (
!preferences.feeds.unpinned.length ? (
<View
style={[
pal.border,
isMobile && s.flex1,
pal.viewLight,
styles.empty,
]}>
<Text type="lg" style={[pal.text]}>
You don't have any saved feeds.
</Text>
</View>
) : (
<ActivityIndicator style={{marginTop: 20}} />
)}
preferences.feeds.unpinned.map(uri => (
<ListItem key={uri} feedUri={uri} isPinned={false} />
))
)
) : (
<ActivityIndicator style={{marginTop: 20}} />
)}
<View style={styles.footerText}>
<Text type="sm" style={pal.textLight}>
Feeds are custom algorithms that users build with a little coding
expertise.{' '}
<TextLink
type="sm"
style={pal.link}
href="https://github.com/bluesky-social/feed-generator"
text="See this guide"
/>{' '}
for more information.
</Text>
</View>
<View style={{height: 100}} />
</ScrollView>
</CenteredView>
)
}),
)
<View style={styles.footerText}>
<Text type="sm" style={pal.textLight}>
Feeds are custom algorithms that users build with a little coding
expertise.{' '}
<TextLink
type="sm"
style={pal.link}
href="https://github.com/bluesky-social/feed-generator"
text="See this guide"
/>{' '}
for more information.
</Text>
</View>
<View style={{height: 100}} />
</ScrollView>
</CenteredView>
)
})
const ListItem = observer(function ListItemImpl({
savedFeeds,
item,
feedUri,
isPinned,
}: {
savedFeeds: SavedFeedsModel
item: FeedSourceModel
feedUri: string // uri
isPinned: boolean
}) {
const pal = usePalette('default')
const isPinned = item.isPinned
const queryClient = useQueryClient()
const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
useUnpinFeedMutation()
const {isPending: isMovePending, mutateAsync: setSavedFeeds} =
useSetSaveFeedsMutation()
const onTogglePinned = useCallback(() => {
const onTogglePinned = React.useCallback(async () => {
Haptics.default()
item.togglePin().catch(e => {
try {
if (isPinned) {
await unpinFeed({uri: feedUri})
} else {
await pinFeed({uri: feedUri})
}
} catch (e) {
Toast.show('There was an issue contacting the server')
logger.error('Failed to toggle pinned feed', {error: e})
})
}, [item])
const onPressUp = useCallback(
() =>
savedFeeds.movePinnedFeed(item, 'up').catch(e => {
Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e})
}),
[savedFeeds, item],
)
const onPressDown = useCallback(
() =>
savedFeeds.movePinnedFeed(item, 'down').catch(e => {
Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e})
}),
[savedFeeds, item],
)
}
}, [feedUri, isPinned, pinFeed, unpinFeed])
const onPressUp = React.useCallback(async () => {
if (!isPinned) return
const feeds = queryClient.getQueryData<UsePreferencesQueryResponse>(
usePreferencesQueryKey,
)?.feeds
const pinned = feeds?.pinned ?? []
const index = pinned.indexOf(feedUri)
if (index === -1 || index === 0) return
;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
try {
await setSavedFeeds({saved: feeds?.saved ?? [], pinned})
track('CustomFeed:Reorder', {
uri: feedUri,
index: pinned.indexOf(feedUri),
})
} catch (e) {
Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e})
}
}, [feedUri, isPinned, queryClient, setSavedFeeds])
const onPressDown = React.useCallback(async () => {
if (!isPinned) return
const feeds = queryClient.getQueryData<UsePreferencesQueryResponse>(
usePreferencesQueryKey,
)?.feeds
const pinned = feeds?.pinned ?? []
const index = pinned.indexOf(feedUri)
if (index === -1 || index >= pinned.length - 1) return
;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
try {
await setSavedFeeds({saved: feeds?.saved ?? [], pinned})
track('CustomFeed:Reorder', {
uri: feedUri,
index: pinned.indexOf(feedUri),
})
} catch (e) {
Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e})
}
}, [feedUri, isPinned, queryClient, setSavedFeeds])
return (
<Pressable
@ -195,6 +231,7 @@ const ListItem = observer(function ListItemImpl({
{isPinned ? (
<View style={styles.webArrowButtonsContainer}>
<TouchableOpacity
disabled={isMovePending}
accessibilityRole="button"
onPress={onPressUp}
hitSlop={HITSLOP_TOP}>
@ -205,6 +242,7 @@ const ListItem = observer(function ListItemImpl({
/>
</TouchableOpacity>
<TouchableOpacity
disabled={isMovePending}
accessibilityRole="button"
onPress={onPressDown}
hitSlop={HITSLOP_BOTTOM}>
@ -212,13 +250,14 @@ const ListItem = observer(function ListItemImpl({
</TouchableOpacity>
</View>
) : null}
<FeedSourceCard
key={item.uri}
item={item}
showSaveBtn
<NewFeedSourceCard
key={feedUri}
feedUri={feedUri}
style={styles.noBorder}
showSaveBtn
/>
<TouchableOpacity
disabled={isPinPending || isUnpinPending}
accessibilityRole="button"
hitSlop={10}
onPress={onTogglePinned}>

View file

@ -59,6 +59,7 @@ import {
} from '#/state/preferences'
import {useSession, useSessionApi, SessionAccount} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile'
import {useClearPreferencesMutation} from '#/state/queries/preferences'
// TEMPORARY (APP-700)
// remove after backend testing finishes
@ -153,6 +154,7 @@ export const SettingsScreen = withAuthRequired(
const {openModal} = useModalControls()
const {isSwitchingAccounts, accounts, currentAccount} = useSession()
const {clearCurrentAccount} = useSessionApi()
const {mutate: clearPreferences} = useClearPreferencesMutation()
const primaryBg = useCustomPalette<ViewStyle>({
light: {backgroundColor: colors.blue0},
@ -219,9 +221,8 @@ export const SettingsScreen = withAuthRequired(
}, [openModal])
const onPressResetPreferences = React.useCallback(async () => {
await store.preferences.reset()
Toast.show('Preferences reset')
}, [store])
clearPreferences()
}, [clearPreferences])
const onPressResetOnboarding = React.useCallback(async () => {
onboardingDispatch({type: 'start'})
@ -300,7 +301,7 @@ export const SettingsScreen = withAuthRequired(
</View>
<View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}>
<Trans>Birthday: </Trans>
<Trans>Birthday:</Trans>{' '}
</Text>
<Link onPress={() => openModal({name: 'birth-date-settings'})}>
<Text type="lg" style={pal.link}>