about summary refs log tree commit diff
path: root/src/state/queries/preferences
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries/preferences')
-rw-r--r--src/state/queries/preferences/const.ts51
-rw-r--r--src/state/queries/preferences/index.ts271
-rw-r--r--src/state/queries/preferences/moderation.ts181
-rw-r--r--src/state/queries/preferences/types.ts52
-rw-r--r--src/state/queries/preferences/util.ts16
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
+}