diff options
Diffstat (limited to 'src/state/queries')
-rw-r--r-- | src/state/queries/feed.ts | 106 | ||||
-rw-r--r-- | src/state/queries/preferences/const.ts | 27 | ||||
-rw-r--r-- | src/state/queries/preferences/index.ts | 257 | ||||
-rw-r--r-- | src/state/queries/preferences/moderation.ts | 163 | ||||
-rw-r--r-- | src/state/queries/preferences/types.ts | 46 | ||||
-rw-r--r-- | src/state/queries/preferences/util.ts | 16 |
6 files changed, 615 insertions, 0 deletions
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts new file mode 100644 index 000000000..0ba323314 --- /dev/null +++ b/src/state/queries/feed.ts @@ -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 + }, + }) +} diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts new file mode 100644 index 000000000..5db137e58 --- /dev/null +++ b/src/state/queries/preferences/const.ts @@ -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')], +} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts new file mode 100644 index 000000000..d64bbd954 --- /dev/null +++ b/src/state/queries/preferences/index.ts @@ -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, + }) + }, + }) +} diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts new file mode 100644 index 000000000..a26380a36 --- /dev/null +++ b/src/state/queries/preferences/moderation.ts @@ -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: {}, + }, + ], + } +} diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts new file mode 100644 index 000000000..9f4c30e53 --- /dev/null +++ b/src/state/queries/preferences/types.ts @@ -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 +} 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 +} |