diff options
Diffstat (limited to 'src/state/queries/preferences')
-rw-r--r-- | src/state/queries/preferences/const.ts | 51 | ||||
-rw-r--r-- | src/state/queries/preferences/index.ts | 271 | ||||
-rw-r--r-- | src/state/queries/preferences/moderation.ts | 181 | ||||
-rw-r--r-- | src/state/queries/preferences/types.ts | 52 | ||||
-rw-r--r-- | src/state/queries/preferences/util.ts | 16 |
5 files changed, 571 insertions, 0 deletions
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts new file mode 100644 index 000000000..b7f9206e8 --- /dev/null +++ b/src/state/queries/preferences/const.ts @@ -0,0 +1,51 @@ +import { + UsePreferencesQueryResponse, + ThreadViewPreferences, +} from '#/state/queries/preferences/types' +import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' + +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')], +} + +export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { + birthDate: new Date('2022-11-17'), // TODO(pwi) + adultContentEnabled: false, + feeds: { + saved: [], + pinned: [], + unpinned: [], + }, + // labels are undefined until set by user + contentLabels: { + nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw, + nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity, + suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive, + gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore, + hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate, + spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam, + impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation, + }, + feedViewPrefs: DEFAULT_HOME_FEED_PREFS, + threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, + userAge: 13, // TODO(pwi) +} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts new file mode 100644 index 000000000..afdec267d --- /dev/null +++ b/src/state/queries/preferences/index.ts @@ -0,0 +1,271 @@ +import {useMemo} from 'react' +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, getAgent} 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, + DEFAULT_LOGGED_OUT_PREFERENCES, +} from '#/state/queries/preferences/const' +import {getModerationOpts} from '#/state/queries/preferences/moderation' +import {STALE} from '#/state/queries' + +export * from '#/state/queries/preferences/types' +export * from '#/state/queries/preferences/moderation' +export * from '#/state/queries/preferences/const' + +export const preferencesQueryKey = ['getPreferences'] + +export function usePreferencesQuery() { + return useQuery({ + staleTime: STALE.MINUTES.ONE, + queryKey: preferencesQueryKey, + queryFn: async () => { + const agent = getAgent() + + if (agent.session?.did === undefined) { + return DEFAULT_LOGGED_OUT_PREFERENCES + } else { + 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 useModerationOpts() { + const {currentAccount} = useSession() + const prefs = usePreferencesQuery() + const opts = useMemo(() => { + if (!prefs.data) { + return + } + return getModerationOpts({ + userDid: currentAccount?.did || '', + preferences: prefs.data, + }) + }, [currentAccount?.did, prefs.data]) + return opts +} + +export function useClearPreferencesMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + await getAgent().app.bsky.actor.putPreferences({preferences: []}) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function usePreferencesSetContentLabelMutation() { + const queryClient = useQueryClient() + + return useMutation< + void, + unknown, + {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference} + >({ + mutationFn: async ({labelGroup, visibility}) => { + await getAgent().setContentLabelPref(labelGroup, visibility) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function usePreferencesSetAdultContentMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {enabled: boolean}>({ + mutationFn: async ({enabled}) => { + await getAgent().setAdultContentEnabled(enabled) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function usePreferencesSetBirthDateMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {birthDate: Date}>({ + mutationFn: async ({birthDate}: {birthDate: Date}) => { + await getAgent().setPersonalDetails({birthDate}) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSetFeedViewPreferencesMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({ + mutationFn: async prefs => { + await getAgent().setFeedViewPrefs('home', prefs) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSetThreadViewPreferencesMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, Partial<ThreadViewPreferences>>({ + mutationFn: async prefs => { + await getAgent().setThreadViewPrefs(prefs) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSetSaveFeedsMutation() { + const queryClient = useQueryClient() + + return useMutation< + void, + unknown, + Pick<UsePreferencesQueryResponse['feeds'], 'saved' | 'pinned'> + >({ + mutationFn: async ({saved, pinned}) => { + await getAgent().setSavedFeeds(saved, pinned) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSaveFeedMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().addSavedFeed(uri) + track('CustomFeed:Save') + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useRemoveFeedMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().removeSavedFeed(uri) + track('CustomFeed:Unsave') + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function usePinFeedMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().addPinnedFeed(uri) + track('CustomFeed:Pin', {uri}) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useUnpinFeedMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().removePinnedFeed(uri) + track('CustomFeed:Unpin', {uri}) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts new file mode 100644 index 000000000..cdae52937 --- /dev/null +++ b/src/state/queries/preferences/moderation.ts @@ -0,0 +1,181 @@ +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', +} + +/** + * More strict than our default settings for logged in users. + * + * TODO(pwi) + */ +export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record< + ConfigurableLabelGroup, + LabelPreference +> = { + nsfw: 'hide', + nudity: 'hide', + suggestive: 'hide', + gore: 'hide', + 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: {}, + }, + ], + } +} diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts new file mode 100644 index 000000000..5fca8d558 --- /dev/null +++ b/src/state/queries/preferences/types.ts @@ -0,0 +1,52 @@ +import { + BskyPreferences, + LabelPreference, + BskyThreadViewPreference, + BskyFeedViewPreference, +} 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: BskyFeedViewPreference & { + lab_mergeFeedEnabled?: boolean + } + /** + * 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 = Pick< + BskyThreadViewPreference, + 'prioritizeFollowedUsers' +> & { + sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string + lab_treeViewEnabled?: boolean +} diff --git a/src/state/queries/preferences/util.ts b/src/state/queries/preferences/util.ts new file mode 100644 index 000000000..7b8160c28 --- /dev/null +++ b/src/state/queries/preferences/util.ts @@ -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 +} |