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

106
src/state/queries/feed.ts Normal file
View file

@ -0,0 +1,106 @@
import {useQuery} from '@tanstack/react-query'
import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useSession} from '#/state/session'
type FeedSourceInfo =
| {
type: 'feed'
uri: string
cid: string
avatar: string | undefined
displayName: string
description: RichText
creatorDid: string
creatorHandle: string
likeCount: number | undefined
likeUri: string | undefined
}
| {
type: 'list'
uri: string
cid: string
avatar: string | undefined
displayName: string
description: RichText
creatorDid: string
creatorHandle: string
}
export const useFeedSourceInfoQueryKey = ({uri}: {uri: string}) => [
'getFeedSourceInfo',
uri,
]
const feedSourceNSIDs = {
feed: 'app.bsky.feed.generator',
list: 'app.bsky.graph.list',
}
function hydrateFeedGenerator(
view: AppBskyFeedDefs.GeneratorView,
): FeedSourceInfo {
return {
type: 'feed',
uri: view.uri,
cid: view.cid,
avatar: view.avatar,
displayName: view.displayName
? sanitizeDisplayName(view.displayName)
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`,
description: new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
}),
creatorDid: view.creator.did,
creatorHandle: view.creator.handle,
likeCount: view.likeCount,
likeUri: view.viewer?.like,
}
}
function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
return {
type: 'list',
uri: view.uri,
cid: view.cid,
avatar: view.avatar,
description: new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
}),
creatorDid: view.creator.did,
creatorHandle: view.creator.handle,
displayName: view.name
? sanitizeDisplayName(view.name)
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
}
}
export function useFeedSourceInfoQuery({uri}: {uri: string}) {
const {agent} = useSession()
const {pathname} = new AtUri(uri)
const type = pathname.includes(feedSourceNSIDs.feed) ? 'feed' : 'list'
return useQuery({
queryKey: useFeedSourceInfoQueryKey({uri}),
queryFn: async () => {
let view: FeedSourceInfo
if (type === 'feed') {
const res = await agent.app.bsky.feed.getFeedGenerator({feed: uri})
view = hydrateFeedGenerator(res.data.view)
} else {
const res = await agent.app.bsky.graph.getList({
list: uri,
limit: 1,
})
view = hydrateList(res.data.list)
}
return view
},
})
}

View file

@ -0,0 +1,27 @@
import {
UsePreferencesQueryResponse,
ThreadViewPreferences,
} from '#/state/queries/preferences/types'
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{
hideReplies: false,
hideRepliesByUnfollowed: false,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
lab_mergeFeedEnabled: false, // experimental
}
export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = {
sort: 'newest',
prioritizeFollowedUsers: true,
lab_treeViewEnabled: false,
}
const DEFAULT_PROD_FEED_PREFIX = (rkey: string) =>
`at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
export const DEFAULT_PROD_FEEDS = {
pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
}

View file

@ -0,0 +1,257 @@
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {
ConfigurableLabelGroup,
UsePreferencesQueryResponse,
ThreadViewPreferences,
} from '#/state/queries/preferences/types'
import {temp__migrateLabelPref} from '#/state/queries/preferences/util'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_THREAD_VIEW_PREFS,
} from '#/state/queries/preferences/const'
export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/const'
export const usePreferencesQueryKey = ['getPreferences']
export function usePreferencesQuery() {
const {agent} = useSession()
return useQuery({
queryKey: usePreferencesQueryKey,
queryFn: async () => {
const res = await agent.getPreferences()
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
saved: res.feeds?.saved || [],
pinned: res.feeds?.pinned || [],
unpinned:
res.feeds.saved?.filter(f => {
return !res.feeds.pinned?.includes(f)
}) || [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: temp__migrateLabelPref(
res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
),
nudity: temp__migrateLabelPref(
res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
),
suggestive: temp__migrateLabelPref(
res.contentLabels?.suggestive ||
DEFAULT_LABEL_PREFERENCES.suggestive,
),
gore: temp__migrateLabelPref(
res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
),
hate: temp__migrateLabelPref(
res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
),
spam: temp__migrateLabelPref(
res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
),
impersonation: temp__migrateLabelPref(
res.contentLabels?.impersonation ||
DEFAULT_LABEL_PREFERENCES.impersonation,
),
},
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
},
threadViewPrefs: {
...DEFAULT_THREAD_VIEW_PREFS,
...(res.threadViewPrefs ?? {}),
},
userAge: res.birthDate ? getAge(res.birthDate) : undefined,
}
return preferences
},
})
}
export function useClearPreferencesMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
await agent.app.bsky.actor.putPreferences({preferences: []})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function usePreferencesSetContentLabelMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<
void,
unknown,
{labelGroup: ConfigurableLabelGroup; visibility: LabelPreference}
>({
mutationFn: async ({labelGroup, visibility}) => {
await agent.setContentLabelPref(labelGroup, visibility)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function usePreferencesSetAdultContentMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, unknown, {enabled: boolean}>({
mutationFn: async ({enabled}) => {
await agent.setAdultContentEnabled(enabled)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function usePreferencesSetBirthDateMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, unknown, {birthDate: Date}>({
mutationFn: async ({birthDate}: {birthDate: Date}) => {
await agent.setPersonalDetails({birthDate})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function useSetFeedViewPreferencesMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({
mutationFn: async prefs => {
await agent.setFeedViewPrefs('home', prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function useSetThreadViewPreferencesMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, unknown, Partial<ThreadViewPreferences>>({
mutationFn: async prefs => {
await agent.setThreadViewPrefs(prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function useSetSaveFeedsMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<
void,
unknown,
Pick<UsePreferencesQueryResponse['feeds'], 'saved' | 'pinned'>
>({
mutationFn: async ({saved, pinned}) => {
await agent.setSavedFeeds(saved, pinned)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function useSaveFeedMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => {
await agent.addSavedFeed(uri)
track('CustomFeed:Save')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function useRemoveFeedMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => {
await agent.removeSavedFeed(uri)
track('CustomFeed:Unsave')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function usePinFeedMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => {
await agent.addPinnedFeed(uri)
track('CustomFeed:Pin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}
export function useUnpinFeedMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => {
await agent.removePinnedFeed(uri)
track('CustomFeed:Unpin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
})
},
})
}

View file

@ -0,0 +1,163 @@
import {
LabelPreference,
ComAtprotoLabelDefs,
ModerationOpts,
} from '@atproto/api'
import {
LabelGroup,
ConfigurableLabelGroup,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types'
export type Label = ComAtprotoLabelDefs.Label
export type LabelGroupConfig = {
id: LabelGroup
title: string
isAdultImagery?: boolean
subtitle?: string
warning: string
values: string[]
}
export const DEFAULT_LABEL_PREFERENCES: Record<
ConfigurableLabelGroup,
LabelPreference
> = {
nsfw: 'hide',
nudity: 'warn',
suggestive: 'warn',
gore: 'warn',
hate: 'hide',
spam: 'hide',
impersonation: 'hide',
}
export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = {
id: 'illegal',
title: 'Illegal Content',
warning: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
}
export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = {
id: 'always-filter',
title: 'Content Warning',
warning: 'Content Warning',
values: ['!filter'],
}
export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = {
id: 'always-warn',
title: 'Content Warning',
warning: 'Content Warning',
values: ['!warn', 'account-security'],
}
export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = {
id: 'unknown',
title: 'Unknown Label',
warning: 'Content Warning',
values: [],
}
export const CONFIGURABLE_LABEL_GROUPS: Record<
ConfigurableLabelGroup,
LabelGroupConfig
> = {
nsfw: {
id: 'nsfw',
title: 'Explicit Sexual Images',
subtitle: 'i.e. pornography',
warning: 'Sexually Explicit',
values: ['porn', 'nsfl'],
isAdultImagery: true,
},
nudity: {
id: 'nudity',
title: 'Other Nudity',
subtitle: 'Including non-sexual and artistic',
warning: 'Nudity',
values: ['nudity'],
isAdultImagery: true,
},
suggestive: {
id: 'suggestive',
title: 'Sexually Suggestive',
subtitle: 'Does not include nudity',
warning: 'Sexually Suggestive',
values: ['sexual'],
isAdultImagery: true,
},
gore: {
id: 'gore',
title: 'Violent / Bloody',
subtitle: 'Gore, self-harm, torture',
warning: 'Violence',
values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
isAdultImagery: true,
},
hate: {
id: 'hate',
title: 'Hate Group Iconography',
subtitle: 'Images of terror groups, articles covering events, etc.',
warning: 'Hate Groups',
values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
},
spam: {
id: 'spam',
title: 'Spam',
subtitle: 'Excessive unwanted interactions',
warning: 'Spam',
values: ['spam'],
},
impersonation: {
id: 'impersonation',
title: 'Impersonation',
subtitle: 'Accounts falsely claiming to be people or orgs',
warning: 'Impersonation',
values: ['impersonation'],
},
}
export function getModerationOpts({
userDid,
preferences,
}: {
userDid: string
preferences: UsePreferencesQueryResponse
}): ModerationOpts {
return {
userDid: userDid,
adultContentEnabled: preferences.adultContentEnabled,
labels: {
porn: preferences.contentLabels.nsfw,
sexual: preferences.contentLabels.suggestive,
nudity: preferences.contentLabels.nudity,
nsfl: preferences.contentLabels.gore,
corpse: preferences.contentLabels.gore,
gore: preferences.contentLabels.gore,
torture: preferences.contentLabels.gore,
'self-harm': preferences.contentLabels.gore,
'intolerant-race': preferences.contentLabels.hate,
'intolerant-gender': preferences.contentLabels.hate,
'intolerant-sexual-orientation': preferences.contentLabels.hate,
'intolerant-religion': preferences.contentLabels.hate,
intolerant: preferences.contentLabels.hate,
'icon-intolerant': preferences.contentLabels.hate,
spam: preferences.contentLabels.spam,
impersonation: preferences.contentLabels.impersonation,
scam: 'warn',
},
labelers: [
{
labeler: {
did: '',
displayName: 'Bluesky Social',
},
labels: {},
},
],
}
}

View file

@ -0,0 +1,46 @@
import {
BskyPreferences,
LabelPreference,
BskyThreadViewPreference,
} from '@atproto/api'
export type ConfigurableLabelGroup =
| 'nsfw'
| 'nudity'
| 'suggestive'
| 'gore'
| 'hate'
| 'spam'
| 'impersonation'
export type LabelGroup =
| ConfigurableLabelGroup
| 'illegal'
| 'always-filter'
| 'always-warn'
| 'unknown'
export type UsePreferencesQueryResponse = Omit<
BskyPreferences,
'contentLabels' | 'feedViewPrefs' | 'feeds'
> & {
/*
* Content labels previously included 'show', which has been deprecated in
* favor of 'ignore'. The API can return legacy data from the database, and
* we clean up the data in `usePreferencesQuery`.
*/
contentLabels: Record<ConfigurableLabelGroup, LabelPreference>
feedViewPrefs: BskyPreferences['feedViewPrefs']['home']
/**
* User thread-view prefs, including newer fields that may not be typed yet.
*/
threadViewPrefs: ThreadViewPreferences
userAge: number | undefined
feeds: Required<BskyPreferences['feeds']> & {
unpinned: string[]
}
}
export type ThreadViewPreferences = Omit<BskyThreadViewPreference, 'sort'> & {
sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string
lab_treeViewEnabled: boolean
}

View file

@ -0,0 +1,16 @@
import {LabelPreference} from '@atproto/api'
/**
* Content labels previously included 'show', which has been deprecated in
* favor of 'ignore'. The API can return legacy data from the database, and
* we clean up the data in `usePreferencesQuery`.
*
* @deprecated
*/
export function temp__migrateLabelPref(
pref: LabelPreference | 'show',
): LabelPreference {
// @ts-ignore
if (pref === 'show') return 'ignore'
return pref
}